You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

14 KiB

登录 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

web/widget/package.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"
  }
}

web/widget/vite.config.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

cd web/widget && bun install
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

web/widget/styles.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

git add web/widget/styles.css
git commit -m "feat(widget): 添加 Widget 样式(light/dark 主题)"

Task 3: 创建 LoginForm 组件

Files:

  • Create: web/widget/LoginForm.jsx

web/widget/LoginForm.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 接收 apiUrlonSuccessonError

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

web/widget/index.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

git add web/widget/index.jsx
git commit -m "feat(widget): 添加 Widget 入口(Shadow DOM + init/destroy API)"

Task 5: 构建验证 + 集成到构建流程

Files:

  • Modify: Makefile

cd web/widget && bun run build

预期产物:web/dist/static/login-widget.js(约 150-200KB,含 React)

ls -la web/dist/static/login-widget.js
head -5 web/dist/static/login-widget.js

预期:文件存在,前几行包含 UMD 包装器和 NewApiLoginWidget

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 &
git add Makefile
git commit -m "feat(widget): 集成 Widget 构建到 Makefile"

Task 6: 本地端到端验证

Files:

  • 无代码改动,纯验证

cd D:/code/new-api && go run main.go

临时创建 test-widget.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>
  • 页面加载后应看到带用户名/密码输入框的登录表单

  • 输入错误密码应显示错误信息

  • 输入正确密码应触发 onSuccess 回调,显示用户数据

  • Shadow DOM 内样式应正常,不影响页面其他元素

rm test-widget.html
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