| @@ -0,0 +1,596 @@ | |||
| # 登录 Widget 实施计划 | |||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |||
| **Goal:** 将现有前端登录表单封装为独立的 JS Widget,嵌入方通过 `<script>` 标签引入后渲染登录表单,调用现有 `/api/user/login` 接口完成认证。 | |||
| **Architecture:** 在 `web/widget/` 下创建独立的 Vite 构建,打包成 UMD 格式,全局暴露 `NewApiLoginWidget`。Widget 使用 Shadow DOM 隔离样式,内部用 React 渲染登录表单。React 和 ReactDOM 打包进 bundle,嵌入方无需任何依赖。零后端改动。 | |||
| **Tech Stack:** React 18, Vite (UMD output), Shadow DOM, fetch API | |||
| --- | |||
| ## File Structure | |||
| ``` | |||
| web/widget/ | |||
| package.json # 独立的 package.json(仅 React 依赖) | |||
| vite.config.js # UMD 打包配置,输出到 ../dist/static/ | |||
| index.jsx # 入口:init() / destroy() API,创建 Shadow DOM | |||
| LoginForm.jsx # 登录表单组件 | |||
| styles.css # Widget 样式 | |||
| ``` | |||
| --- | |||
| ### Task 1: 创建 Widget 项目骨架 | |||
| **Files:** | |||
| - Create: `web/widget/package.json` | |||
| - Create: `web/widget/vite.config.js` | |||
| - [ ] **Step 1: 创建 package.json** | |||
| `web/widget/package.json`: | |||
| ```json | |||
| { | |||
| "name": "new-api-login-widget", | |||
| "version": "1.0.0", | |||
| "private": true, | |||
| "type": "module", | |||
| "scripts": { | |||
| "dev": "vite", | |||
| "build": "vite build" | |||
| }, | |||
| "dependencies": { | |||
| "react": "^18.2.0", | |||
| "react-dom": "^18.2.0" | |||
| }, | |||
| "devDependencies": { | |||
| "@vitejs/plugin-react": "^4.2.1", | |||
| "vite": "^5.2.0" | |||
| } | |||
| } | |||
| ``` | |||
| - [ ] **Step 2: 创建 vite.config.js** | |||
| `web/widget/vite.config.js`: | |||
| ```js | |||
| import react from '@vitejs/plugin-react'; | |||
| import { defineConfig } from 'vite'; | |||
| import { resolve } from 'path'; | |||
| export default defineConfig({ | |||
| plugins: [react()], | |||
| build: { | |||
| lib: { | |||
| entry: resolve(__dirname, 'index.jsx'), | |||
| name: 'NewApiLoginWidget', | |||
| formats: ['umd'], | |||
| fileName: () => 'login-widget.js', | |||
| }, | |||
| outDir: resolve(__dirname, '../dist/static'), | |||
| emptyOutDir: false, | |||
| rollupOptions: { | |||
| external: [], | |||
| output: { | |||
| globals: {}, | |||
| }, | |||
| }, | |||
| }, | |||
| }); | |||
| ``` | |||
| 关键点: | |||
| - `name: 'NewApiLoginWidget'` — UMD 全局变量名 | |||
| - `outDir` 指向 `web/dist/static/`,产物为 `web/dist/static/login-widget.js` | |||
| - `emptyOutDir: false` — 不清空 dist 目录(那是主前端构建产物) | |||
| - React 和 ReactDOM 不做 external,打包进 bundle | |||
| - [ ] **Step 3: 安装依赖** | |||
| ```bash | |||
| cd web/widget && bun install | |||
| ``` | |||
| - [ ] **Step 4: 提交** | |||
| ```bash | |||
| git add web/widget/package.json web/widget/vite.config.js web/widget/bun.lock | |||
| git commit -m "feat(widget): 添加 Widget 项目骨架和 Vite 构建配置" | |||
| ``` | |||
| --- | |||
| ### Task 2: 创建 Widget 样式 | |||
| **Files:** | |||
| - Create: `web/widget/styles.css` | |||
| - [ ] **Step 1: 创建样式文件** | |||
| `web/widget/styles.css`: | |||
| ```css | |||
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); | |||
| :host { | |||
| --naw-primary: #4f46e5; | |||
| --naw-primary-hover: #4338ca; | |||
| --naw-bg: #ffffff; | |||
| --naw-text: #1f2937; | |||
| --naw-text-secondary: #6b7280; | |||
| --naw-border: #d1d5db; | |||
| --naw-input-bg: #f9fafb; | |||
| --naw-error: #ef4444; | |||
| --naw-radius: 8px; | |||
| --naw-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |||
| display: block; | |||
| font-family: var(--naw-font); | |||
| } | |||
| :host([theme="dark"]) { | |||
| --naw-primary: #818cf8; | |||
| --naw-primary-hover: #6366f1; | |||
| --naw-bg: #1f2937; | |||
| --naw-text: #f9fafb; | |||
| --naw-text-secondary: #9ca3af; | |||
| --naw-border: #374151; | |||
| --naw-input-bg: #374151; | |||
| --naw-error: #f87171; | |||
| } | |||
| .naw-container { | |||
| max-width: 400px; | |||
| margin: 0 auto; | |||
| padding: 32px 24px; | |||
| background: var(--naw-bg); | |||
| border-radius: 16px; | |||
| } | |||
| .naw-title { | |||
| text-align: center; | |||
| font-size: 24px; | |||
| font-weight: 600; | |||
| color: var(--naw-text); | |||
| margin: 0 0 32px 0; | |||
| } | |||
| .naw-form { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 16px; | |||
| } | |||
| .naw-field { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 6px; | |||
| } | |||
| .naw-label { | |||
| font-size: 14px; | |||
| font-weight: 500; | |||
| color: var(--naw-text); | |||
| } | |||
| .naw-input { | |||
| width: 100%; | |||
| box-sizing: border-box; | |||
| padding: 10px 14px; | |||
| font-size: 15px; | |||
| border: 1px solid var(--naw-border); | |||
| border-radius: var(--naw-radius); | |||
| background: var(--naw-input-bg); | |||
| color: var(--naw-text); | |||
| outline: none; | |||
| transition: border-color 0.2s; | |||
| font-family: var(--naw-font); | |||
| } | |||
| .naw-input:focus { | |||
| border-color: var(--naw-primary); | |||
| } | |||
| .naw-input::placeholder { | |||
| color: var(--naw-text-secondary); | |||
| } | |||
| .naw-button { | |||
| width: 100%; | |||
| padding: 12px; | |||
| font-size: 15px; | |||
| font-weight: 600; | |||
| color: #ffffff; | |||
| background: var(--naw-primary); | |||
| border: none; | |||
| border-radius: var(--naw-radius); | |||
| cursor: pointer; | |||
| transition: background 0.2s; | |||
| font-family: var(--naw-font); | |||
| } | |||
| .naw-button:hover { | |||
| background: var(--naw-primary-hover); | |||
| } | |||
| .naw-button:disabled { | |||
| opacity: 0.6; | |||
| cursor: not-allowed; | |||
| } | |||
| .naw-error-msg { | |||
| color: var(--naw-error); | |||
| font-size: 13px; | |||
| text-align: center; | |||
| margin: 0; | |||
| min-height: 20px; | |||
| } | |||
| ``` | |||
| 关键点: | |||
| - 使用 `:host` 和 CSS 变量实现主题切换(light/dark) | |||
| - 所有类名以 `naw-` 前缀避免冲突(Shadow DOM 内其实不需要,但保持好习惯) | |||
| - 不依赖任何外部 UI 库(Semi UI 等),纯原生 CSS | |||
| - [ ] **Step 2: 提交** | |||
| ```bash | |||
| git add web/widget/styles.css | |||
| git commit -m "feat(widget): 添加 Widget 样式(light/dark 主题)" | |||
| ``` | |||
| --- | |||
| ### Task 3: 创建 LoginForm 组件 | |||
| **Files:** | |||
| - Create: `web/widget/LoginForm.jsx` | |||
| - [ ] **Step 1: 创建登录表单组件** | |||
| `web/widget/LoginForm.jsx`: | |||
| ```jsx | |||
| import React, { useState } from 'react'; | |||
| export default function LoginForm({ apiUrl, onSuccess, onError }) { | |||
| const [username, setUsername] = useState(''); | |||
| const [password, setPassword] = useState(''); | |||
| const [loading, setLoading] = useState(false); | |||
| const [error, setError] = useState(''); | |||
| const handleSubmit = async (e) => { | |||
| e.preventDefault(); | |||
| setError(''); | |||
| if (!username.trim() || !password.trim()) { | |||
| const msg = '请输入用户名和密码'; | |||
| setError(msg); | |||
| onError?.({ message: msg }); | |||
| return; | |||
| } | |||
| setLoading(true); | |||
| try { | |||
| const res = await fetch(`${apiUrl}/api/user/login`, { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ username: username.trim(), password }), | |||
| credentials: 'include', | |||
| }); | |||
| const data = await res.json(); | |||
| if (data.success) { | |||
| if (data.data?.require_2fa) { | |||
| const msg = '此账户启用了两步验证,暂不支持通过 Widget 登录'; | |||
| setError(msg); | |||
| onError?.({ message: msg }); | |||
| return; | |||
| } | |||
| onSuccess?.(data.data); | |||
| } else { | |||
| const msg = data.message || '登录失败'; | |||
| setError(msg); | |||
| onError?.({ message: msg }); | |||
| } | |||
| } catch (err) { | |||
| const msg = '网络错误,请检查网络连接'; | |||
| setError(msg); | |||
| onError?.({ message: msg }); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| return ( | |||
| <form className="naw-form" onSubmit={handleSubmit}> | |||
| <div className="naw-field"> | |||
| <label className="naw-label">用户名或邮箱</label> | |||
| <input | |||
| className="naw-input" | |||
| type="text" | |||
| placeholder="请输入用户名或邮箱地址" | |||
| value={username} | |||
| onChange={(e) => setUsername(e.target.value)} | |||
| autoComplete="username" | |||
| disabled={loading} | |||
| /> | |||
| </div> | |||
| <div className="naw-field"> | |||
| <label className="naw-label">密码</label> | |||
| <input | |||
| className="naw-input" | |||
| type="password" | |||
| placeholder="请输入密码" | |||
| value={password} | |||
| onChange={(e) => setPassword(e.target.value)} | |||
| autoComplete="current-password" | |||
| disabled={loading} | |||
| /> | |||
| </div> | |||
| <p className="naw-error-msg">{error}</p> | |||
| <button className="naw-button" type="submit" disabled={loading}> | |||
| {loading ? '登录中...' : '登录'} | |||
| </button> | |||
| </form> | |||
| ); | |||
| } | |||
| ``` | |||
| 关键点: | |||
| - 使用原生 `fetch` 而非 `axios`,减少 bundle 体积 | |||
| - `credentials: 'include'` 让浏览器携带/接收 Cookie(跨域需 CORS 支持,已配置) | |||
| - 2FA 场景给出提示而非静默失败 | |||
| - 通过 props 接收 `apiUrl`、`onSuccess`、`onError` | |||
| - [ ] **Step 2: 提交** | |||
| ```bash | |||
| git add web/widget/LoginForm.jsx | |||
| git commit -m "feat(widget): 添加 LoginForm 组件" | |||
| ``` | |||
| --- | |||
| ### Task 4: 创建 Widget 入口(Shadow DOM + init/destroy API) | |||
| **Files:** | |||
| - Create: `web/widget/index.jsx` | |||
| - [ ] **Step 1: 创建入口文件** | |||
| `web/widget/index.jsx`: | |||
| ```jsx | |||
| import React from 'react'; | |||
| import { createRoot } from 'react-dom/client'; | |||
| import LoginForm from './LoginForm'; | |||
| import cssText from './styles.css?inline'; | |||
| function mount(container, props) { | |||
| const host = document.createElement('div'); | |||
| host.setAttribute('data-new-api-widget', 'login'); | |||
| container.appendChild(host); | |||
| const shadow = host.attachShadow({ mode: 'open' }); | |||
| // 注入样式到 Shadow DOM | |||
| const style = document.createElement('style'); | |||
| style.textContent = cssText; | |||
| shadow.appendChild(style); | |||
| // 应用主题 | |||
| if (props.theme === 'dark') { | |||
| host.setAttribute('theme', 'dark'); | |||
| } | |||
| // 创建 React 挂载点 | |||
| const mountPoint = document.createElement('div'); | |||
| shadow.appendChild(mountPoint); | |||
| const root = createRoot(mountPoint); | |||
| root.render( | |||
| <div className="naw-container"> | |||
| <h1 className="naw-title">登 录</h1> | |||
| <LoginForm | |||
| apiUrl={props.apiUrl} | |||
| onSuccess={props.onSuccess} | |||
| onError={props.onError} | |||
| /> | |||
| </div> | |||
| ); | |||
| return { host, root }; | |||
| } | |||
| export function init(options) { | |||
| const container = | |||
| typeof options.container === 'string' | |||
| ? document.querySelector(options.container) | |||
| : options.container; | |||
| if (!container) { | |||
| throw new Error('NewApiLoginWidget: container not found'); | |||
| } | |||
| if (!options.apiUrl) { | |||
| throw new Error('NewApiLoginWidget: apiUrl is required'); | |||
| } | |||
| const { host, root } = mount(container, { | |||
| apiUrl: options.apiUrl.replace(/\/+$/, ''), | |||
| onSuccess: options.onSuccess, | |||
| onError: options.onError, | |||
| theme: options.theme || 'light', | |||
| }); | |||
| return { | |||
| destroy() { | |||
| root.unmount(); | |||
| host.remove(); | |||
| }, | |||
| }; | |||
| } | |||
| // UMD 全局导出 | |||
| if (typeof window !== 'undefined') { | |||
| window.NewApiLoginWidget = { init }; | |||
| } | |||
| ``` | |||
| 关键点: | |||
| - Shadow DOM 隔离样式,`mode: 'open'` 允许嵌入方检查 | |||
| - `?inline` 后缀让 Vite 将 CSS 作为字符串导入,注入到 Shadow DOM | |||
| - `container` 支持 CSS 选择器字符串或 DOM 元素 | |||
| - `apiUrl` 末尾斜杠清理 | |||
| - `destroy()` 卸载 React root 并移除 DOM 元素 | |||
| - UMD 全局暴露 `window.NewApiLoginWidget` | |||
| - [ ] **Step 2: 提交** | |||
| ```bash | |||
| git add web/widget/index.jsx | |||
| git commit -m "feat(widget): 添加 Widget 入口(Shadow DOM + init/destroy API)" | |||
| ``` | |||
| --- | |||
| ### Task 5: 构建验证 + 集成到构建流程 | |||
| **Files:** | |||
| - Modify: `Makefile` | |||
| - [ ] **Step 1: 构建 Widget** | |||
| ```bash | |||
| cd web/widget && bun run build | |||
| ``` | |||
| 预期产物:`web/dist/static/login-widget.js`(约 150-200KB,含 React) | |||
| - [ ] **Step 2: 验证产物** | |||
| ```bash | |||
| ls -la web/dist/static/login-widget.js | |||
| head -5 web/dist/static/login-widget.js | |||
| ``` | |||
| 预期:文件存在,前几行包含 UMD 包装器和 `NewApiLoginWidget`。 | |||
| - [ ] **Step 3: 更新 Makefile,将 Widget 构建加入主构建流程** | |||
| `Makefile` 修改: | |||
| ```makefile | |||
| FRONTEND_DIR = ./web | |||
| BACKEND_DIR = . | |||
| .PHONY: all build-frontend build-widget start-backend docker-build docker-push | |||
| all: build-frontend build-widget start-backend | |||
| build-frontend: | |||
| @echo "Building frontend..." | |||
| @cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build | |||
| build-widget: | |||
| @echo "Building login widget..." | |||
| @cd $(FRONTEND_DIR)/widget && bun install && bun run build | |||
| start-backend: | |||
| @echo "Starting backend dev server..." | |||
| @cd $(BACKEND_DIR) && go run main.go & | |||
| ``` | |||
| - [ ] **Step 4: 提交** | |||
| ```bash | |||
| git add Makefile | |||
| git commit -m "feat(widget): 集成 Widget 构建到 Makefile" | |||
| ``` | |||
| --- | |||
| ### Task 6: 本地端到端验证 | |||
| **Files:** | |||
| - 无代码改动,纯验证 | |||
| - [ ] **Step 1: 启动本地 dev 服务器** | |||
| ```bash | |||
| cd D:/code/new-api && go run main.go | |||
| ``` | |||
| - [ ] **Step 2: 创建测试 HTML 页面** | |||
| 临时创建 `test-widget.html`: | |||
| ```html | |||
| <!DOCTYPE html> | |||
| <html> | |||
| <head><title>Widget 测试</title></head> | |||
| <body> | |||
| <h1>嵌入方页面测试</h1> | |||
| <div id="login-container"></div> | |||
| <script src="http://localhost:3000/static/login-widget.js"></script> | |||
| <script> | |||
| const widget = NewApiLoginWidget.init({ | |||
| container: '#login-container', | |||
| apiUrl: 'http://localhost:3000', | |||
| onSuccess: (data) => { | |||
| document.getElementById('result').textContent = JSON.stringify(data, null, 2); | |||
| widget.destroy(); | |||
| }, | |||
| onError: (err) => { | |||
| alert('错误: ' + err.message); | |||
| } | |||
| }); | |||
| </script> | |||
| <pre id="result"></pre> | |||
| </body> | |||
| </html> | |||
| ``` | |||
| - [ ] **Step 3: 浏览器打开测试页面,验证** | |||
| - 页面加载后应看到带用户名/密码输入框的登录表单 | |||
| - 输入错误密码应显示错误信息 | |||
| - 输入正确密码应触发 `onSuccess` 回调,显示用户数据 | |||
| - Shadow DOM 内样式应正常,不影响页面其他元素 | |||
| - [ ] **Step 4: 清理测试文件** | |||
| ```bash | |||
| rm test-widget.html | |||
| ``` | |||
| - [ ] **Step 5: 最终提交** | |||
| ```bash | |||
| git add web/widget/ web/dist/static/login-widget.js | |||
| git commit -m "feat(widget): 完成登录 Widget v1(密码登录,Shadow DOM 隔离)" | |||
| ``` | |||
| --- | |||
| ## Self-Review 检查 | |||
| ### 1. Spec 覆盖 | |||
| | Spec 要求 | 对应 Task | | |||
| |-----------|----------| | |||
| | UMD 打包,全局暴露 `NewApiLoginWidget` | Task 1 (vite.config.js), Task 4 (index.jsx) | | |||
| | `<script>` 标签引入 | Task 1 (UMD output), Task 5 (托管在 dist/static/) | | |||
| | `init({ container, apiUrl, onSuccess, onError, theme })` API | Task 4 | | |||
| | `destroy()` 方法 | Task 4 | | |||
| | Shadow DOM 样式隔离 | Task 4 | | |||
| | light/dark 主题 | Task 2 (CSS :host 变量), Task 4 (theme prop) | | |||
| | 调用现有 `POST /api/user/login` | Task 3 | | |||
| | 零后端改动 | 全局(无后端文件改动) | | |||
| | React 打包进 bundle | Task 1 (不 external React) | | |||
| | 构建流程集成 | Task 5 (Makefile) | | |||
| ### 2. Placeholder 扫描 | |||
| - 无 TBD、TODO、占位符 | |||
| ### 3. 类型一致性 | |||
| - `onSuccess` 回调参数统一为 `data.data`(API 响应中的 data 字段) | |||
| - `onError` 回调参数统一为 `{ message: string }` | |||
| - 所有文件中的 props 名称一致:`apiUrl`, `onSuccess`, `onError` | |||