40 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.
目标: 把网关后台右上角的用户头像+用户名下拉组件,打包成一个独立的 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' |
外部系统使用方式
<!-- 一行 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 / 错误静默降级 │
│ 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-* 属性配置:
<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 读取 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}
根容器结构
<div class="mokee-uc-root"> ← Vue 挂载点(替换 #mokee-user-chip 的内部)
<div class="mokee-uc-chip"> ← 用户条(头像 + 名字 + 箭头)
...
</div>
</div>
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
/**
* 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 新增脚本
{
"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 构建命令
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 (新建)
/**
* 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 (新建)
/**
* 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 (新建)
<!--
用户头像下拉 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. 通知服务端销毁 session(fire-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 (新建)
/**
* 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 (浏览器可直接执行)
*
* 注意:此文件无任何 export,IIFE 打包后直接执行
*/
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 (项目根目录,新建)
/**
* 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 (修改)
{
"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 (新建)
<!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:32000"
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">
<!-- 1. 引入样式 --><br/>
<link rel="stylesheet" href="/widgets/user-chip.css" /><br/>
<br/>
<!-- 2. 占位元素(可配置网关地址、登录页、切换系统页) --><br/>
<div id="mokee-user-chip"<br/>
data-api-base="http://127.0.0.1:32000"<br/>
data-login-url="http://127.0.0.1:5001/login"<br/>
data-select-url="http://127.0.0.1:5001/select"></div><br/>
<br/>
<!-- 3. 引入脚本(自动渲染) --><br/>
<script src="/widgets/user-chip.js"></script>
</div>
</div>
<!-- 引入 Widget 脚本(放在 body 底部,确保 DOM 就绪) -->
<script src="/widgets/user-chip.js"></script>
</body>
</html>
八、测试要点
8.1 本地开发调试
# 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 配置新增
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 集成(可选,后续优化)
# 在现有构建流程中增加
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.ts(Widget 用独立的
vite.widget.config.ts) - ❌ 不做 NPM 包发布(仅通过 URL 提供)
方案完成。