# 用户头像下拉 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
登录
{{ userInitial }}
{{ displayName }}
切换系统
退出登录
```
---
### 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 脚本自身的