# 用户头像下拉 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. **目标:** 把网关后台右上角的用户头像+用户名下拉组件,打包成一个独立的 JS 文件(Widget),外部系统只需要一个 ` ``` --- ## 二、文件结构 ### 新建文件 ``` mokee-gateway-web/ ├── src/widgets/user-chip/ ← 新建目录 │ ├── main.ts ← 入口:自动初始化,扫描 #mokee-user-chip 并挂载 Vue │ ├── UserChip.vue ← 核心组件(~120行,模板 + 逻辑 + 样式) │ ├── api.ts ← API 调用层(3个函数) │ └── cookie.ts ← Cookie 工具(从 request.ts/auth.ts 提取复用) ├── vite.widget.config.ts ← 新建:Widget 专用 Vite 构建配置 └── public/demo-widget.html ← 新建:接入演示页面 ``` ### 修改文件 | 文件 | 改动 | |------|------| | `package.json` | 新增 `build:widget` 脚本 | ### 文件职责划分 ``` ┌─ main.ts ──────────────────────────────────────┐ │ 职责:DOM 就绪后扫描 #mokee-user-chip, │ │ 读取 data-* 配置,创建并挂载 Vue 应用 │ │ 依赖:UserChip.vue │ │ 输出:立即执行,无 export │ └─────────────────────────────────────────────────┘ │ 创建 Vue app,传入 props ▼ ┌─ UserChip.vue ──────────────────────────────────┐ │ 职责:渲染头像 + 用户名 + 下拉菜单 │ │ 依赖:api.ts, cookie.ts, element-plus 组件 │ │ 状态:loading / userInfo / 错误静默降级 │ │ Props:apiBase, loginUrl, selectUrl │ │ Emits:无(通过 window.location.href 跳转) │ └─────────────────────────────────────────────────┘ │ 调用 ▼ ┌─ api.ts ────────────────────────────────────────┐ │ 职责:封装 3 个 API 调用 │ │ - fetchUserInfo(apiBase): UserInfo | null │ │ - logout(apiBase): void │ │ - refreshFileServiceUrl(apiBase, url): string │ │ 依赖:cookie.ts │ └─────────────────────────────────────────────────┘ │ 调用 ▼ ┌─ cookie.ts ─────────────────────────────────────┐ │ 职责:Token 的 Cookie 读写 │ │ - getToken(): string | null │ │ - removeToken(): void │ └─────────────────────────────────────────────────┘ ``` --- ## 三、API 依赖分析 ### 全部复用现有接口,后端零改动 | API | 方法 | 用途 | 调用时机 | |-----|------|------|----------| | `GET /zgapi/v1/auth/user/info` | GET | 获取当前用户信息 | 组件挂载时 | | `POST /zgapi/v1/auth/logout` | POST | 通知服务端销毁 session | 点"退出登录"时 | | `POST /zgapi/v1/open/token` | POST | 获取文件服务下载 Token | 用户头像 URL 指向文件服务时 | ### API 调用细节 #### 1. 获取用户信息 ``` 请求:GET {apiBase}/zgapi/v1/auth/user/info 请求头:Authorization: Bearer {token} (token 从 Cookie 读取) Content-Type: application/json 响应:{ "code": 200, "data": { "userId": "a1b2c3d4...", "username": "zhangsan", "realName": "张三", "email": "zhangsan@zgitm.com", "phone": "13800000000", "avatar": null | "https://file.wg.zgitm.com/api/download/xxx", "post": "工程师" } } ``` #### 2. 退出登录 ``` 请求:POST {apiBase}/zgapi/v1/auth/logout 请求头:Authorization: Bearer {token} ``` #### 3. 获取文件服务下载 Token(仅在 avatar URL 指向文件服务时调用) ``` 请求:POST {apiBase}/zgapi/v1/open/token 请求体:{ "systemCode": "gateway" } 响应:{ "code": 200, "data": { "token": "eyJ..." } } ``` 拼接:`{avatarBaseUrl}?token={新Token}` --- ## 四、配置与数据流 ### 4.1 外部系统可配置项 通过占位元素的 `data-*` 属性配置: ```html
data-login-url="https://web.gw.zgitm.com" data-select-url="https://web.gw.zgitm.com/select" >
``` | 属性 | 默认值 | 说明 | |------|--------|------| | `data-api-base` | `window.location.origin` | 网关 API 地址 | | `data-login-url` | `window.location.origin + '/login'` | 登录页完整 URL | | `data-select-url` | `window.location.origin + '/select'` | 切换系统页完整 URL | ### 4.2 运行时数据流 ``` 页面加载 │ ▼ widgets/user-chip/main.ts 立即执行(IIFE) │ ├─ 1. document.querySelectorAll('#mokee-user-chip') │ └─ 找到所有占位元素(支持同一页面多个实例) │ ├─ 2. 对每个占位元素: │ ├─ 读取 data-api-base / data-login-url / data-select-url │ ├─ 创建 Vue 3 app,以 UserChip 为根组件 │ ├─ 将配置作为 props 传入 │ └─ app.mount(container) ← 挂载到占位元素内部 │ ▼ UserChip.vue mounted() │ ├─ 3. 从 Cookie 读取 token(cookie.getToken()) │ ├─ 4. 调用 fetchUserInfo(apiBase) │ ├─ 成功 → userInfo.value = 响应数据 │ │ └─ 若 avatar URL 指向文件服务 → refreshFileServiceUrl() │ │ 获取新 Token 拼接完整下载 URL │ └─ 失败(401 / 网络异常)→ userInfo.value = null(显示"登录"按钮) │ └─ 5. 渲染: ├─ loading = true → 骨架屏 ├─ userInfo = null → "登录" 按钮 └─ userInfo 有值 → 头像 + 用户名 + ▼(可点开下拉) ``` ### 4.3 退出登录流程 ``` 用户点击"退出登录" │ ├─ 1. POST {apiBase}/zgapi/v1/auth/logout (fire-and-forget,失败不阻塞) │ ├─ 2. cookie.removeToken() │ └─ document.cookie = "token=;path=/;max-age=0;domain=.zgitm.com" │ └─ 3. window.location.href = loginUrl └─ 完整页面跳转,清空所有内存状态 ``` --- ## 五、样式隔离方案 ### 策略:BEM 命名空间 + 独立 CSS 变量 不使用 Shadow DOM(因为 Element Plus 的 `el-dropdown` 弹出层通过 Teleport 渲染到 `document.body`,Shadow DOM 会阻止弹出层正常工作)。 ``` 所有类名: .mokee-uc-{Block}__{Element}--{Modifier} 所有 CSS 变量: --mokee-uc-{name} ``` ### 根容器结构 ```html
← Vue 挂载点(替换 #mokee-user-chip 的内部)
← 用户条(头像 + 名字 + 箭头) ...
``` ### CSS 变量隔离 ```css .mokee-uc-root { /* 所有颜色变量自给自足,不依赖宿主的 CSS 变量 */ --mokee-uc-text-primary: #1f2937; --mokee-uc-text-secondary: #6b7280; --mokee-uc-text-muted: #9ca3af; --mokee-uc-bg-hover: #f3f4f6; --mokee-uc-border-color: #e5e7eb; --mokee-uc-color-primary: #6366f1; --mokee-uc-radius: 8px; --mokee-uc-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: var(--mokee-uc-font-family); font-size: 14px; line-height: 1.5; color: var(--mokee-uc-text-primary); } /* 所有内部元素使用 --mokee-uc-* 变量 */ .mokee-uc-chip { display: inline-flex; align-items: center; gap: 8px; padding: 5px 12px 5px 5px; border-radius: 24px; cursor: pointer; border: 1px solid transparent; transition: all 0.15s ease; user-select: none; } .mokee-uc-chip:hover { background: var(--mokee-uc-bg-hover); border-color: var(--mokee-uc-border-color); } ``` --- ## 六、构建配置 ### 6.1 vite.widget.config.ts ```typescript /** * Widget 构建配置 — 将 UserChip 组件打包为独立的 IIFE 文件 * * 产物: * - dist/widgets/user-chip.js (~200KB raw, ~65KB gzip) * - dist/widgets/user-chip.css (~4KB raw, ~1KB gzip) * * 策略: * - 全部依赖打入包内(vue, element-plus, axios),外部系统无需引入任何前置依赖 * - IIFE 格式,浏览器直接可用 * - Element Plus 按需导入 — 只打 el-avatar, el-dropdown, el-dropdown-menu, * el-dropdown-item, el-icon 这 5 个组件 * - CSS 单独提取,方便外部系统 引入 */ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, build: { // 输出到 dist/widgets/ outDir: 'dist/widgets', // Library Mode lib: { entry: path.resolve(__dirname, 'src/widgets/user-chip/main.ts'), name: 'MokeeUserChip', formats: ['iife'], fileName: () => 'user-chip.js', }, rollupOptions: { // 全部依赖打入包内,不做 external external: [], output: { // IIFE 模式下不需要 globals // CSS 文件命名 assetFileNames: (assetInfo) => { if (assetInfo.name?.endsWith('.css')) return 'user-chip.css' return assetInfo.name || '[name]-[hash][extname]' }, }, }, // 目标兼容大部分浏览器 target: 'es2015', cssTarget: 'chrome61', // 不生成 sourcemap(减少体积) sourcemap: false, }, }) ``` ### 6.2 package.json 新增脚本 ```json { "scripts": { "dev": "vite --port 5001", "build": "vue-tsc && vite build", "build:prod": "vite build", "build:widget": "vite build --config vite.widget.config.ts", "preview": "vite preview" } } ``` ### 6.3 构建命令 ```bash cd mokee-gateway-web npm run build:widget ``` ### 6.4 产物体积预估 | 产物 | 未压缩 | gzip (~level 6) | brotli | |------|--------|-----------------|--------| | user-chip.js | ~200 KB | ~65 KB | ~55 KB | | user-chip.css | ~4 KB | ~1 KB | ~0.8 KB | | **总计** | **~204 KB** | **~66 KB** | **~56 KB** | --- ## 七、完整代码 ### Task 1: cookie.ts — Cookie 工具 **文件:** `src/widgets/user-chip/cookie.ts` (新建) ```typescript /** * Cookie 工具 — Token 的 Cookie 读写 * * 与 mokee-gateway-web/src/utils/request.ts 和 src/stores/auth.ts 的 * Cookie 读写逻辑完全一致,保持 Token 机制统一。 * * Cookie 属性: * - path=/ : 全站可读 * - max-age=7200 : 2 小时,与 JWT Token 过期时间一致 * - SameSite=Lax : 允许同站点内跨子域携带(.zgitm.com) * - domain : 由外部配置传入或允许跨子域共享 */ const TOKEN_KEY = 'token' /** 从外部 data-* 属性或默认值获取 Cookie domain */ function readCookieDomain(): string { // 尝试从占位元素读取 const el = document.getElementById('mokee-user-chip') if (el) { const d = el.getAttribute('data-cookie-domain') if (d !== null) return d // 即使是空字符串也接受 } // 默认:不设置 domain(仅当前域名) return '' } function getDomainPart(): string { const domain = readCookieDomain() return domain ? `;domain=${domain}` : '' } /** 从 Cookie 读取 Token */ export function getToken(): string | null { const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${TOKEN_KEY}=([^;]*)`)) return match ? decodeURIComponent(match[1]) : null } /** 清除 Cookie 中的 Token */ export function removeToken(): void { const domainPart = getDomainPart() document.cookie = `${TOKEN_KEY}=;path=/;max-age=0${domainPart}` } ``` --- ### Task 2: api.ts — API 调用层 **文件:** `src/widgets/user-chip/api.ts` (新建) ```typescript /** * API 调用层 — 封装 Widget 需要的 3 个网关 API * * 设计原则: * - 直接用 fetch API,不依赖 axios(减少包体积 ~15KB) * - 不依赖 Pinia、request.ts 等网关后台专用的状态管理 * - 所有函数都是纯函数(apiBase 参数化),方便测试 * - 错误静默处理,返回 null 让调用方降级 */ import { getToken } from './cookie' /** 用户信息(与网关 /zgapi/v1/auth/user/info 响应一致) */ export interface UserInfo { userId: string username: string realName: string email: string phone: string avatar: string | null post: string } /** 文件服务地址(用于判断头像 URL 是否需要刷新 Token) */ const FILE_SERVICE_HOST = 'file.wg.zgitm.com' /** 判断 URL 是否为文件服务地址(需要动态 Token) */ function isFileServiceUrl(url: string): boolean { if (!url) return false return url.includes(FILE_SERVICE_HOST) } /** 从 URL 中去掉 ?token=xxx 参数,保留基础路径 */ function stripToken(url: string): string { return url.split('?')[0] } /** * 获取当前用户信息 * * 调用 GET {apiBase}/zgapi/v1/auth/user/info * 通过 Cookie 自动携带 Authorization Token * * @param apiBase - 网关 API 地址(如 https://gw.server.zgitm.com) * @returns 用户信息对象,失败返回 null */ export async function fetchUserInfo(apiBase: string): Promise { const token = getToken() if (!token) return null try { const res = await fetch(`${apiBase}/zgapi/v1/auth/user/info`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json;charset=UTF-8', }, credentials: 'include', // 跨域时携带 Cookie }) if (res.status === 401) { // Token 过期或无效,静默降级为未登录状态 return null } if (!res.ok) return null const body = await res.json() // 网关的响应结构:{ code: 200, data: {...} } if (body.code !== 200) return null return body.data as UserInfo } catch { // 网络异常,静默降级 return null } } /** * 退出登录 * * 调用 POST {apiBase}/zgapi/v1/auth/logout * fire-and-forget:失败不阻塞后续的 Cookie 清除和页面跳转 * * @param apiBase - 网关 API 地址 */ export async function requestLogout(apiBase: string): Promise { const token = getToken() if (!token) return try { await fetch(`${apiBase}/zgapi/v1/auth/logout`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json;charset=UTF-8', }, credentials: 'include', }) } catch { // fire-and-forget:即使失败也继续后续流程 } } /** * 为文件服务 URL 获取新的下载 Token * * 文件服务 Token 是一次一密的:数据库中只存基础 URL(不带 ?token=xxx), * 每次加载头像时需要重新获取下载 Token 并拼接完整 URL。 * * 调用 POST {apiBase}/zgapi/v1/open/token * * @param apiBase - 网关 API 地址 * @param url - 原始头像 URL(可能是带过期 Token 的 URL) * @returns 拼接了新 Token 的完整 URL,获取失败则返回原 URL */ export async function refreshFileServiceUrl(apiBase: string, url: string): Promise { if (!url || !isFileServiceUrl(url)) return url const token = getToken() if (!token) return url try { const baseUrl = stripToken(url) const res = await fetch(`${apiBase}/zgapi/v1/open/token`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json;charset=UTF-8', }, credentials: 'include', body: JSON.stringify({ systemCode: 'gateway' }), }) if (!res.ok) return url const body = await res.json() if (body.code !== 200 || !body.data?.token) return url return `${baseUrl}?token=${body.data.token}` } catch { return url } } ``` --- ### Task 3: UserChip.vue — 核心组件 **文件:** `src/widgets/user-chip/UserChip.vue` (新建) ```vue ``` --- ### Task 4: main.ts — 初始化入口 **文件:** `src/widgets/user-chip/main.ts` (新建) ```typescript /** * UserChip Widget — 自动初始化入口 * * 功能: * - DOM 就绪后自动扫描所有 #mokee-user-chip 占位元素 * - 读取 data-* 配置属性 * - 为每个占位元素创建独立的 Vue 3 应用实例并挂载 * * 使用方式(外部系统): * *
* * * selectUrl 自动从脚本 URL 推断,无需手动配置: * 脚本 src = https://web.gw.zgitm.com/widgets/user-chip.js → selectUrl = https://web.gw.zgitm.com/select * * 为什么不能用 window.location.origin? * - 外部系统的域名 ≠ 网关域名(如外部系统在 app1.zgitm.com,/select 在 web.gw.zgitm.com) * - window.location.origin 拿到的是外部系统域名,拼接 /select 是错的 * - 正确做法:从 Widget 脚本自身的 ``` --- ## 八、测试要点 ### 8.1 本地开发调试 ```bash # 1. 构建 Widget cd mokee-gateway-web npm run build:widget # 2. 启动 Vite 开发服务器(Widget 产物在 dist/widgets/,dev server 默认不提供) # 方案:直接用 nginx 或 vite preview 提供 dist/widgets/ 静态文件 # 3. 或者用 http-server 快速验证 npx http-server dist/widgets -p 8080 --cors # 然后访问 http://127.0.0.1:8080/demo-widget.html ``` ### 8.2 功能测试 | 测试场景 | 前置条件 | 预期结果 | |----------|----------|----------| | 首次加载(无 Token) | Cookie 中无 token | 显示"🔑 登录"按钮 | | 点击"登录"按钮 | 无 Token | 跳转到 `data-login-url` 指定的地址 | | 有 Token,API 正常 | Cookie 中有有效 token | 显示头像 + 用户名 + ▼ | | 点击用户名区域 | 已登录 | 弹出下拉菜单(切换系统、退出登录) | | 点击"切换系统" | 已登录 | `window.location.href = data-select-url` | | 点击"退出登录" | 已登录 | POST logout → 清除 Cookie → 跳转登录页 | | Token 过期(401) | Cookie 中有过期 token | 静默降级,显示"登录"按钮 | | 网络异常 | 断开网络 | 静默降级,显示"登录"按钮(不报错) | | 用户无头像 | 已登录,avatar = null | 显示用户名首字符文字头像 | | 用户头像来自文件服务 | 已登录,avatar 指向 file.wg.zgitm.com | 自动刷新 Token 后正确显示 | | 页面无 #mokee-user-chip | — | 不做任何事(静默退出) | | 同时存在多个占位元素 | 多个 `#mokee-user-chip` | 每个独立渲染,互不影响 | ### 8.3 兼容性测试 | 测试项 | 验证方式 | |--------|----------| | Chrome/Edge 最新版 | 手动打开 demo 页验证 | | Firefox 最新版 | 手动验证 | | Safari 最新版 | 手动验证 | | 移动端 Chrome/Safari | 开发者工具模拟 + 真机 | | 宿主页已有 Element Plus | 检查样式是否污染(`.mokee-uc-*` 命名空间隔离) | | 宿主页无 Element Plus | Widget 自带全部依赖,正常渲染 | --- ## 九、部署方案 ### 9.1 Nginx 静态文件服务 ```nginx # 网关 Nginx 配置新增 location /widgets/ { alias /opt/mokee-gateway-web/dist/widgets/; expires 30d; add_header Cache-Control "public, immutable"; # 允许跨域引用(外部系统从其他域名引入时) add_header Access-Control-Allow-Origin *; # 正确的 MIME 类型 types { application/javascript js; text/css css; } } ``` ### 9.2 部署后 URL ``` https://web.gw.zgitm.com/widgets/user-chip.js https://web.gw.zgitm.com/widgets/user-chip.css ``` ### 9.3 CI/CD 集成(可选,后续优化) ```bash # 在现有构建流程中增加 npm run build:widget # 产物 dist/widgets/ 随网关前端一起部署 ``` --- ## 十、潜在风险与缓解 | 风险 | 影响 | 缓解措施 | |------|------|----------| | **Element Plus 弹出层 Teleport** | `el-dropdown` 弹出内容 Teleport 到 `document.body`,若宿主编译了 Element Plus,样式可能冲突 | `.mokee-uc-root` 内不使用 Element Plus 全局 CSS 变量;弹出菜单本身的样式由 Element Plus 自带,与宿主 Element Plus 共存时版本差异可能导致细微差异 — 这是可接受的风险(Vue 3 支持多实例) | | **Vue 多实例** | 同一页面可能同时存在宿主 Vue 应用和 Widget 的 Vue 实例 | Vue 3 支持多个独立应用实例,无冲突 | | **包体积 66KB gzip** | 首次加载较慢 | 1. 后续可改为 CDN 外部依赖;2. 接入方可用 `` 提前加载 | | **Cookie domain 不一致** | 网关写 Cookie 时指定了 `.zgitm.com`,外部系统如果在完全不同域名下,读不到 Cookie | 同域名/子域名下完美工作;跨域场景需额外配置(增加 `data-cookie-domain` 支持 + Cookie `SameSite=None; Secure`) | | **文件服务 Token 刷新** | 每次页面加载都要额外调用 `/zgapi/v1/open/token`,增加延迟 | 只在 avatar URL 指向文件服务时才调用(绝大多数用户不上传头像,不会触发) | --- ## 十一、与接入文档的关系 接入文档(`IntegrationDoc.vue`)的 §3 "前端接入" 章节已经教外部系统如何: - 从 Cookie 读取 Token - 调用 `/zgapi/v1/auth/user/info` 获取用户信息 - 实现路由守卫和 401 跳转登录 **UserChip Widget 是对 §3 的组件化封装**,把以上步骤自动化成一个 `