|
|
|
@@ -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` |