From f01286a89d97a3db24a737c9af0ff82c42b637c6 Mon Sep 17 00:00:00 2001 From: shanqin <45168502@qq.com> Date: Wed, 26 Aug 2026 13:29:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=BB=9F=E4=B8=80=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E4=B8=AD=E5=BF=83(Keycloak,ADR-012);=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E6=A8=A1=E5=9D=97=20L1/L2=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E6=80=81=E6=A8=A1=E7=89=88(=E5=8E=86=E5=8F=B2=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E5=88=97=E8=A1=A8+=E6=A3=80=E7=B4=A2+=E6=B2=99?= =?UTF-8?q?=E7=AE=B1);=E6=95=B0=E6=8D=AE=E5=9F=9F=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E7=95=8C=E9=9D=A2=E6=94=B9=E7=89=88;?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=20/me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/.env.development | 6 + apps/web/.env.production | 6 + apps/web/package.json | 1 + .../public/sandbox-demo/activity-view.html | 99 +++ apps/web/public/silent-check-sso.html | 14 + apps/web/src/app/app-shell.tsx | 137 ++-- apps/web/src/app/gov-shell.tsx | 68 +- apps/web/src/app/main.tsx | 103 ++- apps/web/src/app/query.ts | 11 +- apps/web/src/app/styles/app.css | 116 +++- apps/web/src/app/styles/console.css | 306 ++++++++- apps/web/src/app/subscribe.ts | 59 +- .../src/features/auth/internal/LoginPage.tsx | 82 +-- apps/web/src/features/desk-me/index.ts | 5 + .../src/features/desk-me/internal/MePage.tsx | 216 ++++++ apps/web/src/features/desk-task/index.ts | 1 + .../desk-task/internal/FileViewer.tsx | 29 +- .../features/desk-task/internal/TaskList.tsx | 28 +- apps/web/src/features/gov-data/index.ts | 2 +- .../src/features/gov-data/internal/panel.tsx | 257 +++++-- apps/web/src/features/gov-task/index.ts | 2 +- .../src/features/gov-task/internal/panel.tsx | 643 ++++++++++++++---- .../src/shared/__tests__/typed-fetch.test.ts | 14 + apps/web/src/shared/auth.ts | 84 +++ .../src/shared/gov/__tests__/surfaces.test.ts | 42 +- apps/web/src/shared/gov/gov-route.ts | 22 +- apps/web/src/shared/gov/primitives.tsx | 8 + apps/web/src/shared/gov/surfaces.ts | 25 +- apps/web/src/shared/query-keys.ts | 13 + apps/web/src/shared/typed-fetch.ts | 30 +- apps/web/vite.config.ts | 3 +- docs/AGENTS.md | 22 + docs/DESIGN-SPEC.md | 216 ++++++ docs/数据域页面改版记录-2026-08-25.md | 78 +++ docs/活动模块重构需求-2026-08-25.md | 104 +++ docs/统一鉴权接入记录-2026-08-26.md | 45 ++ packages/contracts/src/api/workbench.ts | 42 +- packages/contracts/src/org.ts | 29 + pnpm-lock.yaml | 10 + vitest.config.ts | 4 + 40 files changed, 2502 insertions(+), 480 deletions(-) create mode 100644 apps/web/.env.development create mode 100644 apps/web/.env.production create mode 100644 apps/web/public/sandbox-demo/activity-view.html create mode 100644 apps/web/public/silent-check-sso.html create mode 100644 apps/web/src/features/desk-me/index.ts create mode 100644 apps/web/src/features/desk-me/internal/MePage.tsx create mode 100644 apps/web/src/shared/auth.ts create mode 100644 apps/web/src/shared/query-keys.ts create mode 100644 docs/AGENTS.md create mode 100644 docs/DESIGN-SPEC.md create mode 100644 docs/数据域页面改版记录-2026-08-25.md create mode 100644 docs/活动模块重构需求-2026-08-25.md create mode 100644 docs/统一鉴权接入记录-2026-08-26.md diff --git a/apps/web/.env.development b/apps/web/.env.development new file mode 100644 index 0000000..bb474fd --- /dev/null +++ b/apps/web/.env.development @@ -0,0 +1,6 @@ +# 统一鉴权中心(ADR-012)—— 本端 = 用户端 web-portal(keycloak-frontend-guide §3) +# 前端是 public client,没有也不需要 client secret;安全性来自 redirect_uri 精确匹配 + PKCE +VITE_KEYCLOAK_URL=https://keycloak.akhr-ai.com +VITE_KEYCLOAK_REALM=corp +VITE_KEYCLOAK_CLIENT_ID=web-portal +VITE_REQUIRED_ROLE=user diff --git a/apps/web/.env.production b/apps/web/.env.production new file mode 100644 index 0000000..bb474fd --- /dev/null +++ b/apps/web/.env.production @@ -0,0 +1,6 @@ +# 统一鉴权中心(ADR-012)—— 本端 = 用户端 web-portal(keycloak-frontend-guide §3) +# 前端是 public client,没有也不需要 client secret;安全性来自 redirect_uri 精确匹配 + PKCE +VITE_KEYCLOAK_URL=https://keycloak.akhr-ai.com +VITE_KEYCLOAK_REALM=corp +VITE_KEYCLOAK_CLIENT_ID=web-portal +VITE_REQUIRED_ROLE=user diff --git a/apps/web/package.json b/apps/web/package.json index 7aaf2c2..6e85068 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", "diagram-js": "^15.23.0", + "keycloak-js": "26.2.4", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/web/public/sandbox-demo/activity-view.html b/apps/web/public/sandbox-demo/activity-view.html new file mode 100644 index 0000000..7850cc9 --- /dev/null +++ b/apps/web/public/sandbox-demo/activity-view.html @@ -0,0 +1,99 @@ + + + + + +活动视图 · 询价接收与结构化 + + + + +
+
+ +
+

询价接收与结构化

+

quote-intake · 活动视图(服务器返回 HTML · 沙箱隔离渲染)

+
+
+ +
+
今日接收6
+
待人工补全1
+
结构化成功5
+
平均耗时42s
+
+ +
+

最近的询价原文

+ + + + + + + + + +
来源物料数接收时间结构化结果
Itelma 采购部 · 邮件3 项10:12待补全
伏尔加集团 · 聊天记录12 项09:40已结构化
GAZ 采购 · 邮件附件7 项昨天 17:03已结构化
+
+ +
+

人工补全(待执行 · INS-2417)

+
+
物料编码
MCU-STM32F407VET6
+
数量
2,400 pcs
+
交期要求
2026-09-30 前到莫斯科仓
+
+ + +
+
+
+
+ + diff --git a/apps/web/public/silent-check-sso.html b/apps/web/public/silent-check-sso.html new file mode 100644 index 0000000..7fab20b --- /dev/null +++ b/apps/web/public/silent-check-sso.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/apps/web/src/app/app-shell.tsx b/apps/web/src/app/app-shell.tsx index f8cf7f8..58c81b3 100644 --- a/apps/web/src/app/app-shell.tsx +++ b/apps/web/src/app/app-shell.tsx @@ -1,34 +1,34 @@ /** * 工作台外壳 —— 自原型 `(proto)/page.tsx` 平移(README《前端 UI · 原型 1:1 平移》)。 * - * 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报)→ 轨底身份; + * 左轨 = 品牌 → **工作台五要素**(任务 / 审批 / 消息 / 纪要 / 汇报);身份在顶栏右侧菜单; * 任务四区 = 左栏列表区 / 中上标题区 / 中下工作区 / 右栏对话区。 * * **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。 */ import { useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { Session, TaskCard, TaskDetail, WorkOrderReply } from '@contracts'; +import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts'; import { listApprovalsForMeRoute, listMessagesRoute, listTasksRoute, - logoutRoute, + meRoute, replyWorkOrderRoute, - sessionRoute, taskDetailRoute, } from '@contracts'; import { TaskChat, TaskList, ViewRegion } from '@features/desk-task'; +import type { TaskFilter } from '@features/desk-task'; import { ApprovalDesk } from '@features/desk-approval'; import { MessageDesk } from '@features/desk-message'; import { MinuteDesk } from '@features/desk-minute'; import { ReportDesk } from '@features/desk-report'; import { Ico } from '@web-shared/Ico.tsx'; import { call } from '@web-shared/typed-fetch.ts'; +import { logout as kcLogout } from '@web-shared/auth.ts'; import { useChatWidth } from '@web-shared/useChatWidth.ts'; import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { queryKeys } from './query.ts'; -import { Btn } from '@web-shared/Btn.tsx'; type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report'; @@ -43,6 +43,10 @@ const NAV_MODULES: ReadonlyArray<{ key: ModuleKey; label: string; icon: string } export function AppShell() { const queryClient = useQueryClient(); const [module, setModule] = useState('tasks'); + /** 清单状态页签(进行中/已完成)—— TaskList 上报,中间区与对话区跟着它走(2026-08-25 用户定) */ + const [listFilter, setListFilter] = useState('active'); + /** 「建设中」弹窗:点未开放模块(审批/消息/纪要/汇报)或个人中心时弹出,不跳页(2026-08-25 用户定) */ + const [wip, setWip] = useState(null); const [taskId, setTaskId] = useState(''); const [collapsed, setCollapsed] = useState(false); /* @@ -64,9 +68,9 @@ export function AppShell() { const split = useChatWidth(); - const session = useQuery({ - queryKey: queryKeys.session, - queryFn: () => call(sessionRoute), + const me$ = useQuery({ + queryKey: queryKeys.me, + queryFn: () => call(meRoute), }); const tasks = useQuery({ @@ -86,28 +90,39 @@ export function AppShell() { const list = tasks.data?.tasks ?? []; const active = list.filter((t) => t.terminal === undefined); - const current = taskId !== '' ? taskId : (active[0]?.id ?? list[0]?.id ?? ''); + /* + * 中间区/对话区跟着清单页签走:当前选中落在本页签内才保留, + * 否则落到本页签第一单;页签内一单都没有就是空态 —— 不能隔着页签 + * 还摆着另一页签的单(2026-08-25 用户报:切「进行中」仍显示已完成任务)。 + */ + const tabTasks = list.filter((t) => (listFilter === 'active' ? t.terminal === undefined : t.terminal !== undefined)); + const current = taskId !== '' && tabTasks.some((t) => t.id === taskId) ? taskId : (tabTasks[0]?.id ?? ''); const detail = useQuery({ queryKey: queryKeys.task(current), queryFn: () => call(taskDetailRoute, { params: { taskId: current } }), enabled: current !== '', }); + /* + * 详情在拉取途中(远程库往返要几百毫秒)—— 这一刻三区不能摆空态, + * 否则人刚点完页签就看到「手上没有任务」,以为这一下没点上(2026-08-25 用户报)。 + */ + const detailPending = current !== '' && detail.isPending; - /** 登出后**整体清空缓存**:上一个人的任务、审批、消息不能留在下一个人眼前 */ - const logout = useMutation({ - mutationFn: () => call<{ ok: boolean }>(logoutRoute), - onSuccess: () => { - queryClient.clear(); - // 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影 - window.location.assign('/'); - }, - }); + /** + * 登出(ADR-012):先整体清空缓存(上一个人的任务、审批、消息不能留给下一个人), + * 再走 Keycloak end-session 终结 SSO 会话跳回首页 —— 只清本地会「登不出去」(手册 §11)。 + * 后端 `/api/logout` 已退役(410),这里不再调它。 + */ + const logout = () => { + queryClient.clear(); + void kcLogout(); + }; - const me = session.data?.staff.name ?? '—'; + const me = me$.data?.staff.name ?? '—'; /** * 工单回传 —— 视图区与对话区同一条口。 @@ -141,7 +156,7 @@ export function AppShell() { ); })} -
- {/* 轨底只报身份,不再兼职登出入口 —— 退出登录挪到顶栏,免得点头像误触登出 */} -
- {me.slice(0, 1)} - {me} -
-
+ {/* 轨底不再报身份:头像与姓名挪到顶栏右侧的身份菜单里(2026-08-25 用户定) */}
-
- {NAV_MODULES.find((m) => m.key === module)?.label ?? ''} +
+ {/* 顶栏不挂模块名,只留右侧身份菜单(2026-08-25 用户定) */}
- {me} - logout.mutate()}> - - 退出登录 - + {/* 身份菜单:悬停头像/姓名出下拉 —— 个人中心(建设中提示)+ 退出登录;登出仍是全站唯一入口 */} +
+ {me.slice(0, 1)} + {me} + +
+ {/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */} + + + 个人中心 + + {/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */} + + + 设计中心 + + +
+
@@ -198,6 +224,12 @@ export function AppShell() { setTaskId(id); }} onCollapse={() => setCollapsed(true)} + onFilterChange={(s) => { + // 换页签=换一批单:填报缓冲与定位是**过程状态**,不跨单(与 onPick 同一口径) + setOrderValues({}); + setLocate(null); + setListFilter(s); + }} /> {/* 手上没任务时,三栏也得**站得住**:中栏与对话区各给自己的空态。 @@ -222,7 +254,7 @@ export function AppShell() { * 是白卡片,任务这一栏却是画布上飘着的一段文字,看着像没渲染出来。 */
- {tasks.isLoading ? ( + {tasks.isLoading || detailPending ? (
载入中… @@ -271,12 +303,19 @@ export function AppShell() { 开一单即可对话
- + {detailPending ? ( +
+ + 载入中… +
+ ) : ( + + )}
@@ -302,6 +341,22 @@ export function AppShell() {
+ + {/* 「建设中」提示:点遮罩或按钮关闭,不跳页 */} + {wip !== null && ( +
setWip(null)}> +
e.stopPropagation()}> +
+ +
+

该区域正在建设中

+

「{wip}」暂未开放,敬请期待。

+ +
+
+ )} ); } diff --git a/apps/web/src/app/gov-shell.tsx b/apps/web/src/app/gov-shell.tsx index eda6721..1c3faad 100644 --- a/apps/web/src/app/gov-shell.tsx +++ b/apps/web/src/app/gov-shell.tsx @@ -11,10 +11,11 @@ * 刷新与深链都能完整还原;前端零过程状态。 */ import { useCallback, useEffect, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { Session } from '@contracts'; -import { logoutRoute, sessionRoute } from '@contracts'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { Principal } from '@contracts'; +import { meRoute } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; +import { logout as kcLogout } from '@web-shared/auth.ts'; import { Ico } from '@web-shared/Ico.tsx'; import { TABS, ZM, ZONES, routeLabel } from '@web-shared/gov/gov-route.ts'; import type { GovZone, Nav, Route } from '@web-shared/gov/gov-route.ts'; @@ -24,14 +25,14 @@ import { useGovObjects, useGovTasks } from '@web-shared/gov/gov-api.ts'; import { domainShort, sortDomains } from '@web-shared/gov/domains.ts'; import { queryKeys } from './query.ts'; import { BlueprintHome } from '@features/gov-blueprint'; -import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task'; +import { TasksHome, ActvPage, TaskLogPage, TaskPage, WfPage } from '@features/gov-task'; import { RunsHome } from '@features/gov-runs'; import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff'; import { PkgPage, WorkflowsHome } from '@features/gov-process'; import { TriggerPage, TriggerRulesHome } from '@features/gov-trigger'; import { ActPage, LibraryHome } from '@features/gov-activity'; import { ToolPage, ToolsHome } from '@features/gov-tool'; -import { DataHome, LedgersHome, ObjPage } from '@features/gov-data'; +import { DataHome, LedgersHome, ObjPage, RowPage } from '@features/gov-data'; import { ArchiveHome } from '@features/gov-archive'; import { TestbedHome } from '@features/gov-testbed'; import { CockpitHome } from '@features/gov-memory'; @@ -65,21 +66,20 @@ const writePlace = (surface: Surface, p: Place): string => export function GovShell({ surface }: { surface: Surface }) { const [place, setPlace] = useState(() => readPlace(surface)); - const session = useQuery({ queryKey: queryKeys.session, queryFn: () => call(sessionRoute) }); + const me$ = useQuery({ queryKey: queryKeys.me, queryFn: () => call(meRoute) }); // 运行态的两份数据只在运营中心取:设计中心不看运行数字(纪律),也不必替它要 const tasks = useGovTasks({ enabled: surface === 'ops' }); const objects = useGovObjects({ enabled: surface === 'ops' }); const queryClient = useQueryClient(); - /** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */ - const logout = useMutation({ - mutationFn: () => call<{ ok: boolean }>(logoutRoute), - onSuccess: () => { - queryClient.clear(); - // 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影 - window.location.assign('/'); - }, - }); + /* + * 登出(ADR-012):先整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人; + * 再走 Keycloak end-session 终结 SSO 会话(`/api/logout` 已退役 410,不再调)。 + */ + const logout = () => { + queryClient.clear(); + void kcLogout(); + }; // 浏览器前进 / 后退即导航 —— 路由状态就是应用状态 useEffect(() => { @@ -123,7 +123,7 @@ export function GovShell({ surface }: { surface: Surface }) { const { zone, tab, stack } = place; const meta = ZM[zone]; const top = stack[stack.length - 1]; - const me = session.data?.staff; + const me = me$.data?.staff; const other: Surface = surface === 'design' ? 'ops' : 'design'; /** 告警计数:异常工作流 + 非放行终态的任务 —— 只有运营中心管这个 */ @@ -141,11 +141,15 @@ export function GovShell({ surface }: { surface: Surface }) { { label: SURFACE_META[surface].brand, go: () => goZone(zone) }, ]; crumbs.push({ label: meta.title, go: () => go({ zone, tab, stack: [] }) }); - // 台账区的页签名就是域键 —— 静态表里查不到,呈现走人读短名 - crumbs.push({ - label: TABS[zone].find((t) => t.key === tab)?.label ?? (zone === 'ledgers' ? domainShort(effectiveTab) : tab), - go: () => go({ zone, tab, stack: [] }), - }); + /* 页签级只在页签栏真出现时摆 —— 单页签的区页签栏收起(下文 g-tabs 同口径), + 面包屑再摆一级同义页签名就是冗余(2026-08-25 用户定,活动区起) */ + if (zone === 'ledgers' || TABS[zone].length > 1) { + // 台账区的页签名就是域键 —— 静态表里查不到,呈现走人读短名 + crumbs.push({ + label: TABS[zone].find((t) => t.key === tab)?.label ?? (zone === 'ledgers' ? domainShort(effectiveTab) : tab), + go: () => go({ zone, tab, stack: [] }), + }); + } stack.forEach((r, i) => crumbs.push({ label: routeLabel(r), @@ -193,18 +197,20 @@ export function GovShell({ surface }: { surface: Surface }) { 告警{alerts} )} - {/* 三面互跳:另一面按授权亮灯,工作台人人可回 */} - {canSeeSurface(session.data, other) && ( + {/* 三面互跳:另一面与工作台都按 ends 亮灯(ADR-012,权限只到端) */} + {canSeeSurface(me$.data, other) && ( {SURFACE_META[other].brand} )} - - - 工作台 - - logout.mutate()} busy={logout.isPending}> + {(me$.data?.ends ?? []).includes('workbench') && ( + + + 工作台 + + )} + 登出 @@ -323,10 +329,14 @@ function Detail({ route, nav }: { route: Route; nav: Nav }) { return ; case 'act': return ; + case 'actd': + return ; case 'tool': return ; case 'obj': - return ; + return ; + case 'row': + return ; case 'llm': return ; case 'chan': diff --git a/apps/web/src/app/main.tsx b/apps/web/src/app/main.tsx index 7652d81..c16a7e0 100644 --- a/apps/web/src/app/main.tsx +++ b/apps/web/src/app/main.tsx @@ -13,13 +13,17 @@ import { StrictMode, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { Session } from '@contracts'; -import { sessionRoute } from '@contracts'; +import type { Principal } from '@contracts'; +import { meRoute } from '@contracts'; import { AppShell } from './app-shell.tsx'; +import { MePage } from '@features/desk-me'; import { connectStream } from './subscribe.ts'; import { GovShell } from './gov-shell.tsx'; import { LoginPage } from '@features/auth'; import { ApiCallError, call } from '@web-shared/typed-fetch.ts'; +import { initAuth, logout } from '@web-shared/auth.ts'; +import { Ico } from '@web-shared/Ico.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; import { queryClient, queryKeys } from './query.ts'; import { applyTabIdentity } from './tab-identity.ts'; import type { TabSurface } from './tab-identity.ts'; @@ -39,6 +43,7 @@ const legacy = window.location.pathname.startsWith('/gov') if (legacy !== undefined) window.location.replace(legacy); /** 路径决定进哪一面 —— 路由状态即应用状态,可深链(§9.2) */ +const meSurface = window.location.pathname.startsWith('/me'); const surface: TabSurface = window.location.pathname.startsWith(SURFACE_META.design.path) ? 'design' : window.location.pathname.startsWith(SURFACE_META.ops.path) @@ -69,40 +74,96 @@ if (govSurface !== undefined) { await import('./styles/press.css'); /** - * 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。 - * 前端不自存身份,刷新照样问一次 `/api/session`;登出后缓存整体清空, - * 免得上一个人的任务、审批、消息留在下一个人眼前。 + * 鉴权壳(ADR-012)—— 统一鉴权中心后,身份分两层: + * + * ① **登录态**:浏览器与 Keycloak 之间的事,模块加载时 `initAuth()` 静默探测一次 + * (隐藏 iframe,用户无感);没有就摆登录页,一键跳统一登录。 + * ② **准入**:`GET /api/me` 回答「我是谁、能进哪几个端(`ends`)」。 + * 入口亮灯与深链保护都只看 `ends`;`ends` 为空也 200 —— 摆「未授权」页,不落登录页。 + * + * token 只活在 keycloak-js 的内存里,刷新页面靠 SSO 会话静默恢复(手册 §16,不"优化")。 */ +const auth = await initAuth().catch(() => undefined); + +/** 未授权提示页(角色不够 / ends 为空共用)—— 与登录页同一套 jc-login 皮肤 */ +function Denied({ title, desc }: { title: string; desc: string }) { + return ( +
+
+ + + +

{title}

+

{desc}

+ {/* 角色不够时跳登录页没用(还是同一个人)—— 给的是登出,换账号再来(手册 §4.3) */} + void logout()}> + 退出登录 + +
+
+ ); +} + function Gate() { const client = useQueryClient(); - const session = useQuery({ - queryKey: queryKeys.session, - queryFn: () => call(sessionRoute), - // 未登录是正常状态,不是故障 —— 不重试,直接落登录页 + const me = useQuery({ + queryKey: queryKeys.me, + queryFn: () => call(meRoute), + // 只有登录了才问 /api/me;401 是正常状态(会话没了),不重试 + enabled: auth?.authenticated === true && auth.authorized, retry: (_count, error) => !(error instanceof ApiCallError && error.status === 401), }); + const ends = me.data?.ends; /* - * 判据要看 error,不能只看 data。 - * TanStack Query 在**重取失败时保留上一次成功的 data** —— 会话过期或登出后重取拿到 401, - * `session.data` 依旧是旧身份;只认 data 就会停在一个空壳里,退不出去(实测如此)。 + * 深链保护:进 /design /ops 而 ends 不含该端 → 落到能进的第一个端; + * 工作台 / 个人中心同理要看 'workbench'。一个都进不了时 Denied 已接住,到不了这里。 */ - const expired = session.error instanceof ApiCallError && session.error.status === 401; - const authed = session.data !== undefined && !expired; + const need: Surface | 'workbench' | undefined = + govSurface ?? (meSurface || surface === 'desk' ? 'workbench' : undefined); + const blocked = ends !== undefined && ends.length > 0 && need !== undefined && !ends.includes(need); + // 落到能进的第一个端:工作台优先,否则第一个治理端 + const firstGovEnd = ends?.find((e): e is Surface => e === 'design' || e === 'ops'); + const fallback = + ends?.includes('workbench') === true ? '/' : firstGovEnd !== undefined ? SURFACE_META[firstGovEnd].path : undefined; + + useEffect(() => { + if (blocked && fallback !== undefined) window.location.replace(fallback); + }, [blocked, fallback]); // SSE 只在登录后连:未登录时那条连接会被中间件 401 挡下 - useEffect(() => (authed ? connectStream(client) : undefined), [authed, client]); + const streaming = me.data !== undefined && !blocked; + useEffect(() => (streaming ? connectStream(client) : undefined), [streaming, client]); - if (session.isPending) return null; - if (!authed) { - // 只落身份,**不要 clear()**:clear 会连同已挂载的观察者一起移除, - // 随后 setQueryData 建的是新条目,老观察者接不上 —— 界面会卡在「登录中…」(实测)。 - // 缓存的清空由登出那侧的整页跳转保证,这里到达时本就是空的。 - return client.setQueryData(queryKeys.session, s)} />; + // 鉴权中心本身没应答(网络 / 配置)—— 摆实话,不落登录页打转 + if (auth === undefined) + return ; + if (!auth.authenticated) return ; + if (!auth.authorized) + return ; + + if (me.isPending) return null; + // 401(token 救不回来)落登录页;别的错误如实摆出来,不装没发生 + if (me.data === undefined) { + if (me.error instanceof ApiCallError && me.error.status === 401) return ; + return ; } + if (me.data.ends.length === 0) + return ( + + ); + if (blocked) return null; // 重定向进行中(effect 已发) return govSurface !== undefined ? ( + ) : meSurface ? ( + // 个人中心与工作台同皮肤,整页导航进出(2026-08-25) +
+ +
) : ( // .proto-root 是工作台补充样式的作用域(原型 app.css 通篇以它起头); // 治理台不裹它 —— 裹了工作台的补充样式就会漏进治理台的树里 diff --git a/apps/web/src/app/query.ts b/apps/web/src/app/query.ts index 8967ba9..982d02e 100644 --- a/apps/web/src/app/query.ts +++ b/apps/web/src/app/query.ts @@ -3,6 +3,9 @@ * 「SSE 事件到达 → 按信封目标失效对应缓存,由订阅层统一做;组件禁止自行轮询或手写缓存。」 */ import { QueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@web-shared/query-keys.ts'; + +export { queryKeys }; export const queryClient = new QueryClient({ defaultOptions: { @@ -14,11 +17,3 @@ export const queryClient = new QueryClient({ }, }, }); - -export const queryKeys = { - session: ['session'] as const, - tasks: ['desk', 'tasks'] as const, - task: (id: string) => ['desk', 'task', id] as const, - messages: ['desk', 'messages'] as const, - approvals: ['desk', 'approvals'] as const, -}; diff --git a/apps/web/src/app/styles/app.css b/apps/web/src/app/styles/app.css index 8d8cb1e..df384bc 100644 --- a/apps/web/src/app/styles/app.css +++ b/apps/web/src/app/styles/app.css @@ -476,30 +476,52 @@ .proto-root .jc-sup:focus { outline: 0; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(51,112,255,.12); } .proto-root .jc-sup:disabled { background: #f3f4f6; color: #5c626b; } -/* ===== 左侧一体化导航轨:品牌 → 四大模块 → 轨底身份(登出已挪去顶栏,见下)===== */ +/* ===== 左侧一体化导航轨:品牌 → 五大模块(身份已挪去顶栏右侧,见下)===== */ .proto-root .app-nav { padding: 14px 10px 12px; } .proto-root .jc-rail-brand { text-align: center; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-3); margin: -4px 0 6px; } .proto-root .app-nav button { min-height: 58px; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); } .proto-root .app-nav button:active { transform: scale(.97); } -/* 轨底:全局动作(重置/通知/设置)+ 身份,margin-top:auto 吸底 */ -.proto-root .jc-rail-foot { margin-top: auto; display: flex; flex-direction: column; align-items: center; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line-soft); } -/* 覆盖 vendor 对 .app-nav button 的 min-height,避免轨底图标按钮被撑高 */ -.proto-root .jc-rail-foot .icon-button { width: 34px; height: 34px; min-height: 34px; } -.proto-root .jc-nav-user { display: flex; flex-direction: column; align-items: center; gap: 3px; padding-top: 4px; cursor: default; } +/* 轨底身份区已撤:头像与姓名挪去顶栏右侧身份菜单(2026-08-25 用户定); + .jc-nav-ava 保留 —— 顶栏身份菜单复用这枚渐变头像 */ .proto-root .jc-nav-ava { width: 32px; height: 32px; border-radius: 999px; display: grid; place-items: center; flex: 0 0 auto; line-height: 1; color: #fff; font-size: 13px; font-weight: 800; background: linear-gradient(140deg, #2bc7d4, #0e9fd8); box-shadow: 0 2px 6px rgba(14, 159, 216, .35); } -.proto-root .jc-nav-name { font-size: 10px; font-weight: 600; color: var(--ink-3); } -/* ===== 顶栏:当前模块名 + 身份 + 退出登录 —— 与左轨互不重叠,登出只有这一处入口 ===== */ +/* ===== 顶栏:44px 通栏,只放右侧身份菜单 —— 与左轨互不重叠,登出只有这一处入口 ===== */ .proto-root .app-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; } .proto-root .app-main-body { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; } .proto-root .app-main-body > * { flex: 1 1 auto; min-width: 0; min-height: 0; } .proto-root .jc-topbar { flex: 0 0 auto; height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid var(--line); } -.proto-root .jc-topbar-title { font-size: 14px; font-weight: 700; color: var(--ink); } -.proto-root .jc-topbar-user { display: flex; align-items: center; gap: 12px; } +.proto-root .jc-topbar-user { display: flex; align-items: center; gap: 12px; margin-left: auto; } .proto-root .jc-topbar-name { font-size: 13px; color: var(--ink-3); } -.proto-root .jc-topbar-logout { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-2); font-size: 13px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); } -.proto-root .jc-topbar-logout:hover { background: var(--danger-soft); color: var(--danger); border-color: #f3c9c6; } -.proto-root .jc-topbar-logout:disabled { opacity: .6; cursor: default; } +/* 外壳顶栏专属:任务标题栏(共用 .jc-topbar)那条重构规则带 overflow:hidden, + 会把右侧身份菜单的下拉裁得只剩顶部一小条 —— 这里放开(2026-08-25 用户报:下拉被遮挡) */ +.proto-root .jc-topbar.jc-topbar-app { overflow: visible; border-radius: 0; height: 44px; padding: 0 20px; margin-bottom: 0; } /* 圆角/56px 高/大内边距/底部间距都是任务标题栏渗进来的,外壳顶栏不要(2026-08-25 用户定:44px 通栏) */ +/* 顶缘蓝紫渐变条也是渗进来的(::before),外壳顶栏撤掉(2026-08-25 用户定) */ +.proto-root .jc-topbar.jc-topbar-app::before { display: none; } +/* 但右上角装饰光晕定位 right:-60px,裁切一放开它就探出视口右缘 60px,整页出现横向滚动 + (2026-08-25 用户报:页面能左右滑)—— 把它收回盒内,光晕照留 */ +.proto-root .jc-topbar.jc-topbar-app::after { right: 0; } +/* 顶栏右侧身份菜单:头像 + 姓名,悬停出下拉(个人中心 / 退出登录)—— 纯 CSS 悬停无 JS; + 登出入口从独立按钮收进下拉,仍是全站唯一入口(2026-08-25 用户定) */ +.proto-root .jc-usermenu { position: relative; display: flex; align-items: center; gap: 8px; padding: 4px 8px 4px 4px; border-radius: 10px; cursor: default; color: var(--ink-4); transition: background var(--t-fast); } +.proto-root .jc-usermenu:hover { background: var(--soft); } +.proto-root .jc-usermenu .jc-nav-ava { width: 28px; height: 28px; font-size: 12px; } +/* 悬停桥:触发区与菜单之间的间隙由它补住,鼠标下移不断 hover */ +.proto-root .jc-usermenu::after { content: ""; position: absolute; top: 100%; left: 0; right: 0; height: 8px; } +.proto-root .jc-usermenu-pop { position: absolute; top: calc(100% + 6px); right: 0; z-index: 60; min-width: 136px; padding: 6px; background: #fff; border: 1px solid var(--line-soft); border-radius: 12px; box-shadow: var(--shadow-2); opacity: 0; transform: translateY(-4px); pointer-events: none; transition: opacity var(--t-fast), transform var(--t-fast); } +.proto-root .jc-usermenu:hover .jc-usermenu-pop, .proto-root .jc-usermenu:focus-within .jc-usermenu-pop { opacity: 1; transform: none; pointer-events: auto; } +.proto-root .jc-usermenu-pop button, .proto-root .jc-usermenu-pop a { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: none; font-size: 13px; color: var(--ink-2); cursor: pointer; text-align: left; text-decoration: none; box-sizing: border-box; transition: background var(--t-fast), color var(--t-fast); } +.proto-root .jc-usermenu-pop button:hover, .proto-root .jc-usermenu-pop a:hover { background: var(--soft); } +.proto-root .jc-usermenu-pop button.danger:hover { background: var(--danger-soft); color: var(--danger); } +.proto-root .jc-usermenu-pop button:disabled { opacity: .6; cursor: default; } + +/* 「建设中」提示弹窗:未开放模块(审批/消息/纪要/汇报)与个人中心的占位口径 —— 不跳页只弹提示 */ +.proto-root .jc-wip-overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; background: rgba(31, 35, 41, .45); } +.proto-root .jc-wip-modal { width: 360px; max-width: 92vw; padding: 24px 20px 18px; background: #fff; border-radius: 16px; box-shadow: 0 24px 60px rgba(31, 35, 41, .22); text-align: center; animation: rise var(--t-med) both; } +.proto-root .jc-wip-ico { width: 40px; height: 40px; margin: 0 auto 10px; border-radius: 12px; display: grid; place-items: center; color: var(--brand); background: var(--brand-wash); } +.proto-root .jc-wip-modal h3 { margin: 0 0 6px; font-size: 16px; color: var(--ink); } +.proto-root .jc-wip-modal p { margin: 0 0 16px; font-size: 12.5px; line-height: 1.7; color: var(--ink-3); } +.proto-root .jc-wip-ok { height: 34px; padding: 0 22px; border: 1px solid var(--brand); border-radius: 10px; background: var(--brand); color: #fff; font-size: 13px; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); } +.proto-root .jc-wip-ok:hover { background: var(--brand-deep); border-color: var(--brand-deep); } /* 工作清单头:渐变图标点缀 + 两级筛选 */ .proto-root .jc-wl-head { display: flex; align-items: center; justify-content: flex-start; gap: 10px; } @@ -515,8 +537,10 @@ .proto-root .jc-wl-ico { flex: none; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: #fff; background: linear-gradient(140deg, var(--brand), #7f3bf5); box-shadow: 0 3px 8px rgba(51, 112, 255, .28); } .proto-root .jc-flt-wrap { display: flex; flex-direction: column; gap: 6px; padding: 2px 12px 12px; border-bottom: 1px solid var(--line-soft); background: #fbfdff; } .proto-root .jc-flt { display: flex; gap: 6px; } -.proto-root .jc-flt button { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 0; border: 1px solid var(--line-soft); background: #fff; border-radius: 8px; padding: 4px 4px; font-size: 11.5px; color: var(--ink-3); cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); } -.proto-root .jc-flt button:hover:not(.active) { background: var(--soft); } +.proto-root .jc-flt button { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 0; border: 1px solid var(--line-soft); background: #fff; border-radius: 8px; padding: 4px 4px; font-size: 11.5px; color: var(--ink-3); cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast), transform var(--t-fast); } +/* 悬停给主色洗底+描边、按下给 4% 缩放 —— 2026-08-25 用户报:hover 几乎看不出、点了不知道点没点到 */ +.proto-root .jc-flt button:hover:not(.active) { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); } +.proto-root .jc-flt button:active { transform: scale(.96); } .proto-root .jc-flt button strong { font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--ink-4); background: var(--soft); border-radius: 999px; padding: 0 6px; } .proto-root .jc-flt button.active { color: var(--brand-deep); border-color: var(--brand-soft); background: var(--brand-wash); font-weight: 700; } .proto-root .jc-flt button.active strong { background: #fff; color: var(--brand-deep); } @@ -1977,6 +2001,9 @@ /* ══ 顶部导航栏(2026-07-26 重构)══ 工作流 · 任务 · 主题 · 目标 全收在这一条,常驻不滚。下面才是判据/指导书 + 报告页。 */ .proto-root .jc-topbar { position: relative; overflow: hidden; flex: 0 0 auto; margin-bottom: 12px; padding: 12px 18px 13px; background: #fff; border: 1px solid var(--line-soft); border-radius: 16px; box-shadow: var(--shadow-2); } +/* 任务标题栏(带 aria-label)必须是块级:外壳顶栏那条 56px 的 .jc-topbar 规则带 display:flex, + 同优先级漏进来会把面包屑行与标题行横排挤成一行(2026-08-25 用户报:标题栏变形) */ +.proto-root .jc-topbar[aria-label] { display: block; height: auto; } /* 设计感:顶缘 AI 渐变棱线 + 右上品牌光晕;两行定高(≈88px),其余信息在「工作流」页签 */ .proto-root .jc-topbar::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--ai-grad); } .proto-root .jc-topbar::after { content: ""; position: absolute; right: -60px; top: -80px; width: 210px; height: 210px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.08), transparent 70%); pointer-events: none; } @@ -3363,3 +3390,62 @@ display: block; margin-top: 3px; font-style: normal; font-size: 11px; line-height: 1.6; color: var(--ink-3); } + +/* ══ 个人中心(/me 台面,2026-08-25)══ + 与工作台同皮肤:44px 通栏顶栏 + 居中内容列。 + 只加 .jc-me-* 作用域,共享件(.jc-kpi/.jc-nav-ava/EmptyState)本体不动。 */ +.proto-root .jc-me { min-height: 100vh; display: flex; flex-direction: column; background: var(--soft); } +.proto-root .jc-me-back { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--ink-3); text-decoration: none; transition: color var(--t-fast); } +.proto-root .jc-me-back:hover { color: var(--brand-deep); } +.proto-root .jc-me-body { width: min(960px, calc(100% - 48px)); margin: 24px auto 48px; display: flex; flex-direction: column; gap: 14px; } + +/* 卡片基座:白卡 1px 描边,与全站面板卡同语气 */ +.proto-root .jc-me-card { background: #fff; border: 1px solid var(--line-soft); border-radius: 14px; box-shadow: var(--card-shadow, 0 1px 2px rgba(31, 35, 41, .05)); } + +/* 身份卡:大头像 + 姓名岗位 + 登出入口 */ +.proto-root .jc-me-hero { display: flex; align-items: flex-start; gap: 18px; padding: 22px 24px; } +.proto-root .jc-me-ava { flex: none; width: 72px; height: 72px; border-radius: 999px; display: grid; place-items: center; color: #fff; font-size: 28px; font-weight: 800; background: linear-gradient(140deg, #2bc7d4, #0e9fd8); box-shadow: 0 4px 12px rgba(14, 159, 216, .35); } +.proto-root .jc-me-id { flex: 1; min-width: 0; } +.proto-root .jc-me-id h1 { margin: 2px 0 4px; font-size: 22px; font-weight: 800; letter-spacing: -.01em; color: var(--ink); } +.proto-root .jc-me-pos { margin: 0 0 6px; font-size: 13.5px; font-weight: 700; color: var(--brand-deep); } +.proto-root .jc-me-meta { margin: 0; font-size: 12px; color: var(--ink-4); } +.proto-root .jc-me-meta em { font-style: normal; font-weight: 700; } +.proto-root .jc-me-meta em.on { color: var(--ok); } +.proto-root .jc-me-meta em.off { color: var(--danger); } +.proto-root .jc-me-note { margin: 8px 0 0; font-size: 12.5px; line-height: 1.7; color: var(--ink-3); } +.proto-root .jc-me-logout { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; border: 1px solid var(--line); border-radius: 9px; background: #fff; color: var(--ink-2); font-size: 13px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); } +.proto-root .jc-me-logout:hover { background: var(--danger-soft); color: var(--danger); border-color: #f3c9c6; } +.proto-root .jc-me-logout:disabled { opacity: .6; cursor: default; } + +/* 概览四卡:复用 .jc-kpi,这里只补链接态 */ +.proto-root .jc-me-kpis { margin-bottom: 0; } +.proto-root .jc-me-kpis a.jc-kpi { text-decoration: none; color: inherit; } + +/* 区块卡:节头 + 内容 */ +.proto-root .jc-me-sec { padding: 16px 20px 14px; } +.proto-root .jc-me-sec-h { display: flex; align-items: baseline; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line-soft); margin-bottom: 10px; } +.proto-root .jc-me-sec-h h2 { margin: 0; font-size: 15px; font-weight: 800; color: var(--ink); } +.proto-root .jc-me-sec-sub { font-size: 11.5px; color: var(--ink-4); } + +/* 权限分组:组名一列,客体成串,动作色章 */ +.proto-root .jc-me-grant { display: grid; grid-template-columns: 92px 1fr; gap: 6px 12px; padding: 8px 0; } +.proto-root .jc-me-grant + .jc-me-grant { border-top: 1px dashed var(--line-soft); } +.proto-root .jc-me-grant > em { display: inline-flex; align-items: center; gap: 5px; font-style: normal; font-size: 12px; font-weight: 700; color: var(--ink-3); } +.proto-root .jc-me-grant > div { display: flex; flex-wrap: wrap; gap: 6px; } +.proto-root .jc-me-grant-item { display: inline-flex; align-items: center; gap: 6px; padding: 3px 4px 3px 10px; border: 1px solid var(--line-soft); border-radius: 8px; font-size: 12px; font-family: var(--mono); color: var(--ink-2); background: var(--soft); } +.proto-root .jc-me-act { font-style: normal; font-family: var(--sans); font-size: 10px; font-weight: 800; padding: 1px 7px; border-radius: 999px; } +.proto-root .jc-me-act.a-read { color: var(--brand-deep); background: var(--brand-wash); } +.proto-root .jc-me-act.a-write { color: #b25e15; background: #fdf0e6; } +.proto-root .jc-me-act.a-govern { color: #5b21cf; background: #f5f0fe; } +/* 可进的端(ADR-012):chip 即入口,点了整页导航过去 */ +.proto-root .jc-me-end { text-decoration: none; font-family: var(--sans); font-weight: 600; color: var(--brand-deep); background: var(--brand-wash); border-color: transparent; padding-right: 10px; } +.proto-root .jc-me-end:hover { filter: brightness(0.96); } + +/* 最近任务:一行一条,标题 + 活动 + 状态章 */ +.proto-root .jc-me-task { display: flex; align-items: center; gap: 10px; padding: 8px 2px; } +.proto-root .jc-me-task + .jc-me-task { border-top: 1px dashed var(--line-soft); } +.proto-root .jc-me-task-t { flex: 1; min-width: 0; font-size: 13px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.proto-root .jc-me-task-a { flex: none; max-width: 40%; font-size: 11.5px; color: var(--ink-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.proto-root .jc-me-task-st { flex: none; font-style: normal; font-size: 10.5px; font-weight: 700; padding: 2px 9px; border-radius: 999px; } +.proto-root .jc-me-task-st.run { color: var(--brand-deep); background: var(--brand-wash); } +.proto-root .jc-me-task-st.done { color: var(--ink-3); background: var(--soft); } diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index ccbb1e2..61af5bd 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -125,8 +125,9 @@ /* ============ 概览页签:KPI 块 ============ */ .g-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(176px, 1fr)); gap: 12px; } -.g-kpi { position: relative; display: flex; flex-direction: column; gap: 7px; padding: 14px 16px; background: var(--card); border: 1px solid var(--cline); border-radius: 13px; box-shadow: var(--cshadow); overflow: hidden; } -.g-kpi::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tone, #c9d2dd); } +/* 左侧 3px 色条用 inset 阴影画 —— 自动贴合圆角,**不需要** overflow 裁切; + 裁切会把 .g-kpi-hint 的气泡一并剪掉(2026-08-24 用户报:气泡显示不全) */ +.g-kpi { position: relative; display: flex; flex-direction: column; gap: 7px; padding: 14px 16px; background: var(--card); border: 1px solid var(--cline); border-radius: 13px; box-shadow: inset 3px 0 0 var(--tone, #c9d2dd), var(--cshadow); } .g-kpi-l { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--ink-4); } .g-kpi-l svg { color: var(--tone-ink, var(--ink-4)); } .g-kpi-v { font-size: 26px; font-weight: 800; font-family: var(--mono); letter-spacing: -.03em; line-height: 1; color: var(--ink); } @@ -135,6 +136,13 @@ .g-kpi.t-danger .g-kpi-v { color: var(--danger); } .g-kpi.t-brand .g-kpi-v { color: var(--brand-deep); } .g-kpi-s { font-size: 11px; line-height: 1.5; color: var(--ink-4); } +/* KPI 角标说明:右上角一枚 info,悬停出气泡讲这个数的口径(纯 CSS,无 JS); + 气泡深底白字 —— 全站唯一的浮层用色,描边/阴影只给浮层(§10) */ +.g-kpi-hint { position: absolute; top: 10px; right: 10px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 999px; color: var(--ink-4); cursor: help; transition: color var(--t-fast), background var(--t-fast); } +.g-kpi-hint:hover { color: var(--brand-deep); background: var(--brand-wash); } +.g-kpi-hint::after { content: attr(data-tip); position: absolute; top: calc(100% + 6px); right: 0; z-index: 5; width: 208px; padding: 8px 10px; border-radius: 6px; background: var(--ink); color: #fff; font-size: 11px; font-weight: 400; line-height: 1.65; text-align: left; white-space: normal; box-shadow: var(--cshadow-2); opacity: 0; transform: translateY(-3px); pointer-events: none; transition: opacity var(--t-fast), transform var(--t-fast); } +.g-kpi-hint:hover::after { opacity: 1; transform: translateY(0); } + /* 环形(比率型指标) */ .g-dial { display: grid; place-items: center; } .g-dial svg { transform: rotate(-90deg); } @@ -255,50 +263,156 @@ .tb-land.on b { color: #c9372c; } .tb-land em { display: block; margin-top: 2px; font-style: normal; font-size: 11.5px; line-height: 1.65; color: var(--g-ink-3, #646a73); } -/* ══ 数据域:按活动分组(2026-08-24 重写)══════════════════════════════ +/* ══ 数据域:按活动分组(2026-08-24 重写;2026-08-24 按 docs/DESIGN-SPEC.md 改版 v2)══ 一屏回答的是「我这个活动写了哪几张表、现在各有几行」—— - 所以行数是主角:字号最大、放行末端;性质降成一枚小徽,地址与主键退成灰字。 */ -.g-actdata { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px 13px; - background: #fff; border: 1px solid var(--g-line); border-radius: 12px; box-shadow: var(--g-shadow, 0 1px 2px rgba(31,35,41,.05)); } -.g-actdata + .g-actdata { margin-top: 14px; } -.g-actdata-h { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; } -.g-actdata-h > svg { color: var(--g-brand, #3370ff); flex: none; } -.g-actdata-h b { font-size: 14.5px; font-weight: 800; color: var(--g-ink, #1f2329); } -.g-actdata-h .n { margin-left: auto; font-size: 12px; font-weight: 700; color: var(--g-ink-3, #646a73); } -.g-actdata-h .n em { font-style: normal; color: var(--g-brand, #3370ff); } -.g-actdata-h p { flex-basis: 100%; margin: 2px 0 0; font-size: 12px; line-height: 1.7; color: var(--g-ink-3, #646a73); } + 行数是主角:字号最大、放行末端;性质降成一枚小徽,地址与主键退成灰字。 + v2 结构改版(DOM 语义与取数不变): + · 页顶汇总带(四个合计数)给这一屏一个主角与记忆点(§1 顶层); + · 表块是**白卡**(2026-08-24 用户定:卡片形式)—— 1px 描边、行数大数字 + 当卡内主角、tabular-nums(§6/§11),hover 描边变深不浮起(§10); + · 卡片描边单层去阴影、圆角 8(§10/§6)。 + `.g-data`/`.g-actdata`/`.g-tbl` 全站仅此页使用,不影响其他面板。 */ +.g-data { display: flex; flex-direction: column; gap: 16px; } +.g-data .g-kpi { border-radius: 8px; box-shadow: inset 3px 0 0 var(--tone, #c9d2dd); border-color: #dfe3ea; } +.g-data .g-kpi-v { font-variant-numeric: tabular-nums; } -.g-actdata-tables { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 8px; } -.g-tbl { display: grid; grid-template-columns: 1fr auto; grid-template-areas: 't n' 'a n'; - gap: 2px 12px; width: 100%; padding: 9px 12px; text-align: left; font: inherit; cursor: pointer; - background: var(--g-soft, #f7f8fa); border: 1px solid transparent; border-radius: 9px; transition: all .16s; } -.g-tbl:hover { background: #fff; border-color: var(--g-brand-soft, #b6d0ff); box-shadow: 0 2px 8px rgba(51,112,255,.10); } -.g-tbl-t { grid-area: t; display: flex; align-items: center; gap: 6px; min-width: 0; } -.g-tbl-t b { font-size: 13px; font-weight: 700; color: var(--g-ink, #1f2329); } -.g-tbl-k { font-style: normal; font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 4px; +.g-actdata { display: flex; flex-direction: column; gap: 4px; padding: 16px; + background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; } +.g-actdata + .g-actdata { margin-top: 16px; } /* 与 .g-data 的 16px gap 合成 32px 组间(组间松) */ +.g-actdata-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-bottom: 8px; } +.g-actdata-i { flex: none; width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; + color: var(--brand-deep); background: var(--brand-wash); } +.g-actdata-h b { font-size: 15px; font-weight: 700; letter-spacing: -.01em; color: var(--ink); } +.g-actdata-h .g-mono { font-size: 11px; color: var(--ink-4); } +.g-actdata-h .n { margin-left: auto; font-size: 12px; font-weight: 600; color: var(--ink-3); font-variant-numeric: tabular-nums; } +.g-actdata-h .n em { font-style: normal; color: var(--brand); } +.g-actdata-h p { flex-basis: 100%; margin: 0; font-size: 12px; line-height: 1.75; color: var(--ink-3); } + +/* 表卡片:白卡 1px 描边(§10),行数大数字是卡内主角(tabular-nums), + 名称一条左线、mono 地址垫底截断;hover 描边变深不浮起(§10) */ +.g-actdata-tables { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 8px; } +.g-tbl { display: grid; grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: 't x' 'n n' 'a a'; align-items: center; gap: 6px 12px; + width: 100%; padding: 12px 14px; text-align: left; font: inherit; cursor: pointer; + background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; + transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast); } +/* hover 三层反馈(§12 悬停是层级工具):描边变深 + 轻洗底 + 卡内主角(行数)染主色; + active 给 1% 按压感 —— 点击「被接住」。全部 0.16s ease-out,只动便宜属性(§13) */ +.g-tbl:hover { border-color: #c4ccd8; background: #f6f7f9; } +.g-tbl:hover .g-tbl-n b { color: var(--brand-deep); } +.g-tbl:active { transform: scale(.99); border-color: var(--brand-soft); } +.g-tbl-t { grid-area: t; display: flex; align-items: center; gap: 8px; min-width: 0; } +.g-tbl-t b { font-size: 13.5px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-tbl-k { flex: none; font-style: normal; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: #eef1f5; color: #5a6068; } .g-tbl-k.t-m { background: #eaf1ff; color: #2b5fd9; } .g-tbl-k.t-r { background: #f2f3f5; color: #5a6068; } .g-tbl-k.t-d { background: #fdf0e6; color: #b25e15; } -.g-tbl-a { grid-area: a; font-size: 11px; color: var(--g-ink-4, #8f959e); overflow-wrap: anywhere; } +/* 地址一行截断不折行,全文在 title 里悬停看 */ +.g-tbl-a { grid-area: a; font-size: 11px; color: var(--ink-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .g-tbl-a em { font-style: normal; } -/* 行数:这一屏的主角 */ -.g-tbl-n { grid-area: n; align-self: center; text-align: right; font-size: 11.5px; color: var(--g-ink-3, #646a73); white-space: nowrap; } -.g-tbl-n b { font-size: 17px; font-weight: 800; color: var(--g-ink, #1f2329); margin-right: 2px; } -.g-tbl-n .none { font-style: normal; color: var(--g-ink-4, #8f959e); } -.g-tbl-n .draft { display: block; font-style: normal; font-size: 10.5px; font-weight: 700; color: var(--g-brand, #3370ff); } +/* 行数:这一屏的主角 —— 大字号 + tabular-nums,扫一列数字时个位对个位 */ +.g-tbl-n { grid-area: n; font-size: 11.5px; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; } +.g-tbl-n b { font-size: 22px; font-weight: 700; letter-spacing: -.02em; color: var(--ink); margin-right: 2px; transition: color var(--t-fast); } +.g-tbl-n .none { font-style: normal; font-weight: 400; font-size: 11px; color: var(--ink-4); } +.g-tbl-n .draft { font-style: normal; font-size: 10.5px; font-weight: 600; color: var(--brand); margin-left: 8px; } +/* 下钻箭头:平时灰,悬停整卡时染主色前移 2px —— 「这卡能点进去」不靠文字说 */ +.g-tbl-x { grid-area: x; display: grid; place-items: center; color: var(--ink-4); transition: color var(--t-fast), transform var(--t-fast); } +.g-tbl:hover .g-tbl-x { color: var(--brand); transform: translateX(2px); } -.g-actdata-runs { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; padding-top: 2px; } -.g-actdata-runs > em { font-style: normal; font-size: 11px; font-weight: 700; color: var(--g-ink-4, #8f959e); } +.g-actdata-runs { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 10px; border-top: 1px dashed var(--line-soft); } +.g-actdata-runs > em { font-style: normal; font-size: 11px; font-weight: 600; color: var(--ink-4); } .g-run { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 999px; - font-size: 11px; font-weight: 700; background: #f2f3f5; color: #5a6068; } -.g-run i { font-style: normal; font-weight: 600; opacity: .72; } + font-size: 11px; font-weight: 600; background: #f2f3f5; color: #5a6068; } +.g-run i { font-style: normal; font-weight: 500; opacity: .72; font-variant-numeric: tabular-nums; } .g-run.t-ok { background: #e9f7ee; color: #1f7a45; } .g-run.t-live { background: #eaf1ff; color: #2b5fd9; } .g-run.t-wait { background: #fdf0e6; color: #b25e15; } .g-run.t-warn { background: #fff1f0; color: #c9372c; } .g-actdata.t-u { background: #fbfbfc; } +/* ══ 对象详情(数据域下钻,`.g-obj` 作用域)══ + 上下结构(2026-08-24 用户定):记录浏览在上整宽 —— 进来的人先看的是行数据; + 模式在下整宽 —— 它是辅助的结构定义(主从两栏试过一版,两栏互相挤压,撤了)。 + 共享件(g-dh / g-card / g-rows / g-dbtable)本体不动 —— 它们横跨十个面板; + 只在本作用域内改皮:圆角收 8、去阴影留描边、模式行三列(名 / mono 键 / 类型徽)、 + 去斑马与卡套卡的双层描边、行高收敛到扫描型密度(§9)。 */ +.g-obj { display: flex; flex-direction: column; gap: 16px; } +.g-obj .g-dh { border-radius: 8px; box-shadow: none; border-color: #dfe3ea; } +/* 页头提级(气质层):主角标题加大、图标块加内描边、kicker 加字距 */ +.g-obj .g-dh-i { width: 46px; height: 46px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--brand-soft); } +.g-obj .g-dh-t h2 { font-size: 20px; } +.g-obj .g-dh-t em { letter-spacing: .06em; } +/* 主键列头的小钥匙染主色 —— 全表只有主键列有图标,有含义才有颜色 */ +.g-obj .g-dbtable thead th svg { color: var(--brand); margin-right: 3px; vertical-align: -1.5px; } +.g-obj .g-dbwrap { overflow-x: auto; background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; } +/* 模式表:转置矩阵 —— 行 = 参数名/字段/数据类型,列 = 字段;左列行标签吸左,列多横滚; + 盒子本体在这里(标题走 Sec 在盒外,与记录浏览同构 —— 2026-08-24 用户定); + overflow-y 必须显式 hidden:只写 overflow-x:auto 时 y 向会被算成 auto, + 列高亮幕(td::after 上下各伸 999px)就把纵向滚动区撑出近千米空白 + (2026-08-25 用户报:3 行已全显示却还能往下滑) */ +.g-obj .g-sx { overflow-x: auto; overflow-y: hidden; background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; } +.g-obj .g-sx table { border-collapse: collapse; min-width: 100%; } +.g-obj .g-sx th { position: sticky; left: 0; z-index: 1; padding: 8px 12px; background: #fff; text-align: left; + font-size: 11px; font-weight: 600; color: var(--ink-4); white-space: nowrap; border-bottom: 1px solid var(--line-soft); } +/* 列高亮(2026-08-24 用户定):这表一列才是一个字段,所以悬停亮**整列** —— + 悬停格画一层纵向贯穿的淡色幕,上下各伸出 999px,由 .g-sx 的 overflow 裁齐; + mix-blend-mode: multiply —— 幕在文字之上,正片叠底让深字几乎不变、只染亮底; + pointer-events 关掉,幕不抢鼠标 */ +.g-obj .g-sx td { position: relative; padding: 8px 12px; white-space: nowrap; border-bottom: 1px solid var(--line-soft); } +.g-obj .g-sx td::after { content: ""; position: absolute; left: 0; right: 0; top: -999px; bottom: -999px; background: var(--brand-wash); mix-blend-mode: multiply; opacity: 0; pointer-events: none; transition: opacity var(--t-fast); } +.g-obj .g-sx td:hover::after { opacity: 1; } +.g-obj .g-sx tr:last-child th, .g-obj .g-sx tr:last-child td { border-bottom: 0; } +/* 与记录浏览同一套斑马(2026-08-24 用户定):偶数行洗底,吸左的行标签跟着换底 */ +.g-obj .g-sx tbody tr:nth-child(even) { background: #f8fafc; } +.g-obj .g-sx tbody tr:nth-child(even) th { background: #f8fafc; } +.g-obj .g-sx th, .g-obj .g-sx td { border-right: 1px solid #eef1f5; } +.g-obj .g-sx tr > :last-child { border-right: 0; } +.g-obj .g-sx td.nm { font-size: 12.5px; font-weight: 600; color: var(--ink-2); } +.g-obj .g-sx td.key { font-size: 11.5px; color: var(--ink-3); } +.g-obj .g-sx .ty { font-style: normal; font-family: var(--mono); font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: var(--soft); border: 1px solid var(--line-soft); color: var(--ink-4); } +/* 类型徽分色:数字蓝 / json 紫 —— 数据库工具的通用语言,语义色只进徽标(§7) */ +.g-obj .g-sx .ty.k-num { background: #eaf1ff; border-color: #d5e2fb; color: #2b5fd9; } +.g-obj .g-sx .ty.k-json { background: #f5f0fe; border-color: #e4d9fa; color: #5b21cf; } +/* 记录浏览:扫描型密度(§9)—— 字号 13、单元格左右 12;长 JSON 一格一行不折行,超宽横滚 */ +.g-obj .g-dbtable { font-size: 13px; } +.g-obj .g-dbtable thead th { padding: 8px 12px; } +.g-obj .g-dbtable tbody td { padding: 8px 12px; white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; } +/* 分隔要看得见(2026-08-24 用户报):行 = 斑马 + 加深一档的发丝线,列 = 极浅竖线; + 三色都压在工作面阶内,不新增色相(§7) */ +.g-obj .g-dbtable tbody tr:nth-child(even) { background: #f8fafc; } +.g-obj .g-dbtable tbody tr:hover { background: var(--brand-wash); } +.g-obj .g-dbtable tbody td { border-bottom-color: #e2e7ee; } +.g-obj .g-dbtable th + th, .g-obj .g-dbtable td + td { border-left: 1px solid #eef1f5; } +/* 空值那一格是 `-`,灰下去 —— 它是「这里没有」,不是数据 */ +.g-obj .g-dbtable td.empty { color: var(--ink-4); } +/* 数字列右对齐 + 等宽(§11),列类型来自 schema,不是猜的 */ +.g-obj .g-dbtable .num { text-align: right; font-variant-numeric: tabular-nums; } +/* 操作列(2026-08-25 用户定):钉在表格右缘 —— 列多横滚时「查看详情」始终够得着; + 吸右格必须自带不透明底色(白/斑马/悬停三态),否则滚动的内容会透到它底下 */ +.g-obj .g-dbtable th.op, .g-obj .g-dbtable td.op { position: sticky; right: 0; z-index: 1; text-align: center; white-space: nowrap; background: #fff; box-shadow: -1px 0 0 #e2e7ee; } +.g-obj .g-dbtable thead th.op { background: #f5f7fa; } +.g-obj .g-dbtable tbody tr:nth-child(even) td.op { background: #f8fafc; } +.g-obj .g-dbtable tbody tr:hover td.op { background: var(--brand-wash); } +/* 行内入口钮:主色浅底的描边小钮,悬停沉成实底 —— 可点要说得像可点(§7) */ +.g-rowbtn { display: inline-flex; align-items: center; gap: 3px; padding: 3px 10px; border-radius: 6px; + border: 1px solid var(--brand-soft); background: var(--brand-wash); color: var(--brand-deep); + font: 600 12px/1.6 var(--sans); cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); } +.g-rowbtn:hover { background: var(--brand); border-color: var(--brand); color: #fff; } +.g-rowbtn:active { transform: scale(.96); } + +/* ══ 记录详情(三级页,`.g-rowpage` 作用域;套 `g-obj` 复用对象页皮)══ + 字段明细表:一字段一行,值整串摆全可折行 —— 浏览表管「扫」,这页管「看全」 */ +.g-rowpage .g-fdt td.nm { font-weight: 600; color: var(--ink-2); white-space: nowrap; } +.g-rowpage .g-fdt td.nm svg { color: var(--brand); margin-right: 3px; vertical-align: -1.5px; } +.g-rowpage .g-fdt td.key { font-size: 11.5px; color: var(--ink-3); white-space: nowrap; } +/* 值列:撤掉浏览表那套单行截断 —— 详情页不截断,长 JSON 折行摆全 */ +.g-rowpage .g-fdt td.val { white-space: pre-wrap; word-break: break-word; max-width: none; overflow: visible; text-overflow: clip; line-height: 1.7; } +/* 类型徽与模式表同款(选择器作用域不同,样式照抄三行) */ +.g-rowpage .g-fdt .ty { font-style: normal; font-family: var(--mono); font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: var(--soft); border: 1px solid var(--line-soft); color: var(--ink-4); white-space: nowrap; } +.g-rowpage .g-fdt .ty.k-num { background: #eaf1ff; border-color: #d5e2fb; color: #2b5fd9; } +.g-rowpage .g-fdt .ty.k-json { background: #f5f0fe; border-color: #e4d9fa; color: #5b21cf; } + .g-kind { display: flex; flex-direction: column; gap: 12px; } .g-kind + .g-kind { margin-top: 22px; } .g-kind-h { display: grid; grid-template-columns: auto auto auto 1fr; align-items: center; gap: 8px; @@ -391,6 +505,136 @@ .g-fcard .pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #98a4b3; } .g-fcard .pill.on { background: #e6f6ee; color: #0c8154; } .g-fcard .pill.on::before { background: #18a568; } + +/* ══ 运营中心 · 活动模块 L1(2026-08-25 重构模版 → 视觉增强,`.g-actv` 作用域)══ + 布局不动,增强只在皮:每张卡一个「业务色相」变量 --ah(报价=品牌蓝 / CRM=青 / 市场=橙 / 采购=紫), + 同一枚色贯穿五处 —— 卡顶色签 / 图标块 / 输出徽章 / 实例数 / 悬停描边与色影; + 色即业务,不是彩虹(全页色相 ≤4,每处都有含义)。共享件 .g-shelf/.g-fcard 本体不动 */ +.g-actv .g-fcard { --ah: #3370ff; --ah-soft: #eaf1ff; } +.g-actv .g-fcard.h-teal { --ah: #0aa294; --ah-soft: #e0f5f3; } +.g-actv .g-fcard.h-amber { --ah: #d9730d; --ah-soft: #fdf0e0; } +.g-actv .g-fcard.h-violet { --ah: #7f3bf5; --ah-soft: #f1e9fe; } +/* 卡顶色签:3px 一线压顶,从左实到右渐隐 —— 第一眼把同一业务的卡归成一堆 */ +.g-actv .g-fcard::before { content: ""; position: absolute; top: -1px; left: 14px; right: 14px; height: 3px; border-radius: 0 0 3px 3px; background: linear-gradient(90deg, var(--ah), color-mix(in srgb, var(--ah) 25%, transparent)); opacity: .9; } +.g-actv .g-fcard.ghost::before { display: none; } +/* 悬停:描边与投影都染业务色(彩色阴影 —— ui-ux-pro-max 企业级方向),上浮 2px */ +.g-actv .g-fcard:hover { border-color: color-mix(in srgb, var(--ah) 38%, #e2e8ef); box-shadow: 0 12px 26px -14px color-mix(in srgb, var(--ah) 55%, transparent); transform: translateY(-2px); } +/* 图标块:浅底微渐变 + 内描边,色随业务;图标语义化(收件箱/计算器/靶心/趋势/眼睛/盾) */ +.g-actv .g-fcard .ico { color: var(--ah); background: linear-gradient(150deg, var(--ah-soft), #fff); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ah) 18%, transparent); } +/* 标题提一档 + 全部显示不截断(2026-08-25 用户定):活动名是卡上最重要的数据,长了换行,不省略 */ +.g-actv .g-fcard .ttl b { font-size: 15px; overflow: visible; white-space: normal; text-overflow: clip; } +/* 实例数染业务色 —— 底板里最该被先看到的数;底板整体字号收小一档(2026-08-25 用户定), + 它是次要信息,把视觉重量让给活动名 */ +.g-actv .plate > span:first-child b { color: var(--ah); } +.g-actv .plate b { font-size: 12.5px; font-weight: 400; } +/* 幽灵卡本期已注释(设计功能暂缓,2026-08-25 用户定):样式留着,恢复即能用 */ +.g-actv .g-fcard.ghost[aria-disabled="true"] { cursor: default; } +.g-actv .g-fcard.ghost[aria-disabled="true"]:hover { border-color: #c0cbd8; background: #fcfdfe; color: var(--ink-4); box-shadow: none; transform: none; } + +/* ══ 活动详情 L2(2026-08-25 视觉重设计,`.g-actvd` 作用域)══ + 业务色相从 L1 卡片接进详情页:--ah 贯穿页头/统计/页签,形成「点哪张卡,进哪色页」的连续感 */ +.g-actvd { display: flex; flex-direction: column; gap: 16px; --ah: #3370ff; --ah-soft: #eaf1ff; } +.g-actvd.h-teal { --ah: #0aa294; --ah-soft: #e0f5f3; } +.g-actvd.h-amber { --ah: #d9730d; --ah-soft: #fdf0e0; } +.g-actvd.h-violet { --ah: #7f3bf5; --ah-soft: #f1e9fe; } +/* 入场:区块自下而上淡入,60ms 逐段 stagger —— 进场有来由(从下 = 深入一层);reduced-motion 全关 */ +.g-actvd > * { animation: actvd-in .4s ease-out both; } +.g-actvd > *:nth-child(2) { animation-delay: 60ms; } +.g-actvd > *:nth-child(3) { animation-delay: 120ms; } +.g-actvd > *:nth-child(4) { animation-delay: 180ms; } +.g-actvd > *:nth-child(5) { animation-delay: 240ms; } +.g-actvd > *:nth-child(n+6) { animation-delay: 300ms; } +@keyframes actvd-in { from { opacity: 0; transform: translateY(10px); } } + +/* —— 页头(2026-08-25 层级重排):回到默认 PageHead —— 它是导航与动作区,不是主角; + 业务色相只留 42px 图标块一处(与 L1 卡片的连续感),渐变底与色签撤掉 */ +.g-actvd .g-dh-i { color: var(--ah); background: var(--ah-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ah) 18%, transparent); } + +/* —— 统计带(2026-08-25 层级重排):一行灰字 —— 它是清单的合计,不是主角; + 状态色只进 6px 圆点(色不进文字,色盲可读:状态词在点旁边) */ +.g-actvd .g-statline { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 20px; padding: 2px 2px 12px; + font-size: 12px; color: var(--ink-3); } +.g-actvd .g-statline b { margin-left: 2px; font: 600 13px/1 var(--mono); color: var(--ink); } +.g-actvd .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; + background: var(--tone, #8f959e); } + +/* —— 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)—— 左合计右检索,窄屏检索条换行落到下面 */ +.g-actvd .g-ihead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; + padding: 2px 2px 12px; } +/* 统计带收进 g-ihead 后不再自己占 padding */ +.g-actvd .g-ihead .g-statline { padding: 0; } +/* —— 实例检索条(2026-08-26 用户定:列表就要能搜):条件下拉 + 随条件换控件 */ +.g-actvd .g-ifilter { display: flex; align-items: center; gap: 8px; color: var(--ink-4); } +.g-actvd .g-isel, .g-actvd .g-iselect-input, .g-actvd .g-ifilter-range input { height: 28px; padding: 0 8px; + font-size: 12px; color: var(--ink); background: #fff; border: 1px solid #dfe3ea; border-radius: 6px; + outline: none; transition: border-color var(--t-fast), box-shadow var(--t-fast); } +.g-actvd .g-isel:focus, .g-actvd .g-iselect-input:focus, .g-actvd .g-ifilter-range input:focus { + border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); } +.g-actvd .g-isel { cursor: pointer; } +.g-actvd .g-iselect-input { width: 220px; } +.g-actvd .g-iselect-input::placeholder { color: var(--ink-4); } +.g-actvd .g-ifilter-range { display: inline-flex; align-items: center; gap: 6px; } +.g-actvd .g-ifilter-range i { font-style: normal; color: var(--ink-4); } +.g-actvd .g-ifilter-n { font-size: 11.5px; color: var(--ink-3); } +.g-actvd .g-ifilter-n b { font-family: var(--mono); color: var(--ink); } +/* 检索无结果:表体里摆一句实话 */ +.g-actvd .g-irow-empty { display: flex; align-items: center; justify-content: center; gap: 6px; + padding: 22px 14px; font-size: 12px; color: var(--ink-4); } + +/* —— 实例清单:列表式(2026-08-26 用户定:卡片撤了改列表)—— + 列齐好扫:状态 / 实例号 / 输入 / 负责人 / 开始时间 / 输出 / 操作; + 状态色只进 6px 圆点(--tone 来自状态色调类);隔行极浅斑马 + 悬停亮行;行内操作悬停浮现(§11) */ +.g-actvd .g-itab { background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; overflow: hidden; } +/* 操作列固定宽:每行是自己的 grid 容器,若用 auto,操作钮多一枚的行会把 fr 列挤窄 —— 列就对错峰了(2026-08-26 用户报) + 列序(2026-08-26 用户定):实例 / 负责人 / 输入 / 输出 / 启动时间 / 状态 / 操作 */ +.g-actvd .g-itab-h, .g-actvd .g-irow { display: grid; align-items: center; gap: 12px; + grid-template-columns: 88px 72px minmax(0, 1.5fr) minmax(0, 1fr) 92px 88px 150px; padding: 0 14px; } +.g-actvd .g-itab-h { height: 34px; font-size: 11px; font-weight: 600; color: var(--ink-4); letter-spacing: .02em; + background: #f7f8fa; border-bottom: 1px solid #e6eaf0; } +.g-actvd .g-irow { min-height: 44px; padding-top: 6px; padding-bottom: 6px; position: relative; + transition: background var(--t-fast); } +.g-actvd .g-irow + .g-irow { border-top: 1px solid #eef1f5; } +.g-actvd .g-itab > .g-irow:nth-of-type(even) { background: #fafbfc; } +/* 与斑马纹同层级(都带 > .g-irow),靠声明顺序让悬停盖得住 */ +.g-actvd .g-itab > .g-irow:hover { background: #f2f6fb; } +.g-actvd .g-irow .st { display: inline-flex; align-items: center; font-size: 12px; color: var(--ink-2); white-space: nowrap; + animation: actvd-pop .28s ease-out; } +.g-actvd .g-irow .st .dot { background: var(--tone, #8f959e); } +.g-actvd .g-irow .id { font-size: 11.5px; font-weight: 600; color: var(--ink-4); letter-spacing: .02em; } +.g-actvd .g-irow .in { font-size: 13px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-actvd .g-irow .ow { font-size: 12px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-actvd .g-irow .at { font-size: 11.5px; color: var(--ink-3); } +.g-actvd .g-irow .out { font-size: 11.5px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* 行内操作常驻(2026-08-26 用户报:悬停浮现看不到钮)—— + 「查看详情」是进 L3 的主入口,不该藏;终止活动是危险操作,更要一直看得见 */ +.g-actvd .g-irow .a { display: flex; justify-content: flex-end; gap: 6px; } +@keyframes actvd-pop { from { opacity: .3; transform: scale(.86); } } +/* 停止钮:危险动作走红 —— 与「禁用活动」同色阶,危险操作永不实心(§11) */ +.g-rowbtn.danger { background: #fdf2f2; border-color: #f6c6c3; color: #d83931; } +.g-rowbtn.danger:hover { background: #d83931; border-color: #d83931; color: #fff; } +.g-rowbtn:disabled { opacity: .45; cursor: default; pointer-events: none; } +/* 实例清单分页条:页数信息在左,翻页钮在右 —— 页少不上页码器 */ +.g-actvd .g-pager { display: flex; align-items: center; gap: 8px; margin-top: 10px; } +.g-actvd .g-pager-n { margin-right: auto; font: 500 11.5px/1.6 var(--mono); color: var(--ink-4); } +.g-actvd .g-pager .g-rowbtn + .g-rowbtn { margin-left: 2px; } + +/* —— 沙箱:仿浏览器 chrome 条(圆点 + 地址丸)+ 白盒;iframe 定高 480(postMessage 自适应待议) */ +.g-actvd .g-sandbox { position: relative; background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; overflow: hidden; } +.g-actvd .g-sandbox-bar { display: flex; align-items: center; gap: 12px; height: 32px; padding: 0 12px; + background: #f5f7fa; border-bottom: 1px solid #e2e7ee; } +.g-actvd .g-sandbox-bar .dots { display: flex; gap: 5px; } +.g-actvd .g-sandbox-bar .dots i { width: 8px; height: 8px; border-radius: 50%; background: #d5dae2; } +.g-actvd .g-sandbox-bar .addr { display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 10px; + border-radius: 10px; background: #fff; border: 1px solid #e2e7ee; + font: 500 10.5px/1 var(--mono); color: var(--ink-4); } +.g-actvd .g-sandbox-bar .addr svg { color: var(--ink-4); } +.g-actvd .g-sandbox-frame { display: block; width: 100%; height: 480px; border: 0; } +.g-actvd .g-sandbox-load { position: absolute; inset: 32px 0 0; display: flex; align-items: center; justify-content: center; gap: 6px; background: #fff; font-size: 12.5px; color: var(--ink-4); } + +/* 动效开关:reduced-motion 时入场全关(§13) */ +@media (prefers-reduced-motion: reduce) { + .g-actvd > * { animation: none; } +} .g-fcard .mbtn { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ink-4); cursor: pointer; } .g-fcard .mbtn:hover, .g-fcard .mbtn.on { border-color: var(--cline); background: #f3f6fa; color: var(--ink); } .g-fcard .mbtn.bad:hover { border-color: #e6c4c4; background: #fdf2f2; color: var(--danger); } diff --git a/apps/web/src/app/subscribe.ts b/apps/web/src/app/subscribe.ts index 30aea93..fdf3bc8 100644 --- a/apps/web/src/app/subscribe.ts +++ b/apps/web/src/app/subscribe.ts @@ -5,9 +5,15 @@ * 组件禁止自行轮询或手写缓存。」 * * 推送不带业务载荷:它只说「哪个目标动了」,画面照旧由服务端投影重建 —— 前端零过程状态。 - * 断线由 `EventSource` 自行重连;重连后整体失效一次,补上断线期间错过的变更。 + * + * ADR-012:`EventSource` 带不了 Authorization 头,改用 **60 秒票据** + * (`GET /api/auth/ticket` → `/api/stream?ticket=…`)。票据过期或断线后, + * 浏览器自动重连用的是旧 URL,会吃 401 且 EventSource 不再自动重试 —— + * 所以 error 时自己关掉、重新取票据、重建连接;重连后整体失效一次,补上断线期间的变更。 */ import type { QueryClient } from '@tanstack/react-query'; +import { authTicketRoute } from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; import { queryKeys } from './query.ts'; export type Touch = { kind: 'task' | 'workflow' | 'staff' | 'system'; id: string; reason: string }; @@ -69,16 +75,49 @@ export function invalidateFor(queryClient: QueryClient, touch: Touch): void { } export function connectStream(queryClient: QueryClient): () => void { - const source = new EventSource('/api/stream'); + let closed = false; + let source: EventSource | undefined; + let retry: ReturnType | undefined; - source.addEventListener('touch', (event) => { - invalidateFor(queryClient, JSON.parse((event as MessageEvent).data) as Touch); - }); + const open = async (): Promise => { + if (closed) return; + let ticket: string; + try { + ticket = (await call<{ ticket: string }>(authTicketRoute)).ticket; + } catch { + // 票据都取不到( Offline / 401 )—— 一秒后再试,连接不上不算故障 + if (!closed) retry = setTimeout(() => void open(), 1000); + return; + } + if (closed) return; - // 重连即补课:断线期间的推送收不到,回来先整体失效一次 - source.addEventListener('open', () => { - void queryClient.invalidateQueries(); - }); + const es = new EventSource(`/api/stream?ticket=${encodeURIComponent(ticket)}`); + source = es; - return () => source.close(); + es.addEventListener('touch', (event) => { + invalidateFor(queryClient, JSON.parse((event as MessageEvent).data) as Touch); + }); + + // 重连即补课:断线期间的推送收不到,回来先整体失效一次 + es.addEventListener('open', () => { + void queryClient.invalidateQueries(); + }); + + /* + * 票据只活 60 秒:断线后浏览器自动重连用的还是旧 URL,后端会 401, + * 而 EventSource 收到非 200 就不再自动重连 —— 所以 error 时自己关掉、 + * 重新取票据、重建(对接说明 §5)。 + */ + es.addEventListener('error', () => { + es.close(); + if (!closed) retry = setTimeout(() => void open(), 1000); + }); + }; + + void open(); + return () => { + closed = true; + if (retry !== undefined) clearTimeout(retry); + source?.close(); + }; } diff --git a/apps/web/src/features/auth/internal/LoginPage.tsx b/apps/web/src/features/auth/internal/LoginPage.tsx index fba396a..3ea7566 100644 --- a/apps/web/src/features/auth/internal/LoginPage.tsx +++ b/apps/web/src/features/auth/internal/LoginPage.tsx @@ -1,87 +1,37 @@ /** - * 登录页(ADR-010)—— 两份原型都没有这一屏,是新造的。 + * 登录页(ADR-012)—— 统一鉴权中心落点。 + * + * 后端已是资源服务器,不再有 `POST /api/login`(410):这一屏只剩一个动作 — + * 跳转公司统一登录页(Keycloak,PKCE 授权码流程)。口令只在浏览器与 Keycloak + * 之间传,本前端碰不到;登录成功由 Keycloak 跳回本页,静默探测接回会话。 * * 它在两个台面**之前**渲染,拿不到任一台面的皮肤(工作台补充样式通篇作用域 `.proto-root`, * 治理台压根不加载它),所以视觉全部由 `login.css` 自带 —— 类名一律 `jc-login-*`, * 不蹭原型的类,免得在另一台面上落空。 */ -import { useState } from 'react'; -import type { Session } from '@contracts'; -import { loginRoute } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; -import { ApiCallError, call } from '@web-shared/typed-fetch.ts'; import { Btn } from '@web-shared/Btn.tsx'; +import { login } from '@web-shared/auth.ts'; -export function LoginPage({ onLogin }: { onLogin(session: Session): void }) { - const [account, setAccount] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(); - const [busy, setBusy] = useState(false); - - const ready = account.trim() !== '' && password !== ''; - - const submit = async () => { - if (!ready || busy) return; - setBusy(true); - setError(undefined); - try { - onLogin(await call(loginRoute, { body: { account: account.trim(), password } })); - } catch (e) { - // 后端只回一句话(不区分账号不存在 / 已停用 / 口令错),这里照实呈现,不替它猜 - setError(e instanceof ApiCallError ? e.message : '登录失败,请稍后再试'); - setBusy(false); - } - }; - +export function LoginPage() { return (
-
{ - e.preventDefault(); - void submit(); - }} - > +

JCAI 任务操作系统

登录后进入你的工位

- - - - {error !== undefined && ( -
- -

{error}

-
- )} - - {/* 在办的那一段照旧可提交 —— `submit` 自己有 `busy` 闸,按下去什么也不会多发一遍 */} - - {busy ? '登录中…' : '登录'} + {/* + 表单已随统一鉴权中心退役:点一下跳 Keycloak 登录页,回来即登录态。 + 不做自动跳转 —— 探测失败时能停在这一屏让人看清,而不是来回跳(手册 §7 无限循环坑)。 + */} + void login()}> + 前往统一登录 -

初始口令由部署时的 JCAI_SEED_PASSWORD 决定

- +

将跳转到公司统一鉴权中心,登录成功后自动返回

+
); } diff --git a/apps/web/src/features/desk-me/index.ts b/apps/web/src/features/desk-me/index.ts new file mode 100644 index 0000000..a0ab9b9 --- /dev/null +++ b/apps/web/src/features/desk-me/index.ts @@ -0,0 +1,5 @@ +/** + * feature: desk-me —— 唯一出口(编码规范 §9.1)。 + * 个人中心(/me 台面):身份、工作概览、岗位权限、最近任务。 + */ +export { MePage } from './internal/MePage.tsx'; diff --git a/apps/web/src/features/desk-me/internal/MePage.tsx b/apps/web/src/features/desk-me/internal/MePage.tsx new file mode 100644 index 0000000..cf402d8 --- /dev/null +++ b/apps/web/src/features/desk-me/internal/MePage.tsx @@ -0,0 +1,216 @@ +/** + * 个人中心(/me 台面)—— 回答「我是谁、我手上有多少事、我能进哪几个端」。 + * + * 数据全部来自既有接口,零后端改动: + * 身份(GET /api/me,Principal)= 身份 + 岗位 + 角色 + 可进的端(ends,ADR-012); + * 任务 / 审批 / 消息 = 与工作台同一批 queryKeys,概览四数; + * 登出 = Keycloak end-session(`/api/logout` 已退役 410),登出即整页清空重投影。 + * + * 布局:44px 通栏顶栏(返回工作台 + 身份)→ 身份卡 → 工作概览 → 我的准入 → 最近任务。 + */ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { Principal, TaskCard } from '@contracts'; +import { + TERMINAL_STATE_LABEL, + listApprovalsForMeRoute, + listMessagesRoute, + listTasksRoute, + meRoute, +} from '@contracts'; +import { Ico } from '@web-shared/Ico.tsx'; +import { EmptyState } from '@web-shared/desk/module-kit.tsx'; +import { call } from '@web-shared/typed-fetch.ts'; +import { logout as kcLogout } from '@web-shared/auth.ts'; +import { queryKeys } from '@web-shared/query-keys.ts'; + +/** 端 → 人读名(ADR-012:权限只到端,这里把「能进哪几端」摆出来) */ +const END_LABEL: Readonly> = { + design: { label: '设计中心', icon: 'route', href: '/design' }, + ops: { label: '运营中心', icon: 'gauge', href: '/ops' }, + workbench: { label: '工作台', icon: 'list-checks', href: '/' }, +}; + +export function MePage() { + const queryClient = useQueryClient(); + + const me$ = useQuery({ + queryKey: queryKeys.me, + queryFn: () => call(meRoute), + }); + const tasks = useQuery({ + queryKey: queryKeys.tasks, + queryFn: () => call<{ tasks: TaskCard[] }>(listTasksRoute), + }); + const messages = useQuery({ + queryKey: queryKeys.messages, + queryFn: () => call<{ messages: Array<{ id: string; read: boolean }> }>(listMessagesRoute), + }); + const approvals = useQuery({ + queryKey: queryKeys.approvals, + queryFn: () => call<{ approvals: Array<{ state: string }> }>(listApprovalsForMeRoute), + }); + + /** 登出(ADR-012):清缓存 + Keycloak end-session,整页归零(登出不留残影) */ + const logout = () => { + queryClient.clear(); + void kcLogout(); + }; + + const staff = me$.data?.staff; + const position = me$.data?.position; + const roles = me$.data?.roles ?? []; + const ends = me$.data?.ends ?? []; + const list = tasks.data?.tasks ?? []; + + const activeN = list.filter((t) => t.terminal === undefined).length; + const doneN = list.length - activeN; + const pendingApvN = (approvals.data?.approvals ?? []).filter((a) => a.state === 'pending').length; + const unreadMsgN = (messages.data?.messages ?? []).filter((m) => !m.read).length; + + const KPIS = [ + { icon: 'activity', dim: '任务', label: '进行中', value: tasks.data === undefined ? '—' : activeN, tone: 'd-act' }, + { icon: 'circle-check', dim: '任务', label: '已完成', value: tasks.data === undefined ? '—' : doneN, tone: 'd-wf' }, + { icon: 'file-check', dim: '审批', label: '待我审批', value: approvals.data === undefined ? '—' : pendingApvN, tone: 'd-wf' }, + { icon: 'messages-square', dim: '消息', label: '未读消息', value: messages.data === undefined ? '—' : unreadMsgN, tone: 'd-act' }, + ]; + + const recent = list.slice(0, 5); + + return ( +
+
+ + + 返回工作台 + +
+ {(staff?.name ?? '—').slice(0, 1)} + {staff?.name ?? '—'} +
+
+ +
+ {/* 身份卡:我是谁 —— 头像 / 姓名 / 岗位 / 账号编号状态,登出入口也在这 */} +
+ {(staff?.name ?? '—').slice(0, 1)} +
+

{staff?.name ?? '—'}

+ {position !== undefined && ( +

+ {position.name} · {position.code} +

+ )} + {staff !== undefined && ( +

+ 账号 {staff.account} · 编号 {staff.id} ·{' '} + {staff.state === 'active' ? '在职' : '停用'} +

+ )} + {position?.note !== undefined &&

{position.note}

} +
+ +
+ + {/* 工作概览:手上事的四个数 —— 点了回工作台 */} +
+ {KPIS.map((k) => ( + + + + {k.dim} + + {k.label} + {k.value} + + ))} +
+ + {/* 我的准入(ADR-012):统一鉴权中心后不再有功能级 grants —— 权限只到端。 + 这里摆角色与可进的端,端入口可直接点过去 */} +
+
+

我的准入

+ {position !== undefined && 岗位 {position.code}} +
+ {me$.data === undefined ? ( +
+ + 载入中… +
+ ) : ( + <> +
+ + + 角色 + +
+ {roles.length === 0 ? ( + 无 + ) : ( + roles.map((r) => ( + + {r} + + )) + )} +
+
+
+ + + 可进的端 + +
+ {ends.length === 0 ? ( + 还没有被授权进入任何一端,请联系运营中心 + ) : ( + ends.map((e) => { + const meta = END_LABEL[e]; + if (meta === undefined) return null; + return ( + + + {meta.label} + + ); + }) + )} +
+
+ + )} +
+ + {/* 最近任务:前 5 条,标题 + 状态章 */} +
+
+

最近任务

+ 共 {list.length} 项 +
+ {tasks.data === undefined ? ( +
+ + 载入中… +
+ ) : recent.length === 0 ? ( + + ) : ( + recent.map((t) => ( +
+ {t.title} + {t.activity} + + {t.terminal === undefined ? '进行中' : TERMINAL_STATE_LABEL[t.terminal]} + +
+ )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/features/desk-task/index.ts b/apps/web/src/features/desk-task/index.ts index 8d01097..eb73248 100644 --- a/apps/web/src/features/desk-task/index.ts +++ b/apps/web/src/features/desk-task/index.ts @@ -6,6 +6,7 @@ * 下面两组是**备着待接**的渲染件,自原型平移、已按皮肤对齐,接回时直接用或按新口径改。 */ export { TaskList } from './internal/TaskList.tsx'; +export type { TaskFilter } from './internal/TaskList.tsx'; export { ViewRegion } from './internal/ViewRegion.tsx'; export { TaskChat } from './internal/TaskChat.tsx'; diff --git a/apps/web/src/features/desk-task/internal/FileViewer.tsx b/apps/web/src/features/desk-task/internal/FileViewer.tsx index b67a364..e47ddfb 100644 --- a/apps/web/src/features/desk-task/internal/FileViewer.tsx +++ b/apps/web/src/features/desk-task/internal/FileViewer.tsx @@ -6,9 +6,28 @@ * excel → 电子表格网格 · word → A4 文档 · pdf → 内嵌 · markdown → 原文纸面 */ import type { FileDoc } from '@contracts'; +import { authTicketRoute } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; +import { call } from '@web-shared/typed-fetch.ts'; import { ViewContainer } from '@ui'; +/** + * 「下载原件」的点击(ADR-012):`` 带不了 Authorization 头, + * 所以点击那一刻现取一张 60 秒票据拼到 `?ticket=` 上再跳转(对接说明 §5)。 + * **不能在渲染时预取** —— 票据 60 秒就过期。 + */ +function downloadWithTicket(href: string): void { + void call<{ ticket: string }>(authTicketRoute) + .then(({ ticket }) => { + const sep = href.includes('?') ? '&' : '?'; + window.location.assign(`${href}${sep}ticket=${encodeURIComponent(ticket)}`); + }) + .catch(() => { + // 票据取不到就如实跳原链 —— 后端会回 401 错误体,人从响应里能看到原因 + window.location.assign(href); + }); +} + const KIND: Readonly> = { html: { label: 'HTML', icon: 'file-text' }, markdown: { label: 'Markdown', icon: 'file-text' }, @@ -37,7 +56,15 @@ export function FileViewer({ doc, href }: { doc: FileDoc; href?: string | undefi 「产出了 Word」这句话就只能靠界面自称。取不到 href 时不摆这个钮(不给死链)。 */} {href !== undefined && ( - + { + e.preventDefault(); + downloadWithTicket(href); + }} + > 下载原件 )} diff --git a/apps/web/src/features/desk-task/internal/TaskList.tsx b/apps/web/src/features/desk-task/internal/TaskList.tsx index 5e6b3fe..8ffffe1 100644 --- a/apps/web/src/features/desk-task/internal/TaskList.tsx +++ b/apps/web/src/features/desk-task/internal/TaskList.tsx @@ -15,7 +15,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx'; const TASK_COLORS = ['#3370ff', '#ff8800', '#7f3bf5', '#0aa294', '#f14ba9']; -type Status = 'active' | 'done'; +export type TaskFilter = 'active' | 'done'; export type TaskListProps = { readonly tasks: readonly TaskCard[]; @@ -23,17 +23,19 @@ export type TaskListProps = { readonly collapsed: boolean; onPick(id: string): void; onCollapse(): void; + /** 状态页签(进行中/已完成)切换时上报 —— 壳层据此让中间区与对话区跟着页签走 */ + onFilterChange(s: TaskFilter): void; }; -const statusOf = (task: TaskCard): Status => (task.terminal === undefined ? 'active' : 'done'); +const statusOf = (task: TaskCard): TaskFilter => (task.terminal === undefined ? 'active' : 'done'); -export function TaskList({ tasks, selectedId, collapsed, onPick, onCollapse }: TaskListProps) { - const [filter, setFilter] = useState('active'); +export function TaskList({ tasks, selectedId, collapsed, onPick, onCollapse, onFilterChange }: TaskListProps) { + const [filter, setFilter] = useState('active'); const [query, setQuery] = useState(''); if (collapsed) return null; - const count = (s: Status) => tasks.filter((t) => statusOf(t) === s).length; + const count = (s: TaskFilter) => tasks.filter((t) => statusOf(t) === s).length; const q = query.trim().toLowerCase(); const visible = tasks.filter( (t) => @@ -58,11 +60,23 @@ export function TaskList({ tasks, selectedId, collapsed, onPick, onCollapse }: T
- - diff --git a/apps/web/src/features/gov-data/index.ts b/apps/web/src/features/gov-data/index.ts index a274856..daf9687 100644 --- a/apps/web/src/features/gov-data/index.ts +++ b/apps/web/src/features/gov-data/index.ts @@ -2,4 +2,4 @@ * feature `gov-data` 唯一出口(编码规范 §9.1)。 * 数据的两副面孔:结构定义在设计中心(数据域),行数据在运营中心(台账 · 某域页签)。 */ -export { DataHome, LedgersHome, ObjPage } from './internal/panel.tsx'; +export { DataHome, LedgersHome, ObjPage, RowPage } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-data/internal/panel.tsx b/apps/web/src/features/gov-data/internal/panel.tsx index 79911d1..7869adb 100644 --- a/apps/web/src/features/gov-data/internal/panel.tsx +++ b/apps/web/src/features/gov-data/internal/panel.tsx @@ -11,7 +11,7 @@ * 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移: * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 */ -import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx'; +import { Chip, Clear, Kpi, Kpis, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; import { Ico } from '@web-shared/Ico.tsx'; import { useGovDataByActivity, useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts'; @@ -23,9 +23,20 @@ export type HomeProps = { tab: string; nav: Nav }; * 记录浏览的格子文本 —— JSON 列(决策链地图 / 互动时间线)从前被 String() 成 * `[object Object]`(2026-08-24 用户报):对象与数组原样 stringify 摆出来, * 超长截断进 title(截了才加省略号,悬停看全文)。这是取值,不是判断。 + * 空值(null/undefined)摆 `-`(2026-08-24 用户定)—— 空格会让人疑心是没加载出来。 + * ISO 时间串(如信号日期 `2026-05-31T16:00:00.000Z`)格式化成 `YYYY-MM-DD HH:MM:SS` + * 本地时间(2026-08-24 用户定)—— ISO 原串是机器的写法,不是给人扫的。 */ +const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/; +const fmtDateTime = (iso: string): string => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const p = (n: number): string => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +}; const cellText = (v: unknown): string => { - if (v === null || v === undefined) return ''; + if (v === null || v === undefined) return '-'; + if (typeof v === 'string' && ISO_RE.test(v)) return fmtDateTime(v); if (typeof v === 'object') { try { return JSON.stringify(v); @@ -77,23 +88,44 @@ const atMinute = (iso: string): string => { return Number.isNaN(d.getTime()) ? '' : `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; +/** 数字列(右对齐 + 等宽)—— 依据 schema 声明的列类型,不是按值猜 */ +const NUM_TYPES: ReadonlySet = new Set(['number', 'integer', 'real', 'numeric', 'decimal', 'float', 'bigint']); +const isNumCol = (type: string): boolean => NUM_TYPES.has(type.toLowerCase()); +/** 类型徽分色:数字蓝 / json 紫 / 其余灰(语义色只进徽标,§7) */ +const tyTone = (t: string): string => (isNumCol(t) ? ' k-num' : t.toLowerCase() === 'json' ? ' k-json' : ''); + export function DataHome({ nav }: HomeProps) { const q = useGovDataByActivity(); const activities = q.data?.activities ?? []; - const unclaimed = q.data?.unclaimed ?? []; // 一张表都没声明的活动不占地方 —— 那多半是还没写到台账的槽位 const withTables = activities.filter((a) => a.tables.length > 0); + // 页顶汇总带:四个数全是下面清单的合计,纯呈现,不另取数 + const tableCount = withTables.reduce((n, a) => n + a.tables.length, 0); + const formalRows = withTables.reduce((n, a) => n + a.tables.reduce((m, t) => m + (t.rows ?? 0), 0), 0); + const draftRows = withTables.reduce((n, a) => n + a.tables.reduce((m, t) => m + t.drafts, 0), 0); return ( - <> +
+ + + + + 0 ? { tone: 'brand' } : {})} + hint="还躺在各单草稿层、尚未放行的行数合计 —— 单子放行后,这些行才并入正式行。" /> + {withTables.map((a) => { const formal = a.tables.reduce((n, t) => n + (t.rows ?? 0), 0); const drafting = a.tables.reduce((n, t) => n + t.drafts, 0); return (
- + + + {a.name} {a.activityId} @@ -119,7 +151,10 @@ export function DataHome({ nav }: HomeProps) { {t.label ?? t.object} {badge !== undefined && {badge.label}} - + 0 ? ` · 主键 ${t.primaryKey.join(' + ')}` : ''}`} + > {t.domain}.{t.object} {t.primaryKey.length > 0 && · 主键 {t.primaryKey.join(' + ')}} @@ -134,6 +169,9 @@ export function DataHome({ nav }: HomeProps) { )} {t.drafts > 0 && 在办 {t.drafts} 行} + + + ); })} @@ -157,39 +195,12 @@ export function DataHome({ nav }: HomeProps) { ); })} - {unclaimed.length > 0 && ( -
-
- - 无人声明 - {unclaimed.length} 张表 -

库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。

-
-
- {unclaimed.map((o) => ( - - ))} -
-
- )} + {/* 「无人声明」的壳表不在此呈现(2026-08-24 用户定:隐藏) —— 数据仍在 unclaimed 里,要看时把这段渲染加回来 */} - {withTables.length === 0 && unclaimed.length === 0 && ( + {withTables.length === 0 && ( 还没有活动声明台账 —— 装一个活动包,它声明的表就出现在这里;第一次写入时表才真建出来。 )} - +
); } @@ -250,15 +261,19 @@ function LedgerObjectRow({ domain, object, label, nav }: { domain: string; objec /* ==================== 对象详情(两面共用) ==================== */ -export function ObjPage({ id }: { id: string }) { +export function ObjPage({ id, nav }: { id: string; nav: Nav }) { const [domain, object] = id.split('.'); const objects = useGovObjects(); const page = useGovLedger(domain, object); const known = (objects.data?.objects ?? []).some((o) => o.domain === domain && o.object === object); if (!known) return 对象未物化。; const columns = page.data?.columns ?? []; + const pkSet = new Set(page.data?.primaryKey ?? []); + /* 浏览表只留 6-8 条重要数据(2026-08-25 用户定):主键列永远在最前 —— 它是「这一行是谁」; + 其余按声明顺序补足,至多 8 列,全量字段进三级页看 */ + const showCols = [...columns.filter((c) => pkSet.has(c.name)), ...columns.filter((c) => !pkSet.has(c.name))].slice(0, 8); return ( - <> +
- -
- {columns.map((c) => ( -
- {/* 人读名在前、字段键在后:填表的人看名字,写映射的人看键 */} - {c.label ?? c.name} - - {c.name} · {c.type} - -
- ))} -
-
- + (page.data?.rows ?? []).length + ? `前 ${(page.data?.rows ?? []).length} 行 · 共 ${page.data?.total ?? 0} 行` + : `共 ${(page.data?.rows ?? []).length} 行`} + > {(page.data?.rows ?? []).length === 0 ? ( 这张表还没有记录 —— 任务放行后,过门的事实才落进来。 ) : ( @@ -293,25 +302,40 @@ export function ObjPage({ id }: { id: string }) { - {columns.map((c) => ( - ))} + {/* 操作列钉在最右(2026-08-25 用户定):整行数据的入口,三级页看全量 */} + {(page.data?.rows ?? []).map((row, i) => ( - {columns.map((c) => { + {showCols.map((c) => { + const empty = row[c.name] === null || row[c.name] === undefined; const full = cellText(row[c.name]); const long = full.length > 120; return ( - ); })} + ))} @@ -319,6 +343,125 @@ export function ObjPage({ id }: { id: string }) { )} - + + {/* 转置矩阵(2026-08-24 用户定):行 = 参数名/字段/数据类型,列 = 每个字段; + 一列就是一列的完整定义,横着扫就是整表的结构 */} +
+
+ {showCols.map((c) => ( + + {/* 主键列名前挂一枚小钥匙 —— 全表只有它能当「这一行是谁」 */} + {pkSet.has(c.name) && } {c.label ?? c.name} 操作
+ {long ? `${full.slice(0, 120)}…` : full} + +
+ + + + {columns.map((c) => ( + + ))} + + + + {columns.map((c) => ( + + ))} + + + + {columns.map((c) => ( + + ))} + + +
参数名 + {c.label ?? c.name} +
字段 + {c.name} +
数据类型 + {c.type} +
+
+ +
+ ); +} + +/* ==================== 记录详情(三级页) ==================== */ + +/** + * 一行记录的全量视图(2026-08-25 用户定):浏览表只摆 8 列要点, + * 这页把**所有字段**摊开 —— 一字段一行:参数名 / 字段 / 数据类型 / 完整值。 + * 值不截断、可折行(JSON 整串摆全)—— 详情页的天职就是「看全」。 + * + * 没有单行接口:复用台账浏览(`useGovLedger`)的同一份查询缓存按下标取行, + * 从浏览页点进来时零额外请求;深链/刷新时重新拉第一页,下标依旧对得上。 + */ +export function RowPage({ id }: { id: string }) { + const at = id.lastIndexOf('.'); + const objId = at > 0 ? id.slice(0, at) : id; + const rowIndex = at > 0 ? Number(id.slice(at + 1)) : Number.NaN; + const [domain, object] = objId.split('.'); + const objects = useGovObjects(); + const page = useGovLedger(domain, object); + const known = (objects.data?.objects ?? []).some((o) => o.domain === domain && o.object === object); + if (!known) return 对象未物化。; + const columns = page.data?.columns ?? []; + const pkSet = new Set(page.data?.primaryKey ?? []); + const rows = page.data?.rows ?? []; + const row = Number.isInteger(rowIndex) ? rows[rowIndex] : undefined; + if (page.data !== undefined && row === undefined) { + return 这条记录不在当前页里 —— 数据可能已变动,返回「记录浏览」重新进入。; + } + /** 标题优先摆主键值 —— 那是「这一行是谁」;没有主键就摆行号 */ + const pkText = + row === undefined || pkSet.size === 0 + ? '' + : [...pkSet] + .map((k) => cellText(row[k])) + .filter((s) => s !== '-') + .join(' · '); + return ( +
+ + 第 {rowIndex + 1} 行 + {columns.length} 列 + 主键 {(page.data?.primaryKey ?? []).join(', ') || '—'} + + } + desc="这条记录的全部字段 —— 值完整呈现、不加截断;结构口径以「模式」为准,此处只读正式版。" + /> + +
+ + + + + + + + + + + {columns.map((c) => { + const empty = row === undefined || row[c.name] === null || row[c.name] === undefined; + return ( + + + + + + + ); + })} + +
参数名字段数据类型值
+ {pkSet.has(c.name) && } + {c.label ?? c.name} + {c.name} + {c.type} + {row === undefined ? '' : cellText(row[c.name])}
+
+
+
); } diff --git a/apps/web/src/features/gov-task/index.ts b/apps/web/src/features/gov-task/index.ts index e6364a8..7466b65 100644 --- a/apps/web/src/features/gov-task/index.ts +++ b/apps/web/src/features/gov-task/index.ts @@ -2,4 +2,4 @@ * feature `gov-task` 唯一出口(编码规范 §9.1)。 * 任务管理:在途监控、控制事件(暂停/叫停/终止)、恢复、改派、任务日志查看 */ -export { TasksHome, WfPage, TaskPage, TaskLogPage, WorkflowBoard } from './internal/panel.tsx'; +export { TasksHome, ActvPage, WfPage, TaskPage, TaskLogPage, WorkflowBoard } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index fa11533..f996317 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -19,20 +19,14 @@ import { import type { ControlSemantic } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import { call } from '@web-shared/typed-fetch.ts'; -import { Ava, Card, Chip, Clear, Entry, Facts, Kpi, NList, NRow, Note, PageHead, QueryGate, Sec, St } from '@web-shared/gov/primitives.tsx'; +import { Ava, Card, Chip, Clear, Entry, Facts, NList, NRow, Note, PageHead, Sec } from '@web-shared/gov/primitives.tsx'; import type { ActSpec } from '@web-shared/gov/primitives.tsx'; -import { APR_ST, RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'; +import { RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts'; import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx'; import { atFull, atMinute } from '@web-shared/at.ts'; import { Btn } from '@web-shared/Btn.tsx'; -import { - useGovApprovals, - useGovStaff, - useGovTaskLog, - useGovTasks, - useGovWorkflow, -} from '@web-shared/gov/gov-api.ts'; +import { useGovStaff, useGovTaskLog, useGovTasks, useGovWorkflow } from '@web-shared/gov/gov-api.ts'; export type HomeProps = { tab: string; nav: Nav }; @@ -126,133 +120,518 @@ export function WorkflowBoard({ nav }: { nav: Nav }) { ); } +/** + * 运营中心 · 活动区一级页(L1)—— 2026-08-25 重构模版(静态数据占位,接口未接): + * 页签二分「已发布 / 审核中」(与「创建过审核才展示」的规则对应); + * 活动以卡片呈现 —— 头 = 名称 + 标识 + 状态丸,契约带 = 输入 → 输出,底板 = 实例/负责人/时间; + * 尾部幽灵卡 = 创建活动入口(创建功能本期不做,纯占位)。 + * L2(活动详情 + 实例清单)、L3(实例详情)随后接。 + */ +type DemoActivity = { + id: string; + name: string; + biz: string; + instances: number; + owner: string; + createdAt: string; + /** 运营态:激活中 / 已禁用(2026-08-25 用户定)—— L2 里可改;发布/审核是设计部分,暂缓 */ + status: 'active' | 'disabled'; + /** 当前版本号 —— 活动无编辑/删除,演进只能靠发新版本(2026-08-25 用户定) */ + version?: string; + /** 语义图标与业务色相 —— 色即业务,一卡一色(2026-08-25 视觉增强) */ + ico: string; + hue: 'blue' | 'teal' | 'amber' | 'violet'; + /** 这个活动绑定的 LLM —— 每个活动一个专属 agent,负责把流程跑通(2026-08-25 用户定:L2 要展示) */ + llm: string; +}; + +/** 占位静态数据 —— 接口设计定稿后整体换掉(2026-08-25 用户定:先出页面模版看效果) */ +const DEMO_ACTIVITIES: readonly DemoActivity[] = [ + { id: 'quote-intake', name: '询价接收与结构化', biz: '报价业务', instances: 12, owner: '柴浩', createdAt: '08-12', status: 'active', version: '1.3.0', ico: 'inbox', hue: 'blue', llm: 'K3 · quote-intake' }, + { id: 'quote-pricing', name: '报价定价测算', biz: '报价业务', instances: 8, owner: '丁子乔', createdAt: '08-12', status: 'active', version: '1.2.0', ico: 'calculator', hue: 'blue', llm: 'K3 · quote-pricing' }, + { id: 'crm-coverage', name: '关键人触达计划', biz: 'CRM 业务', instances: 5, owner: '冯兵', createdAt: '08-15', status: 'active', version: '1.0.0', ico: 'target', hue: 'teal', llm: 'K3 · crm-coverage' }, + { id: 'competitor-watch', name: '竞品价格监测', biz: '报价业务', instances: 2, owner: '柴浩', createdAt: '08-24', status: 'active', version: '1.1.0', ico: 'eye', hue: 'blue', llm: 'K3 · competitor-watch' }, + { id: 'market-weekly', name: '市场信号周报生成', biz: '市场业务', instances: 3, owner: '林佩萱', createdAt: '08-18', status: 'disabled', version: '2.1.0', ico: 'trending-up', hue: 'amber', llm: 'K3 · market-weekly' }, + { id: 'supplier-verify', name: '供应商资质核验', biz: '采购业务', instances: 1, owner: '丁子乔', createdAt: '08-25', status: 'disabled', version: '1.0.0', ico: 'shield-check', hue: 'violet', llm: 'K3 · supplier-verify' }, +]; + function RuntimeHome({ tab, nav }: HomeProps) { - const q = useGovTasks(); - const approvals = useGovApprovals(); - const tasks = q.data?.tasks ?? []; - const workflows = q.data?.workflows ?? []; - - if (tab === 'apr') { - const rows = approvals.data?.approvals ?? []; - return ( - <> - - 审批点是流程层的通用人裁节点,与活动、网关、触发器、流程包并列为编排层五要素——它是 - 系统内置要素,声明即用、零开发、不进活动库。它卡到审批人工位产出审批单:通过 / - 驳回,附依据;没有预算、没有 Harness、没有任务日志,所以不是任务。 - - - {rows.map((a) => ( - - {APR_ST[a.state].label} - - } - onClick={() => nav.push({ k: 'wf', id: a.workflowId })} - /> - ))} - {rows.length === 0 && 暂无审批单——流上还没有走到审批点。} - - - ); - } - - if (tab === 'list') { - return ( - <> - - {tasks.map((t) => { - const st = t.terminal !== undefined ? TERMINAL_ST[t.terminal] : RUNTIME_ST[t.runtime]; - const label = t.terminal !== undefined ? TERMINAL_STATE_LABEL[t.terminal] : TASK_RUNTIME_STATE_LABEL[t.runtime]; - return ( - - {t.usedSteps}/{t.capSteps} - - ), - }, - { k: '起始', v: {atMinute(t.startedAt)} }, - ]} - right={ - - {label} - - } - onClick={() => nav.push({ k: 'task', id: t.id })} - /> - ); - })} - {tasks.length === 0 && 暂无任务——起单后这里会出现。} - - - ); - } - - const abnormal = workflows.filter((w) => w.state === 'abnormal'); - const stuck = tasks.filter((t) => t.terminal === 'blocked' || t.terminal === 'exhausted' || t.terminal === 'stalled'); + const items = DEMO_ACTIVITIES.filter((a) => a.status === tab); return ( - -
-
- w.state === 'running' || w.state === 'waiting').length} - sub="运行中 + 等待中" - /> - - - t.terminal === 'released').length} - sub="唯一成功终态" - /> +
+
+ {items.map((a) => ( +
+ {/* 整卡热区 → L2 活动详情(2026-08-25 接) */} +
+ ))} + {/* + 幽灵卡 = 创建入口 —— 2026-08-25 用户定:设计功能暂缓,先做运营部分, + 创建活动的幽灵卡先注释掉;恢复时把这段 JSX 解开即可(样式 .g-fcard.ghost 保留着) +
+ + 创建活动 +
+ */}
- {abnormal.map((w) => ( - nav.push({ k: 'wf', id: w.id })} - /> - ))} - {stuck.map((t) => ( - nav.push({ k: 'task', id: t.id })} - /> - ))} - {abnormal.length + stuck.length === 0 && 无待处置——在途实例全部正常推进。}
- + ); +} + +/* ==================== 活动详情(L2,2026-08-25 重构模版) ==================== */ + +/** 实例状态五态(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰 */ +type InstanceState = '进行中' | '待执行' | '已完成' | '驳回' | '失败'; +const INSTANCE_ST: Readonly> = { + 进行中: 'brand', + 待执行: 'warn', + 已完成: 'ok', + 驳回: 'danger', + 失败: 'mute', +}; + +type DemoInstance = { + id: string; + input: string; + state: InstanceState; + owner: string; + startedAt: string; + output: string; +}; + +/** 占位实例清单 —— 每个活动先共用同一份样子,接口定稿后按活动取(2026-08-25 模版) */ +const demoInstances = (owner: string): readonly DemoInstance[] => [ + { id: 'INS-2417', input: 'Itelma 车身控制器询价 · 3 项物料', state: '进行中', owner, startedAt: '08-25 10:12', output: '—' }, + { id: 'INS-2416', input: '伏尔加重卡灯组询价 · 12 项物料', state: '待执行', owner, startedAt: '08-25 09:40', output: '—' }, + { id: 'INS-2415', input: 'GAZ 底盘线束询价 · 7 项物料', state: '已完成', owner, startedAt: '08-24 17:03', output: 'quote-20260824-07.json' }, + { id: 'INS-2414', input: 'KAMAZ 传感器询价 · 5 项物料', state: '已完成', owner, startedAt: '08-24 14:26', output: 'quote-20260824-03.json' }, + { id: 'INS-2413', input: 'UAZ 控制模块询价 · 4 项物料', state: '驳回', owner, startedAt: '08-23 16:51', output: '—' }, + { id: 'INS-2412', input: 'Lada 车机询价 · 9 项物料', state: '失败', owner, startedAt: '08-23 11:05', output: '—' }, + { id: 'INS-2411', input: 'Itelma 座椅电机询价 · 6 项物料', state: '已完成', owner, startedAt: '08-22 18:22', output: 'quote-20260822-11.json' }, + { id: 'INS-2410', input: 'GAZ 门锁执行器询价 · 2 项物料', state: '已完成', owner, startedAt: '08-22 15:47', output: 'quote-20260822-06.json' }, + { id: 'INS-2409', input: 'KAMAZ 变速箱询价 · 8 项物料', state: '驳回', owner, startedAt: '08-22 10:19', output: '—' }, + { id: 'INS-2408', input: '伏尔加仪表集群询价 · 5 项物料', state: '已完成', owner, startedAt: '08-21 16:33', output: 'quote-20260821-09.json' }, + { id: 'INS-2407', input: 'UAZ 分动箱询价 · 3 项物料', state: '失败', owner, startedAt: '08-21 13:58', output: '—' }, + { id: 'INS-2406', input: 'Lada 悬挂件询价 · 11 项物料', state: '已完成', owner, startedAt: '08-21 09:24', output: 'quote-20260821-02.json' }, + { id: 'INS-2405', input: 'Itelma 雨刮电机询价 · 1 项物料', state: '已完成', owner, startedAt: '08-20 17:41', output: 'quote-20260820-08.json' }, +]; + +/** 实例清单单页上限(2026-08-25 用户定:6 → 8,6 张时页面与卡内留白太多) */ +const INSTANCE_PAGE_SIZE = 8; + +/* ── 历史活动检索(2026-08-26 用户定:列表就要能搜)────────────────────── + 条件下拉四选:实例(号,模糊)/ 负责人(人员下拉)/ 状态(五态)/ 启动时间(日期段); + 条件一换,右边的控件跟着换 —— 一个条件一个趁手的组件 */ + +type InstanceFilterKey = 'id' | 'owner' | 'state' | 'time'; + +const INSTANCE_FILTER_LABEL: Readonly> = { + id: '实例', + owner: '负责人', + state: '状态', + time: '启动时间', +}; + +/** 占位数据的启动时间是 `MM-DD HH:mm`(2026 年),转成可比较的 Date;接口定稿后随真数据格式一起换 */ +function instanceTime(at: string): number { + const m = /^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(at); + if (m === null) return 0; + return new Date(2026, Number(m[1]) - 1, Number(m[2]), Number(m[3]), Number(m[4])).getTime(); +} + +/** 日期段 [from, to](`YYYY-MM-DD`,含两端整日)—— 空端不卡那一边 */ +function inTimeRange(at: string, from: string, to: string): boolean { + const t = instanceTime(at); + if (from !== '' && t < new Date(`${from}T00:00:00`).getTime()) return false; + if (to !== '' && t > new Date(`${to}T23:59:59`).getTime()) return false; + return true; +} + +/** + * L2 · 活动详情 —— 两个问题一屏答:「这个活动是什么」(定义,只读) + * 与「它跑得怎么样」(小统计 + 实例清单,点行进 L3)。 + * + * 头部动作三个(2026-08-25 用户定): + * · 激活/禁用 —— 本期做交互,二次确认;**有实例时无法禁用**(规则来自接口,本期前端占位演示); + * · 启动活动(即创建实例)/ 版本迭代 —— 只留入口,功能后做(disabled)。 + * + * 沙箱区:加载服务器返回的活动视图 HTML —— iframe + sandbox 隔离; + * v1 静态占位页,src 与内容高度通讯(postMessage)待接口一起定。 + */ +export function ActvPage({ id }: { id: string }) { + const a = DEMO_ACTIVITIES.find((x) => x.id === id); + const [status, setStatus] = useState<'active' | 'disabled' | undefined>(undefined); + const [blocked, setBlocked] = useState(false); + const [frameLoaded, setFrameLoaded] = useState(false); + /* 实例清单入组件状态:操作列的「终止活动」要就地改行(本期前端演示,接口对接后走 mutation) */ + const [rows, setRows] = useState(undefined); + const [page, setPage] = useState(0); + /* 检索条件(2026-08-26 用户定):条件下拉 + 随条件换控件;值一变回第一页 */ + const [fKey, setFKey] = useState('id'); + const [fText, setFText] = useState(''); + const [fOwner, setFOwner] = useState(''); + const [fState, setFState] = useState(''); + const [fFrom, setFFrom] = useState(''); + const [fTo, setFTo] = useState(''); + if (a === undefined) return 活动不存在 —— 它可能已被调整,返回活动清单看看。; + const st = status ?? a.status; + const instances = rows ?? demoInstances(a.owner); + const countOf = (s: InstanceState): number => instances.filter((i) => i.state === s).length; + /** 终止实例(2026-08-25 用户定:进行中的实例要有「终止活动」钮)—— 终止后归到哪个状态接口语义未定,前端先落「失败」占位 */ + const stopInstance = (rid: string): void => + setRows((cur) => (cur ?? demoInstances(a.owner)).map((r) => (r.id === rid ? { ...r, state: '失败' as InstanceState } : r))); + + /* 检索:负责人下拉的候选从现有实例里收(真接口落地后换人员接口) */ + const owners = [...new Set(instances.map((i) => i.owner))]; + /** 当前条件是否真的带了值 —— 没带值不过滤,等于没搜 */ + const filtering = + (fKey === 'id' && fText.trim() !== '') || + (fKey === 'owner' && fOwner !== '') || + (fKey === 'state' && fState !== '') || + (fKey === 'time' && (fFrom !== '' || fTo !== '')); + const filtered = !filtering + ? instances + : instances.filter((r) => { + switch (fKey) { + case 'id': + return r.id.toLowerCase().includes(fText.trim().toLowerCase()); + case 'owner': + return r.owner === fOwner; + case 'state': + return r.state === fState; + case 'time': + return inTimeRange(r.startedAt, fFrom, fTo); + } + }); + const clearFilter = () => { + setFText(''); + setFOwner(''); + setFState(''); + setFFrom(''); + setFTo(''); + setPage(0); + }; + /* 分页(2026-08-25 用户定:单页最多 8 条)—— 检索后按筛出的集合翻页 */ + const pageCount = Math.max(1, Math.ceil(filtered.length / INSTANCE_PAGE_SIZE)); + const cur = Math.min(page, pageCount - 1); + const pageRows = filtered.slice(cur * INSTANCE_PAGE_SIZE, (cur + 1) * INSTANCE_PAGE_SIZE); + + return ( +
+ + {st === 'active' ? '激活中' : '已禁用'} + {a.version !== undefined && v{a.version}} + {/* 绑定的 LLM 上头版(2026-08-25 用户定):每个活动一个专属 agent */} + {a.llm} + {a.owner} + 创建于 {a.createdAt} + + } + desc="活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。" + acts={[ + st === 'active' + ? { + label: '禁用活动', + ico: 'power', + danger: true, + ask: { + title: '确认禁用这个活动?', + note: '禁用后不能再为它创建实例;已在跑的实例不受影响。', + impact: { who: '该活动及其后续实例创建', undo: '可恢复 —— 随时重新激活', when: '确认后立即生效(本期前端演示,接口对接后落库)' }, + okLabel: '确认禁用', + onOk: () => { + /* 规则(2026-08-25 用户定):有实例时接口会提示无法禁用 —— 本期前端先演出这个拦截 */ + if (a.instances > 0) { + setBlocked(true); + } else { + setStatus('disabled'); + setBlocked(false); + } + }, + }, + } + : { + label: '激活活动', + ico: 'zap', + primary: true, + ask: { + title: '确认激活这个活动?', + note: '激活后可以为它创建实例。', + impact: { who: '该活动及其后续实例创建', undo: '可逆 —— 随时可以再次禁用', when: '确认后立即生效(本期前端演示,接口对接后落库)' }, + okLabel: '确认激活', + onOk: () => { + setStatus('active'); + setBlocked(false); + }, + }, + }, + { label: '启动活动', ico: 'plus', disabled: true }, + /* 版本迭代入口先隐藏(2026-08-25 用户定):功能设计回头讨论,要做时把这行加回 acts + { label: '版本迭代', ico: 'git-branch', disabled: true }, + */ + ]} + /> + {blocked && ( + + 无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。 + + )} + + + {/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)—— + 左:总数 + 五态灰字合计(状态色只进 6px 小圆点);右:条件下拉 + 随条件换控件 */} +
+
+ + 总数 {instances.length} + + {(Object.keys(INSTANCE_ST) as InstanceState[]).map((s) => ( + + + {s} {countOf(s)} + + ))} +
+
+ + + {fKey === 'id' && ( + { + setFText(e.target.value); + setPage(0); + }} + /> + )} + {fKey === 'owner' && ( + + )} + {fKey === 'state' && ( + + )} + {fKey === 'time' && ( + + { + setFFrom(e.target.value); + setPage(0); + }} + /> + – + { + setFTo(e.target.value); + setPage(0); + }} + /> + + )} + {filtering && ( + <> + + 筛出 {filtered.length} / 共 {instances.length} 个 + + + + )} +
+
+ {/* 列表式清单(2026-08-26 用户定:卡片撤了改列表)—— + 行 = 一次实例,列齐好扫;状态色只进 6px 圆点(色盲可读:状态词在点旁边); + 行内操作常驻(查看详情是主入口,不藏) */} +
+ {/* 列序(2026-08-26 用户定):实例 / 负责人 / 输入 / 输出 / 启动时间 / 状态 / 操作(操作栏也有标题) */} +
+ 实例 + 负责人 + 输入 + 输出 + 启动时间 + 状态 + 操作 +
+ {pageRows.map((r) => ( +
+ {r.id} + {r.owner} + + {r.input} + + + {r.output} + + {r.startedAt} + {/* key 带状态:状态翻转(终止)时弹出一次高亮 —— 状态变化要看得见 */} + + + {r.state} + + + {r.state === '进行中' && ( + + )} + + +
+ ))} + {/* 检索无结果:如实摆一句话,不留一张只剩表头的空表 */} + {pageRows.length === 0 && ( +
+ + 没有匹配的实例 —— 换个条件,或清空重看全部。 +
+ )} +
+ {/* 分页(2026-08-25 用户定:单页最多 8 条)—— 页少不上页码器,翻页两枚就够;计数跟着筛出的集合走 */} +
+ + 第 {cur + 1} / {pageCount} 页 · {filtering ? `筛出 ${filtered.length} / 共 ${instances.length} 个` : `共 ${filtered.length} 个`} + + + +
+
+ + {/* + * 沙箱(2026-08-25 用户定):加载服务器返回的活动视图 HTML。 + * 隔离:iframe + sandbox="allow-scripts" —— 不透明源,禁同源/表单/弹窗,视图只能渲染与跑自己的脚本; + * v1 静态占位页(/sandbox-demo/activity-view.html),src 以后来自接口(活动定义里的视图地址); + * 待议:内容高度 postMessage 自适应(现在固定高 480)、加载失败态(接口 404/超时)。 + */} + +
+ {/* 仿浏览器 chrome 条:让人一眼认出「这是嵌进来的外部视图」—— 全页唯一一处隐喻,不多用 */} +
+ + + + + + + + {a.id} · 活动视图(沙箱隔离) + +
+ {!frameLoaded && ( +
+ + 视图载入中… +
+ )} +