1294 lines
40 KiB
Markdown
1294 lines
40 KiB
Markdown
# 用户头像下拉 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 / 错误静默降级 │
|
||
│ 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
|
||
<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}
|
||
```
|
||
|
||
### 根容器结构
|
||
|
||
```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. 通知服务端销毁 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` (新建)
|
||
|
||
```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 (浏览器可直接执行)
|
||
*
|
||
* 注意:此文件无任何 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` (项目根目录,新建)
|
||
|
||
```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: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 本地开发调试
|
||
|
||
```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. 接入方可用 `<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 提供)
|
||
|
||
---
|
||
|
||
**方案完成。**
|