feat: 完善遥测系统、优化图表性能及提升 UI E2E 稳定性

1. 遥测系统 (Telemetry) 实现:在 telemetry.ts 中实现了路由变化API 请求及全局错误的自动化追踪,并修复了 ESLint 错误。2. 性能优化 (Performance):针对 ECharts 实现了动态导入和按需组件注册,减小首屏体积,优化分包策略。3. UI E2E 稳定性提升:完善 Playwright 配置,新增 Docker 运行脚本及详细指南,更CI 流程。4. 其它改进与修复:修复 401 自动跳转逻辑及多处 ESLint 问题。
develop
hnu202326010131 4 months ago
parent 917b0aa9e4
commit 769f5e5d50

@ -27,10 +27,23 @@ jobs:
run: corepack enable
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck
- name: Test
run: pnpm run test
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps chromium
- name: E2E
run: pnpm run e2e:ui
- name: Upload Playwright Report
if: failure()
uses: actions/upload-artifact@v4
with:
name: frontend-vue-playwright-report
path: |
frontend-vue/playwright-report
frontend-vue/test-results
- name: Build
run: pnpm run build

2
.gitignore vendored

@ -3,6 +3,8 @@ src/fronted/vue3/
frontend-vue/node_modules/
frontend-vue/dist/
frontend-vue/.vite/
frontend-vue/test-results/
frontend-vue/playwright-report/
.venv/
__pycache__/
*.pyc

@ -0,0 +1,64 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

@ -0,0 +1,62 @@
# UI E2E本地与 CI 稳定运行指南)
本项目使用 Playwright 进行端到端测试。
## 指令总览
`frontend-vue/` 目录执行:
```bash
pnpm run e2e
```
```bash
pnpm run e2e:ui
```
## CI 如何运行
CI 会自动安装浏览器与系统依赖后运行 UI E2E并在失败时上传报告与 trace。
## 本地运行(推荐顺序)
### 方案 A原生运行Linux
首次运行需要安装 Playwright 的 Chromium 及系统依赖:
```bash
pnpm run e2e:ui:install
```
之后直接运行:
```bash
pnpm run e2e:ui
```
### 方案 BDocker 运行(最稳定)
本地没有系统依赖、或者安装依赖很慢时,推荐使用 DockerPlaywright 官方镜像已内置依赖):
```bash
pnpm run e2e:ui:docker
```
可通过环境变量指定镜像版本:
```bash
PLAYWRIGHT_IMAGE=mcr.microsoft.com/playwright:v1.57.0-jammy pnpm run e2e:ui:docker
```
## 产物与排查
UI E2E 失败后会生成:
- `frontend-vue/playwright-report/`HTML 报告)
- `frontend-vue/test-results/`trace / video / screenshot
本地查看报告:
```bash
pnpm exec playwright show-report playwright-report
```

@ -0,0 +1,56 @@
# 测试命令清单frontend-vue
以下命令均在 `frontend-vue/` 目录下执行(已使用 pnpm scripts 封装,见 [package.json](file:///home/devbox/project/frontend-vue/package.json))。
## 单元测试Vitest
```bash
pnpm test
```
监听模式:
```bash
pnpm run test:watch
```
## 质量门禁(建议提 PR 前本地跑一遍)
代码规范检查:
```bash
pnpm run lint
```
TypeScript 类型检查:
```bash
pnpm run typecheck
```
生产构建验证:
```bash
pnpm run build
```
一键跑完lint + typecheck + test + build
```bash
pnpm run lint && pnpm run typecheck && pnpm test && pnpm run build
```
## 端到端测试Playwright
API 级 smoke不依赖浏览器依赖
```bash
pnpm run e2e
```
UI 级端到端(需要系统安装 Playwright 浏览器依赖CI 会自动安装):
```bash
pnpm run e2e:ui
```

@ -0,0 +1,78 @@
import { test, expect } from "@playwright/test";
function seedAuth(storage: { role?: "admin" | "operator" | "observer" } = {}) {
const role = storage.role ?? "admin";
return (window: Window) => {
window.localStorage.setItem(
"cm_user",
JSON.stringify({ id: 1, username: "e2e", role })
);
window.localStorage.setItem("cm_token", "e2e-token");
};
}
test("未登录访问 diagnosis 会跳转到 login", async ({ page }) => {
await page.goto("/#/diagnosis");
await expect(page).toHaveURL(/#\/login/);
await expect(page.getByText("登录", { exact: false })).toBeVisible();
});
test("已登录可打开 diagnosis 并完成一次聊天流式渲染", async ({ page }) => {
await page.addInitScript(seedAuth({ role: "admin" }));
await page.route("**/api/v1/ai/history**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
await page.route("**/api/v1/clusters**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
clusters: [{ id: "c1", uuid: "c1", name: "cluster-1" }],
}),
});
});
await page.route("**/api/v1/nodes**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
nodes: [{ name: "node-1", status: "running" }],
}),
});
});
await page.route("**/api/v1/diagnosis/logs**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ logs: [] }),
});
});
await page.route("**/api/v1/ai/chat**", async (route) => {
const body =
'data: {"content":"OK-1"}\n' + 'data: {"content":"OK-2"}\n' + "data: [DONE]\n";
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream" },
body,
});
});
await page.goto("/#/diagnosis");
await expect(page.getByText("诊断助手")).toBeVisible();
await page.getByPlaceholder("支持Markdown输入... Enter 发送").fill("hello");
await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText("OK-1")).toBeVisible();
await expect(page.getByText("OK-2")).toBeVisible();
});

@ -0,0 +1,9 @@
import { test, expect } from "@playwright/test";
test("dev server 返回 index.html", async ({ request }) => {
const res = await request.get("/");
expect(res.status()).toBe(200);
const html = await res.text();
expect(html).toContain('id="app"');
});

@ -5,9 +5,15 @@
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts",
"lint": "eslint \"src/**/*.{ts,vue}\"",
"lint:fix": "pnpm run lint --fix",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test --project=api",
"e2e:ui": "playwright test --project=ui-chromium",
"e2e:ui:install": "playwright install --with-deps chromium",
"e2e:ui:docker": "bash scripts/e2e-ui-docker.sh",
"build": "vite build --config vite.config.ts",
"preview": "vite preview --config vite.config.ts"
},
@ -24,15 +30,54 @@
"vue-router": "^4.4.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/marked": "^5.0.2",
"@vue/test-utils": "^2.4.6",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-vue": "^5.1.2",
"@vue/test-utils": "^2.4.6",
"amfe-flexible": "^2.2.1",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.32.0",
"happy-dom": "^16.3.1",
"postcss-pxtorem": "^6.1.0",
"sass": "^1.97.2",
"typescript": "^5.6.2",
"unplugin-vue-components": "^30.0.0",
"vite": "^5.4.10",
"vitest": "^2.1.9"
"vitest": "^2.1.9",
"vue-eslint-parser": "^9.4.3"
},
"eslintConfig": {
"root": true,
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parser": "vue-eslint-parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"parser": "@typescript-eslint/parser"
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-essential"
],
"plugins": [
"vue",
"@typescript-eslint"
],
"ignorePatterns": [
"dist",
"node_modules"
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"vue/multi-word-component-names": "off"
}
}
}

@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
outputDir: "test-results",
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: { baseURL: "http://127.0.0.1:5173" },
webServer: {
command: "pnpm dev --host 127.0.0.1 --port 5173",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: "api",
testMatch: /.*\.api\.spec\.ts/,
},
{
name: "ui-chromium",
testMatch: /.*\.ui\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "only-on-failure",
},
},
],
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found"
exit 1
fi
IMAGE="${PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.57.0-jammy}"
docker run --rm -t \
-v "${ROOT_DIR}:/work" \
-w /work \
"${IMAGE}" \
bash -lc 'corepack enable && pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile && pnpm run e2e:ui'

@ -19,5 +19,9 @@ export const AuthService = {
/** 后端健康检查 */
async health(): Promise<any> {
return api.get('/v1/health')
},
async refresh(payload: any): Promise<any> {
return api.post('/v1/auth/refresh', payload)
}
}

@ -4,16 +4,17 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { NodeService } from '../api/node.service'
import { MetricService } from '../api/metric.service'
import { useUIStore } from '../stores/ui'
import { loadEcharts } from '../lib/echarts'
import type { EChartsType } from 'echarts/core'
const props = defineProps<{ cluster: string }>()
const ui = useUIStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
let chart: EChartsType | null = null
let ro: ResizeObserver | null = null
let destroyed = false
function render(used: number, idle: number) {
if (!chart) return
@ -70,17 +71,18 @@ async function load() {
}
}
function initChart() {
if (!root.value) return
if (chart) {
chart.dispose()
}
chart = echarts.init(root.value, ui.isDark ? 'dark' : undefined)
load()
async function initChart() {
const el = root.value
if (!el) return
const echarts = await loadEcharts()
if (destroyed || root.value !== el) return
if (chart) chart.dispose()
chart = echarts.init(el)
await load()
}
onMounted(() => {
initChart()
void initChart()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
ro = new ResizeObserver(() => { chart && chart.resize() })
@ -90,8 +92,8 @@ onMounted(() => {
})
watch(() => ui.isDark, () => {
initChart()
void initChart()
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
onBeforeUnmount(() => { destroyed = true; ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
</script>

@ -4,15 +4,17 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { MetricService } from '../api/metric.service'
import { useUIStore } from '../stores/ui'
import { loadEcharts } from '../lib/echarts'
import type { EChartsType } from 'echarts/core'
const props = defineProps<{ cluster: string }>()
const ui = useUIStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
let chart: EChartsType | null = null
let ro: ResizeObserver | null = null
let destroyed = false
function render(used: number, free: number) {
if (!chart) return
@ -69,17 +71,18 @@ async function load() {
}
}
function initChart() {
if (!root.value) return
if (chart) {
chart.dispose()
}
chart = echarts.init(root.value, ui.isDark ? 'dark' : undefined)
load()
async function initChart() {
const el = root.value
if (!el) return
const echarts = await loadEcharts()
if (destroyed || root.value !== el) return
if (chart) chart.dispose()
chart = echarts.init(el)
await load()
}
onMounted(() => {
initChart()
void initChart()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
ro = new ResizeObserver(() => { chart && chart.resize() })
@ -89,8 +92,8 @@ onMounted(() => {
})
watch(() => ui.isDark, () => {
initChart()
void initChart()
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
onBeforeUnmount(() => { destroyed = true; ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
</script>

@ -1,12 +1,14 @@
import * as echarts from 'echarts'
export function initCpu(el: HTMLElement) {
import { loadEcharts } from '../lib/echarts'
export async function initCpu(el: HTMLElement) {
const echarts = await loadEcharts()
const c = echarts.init(el)
c.setOption({ xAxis: { type: 'category', boundaryGap: false, data: ['00:00','04:00','08:00','12:00','16:00','20:00','24:00'] }, yAxis: { type: 'value', min:0, max:100 }, series: [{ type: 'line', smooth: true, data: [20,35,45,60,55,40,30] }] })
return c
}
export function initMem(el: HTMLElement) {
export async function initMem(el: HTMLElement) {
const echarts = await loadEcharts()
const c = echarts.init(el)
c.setOption({ series: [{ type: 'pie', radius: ['40%','70%'], data: [{ value: 8.5, name: '已使用' }, { value: 15.5, name: '可用' }] }] })
return c
}

@ -106,7 +106,7 @@ export function useDiagnosisChat(options: {
let buffer = "";
let hasReceivedContent = false;
while (true) {
for (;;) {
const { done, value } = await reader.read();
if (done) break;

@ -5,3 +5,11 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_TELEMETRY_ENABLED?: string
readonly VITE_TELEMETRY_ENDPOINT?: string
readonly VITE_TELEMETRY_SAMPLE_RATE?: string
readonly VITE_AUTH_REFRESH_ENABLED?: string
readonly VITE_AUTH_REFRESH_ENDPOINT?: string
}

@ -1,5 +1,6 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { trackError, trackEvent } from './telemetry'
// 扩展 AxiosInstance 接口,使其支持解包后的数据类型
declare module 'axios' {
@ -20,6 +21,38 @@ const api: AxiosInstance = axios.create({
timeout: 10000
})
const refreshApi = axios.create({
baseURL: '/api',
timeout: 10000
})
let refreshPromise: Promise<string | null> | null = null
function isRefreshEnabled() {
const raw = String(import.meta.env.VITE_AUTH_REFRESH_ENABLED || '')
if (!raw) return false
return raw.toLowerCase() === 'true'
}
function getRefreshEndpoint() {
return String(import.meta.env.VITE_AUTH_REFRESH_ENDPOINT || '/v1/auth/refresh')
}
async function refreshAccessToken() {
const auth = useAuthStore()
const refreshToken = auth.refreshToken
if (!refreshToken) return null
const endpoint = getRefreshEndpoint()
const r = await refreshApi.post(endpoint, { refreshToken, refresh_token: refreshToken })
const token = r?.data?.token || r?.data?.accessToken || r?.data?.access_token || r?.token || r?.accessToken || r?.access_token
const nextRefresh = r?.data?.refreshToken || r?.data?.refresh_token || r?.refreshToken || r?.refresh_token || refreshToken
if (!token) return null
auth.token = token
auth.refreshToken = nextRefresh || refreshToken
auth.persist()
return token as string
}
// 请求拦截器
api.interceptors.request.use(
(config) => {
@ -44,10 +77,16 @@ api.interceptors.response.use(
if (startTime) {
const duration = new Date().getTime() - startTime.getTime()
console.log(`API [${response.config.method?.toUpperCase()}] ${response.config.url} 耗时: ${duration}ms`)
trackEvent('api_ok', {
method: response.config.method?.toUpperCase() || '',
url: String(response.config.url || '').split('?')[0],
status: response.status,
durationMs: duration
})
}
return response.data // 直接解包 data
},
(error) => {
async (error) => {
const status = error.response?.status
const isRegister = error.config?.url?.includes('/v1/user/register')
let message = '网络异常,请检查您的网络连接'
@ -65,11 +104,46 @@ api.interceptors.response.use(
message = isRegister ? `注册请求失败 (状态码: ${status || '未知'})` : '操作失败'
}
trackError('api_error', error, {
method: error.config?.method?.toUpperCase?.() || '',
url: String(error.config?.url || '').split('?')[0],
status: status || null
})
const shouldTryRefresh =
status === 401 &&
isRefreshEnabled() &&
error?.config &&
!(error.config as any)._retry &&
!String(error.config.url || '').includes('/v1/user/login') &&
!String(error.config.url || '').includes('/v1/user/register') &&
!String(error.config.url || '').includes(getRefreshEndpoint())
if (shouldTryRefresh) {
try {
(error.config as any)._retry = true
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null
})
}
const nextToken = await refreshPromise
if (nextToken) {
error.config.headers = error.config.headers || {}
error.config.headers.Authorization = `Bearer ${nextToken}`
return api.request(error.config)
}
} catch (e) {
void e
}
}
if (status === 401) {
const auth = useAuthStore()
const current = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash
auth.logout()
if (!window.location.hash.includes('login')) {
window.location.hash = '#/login'
window.location.hash = `#/login?redirect=${encodeURIComponent(current || '/diagnosis')}`
}
}

@ -0,0 +1,37 @@
import type { EChartsType } from "echarts/core";
type EchartsModule = {
init: (dom: HTMLElement, theme?: unknown, opts?: unknown) => EChartsType;
use: (plugins: unknown[]) => void;
};
let modulePromise: Promise<EchartsModule> | null = null;
export async function loadEcharts(): Promise<EchartsModule> {
if (modulePromise) return modulePromise;
modulePromise = (async () => {
const echarts = (await import("echarts/core")) as unknown as EchartsModule;
const { PieChart, LineChart } = await import("echarts/charts");
const {
TooltipComponent,
LegendComponent,
GridComponent,
DatasetComponent,
} = await import("echarts/components");
const { CanvasRenderer } = await import("echarts/renderers");
echarts.use([
PieChart,
LineChart,
TooltipComponent,
LegendComponent,
GridComponent,
DatasetComponent,
CanvasRenderer,
]);
return echarts;
})();
return modulePromise;
}

@ -0,0 +1,118 @@
type TelemetryLevel = "debug" | "info" | "warn" | "error";
type TelemetryEvent = {
name: string;
level: TelemetryLevel;
ts: number;
context?: Record<string, unknown>;
};
type TelemetryConfig = {
enabled: boolean;
endpoint: string;
sampleRate: number;
getContext?: () => Record<string, unknown>;
};
let config: TelemetryConfig = {
enabled: false,
endpoint: "",
sampleRate: 1,
};
function clamp01(n: number) {
if (Number.isNaN(n)) return 1;
return Math.max(0, Math.min(1, n));
}
function safeString(v: unknown, maxLen = 400) {
const s = typeof v === "string" ? v : JSON.stringify(v);
return s.length > maxLen ? s.slice(0, maxLen) : s;
}
function normalizeError(e: unknown) {
if (e instanceof Error) {
return {
name: e.name,
message: safeString(e.message, 800),
stack: safeString(e.stack || "", 2000),
};
}
return { message: safeString(e, 800) };
}
function shouldSend() {
if (!config.enabled) return false;
const r = clamp01(config.sampleRate);
if (r >= 1) return true;
return Math.random() < r;
}
function mergeContext(extra?: Record<string, unknown>) {
const base = config.getContext ? config.getContext() : {};
return { ...base, ...(extra || {}) };
}
function postEvent(evt: TelemetryEvent) {
if (!config.endpoint) return;
const payload = JSON.stringify(evt);
try {
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
const blob = new Blob([payload], { type: "application/json" });
navigator.sendBeacon(config.endpoint, blob);
return;
}
} catch (e) {
void e;
}
try {
fetch(config.endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: payload,
keepalive: true,
}).catch(() => {});
} catch (e) {
void e;
}
}
export function initTelemetry(next: Partial<TelemetryConfig>) {
config = {
...config,
...next,
enabled: !!next.enabled,
sampleRate: clamp01(Number(next.sampleRate ?? config.sampleRate)),
endpoint: String(next.endpoint ?? config.endpoint),
};
}
export function trackEvent(name: string, context?: Record<string, unknown>, level: TelemetryLevel = "info") {
if (!shouldSend()) return;
postEvent({ name, level, ts: Date.now(), context: mergeContext(context) });
}
export function trackError(name: string, error: unknown, context?: Record<string, unknown>) {
postEvent({
name,
level: "error",
ts: Date.now(),
context: mergeContext({ error: normalizeError(error), ...(context || {}) }),
});
}
export function installGlobalErrorHandlers() {
window.addEventListener("error", (ev) => {
trackError("window_error", ev.error || ev.message, {
filename: ev.filename,
lineno: ev.lineno,
colno: ev.colno,
});
});
window.addEventListener("unhandledrejection", (ev) => {
trackError("unhandledrejection", ev.reason);
});
}

@ -4,21 +4,50 @@ import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './styles/theme.scss'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { Aim, ArrowDown, ArrowRight, CircleCheck, Edit, Expand, Fold, FullScreen, Lock, Monitor, Moon, More, Plus, Rank, Refresh, Sunny, User, UserFilled, Message } from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import { initTelemetry, installGlobalErrorHandlers, trackEvent } from './lib/telemetry'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.component('Aim', Aim)
app.component('ArrowDown', ArrowDown)
app.component('ArrowRight', ArrowRight)
app.component('CircleCheck', CircleCheck)
app.component('Edit', Edit)
app.component('Expand', Expand)
app.component('Fold', Fold)
app.component('FullScreen', FullScreen)
app.component('Lock', Lock)
app.component('Monitor', Monitor)
app.component('Moon', Moon)
app.component('More', More)
app.component('Plus', Plus)
app.component('Rank', Rank)
app.component('Refresh', Refresh)
app.component('Sunny', Sunny)
app.component('User', User)
app.component('UserFilled', UserFilled)
app.component('Message', Message)
app.use(ElementPlus)
const auth = useAuthStore()
auth.restore()
app.use(router)
initTelemetry({
enabled: String(import.meta.env.VITE_TELEMETRY_ENABLED || '').toLowerCase() === 'true',
endpoint: String(import.meta.env.VITE_TELEMETRY_ENDPOINT || ''),
sampleRate: Number(import.meta.env.VITE_TELEMETRY_SAMPLE_RATE || 1),
getContext: () => ({
route: window.location.hash || '',
userId: auth.user?.id || null,
role: auth.user?.role || null
})
})
installGlobalErrorHandlers()
trackEvent('app_boot')
app.mount('#app')

@ -1,6 +1,7 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { Roles, AllRoles } from '../constants/roles'
import { trackEvent } from '../lib/telemetry'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/diagnosis' },
@ -22,10 +23,14 @@ const router = createRouter({ history: createWebHashHistory(), routes })
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta || to.meta.requiresAuth === false) return true
if (!auth.isAuthenticated) return { name: 'login' }
if (!auth.isAuthenticated) return { name: 'login', query: { redirect: to.fullPath } }
const roles = to.meta.roles as string[] | undefined
if (roles && !roles.includes(auth.role || '')) return { name: auth.defaultPage }
return true
})
router.afterEach((to, from) => {
trackEvent('route_change', { to: to.fullPath, from: from.fullPath })
})
export default router

@ -19,7 +19,7 @@ function normalizeRole(r: string): 'admin'|'operator'|'observer'|'' {
}
export const useAuthStore = defineStore('auth', {
state: () => ({ user: null as User|null, token: null as string|null }),
state: () => ({ user: null as User|null, token: null as string|null, refreshToken: null as string|null }),
getters: {
isAuthenticated: (s) => !!(s.user && s.token),
role: (s) => s.user?.role || null,
@ -34,14 +34,18 @@ export const useAuthStore = defineStore('auth', {
restore() {
const rawUser = localStorage.getItem('cm_user')
const rawToken = localStorage.getItem('cm_token')
const rawRefreshToken = localStorage.getItem('cm_refresh_token')
if (rawUser && rawToken) {
this.user = JSON.parse(rawUser)
this.token = rawToken
this.refreshToken = rawRefreshToken || null
} else {
this.user = null
this.token = null
this.refreshToken = null
localStorage.removeItem('cm_user')
localStorage.removeItem('cm_token')
localStorage.removeItem('cm_refresh_token')
}
},
persist() {
@ -49,11 +53,14 @@ export const useAuthStore = defineStore('auth', {
else localStorage.removeItem('cm_user')
if (this.token) localStorage.setItem('cm_token', this.token)
else localStorage.removeItem('cm_token')
if (this.refreshToken) localStorage.setItem('cm_refresh_token', this.refreshToken)
else localStorage.removeItem('cm_refresh_token')
},
async login(username: string, password: string) {
try {
const r: any = await AuthService.login({ username, password })
const token = r?.token
const refreshToken = r?.refreshToken || r?.refresh_token || r?.tokens?.refresh || null
const userId = r?.user?.id || r?.id || 0
const backendRoles = (r?.roles || []) as string[]
const backendRoleRaw = (r?.user?.role || r?.role || r?.role_key || (backendRoles.length > 0 ? backendRoles[0] : '')) as string
@ -64,6 +71,7 @@ export const useAuthStore = defineStore('auth', {
}
this.user = { id: userId, username, role }
this.token = token
this.refreshToken = refreshToken
this.persist()
return { ok: true, role }
} catch (e: any) {
@ -80,12 +88,13 @@ export const useAuthStore = defineStore('auth', {
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' || username === 'administrator' ? 'admin' : (username === 'ops' || username === 'operator') ? 'operator' : 'observer')
this.user = { id: userId, username, role }
this.token = r?.token || null
this.refreshToken = r?.refreshToken || r?.refresh_token || r?.tokens?.refresh || null
this.persist()
return { ok: true, role }
} catch (e: any) {
return { ok: false, message: e.friendlyMessage || '注册失败' }
}
},
logout() { this.user = null; this.token = null; this.persist() }
logout() { this.user = null; this.token = null; this.refreshToken = null; this.persist() }
}
})

@ -69,7 +69,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useAuthStore } from "../stores/auth";
import { useUIStore } from "../stores/ui";
@ -79,6 +79,7 @@ import type { FormInstance, FormRules } from "element-plus";
import { Monitor, User, Lock, Moon, Sunny } from '@element-plus/icons-vue'
const router = useRouter();
const route = useRoute();
const auth = useAuthStore();
const ui = useUIStore();
const { isDark } = storeToRefs(ui);
@ -155,7 +156,8 @@ async function onSubmit() {
const r = await auth.login(loginForm.username, loginForm.password);
if (r.ok) {
ElMessage.success("登录成功");
router.replace({ name: auth.defaultPage });
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "";
router.replace(redirect || { name: auth.defaultPage });
} else {
ElMessage.error(r.message || "登录失败!");
}

@ -9,9 +9,22 @@ export default defineConfig(({ mode }) => {
const hmrHost = "localhost";
const hmrPort = 5173;
const allowedHostsEnv = (env.VITE_ALLOWED_HOSTS || "").split(",").map((s) => s.trim()).filter(Boolean);
const isProd = mode === "production";
return {
plugins: [vue()],
cacheDir: ".vite",
esbuild: isProd ? { drop: ["console", "debugger"] } : undefined,
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router", "pinia"],
elementPlus: ["element-plus"],
vendor: ["axios", "@vueuse/core", "marked", "vue-i18n"],
},
},
},
},
server: {
host: devHost,
strictPort: true,

@ -6,6 +6,6 @@ export default defineConfig({
test: {
environment: "happy-dom",
globals: true,
exclude: ["e2e/**", "node_modules/**", "dist/**"],
},
});

Loading…
Cancel
Save