0.10.1: 安全与业务逻辑加固、新品牌与部署加固

三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
This commit is contained in:
2026-08-13 10:50:51 +08:00
parent b536672000
commit 9501751792
136 changed files with 8024 additions and 1476 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+69
View File
@@ -0,0 +1,69 @@
import request from '@/utils/http'
export interface AgentNode {
id: string
code: string
name: string
description: string
endpoint: string
node_type: 'worker' | 'gateway' | 'executor'
pool_type: 'public' | 'private'
pool_code: string
enabled: boolean
status: 'pending' | 'online' | 'offline' | 'disabled'
token_prefix: string
version: string
capabilities: Record<string, unknown>
metadata: Record<string, unknown>
last_heartbeat_at?: string
last_heartbeat_ip?: string
last_error: string
created_at: string
updated_at: string
}
export type AgentNodeInput = Pick<AgentNode, 'name' | 'description' | 'endpoint' | 'node_type' | 'pool_type' | 'pool_code' | 'enabled'> & { code?: string }
export interface AgentNodeTokenResponse { node: AgentNode; token: string; warning: string }
export interface AgentNodeRoutePreviewInput {
pool_type: AgentNode['pool_type']
pool_code: string
required_capabilities: string[]
request_key: string
}
export interface AgentNodeRoutePreview {
pool_type: AgentNode['pool_type']
pool_code: string
required_capabilities: string[]
request_key: string
selection_policy: string
reason: 'selected_online_node' | 'no_online_node' | 'no_capable_node'
selected: AgentNode | null
candidates: AgentNode[]
}
export function fetchAgentNodes() {
return request.get<AgentNode[]>({ url: '/api/v1/admin/agent-nodes' })
}
export function createAgentNode(data: AgentNodeInput) {
return request.post<AgentNodeTokenResponse>({ url: '/api/v1/admin/agent-nodes', params: data })
}
export function updateAgentNode(id: string, data: AgentNodeInput) {
return request.put<AgentNode>({ url: `/api/v1/admin/agent-nodes/${id}`, params: data })
}
export function deleteAgentNode(id: string) {
return request.del<{ deleted: boolean }>({ url: `/api/v1/admin/agent-nodes/${id}` })
}
export function rotateAgentNodeToken(id: string) {
return request.post<AgentNodeTokenResponse>({ url: `/api/v1/admin/agent-nodes/${id}/rotate-token` })
}
export function previewAgentNodeRoute(data: AgentNodeRoutePreviewInput) {
return request.post<AgentNodeRoutePreview>({ url: '/api/v1/admin/agent-nodes/route-preview', params: data })
}
+21
View File
@@ -0,0 +1,21 @@
import request from '@/utils/http'
export interface AgentSession {
id: string
trace_type: 'application' | 'digital_employee'
target_code: string
trace_count: number
latest_trace_id: string
latest_status: 'running' | 'success' | 'error'
started_at: string
updated_at: string
retrieval_count: number
model_call_count: number
tool_call_count: number
}
export interface AgentSessionPage { items: AgentSession[] }
export function fetchAgentSessions(params: Record<string, string | number | undefined>) {
return request.get<AgentSessionPage>({ url: '/api/v1/admin/agent-sessions', params })
}
+34
View File
@@ -0,0 +1,34 @@
import request from '@/utils/http'
export interface InboxMessage {
id: string
recipient_kind: string
recipient_user_id: string
sender_type: string
category: string
title: string
body: string
link: string
payload?: Record<string, unknown>
read_at?: string
created_at: string
}
export interface BroadcastInput {
recipient_kind: 'admin' | 'portal'
department_ids?: string[]
category: string
title: string
body: string
link?: string
}
export const fetchInbox = (scope = 'mine') =>
request.get<InboxMessage[]>({ url: '/api/v1/admin/inbox', params: { scope } })
export const fetchInboxUnread = () => request.get<{ unread: number }>({ url: '/api/v1/admin/inbox/unread' })
export const markInboxRead = (id: string) =>
request.post<{ read: boolean }>({ url: `/api/v1/admin/inbox/${id}/read` })
export const markInboxAllRead = () =>
request.post<{ read_all: number }>({ url: '/api/v1/admin/inbox/read-all' })
export const broadcastInbox = (input: BroadcastInput) =>
request.post<{ sent: number; ok: boolean }>({ url: '/api/v1/admin/inbox/broadcast', data: input })
+6
View File
@@ -7,6 +7,8 @@ export interface ProviderRecord {
base_url: string
api_key_masked: string
key_configured: boolean
/** 凭据解密失败(KEK 不匹配/数据损坏)时的警示信息;为空表示正常 */
credential_error: string
capabilities: string[]
config: Record<string, unknown>
enabled: boolean
@@ -68,6 +70,10 @@ export function updateProvider(id: string, data: ProviderInput) {
return request.put<ProviderRecord>({ url: `/api/v1/admin/providers/${id}`, params: data })
}
export function deleteProvider(id: string) {
return request.del<{ deleted: boolean }>({ url: `/api/v1/admin/providers/${id}` })
}
export function testProviderConnection(id: string) {
return request.post<ProviderConnectionResult>({
url: `/api/v1/admin/providers/${id}/test`
+31
View File
@@ -0,0 +1,31 @@
import request from '@/utils/http'
export interface ScheduledTask {
id: string; code: string; name: string; description: string
cron_expression: string; timezone: string; target_type: 'application' | 'digital_employee'; target_code: string
prompt: string; variables: Record<string, unknown>; skill_ids: string[]; mcp_server_ids: string[]
conversation_id: string; notification_channel_id?: string; has_api_key: boolean; enabled: boolean
next_run_at?: string; last_run_at?: string; last_status: string; last_error: string; revision: number
}
export interface ScheduledTaskInput {
code: string; name: string; description: string; cron_expression: string; timezone: string
target_type: 'application' | 'digital_employee'; target_code: string; prompt: string
variables: Record<string, unknown>; skill_ids: string[]; mcp_server_ids: string[]
conversation_id: string; notification_channel_id?: string | null; api_key?: string; enabled: boolean
}
export interface ScheduledTaskRun {
id: string; task_id: string; task_code: string; trigger_type: string; scheduled_for: string
status: string; attempts: number; worker_id: string; started_at?: string; finished_at?: string
response?: Record<string, unknown>; error: string; created_at: string
}
export const fetchScheduledTasks = () => request.get<ScheduledTask[]>({ url: '/api/v1/admin/scheduled-tasks' })
export const createScheduledTask = (data: ScheduledTaskInput) => request.post<ScheduledTask>({ url: '/api/v1/admin/scheduled-tasks', data })
export const updateScheduledTask = (id: string, data: ScheduledTaskInput) => request.put<ScheduledTask>({ url: `/api/v1/admin/scheduled-tasks/${id}`, data })
export const deleteScheduledTask = (id: string) => request.del({ url: `/api/v1/admin/scheduled-tasks/${id}` })
export const startScheduledTask = (id: string) => request.post<ScheduledTask>({ url: `/api/v1/admin/scheduled-tasks/${id}/start` })
export const pauseScheduledTask = (id: string) => request.post<ScheduledTask>({ url: `/api/v1/admin/scheduled-tasks/${id}/pause` })
export const runScheduledTask = (id: string) => request.post<ScheduledTaskRun>({ url: `/api/v1/admin/scheduled-tasks/${id}/run` })
export const fetchScheduledTaskRuns = (id: string) => request.get<ScheduledTaskRun[]>({ url: `/api/v1/admin/scheduled-tasks/${id}/runs` })
+51
View File
@@ -0,0 +1,51 @@
import request from '@/utils/http'
export interface TraceSpan {
id: string
trace_id: string
parent_id?: string
span_type: 'model' | 'tool' | 'retrieval'
name: string
status: 'running' | 'success' | 'error'
started_at: string
finished_at?: string
latency_ms?: number
provider_code?: string
model?: string
input_tokens: number
output_tokens: number
round: number
error: string
metadata: Record<string, unknown>
}
export interface Trace {
id: string
request_id: string
api_key_id?: string
tenant_id?: string
trace_type: 'application' | 'digital_employee'
target_id?: string
target_code: string
conversation_id: string
status: 'running' | 'success' | 'error'
started_at: string
finished_at?: string
latency_ms?: number
retrieval_count: number
model_call_count: number
tool_call_count: number
error: string
metadata: Record<string, unknown>
spans?: TraceSpan[]
}
export interface TracePage { items: Trace[] }
export function fetchTraces(params: Record<string, string | number | undefined>) {
return request.get<TracePage>({ url: '/api/v1/admin/traces', params })
}
export function fetchTrace(id: string) {
return request.get<Trace>({ url: `/api/v1/admin/traces/${id}` })
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0B2E6E"/>
<stop offset="1" stop-color="#071F4D"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00E4E5"/>
<stop offset="1" stop-color="#006EFF"/>
</linearGradient>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<!-- 网关路由连接线 -->
<g stroke="url(#accent)" stroke-width="20" stroke-linecap="round">
<line x1="256" y1="256" x2="158" y2="158"/>
<line x1="256" y1="256" x2="354" y2="158"/>
<line x1="256" y1="256" x2="256" y2="372"/>
</g>
<!-- 分支节点 -->
<g fill="#0E3A8C" stroke="url(#accent)" stroke-width="9">
<circle cx="158" cy="158" r="38"/>
<circle cx="354" cy="158" r="38"/>
<circle cx="256" cy="372" r="38"/>
</g>
<!-- 中心枢纽 -->
<circle cx="256" cy="256" r="70" fill="url(#accent)"/>
<circle cx="256" cy="256" r="70" fill="none" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="2"/>
<!-- 中心"语枢"标记:字母 A 与枢纽点结合 -->
<path d="M256 210 L220 304 H241 L256 268 L271 304 H292 L256 210 Z" fill="#FFFFFF"/>
<circle cx="256" cy="256" r="7" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 33 KiB

@@ -1 +1,25 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="44" y="42" width="312" height="217"><path d="M355.3 42H44v216.9h311.3V42Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M288.2 248.4h25.1v-30h-25.1v30Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.498 238.199c-1.5-3.9-5.9-15.4-4-21.6-2.9.8-3.3.1-5-.1-1.7-.1 0 10.7 2.2 16.4 1.7 4.5 2.1 11.1 2.1 13.6h5.4c.2-1.9.3-5.5-.7-8.3Z" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6M290.2 214.7h21.4c1 0 1.8.8 1.8 1.8v29" stroke="#071F4D" stroke-width="1.096"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" fill="#fff"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" stroke="#071F4D" stroke-width="1.096"/><path d="M295.402 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3M300.502 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m331 258.4-.3-5.2H88.5l-1.2 5.2H331Z" fill="#C7DEFF"/><path d="M252.9 248.7H331M216.6 258.4H331M47.1 139.3l-2.6 1.5 42.7 117.6h129.2v-6.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" fill="#fff"/><path d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" stroke="#071F4D"/><path d="m203.2 153.2 32.2 88.7H97.8l-32.3-88.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M72.2 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4ZM79.3 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M263.5 171.2h80.3v-63.7h-80.3v63.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M290 143.9h-45.6l12.5 51.3H290v-51.3Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M286 117.4h-29.3v77.8h92.9v-67.6l-55.9.6-7.7-10.8Z" fill="#00E4E5"/><path d="m332.6 127.6-38.9.6-7.7-10.8h-11.7M308.9 195.2h45.9M250.3 195.2h28.5M287.3 195.2h12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 211.4H186v-44h-55.5v44Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M148.7 192.5h-31.6l8.7 35.5h22.9v-35.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M145.9 174.2h-20.2V228h64.1v-46.7l-38.6.4-5.3-7.5Z" fill="#006EFF"/><path d="m179 181.3-27.8.4-5.3-7.5h-7.7M176.2 201.7h19.2M163.2 210.7H195M172.1 228h-54.2M184.8 228h8.1M174.9 228h5.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m293.2 155.7-6.4 6.3 15.3 15.3 22.7-22.6-6.4-6.4-16.3 16.3-8.9-8.9Z" fill="#fff"/><path d="M57.2 258.4h283.6M345.9 258.4h8.1M55.4 258.4h220.5M160.1 118.8l-1.2 2.7M156.7 127c-.3.8-.7 1.8-1.1 2.8M222 68.5c-1 .2-1.9.5-2.9.8M214.1 70.7c-5.8 1.9-11.3 4.4-16.5 7.4M195.4 79.5c-.9.5-1.7 1.1-2.5 1.6M314.2 98.5c-.6-.8-1.3-1.5-2-2.3M308.9 92.8c-4-4-8.3-7.6-13-10.8M293.9 80.7c-.8-.5-1.7-1.1-2.5-1.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.296 71.203c-3.6-1.5-18.5-2.9-21.8-1.9-1 5.8 4.9 13.5 4.9 13.5s6-9.9 16.9-11.6Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.3 42.704c-6.5 6.7-7.8 13-8.8 19.3 24.4-1.1 36.3 13 42.8 20 3.2-9.1 7.8-23 7.2-29-7.1-6.4-20-11.7-41.2-10.3Z" fill="#C7DEFF"/><path d="M230 69.3c36.2-3.8 52 21.1 52 21.1s11.4-28.2 10.5-37.4c-7.3-6.5-23.3-12-45.6-10.1-9 6.3-15.6 18.7-16.9 26.4Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.604 70.7c-6 8.4-9.9 21.9-8.8 33.8 8.4 5.3 32.3 10.5 43.6 11.5 6.1-7.9 15.9-26 15.9-26s-32-4.8-50.7-19.3Z" fill="#C7DEFF"/><path d="M193.103 119.5c4.8-2.7 19.2-29.5 19.2-29.5s-35.8-5.4-53.7-21.8c-9.3 6.1-16.4 24.3-15 40.1 10.6 6.7 45.8 13.3 49.5 11.2Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M189.5 111.6c-3 5.2-5.7 7.2-9.8 6.6 12.2 2.6 13.5 1.2 15.6-1.1 2.2-2.4 4.2-6.6 4.2-6.6s-3.1 2.5-10 1.1Z" fill="#071F4D"/><path d="M331 251.8v6.6M77 165.4l-2.7-6.7h7.8M222.8 228.9l2.8 6.6h-7.9" stroke="#071F4D"/></g></svg>
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0B2E6E"/>
<stop offset="1" stop-color="#071F4D"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00E4E5"/>
<stop offset="1" stop-color="#006EFF"/>
</linearGradient>
</defs>
<rect x="60" y="30" width="280" height="240" rx="56" fill="url(#bg)"/>
<g stroke="url(#accent)" stroke-width="12" stroke-linecap="round">
<line x1="200" y1="150" x2="140" y2="90"/>
<line x1="200" y1="150" x2="260" y2="90"/>
<line x1="200" y1="150" x2="200" y2="220"/>
</g>
<g fill="#0E3A8C" stroke="url(#accent)" stroke-width="5">
<circle cx="140" cy="90" r="22"/>
<circle cx="260" cy="90" r="22"/>
<circle cx="200" cy="220" r="22"/>
</g>
<circle cx="200" cy="150" r="42" fill="url(#accent)"/>
<path d="M200 122 L178 180 H191 L200 158 L209 180 H222 L200 122 Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,7 +1,7 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
<img :style="logoStyle" src="@imgs/common/logo.svg" alt="logo" class="w-full h-full" />
</div>
</template>
@@ -3,11 +3,6 @@
<div
class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between"
>
<div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
<ArtLogo class="icon" size="46" />
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.shortName }}</h1>
</div>
<div class="flex-cc gap-1.5 mr-2 max-sm:mr-5">
<div class="color-picker-expandable relative flex-c max-sm:!hidden">
<div
@@ -27,7 +22,7 @@
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
<ArtSvgIcon
icon="ri:palette-line"
class="text-xl text-[#d3e8ff] transition-colors duration-300"
class="text-xl text-[#45636f] transition-colors duration-300"
/>
</div>
</div>
@@ -39,7 +34,7 @@
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
<ArtSvgIcon
icon="ri:translate-2"
class="text-[19px] text-[#d3e8ff] transition-colors duration-300"
class="text-[19px] text-[#45636f] transition-colors duration-300"
/>
</div>
<template #dropdown>
@@ -63,7 +58,7 @@
>
<ArtSvgIcon
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
class="text-xl text-[#d3e8ff] transition-colors duration-300"
class="text-xl text-[#45636f] transition-colors duration-300"
/>
</div>
</div>
@@ -105,9 +100,9 @@
</script>
<style scoped>
/* 深色 HUD 登录页:工具栏按钮玻璃底 + 青色描边 */
/* 深浅分屏登录页:工具栏位于浅色侧,使用低对比描边 */
h1 {
color: #eaf6ff;
color: #12384a;
font-weight: 600;
letter-spacing: 0.02em;
}
@@ -115,15 +110,15 @@
.palette-btn,
.language-btn,
.theme-btn {
background: rgb(7 17 38 / 55%);
border: 1px solid rgb(0 229 255 / 25%);
background: rgb(255 255 255 / 75%);
border: 1px solid #d6e4e9;
border-radius: 8px;
box-shadow: 0 0 12px rgb(0 229 255 / 8%);
box-shadow: 0 5px 18px rgb(35 85 100 / 7%);
transition: all 0.25s;
&:hover {
border-color: #00e5ff;
box-shadow: 0 0 16px rgb(0 229 255 / 40%);
border-color: #52a9b9;
box-shadow: 0 6px 18px rgb(35 120 140 / 13%);
}
}
@@ -1,453 +1,286 @@
<!-- 登录注册忘记密码左侧背景色安全 HUD -->
<!-- 授权页左侧品牌区海军蓝安全控制台风格 -->
<template>
<div class="login-left-view">
<!-- HUD 背景层深藏青渐变 + 网格 + 辉光 -->
<section class="login-left-view" aria-label="LLMGuardX 品牌介绍">
<div class="hud-bg"></div>
<div class="hud-grid"></div>
<div class="hud-glow glow-a"></div>
<div class="hud-glow glow-b"></div>
<div class="hud-scan"></div>
<span class="frame-line frame-top"></span>
<span class="frame-line frame-left"></span>
<span class="frame-line frame-right"></span>
<span class="frame-line frame-bottom"></span>
<!-- HUD 四角括线 -->
<span class="hud-corner corner-tl"></span>
<span class="hud-corner corner-tr"></span>
<span class="hud-corner corner-bl"></span>
<span class="hud-corner corner-br"></span>
<!-- 顶栏Logo + 主题切换 -->
<div class="top-row">
<div class="logo">
<ArtLogo class="icon" size="46" />
<h1 class="title">{{ AppConfig.systemInfo.shortName }}</h1>
</div>
<button type="button" class="theme-toggle" :title="$t('login.toggleTheme')" @click="themeAnimation">
<span class="tt-orb"></span>
</button>
<div class="eyebrow">
<span class="eyebrow-dot"></span>
<span>LLM SECURITY &amp; GOVERNANCE</span>
</div>
<!-- 中央雷达插画 -->
<div class="radar-wrap">
<span class="radar-ring ring-1"></span>
<span class="radar-ring ring-2"></span>
<span class="radar-sweep"></span>
<div class="radar-core">
<ThemeSvg :src="loginIcon" size="100%" />
</div>
</div>
<!-- 文案 -->
<div class="text-wrap">
<h1>{{ $t('login.leftView.title') }}</h1>
<h1>
<span>{{ $t('login.leftView.headlineMain') }}</span>
<strong>{{ $t('login.leftView.headlineAccent') }}</strong>
</h1>
<p>{{ $t('login.leftView.subTitle') }}</p>
</div>
<!-- 底部 HUD tagline -->
<div class="hud-tagline">
<span class="tag-dot"></span>
<span class="tag-text">SECURE LLM GATEWAY</span>
<i class="tag-sep">//</i>
<span class="tag-text">{{ $t('login.tagline') }}</span>
<div class="radar-wrap" aria-hidden="true">
<span class="radar-ring ring-1"></span>
<span class="radar-ring ring-2"></span>
<span class="radar-ring ring-3"></span>
<span class="radar-sweep"></span>
<span class="signal signal-a"></span>
<span class="signal signal-b"></span>
<span class="signal signal-c"></span>
<div class="radar-core"><ArtLogo size="48" /></div>
</div>
<!-- 数据粒子 -->
<span v-for="n in 10" :key="n" class="particle" :style="{ '--i': n }"></span>
</div>
<div class="hud-tagline">
<span class="tag-dot"></span>
<span>SECURE LLM GATEWAY</span>
<i>//</i>
<span>{{ $t('login.tagline') }}</span>
</div>
</section>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import loginIcon from '@imgs/svg/login_icon.svg'
import { themeAnimation } from '@/utils/ui/animation'
// 定义 props
defineProps<{
hideContent?: boolean // 是否隐藏内容,只显示 logo
}>()
defineProps<{ hideContent?: boolean }>()
</script>
<style lang="scss" scoped>
// 语枢网关 HUD 色板(登录页恒定深色,不随系统主题变化)
$cyber-bg: #050b18;
$cyber-accent: #00e5ff;
$cyber-blue: #2f80ff;
$cyber-green: #00ff9c;
$cyber-text: #eaf6ff;
$accent: #42dce8;
$green: #19d8bd;
.login-left-view {
position: relative;
box-sizing: border-box;
width: 65vw;
width: 54.5vw;
height: 100%;
padding: 15px;
min-height: 620px;
overflow: hidden;
color: $cyber-text;
background-color: $cyber-bg;
color: #ecfaff;
background: #041a29;
}
// 深藏青渐变底
.hud-bg {
position: absolute;
inset: 0;
background:
radial-gradient(1100px 640px at 78% -10%, rgb(0 229 255 / 14%), transparent 60%),
radial-gradient(900px 560px at -8% 108%, rgb(47 128 255 / 16%), transparent 55%),
radial-gradient(560px 380px at 86% 96%, rgb(0 255 156 / 6%), transparent 60%),
linear-gradient(160deg, #071226 0%, #050b18 55%, #040810 100%);
}
.hud-bg,
.hud-grid,
.hud-glow,
.frame-line {
position: absolute;
pointer-events: none;
}
// 极细暗网格
.hud-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgb(0 229 255 / 5%) 1px, transparent 1px),
linear-gradient(90deg, rgb(0 229 255 / 5%) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(ellipse at center, rgb(0 0 0 / 85%), transparent 78%);
}
.hud-bg {
inset: 0;
background:
radial-gradient(620px 500px at 50% 30%, rgb(20 132 162 / 12%), transparent 72%),
linear-gradient(135deg, #062438 0%, #031927 58%, #031420 100%);
}
// 青色辉光光斑
.hud-glow {
position: absolute;
border-radius: 50%;
filter: blur(70px);
pointer-events: none;
.hud-grid {
inset: 0;
opacity: 0.42;
background-image:
linear-gradient(rgb(71 205 221 / 7%) 1px, transparent 1px),
linear-gradient(90deg, rgb(71 205 221 / 7%) 1px, transparent 1px);
background-size: 66px 66px;
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
&.glow-a {
top: 6%;
right: -80px;
width: 360px;
height: 360px;
background: rgb(0 229 255 / 16%);
}
.hud-glow {
border-radius: 50%;
filter: blur(86px);
}
&.glow-b {
bottom: -140px;
left: 12%;
width: 420px;
height: 420px;
background: rgb(47 128 255 / 18%);
}
}
.glow-a {
top: 12%;
right: 6%;
width: 360px;
height: 360px;
background: rgb(31 201 218 / 8%);
}
// 垂直扫描线
.hud-scan {
position: absolute;
inset: -40% 0;
background: linear-gradient(180deg, transparent 0%, rgb(0 229 255 / 6%) 50%, transparent 100%);
pointer-events: none;
animation: scanMove 7s linear infinite;
}
.glow-b {
bottom: -12%;
left: 18%;
width: 420px;
height: 420px;
background: rgb(22 119 170 / 11%);
}
// HUD 四角括线
.hud-corner {
position: absolute;
z-index: 4;
width: 22px;
height: 22px;
border: 0 solid rgb(0 229 255 / 70%);
pointer-events: none;
.frame-line {
z-index: 2;
background: rgb(68 202 219 / 12%);
}
&.corner-tl {
top: 16px;
left: 16px;
border-top-width: 2px;
border-left-width: 2px;
}
.frame-top,
.frame-bottom {
right: 7%;
left: 6.5%;
height: 1px;
}
&.corner-tr {
top: 16px;
right: 16px;
border-top-width: 2px;
border-right-width: 2px;
}
.frame-left,
.frame-right {
top: 7%;
bottom: 6.5%;
width: 1px;
}
&.corner-bl {
bottom: 16px;
left: 16px;
border-bottom-width: 2px;
border-left-width: 2px;
}
.frame-top { top: 7%; }
.frame-bottom { bottom: 6.5%; }
.frame-left { left: 6.5%; }
.frame-right { right: 7%; }
&.corner-br {
right: 16px;
bottom: 16px;
border-right-width: 2px;
border-bottom-width: 2px;
}
}
.eyebrow {
position: absolute;
top: 15.5%;
left: 21%;
z-index: 3;
display: flex;
align-items: center;
gap: 12px;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
color: $accent;
}
// 顶栏
.top-row {
position: relative;
z-index: 100;
.eyebrow-dot,
.tag-dot,
.signal {
display: block;
width: 6px;
height: 6px;
background: $accent;
border-radius: 50%;
box-shadow: 0 0 10px $accent;
}
.text-wrap {
position: absolute;
top: 21%;
left: 21%;
z-index: 3;
max-width: 610px;
animation: reveal 0.65s ease-out both;
h1 {
display: flex;
align-items: center;
justify-content: space-between;
.logo {
display: flex;
align-items: center;
.title {
margin-left: 10px;
font-size: 20px;
font-weight: 400;
letter-spacing: 1px;
color: $cyber-text;
text-shadow: 0 0 16px rgb(0 229 255 / 30%);
}
}
// HUD 主题切换按钮(圆形仪表风格)
.theme-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
cursor: pointer;
background: rgb(10 25 48 / 60%);
border: 1px solid rgb(0 229 255 / 35%);
border-radius: 50%;
transition: all 0.25s;
.tt-orb {
width: 10px;
height: 10px;
background: $cyber-accent;
border-radius: 50%;
box-shadow: 0 0 12px $cyber-accent;
}
&:hover {
border-color: $cyber-accent;
box-shadow: 0 0 16px rgb(0 229 255 / 40%);
}
}
flex-direction: column;
margin: 0;
font-size: clamp(42px, 3.55vw, 68px);
font-weight: 760;
line-height: 1.14;
letter-spacing: -0.04em;
color: #effcff;
}
// 中央雷达插画
.radar-wrap {
position: absolute;
inset: 0 0 12%;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 320px;
height: 320px;
margin: auto;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
.radar-core {
position: relative;
z-index: 2;
width: 74%;
height: 74%;
filter: drop-shadow(0 0 24px rgb(0 229 255 / 35%));
animation: floaty 6s ease-in-out infinite;
}
.radar-ring {
position: absolute;
border-radius: 50%;
border: 1px solid rgb(0 229 255 / 22%);
&.ring-1 {
inset: 6%;
}
&.ring-2 {
inset: 16%;
border-style: dashed;
animation: spin 26s linear infinite;
}
}
.radar-sweep {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from 0deg, rgb(0 229 255 / 22%), transparent 22%);
mask-image: radial-gradient(circle, rgb(0 0 0 / 85%), transparent 72%);
animation: spin 5s linear infinite;
}
strong {
font-weight: inherit;
color: $accent;
text-shadow: 0 0 34px rgb(66 220 232 / 13%);
}
// 文案
.text-wrap {
position: absolute;
bottom: 92px;
z-index: 3;
width: 100%;
text-align: center;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
h1 {
font-size: 24px;
font-weight: 600;
letter-spacing: 2px;
color: $cyber-text;
text-shadow: 0 0 18px rgb(0 229 255 / 40%);
}
p {
margin-top: 12px;
font-size: 14px;
letter-spacing: 1px;
color: rgb(0 229 255 / 78%);
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
}
}
// 底部 HUD tagline
.hud-tagline {
position: absolute;
bottom: 26px;
left: 50%;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
letter-spacing: 1px;
color: rgb(0 229 255 / 85%);
background: rgb(0 229 255 / 4%);
border: 1px solid rgb(0 229 255 / 20%);
border-radius: 4px;
box-shadow: 0 0 12px rgb(0 229 255 / 10%);
transform: translateX(-50%);
.tag-dot {
width: 6px;
height: 6px;
background: $cyber-green;
border-radius: 50%;
box-shadow: 0 0 8px $cyber-green;
animation: blink 2s ease-in-out infinite;
}
.tag-sep {
font-style: normal;
color: rgb(0 229 255 / 45%);
}
}
// 数据粒子
.particle {
position: absolute;
bottom: -10px;
left: calc(var(--i) * 9.1% + 2%);
z-index: 2;
width: 3px;
height: 3px;
background: rgb(0 229 255 / 80%);
border-radius: 50%;
box-shadow: 0 0 6px $cyber-accent;
opacity: 0;
animation: floatUp 8s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.8s);
}
@media only screen and (width <= 1600px) {
width: 60vw;
.text-wrap {
bottom: 64px;
}
.radar-wrap {
inset: 0 0 10%;
}
}
@media only screen and (width <= 1180px) {
width: auto;
height: auto;
padding: 0;
background: transparent;
.top-row,
.radar-wrap,
.text-wrap,
.hud-tagline,
.particle,
.hud-bg,
.hud-grid,
.hud-glow,
.hud-scan,
.hud-corner {
display: none;
}
p {
max-width: 570px;
margin-top: 24px;
font-size: 15px;
line-height: 2;
letter-spacing: 0.04em;
color: rgb(153 205 224 / 82%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
.radar-wrap {
position: absolute;
bottom: 8.5%;
left: 36%;
z-index: 3;
width: 280px;
height: 280px;
}
@keyframes scanMove {
0% {
transform: translateY(-50%);
}
100% {
transform: translateY(50%);
}
.radar-ring,
.radar-sweep,
.radar-core,
.signal {
position: absolute;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
.radar-ring {
border: 1px solid rgb(70 207 222 / 11%);
border-radius: 50%;
}
@keyframes floaty {
0%,
100% {
transform: translateY(0);
}
.ring-1 { inset: 0; }
.ring-2 { inset: 14%; }
.ring-3 { inset: 29%; }
50% {
transform: translateY(-8px);
}
.radar-sweep {
inset: 0;
border-radius: 50%;
background: conic-gradient(from 10deg, rgb(64 218 230 / 13%), transparent 22%);
mask-image: radial-gradient(circle, #000 0 68%, transparent 72%);
animation: spin 8s linear infinite;
}
@keyframes floatUp {
0% {
opacity: 0;
transform: translateY(0) scale(1);
}
.radar-core {
inset: 36%;
display: grid;
place-items: center;
border: 1px solid rgb(66 220 232 / 38%);
border-radius: 24%;
filter: drop-shadow(0 0 16px rgb(66 220 232 / 30%));
transform: rotate(45deg);
12% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translateY(-46vh) scale(0.4);
}
:deep(*) { transform: rotate(-45deg); }
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
.signal { animation: pulse 2.6s ease-in-out infinite; }
.signal-a { top: 10%; left: 24%; }
.signal-b { right: 5%; bottom: 33%; animation-delay: -0.8s; }
.signal-c { bottom: 5%; left: 17%; animation-delay: -1.6s; }
to {
opacity: 1;
transform: translateX(0);
}
.hud-tagline {
position: absolute;
bottom: 3.6%;
left: 21%;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 10px;
letter-spacing: 0.12em;
color: rgb(113 191 211 / 68%);
.tag-dot { width: 5px; height: 5px; background: $green; box-shadow: 0 0 9px $green; }
i { font-style: normal; color: rgb(66 220 232 / 28%); }
}
@media (width <= 1500px) {
.eyebrow,
.text-wrap,
.hud-tagline { left: 15%; }
.radar-wrap { left: 33%; width: 240px; height: 240px; }
}
@media (width <= 1180px) {
.login-left-view { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.radar-sweep,
.signal,
.text-wrap { animation: none; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: 0.25; transform: scale(0.75); } }
@keyframes reveal {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+9 -3
View File
@@ -12,7 +12,9 @@
"requestCancelled": "Request cancelled",
"networkError": "Network connection error, please check your connection",
"requestFailed": "Request failed",
"requestConfigError": "Request configuration error"
"requestConfigError": "Request configuration error",
"badRequest": "Bad request",
"tooManyRequests": "Too many requests"
},
"topBar": {
"search": {
@@ -151,12 +153,16 @@
"login": {
"leftView": {
"title": "Unified LLM Access Gateway",
"subTitle": "Unified auth · rate limiting · quota · security audit"
"headlineMain": "Connect every model.",
"headlineAccent": "Protect every call.",
"subTitle": "Unify access to models and AI resources while governing identity, quota, content risk, and audit trails."
},
"title": "Welcome back",
"subTitle": "Secure access · unified governance",
"tagline": "Encrypted transport · zero-trust access",
"toggleTheme": "Toggle theme",
"accountLabel": "Account",
"passwordLabel": "Password",
"roles": {
"super": "Super Admin",
"admin": "Admin",
@@ -169,7 +175,7 @@
"rememberPwd": "Remember password",
"securityBadge": "AES-256 encryption · TLS 1.3 · login audit",
"forgetPwd": "Forgot password",
"btnText": "Login",
"btnText": "Enter platform",
"noAccount": "No account yet?",
"register": "Register",
"success": {
+9 -3
View File
@@ -12,7 +12,9 @@
"requestCancelled": "请求已取消",
"networkError": "网络连接异常,请检查网络连接",
"requestFailed": "请求失败",
"requestConfigError": "请求配置错误"
"requestConfigError": "请求配置错误",
"badRequest": "请求无效,请检查输入",
"tooManyRequests": "请求过于频繁,请稍后再试"
},
"topBar": {
"search": {
@@ -151,12 +153,16 @@
"login": {
"leftView": {
"title": "大模型统一接入网关",
"subTitle": "统一鉴权 · 限流 · 配额 · 安全审计"
"headlineMain": "连接每个模型,",
"headlineAccent": "守住每次调用。",
"subTitle": "统一接入大模型与 AI 资源,持续治理身份、配额、内容风险与调用审计。"
},
"title": "欢迎回来",
"subTitle": "安全接入 · 统一治理",
"tagline": "数据加密传输 · 零信任接入",
"toggleTheme": "切换主题",
"accountLabel": "账号",
"passwordLabel": "密码",
"roles": {
"super": "超级管理员",
"admin": "管理员",
@@ -169,7 +175,7 @@
"rememberPwd": "记住密码",
"securityBadge": "AES-256 加密 · TLS 1.3 · 登录安全审计",
"forgetPwd": "忘记密码",
"btnText": "登录",
"btnText": "进入平台",
"noAccount": "还没有账号?",
"register": "注册",
"success": {
+5 -1
View File
@@ -229,7 +229,11 @@ export const useUserStore = defineStore(
{
persist: {
key: 'user',
storage: localStorage
storage: sessionStorage,
// 仅持久化必要的 UI 状态与会话凭证:账户信息(info)、锁屏密码等
// 敏感数据不再落盘。sessionStorage 随浏览器会话关闭自动清理,
// 避免令牌/密码长期驻留本地被 XSS 或本地访问窃取。
pick: ['isLogin', 'accessToken', 'refreshToken', 'language']
}
}
)
+10 -4
View File
@@ -99,10 +99,12 @@ export class HttpError extends Error {
*/
const getErrorMessage = (status: number): string => {
const errorMap: Record<number, string> = {
[ApiStatus.badRequest]: 'httpMsg.badRequest',
[ApiStatus.unauthorized]: 'httpMsg.unauthorized',
[ApiStatus.forbidden]: 'httpMsg.forbidden',
[ApiStatus.notFound]: 'httpMsg.notFound',
[ApiStatus.methodNotAllowed]: 'httpMsg.methodNotAllowed',
[ApiStatus.tooManyRequests]: 'httpMsg.tooManyRequests',
[ApiStatus.requestTimeout]: 'httpMsg.requestTimeout',
[ApiStatus.internalServerError]: 'httpMsg.internalServerError',
[ApiStatus.badGateway]: 'httpMsg.badGateway',
@@ -137,10 +139,14 @@ export function handleError(error: AxiosError<ErrorResponse>): never {
})
}
// 处理 HTTP 状态码错误
const message = statusCode
? getErrorMessage(statusCode)
: errorMessage || $t('httpMsg.requestFailed')
// 处理 HTTP 状态码错误:后端返回的具体 msg(校验失败原因等)优先展示,
// 只有后端未提供消息时才回退到按状态码的通用文案。否则 400/429 等
// 全部显示"服务器内部错误",用户得不到任何可操作的反馈。
const backendMessage =
typeof error.response.data?.msg === 'string' && error.response.data.msg.trim() !== ''
? error.response.data.msg
: ''
const message = backendMessage || (statusCode ? getErrorMessage(statusCode) : errorMessage || $t('httpMsg.requestFailed'))
throw new HttpError(message, statusCode || ApiStatus.error, {
data: error.response.data,
url: requestConfig?.url,
+2
View File
@@ -4,6 +4,8 @@
export enum ApiStatus {
success = 200, // 成功
error = 400, // 错误
badRequest = 400, // 请求无效
tooManyRequests = 429, // 请求过于频繁
unauthorized = 401, // 未授权
forbidden = 403, // 禁止访问
notFound = 404, // 未找到
+39 -27
View File
@@ -3,11 +3,16 @@
<div class="login-page flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<div class="auth-stage relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<div class="login-card-brand">
<ArtLogo size="34" />
<span>{{ AppConfig.systemInfo.shortName }}</span>
<small>管理中心</small>
</div>
<h3 class="title">{{ $t('login.title') }}</h3>
<p class="sub-title">{{ $t('login.subTitle') }}</p>
<ElForm
@@ -30,6 +35,7 @@
</ElOption>
</ElSelect>
</ElFormItem>
<div class="field-label">{{ $t('login.accountLabel') }}</div>
<ElFormItem prop="username">
<ElInput
class="custom-height"
@@ -37,6 +43,7 @@
v-model.trim="formData.username"
/>
</ElFormItem>
<div class="field-label">{{ $t('login.passwordLabel') }}</div>
<ElFormItem prop="password">
<ElInput
class="custom-height"
@@ -69,7 +76,7 @@
</ElButton>
</div>
<div class="mt-5 text-sm text-[#a9c2e6]">
<div class="login-register-row mt-5 text-sm">
<span>{{ $t('login.noAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Register' }">{{
$t('login.register')
@@ -278,74 +285,79 @@
</style>
<style lang="scss">
// 语枢网关登录页:深色 HUD 背景与 Element Plus 控件(登录页恒定深色,不随主题变化)
// 参考安全运营平台的深浅分屏:左侧 HUD,右侧明亮登录卡片。
.login-page {
background:
radial-gradient(900px 520px at 100% -20%, rgb(0 229 255 / 10%), transparent 60%),
#050b18;
background: #f3f8fa;
.auth-stage {
min-width: 0;
background:
radial-gradient(520px 380px at 58% 50%, rgb(74 172 191 / 9%), transparent 72%),
#f3f8fa;
}
// 输入框
.el-input__wrapper,
.el-select__wrapper {
background-color: rgb(5 15 35 / 80%) !important;
border-radius: 8px;
box-shadow: 0 0 0 1px rgb(0 229 255 / 22%) inset !important;
background-color: #fff !important;
border-radius: 9px;
box-shadow: 0 0 0 1px #c8dce3 inset !important;
transition: box-shadow 0.25s;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused {
box-shadow:
0 0 0 1px #00e5ff inset,
0 0 14px rgb(0 229 255 / 35%) !important;
0 0 0 1px #13889d inset,
0 0 0 4px rgb(19 136 157 / 9%) !important;
}
.el-input__inner,
.el-select__placeholder,
.el-select__selected-item {
color: #eaf6ff !important;
caret-color: #00e5ff;
color: #0b2b3d !important;
caret-color: #13889d;
}
.el-input__inner::placeholder {
color: rgb(160 190 225 / 50%);
color: #8ba4af;
}
// 复选框
.el-checkbox__label {
color: #cfe4ff;
color: #58717d;
}
// 登录按钮(青色渐变发光)
.el-button--primary {
color: #03141f;
color: #fff;
font-weight: 600;
letter-spacing: 2px;
background-image: linear-gradient(120deg, #00e5ff, #2f80ff);
background: #0e8197;
border: none;
box-shadow: 0 6px 22px rgb(0 229 255 / 35%);
box-shadow: 0 8px 18px rgb(14 129 151 / 20%);
&:hover,
&:focus {
color: #03141f;
background-image: linear-gradient(120deg, #1be9ff, #4d93ff);
box-shadow: 0 8px 30px rgb(0 229 255 / 50%);
color: #fff;
background: #0a6e82;
box-shadow: 0 10px 24px rgb(14 129 151 / 28%);
}
}
// SSO 登录按钮
.el-button:not(.el-button--primary) {
color: #cfe4ff;
background: rgb(0 229 255 / 6%);
border: 1px solid rgb(0 229 255 / 22%);
color: #235263;
background: #f8fbfc;
border: 1px solid #cbdde3;
}
.el-divider {
border-color: rgb(0 229 255 / 18%);
border-color: #dce7eb;
.el-divider__text {
color: #a9c2e6;
background: transparent;
color: #78909b;
background: #fff;
}
}
}
+77 -63
View File
@@ -1,104 +1,118 @@
@reference '@styles/core/tailwind.css';
/* 授权页右侧区域(语枢网关深色 HUD 玻璃面板) */
.auth-right-wrap {
position: absolute;
inset: 0;
width: 440px;
height: 640px;
width: min(438px, calc(100% - 48px));
min-height: 508px;
height: fit-content;
margin: auto;
padding: 40px 48px;
padding: 40px 42px 54px;
overflow: hidden;
background: rgb(7 17 38 / 72%);
backdrop-filter: blur(14px);
border: 1px solid rgb(0 229 255 / 16%);
background: rgb(255 255 255 / 96%);
border: 1px solid #d7e5ea;
border-radius: 14px;
box-shadow:
0 0 40px rgb(0 229 255 / 8%),
inset 0 0 60px rgb(0 229 255 / 3%);
animation: slideInRight 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
box-shadow: 0 24px 58px rgb(25 72 88 / 15%);
animation: cardReveal 0.55s ease-out both;
.form {
height: 100%;
.login-card-brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 34px;
color: #082f43;
span {
font-size: 20px;
font-weight: 750;
letter-spacing: 0.01em;
}
small {
padding-left: 9px;
font-size: 12px;
color: #78919d;
border-left: 1px solid #d6e3e8;
}
}
.form { height: 100%; }
.title {
font-size: 32px;
font-weight: 600;
letter-spacing: 1px;
color: #f0f7ff;
text-shadow: 0 0 20px rgb(0 229 255 / 45%);
margin: 0;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
color: #082b3d;
}
.sub-title {
margin-top: 10px;
font-size: 14px;
letter-spacing: 1px;
color: rgb(0 229 255 / 75%);
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
margin-top: 8px;
font-size: 13px;
letter-spacing: 0.02em;
color: #8298a3;
}
.custom-height {
height: 40px !important;
.field-label {
margin: 2px 0 8px;
font-size: 12px;
font-weight: 600;
color: #264b5a;
}
/* 安全徽标 */
.custom-height { height: 40px !important; }
.text-theme { color: #0e8197; }
.login-register-row { color: #8298a3; }
.security-badge {
position: absolute;
right: 0;
bottom: 14px;
bottom: 22px;
left: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
letter-spacing: 1px;
color: rgb(0 229 255 / 70%);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 9px;
letter-spacing: 0.11em;
color: #8199a3;
.sb-dot {
width: 6px;
height: 6px;
background: #00ff9c;
width: 5px;
height: 5px;
background: #19cbb2;
border-radius: 50%;
box-shadow: 0 0 8px #00ff9c;
animation: sbBlink 2s ease-in-out infinite;
box-shadow: 0 0 8px rgb(25 203 178 / 60%);
animation: sbBlink 2.2s ease-in-out infinite;
}
}
@media only screen and (width < 640px) {
width: 100%;
height: auto;
padding: 32px 24px 64px;
}
@media (width < 640px) {
width: calc(100% - 32px);
min-height: auto;
padding: 32px 24px 58px;
border-radius: 12px;
@media only screen and (width < 768px) {
animation: none;
.login-card-brand { margin-bottom: 28px; }
}
}
/* 滑入动画 */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
@media (width <= 1180px) {
.auth-right-wrap { max-width: 438px; }
}
@media (prefers-reduced-motion: reduce) {
.auth-right-wrap,
.security-badge .sb-dot { animation: none; }
}
@keyframes cardReveal {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* 安全徽标呼吸 */
@keyframes sbBlink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
50% { opacity: 0.35; }
}
@@ -0,0 +1,94 @@
<template>
<div class="page-content">
<div class="mb-5 flex items-start justify-between gap-4">
<div>
<h2 class="text-xl font-semibold">智能体节点</h2>
<p class="text-g-500 mt-1 text-sm">登记执行节点并监控心跳令牌只在创建或轮换时显示一次当前版本不自动下发任务</p>
</div>
<div class="flex gap-2"><ElButton @click="openRoutePreview">路由预览</ElButton><ElButton type="primary" @click="openCreate">登记节点</ElButton></div>
</div>
<ElAlert class="mb-4" type="info" :closable="false" title="节点每次心跳都会刷新在线状态;超过 90 秒未心跳显示为离线。路由预览只验证在线候选和稳定哈希顺序,不会触发远程执行。" />
<ElTable v-loading="loading" :data="items" row-key="id">
<ElTableColumn label="状态" width="95"><template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusLabel(row.status) }}</ElTag></template></ElTableColumn>
<ElTableColumn label="节点" min-width="190"><template #default="{ row }"><div class="font-medium">{{ row.name }}</div><div class="text-g-500 text-xs">{{ row.code }}</div></template></ElTableColumn>
<ElTableColumn label="类型 / 节点池" min-width="180"><template #default="{ row }">{{ typeLabel(row.node_type) }} · {{ row.pool_type === 'public' ? '公有' : '私有' }} / {{ row.pool_code }}</template></ElTableColumn>
<ElTableColumn label="版本 / 能力" min-width="180"><template #default="{ row }"><div>{{ row.version || '—' }}</div><div class="text-g-500 text-xs">{{ capabilityCount(row.capabilities) }} 项能力</div></template></ElTableColumn>
<ElTableColumn label="最近心跳" min-width="180"><template #default="{ row }"><div>{{ formatTime(row.last_heartbeat_at) }}</div><div v-if="row.last_heartbeat_ip" class="text-g-500 text-xs">{{ row.last_heartbeat_ip }}</div></template></ElTableColumn>
<ElTableColumn label="Endpoint" prop="endpoint" min-width="180" show-overflow-tooltip />
<ElTableColumn label="操作" width="250" fixed="right"><template #default="{ row }"><ElButton link type="primary" @click="openEdit(row)">编辑</ElButton><ElButton link type="warning" @click="rotate(row)">轮换令牌</ElButton><ElButton link type="danger" @click="remove(row)">删除</ElButton></template></ElTableColumn>
</ElTable>
<ElDialog v-model="routePreviewVisible" title="节点池路由预览" width="820px" destroy-on-close>
<ElAlert class="mb-4" type="warning" :closable="false" title="这是只读验证:系统会按节点池、能力和 90 秒在线窗口筛选候选,再用 request key 做稳定哈希选出首节点;不会向节点发送任务。" />
<ElForm :model="routeForm" label-width="112px">
<div class="grid grid-cols-2 gap-4"><ElFormItem label="节点池"><ElSelect v-model="routeForm.pool_type" class="w-full"><ElOption label="私有池" value="private" /><ElOption label="公有池" value="public" /></ElSelect></ElFormItem><ElFormItem label="池编码"><ElInput v-model="routeForm.pool_code" maxlength="64" /></ElFormItem></div>
<ElFormItem label="Request key"><ElInput v-model="routeForm.request_key" maxlength="512" placeholder="同一请求 key 会稳定命中同一排序" /></ElFormItem>
<ElFormItem label="所需能力"><ElInput v-model="routeForm.capabilities" placeholder="多个能力用英文逗号分隔,例如 tool_exec,browser" /></ElFormItem>
</ElForm>
<div v-if="routePreviewResult" class="mt-2">
<ElAlert v-if="!routePreviewResult.selected" class="mb-4" type="warning" :closable="false" :title="routeReasonLabel(routePreviewResult.reason)" />
<div v-else class="mb-4 flex items-center justify-between rounded border border-primary/30 bg-primary/5 px-4 py-3"><div><div class="text-xs text-g-500">本次预览首选节点</div><div class="font-medium">{{ routePreviewResult.selected.name }} <span class="text-g-500 text-xs">{{ routePreviewResult.selected.code }}</span></div></div><ElTag type="success">在线 / 已选</ElTag></div>
<div class="mb-2 text-sm text-g-500">候选节点 {{ routePreviewResult.candidates.length }} · 策略 {{ routePreviewResult.selection_policy }}</div>
<ElTable :data="routePreviewResult.candidates" max-height="260" row-key="id"><ElTableColumn label="顺序" width="70"><template #default="{ $index }">{{ $index + 1 }}</template></ElTableColumn><ElTableColumn label="节点" min-width="190"><template #default="{ row }"><div class="font-medium">{{ row.name }}</div><div class="text-g-500 text-xs">{{ row.code }}</div></template></ElTableColumn><ElTableColumn label="类型 / 节点池" min-width="180"><template #default="{ row }">{{ typeLabel(row.node_type) }} · {{ row.pool_type === 'public' ? '公有' : '私有' }} / {{ row.pool_code }}</template></ElTableColumn><ElTableColumn label="版本" prop="version" min-width="120" /><ElTableColumn label="最近心跳" min-width="170"><template #default="{ row }">{{ formatTime(row.last_heartbeat_at) }}</template></ElTableColumn></ElTable>
</div>
<template #footer><ElButton @click="routePreviewVisible = false">关闭</ElButton><ElButton type="primary" :loading="routePreviewing" @click="runRoutePreview">开始预览</ElButton></template>
</ElDialog>
<ElDialog v-model="dialogVisible" :title="editingID ? '编辑智能体节点' : '登记智能体节点'" width="680px" destroy-on-close>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px">
<ElFormItem label="节点编码" prop="code"><ElInput v-model="form.code" :disabled="!!editingID" maxlength="128" placeholder="agent-worker-01" /></ElFormItem>
<ElFormItem label="节点名称" prop="name"><ElInput v-model="form.name" maxlength="128" /></ElFormItem>
<ElFormItem label="节点类型"><ElSelect v-model="form.node_type" class="w-full"><ElOption label="执行 Worker" value="worker" /><ElOption label="网关节点" value="gateway" /><ElOption label="执行器" value="executor" /></ElSelect></ElFormItem>
<div class="grid grid-cols-2 gap-4"><ElFormItem label="节点池"><ElSelect v-model="form.pool_type" class="w-full"><ElOption label="私有池" value="private" /><ElOption label="公有池" value="public" /></ElSelect></ElFormItem><ElFormItem label="池编码"><ElInput v-model="form.pool_code" maxlength="64" /></ElFormItem></div>
<ElFormItem label="Endpoint"><ElInput v-model="form.endpoint" maxlength="512" placeholder="可选,仅作登记信息" /></ElFormItem>
<ElFormItem label="描述"><ElInput v-model="form.description" type="textarea" :rows="3" maxlength="4000" show-word-limit /></ElFormItem>
<ElFormItem label="启用"><ElSwitch v-model="form.enabled" /></ElFormItem>
</ElForm>
<template #footer><ElButton @click="dialogVisible = false">取消</ElButton><ElButton type="primary" :loading="saving" @click="save">保存</ElButton></template>
</ElDialog>
<ElDialog v-model="tokenVisible" title="保存节点令牌" width="620px">
<ElAlert class="mb-4" type="warning" :closable="false" title="令牌只显示这一次;关闭后无法恢复,只能重新轮换。" />
<ElInput v-model="tokenText" readonly><template #append><ElButton @click="copyToken">复制</ElButton></template></ElInput>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { createAgentNode, deleteAgentNode, fetchAgentNodes, previewAgentNodeRoute, rotateAgentNodeToken, updateAgentNode, type AgentNode, type AgentNodeInput, type AgentNodeRoutePreview } from '@/api/agent-nodes'
const items = ref<AgentNode[]>([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const tokenVisible = ref(false)
const tokenText = ref('')
const editingID = ref('')
const routePreviewVisible = ref(false)
const routePreviewing = ref(false)
const routePreviewResult = ref<AgentNodeRoutePreview>()
const formRef = ref<FormInstance>()
const form = reactive<AgentNodeInput>({ code: '', name: '', description: '', endpoint: '', node_type: 'worker', pool_type: 'private', pool_code: 'default', enabled: true })
const routeForm = reactive({ pool_type: 'public' as AgentNode['pool_type'], pool_code: 'shared', request_key: 'preview-request-1', capabilities: '' })
const rules: FormRules = { code: [{ required: true, message: '请输入节点编码', trigger: 'blur' }], name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }] }
async function load() { loading.value = true; try { items.value = await fetchAgentNodes() } finally { loading.value = false } }
function reset() { Object.assign(form, { code: '', name: '', description: '', endpoint: '', node_type: 'worker', pool_type: 'private', pool_code: 'default', enabled: true }) }
function openCreate() { editingID.value = ''; reset(); dialogVisible.value = true }
function openEdit(row: AgentNode) { editingID.value = row.id; Object.assign(form, { code: row.code, name: row.name, description: row.description, endpoint: row.endpoint, node_type: row.node_type, pool_type: row.pool_type, pool_code: row.pool_code, enabled: row.enabled }); dialogVisible.value = true }
function openRoutePreview() { routePreviewResult.value = undefined; routePreviewVisible.value = true }
async function runRoutePreview() { routePreviewing.value = true; try { routePreviewResult.value = await previewAgentNodeRoute({ pool_type: routeForm.pool_type, pool_code: routeForm.pool_code, request_key: routeForm.request_key, required_capabilities: routeForm.capabilities.split(',').map(value => value.trim()).filter(Boolean) }) } finally { routePreviewing.value = false } }
async function save() { if (!(await formRef.value?.validate())) return; saving.value = true; try { const result = editingID.value ? await updateAgentNode(editingID.value, form) : await createAgentNode(form); dialogVisible.value = false; if (!editingID.value && 'token' in result) { tokenText.value = result.token; tokenVisible.value = true } ElMessage.success('节点已保存'); await load() } finally { saving.value = false } }
async function rotate(row: AgentNode) { await ElMessageBox.confirm(`轮换节点“${row.name}”令牌后旧令牌会立即失效,继续?`, '轮换节点令牌', { type: 'warning' }); const result = await rotateAgentNodeToken(row.id); tokenText.value = result.token; tokenVisible.value = true; await load() }
async function remove(row: AgentNode) { await ElMessageBox.confirm(`确认删除节点“${row.name}”?删除后该节点令牌立即失效。`, '删除智能体节点', { type: 'warning' }); await deleteAgentNode(row.id); ElMessage.success('节点已删除'); await load() }
async function copyToken() { await navigator.clipboard.writeText(tokenText.value); ElMessage.success('令牌已复制') }
function formatTime(value?: string) { return value ? new Date(value).toLocaleString() : '尚未心跳' }
function statusLabel(value: string) { return ({ pending: '待上线', online: '在线', offline: '离线', disabled: '已停用' } as Record<string, string>)[value] ?? value }
function statusType(value: string) { return value === 'online' ? 'success' : value === 'offline' ? 'danger' : value === 'disabled' ? 'info' : 'warning' }
function typeLabel(value: string) { return ({ worker: 'Worker', gateway: 'Gateway', executor: 'Executor' } as Record<string, string>)[value] ?? value }
function capabilityCount(value: Record<string, unknown>) { return Object.keys(value || {}).length }
function routeReasonLabel(value: AgentNodeRoutePreview['reason']) { return value === 'no_online_node' ? '该节点池暂无在线节点' : value === 'no_capable_node' ? '在线节点均不满足所需能力' : '已找到可用节点' }
onMounted(load)
</script>
@@ -0,0 +1,75 @@
<template>
<div class="page-content">
<div class="mb-5">
<h2 class="text-xl font-semibold">智能体会话</h2>
<p class="text-g-500 mt-1 text-sm">按会话聚合 AI 应用与数字员工请求仅展示运行元数据和 Trace 入口不展示对话正文</p>
</div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<ElDatePicker v-model="range" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
<ElSelect v-model="filter.trace_type" clearable placeholder="会话类型" class="!w-36"><ElOption label="AI 应用" value="application" /><ElOption label="数字员工" value="digital_employee" /></ElSelect>
<ElInput v-model="filter.target_code" clearable placeholder="目标编码" class="!w-48" />
<ElInput v-model="filter.session_id" clearable placeholder="会话 ID" class="!w-64" />
<ElButton type="primary" @click="load">查询</ElButton>
</div>
<ElTable v-loading="loading" :data="items" row-key="id">
<ElTableColumn label="类型 / 目标" min-width="180"><template #default="{ row }"><div>{{ row.trace_type === 'application' ? 'AI 应用' : '数字员工' }}</div><div class="text-g-500 text-xs">{{ row.target_code }}</div></template></ElTableColumn>
<ElTableColumn label="会话 ID" min-width="260" show-overflow-tooltip><template #default="{ row }"><span :title="row.id">{{ row.id }}</span></template></ElTableColumn>
<ElTableColumn label="Trace" width="90"><template #default="{ row }">{{ row.trace_count }}</template></ElTableColumn>
<ElTableColumn label="调用统计" min-width="220"><template #default="{ row }">模型 {{ row.model_call_count }} · 工具 {{ row.tool_call_count }} · 检索 {{ row.retrieval_count }}</template></ElTableColumn>
<ElTableColumn label="最近状态" width="110"><template #default="{ row }"><ElTag :type="statusType(row.latest_status)">{{ statusLabel(row.latest_status) }}</ElTag></template></ElTableColumn>
<ElTableColumn label="最近活动" min-width="180"><template #default="{ row }">{{ formatTime(row.updated_at) }}</template></ElTableColumn>
<ElTableColumn label="操作" width="120" fixed="right"><template #default="{ row }"><ElButton link type="primary" @click="showTrace(row)">查看 Trace</ElButton></template></ElTableColumn>
</ElTable>
<ElDialog v-model="detailVisible" :title="`最近 Trace · ${selectedTrace?.target_code ?? ''}`" width="980px">
<template v-if="selectedTrace">
<div class="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<ElCard shadow="never"><div class="text-g-500 text-xs">类型</div><div class="mt-1 font-medium">{{ selectedTrace.trace_type === 'application' ? 'AI 应用' : '数字员工' }}</div></ElCard>
<ElCard shadow="never"><div class="text-g-500 text-xs">状态</div><div class="mt-1 font-medium">{{ statusLabel(selectedTrace.status) }}</div></ElCard>
<ElCard shadow="never"><div class="text-g-500 text-xs">总耗时</div><div class="mt-1 font-medium">{{ selectedTrace.latency_ms ?? '—' }} ms</div></ElCard>
<ElCard shadow="never"><div class="text-g-500 text-xs">Request ID</div><div class="mt-1 truncate font-medium" :title="selectedTrace.request_id">{{ selectedTrace.request_id }}</div></ElCard>
</div>
<ElAlert v-if="selectedTrace.error" class="mb-4" type="error" :closable="false" :title="selectedTrace.error" />
<ElTable :data="selectedTrace.spans ?? []" row-key="id" max-height="480">
<ElTableColumn label="类型" width="100"><template #default="{ row }"><ElTag :type="spanType(row.span_type)">{{ spanLabel(row.span_type) }}</ElTag></template></ElTableColumn>
<ElTableColumn prop="name" label="名称" min-width="190" />
<ElTableColumn label="模型 / Provider" min-width="180"><template #default="{ row }">{{ row.model || '—' }}<span v-if="row.provider_code" class="text-g-500 text-xs"> · {{ row.provider_code }}</span></template></ElTableColumn>
<ElTableColumn label="状态" width="90"><template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusLabel(row.status) }}</ElTag></template></ElTableColumn>
<ElTableColumn label="Token" width="130"><template #default="{ row }">{{ row.input_tokens || 0 }} + {{ row.output_tokens || 0 }}</template></ElTableColumn>
<ElTableColumn label="耗时" width="110"><template #default="{ row }">{{ row.latency_ms == null ? '—' : `${row.latency_ms} ms` }}</template></ElTableColumn>
<ElTableColumn prop="error" label="错误" min-width="220" show-overflow-tooltip />
</ElTable>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { fetchAgentSessions, type AgentSession } from '@/api/agent-sessions'
import { fetchTrace, type Trace, type TraceSpan } from '@/api/traces'
const now = new Date()
const range = ref<[Date, Date]>([new Date(now.getTime() - 30 * 24 * 3600_000), now])
const filter = reactive({ trace_type: '', target_code: '', session_id: '' })
const items = ref<AgentSession[]>([])
const loading = ref(false)
const detailVisible = ref(false)
const selectedTrace = ref<Trace>()
async function load() {
loading.value = true
try {
const result = await fetchAgentSessions({ from: range.value[0].toISOString(), to: range.value[1].toISOString(), trace_type: filter.trace_type || undefined, target_code: filter.target_code || undefined, session_id: filter.session_id || undefined, limit: 100 })
items.value = result.items
} finally { loading.value = false }
}
async function showTrace(row: AgentSession) { selectedTrace.value = await fetchTrace(row.latest_trace_id); detailVisible.value = true }
function formatTime(value: string) { return new Date(value).toLocaleString() }
function statusLabel(value: string) { return ({ running: '运行中', success: '成功', error: '失败' } as Record<string, string>)[value] ?? value }
function statusType(value: string) { return value === 'success' ? 'success' : value === 'error' ? 'danger' : 'warning' }
function spanLabel(value: TraceSpan['span_type']) { return ({ model: '模型', tool: '工具', retrieval: '检索' } as Record<string, string>)[value] ?? value }
function spanType(value: TraceSpan['span_type']) { return value === 'model' ? 'primary' : value === 'tool' ? 'warning' : 'info' }
onMounted(load)
</script>
@@ -0,0 +1,115 @@
<template>
<div class="page-content">
<div class="mb-5 flex justify-between">
<div>
<h2 class="text-xl font-semibold">站内消息</h2>
<p class="text-g-500 mt-1 text-sm">系统事件物化的站内消息管理员可向门户用户广播未读数以数据库为准</p>
</div>
<div class="flex gap-2">
<ElButton @click="markAllRead">全部已读</ElButton>
<ElButton type="primary" @click="openBroadcast">发送广播</ElButton>
</div>
</div>
<ElTabs v-model="scope" @tab-change="load">
<ElTabPane label="我的消息" name="mine"/>
<ElTabPane label="已发送广播" name="broadcasts"/>
</ElTabs>
<ElTable v-loading="loading" :data="messages">
<ElTableColumn label="状态" width="90">
<template #default="{row}">
<ElTag :type="row.read_at ? 'info' : 'primary'" size="small">{{ row.read_at ? '已读' : '未读' }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="类型" width="110">
<template #default="{row}">{{ categoryLabel(row.category) }}</template>
</ElTableColumn>
<ElTableColumn prop="title" label="标题" min-width="220" show-overflow-tooltip/>
<ElTableColumn prop="body" label="内容" min-width="260" show-overflow-tooltip/>
<ElTableColumn prop="created_at" label="时间" width="180"/>
<ElTableColumn label="操作" width="140" fixed="right"><template #default="{row}">
<ElButton v-if="!row.read_at" link type="primary" @click="read(row)">标为已读</ElButton>
<ElButton v-if="row.link" link type="primary" @click="goLink(row)">前往</ElButton>
</template></ElTableColumn>
</ElTable>
<ElEmpty v-if="!loading && messages.length === 0" description="暂无消息" class="mt-10"/>
<ElDialog v-model="broadcastVisible" title="向门户用户发送广播" width="640px">
<ElForm label-width="100px">
<ElFormItem label="接收对象">
<ElSelect v-model="broadcastForm.recipient_kind" class="w-full">
<ElOption label="全部门户用户" value="portal"/>
<ElOption label="全部管理员" value="admin"/>
</ElSelect>
</ElFormItem>
<ElFormItem label="类型">
<ElSelect v-model="broadcastForm.category" class="w-full">
<ElOption label="系统" value="system"/>
<ElOption label="审批" value="approval"/>
<ElOption label="任务结果" value="task_result"/>
<ElOption label="资源" value="resource"/>
</ElSelect>
</ElFormItem>
<ElFormItem label="标题" required>
<ElInput v-model="broadcastForm.title" maxlength="256" show-word-limit placeholder="广播标题"/>
</ElFormItem>
<ElFormItem label="内容">
<ElInput v-model="broadcastForm.body" type="textarea" :rows="3" maxlength="4000" placeholder="广播正文"/>
</ElFormItem>
<ElFormItem label="跳转链接">
<ElInput v-model="broadcastForm.link" placeholder="/portal/xxx(可选)"/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="broadcastVisible=false">取消</ElButton>
<ElButton type="primary" :loading="sending" @click="sendBroadcast">发送</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { BroadcastInput, broadcastInbox, fetchInbox, markInboxAllRead, markInboxRead } from '@/api/inbox'
import type { InboxMessage } from '@/api/inbox'
const loading = ref(false), sending = ref(false), scope = ref('mine'), broadcastVisible = ref(false)
const router = useRouter()
const messages = ref<InboxMessage[]>([])
const broadcastForm = reactive<BroadcastInput>({ recipient_kind: 'portal', category: 'system', title: '', body: '', link: '' })
const categoryMap: Record<string, string> = { system: '系统', approval: '审批', task_result: '任务结果', resource: '资源' }
function categoryLabel(c: string) { return categoryMap[c] ?? c }
async function load() {
loading.value = true
try { messages.value = await fetchInbox(scope.value) } finally { loading.value = false }
}
async function read(row: InboxMessage) {
await markInboxRead(row.id)
row.read_at = new Date().toISOString()
}
async function markAllRead() {
await markInboxAllRead()
ElMessage.success('已全部标记为已读')
await load()
}
function goLink(row: InboxMessage) {
if (row.link.startsWith('/')) return router.push(row.link)
const target = new URL(row.link)
if (target.protocol === 'http:' || target.protocol === 'https:') {
window.open(target.href, '_blank', 'noopener,noreferrer')
}
}
function openBroadcast() {
Object.assign(broadcastForm, { recipient_kind: 'portal', category: 'system', title: '', body: '', link: '' })
broadcastVisible.value = true
}
async function sendBroadcast() {
if (!broadcastForm.title.trim()) return ElMessage.warning('请填写标题')
sending.value = true
try {
const res = await broadcastInbox(broadcastForm)
ElMessage.success(`已发送给 ${res.sent} 个用户`)
broadcastVisible.value = false
} finally { sending.value = false }
}
onMounted(load)
</script>
@@ -22,9 +22,14 @@
<ElTableColumn prop="code" label="代码" min-width="150" />
<ElTableColumn prop="adapter" label="适配器" min-width="170" />
<ElTableColumn prop="base_url" label="上游地址" min-width="260" show-overflow-tooltip />
<ElTableColumn label="凭据" width="150">
<ElTableColumn label="凭据" width="220">
<template #default="{ row }">
<span>{{ row.key_configured ? row.api_key_masked : '未配置' }}</span>
<template v-if="row.credential_error">
<ElTooltip :content="row.credential_error" placement="top">
<ElTag type="danger">凭据异常</ElTag>
</ElTooltip>
</template>
<span v-else>{{ row.key_configured ? row.api_key_masked : '未配置' }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100">
@@ -54,6 +59,7 @@
</ElButton>
<ElButton link type="primary" @click="showModels(row)">模型</ElButton>
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" @click="removeProvider(row)">删除</ElButton>
</template>
</ElTableColumn>
</ElTable>
@@ -129,6 +135,7 @@
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import {
createProvider,
deleteProvider,
fetchProviderModels,
fetchProviders,
ProviderInput,
@@ -262,6 +269,25 @@
}
}
async function removeProvider(record: ProviderRecord) {
try {
await ElMessageBox.confirm(
`删除供应商「${record.code}」将级联删除其模型与路由配置,且不可恢复。`,
'删除供应商',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
try {
await deleteProvider(record.id)
ElMessage.success('供应商已删除')
await loadProviders()
} catch {
/* 错误提示由全局拦截器处理 */
}
}
async function rotateCredentials() {
try {
await ElMessageBox.confirm(
@@ -0,0 +1,99 @@
<template>
<div class="page-content">
<div class="mb-5 flex items-start justify-between">
<div>
<h2 class="text-xl font-semibold">定时任务</h2>
<p class="text-g-500 mt-1 text-sm">Cron 调度应用或数字员工执行记录持久化支持多副本安全抢占与超时回收</p>
</div>
<ElButton type="primary" @click="openCreate">新建任务</ElButton>
</div>
<ElAlert class="mb-4" :closable="false" type="info" title="Cron 使用 5 段格式:分 时 日 月 周,例如 0 9 * * 1-5;任务 API Key 由平台主密钥加密保存。" />
<ElTable v-loading="loading" :data="items" row-key="id">
<ElTableColumn label="状态" width="90"><template #default="{row}"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '运行中' : '已暂停' }}</ElTag></template></ElTableColumn>
<ElTableColumn prop="name" label="任务" min-width="170"><template #default="{row}"><div class="font-medium">{{row.name}}</div><div class="text-g-500 text-xs">{{row.code}}</div></template></ElTableColumn>
<ElTableColumn label="调度" min-width="160"><template #default="{row}"><code>{{row.cron_expression}}</code><div class="text-g-500 text-xs">{{row.timezone}}</div></template></ElTableColumn>
<ElTableColumn label="执行目标" min-width="180"><template #default="{row}">{{row.target_type === 'application' ? '应用' : '数字员工'}} · {{row.target_code}}</template></ElTableColumn>
<ElTableColumn label="下次执行" width="180"><template #default="{row}">{{formatTime(row.next_run_at)}}</template></ElTableColumn>
<ElTableColumn label="最近结果" width="120"><template #default="{row}"><ElTag v-if="row.last_status" :type="row.last_status === 'success' ? 'success' : 'danger'">{{row.last_status === 'success' ? '成功' : '失败'}}</ElTag><span v-else>-</span></template></ElTableColumn>
<ElTableColumn label="操作" width="285" fixed="right"><template #default="{row}">
<ElButton link type="primary" @click="edit(row)">编辑</ElButton>
<ElButton link :type="row.enabled ? 'warning' : 'success'" @click="toggle(row)">{{row.enabled ? '暂停' : '启动'}}</ElButton>
<ElButton link type="primary" @click="runNow(row)">立即执行</ElButton>
<ElButton link type="primary" @click="showRuns(row)">历史</ElButton>
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
</template></ElTableColumn>
</ElTable>
<ElDialog v-model="visible" :title="editing ? '编辑定时任务' : '新建定时任务'" width="780px" destroy-on-close>
<ElForm label-width="110px">
<div class="grid grid-cols-2 gap-x-4">
<ElFormItem label="任务编码" required><ElInput v-model="form.code" :disabled="!!editing" placeholder="daily_security_report" /></ElFormItem>
<ElFormItem label="任务名称" required><ElInput v-model="form.name" /></ElFormItem>
<ElFormItem label="Cron" required><ElInput v-model="form.cron_expression" placeholder="0 9 * * 1-5" /></ElFormItem>
<ElFormItem label="时区" required><ElSelect v-model="form.timezone" filterable allow-create class="w-full"><ElOption label="中国标准时间" value="Asia/Shanghai"/><ElOption label="UTC" value="UTC"/></ElSelect></ElFormItem>
<ElFormItem label="目标类型"><ElSelect v-model="form.target_type" class="w-full" @change="targetChanged"><ElOption label="AI 应用" value="application"/><ElOption label="数字员工" value="digital_employee"/></ElSelect></ElFormItem>
<ElFormItem label="执行目标" required><ElSelect v-model="form.target_code" filterable class="w-full" @change="targetChanged"><ElOption v-for="target in targets" :key="target.code" :label="`${target.name} (${target.code})`" :value="target.code"/></ElSelect></ElFormItem>
</div>
<ElFormItem label="任务描述"><ElInput v-model="form.description" type="textarea" :rows="2" maxlength="4000" /></ElFormItem>
<ElFormItem label="提示词" required><ElInput v-model="form.prompt" type="textarea" :rows="4" maxlength="100000" show-word-limit placeholder="每次执行发送给目标的用户提示词" /></ElFormItem>
<ElFormItem label="变量 JSON"><ElInput v-model="variablesText" type="textarea" :rows="3" placeholder='{"department":"security"}' /></ElFormItem>
<template v-if="form.target_type === 'digital_employee'">
<ElFormItem label="Skill 范围"><ElSelect v-model="form.skill_ids" multiple clearable class="w-full" placeholder="留空使用数字员工全部 Skill"><ElOption v-for="item in availableSkills" :key="item.id" :label="item.name" :value="item.id"/></ElSelect></ElFormItem>
<ElFormItem label="MCP 范围"><ElSelect v-model="form.mcp_server_ids" multiple clearable class="w-full" placeholder="留空使用数字员工全部 MCP"><ElOption v-for="item in availableMCP" :key="item.id" :label="item.name" :value="item.id"/></ElSelect></ElFormItem>
</template>
<div class="grid grid-cols-2 gap-x-4">
<ElFormItem label="会话 ID"><ElInput v-model="form.conversation_id" placeholder="可选;相同任务保留最近 5 轮上下文" /></ElFormItem>
<ElFormItem label="通知渠道"><ElSelect v-model="form.notification_channel_id" clearable class="w-full"><ElOption v-for="channel in channels" :key="channel.id" :label="channel.name" :value="channel.id"/></ElSelect></ElFormItem>
</div>
<ElFormItem label="执行 API Key" :required="!editing"><ElInput v-model="form.api_key" type="password" show-password :placeholder="editing ? '留空保留当前密钥' : '具有目标调用权限的 Gateway API Key'" /></ElFormItem>
<ElFormItem label="创建后启动"><ElSwitch v-model="form.enabled" /></ElFormItem>
</ElForm>
<template #footer><ElButton @click="visible=false">取消</ElButton><ElButton type="primary" :loading="saving" @click="save">保存</ElButton></template>
</ElDialog>
<ElDialog v-model="runsVisible" :title="`${selected?.name ?? ''} · 执行历史`" width="900px">
<ElTable :data="runs" max-height="520">
<ElTableColumn label="状态" width="90"><template #default="{row}"><ElTag :type="runType(row.status)">{{runLabel(row.status)}}</ElTag></template></ElTableColumn>
<ElTableColumn prop="trigger_type" label="触发" width="90" />
<ElTableColumn label="计划时间" width="180"><template #default="{row}">{{formatTime(row.scheduled_for)}}</template></ElTableColumn>
<ElTableColumn prop="attempts" label="尝试" width="70" />
<ElTableColumn prop="error" label="错误" min-width="220" show-overflow-tooltip />
<ElTableColumn label="结果" width="90"><template #default="{row}"><ElButton v-if="row.response" link type="primary" @click="inspectResponse(row)">查看</ElButton></template></ElTableColumn>
</ElTable>
</ElDialog>
<ElDialog v-model="responseVisible" title="执行结果 JSON" width="760px"><pre class="max-h-[520px] overflow-auto rounded bg-black/5 p-4 text-xs">{{responseText}}</pre></ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchApplications, fetchNotificationChannels, type Application, type NotificationChannel } from '@/api/workbench'
import { fetchDigitalEmployees, fetchMCPServers, fetchSkills, type DigitalEmployee, type MCPServer, type Skill } from '@/api/marketplace'
import { createScheduledTask, deleteScheduledTask, fetchScheduledTaskRuns, fetchScheduledTasks, pauseScheduledTask, runScheduledTask, startScheduledTask, updateScheduledTask, type ScheduledTask, type ScheduledTaskInput, type ScheduledTaskRun } from '@/api/scheduled-tasks'
const loading=ref(false),saving=ref(false),visible=ref(false),runsVisible=ref(false),responseVisible=ref(false)
const editing=ref(''),items=ref<ScheduledTask[]>([]),runs=ref<ScheduledTaskRun[]>([]),selected=ref<ScheduledTask>()
const applications=ref<Application[]>([]),employees=ref<DigitalEmployee[]>([]),skills=ref<Skill[]>([]),mcpServers=ref<MCPServer[]>([]),channels=ref<NotificationChannel[]>([])
const variablesText=ref('{}'),responseText=ref('')
const blank=():ScheduledTaskInput=>({code:'',name:'',description:'',cron_expression:'0 9 * * 1-5',timezone:'Asia/Shanghai',target_type:'digital_employee',target_code:'',prompt:'',variables:{},skill_ids:[],mcp_server_ids:[],conversation_id:'',notification_channel_id:null,api_key:'',enabled:false})
const form=reactive<ScheduledTaskInput>(blank())
const targets=computed(()=>form.target_type==='application'?applications.value.filter(v=>v.status==='active'&&v.published_version):employees.value.filter(v=>v.enabled&&v.status==='published'))
const currentEmployee=computed(()=>employees.value.find(v=>v.code===form.target_code))
const availableSkills=computed(()=>skills.value.filter(v=>currentEmployee.value?.skill_ids.includes(v.id)))
const availableMCP=computed(()=>mcpServers.value.filter(v=>currentEmployee.value?.mcp_server_ids.includes(v.id)))
function targetChanged(){form.skill_ids=[];form.mcp_server_ids=[]}
function formatTime(v?:string){return v?new Date(v).toLocaleString():'-'}
function runType(s:string){return s==='success'?'success':s==='failed'?'danger':s==='running'?'warning':'info'}
function runLabel(s:string){return({success:'成功',failed:'失败',running:'执行中',pending:'排队中'} as Record<string,string>)[s]??s}
async function load(){loading.value=true;try{[items.value,applications.value,employees.value,skills.value,mcpServers.value,channels.value]=await Promise.all([fetchScheduledTasks(),fetchApplications(),fetchDigitalEmployees(),fetchSkills(),fetchMCPServers(),fetchNotificationChannels()])}finally{loading.value=false}}
function openCreate(){editing.value='';Object.assign(form,blank());variablesText.value='{}';visible.value=true}
function edit(row:ScheduledTask){editing.value=row.id;Object.assign(form,{code:row.code,name:row.name,description:row.description,cron_expression:row.cron_expression,timezone:row.timezone,target_type:row.target_type,target_code:row.target_code,prompt:row.prompt,variables:{...row.variables},skill_ids:[...row.skill_ids],mcp_server_ids:[...row.mcp_server_ids],conversation_id:row.conversation_id,notification_channel_id:row.notification_channel_id??null,api_key:'',enabled:row.enabled});variablesText.value=JSON.stringify(row.variables??{},null,2);visible.value=true}
async function save(){if(!form.code||!form.name||!form.target_code||!form.prompt)return ElMessage.warning('请填写必填项');let variables:Record<string,unknown>;try{variables=JSON.parse(variablesText.value||'{}')}catch{return ElMessage.warning('变量 JSON 格式无效')}if(Array.isArray(variables)||variables===null)return ElMessage.warning('变量必须是 JSON 对象');saving.value=true;try{const payload={...form,variables};editing.value?await updateScheduledTask(editing.value,payload):await createScheduledTask(payload);visible.value=false;ElMessage.success('任务已保存');await load()}finally{saving.value=false}}
async function toggle(row:ScheduledTask){row.enabled?await pauseScheduledTask(row.id):await startScheduledTask(row.id);await load()}
async function runNow(row:ScheduledTask){await runScheduledTask(row.id);ElMessage.success('已进入执行队列');setTimeout(()=>showRuns(row),800)}
async function showRuns(row:ScheduledTask){selected.value=row;runs.value=await fetchScheduledTaskRuns(row.id);runsVisible.value=true}
function inspectResponse(row:ScheduledTaskRun){responseText.value=JSON.stringify(row.response,null,2);responseVisible.value=true}
async function remove(row:ScheduledTask){await ElMessageBox.confirm(`删除任务“${row.name}”及全部执行历史?`,'确认删除',{type:'warning'});await deleteScheduledTask(row.id);await load()}
onMounted(load)
</script>
@@ -0,0 +1,75 @@
<template>
<div class="page-content">
<div class="mb-5">
<h2 class="text-xl font-semibold">LLM Trace</h2>
<p class="text-g-500 mt-1 text-sm">查看应用与数字员工的一次请求如何经过检索模型和工具调用仅展示运行元数据不保存正文</p>
</div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<ElDatePicker v-model="range" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
<ElSelect v-model="filter.trace_type" clearable placeholder="目标类型" class="!w-36"><ElOption label="AI 应用" value="application" /><ElOption label="数字员工" value="digital_employee" /></ElSelect>
<ElInput v-model="filter.target_code" clearable placeholder="目标编码" class="!w-48" />
<ElSelect v-model="filter.status" clearable placeholder="状态" class="!w-32"><ElOption label="运行中" value="running" /><ElOption label="成功" value="success" /><ElOption label="失败" value="error" /></ElSelect>
<ElInput v-model="filter.request_id" clearable placeholder="Request ID" class="!w-56" />
<ElButton type="primary" @click="load">查询</ElButton>
</div>
<ElTable v-loading="loading" :data="items" row-key="id">
<ElTableColumn label="时间" min-width="180"><template #default="{ row }">{{ formatTime(row.started_at) }}</template></ElTableColumn>
<ElTableColumn label="目标" min-width="190"><template #default="{ row }"><div>{{ row.trace_type === 'application' ? 'AI 应用' : '数字员工' }}</div><div class="text-g-500 text-xs">{{ row.target_code }}</div></template></ElTableColumn>
<ElTableColumn label="状态" width="90"><template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusLabel(row.status) }}</ElTag></template></ElTableColumn>
<ElTableColumn label="调用链" min-width="180"><template #default="{ row }">模型 {{ row.model_call_count }} · 工具 {{ row.tool_call_count }} · 检索 {{ row.retrieval_count }}</template></ElTableColumn>
<ElTableColumn label="耗时" width="110"><template #default="{ row }">{{ row.latency_ms == null ? '—' : `${row.latency_ms} ms` }}</template></ElTableColumn>
<ElTableColumn prop="request_id" label="Request ID" min-width="220" />
<ElTableColumn label="操作" width="90" fixed="right"><template #default="{ row }"><ElButton link type="primary" @click="showDetail(row)">详情</ElButton></template></ElTableColumn>
</ElTable>
<ElDialog v-model="detailVisible" :title="`Trace · ${selected?.target_code ?? ''}`" width="980px">
<template v-if="selected">
<div class="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<ElCard shadow="never"><div class="text-g-500 text-xs">状态</div><div class="mt-1 font-medium">{{ statusLabel(selected.status) }}</div></ElCard>
<ElCard shadow="never"><div class="text-g-500 text-xs">总耗时</div><div class="mt-1 font-medium">{{ selected.latency_ms ?? '—' }} ms</div></ElCard>
<ElCard shadow="never"><div class="text-g-500 text-xs">模型调用</div><div class="mt-1 font-medium">{{ selected.model_call_count }}</div></ElCard>
<ElCard shadow="never"><div class="text-g-500 text-xs">Request ID</div><div class="mt-1 truncate font-medium" :title="selected.request_id">{{ selected.request_id }}</div></ElCard>
</div>
<ElAlert v-if="selected.error" class="mb-4" type="error" :closable="false" :title="selected.error" />
<ElTable :data="selected.spans ?? []" row-key="id" max-height="480">
<ElTableColumn label="类型" width="100"><template #default="{ row }"><ElTag :type="spanType(row.span_type)">{{ spanLabel(row.span_type) }}</ElTag></template></ElTableColumn>
<ElTableColumn prop="name" label="名称" min-width="190" />
<ElTableColumn label="模型 / Provider" min-width="180"><template #default="{ row }">{{ row.model || '—' }}<span v-if="row.provider_code" class="text-g-500 text-xs"> · {{ row.provider_code }}</span></template></ElTableColumn>
<ElTableColumn label="状态" width="90"><template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusLabel(row.status) }}</ElTag></template></ElTableColumn>
<ElTableColumn label="Token" width="130"><template #default="{ row }">{{ row.input_tokens || 0 }} + {{ row.output_tokens || 0 }}</template></ElTableColumn>
<ElTableColumn label="耗时" width="110"><template #default="{ row }">{{ row.latency_ms == null ? '—' : `${row.latency_ms} ms` }}</template></ElTableColumn>
<ElTableColumn prop="error" label="错误" min-width="220" show-overflow-tooltip />
</ElTable>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { fetchTrace, fetchTraces, type Trace, type TraceSpan } from '@/api/traces'
const now = new Date()
const range = ref<[Date, Date]>([new Date(now.getTime() - 24 * 3600_000), now])
const filter = reactive({ trace_type: '', target_code: '', status: '', request_id: '' })
const items = ref<Trace[]>([])
const loading = ref(false)
const detailVisible = ref(false)
const selected = ref<Trace>()
async function load() {
loading.value = true
try {
const result = await fetchTraces({ from: range.value[0].toISOString(), to: range.value[1].toISOString(), trace_type: filter.trace_type || undefined, target_code: filter.target_code || undefined, status: filter.status || undefined, request_id: filter.request_id || undefined, limit: 100 })
items.value = result.items
} finally { loading.value = false }
}
async function showDetail(row: Trace) { selected.value = await fetchTrace(row.id); detailVisible.value = true }
function formatTime(value: string) { return new Date(value).toLocaleString() }
function statusLabel(value: string) { return ({ running: '运行中', success: '成功', error: '失败' } as Record<string, string>)[value] ?? value }
function statusType(value: string) { return value === 'success' ? 'success' : value === 'error' ? 'danger' : 'warning' }
function spanLabel(value: TraceSpan['span_type']) { return ({ model: '模型', tool: '工具', retrieval: '检索' } as Record<string, string>)[value] ?? value }
function spanType(value: TraceSpan['span_type']) { return value === 'model' ? 'primary' : value === 'tool' ? 'warning' : 'info' }
onMounted(load)
</script>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+7
View File
@@ -23,6 +23,13 @@ export const fetchStats=(days:number)=>request.get<Stats>({url:'/api/v1/portal/s
export const fetchLogs=(days:number,limit=50)=>request.get<{items:AuditEvent[]}>({url:'/api/v1/portal/logs',params:{days,limit}})
export const changePassword=(params:{old_password:string;new_password:string})=>request.post({url:'/api/v1/portal/password',params})
// --- 站内消息(M8 P4---
export interface InboxMessage { id:string;recipient_kind:string;recipient_user_id:string;sender_type:string;category:string;title:string;body:string;link:string;payload?:Record<string,unknown>;read_at?:string;created_at:string }
export const fetchInbox=()=>request.get<InboxMessage[]>({url:'/api/v1/portal/inbox'})
export const fetchInboxUnread=()=>request.get<{unread:number}>({url:'/api/v1/portal/inbox/unread'})
export const markInboxRead=(id:string)=>request.post<{read:boolean}>({url:`/api/v1/portal/inbox/${id}/read`})
export const markInboxAllRead=()=>request.post<{read_all:number}>({url:'/api/v1/portal/inbox/read-all'})
// --- 个人文件仓库(M8 对象存储)---
export interface FileObject { id:string;original_name:string;content_type:string;size_bytes:number;content_sha256:string;scope:string;created_at:string;updated_at:string }
export const fetchMyFiles=()=>request.get<FileObject[]>({url:'/api/v1/portal/files'})
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0B2E6E"/>
<stop offset="1" stop-color="#071F4D"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00E4E5"/>
<stop offset="1" stop-color="#006EFF"/>
</linearGradient>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<!-- 网关路由连接线 -->
<g stroke="url(#accent)" stroke-width="20" stroke-linecap="round">
<line x1="256" y1="256" x2="158" y2="158"/>
<line x1="256" y1="256" x2="354" y2="158"/>
<line x1="256" y1="256" x2="256" y2="372"/>
</g>
<!-- 分支节点 -->
<g fill="#0E3A8C" stroke="url(#accent)" stroke-width="9">
<circle cx="158" cy="158" r="38"/>
<circle cx="354" cy="158" r="38"/>
<circle cx="256" cy="372" r="38"/>
</g>
<!-- 中心枢纽 -->
<circle cx="256" cy="256" r="70" fill="url(#accent)"/>
<circle cx="256" cy="256" r="70" fill="none" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="2"/>
<!-- 中心"语枢"标记:字母 A 与枢纽点结合 -->
<path d="M256 210 L220 304 H241 L256 268 L271 304 H292 L256 210 Z" fill="#FFFFFF"/>
<circle cx="256" cy="256" r="7" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 33 KiB

@@ -1 +1,25 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="44" y="42" width="312" height="217"><path d="M355.3 42H44v216.9h311.3V42Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M288.2 248.4h25.1v-30h-25.1v30Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.498 238.199c-1.5-3.9-5.9-15.4-4-21.6-2.9.8-3.3.1-5-.1-1.7-.1 0 10.7 2.2 16.4 1.7 4.5 2.1 11.1 2.1 13.6h5.4c.2-1.9.3-5.5-.7-8.3Z" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6M290.2 214.7h21.4c1 0 1.8.8 1.8 1.8v29" stroke="#071F4D" stroke-width="1.096"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" fill="#fff"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" stroke="#071F4D" stroke-width="1.096"/><path d="M295.402 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3M300.502 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m331 258.4-.3-5.2H88.5l-1.2 5.2H331Z" fill="#C7DEFF"/><path d="M252.9 248.7H331M216.6 258.4H331M47.1 139.3l-2.6 1.5 42.7 117.6h129.2v-6.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" fill="#fff"/><path d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" stroke="#071F4D"/><path d="m203.2 153.2 32.2 88.7H97.8l-32.3-88.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M72.2 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4ZM79.3 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M263.5 171.2h80.3v-63.7h-80.3v63.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M290 143.9h-45.6l12.5 51.3H290v-51.3Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M286 117.4h-29.3v77.8h92.9v-67.6l-55.9.6-7.7-10.8Z" fill="#00E4E5"/><path d="m332.6 127.6-38.9.6-7.7-10.8h-11.7M308.9 195.2h45.9M250.3 195.2h28.5M287.3 195.2h12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 211.4H186v-44h-55.5v44Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M148.7 192.5h-31.6l8.7 35.5h22.9v-35.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M145.9 174.2h-20.2V228h64.1v-46.7l-38.6.4-5.3-7.5Z" fill="#006EFF"/><path d="m179 181.3-27.8.4-5.3-7.5h-7.7M176.2 201.7h19.2M163.2 210.7H195M172.1 228h-54.2M184.8 228h8.1M174.9 228h5.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m293.2 155.7-6.4 6.3 15.3 15.3 22.7-22.6-6.4-6.4-16.3 16.3-8.9-8.9Z" fill="#fff"/><path d="M57.2 258.4h283.6M345.9 258.4h8.1M55.4 258.4h220.5M160.1 118.8l-1.2 2.7M156.7 127c-.3.8-.7 1.8-1.1 2.8M222 68.5c-1 .2-1.9.5-2.9.8M214.1 70.7c-5.8 1.9-11.3 4.4-16.5 7.4M195.4 79.5c-.9.5-1.7 1.1-2.5 1.6M314.2 98.5c-.6-.8-1.3-1.5-2-2.3M308.9 92.8c-4-4-8.3-7.6-13-10.8M293.9 80.7c-.8-.5-1.7-1.1-2.5-1.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.296 71.203c-3.6-1.5-18.5-2.9-21.8-1.9-1 5.8 4.9 13.5 4.9 13.5s6-9.9 16.9-11.6Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.3 42.704c-6.5 6.7-7.8 13-8.8 19.3 24.4-1.1 36.3 13 42.8 20 3.2-9.1 7.8-23 7.2-29-7.1-6.4-20-11.7-41.2-10.3Z" fill="#C7DEFF"/><path d="M230 69.3c36.2-3.8 52 21.1 52 21.1s11.4-28.2 10.5-37.4c-7.3-6.5-23.3-12-45.6-10.1-9 6.3-15.6 18.7-16.9 26.4Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.604 70.7c-6 8.4-9.9 21.9-8.8 33.8 8.4 5.3 32.3 10.5 43.6 11.5 6.1-7.9 15.9-26 15.9-26s-32-4.8-50.7-19.3Z" fill="#C7DEFF"/><path d="M193.103 119.5c4.8-2.7 19.2-29.5 19.2-29.5s-35.8-5.4-53.7-21.8c-9.3 6.1-16.4 24.3-15 40.1 10.6 6.7 45.8 13.3 49.5 11.2Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M189.5 111.6c-3 5.2-5.7 7.2-9.8 6.6 12.2 2.6 13.5 1.2 15.6-1.1 2.2-2.4 4.2-6.6 4.2-6.6s-3.1 2.5-10 1.1Z" fill="#071F4D"/><path d="M331 251.8v6.6M77 165.4l-2.7-6.7h7.8M222.8 228.9l2.8 6.6h-7.9" stroke="#071F4D"/></g></svg>
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0B2E6E"/>
<stop offset="1" stop-color="#071F4D"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00E4E5"/>
<stop offset="1" stop-color="#006EFF"/>
</linearGradient>
</defs>
<rect x="60" y="30" width="280" height="240" rx="56" fill="url(#bg)"/>
<g stroke="url(#accent)" stroke-width="12" stroke-linecap="round">
<line x1="200" y1="150" x2="140" y2="90"/>
<line x1="200" y1="150" x2="260" y2="90"/>
<line x1="200" y1="150" x2="200" y2="220"/>
</g>
<g fill="#0E3A8C" stroke="url(#accent)" stroke-width="5">
<circle cx="140" cy="90" r="22"/>
<circle cx="260" cy="90" r="22"/>
<circle cx="200" cy="220" r="22"/>
</g>
<circle cx="200" cy="150" r="42" fill="url(#accent)"/>
<path d="M200 122 L178 180 H191 L200 158 L209 180 H222 L200 122 Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,7 +1,7 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
<img :style="logoStyle" src="@imgs/common/logo.svg" alt="logo" class="w-full h-full" />
</div>
</template>
@@ -119,6 +119,11 @@
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
</ArtIconButton>
<!-- 站内消息铃铛M8 P4轮询未读数点击跳转收件箱 -->
<ElBadge :value="inboxUnread" :max="99" :hidden="inboxUnread === 0" class="inbox-badge">
<ArtIconButton icon="ri:mail-line" class="inbox-button relative" @click="goInbox" />
</ElBadge>
<!-- 聊天按钮 -->
<ArtIconButton
v-if="shouldShowChat"
@@ -182,6 +187,7 @@
import { themeAnimation } from '@/utils/ui/animation'
import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import { fetchInboxUnread } from '@/api/portal'
import ArtUserMenu from './widget/ArtUserMenu.vue'
defineOptions({ name: 'ArtHeaderBar' })
@@ -222,6 +228,21 @@
const showNotice = ref(false)
const notice = ref(null)
// 站内消息未读数(M8 P4):30s 轮询,点击铃铛跳转收件箱
const inboxUnread = ref(0)
let inboxTimer: ReturnType<typeof setInterval> | null = null
async function refreshInboxUnread() {
try {
const res = await fetchInboxUnread()
inboxUnread.value = res.unread
} catch {
// 未读徽标获取失败不打扰用户,下一轮重试
}
}
const goInbox = (): void => {
router.push('/portal/inbox')
}
// 菜单类型判断
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
@@ -233,10 +254,13 @@
onMounted(() => {
initLanguage()
document.addEventListener('click', bodyCloseNotice)
refreshInboxUnread()
inboxTimer = setInterval(refreshInboxUnread, 30000)
})
onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice)
if (inboxTimer) clearInterval(inboxTimer)
})
/**
@@ -477,6 +501,15 @@
animation: shake 0.5s ease-in-out;
}
.inbox-badge {
margin-left: 2px;
:deep(.el-badge__content) {
font-size: 10px;
border: none;
}
}
.chat-button:hover :deep(.art-svg-icon) {
animation: shake 0.5s ease-in-out;
}
@@ -3,11 +3,6 @@
<div
class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between"
>
<div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
<ArtLogo class="icon" size="46" />
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.shortName }}</h1>
</div>
<div class="flex-cc gap-1.5 mr-2 max-sm:mr-5">
<div class="color-picker-expandable relative flex-c max-sm:!hidden">
<div
@@ -27,7 +22,7 @@
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
<ArtSvgIcon
icon="ri:palette-line"
class="text-xl text-[#d3e8ff] transition-colors duration-300"
class="text-xl text-[#45636f] transition-colors duration-300"
/>
</div>
</div>
@@ -39,7 +34,7 @@
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
<ArtSvgIcon
icon="ri:translate-2"
class="text-[19px] text-[#d3e8ff] transition-colors duration-300"
class="text-[19px] text-[#45636f] transition-colors duration-300"
/>
</div>
<template #dropdown>
@@ -63,7 +58,7 @@
>
<ArtSvgIcon
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
class="text-xl text-[#d3e8ff] transition-colors duration-300"
class="text-xl text-[#45636f] transition-colors duration-300"
/>
</div>
</div>
@@ -105,9 +100,9 @@
</script>
<style scoped>
/* 深色 HUD 登录页:工具栏按钮玻璃底 + 青色描边 */
/* 深浅分屏登录页:工具栏位于浅色侧,使用低对比描边 */
h1 {
color: #eaf6ff;
color: #12384a;
font-weight: 600;
letter-spacing: 0.02em;
}
@@ -115,15 +110,15 @@
.palette-btn,
.language-btn,
.theme-btn {
background: rgb(7 17 38 / 55%);
border: 1px solid rgb(0 229 255 / 25%);
background: rgb(255 255 255 / 75%);
border: 1px solid #d6e4e9;
border-radius: 8px;
box-shadow: 0 0 12px rgb(0 229 255 / 8%);
box-shadow: 0 5px 18px rgb(35 85 100 / 7%);
transition: all 0.25s;
&:hover {
border-color: #00e5ff;
box-shadow: 0 0 16px rgb(0 229 255 / 40%);
border-color: #52a9b9;
box-shadow: 0 6px 18px rgb(35 120 140 / 13%);
}
}
@@ -1,453 +1,287 @@
<!-- 登录注册忘记密码左侧背景色安全 HUD -->
<!-- 授权页左侧品牌区海军蓝安全控制台风格 -->
<template>
<div class="login-left-view">
<!-- HUD 背景层深藏青渐变 + 网格 + 辉光 -->
<section class="login-left-view" aria-label="LLMGuardX 品牌介绍">
<div class="hud-bg"></div>
<div class="hud-grid"></div>
<div class="hud-glow glow-a"></div>
<div class="hud-glow glow-b"></div>
<div class="hud-scan"></div>
<span class="frame-line frame-top"></span>
<span class="frame-line frame-left"></span>
<span class="frame-line frame-right"></span>
<span class="frame-line frame-bottom"></span>
<!-- HUD 四角括线 -->
<span class="hud-corner corner-tl"></span>
<span class="hud-corner corner-tr"></span>
<span class="hud-corner corner-bl"></span>
<span class="hud-corner corner-br"></span>
<!-- 顶栏Logo + 主题切换 -->
<div class="top-row">
<div class="logo">
<ArtLogo class="icon" size="46" />
<h1 class="title">{{ AppConfig.systemInfo.shortName }}</h1>
</div>
<button type="button" class="theme-toggle" :title="$t('login.toggleTheme')" @click="themeAnimation">
<span class="tt-orb"></span>
</button>
<div class="eyebrow">
<span class="eyebrow-dot"></span>
<span>LLM SECURITY &amp; GOVERNANCE</span>
</div>
<!-- 中央雷达插画 -->
<div class="radar-wrap">
<span class="radar-ring ring-1"></span>
<span class="radar-ring ring-2"></span>
<span class="radar-sweep"></span>
<div class="radar-core">
<ThemeSvg :src="loginIcon" size="100%" />
</div>
</div>
<!-- 文案 -->
<div class="text-wrap">
<h1>{{ $t('login.leftView.title') }}</h1>
<h1>
<span>{{ $t('login.leftView.headlineMain') }}</span>
<strong>{{ $t('login.leftView.headlineAccent') }}</strong>
</h1>
<p>{{ $t('login.leftView.subTitle') }}</p>
</div>
<!-- 底部 HUD tagline -->
<div class="hud-tagline">
<span class="tag-dot"></span>
<span class="tag-text">SECURE LLM GATEWAY</span>
<i class="tag-sep">//</i>
<span class="tag-text">{{ $t('login.tagline') }}</span>
<div class="radar-wrap" aria-hidden="true">
<span class="radar-ring ring-1"></span>
<span class="radar-ring ring-2"></span>
<span class="radar-ring ring-3"></span>
<span class="radar-sweep"></span>
<span class="signal signal-a"></span>
<span class="signal signal-b"></span>
<span class="signal signal-c"></span>
<div class="radar-core"><ArtLogo size="48" /></div>
</div>
<!-- 数据粒子 -->
<span v-for="n in 10" :key="n" class="particle" :style="{ '--i': n }"></span>
</div>
<div class="hud-tagline">
<span class="tag-dot"></span>
<span>SECURE LLM GATEWAY</span>
<i>//</i>
<span>{{ $t('login.tagline') }}</span>
</div>
</section>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import loginIcon from '@imgs/svg/login_icon.svg'
import { themeAnimation } from '@/utils/ui/animation'
// 定义 props
defineProps<{
hideContent?: boolean // 是否隐藏内容,只显示 logo
}>()
defineProps<{ hideContent?: boolean }>()
</script>
<style lang="scss" scoped>
// 语枢网关 HUD 色板(登录页恒定深色,不随系统主题变化)
$cyber-bg: #050b18;
$cyber-accent: #00e5ff;
$cyber-blue: #2f80ff;
$cyber-green: #00ff9c;
$cyber-text: #eaf6ff;
$accent: #42dce8;
$green: #19d8bd;
.login-left-view {
position: relative;
box-sizing: border-box;
width: 65vw;
width: 54.5vw;
height: 100%;
padding: 15px;
min-height: 620px;
overflow: hidden;
color: $cyber-text;
background-color: $cyber-bg;
color: #ecfaff;
background: #041a29;
}
// 深藏青渐变底
.hud-bg {
position: absolute;
inset: 0;
background:
radial-gradient(1100px 640px at 78% -10%, rgb(0 229 255 / 14%), transparent 60%),
radial-gradient(900px 560px at -8% 108%, rgb(47 128 255 / 16%), transparent 55%),
radial-gradient(560px 380px at 86% 96%, rgb(0 255 156 / 6%), transparent 60%),
linear-gradient(160deg, #071226 0%, #050b18 55%, #040810 100%);
}
.hud-bg,
.hud-grid,
.hud-glow,
.frame-line {
position: absolute;
pointer-events: none;
}
// 极细暗网格
.hud-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgb(0 229 255 / 5%) 1px, transparent 1px),
linear-gradient(90deg, rgb(0 229 255 / 5%) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(ellipse at center, rgb(0 0 0 / 85%), transparent 78%);
}
.hud-bg {
inset: 0;
background:
radial-gradient(620px 500px at 50% 30%, rgb(20 132 162 / 12%), transparent 72%),
linear-gradient(135deg, #062438 0%, #031927 58%, #031420 100%);
}
// 青色辉光光斑
.hud-glow {
position: absolute;
border-radius: 50%;
filter: blur(70px);
pointer-events: none;
.hud-grid {
inset: 0;
opacity: 0.42;
background-image:
linear-gradient(rgb(71 205 221 / 7%) 1px, transparent 1px),
linear-gradient(90deg, rgb(71 205 221 / 7%) 1px, transparent 1px);
background-size: 66px 66px;
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
&.glow-a {
top: 6%;
right: -80px;
width: 360px;
height: 360px;
background: rgb(0 229 255 / 16%);
}
.hud-glow {
position: absolute;
border-radius: 50%;
filter: blur(86px);
}
&.glow-b {
bottom: -140px;
left: 12%;
width: 420px;
height: 420px;
background: rgb(47 128 255 / 18%);
}
}
.glow-a {
top: 12%;
right: 6%;
width: 360px;
height: 360px;
background: rgb(31 201 218 / 8%);
}
// 垂直扫描线
.hud-scan {
position: absolute;
inset: -40% 0;
background: linear-gradient(180deg, transparent 0%, rgb(0 229 255 / 6%) 50%, transparent 100%);
pointer-events: none;
animation: scanMove 7s linear infinite;
}
.glow-b {
bottom: -12%;
left: 18%;
width: 420px;
height: 420px;
background: rgb(22 119 170 / 11%);
}
// HUD 四角括线
.hud-corner {
position: absolute;
z-index: 4;
width: 22px;
height: 22px;
border: 0 solid rgb(0 229 255 / 70%);
pointer-events: none;
.frame-line {
z-index: 2;
background: rgb(68 202 219 / 12%);
}
&.corner-tl {
top: 16px;
left: 16px;
border-top-width: 2px;
border-left-width: 2px;
}
.frame-top,
.frame-bottom {
right: 7%;
left: 6.5%;
height: 1px;
}
&.corner-tr {
top: 16px;
right: 16px;
border-top-width: 2px;
border-right-width: 2px;
}
.frame-left,
.frame-right {
top: 7%;
bottom: 6.5%;
width: 1px;
}
&.corner-bl {
bottom: 16px;
left: 16px;
border-bottom-width: 2px;
border-left-width: 2px;
}
.frame-top { top: 7%; }
.frame-bottom { bottom: 6.5%; }
.frame-left { left: 6.5%; }
.frame-right { right: 7%; }
&.corner-br {
right: 16px;
bottom: 16px;
border-right-width: 2px;
border-bottom-width: 2px;
}
}
.eyebrow {
position: absolute;
top: 15.5%;
left: 21%;
z-index: 3;
display: flex;
align-items: center;
gap: 12px;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
color: $accent;
}
// 顶栏
.top-row {
position: relative;
z-index: 100;
.eyebrow-dot,
.tag-dot,
.signal {
display: block;
width: 6px;
height: 6px;
background: $accent;
border-radius: 50%;
box-shadow: 0 0 10px $accent;
}
.text-wrap {
position: absolute;
top: 21%;
left: 21%;
z-index: 3;
max-width: 610px;
animation: reveal 0.65s ease-out both;
h1 {
display: flex;
align-items: center;
justify-content: space-between;
.logo {
display: flex;
align-items: center;
.title {
margin-left: 10px;
font-size: 20px;
font-weight: 400;
letter-spacing: 1px;
color: $cyber-text;
text-shadow: 0 0 16px rgb(0 229 255 / 30%);
}
}
// HUD 主题切换按钮(圆形仪表风格)
.theme-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
cursor: pointer;
background: rgb(10 25 48 / 60%);
border: 1px solid rgb(0 229 255 / 35%);
border-radius: 50%;
transition: all 0.25s;
.tt-orb {
width: 10px;
height: 10px;
background: $cyber-accent;
border-radius: 50%;
box-shadow: 0 0 12px $cyber-accent;
}
&:hover {
border-color: $cyber-accent;
box-shadow: 0 0 16px rgb(0 229 255 / 40%);
}
}
flex-direction: column;
margin: 0;
font-size: clamp(42px, 3.55vw, 68px);
font-weight: 760;
line-height: 1.14;
letter-spacing: -0.04em;
color: #effcff;
}
// 中央雷达插画
.radar-wrap {
position: absolute;
inset: 0 0 12%;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 320px;
height: 320px;
margin: auto;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
.radar-core {
position: relative;
z-index: 2;
width: 74%;
height: 74%;
filter: drop-shadow(0 0 24px rgb(0 229 255 / 35%));
animation: floaty 6s ease-in-out infinite;
}
.radar-ring {
position: absolute;
border-radius: 50%;
border: 1px solid rgb(0 229 255 / 22%);
&.ring-1 {
inset: 6%;
}
&.ring-2 {
inset: 16%;
border-style: dashed;
animation: spin 26s linear infinite;
}
}
.radar-sweep {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from 0deg, rgb(0 229 255 / 22%), transparent 22%);
mask-image: radial-gradient(circle, rgb(0 0 0 / 85%), transparent 72%);
animation: spin 5s linear infinite;
}
strong {
font-weight: inherit;
color: $accent;
text-shadow: 0 0 34px rgb(66 220 232 / 13%);
}
// 文案
.text-wrap {
position: absolute;
bottom: 92px;
z-index: 3;
width: 100%;
text-align: center;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
h1 {
font-size: 24px;
font-weight: 600;
letter-spacing: 2px;
color: $cyber-text;
text-shadow: 0 0 18px rgb(0 229 255 / 40%);
}
p {
margin-top: 12px;
font-size: 14px;
letter-spacing: 1px;
color: rgb(0 229 255 / 78%);
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
}
}
// 底部 HUD tagline
.hud-tagline {
position: absolute;
bottom: 26px;
left: 50%;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
letter-spacing: 1px;
color: rgb(0 229 255 / 85%);
background: rgb(0 229 255 / 4%);
border: 1px solid rgb(0 229 255 / 20%);
border-radius: 4px;
box-shadow: 0 0 12px rgb(0 229 255 / 10%);
transform: translateX(-50%);
.tag-dot {
width: 6px;
height: 6px;
background: $cyber-green;
border-radius: 50%;
box-shadow: 0 0 8px $cyber-green;
animation: blink 2s ease-in-out infinite;
}
.tag-sep {
font-style: normal;
color: rgb(0 229 255 / 45%);
}
}
// 数据粒子
.particle {
position: absolute;
bottom: -10px;
left: calc(var(--i) * 9.1% + 2%);
z-index: 2;
width: 3px;
height: 3px;
background: rgb(0 229 255 / 80%);
border-radius: 50%;
box-shadow: 0 0 6px $cyber-accent;
opacity: 0;
animation: floatUp 8s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.8s);
}
@media only screen and (width <= 1600px) {
width: 60vw;
.text-wrap {
bottom: 64px;
}
.radar-wrap {
inset: 0 0 10%;
}
}
@media only screen and (width <= 1180px) {
width: auto;
height: auto;
padding: 0;
background: transparent;
.top-row,
.radar-wrap,
.text-wrap,
.hud-tagline,
.particle,
.hud-bg,
.hud-grid,
.hud-glow,
.hud-scan,
.hud-corner {
display: none;
}
p {
max-width: 570px;
margin-top: 24px;
font-size: 15px;
line-height: 2;
letter-spacing: 0.04em;
color: rgb(153 205 224 / 82%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
.radar-wrap {
position: absolute;
bottom: 8.5%;
left: 36%;
z-index: 3;
width: 280px;
height: 280px;
}
@keyframes scanMove {
0% {
transform: translateY(-50%);
}
100% {
transform: translateY(50%);
}
.radar-ring,
.radar-sweep,
.radar-core,
.signal {
position: absolute;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
.radar-ring {
border: 1px solid rgb(70 207 222 / 11%);
border-radius: 50%;
}
@keyframes floaty {
0%,
100% {
transform: translateY(0);
}
.ring-1 { inset: 0; }
.ring-2 { inset: 14%; }
.ring-3 { inset: 29%; }
50% {
transform: translateY(-8px);
}
.radar-sweep {
inset: 0;
border-radius: 50%;
background: conic-gradient(from 10deg, rgb(64 218 230 / 13%), transparent 22%);
mask-image: radial-gradient(circle, #000 0 68%, transparent 72%);
animation: spin 8s linear infinite;
}
@keyframes floatUp {
0% {
opacity: 0;
transform: translateY(0) scale(1);
}
.radar-core {
inset: 36%;
display: grid;
place-items: center;
border: 1px solid rgb(66 220 232 / 38%);
border-radius: 24%;
filter: drop-shadow(0 0 16px rgb(66 220 232 / 30%));
transform: rotate(45deg);
12% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translateY(-46vh) scale(0.4);
}
:deep(*) { transform: rotate(-45deg); }
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
.signal { animation: pulse 2.6s ease-in-out infinite; }
.signal-a { top: 10%; left: 24%; }
.signal-b { right: 5%; bottom: 33%; animation-delay: -0.8s; }
.signal-c { bottom: 5%; left: 17%; animation-delay: -1.6s; }
to {
opacity: 1;
transform: translateX(0);
}
.hud-tagline {
position: absolute;
bottom: 3.6%;
left: 21%;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 10px;
letter-spacing: 0.12em;
color: rgb(113 191 211 / 68%);
.tag-dot { width: 5px; height: 5px; background: $green; box-shadow: 0 0 9px $green; }
i { font-style: normal; color: rgb(66 220 232 / 28%); }
}
@media (width <= 1500px) {
.eyebrow,
.text-wrap,
.hud-tagline { left: 15%; }
.radar-wrap { left: 33%; width: 240px; height: 240px; }
}
@media (width <= 1180px) {
.login-left-view { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.radar-sweep,
.signal,
.text-wrap { animation: none; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: 0.25; transform: scale(0.75); } }
@keyframes reveal {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+9 -3
View File
@@ -12,7 +12,9 @@
"requestCancelled": "Request cancelled",
"networkError": "Network connection error, please check your connection",
"requestFailed": "Request failed",
"requestConfigError": "Request configuration error"
"requestConfigError": "Request configuration error",
"badRequest": "Bad request",
"tooManyRequests": "Too many requests"
},
"topBar": {
"search": {
@@ -151,12 +153,16 @@
"login": {
"leftView": {
"title": "Unified LLM Access Gateway",
"subTitle": "Unified auth · rate limiting · quota · security audit"
"headlineMain": "Connect every model.",
"headlineAccent": "Protect every call.",
"subTitle": "Unify access to models and AI resources while governing identity, quota, content risk, and audit trails."
},
"title": "Welcome back",
"subTitle": "Secure access · unified governance",
"tagline": "Encrypted transport · zero-trust access",
"toggleTheme": "Toggle theme",
"accountLabel": "Account",
"passwordLabel": "Password",
"roles": {
"super": "Super Admin",
"admin": "Admin",
@@ -169,7 +175,7 @@
"rememberPwd": "Remember password",
"securityBadge": "AES-256 encryption · TLS 1.3 · login audit",
"forgetPwd": "Forgot password",
"btnText": "Login",
"btnText": "Enter platform",
"noAccount": "No account yet?",
"register": "Register",
"success": {
+9 -3
View File
@@ -12,7 +12,9 @@
"requestCancelled": "请求已取消",
"networkError": "网络连接异常,请检查网络连接",
"requestFailed": "请求失败",
"requestConfigError": "请求配置错误"
"requestConfigError": "请求配置错误",
"badRequest": "请求无效,请检查输入",
"tooManyRequests": "请求过于频繁,请稍后再试"
},
"topBar": {
"search": {
@@ -151,12 +153,16 @@
"login": {
"leftView": {
"title": "大模型统一接入网关",
"subTitle": "统一鉴权 · 限流 · 配额 · 安全审计"
"headlineMain": "连接每个模型,",
"headlineAccent": "守住每次调用。",
"subTitle": "统一接入大模型与 AI 资源,持续治理身份、配额、内容风险与调用审计。"
},
"title": "欢迎回来",
"subTitle": "安全接入 · 统一治理",
"tagline": "数据加密传输 · 零信任接入",
"toggleTheme": "切换主题",
"accountLabel": "账号",
"passwordLabel": "密码",
"roles": {
"super": "超级管理员",
"admin": "管理员",
@@ -169,7 +175,7 @@
"rememberPwd": "记住密码",
"securityBadge": "AES-256 加密 · TLS 1.3 · 登录安全审计",
"forgetPwd": "忘记密码",
"btnText": "登录",
"btnText": "进入平台",
"noAccount": "还没有账号?",
"register": "注册",
"success": {
+5 -1
View File
@@ -229,7 +229,11 @@ export const useUserStore = defineStore(
{
persist: {
key: 'user',
storage: localStorage
storage: sessionStorage,
// 仅持久化必要的 UI 状态与会话凭证:账户信息(info)、锁屏密码等
// 敏感数据不再落盘。sessionStorage 随浏览器会话关闭自动清理,
// 避免令牌/密码长期驻留本地被 XSS 或本地访问窃取。
pick: ['isLogin', 'accessToken', 'refreshToken', 'language']
}
}
)
+2
View File
@@ -4,6 +4,8 @@
export enum ApiStatus {
success = 200, // 成功
error = 400, // 错误
badRequest = 400, // 请求无效
tooManyRequests = 429, // 请求过于频繁
unauthorized = 401, // 未授权
forbidden = 403, // 禁止访问
notFound = 404, // 未找到
+39 -27
View File
@@ -3,11 +3,16 @@
<div class="login-page flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<div class="auth-stage relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<div class="login-card-brand">
<ArtLogo size="34" />
<span>{{ AppConfig.systemInfo.shortName }}</span>
<small>用户门户</small>
</div>
<h3 class="title">{{ $t('login.title') }}</h3>
<p class="sub-title">{{ $t('login.subTitle') }}</p>
<ElForm
@@ -30,6 +35,7 @@
</ElOption>
</ElSelect>
</ElFormItem>
<div class="field-label">{{ $t('login.accountLabel') }}</div>
<ElFormItem prop="username">
<ElInput
class="custom-height"
@@ -37,6 +43,7 @@
v-model.trim="formData.username"
/>
</ElFormItem>
<div class="field-label">{{ $t('login.passwordLabel') }}</div>
<ElFormItem prop="password">
<ElInput
class="custom-height"
@@ -83,7 +90,7 @@
</div>
</template>
<div class="mt-5 text-sm text-[#a9c2e6]">
<div class="login-register-row mt-5 text-sm">
<span>{{ $t('login.noAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Register' }">{{
$t('login.register')
@@ -311,74 +318,79 @@
</style>
<style lang="scss">
// 语枢网关登录页:深色 HUD 背景与 Element Plus 控件(登录页恒定深色,不随主题变化)
// 参考安全运营平台的深浅分屏:左侧 HUD,右侧明亮登录卡片。
.login-page {
background:
radial-gradient(900px 520px at 100% -20%, rgb(0 229 255 / 10%), transparent 60%),
#050b18;
background: #f3f8fa;
.auth-stage {
min-width: 0;
background:
radial-gradient(520px 380px at 58% 50%, rgb(74 172 191 / 9%), transparent 72%),
#f3f8fa;
}
// 输入框
.el-input__wrapper,
.el-select__wrapper {
background-color: rgb(5 15 35 / 80%) !important;
border-radius: 8px;
box-shadow: 0 0 0 1px rgb(0 229 255 / 22%) inset !important;
background-color: #fff !important;
border-radius: 9px;
box-shadow: 0 0 0 1px #c8dce3 inset !important;
transition: box-shadow 0.25s;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused {
box-shadow:
0 0 0 1px #00e5ff inset,
0 0 14px rgb(0 229 255 / 35%) !important;
0 0 0 1px #13889d inset,
0 0 0 4px rgb(19 136 157 / 9%) !important;
}
.el-input__inner,
.el-select__placeholder,
.el-select__selected-item {
color: #eaf6ff !important;
caret-color: #00e5ff;
color: #0b2b3d !important;
caret-color: #13889d;
}
.el-input__inner::placeholder {
color: rgb(160 190 225 / 50%);
color: #8ba4af;
}
// 复选框
.el-checkbox__label {
color: #cfe4ff;
color: #58717d;
}
// 登录按钮(青色渐变发光)
.el-button--primary {
color: #03141f;
color: #fff;
font-weight: 600;
letter-spacing: 2px;
background-image: linear-gradient(120deg, #00e5ff, #2f80ff);
background: #0e8197;
border: none;
box-shadow: 0 6px 22px rgb(0 229 255 / 35%);
box-shadow: 0 8px 18px rgb(14 129 151 / 20%);
&:hover,
&:focus {
color: #03141f;
background-image: linear-gradient(120deg, #1be9ff, #4d93ff);
box-shadow: 0 8px 30px rgb(0 229 255 / 50%);
color: #fff;
background: #0a6e82;
box-shadow: 0 10px 24px rgb(14 129 151 / 28%);
}
}
// SSO 登录按钮
.el-button:not(.el-button--primary) {
color: #cfe4ff;
background: rgb(0 229 255 / 6%);
border: 1px solid rgb(0 229 255 / 22%);
color: #235263;
background: #f8fbfc;
border: 1px solid #cbdde3;
}
.el-divider {
border-color: rgb(0 229 255 / 18%);
border-color: #dce7eb;
.el-divider__text {
color: #a9c2e6;
background: transparent;
color: #78909b;
background: #fff;
}
}
}
+77 -63
View File
@@ -1,104 +1,118 @@
@reference '@styles/core/tailwind.css';
/* 授权页右侧区域(语枢网关深色 HUD 玻璃面板) */
.auth-right-wrap {
position: absolute;
inset: 0;
width: 440px;
height: 640px;
width: min(438px, calc(100% - 48px));
min-height: 508px;
height: fit-content;
margin: auto;
padding: 40px 48px;
padding: 40px 42px 54px;
overflow: hidden;
background: rgb(7 17 38 / 72%);
backdrop-filter: blur(14px);
border: 1px solid rgb(0 229 255 / 16%);
background: rgb(255 255 255 / 96%);
border: 1px solid #d7e5ea;
border-radius: 14px;
box-shadow:
0 0 40px rgb(0 229 255 / 8%),
inset 0 0 60px rgb(0 229 255 / 3%);
animation: slideInRight 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
box-shadow: 0 24px 58px rgb(25 72 88 / 15%);
animation: cardReveal 0.55s ease-out both;
.form {
height: 100%;
.login-card-brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 34px;
color: #082f43;
span {
font-size: 20px;
font-weight: 750;
letter-spacing: 0.01em;
}
small {
padding-left: 9px;
font-size: 12px;
color: #78919d;
border-left: 1px solid #d6e3e8;
}
}
.form { height: 100%; }
.title {
font-size: 32px;
font-weight: 600;
letter-spacing: 1px;
color: #f0f7ff;
text-shadow: 0 0 20px rgb(0 229 255 / 45%);
margin: 0;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
color: #082b3d;
}
.sub-title {
margin-top: 10px;
font-size: 14px;
letter-spacing: 1px;
color: rgb(0 229 255 / 75%);
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
margin-top: 8px;
font-size: 13px;
letter-spacing: 0.02em;
color: #8298a3;
}
.custom-height {
height: 40px !important;
.field-label {
margin: 2px 0 8px;
font-size: 12px;
font-weight: 600;
color: #264b5a;
}
/* 安全徽标 */
.custom-height { height: 40px !important; }
.text-theme { color: #0e8197; }
.login-register-row { color: #8298a3; }
.security-badge {
position: absolute;
right: 0;
bottom: 14px;
bottom: 22px;
left: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
letter-spacing: 1px;
color: rgb(0 229 255 / 70%);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 9px;
letter-spacing: 0.11em;
color: #8199a3;
.sb-dot {
width: 6px;
height: 6px;
background: #00ff9c;
width: 5px;
height: 5px;
background: #19cbb2;
border-radius: 50%;
box-shadow: 0 0 8px #00ff9c;
animation: sbBlink 2s ease-in-out infinite;
box-shadow: 0 0 8px rgb(25 203 178 / 60%);
animation: sbBlink 2.2s ease-in-out infinite;
}
}
@media only screen and (width < 640px) {
width: 100%;
height: auto;
padding: 32px 24px 64px;
}
@media (width < 640px) {
width: calc(100% - 32px);
min-height: auto;
padding: 32px 24px 58px;
border-radius: 12px;
@media only screen and (width < 768px) {
animation: none;
.login-card-brand { margin-bottom: 28px; }
}
}
/* 滑入动画 */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
@media (width <= 1180px) {
.auth-right-wrap { max-width: 438px; }
}
@media (prefers-reduced-motion: reduce) {
.auth-right-wrap,
.security-badge .sb-dot { animation: none; }
}
@keyframes cardReveal {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* 安全徽标呼吸 */
@keyframes sbBlink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
50% { opacity: 0.35; }
}
@@ -0,0 +1,61 @@
<template>
<div class="page-content">
<div class="mb-5 flex justify-between">
<div>
<h2 class="text-xl font-semibold">站内消息</h2>
<p class="text-g-500 mt-1 text-sm">审批进度资源安装与系统通知的收件箱</p>
</div>
<ElButton type="primary" plain @click="markAllRead">全部已读</ElButton>
</div>
<ElTable v-loading="loading" :data="messages">
<ElTableColumn label="状态" width="90">
<template #default="{row}">
<ElTag :type="row.read_at ? 'info' : 'primary'" size="small">{{ row.read_at ? '已读' : '未读' }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="类型" width="110">
<template #default="{row}">{{ categoryLabel(row.category) }}</template>
</ElTableColumn>
<ElTableColumn prop="title" label="标题" min-width="200" show-overflow-tooltip/>
<ElTableColumn prop="body" label="内容" min-width="260" show-overflow-tooltip/>
<ElTableColumn prop="created_at" label="时间" width="180"/>
<ElTableColumn label="操作" width="150" fixed="right"><template #default="{row}">
<ElButton v-if="!row.read_at" link type="primary" @click="read(row)">标为已读</ElButton>
<ElButton v-if="row.link" link type="primary" @click="goLink(row)">前往</ElButton>
</template></ElTableColumn>
</ElTable>
<ElEmpty v-if="!loading && messages.length === 0" description="暂无消息" class="mt-10"/>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { fetchInbox, markInboxAllRead, markInboxRead } from '@/api/portal'
import type { InboxMessage } from '@/api/portal'
const loading = ref(false)
const router = useRouter()
const messages = ref<InboxMessage[]>([])
const categoryMap: Record<string, string> = { system: '系统', approval: '审批', task_result: '任务结果', resource: '资源' }
function categoryLabel(c: string) { return categoryMap[c] ?? c }
async function load() {
loading.value = true
try { messages.value = await fetchInbox() } finally { loading.value = false }
}
async function read(row: InboxMessage) {
await markInboxRead(row.id)
row.read_at = new Date().toISOString()
}
async function markAllRead() {
await markInboxAllRead()
ElMessage.success('已全部标记为已读')
await load()
}
function goLink(row: InboxMessage) {
if (row.link.startsWith('/')) return router.push(row.link)
const target = new URL(row.link)
if (target.protocol === 'http:' || target.protocol === 'https:') {
window.open(target.href, '_blank', 'noopener,noreferrer')
}
}
onMounted(load)
</script>
@@ -1,11 +1,11 @@
<template>
<div class="page-content">
<div class="mb-5 flex justify-between"><div><h2 class="text-xl font-semibold">我的用量</h2><p class="text-g-500 mt-1 text-sm">统计仅包含归属于你账号的 API Key</p></div><ElSegmented v-model="days" :options="[{label:'今日',value:1},{label:'近 7 天',value:7},{label:'近 30 天',value:30}]" @change="load"/></div>
<div class="mb-5 grid grid-cols-2 gap-4 lg:grid-cols-4"><ElCard shadow="never"><div class="text-g-500 text-sm">请求数</div><b class="mt-2 block text-2xl">{{stats.requests}}</b></ElCard><ElCard shadow="never"><div class="text-g-500 text-sm"> Tokens</div><b class="mt-2 block text-2xl">{{stats.total_tokens}}</b></ElCard><ElCard shadow="never"><div class="text-g-500 text-sm">失败请求</div><b class="mt-2 block text-2xl">{{stats.failed_requests}}</b></ElCard><ElCard shadow="never"><div class="text-g-500 text-sm">费用微单位</div><b class="mt-2 block text-2xl">{{stats.cost_microunits}}</b></ElCard></div>
<div class="mb-5 grid grid-cols-2 gap-4 lg:grid-cols-4"><ElCard shadow="never"><div class="text-g-500 text-sm">请求数</div><b class="mt-2 block text-2xl">{{stats.requests}}</b></ElCard><ElCard shadow="never"><div class="text-g-500 text-sm"> Tokens</div><b class="mt-2 block text-2xl">{{stats.total_tokens}}</b></ElCard><ElCard shadow="never"><div class="text-g-500 text-sm">失败请求</div><b class="mt-2 block text-2xl">{{stats.failed_requests}}</b></ElCard><ElCard shadow="never"><div class="text-g-500 text-sm">估算费用</div><b class="mt-2 block text-2xl">{{ costText(stats.cost_microunits) }}</b></ElCard></div>
<ElTable v-loading="loading" :data="logs"><ElTableColumn prop="recorded_at" label="时间" width="200"/><ElTableColumn prop="model" label="模型" min-width="170"/><ElTableColumn prop="protocol" label="协议" width="130"/><ElTableColumn prop="status_code" label="状态" width="90"><template #default="{row}"><ElTag :type="row.status_code<400?'success':'danger'">{{row.status_code||'-'}}</ElTag></template></ElTableColumn><ElTableColumn label="Tokens" width="120"><template #default="{row}">{{(row.prompt_tokens||0)+(row.completion_tokens||0)}}</template></ElTableColumn><ElTableColumn prop="latency_ms" label="耗时(ms)" width="110"/><ElTableColumn prop="request_id" label="Request ID" min-width="220"/></ElTable>
</div>
</template>
<script setup lang="ts">
import{AuditEvent,Stats,fetchLogs,fetchStats}from'@/api/portal';const loading=ref(false),days=ref(7),logs=ref<AuditEvent[]>([]),stats=reactive<Stats>({days:7,requests:0,failed_requests:0,prompt_tokens:0,completion_tokens:0,total_tokens:0,cost_microunits:0});async function load(){loading.value=true;try{const[s,l]=await Promise.all([fetchStats(days.value),fetchLogs(days.value)]);Object.assign(stats,s);logs.value=l.items}finally{loading.value=false}}onMounted(load)
import{AuditEvent,Stats,fetchLogs,fetchStats}from'@/api/portal';const loading=ref(false),days=ref(7),logs=ref<AuditEvent[]>([]),stats=reactive<Stats>({days:7,requests:0,failed_requests:0,prompt_tokens:0,completion_tokens:0,total_tokens:0,cost_microunits:0});function costText(value:number|null|undefined){return value!=null&&value>0?`USD ${(value/1e6).toFixed(6)}`:'—'}async function load(){loading.value=true;try{const[s,l]=await Promise.all([fetchStats(days.value),fetchLogs(days.value)]);Object.assign(stats,s);logs.value=l.items}finally{loading.value=false}}onMounted(load)
</script>