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 币种维度)
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 33 KiB |
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -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`
|
||||
|
||||
@@ -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` })
|
||||
@@ -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}` })
|
||||
}
|
||||
|
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 |
|
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 & 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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -229,7 +229,11 @@ export const useUserStore = defineStore(
|
||||
{
|
||||
persist: {
|
||||
key: 'user',
|
||||
storage: localStorage
|
||||
storage: sessionStorage,
|
||||
// 仅持久化必要的 UI 状态与会话凭证:账户信息(info)、锁屏密码等
|
||||
// 敏感数据不再落盘。sessionStorage 随浏览器会话关闭自动清理,
|
||||
// 避免令牌/密码长期驻留本地被 XSS 或本地访问窃取。
|
||||
pick: ['isLogin', 'accessToken', 'refreshToken', 'language']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export enum ApiStatus {
|
||||
success = 200, // 成功
|
||||
error = 400, // 错误
|
||||
badRequest = 400, // 请求无效
|
||||
tooManyRequests = 429, // 请求过于频繁
|
||||
unauthorized = 401, // 未授权
|
||||
forbidden = 403, // 禁止访问
|
||||
notFound = 404, // 未找到
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 33 KiB |
@@ -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 |
|
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 & 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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -229,7 +229,11 @@ export const useUserStore = defineStore(
|
||||
{
|
||||
persist: {
|
||||
key: 'user',
|
||||
storage: localStorage
|
||||
storage: sessionStorage,
|
||||
// 仅持久化必要的 UI 状态与会话凭证:账户信息(info)、锁屏密码等
|
||||
// 敏感数据不再落盘。sessionStorage 随浏览器会话关闭自动清理,
|
||||
// 避免令牌/密码长期驻留本地被 XSS 或本地访问窃取。
|
||||
pick: ['isLogin', 'accessToken', 'refreshToken', 'language']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export enum ApiStatus {
|
||||
success = 200, // 成功
|
||||
error = 400, // 错误
|
||||
badRequest = 400, // 请求无效
|
||||
tooManyRequests = 429, // 请求过于频繁
|
||||
unauthorized = 401, // 未授权
|
||||
forbidden = 403, // 禁止访问
|
||||
notFound = 404, // 未找到
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||