From 2daf93ab24506f6ece56404d462483be102f156f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Thu, 11 Jun 2026 23:52:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B1=95=E7=A4=BA=E9=9B=86=E6=88=90?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E8=AF=B4=E6=98=8E=E4=B8=8E=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/notification-platform-config.md | 166 +++++++++ src/components/BpmnStatusViewer.vue | 532 ++++++++++++++++++++++++--- src/services/devopsApi.ts | 190 +++++++++- src/types/devops.ts | 7 + src/views/SettingsView.vue | 214 +++++++---- 6 files changed, 982 insertions(+), 131 deletions(-) create mode 100644 docs/notification-platform-config.md diff --git a/README.md b/README.md index d6511e0..b0b70ba 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Vue 3 + Vite + TypeScript 实现的 DevOps 运维控制台第一阶段,用于 - 所有项目、发布、refs、流程和集成状态均调用 `devops-platform-api`,前端不再提供本地 mock 数据降级。 - 发布中心包含项目、环境、ref/tag、备注、生产二次确认、触发、取消、重试和 Jenkins 状态同步入口。 - 运行记录展示 Jenkins queue/build、Build URL、开始/结束时间、执行提示、失败摘要和日志摘要。 -- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并按 queued/running/success/failed/skipped/canceled 状态高亮节点。 +- BPMN Viewer 使用 `bpmn-js` 渲染后端流程 XML,并通过进度统计、节点状态 badge、右侧时间线和详情面板展示 queued/running/success/failed/skipped/canceled 状态。 - Agent 作为全局工具从顶部打开,只包含发布风险摘要、失败诊断、Runbook 问答、发布说明草稿,并通过后端 Agent API 创建调用记录。 ## 技术栈 @@ -41,6 +41,8 @@ pnpm build 接口入口为 `src/services/devopsApi.ts`,只调用后端统一 envelope 响应;后端不可用时页面应暴露真实接口错误,不伪造项目、运行记录或 refs。后续以 `/Users/mac033/Desktop/my-project/devops-platform-docs/devops-platform-implementation-tracker.md` 中的产品约束和后端 OpenAPI 为准继续收敛字段。 +通知平台配置方案见 [docs/notification-platform-config.md](docs/notification-platform-config.md),包含企业微信、飞书和通用 webhook 的后端变量、安全边界与验证步骤。`/settings` 页面支持展示后端返回的变量说明字段,用于 hover 查看每个变量含义。 + 浏览器端不得保存或直连以下密钥和服务: - Jenkins token diff --git a/docs/notification-platform-config.md b/docs/notification-platform-config.md new file mode 100644 index 0000000..53681b3 --- /dev/null +++ b/docs/notification-platform-config.md @@ -0,0 +1,166 @@ +# DevOps 通知平台配置方案 + +更新时间:2026-06-11 + +## 当前结论 + +通知平台不是前端能力,web 端只展示后端返回的配置状态、变量名和变量说明。真实 webhook、token、签名密钥只能存在于 `devops-platform-api` 的服务端环境变量、服务器 secret 或后续加密存储中。 + +当前后端通知 provider 已按以下变量预留: + +| Provider | 适用平台 | 必需变量 | Webhook 变量 | 当前策略 | +| --- | --- | --- | --- | --- | +| `wecom` | 企业微信 | `NOTIFICATION_PROVIDER=wecom` | `WECOM_WEBHOOK_URL` | 发送 markdown 发布通知 | +| `feishu` | 飞书 | `NOTIFICATION_PROVIDER=feishu` | `FEISHU_WEBHOOK_URL` | 发送 text 发布通知 | +| `generic` | 内部通知网关 | `NOTIFICATION_PROVIDER=generic` | `NOTIFICATION_WEBHOOK_URL` | 发送结构化 JSON | + +未配置 provider 或 webhook 时,发布流程不应被阻断;通知节点应显示 skipped/failed,并把脱敏摘要写入审计日志。 + +## 后端环境变量 + +```env +DEVOPS_PUBLIC_URL=https://devops.mrzhan.top + +# 任选其一:wecom / feishu / generic +NOTIFICATION_PROVIDER=wecom + +# 企业微信机器人 +WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-real-key + +# 飞书自定义机器人 +FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-with-real-token + +# 通用内部 webhook +NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-with-secret +``` + +安全要求: + +| 要求 | 说明 | +| --- | --- | +| 不进前端 | web 端只拿变量名、状态和说明,不接收真实 URL | +| 不进 Git | `.env`、服务器 secret、后续 `secrets` 表均不得提交真实 webhook | +| 日志脱敏 | 任何异常日志、审计摘要和 Agent 上下文都不能包含 webhook 原文 | +| 不阻断发布 | 通知失败只影响通知节点和审计,不回滚 Jenkins 构建结果 | + +## 企业微信配置 + +官方参考:`https://developer.work.weixin.qq.com/document/path/91770` + +1. 在企业微信群中添加群机器人。 +2. 复制机器人 webhook,保存到服务端环境变量 `WECOM_WEBHOOK_URL`。 +3. 设置 `NOTIFICATION_PROVIDER=wecom`。 +4. 重启 `devops-platform-api`。 +5. 访问 `/settings`,确认“通知平台”从未配置变成已配置或部分配置。 +6. 创建一次测试发布单,检查流程中的通知节点和群内消息。 + +本地验证命令: + +```bash +curl -X POST "$WECOM_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d '{"msgtype":"markdown","markdown":{"content":"# DevOps 通知测试\n\n> 项目:devops-platform-web\n> 环境:production\n> 状态:success"}}' +``` + +## 飞书配置 + +官方参考:`https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot` + +1. 在飞书目标群添加“自定义机器人”。 +2. 复制 webhook,保存到服务端环境变量 `FEISHU_WEBHOOK_URL`。 +3. 建议开启安全设置:关键词、IP 白名单或签名校验。 +4. 设置 `NOTIFICATION_PROVIDER=feishu`。 +5. 重启 `devops-platform-api`。 +6. 创建测试发布单,检查通知节点和飞书群消息。 + +基础 text 消息验证: + +```bash +curl -X POST "$FEISHU_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d '{"msg_type":"text","content":{"text":"DevOps 通知测试:devops-platform-web production success"}}' +``` + +如果开启飞书签名校验,需要后端按飞书规则计算 timestamp/sign,再放入请求 body;不要在前端计算或保存签名密钥。 + +## 通用 webhook 配置 + +适合接内部通知网关或消息中转服务: + +```env +NOTIFICATION_PROVIDER=generic +NOTIFICATION_WEBHOOK_URL=https://notification.example.internal/webhook/replace-with-secret +``` + +建议后端投递结构: + +```json +{ + "event": "deploy.succeeded", + "title": "DevOps 发布成功", + "run": { + "id": "run_xxx", + "projectKey": "devops-platform-web", + "environment": "production", + "ref": "master", + "status": "success", + "operator": "ops-admin" + }, + "publicUrl": "https://devops.mrzhan.top" +} +``` + +## Settings 变量说明契约 + +`/settings` 页面已按后端返回字段展示 hover 说明。后端推荐在 `GET /settings/integration-config` 中为每个变量补充 `variableHelp`: + +```json +{ + "key": "notification", + "name": "通知平台", + "status": "partial", + "required": ["NOTIFICATION_PROVIDER", "WECOM_WEBHOOK_URL"], + "configured": ["NOTIFICATION_PROVIDER"], + "missing": ["WECOM_WEBHOOK_URL"], + "optional": ["FEISHU_WEBHOOK_URL", "NOTIFICATION_WEBHOOK_URL", "DEVOPS_PUBLIC_URL"], + "variableHelp": { + "NOTIFICATION_PROVIDER": { + "description": "通知 provider,可选 wecom、feishu、generic。", + "example": "wecom", + "docUrl": "https://developer.work.weixin.qq.com/document/path/91770" + }, + "WECOM_WEBHOOK_URL": { + "description": "企业微信群机器人的服务端 webhook,只能保存在后端。", + "docUrl": "https://developer.work.weixin.qq.com/document/path/91770" + }, + "FEISHU_WEBHOOK_URL": { + "description": "飞书自定义机器人的服务端 webhook,只能保存在后端。", + "docUrl": "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot" + }, + "NOTIFICATION_WEBHOOK_URL": { + "description": "内部通知网关或通用 webhook 的服务端地址。" + }, + "DEVOPS_PUBLIC_URL": { + "description": "通知消息里跳转回 DevOps 平台的公开访问地址。" + } + } +} +``` + +前端兼容字段: + +| 字段 | 说明 | +| --- | --- | +| `variableHelp` | 推荐格式,支持 description、example、docUrl | +| `variableDescriptions` | 兼容 `变量名 -> 说明字符串` 的简单格式 | +| `variables[]` | 兼容 `{ name, description, example, docUrl }` 数组格式 | + +## 上线检查 + +| 检查项 | 通过标准 | +| --- | --- | +| `/settings` | 只展示变量名、配置状态和后端说明,不展示真实 webhook | +| 发布流程 | 通知未配置时节点 skipped,不阻断成功发布 | +| 审计日志 | 记录通知投递结果,摘要已脱敏 | +| Agent | 只能解释通知 skipped/failed,不输出或猜测 webhook | +| 服务器 | 真实 webhook 仅在 `.env`、secret manager 或加密存储中存在 | diff --git a/src/components/BpmnStatusViewer.vue b/src/components/BpmnStatusViewer.vue index 7f8678d..526fffb 100644 --- a/src/components/BpmnStatusViewer.vue +++ b/src/components/BpmnStatusViewer.vue @@ -12,9 +12,19 @@ const props = defineProps<{ steps: PipelineStep[] }>() +type StatusMeta = { + label: string + short: string + className: string + description: string +} + const canvasEl = ref() const selectedNodeId = ref('') const viewer = shallowRef>() +let eventBusBound = false +let overlayIds: Array = [] +let markedNodeIds = new Set() const markerClasses: Record = { pending: 'bpmn-step-pending', @@ -26,18 +36,103 @@ const markerClasses: Record = { canceled: 'bpmn-step-canceled', } -function findStep(nodeId: string) { - return props.steps.find((step) => step.bpmnNodeId === nodeId) +const statusMeta: Record = { + pending: { + label: '等待中', + short: '等', + className: 'pending', + description: '前置节点尚未完成,流程还没有执行到这里。', + }, + queued: { + label: '排队中', + short: '排', + className: 'queued', + description: '任务已经提交,正在等待 Jenkins 或后端调度。', + }, + running: { + label: '运行中', + short: '行', + className: 'running', + description: '当前节点正在执行,状态会随后端同步刷新。', + }, + success: { + label: '成功', + short: '成', + className: 'success', + description: '节点已经完成且没有阻断后续流程。', + }, + failed: { + label: '失败', + short: '败', + className: 'failed', + description: '节点执行失败,需要查看失败摘要和日志片段。', + }, + skipped: { + label: '跳过', + short: '跳', + className: 'skipped', + description: '节点被后端判定为可跳过,通常不阻断发布。', + }, + canceled: { + label: '取消', + short: '取', + className: 'canceled', + description: '流程被人工取消或由后端终止。', + }, } +const statusLegend = Object.values(statusMeta) +const orderedSteps = computed(() => props.steps) + +const currentStep = computed(() => { + const active = orderedSteps.value.find((step) => + ['running', 'queued', 'failed'].includes(step.status), + ) + + if (active) { + return active + } + + return ( + orderedSteps.value + .slice() + .reverse() + .find((step) => ['success', 'skipped', 'canceled'].includes(step.status)) ?? + orderedSteps.value[0] + ) +}) + const selectedStep = computed(() => { if (!selectedNodeId.value) { - return undefined + return currentStep.value } - return findStep(selectedNodeId.value) + return findStep(selectedNodeId.value) ?? currentStep.value }) +const stepStats = computed(() => { + const total = orderedSteps.value.length + const done = orderedSteps.value.filter((step) => + ['success', 'failed', 'skipped', 'canceled'].includes(step.status), + ).length + const running = orderedSteps.value.filter((step) => step.status === 'running').length + const failed = orderedSteps.value.filter((step) => step.status === 'failed').length + + return { total, done, running, failed } +}) + +const progressPercent = computed(() => { + if (stepStats.value.total === 0) { + return 0 + } + + return Math.round((stepStats.value.done / stepStats.value.total) * 100) +}) + +function findStep(nodeId: string) { + return props.steps.find((step) => step.bpmnNodeId === nodeId) +} + async function renderDiagram() { if (!canvasEl.value || !props.xml) { return @@ -52,12 +147,17 @@ async function renderDiagram() { await viewer.value.importXML(props.xml) const canvas = viewer.value.get('canvas') as { zoom: (mode: string) => void - addMarker: (id: string, marker: string) => void - removeMarker: (id: string, marker: string) => void } canvas.zoom('fit-viewport') + bindEventBus() applyMarkers() +} + +function bindEventBus() { + if (!viewer.value || eventBusBound) { + return + } const eventBus = viewer.value.get('eventBus') as { on: (event: string, callback: (event: { element?: { id: string } }) => void) => void @@ -65,10 +165,12 @@ async function renderDiagram() { eventBus.on('element.click', (event) => { const nodeId = event.element?.id + if (nodeId && findStep(nodeId)) { selectedNodeId.value = nodeId } }) + eventBusBound = true } function applyMarkers() { @@ -80,17 +182,90 @@ function applyMarkers() { addMarker: (id: string, marker: string) => void removeMarker: (id: string, marker: string) => void } + const elementRegistry = viewer.value.get('elementRegistry') as { + get: (id: string) => unknown + } + + markedNodeIds.forEach((nodeId) => { + Object.values(markerClasses).forEach((marker) => { + canvas.removeMarker(nodeId, marker) + }) + canvas.removeMarker(nodeId, 'bpmn-step-selected') + }) + markedNodeIds = new Set() + clearOverlays() props.steps.forEach((step) => { - Object.values(markerClasses).forEach((marker) => { - canvas.removeMarker(step.bpmnNodeId, marker) - }) + if (!elementRegistry.get(step.bpmnNodeId)) { + return + } + canvas.addMarker(step.bpmnNodeId, markerClasses[step.status] ?? markerClasses.pending) + + if (selectedStep.value?.bpmnNodeId === step.bpmnNodeId) { + canvas.addMarker(step.bpmnNodeId, 'bpmn-step-selected') + } + + markedNodeIds.add(step.bpmnNodeId) + addStepOverlay(step) }) } +function clearOverlays() { + if (!viewer.value || overlayIds.length === 0) { + overlayIds = [] + return + } + + const overlays = viewer.value.get('overlays') as { + remove: (id: string | number) => void + } + + overlayIds.forEach((id) => overlays.remove(id)) + overlayIds = [] +} + +function addStepOverlay(step: PipelineStep) { + if (!viewer.value) { + return + } + + const overlays = viewer.value.get('overlays') as { + add: ( + elementId: string, + type: string, + options: { position: { top?: number; right?: number }; html: HTMLElement }, + ) => string | number + } + const meta = statusMeta[step.status] + const badge = document.createElement('button') + badge.type = 'button' + badge.className = `bpmn-step-badge is-${meta.className}` + badge.textContent = meta.short + badge.title = `${step.name}:${meta.label}` + badge.addEventListener('click', (event) => { + event.stopPropagation() + selectedNodeId.value = step.bpmnNodeId + }) + + overlayIds.push( + overlays.add(step.bpmnNodeId, 'step-status', { + position: { top: -12, right: -12 }, + html: badge, + }), + ) +} + +function statusClass(status: StepStatus): string { + return statusMeta[status]?.className ?? 'pending' +} + +function isSelectedStep(step: PipelineStep): boolean { + return selectedStep.value?.bpmnNodeId === step.bpmnNodeId +} + watch( - () => [props.xml, props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|')], + () => props.xml, async () => { await nextTick() await renderDiagram() @@ -100,41 +275,100 @@ watch( }, ) +watch( + () => props.steps.map((step) => `${step.bpmnNodeId}:${step.status}`).join('|'), + () => { + applyMarkers() + }, +) + +watch(selectedNodeId, () => { + applyMarkers() +}) + onBeforeUnmount(() => { + clearOverlays() viewer.value?.destroy() + viewer.value = undefined + eventBusBound = false }) diff --git a/src/services/devopsApi.ts b/src/services/devopsApi.ts index 62e4c00..9594728 100644 --- a/src/services/devopsApi.ts +++ b/src/services/devopsApi.ts @@ -13,6 +13,7 @@ import type { IntegrationConfigKey, IntegrationConfigState, IntegrationConfigStatus, + IntegrationVariableHelp, IntegrationStatus, JenkinsSyncSummary, PipelineStep, @@ -28,6 +29,13 @@ import { http } from './http' type DataSource = 'backend' type BackendHealthStatus = 'ok' | 'not_configured' | 'unavailable' type BackendRunStatus = 'pending' | 'queued' | 'running' | 'success' | 'failed' | 'canceled' +type BackendVariableHelpValue = + | string + | { + description?: unknown + example?: unknown + docUrl?: unknown + } interface BackendHealth { status: BackendHealthStatus @@ -99,6 +107,31 @@ interface BackendProcessDefinition { nodeIds: string[] } +interface BackendIntegrationConfigItem { + key: IntegrationConfigKey + name: string + status: IntegrationConfigState + required?: string[] + configured?: string[] + missing?: string[] + optional?: string[] + note?: string + variableHelp?: Record + variableDescriptions?: Record + variables?: Array<{ + name?: unknown + key?: unknown + description?: unknown + example?: unknown + docUrl?: unknown + }> +} + +interface BackendIntegrationConfigStatus { + checkedAt?: string + integrations?: BackendIntegrationConfigItem[] +} + let lastDataSource: DataSource = 'backend' const integrationConfigKeys: IntegrationConfigKey[] = [ 'jenkins', @@ -283,6 +316,14 @@ export const devopsApi = { return fetchIntegrationConfigStatus() }, + async getReleaseProcessXml(): Promise { + const { data } = await http.get( + '/process-definitions/release', + ) + lastDataSource = 'backend' + return data.xml + }, + async createAgentInvocation( payload: CreateAgentInvocationPayload, ): Promise { @@ -300,13 +341,6 @@ export const devopsApi = { return toSystemSettings(health, integrationConfig) }, - async getReleaseProcessXml(): Promise { - const { data } = await http.get( - '/process-definitions/release', - ) - lastDataSource = 'backend' - return data.xml - }, } async function fetchHealth(): Promise { @@ -325,7 +359,9 @@ async function fetchRuns(): Promise { } async function fetchIntegrationConfigStatus(): Promise { - const { data } = await http.get('/settings/integration-config') + const { data } = await http.get( + '/settings/integration-config', + ) return sanitizeIntegrationConfigStatus(data) } @@ -358,7 +394,7 @@ function toIntegrationStatus( } function sanitizeIntegrationConfigStatus( - payload: IntegrationConfigStatus, + payload: BackendIntegrationConfigStatus, ): IntegrationConfigStatus { const integrations = Array.isArray(payload?.integrations) ? payload.integrations : [] @@ -373,6 +409,8 @@ function sanitizeIntegrationConfigStatus( const required = sanitizeVariableNames(item.required) const configured = sanitizeVariableNames(item.configured) const missing = sanitizeVariableNames(item.missing) + const optional = sanitizeVariableNames(item.optional) + const variableHelp = sanitizeVariableHelp(item) return { key: item.key, @@ -381,8 +419,9 @@ function sanitizeIntegrationConfigStatus( required, configured, missing, - optional: sanitizeVariableNames(item.optional), + optional, note: sanitizeIntegrationNote(item.note), + ...(Object.keys(variableHelp).length > 0 ? { variableHelp } : {}), } }), } @@ -423,6 +462,137 @@ function sanitizeIntegrationNote(note?: string): string | undefined { .replace(/key=[^&\s]+/g, 'key=[已隐藏]') } +function sanitizeVariableHelp( + item: BackendIntegrationConfigItem, +): Record { + const help: Record = {} + + collectVariableHelpMap(help, item.variableHelp) + collectVariableDescriptionMap(help, item.variableDescriptions) + collectVariableHelpArray(help, item.variables) + + return help +} + +function collectVariableHelpMap( + target: Record, + value?: Record, +) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return + } + + Object.entries(value).forEach(([rawName, rawHelp]) => { + const name = sanitizeVariableName(rawName) + if (!name) { + return + } + + const help = + typeof rawHelp === 'string' + ? sanitizeVariableHelpValue({ description: rawHelp }) + : sanitizeVariableHelpValue(rawHelp) + + if (help) { + target[name] = help + } + }) +} + +function collectVariableDescriptionMap( + target: Record, + value?: Record, +) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return + } + + Object.entries(value).forEach(([rawName, rawDescription]) => { + const name = sanitizeVariableName(rawName) + const description = sanitizeVariableDescription(rawDescription) + + if (name && description) { + target[name] = { description } + } + }) +} + +function collectVariableHelpArray( + target: Record, + value?: BackendIntegrationConfigItem['variables'], +) { + if (!Array.isArray(value)) { + return + } + + value.forEach((item) => { + const name = sanitizeVariableName(item.name ?? item.key) + const help = sanitizeVariableHelpValue(item) + + if (name && help) { + target[name] = help + } + }) +} + +function sanitizeVariableHelpValue( + value?: { + description?: unknown + example?: unknown + docUrl?: unknown + }, +): IntegrationVariableHelp | undefined { + const description = sanitizeVariableDescription(value?.description) + + if (!description) { + return undefined + } + + return { + description, + ...(typeof value?.example === 'string' && value.example.trim() + ? { example: sanitizeIntegrationNote(value.example.trim()) ?? value.example.trim() } + : {}), + ...(typeof value?.docUrl === 'string' && isSafeDocUrl(value.docUrl) + ? { docUrl: value.docUrl.trim() } + : {}), + } +} + +function sanitizeVariableDescription(value: unknown): string | undefined { + if (typeof value !== 'string' || !value.trim()) { + return undefined + } + + return sanitizeIntegrationNote(value.trim()) +} + +function sanitizeVariableName(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const name = value.trim() + return /^[A-Z][A-Z0-9_]*$/.test(name) ? name : undefined +} + +function isSafeDocUrl(value: string): boolean { + try { + const url = new URL(value) + const normalized = url.toString().toLowerCase() + + return ( + url.protocol === 'https:' && + !url.searchParams.has('key') && + !normalized.includes('/cgi-bin/webhook/') && + !normalized.includes('/open-apis/bot/') && + !normalized.includes('/webhook/') + ) + } catch { + return false + } +} + function safeText(value: unknown, fallback: string): string { return typeof value === 'string' && value.trim() ? value : fallback } diff --git a/src/types/devops.ts b/src/types/devops.ts index bee58ec..227edfd 100644 --- a/src/types/devops.ts +++ b/src/types/devops.ts @@ -43,6 +43,12 @@ export type IntegrationConfigKey = export type IntegrationConfigState = 'configured' | 'partial' | 'missing' +export interface IntegrationVariableHelp { + description: string + example?: string + docUrl?: string +} + /** * 后端安全配置检查结果;数组字段只承载变量名,不承载任何密钥值或 URL 原文。 */ @@ -55,6 +61,7 @@ export interface IntegrationConfigItem { missing: string[] optional?: string[] note?: string + variableHelp?: Record } /** diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 18b1c33..856a7e3 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -1,11 +1,23 @@ @@ -72,7 +136,7 @@ function auditDigest(record: AuditLogEntry): string { @@ -102,57 +166,45 @@ function auditDigest(record: AuditLogEntry): string {
-
- 必需变量 +
+ {{ group.label }}
- - {{ name }} - -
-
-
- 已识别 -
- - {{ name }} - - 暂无 -
-
-
- 缺失 -
- - {{ name }} - - -
-
-
- 可选变量 -
- - {{ name }} - - 暂无 + + + {{ name }} + + + + {{ group.emptyText }} +
@@ -205,9 +257,9 @@ function auditDigest(record: AuditLogEntry): string { {{ auditActor(row) }} - + @@ -334,23 +386,45 @@ function auditDigest(record: AuditLogEntry): string { min-height: 24px; } +.variable-tag { + cursor: help; +} + +.variable-tooltip { + max-width: 320px; +} + +.variable-tooltip strong, +.variable-tooltip small, +.variable-tooltip a { + display: block; +} + +.variable-tooltip p { + margin: 6px 0; + color: #4b5565; + font-size: 12px; + line-height: 1.6; +} + +.variable-tooltip small { + margin-top: 4px; + color: #7b8798; + font-size: 12px; +} + +.variable-tooltip a { + margin-top: 6px; + color: #1d5fd0; + font-size: 12px; +} + .empty-value { color: #9aa4b2; font-size: 12px; line-height: 24px; } -.audit-digest { - display: block; - max-width: 100%; - overflow: hidden; - color: #4b5565; - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - @media (max-width: 1080px) { .variable-grid { grid-template-columns: repeat(2, minmax(0, 1fr));