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

40 KiB
Raw Permalink Blame History

用户头像下拉 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 / 错误静默降级          │
│ 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-* 属性配置:

<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.bodyShadow 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

七、完整代码

文件: 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. 通知服务端销毁 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 (新建)

/**
 * 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 (项目根目录,新建)

/**
 * 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">
&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:32000"<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 本地开发调试

# 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 配置新增
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.tsWidget 用独立的 vite.widget.config.ts
  • 不做 NPM 包发布(仅通过 URL 提供)

方案完成。