web-client.html 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>API Lab · 浏览器调用实验</title>
  7. <style>
  8. :root { font-family: system-ui, sans-serif; color: #182c39; background: #f0f3f5; }
  9. body { max-width: 900px; margin: 40px auto; padding: 0 20px; }
  10. h1 { font-size: 30px; } h2 { font-size: 20px; margin-top: 0; }
  11. p { line-height: 1.7; } code { color: #23526c; }
  12. section { background: white; padding: 24px; border: 1px solid #d5dee3; border-radius: 12px; margin: 20px 0; }
  13. label { display: block; margin: 14px 0 8px; }
  14. input { width: min(95%, 500px); padding: 11px; border: 1px solid #a8b8c1; border-radius: 6px; font: inherit; }
  15. button { padding: 10px 16px; margin: 12px 8px 0 0; background: #194e6e; color: white; border: 0; border-radius: 6px; cursor: pointer; font: inherit; }
  16. button:disabled { opacity: .45; cursor: default; }
  17. button.secondary { background: #e5ecef; color: #182c39; }
  18. pre { background: #edf2f5; padding: 16px; min-height: 48px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
  19. .hint { font-size: 14px; color: #566b78; }
  20. :focus-visible { outline: 3px solid #df9a23; outline-offset: 3px; }
  21. </style>
  22. </head>
  23. <body>
  24. <p class="hint">API 服务开发 / 配套实验</p>
  25. <h1>让浏览器调用你的 FastAPI</h1>
  26. <p>先启动 <code>http://127.0.0.1:8000</code> 的 API,再通过 <code>http://localhost:5500/web-client.html</code> 打开此页。在开发者工具的 Network 面板中观察请求。</p>
  27. <section>
  28. <h2>01 · POST /products</h2>
  29. <p>浏览器发送 JSON 请求体,服务创建商品并返回 <code>201</code>。</p>
  30. <label for="product-name">商品名称</label>
  31. <input id="product-name" value="陶瓷杯" maxlength="120">
  32. <label for="product-price">价格(分,1990 表示 19.90 元)</label>
  33. <input id="product-price" type="number" value="1990" min="0" max="2147483647" step="1">
  34. <p class="hint">新商品默认未上架;0 分只用于免费教学样品。</p>
  35. <div><button id="create-product" type="button">创建商品</button></div>
  36. <pre id="product-output" aria-live="polite">等待请求</pre>
  37. </section>
  38. <section>
  39. <h2>02 · GET /stream · SSE</h2>
  40. <p>模拟商品清单导出进度:服务连续发送 <code>token</code> 事件,最后发送 <code>done</code>。本实验只演示进度传输,不生成文件。</p>
  41. <label for="prompt">导出清单名称</label>
  42. <input id="prompt" value="上架商品清单" maxlength="200">
  43. <div><button id="start-stream" type="button">开始接收</button><button id="stop-stream" class="secondary" type="button" disabled>停止</button></div>
  44. <p id="stream-status" class="hint" role="status">未连接</p>
  45. <pre id="stream-output" aria-live="polite">收到的文本会显示在这里</pre>
  46. </section>
  47. <section>
  48. <h2>03 · /ws · WebSocket</h2>
  49. <p>用商品咨询演示双向传输,服务仅回显问题。这里的文本 <code>ping</code> 是应用约定,不是协议控制帧。</p>
  50. <div><button id="connect-ws" type="button">连接</button><button id="close-ws" class="secondary" type="button" disabled>断开</button></div>
  51. <label for="ws-message">消息</label>
  52. <input id="ws-message" value="这款陶瓷杯有多大?">
  53. <div><button id="send-ws" type="button" disabled>发送消息</button><button id="ping-ws" class="secondary" type="button" disabled>发送 ping</button></div>
  54. <p id="ws-status" class="hint" role="status">未连接</p>
  55. <pre id="ws-output" aria-live="polite">服务端消息会显示在这里</pre>
  56. </section>
  57. <p class="hint">本页只在本机练习。CORS 允许来源为 localhost:5500 和 127.0.0.1:5500;请勿双击 HTML 使用 file:// 打开。</p>
  58. <script>
  59. const API = 'http://127.0.0.1:8000';
  60. const $ = (id) => document.getElementById(id);
  61. let stream = null;
  62. let socket = null;
  63. $('create-product').onclick = async () => {
  64. $('create-product').disabled = true;
  65. $('product-output').textContent = '发送中…';
  66. try {
  67. const priceText = $('product-price').value.trim();
  68. const priceCents = Number(priceText);
  69. if (!priceText || !Number.isInteger(priceCents) || priceCents < 0 || priceCents > 2147483647) {
  70. throw new Error('价格需为 0~2147483647 之间的整数分');
  71. }
  72. const response = await fetch(`${API}/products`, {
  73. method: 'POST',
  74. headers: {'Content-Type': 'application/json'},
  75. body: JSON.stringify({name: $('product-name').value, description: '', is_active: false, price_cents: priceCents}),
  76. });
  77. const data = await response.json();
  78. $('product-output').textContent = `HTTP ${response.status}\n${JSON.stringify(data, null, 2)}`;
  79. } catch (error) {
  80. $('product-output').textContent = `请求失败:${error.message}\n检查服务是否启动、页面是否来自 localhost:5500。`;
  81. } finally {
  82. $('create-product').disabled = false;
  83. }
  84. };
  85. function stopStream(message = '已停止') {
  86. if (stream) stream.close();
  87. stream = null;
  88. $('stream-status').textContent = message;
  89. $('stop-stream').disabled = true;
  90. }
  91. $('start-stream').onclick = () => {
  92. stopStream(); // 重复开始时先关闭上一条连接。
  93. const prompt = $('prompt').value.trim();
  94. if (!prompt) {
  95. $('stream-status').textContent = '请先输入文本';
  96. return;
  97. }
  98. $('stream-output').textContent = '';
  99. $('stream-status').textContent = '连接中…';
  100. $('stop-stream').disabled = false;
  101. const current = new EventSource(`${API}/stream?${new URLSearchParams({prompt})}`);
  102. stream = current;
  103. current.onopen = () => {
  104. if (stream === current) $('stream-status').textContent = '正在接收';
  105. };
  106. current.addEventListener('token', (event) => {
  107. if (stream !== current) return;
  108. try {
  109. $('stream-output').textContent += JSON.parse(event.data).text;
  110. } catch {
  111. stopStream('数据格式错误,连接已关闭');
  112. }
  113. });
  114. current.addEventListener('done', () => {
  115. if (stream === current) stopStream('已完成,连接已关闭');
  116. });
  117. current.onerror = () => {
  118. if (stream === current) stopStream('连接失败或中断,请检查服务后重新开始');
  119. };
  120. };
  121. $('stop-stream').onclick = () => stopStream();
  122. function setSocketControls(connected) {
  123. $('send-ws').disabled = !connected;
  124. $('ping-ws').disabled = !connected;
  125. $('close-ws').disabled = !socket;
  126. $('connect-ws').disabled = Boolean(socket);
  127. }
  128. function closeSocket() {
  129. const previous = socket;
  130. socket = null;
  131. if (previous) previous.close(1000, 'Client closed');
  132. $('ws-status').textContent = '已断开';
  133. setSocketControls(false);
  134. }
  135. $('connect-ws').onclick = () => {
  136. closeSocket();
  137. const current = new WebSocket(`${API.replace('http:', 'ws:')}/ws`);
  138. socket = current;
  139. $('ws-output').textContent = '';
  140. $('ws-status').textContent = '连接中…';
  141. setSocketControls(false);
  142. current.onopen = () => {
  143. if (socket !== current) return;
  144. $('ws-status').textContent = '已连接';
  145. setSocketControls(true);
  146. };
  147. current.onmessage = (event) => {
  148. if (socket === current) $('ws-output').textContent += `${event.data}\n`;
  149. };
  150. current.onerror = () => {
  151. if (socket === current) $('ws-status').textContent = '连接错误,检查服务是否启动';
  152. };
  153. current.onclose = (event) => {
  154. if (socket !== current) return;
  155. socket = null;
  156. $('ws-status').textContent = `连接已关闭(code ${event.code})`;
  157. setSocketControls(false);
  158. };
  159. };
  160. function sendMessage(text) {
  161. if (socket?.readyState === WebSocket.OPEN) socket.send(text);
  162. }
  163. $('send-ws').onclick = () => sendMessage($('ws-message').value);
  164. $('ping-ws').onclick = () => sendMessage('ping');
  165. $('close-ws').onclick = closeSocket;
  166. window.addEventListener('beforeunload', () => {
  167. stopStream();
  168. closeSocket();
  169. });
  170. </script>
  171. </body>
  172. </html>