Files
mokeegateway/docs/superpowers/plans/2026-06-13-user-chip-widget.md
2026-07-15 16:13:27 +08:00

1294 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 用户头像下拉 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外部系统只需要一个 `<script>` 标签引用,就能在自己的页面渲染出完整的用户身份组件。
**架构:** Vue 3 + Element Plus 按需导入 → Vite Library Mode 打包为 IIFE 单文件 → 部署到网关 Nginx 静态目录 → 外部系统通过 `<script src="...">` 引入。组件内部直接 fetch API不依赖 Pinia通过 Cookie 自动携带 Token。
**技术栈:** Vue 3.4, Element Plus 2.8, Vite 5.4 (Library Mode), TypeScript 5
---
## 一、需求回顾
### 组件功能
```
┌──────────────────────────────┐
│ 加载中: │
│ ◌ 加载中... │
├──────────────────────────────┤
│ 未登录: │
│ ┌────────┐ │
│ │ 🔑 登录 │ → 跳 /login │
│ └────────┘ │
├──────────────────────────────┤
│ 已登录: │
│ ┌────┐ │
│ │头像│ 张三 ▼ │
│ └────┘ │
│ ┌──────────────┐ │
│ │ 🔄 切换系统 │ → /select │
│ │ 🚪 退出登录 │ → 清Token │
│ │ │ → /login │
│ └──────────────┘ │
└──────────────────────────────┘
```
### 菜单项(仅两项)
| 菜单项 | 行为 |
|--------|------|
| **切换系统** | `window.location.href = '{loginUrl}/select'` |
| **退出登录** | `POST /zgapi/v1/auth/logout` → 清空 Cookie token → `window.location.href = '{loginUrl}/login'` |
### 外部系统使用方式
```html
<!-- 一行 CSS -->
<link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />
<!-- 一个占位元素 -->
<div id="mokee-user-chip"></div>
<!-- 一行 JS -->
<script src="https://web.gw.zgitm.com/widgets/user-chip.js"></script>
```
---
## 二、文件结构
### 新建文件
```
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 / 错误静默降级 │
│ PropsapiBase, 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
<div
id="mokee-user-chip"
data-api-base="https://gw.server.zgitm.com" <!-- 网关 API 地址 -->
data-login-url="https://web.gw.zgitm.com" <!-- 统一登录页地址(可不配,自动从脚本 URL 推断) -->
data-select-url="https://web.gw.zgitm.com/select" <!-- 切换系统页地址(可不配,自动推断) -->
></div>
```
| 属性 | 默认值 | 说明 |
|------|--------|------|
| `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 读取 tokencookie.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
<div class="mokee-uc-root"> ← Vue 挂载点(替换 #mokee-user-chip 的内部)
<div class="mokee-uc-chip"> ← 用户条(头像 + 名字 + 箭头)
...
</div>
</div>
```
### 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 单独提取,方便外部系统 <link> 引入
*/
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<UserInfo | null> {
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<void> {
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<string> {
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
<!--
用户头像下拉 Widget 核心组件
功能
- 加载中 显示骨架屏
- 未登录 Token API 返回失败 显示"登录"按钮点击跳转登录页
- 已登录 显示头像 + 用户名 + 下拉箭头下拉菜单包含切换系统和退出登录
Props 接收外部配置data-* 属性通过 main.ts 传入
外部依赖
- element-plus: ElAvatar, ElDropdown, ElDropdownMenu, ElDropdownItem, ElIcon
- @element-plus/icons-vue: User, Switch, SwitchButton, ArrowDown
- ./api: fetchUserInfo, requestLogout, refreshFileServiceUrl
- ./cookie: removeToken
-->
<template>
<div class="mokee-uc-root">
<!-- ===== 加载中骨架屏 ===== -->
<div v-if="loading" class="mokee-uc-chip mokee-uc-chip--loading">
<div class="mokee-uc-skeleton-avatar" />
<div class="mokee-uc-skeleton-name" />
</div>
<!-- ===== 未登录登录按钮 ===== -->
<div v-else-if="!userInfo" class="mokee-uc-chip mokee-uc-chip--guest" @click="goLogin">
<el-icon :size="16"><User /></el-icon>
<span class="mokee-uc-name">登录</span>
</div>
<!-- ===== 已登录头像 + 用户名 + 下拉 ===== -->
<el-dropdown v-else trigger="hover" placement="bottom-end">
<div class="mokee-uc-chip mokee-uc-chip--logged">
<el-avatar :size="28" :src="userInfo.avatar" class="mokee-uc-avatar">
{{ userInitial }}
</el-avatar>
<span class="mokee-uc-name">{{ displayName }}</span>
<el-icon :size="12" class="mokee-uc-chevron"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="goSelectSystem">
<el-icon><Switch /></el-icon>切换系统
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElAvatar, ElDropdown, ElDropdownMenu, ElDropdownItem, ElIcon } from 'element-plus'
import { User, Switch, SwitchButton, ArrowDown } from '@element-plus/icons-vue'
import { fetchUserInfo, requestLogout, refreshFileServiceUrl } from './api'
import { removeToken } from './cookie'
import type { UserInfo } from './api'
// ===== Props从 main.ts 传入(读取自 data-* 属性) =====
const props = defineProps({
/** 网关 API 地址(默认当前页面域名) */
apiBase: {
type: String,
default: () => window.location.origin,
},
/** 登录页完整 URL */
loginUrl: {
type: String,
default: () => window.location.origin + '/login',
},
/** 切换系统页完整 URL */
selectUrl: {
type: String,
default: () => window.location.origin + '/select',
},
})
// ===== 状态 =====
const loading = ref<boolean>(true)
const userInfo = ref<UserInfo | null>(null)
// ===== 计算属性 =====
/** 用户名首字符(用于文字头像 fallback */
const userInitial = computed<string>(() => {
if (!userInfo.value) return 'U'
return (userInfo.value.realName || userInfo.value.username || 'U').charAt(0)
})
/** 显示名称(优先 realName其次 username */
const displayName = computed<string>(() => {
if (!userInfo.value) return ''
return userInfo.value.realName || userInfo.value.username || ''
})
// ===== 生命周期 =====
onMounted(async () => {
loading.value = true
const data = await fetchUserInfo(props.apiBase)
if (data) {
// 若头像 URL 指向文件服务,刷新下载 Token
if (data.avatar) {
data.avatar = await refreshFileServiceUrl(props.apiBase, data.avatar)
}
userInfo.value = data
} else {
userInfo.value = null
}
loading.value = false
})
// ===== 交互方法 =====
/** 跳转登录页 */
function goLogin(): void {
window.location.href = props.loginUrl
}
/** 跳转切换系统页 */
function goSelectSystem(): void {
window.location.href = props.selectUrl
}
/** 退出登录 */
async function handleLogout(): Promise<void> {
// 1. 通知服务端销毁 sessionfire-and-forget
await requestLogout(props.apiBase)
// 2. 清除 Cookie 中的 Token
removeToken()
// 3. 完整跳转登录页(清空所有内存状态)
window.location.href = props.loginUrl
}
</script>
<style scoped>
/* ================================================================
UserChip 组件样式
命名空间:.mokee-uc-{Block}__{Element}--{Modifier}
CSS 变量:--mokee-uc-{name}
策略:自给自足,不依赖宿主页面的任何 CSS 变量或类名
================================================================ */
/* ===== CSS 变量 ===== */
.mokee-uc-root {
--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-sm: 4px;
--mokee-uc-radius-md: 8px;
--mokee-uc-radius-full: 24px;
--mokee-uc-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
--mokee-uc-transition: 0.15s ease;
display: inline-flex;
align-items: center;
font-family: var(--mokee-uc-font-family);
font-size: 14px;
line-height: 1.5;
color: var(--mokee-uc-text-primary);
-webkit-font-smoothing: antialiased;
}
/* ===== 用户条(公共) ===== */
.mokee-uc-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 12px 5px 5px;
border-radius: var(--mokee-uc-radius-full);
cursor: pointer;
border: 1px solid transparent;
transition: all var(--mokee-uc-transition);
user-select: none;
white-space: nowrap;
}
.mokee-uc-chip:hover {
background: var(--mokee-uc-bg-hover);
border-color: var(--mokee-uc-border-color);
}
/* ===== 头像 ===== */
.mokee-uc-avatar {
flex-shrink: 0;
}
/* ===== 用户名 ===== */
.mokee-uc-name {
font-size: 14px;
font-weight: 500;
color: var(--mokee-uc-text-secondary);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== 下拉箭头 ===== */
.mokee-uc-chevron {
color: var(--mokee-uc-text-muted);
flex-shrink: 0;
transition: transform var(--mokee-uc-transition);
}
/* ===== 状态:加载中 ===== */
.mokee-uc-chip--loading {
cursor: default;
gap: 10px;
padding: 5px 12px;
}
.mokee-uc-chip--loading:hover {
background: transparent;
border-color: transparent;
}
.mokee-uc-skeleton-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--mokee-uc-border-color);
animation: mokee-uc-pulse 1.5s ease-in-out infinite;
}
.mokee-uc-skeleton-name {
width: 50px;
height: 14px;
border-radius: var(--mokee-uc-radius-sm);
background: var(--mokee-uc-border-color);
animation: mokee-uc-pulse 1.5s ease-in-out infinite;
}
@keyframes mokee-uc-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ===== 状态:未登录 ===== */
.mokee-uc-chip--guest {
padding: 5px 14px;
color: var(--mokee-uc-color-primary);
font-weight: 500;
}
</style>
```
---
### Task 4: main.ts — 初始化入口
**文件:** `src/widgets/user-chip/main.ts` (新建)
```typescript
/**
* UserChip Widget — 自动初始化入口
*
* 功能:
* - DOM 就绪后自动扫描所有 #mokee-user-chip 占位元素
* - 读取 data-* 配置属性
* - 为每个占位元素创建独立的 Vue 3 应用实例并挂载
*
* 使用方式(外部系统):
* <link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />
* <div id="mokee-user-chip"
* data-api-base="https://gw.server.zgitm.com"
* data-login-url="https://web.gw.zgitm.com/login"></div>
* <script src="https://web.gw.zgitm.com/widgets/user-chip.js"></script>
*
* 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 脚本自身的 <script src="..."> URL 中提取 origin
*
* 打包方式 (IIFE)
* vite build --config vite.widget.config.ts
* 产物dist/widgets/user-chip.js (浏览器可直接执行)
*
* 注意:此文件无任何 exportIIFE 打包后直接执行
*/
import { createApp } from 'vue'
import UserChip from './UserChip.vue'
/**
* 从当前 script 标签的 src 推断网关 Web 地址
*
* 原理Widget 脚本部署在网关的 Web 域名下(如 web.gw.zgitm.com
* 脚本执行时读取自身 <script src="..."> 的 URL提取 origin。
* 这样无论开发环境还是生产环境selectUrl 都自动指向正确的域名。
*
* @returns 脚本所在域名的 origin解析失败返回 window.location.origin 作为降级
*/
function getScriptOrigin(): string {
try {
const scripts = document.querySelectorAll('script[src*="user-chip.js"]')
const src = scripts[scripts.length - 1]?.getAttribute('src') || ''
if (src) return new URL(src).origin
} catch { /* 解析失败走降级 */ }
return window.location.origin
}
/**
* 读取占位元素的 data-* 配置,返回 props 对象
*
* 关键规则:
* - selectUrl 默认值从脚本自身 URL 提取(/select 和 Widget JS 在同一网关 Web 域名下)
* - loginUrl 和 apiBase 是独立域名,需手动配置或从脚本 origin 推导
*/
function readConfig(el: Element) {
const scriptOrigin = getScriptOrigin()
return {
// API 网关地址:优先 data-api-base其次脚本域名外部系统需显式配置
apiBase: el.getAttribute('data-api-base') || scriptOrigin,
// 统一登录页:优先 data-login-url其次脚本域名 + /login
loginUrl: el.getAttribute('data-login-url') || (scriptOrigin + '/login'),
// 切换系统页:优先 data-select-url其次脚本域名 + /select
// 关键scriptOrigin 来自 Widget JS 自身的 URL不是外部系统的域名
selectUrl: el.getAttribute('data-select-url') || (scriptOrigin + '/select'),
}
}
/**
* 自动初始化:扫描页面上所有 #mokee-user-chip 元素,挂载 Vue 应用
*/
function autoInit(): void {
const containers = document.querySelectorAll('#mokee-user-chip')
if (containers.length === 0) {
// 没有占位元素 → 不做任何事(不报错、不渲染、静默退出)
return
}
containers.forEach((container) => {
const props = readConfig(container)
const app = createApp(UserChip, props)
app.mount(container)
})
}
/**
* DOM 加载完成后自动初始化
*
* 使用 DOMContentLoaded 确保占位元素已在 DOM 中
* 但如果脚本放在页面底部(</body> 前DOM 可能已经就绪,
* 此时 document.readyState === 'interactive' 或 'complete'
* DOMContentLoaded 不会再触发,所以需要检查 readyState
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit)
} else {
autoInit()
}
```
---
### Task 5: vite.widget.config.ts — 构建配置
**文件:** `vite.widget.config.ts` (项目根目录,新建)
```typescript
/**
* Vite Library Mode 构建配置 — 用户头像下拉 Widget
*
* 产物:
* - dist/widgets/user-chip.js IIFE 格式,包含 Vue + Element Plus 组件 + 业务代码
* - dist/widgets/user-chip.css 提取的 CSS
*
* 使用npm run build:widget
*/
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: {
outDir: 'dist/widgets',
lib: {
entry: path.resolve(__dirname, 'src/widgets/user-chip/main.ts'),
name: 'MokeeUserChip',
formats: ['iife'],
fileName: () => 'user-chip.js',
},
rollupOptions: {
// 全部依赖打入包内:外部系统不需要前置依赖
external: [],
output: {
// 所有资源文件统一命名
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) return 'user-chip.css'
return assetInfo.name || '[name]-[hash][extname]'
},
},
},
target: 'es2015',
cssTarget: 'chrome61',
sourcemap: false,
},
})
```
---
### Task 6: package.json — 新增构建脚本
**文件:** `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"
}
}
```
> 仅新增 `"build:widget": "vite build --config vite.widget.config.ts"` 一行。
---
### Task 7: demo-widget.html — 接入演示页面
**文件:** `public/demo-widget.html` (新建)
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mokee UserChip Widget — 接入演示</title>
<!-- 引入 Widget 样式 -->
<link rel="stylesheet" href="/widgets/user-chip.css" />
<style>
/* 演示页面样式(不属于 Widget */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
}
.demo-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.demo-header-left {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.demo-header-right {
display: flex;
align-items: center;
}
.demo-body {
max-width: 800px;
margin: 40px auto;
padding: 32px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.demo-body h2 {
margin-bottom: 12px;
color: #1f2937;
}
.demo-body p {
color: #6b7280;
line-height: 1.8;
}
.demo-code {
margin-top: 16px;
padding: 16px;
background: #1e293b;
color: #e2e8f0;
border-radius: 8px;
font-family: 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
}
</style>
</head>
<body>
<!-- 模拟一个外部系统的顶部导航栏 -->
<header class="demo-header">
<div class="demo-header-left">某业务系统</div>
<div class="demo-header-right">
<!-- ===== Mokee UserChip Widget 占位元素 ===== -->
<!-- 配置项通过 data-* 属性传入 -->
<div
id="mokee-user-chip"
data-api-base="http://127.0.0.1:9000"
data-login-url="http://127.0.0.1:5001/login"
data-select-url="http://127.0.0.1:5001/select"
></div>
</div>
</header>
<!-- 页面主体内容 -->
<div class="demo-body">
<h2>欢迎使用某业务系统</h2>
<p>
右上角的用户头像下拉组件由 Mokee Gateway 提供。
只需在页面中引入一个 CSS 文件、一个 JS 文件,放置一个占位 div 即可。
</p>
<div class="demo-code">
&lt;!-- 1. 引入样式 --&gt;<br/>
&lt;link rel="stylesheet" href="/widgets/user-chip.css" /&gt;<br/>
<br/>
&lt;!-- 2. 占位元素(可配置网关地址、登录页、切换系统页) --&gt;<br/>
&lt;div id="mokee-user-chip"<br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;data-api-base="http://127.0.0.1:9000"<br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;data-login-url="http://127.0.0.1:5001/login"<br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;data-select-url="http://127.0.0.1:5001/select"&gt;&lt;/div&gt;<br/>
<br/>
&lt;!-- 3. 引入脚本(自动渲染) --&gt;<br/>
&lt;script src="/widgets/user-chip.js"&gt;&lt;/script&gt;
</div>
</div>
<!-- 引入 Widget 脚本(放在 body 底部,确保 DOM 就绪) -->
<script src="/widgets/user-chip.js"></script>
</body>
</html>
```
---
## 八、测试要点
### 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` 指定的地址 |
| 有 TokenAPI 正常 | 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. 接入方可用 `<link rel="preload">` 提前加载 |
| **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 的组件化封装**,把以上步骤自动化成一个 `<script>` 标签,让外部系统省去手写代码。两者互补:
- 接入文档教"原理和手动接入"
- Widget 提供"一键复制粘贴接入"
---
## 十二、执行任务汇总
| 序号 | 文件 | 操作 | 说明 |
|------|------|------|------|
| 1 | `src/widgets/user-chip/cookie.ts` | 新建 | Cookie 工具getToken / removeToken |
| 2 | `src/widgets/user-chip/api.ts` | 新建 | API 调用层3 个函数) |
| 3 | `src/widgets/user-chip/UserChip.vue` | 新建 | 核心组件(模板 + 逻辑 + 样式) |
| 4 | `src/widgets/user-chip/main.ts` | 新建 | 初始化入口(自动扫描挂载) |
| 5 | `vite.widget.config.ts` | 新建 | Widget 专用构建配置 |
| 6 | `package.json` | 修改 | 新增 `build:widget` 脚本1行 |
| 7 | `public/demo-widget.html` | 新建 | 接入演示页面 |
| 8 | — | 构建验证 | `npm run build:widget` |
| 9 | — | Nginx 配置 | `/widgets/` 静态文件 location |
---
## 十三、不做的事(范围边界)
- ❌ 不新增后端接口(全部复用现有 API
- ❌ 不修改现有 AdminLayout.vue提取为独立组件不影响网关后台自身的使用
- ❌ 不做 Shadow DOM因 Element Plus Teleport 限制)
- ❌ 不修改现有 vite.config.tsWidget 用独立的 `vite.widget.config.ts`
- ❌ 不做 NPM 包发布(仅通过 URL 提供)
---
**方案完成。**