Просмотр исходного кода

docs: 添加登录 Widget 实施计划

6 个 Task:项目骨架 → 样式 → LoginForm → 入口 → 构建集成 → E2E 验证
master
fengsilin 3 недель назад
Родитель
Сommit
3c5b821584
1 измененных файлов: 596 добавлений и 0 удалений
  1. +596
    -0
      docs/superpowers/plans/2026-05-20-login-widget.md

+ 596
- 0
docs/superpowers/plans/2026-05-20-login-widget.md Просмотреть файл

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

Загрузка…
Отмена
Сохранить