Archived
接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>活动视图 · 询价接收与结构化</title>
|
||||
<!--
|
||||
活动视图占位页(2026-08-25,L2 沙箱 v1):
|
||||
模拟「服务器返回的 HTML」—— 由 iframe + sandbox="allow-scripts" 隔离渲染,
|
||||
与宿主页面零样式/脚本互通;正式版由接口按活动定义返回。
|
||||
-->
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font: 14px/1.7 -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #161a22; background: #f6f7f9; padding: 24px;
|
||||
}
|
||||
.wrap { max-width: 880px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
.head { display: flex; align-items: center; gap: 12px; }
|
||||
.head .logo { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center;
|
||||
background: linear-gradient(150deg, #eaf1ff, #fff); color: #3370ff; box-shadow: inset 0 0 0 1px rgba(51,112,255,.18); }
|
||||
.head h1 { font-size: 18px; font-weight: 700; letter-spacing: .01em; }
|
||||
.head p { font-size: 12px; color: #8b93a3; font-family: ui-monospace, monospace; }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.stat { background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; padding: 12px 14px; }
|
||||
.stat em { display: block; font-style: normal; font-size: 11px; color: #8b93a3; }
|
||||
.stat b { display: block; margin-top: 2px; font: 600 20px/1.2 ui-monospace, monospace; color: #161a22; }
|
||||
.stat.hot b { color: #3370ff; }
|
||||
.card { background: #fff; border: 1px solid #dfe3ea; border-radius: 8px; overflow: hidden; }
|
||||
.card h2 { padding: 12px 16px; font-size: 13px; font-weight: 700; color: #4a5160;
|
||||
border-bottom: 1px solid #eef1f5; letter-spacing: .03em; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th { text-align: left; padding: 8px 16px; background: #f5f7fa; color: #4a5160;
|
||||
font-size: 11px; font-weight: 700; border-bottom: 1px solid #e2e7ee; }
|
||||
tbody td { padding: 10px 16px; border-bottom: 1px solid #eef1f5; }
|
||||
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.ok { display: inline-block; padding: 1px 8px; border-radius: 999px; background: #e6f6ee; color: #0c8154; font-size: 11px; font-weight: 600; }
|
||||
.wait { display: inline-block; padding: 1px 8px; border-radius: 999px; background: #fdf3e3; color: #b0822d; font-size: 11px; font-weight: 600; }
|
||||
.form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; }
|
||||
.field em { display: block; font-style: normal; font-size: 11px; color: #8b93a3; margin-bottom: 4px; }
|
||||
.field .box { height: 34px; border: 1px solid #dfe3ea; border-radius: 6px; background: #fbfcfd;
|
||||
display: flex; align-items: center; padding: 0 10px; font-size: 12.5px; color: #4a5160; }
|
||||
.field.wide { grid-column: 1 / -1; }
|
||||
.cta { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.btn { height: 34px; padding: 0 16px; border: 1px solid #dfe3ea; border-radius: 6px; background: #fff;
|
||||
font-size: 13px; color: #4a5160; }
|
||||
.btn.primary { background: #3370ff; border-color: #3370ff; color: #fff; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="logo">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg>
|
||||
</span>
|
||||
<div>
|
||||
<h1>询价接收与结构化</h1>
|
||||
<p>quote-intake · 活动视图(服务器返回 HTML · 沙箱隔离渲染)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><em>今日接收</em><b>6</b></div>
|
||||
<div class="stat hot"><em>待人工补全</em><b>1</b></div>
|
||||
<div class="stat"><em>结构化成功</em><b>5</b></div>
|
||||
<div class="stat"><em>平均耗时</em><b>42s</b></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>最近的询价原文</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>来源</th><th>物料数</th><th>接收时间</th><th>结构化结果</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Itelma 采购部 · 邮件</td><td class="mono">3 项</td><td class="mono">10:12</td><td><span class="wait">待补全</span></td></tr>
|
||||
<tr><td>伏尔加集团 · 聊天记录</td><td class="mono">12 项</td><td class="mono">09:40</td><td><span class="ok">已结构化</span></td></tr>
|
||||
<tr><td>GAZ 采购 · 邮件附件</td><td class="mono">7 项</td><td class="mono">昨天 17:03</td><td><span class="ok">已结构化</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>人工补全(待执行 · INS-2417)</h2>
|
||||
<div class="form">
|
||||
<div class="field"><em>物料编码</em><div class="box">MCU-STM32F407VET6</div></div>
|
||||
<div class="field"><em>数量</em><div class="box mono">2,400 pcs</div></div>
|
||||
<div class="field wide"><em>交期要求</em><div class="box">2026-09-30 前到莫斯科仓</div></div>
|
||||
<div class="cta">
|
||||
<button class="btn">暂存</button>
|
||||
<button class="btn primary">确认提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Keycloak 静默 SSO 探测的落点页(keycloak-frontend-guide §5):
|
||||
keycloak-js 开隐藏 iframe 指向 Keycloak,判完会话把 iframe 重定向回本页,
|
||||
本页用 postMessage 把结果传回父窗口。整个过程用户看不见。
|
||||
**必须在 public 根目录**,构建后能通过 /silent-check-sso.html 直接访问。
|
||||
-->
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<ModuleKey>('tasks');
|
||||
/** 清单状态页签(进行中/已完成)—— TaskList 上报,中间区与对话区跟着它走(2026-08-25 用户定) */
|
||||
const [listFilter, setListFilter] = useState<TaskFilter>('active');
|
||||
/** 「建设中」弹窗:点未开放模块(审批/消息/纪要/汇报)或个人中心时弹出,不跳页(2026-08-25 用户定) */
|
||||
const [wip, setWip] = useState<string | null>(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<Session>(sessionRoute),
|
||||
const me$ = useQuery({
|
||||
queryKey: queryKeys.me,
|
||||
queryFn: () => call<Principal>(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<TaskDetail>(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() {
|
||||
<button
|
||||
key={m.key}
|
||||
className={module === m.key ? 'active' : ''}
|
||||
onClick={() => setModule(m.key)}
|
||||
onClick={() => (m.key === 'tasks' ? setModule('tasks') : setWip(m.label))}
|
||||
aria-current={module === m.key ? 'page' : undefined}
|
||||
>
|
||||
<span className="app-nav-icon-wrap">
|
||||
@@ -152,24 +167,35 @@ export function AppShell() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="jc-rail-foot">
|
||||
{/* 轨底只报身份,不再兼职登出入口 —— 退出登录挪到顶栏,免得点头像误触登出 */}
|
||||
<div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
|
||||
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
||||
<span className="jc-nav-name">{me}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 轨底不再报身份:头像与姓名挪到顶栏右侧的身份菜单里(2026-08-25 用户定) */}
|
||||
</nav>
|
||||
|
||||
<div className="app-main">
|
||||
<header className="jc-topbar">
|
||||
<span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span>
|
||||
<header className="jc-topbar jc-topbar-app">
|
||||
{/* 顶栏不挂模块名,只留右侧身份菜单(2026-08-25 用户定) */}
|
||||
<div className="jc-topbar-user">
|
||||
<span className="jc-topbar-name">{me}</span>
|
||||
<Btn className="jc-topbar-logout" busy={logout.isPending} onClick={() => logout.mutate()}>
|
||||
<Ico name="log-out" s={14} />
|
||||
退出登录
|
||||
</Btn>
|
||||
{/* 身份菜单:悬停头像/姓名出下拉 —— 个人中心(建设中提示)+ 退出登录;登出仍是全站唯一入口 */}
|
||||
<div className="jc-usermenu">
|
||||
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
||||
<span className="jc-topbar-name">{me}</span>
|
||||
<Ico name="chevron-down" s={12} />
|
||||
<div className="jc-usermenu-pop">
|
||||
{/* 个人中心独立成页(/me 台面),整页导航过去(2026-08-25 用户定) */}
|
||||
<a href="/me">
|
||||
<Ico name="user" s={14} />
|
||||
个人中心
|
||||
</a>
|
||||
{/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */}
|
||||
<a href="/design#/design/data/domains">
|
||||
<Ico name="pen-line" s={14} />
|
||||
设计中心
|
||||
</a>
|
||||
<button type="button" className="danger" onClick={logout}>
|
||||
<Ico name="log-out" s={14} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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() {
|
||||
* 是白卡片,任务这一栏却是画布上飘着的一段文字,看着像没渲染出来。
|
||||
*/
|
||||
<main className="execution-panel jc-panel-card">
|
||||
{tasks.isLoading ? (
|
||||
{tasks.isLoading || detailPending ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
载入中…
|
||||
@@ -271,12 +303,19 @@ export function AppShell() {
|
||||
<span className="jc-tch-desc">开一单即可对话</span>
|
||||
</header>
|
||||
<div className="jc-tch-scroll">
|
||||
<EmptyState
|
||||
ico="messages-square"
|
||||
tone="brand"
|
||||
title="还没有可对话的任务"
|
||||
desc="每一单任务都有自己的对话:AI 有结论、要派工、或者要你拿个主意,都在那一单里说 —— 所以得先有一单在手。"
|
||||
/>
|
||||
{detailPending ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="messages-square" s={18} />
|
||||
载入中…
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
ico="messages-square"
|
||||
tone="brand"
|
||||
title="还没有可对话的任务"
|
||||
desc="每一单任务都有自己的对话:AI 有结论、要派工、或者要你拿个主意,都在那一单里说 —— 所以得先有一单在手。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="copilot-composer session-composer">
|
||||
<div className="composer-row">
|
||||
@@ -302,6 +341,22 @@ export function AppShell() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 「建设中」提示:点遮罩或按钮关闭,不跳页 */}
|
||||
{wip !== null && (
|
||||
<div className="jc-wip-overlay" onClick={() => setWip(null)}>
|
||||
<div className="jc-wip-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="jc-wip-ico">
|
||||
<Ico name="info" s={18} />
|
||||
</div>
|
||||
<h3>该区域正在建设中</h3>
|
||||
<p>「{wip}」暂未开放,敬请期待。</p>
|
||||
<button type="button" className="jc-wip-ok" onClick={() => setWip(null)}>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Place>(() => readPlace(surface));
|
||||
const session = useQuery({ queryKey: queryKeys.session, queryFn: () => call<Session>(sessionRoute) });
|
||||
const me$ = useQuery({ queryKey: queryKeys.me, queryFn: () => call<Principal>(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 }) {
|
||||
告警<b>{alerts}</b>
|
||||
</button>
|
||||
)}
|
||||
{/* 三面互跳:另一面按授权亮灯,工作台人人可回 */}
|
||||
{canSeeSurface(session.data, other) && (
|
||||
{/* 三面互跳:另一面与工作台都按 ends 亮灯(ADR-012,权限只到端) */}
|
||||
{canSeeSurface(me$.data, other) && (
|
||||
<a className="g-tbtn" href={SURFACE_META[other].path}>
|
||||
<Ico name={other === 'design' ? 'route' : 'gauge'} s={13} />
|
||||
{SURFACE_META[other].brand}
|
||||
</a>
|
||||
)}
|
||||
<a className="g-tbtn" href="/">
|
||||
<Ico name="list-checks" s={13} />
|
||||
工作台
|
||||
</a>
|
||||
<Btn className="g-tbtn" onClick={() => logout.mutate()} busy={logout.isPending}>
|
||||
{(me$.data?.ends ?? []).includes('workbench') && (
|
||||
<a className="g-tbtn" href="/">
|
||||
<Ico name="list-checks" s={13} />
|
||||
工作台
|
||||
</a>
|
||||
)}
|
||||
<Btn className="g-tbtn" onClick={logout}>
|
||||
<Ico name="log-out" s={13} />
|
||||
登出
|
||||
</Btn>
|
||||
@@ -323,10 +329,14 @@ function Detail({ route, nav }: { route: Route; nav: Nav }) {
|
||||
return <TriggerPage id={route.id} />;
|
||||
case 'act':
|
||||
return <ActPage id={route.id} />;
|
||||
case 'actd':
|
||||
return <ActvPage id={route.id} />;
|
||||
case 'tool':
|
||||
return <ToolPage id={route.id} />;
|
||||
case 'obj':
|
||||
return <ObjPage id={route.id} />;
|
||||
return <ObjPage id={route.id} nav={nav} />;
|
||||
case 'row':
|
||||
return <RowPage id={route.id} />;
|
||||
case 'llm':
|
||||
return <LlmPage id={route.id} />;
|
||||
case 'chan':
|
||||
|
||||
+82
-21
@@ -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 (
|
||||
<div className="jc-login">
|
||||
<div className="jc-login-card">
|
||||
<span className="jc-login-mark">
|
||||
<Ico name="shield-check" s={24} />
|
||||
</span>
|
||||
<h1 className="jc-login-title">{title}</h1>
|
||||
<p className="jc-login-sub">{desc}</p>
|
||||
{/* 角色不够时跳登录页没用(还是同一个人)—— 给的是登出,换账号再来(手册 §4.3) */}
|
||||
<Btn className="jc-login-go" onClick={() => void logout()}>
|
||||
退出登录
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gate() {
|
||||
const client = useQueryClient();
|
||||
const session = useQuery({
|
||||
queryKey: queryKeys.session,
|
||||
queryFn: () => call<Session>(sessionRoute),
|
||||
// 未登录是正常状态,不是故障 —— 不重试,直接落登录页
|
||||
const me = useQuery({
|
||||
queryKey: queryKeys.me,
|
||||
queryFn: () => call<Principal>(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 <LoginPage onLogin={(s) => client.setQueryData(queryKeys.session, s)} />;
|
||||
// 鉴权中心本身没应答(网络 / 配置)—— 摆实话,不落登录页打转
|
||||
if (auth === undefined)
|
||||
return <Denied title="鉴权中心暂时连不上" desc="请检查网络后刷新重试;反复出现请联系管理员。" />;
|
||||
if (!auth.authenticated) return <LoginPage />;
|
||||
if (!auth.authorized)
|
||||
return <Denied title="你没有本系统的访问权限" desc="当前账号缺少本端要求的角色,请退出后换账号登录。" />;
|
||||
|
||||
if (me.isPending) return null;
|
||||
// 401(token 救不回来)落登录页;别的错误如实摆出来,不装没发生
|
||||
if (me.data === undefined) {
|
||||
if (me.error instanceof ApiCallError && me.error.status === 401) return <LoginPage />;
|
||||
return <Denied title="身份确认失败" desc={me.error instanceof Error ? me.error.message : '请刷新重试'} />;
|
||||
}
|
||||
if (me.data.ends.length === 0)
|
||||
return (
|
||||
<Denied
|
||||
title="还没有可进的端"
|
||||
desc={`你的角色(${me.data.roles.join('、') || '无'})还没有被授权进入任何一端,请联系运营中心。`}
|
||||
/>
|
||||
);
|
||||
if (blocked) return null; // 重定向进行中(effect 已发)
|
||||
|
||||
return govSurface !== undefined ? (
|
||||
<GovShell surface={govSurface} />
|
||||
) : meSurface ? (
|
||||
// 个人中心与工作台同皮肤,整页导航进出(2026-08-25)
|
||||
<div className="proto-root">
|
||||
<MePage />
|
||||
</div>
|
||||
) : (
|
||||
// .proto-root 是工作台补充样式的作用域(原型 app.css 通篇以它起头);
|
||||
// 治理台不裹它 —— 裹了工作台的补充样式就会漏进治理台的树里
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+101
-15
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<typeof setTimeout> | undefined;
|
||||
|
||||
source.addEventListener('touch', (event) => {
|
||||
invalidateFor(queryClient, JSON.parse((event as MessageEvent<string>).data) as Touch);
|
||||
});
|
||||
const open = async (): Promise<void> => {
|
||||
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<string>).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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
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<Session>(loginRoute, { body: { account: account.trim(), password } }));
|
||||
} catch (e) {
|
||||
// 后端只回一句话(不区分账号不存在 / 已停用 / 口令错),这里照实呈现,不替它猜
|
||||
setError(e instanceof ApiCallError ? e.message : '登录失败,请稍后再试');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="jc-login">
|
||||
<form
|
||||
className="jc-login-card"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<div className="jc-login-card">
|
||||
<span className="jc-login-mark">
|
||||
<Ico name="bot" s={24} />
|
||||
</span>
|
||||
<h1 className="jc-login-title">JCAI 任务操作系统</h1>
|
||||
<p className="jc-login-sub">登录后进入你的工位</p>
|
||||
|
||||
<label className="jc-login-field">
|
||||
<span>账号</span>
|
||||
<input
|
||||
value={account}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder="如 fengbing"
|
||||
onChange={(e) => setAccount(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="jc-login-field">
|
||||
<span>口令</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
placeholder="口令"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error !== undefined && (
|
||||
<div className="jc-login-err" role="alert">
|
||||
<Ico name="alert-circle" s={15} />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 在办的那一段照旧可提交 —— `submit` 自己有 `busy` 闸,按下去什么也不会多发一遍 */}
|
||||
<Btn className="jc-login-go" type="submit" disabled={!ready} busy={busy}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
{/*
|
||||
表单已随统一鉴权中心退役:点一下跳 Keycloak 登录页,回来即登录态。
|
||||
不做自动跳转 —— 探测失败时能停在这一屏让人看清,而不是来回跳(手册 §7 无限循环坑)。
|
||||
*/}
|
||||
<Btn className="jc-login-go" onClick={() => void login()}>
|
||||
前往统一登录
|
||||
</Btn>
|
||||
<p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p>
|
||||
</form>
|
||||
<p className="jc-login-hint">将跳转到公司统一鉴权中心,登录成功后自动返回</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature: desk-me —— 唯一出口(编码规范 §9.1)。
|
||||
* 个人中心(/me 台面):身份、工作概览、岗位权限、最近任务。
|
||||
*/
|
||||
export { MePage } from './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<Record<string, { label: string; icon: string; href: string }>> = {
|
||||
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<Principal>(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 (
|
||||
<div className="jc-me">
|
||||
<header className="jc-topbar jc-topbar-app">
|
||||
<a className="jc-me-back" href="/">
|
||||
<Ico name="arrow-left" s={14} />
|
||||
返回工作台
|
||||
</a>
|
||||
<div className="jc-topbar-user">
|
||||
<span className="jc-nav-ava">{(staff?.name ?? '—').slice(0, 1)}</span>
|
||||
<span className="jc-topbar-name">{staff?.name ?? '—'}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="jc-me-body">
|
||||
{/* 身份卡:我是谁 —— 头像 / 姓名 / 岗位 / 账号编号状态,登出入口也在这 */}
|
||||
<section className="jc-me-card jc-me-hero">
|
||||
<span className="jc-me-ava">{(staff?.name ?? '—').slice(0, 1)}</span>
|
||||
<div className="jc-me-id">
|
||||
<h1>{staff?.name ?? '—'}</h1>
|
||||
{position !== undefined && (
|
||||
<p className="jc-me-pos">
|
||||
{position.name} · {position.code}
|
||||
</p>
|
||||
)}
|
||||
{staff !== undefined && (
|
||||
<p className="jc-me-meta">
|
||||
账号 {staff.account} · 编号 {staff.id} ·{' '}
|
||||
<em className={staff.state === 'active' ? 'on' : 'off'}>{staff.state === 'active' ? '在职' : '停用'}</em>
|
||||
</p>
|
||||
)}
|
||||
{position?.note !== undefined && <p className="jc-me-note">{position.note}</p>}
|
||||
</div>
|
||||
<button type="button" className="jc-me-logout" onClick={logout}>
|
||||
<Ico name="log-out" s={14} />
|
||||
退出登录
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 工作概览:手上事的四个数 —— 点了回工作台 */}
|
||||
<div className="jc-kpis jc-me-kpis">
|
||||
{KPIS.map((k) => (
|
||||
<a key={k.label} className="jc-kpi" href="/" title="回工作台查看">
|
||||
<span className={'jc-kpi-dim ' + k.tone}>
|
||||
<Ico name={k.icon} s={10} />
|
||||
{k.dim}
|
||||
</span>
|
||||
<em>{k.label}</em>
|
||||
<b className="jc-mono">{k.value}</b>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 我的准入(ADR-012):统一鉴权中心后不再有功能级 grants —— 权限只到端。
|
||||
这里摆角色与可进的端,端入口可直接点过去 */}
|
||||
<section className="jc-me-card jc-me-sec">
|
||||
<header className="jc-me-sec-h">
|
||||
<h2>我的准入</h2>
|
||||
{position !== undefined && <span className="jc-me-sec-sub">岗位 {position.code}</span>}
|
||||
</header>
|
||||
{me$.data === undefined ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="shield-check" s={18} />
|
||||
载入中…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="jc-me-grant">
|
||||
<em>
|
||||
<Ico name="user" s={12} />
|
||||
角色
|
||||
</em>
|
||||
<div>
|
||||
{roles.length === 0 ? (
|
||||
<span className="jc-me-grant-item">无</span>
|
||||
) : (
|
||||
roles.map((r) => (
|
||||
<span key={r} className="jc-me-grant-item">
|
||||
{r}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="jc-me-grant">
|
||||
<em>
|
||||
<Ico name="shield-check" s={12} />
|
||||
可进的端
|
||||
</em>
|
||||
<div>
|
||||
{ends.length === 0 ? (
|
||||
<span className="jc-me-grant-item">还没有被授权进入任何一端,请联系运营中心</span>
|
||||
) : (
|
||||
ends.map((e) => {
|
||||
const meta = END_LABEL[e];
|
||||
if (meta === undefined) return null;
|
||||
return (
|
||||
<a key={e} className="jc-me-grant-item jc-me-end" href={meta.href}>
|
||||
<Ico name={meta.icon} s={12} />
|
||||
{meta.label}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 最近任务:前 5 条,标题 + 状态章 */}
|
||||
<section className="jc-me-card jc-me-sec">
|
||||
<header className="jc-me-sec-h">
|
||||
<h2>最近任务</h2>
|
||||
<span className="jc-me-sec-sub">共 {list.length} 项</span>
|
||||
</header>
|
||||
{tasks.data === undefined ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
载入中…
|
||||
</div>
|
||||
) : recent.length === 0 ? (
|
||||
<EmptyState ico="list-checks" tone="brand" title="还没有任务" desc="流程走到你负责的活动时,任务会送到工作台。" />
|
||||
) : (
|
||||
recent.map((t) => (
|
||||
<div key={t.id} className="jc-me-task">
|
||||
<span className="jc-me-task-t">{t.title}</span>
|
||||
<span className="jc-me-task-a">{t.activity}</span>
|
||||
<i className={'jc-me-task-st' + (t.terminal === undefined ? ' run' : ' done')}>
|
||||
{t.terminal === undefined ? '进行中' : TERMINAL_STATE_LABEL[t.terminal]}
|
||||
</i>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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):`<a download>` 带不了 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<Record<FileDoc['kind'], { label: string; icon: string }>> = {
|
||||
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 && (
|
||||
<a className="fileview-dl" href={href} download={doc.name}>
|
||||
<a
|
||||
className="fileview-dl"
|
||||
href={href}
|
||||
download={doc.name}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
downloadWithTicket(href);
|
||||
}}
|
||||
>
|
||||
<Ico name="download" s={13} /> 下载原件
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -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<Status>('active');
|
||||
export function TaskList({ tasks, selectedId, collapsed, onPick, onCollapse, onFilterChange }: TaskListProps) {
|
||||
const [filter, setFilter] = useState<TaskFilter>('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
|
||||
|
||||
<div className="jc-flt-wrap" aria-label="任务清单筛选">
|
||||
<div className="jc-flt" role="tablist" aria-label="按状态">
|
||||
<button className={filter === 'active' ? 'active' : ''} onClick={() => setFilter('active')}>
|
||||
<button
|
||||
className={filter === 'active' ? 'active' : ''}
|
||||
onClick={() => {
|
||||
setFilter('active');
|
||||
onFilterChange('active');
|
||||
}}
|
||||
>
|
||||
<span>进行中</span>
|
||||
<strong>{count('active')}</strong>
|
||||
</button>
|
||||
<button className={filter === 'done' ? 'active' : ''} onClick={() => setFilter('done')}>
|
||||
<button
|
||||
className={filter === 'done' ? 'active' : ''}
|
||||
onClick={() => {
|
||||
setFilter('done');
|
||||
onFilterChange('done');
|
||||
}}
|
||||
>
|
||||
<span>已完成</span>
|
||||
<strong>{count('done')}</strong>
|
||||
</button>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string> = 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 (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<div className="g-data">
|
||||
<Kpis>
|
||||
<Kpi ico="layers" label="声明活动" value={withTables.length} unit="个"
|
||||
hint="库里有声明台账表的在装活动数 —— 一张表都没声明的活动不占这个数。" />
|
||||
<Kpi ico="database" label="声明表" value={tableCount} unit="张"
|
||||
hint="所有在装活动声明的台账表合计。库里存在、但没有活动认领的表(无人声明)不计入。" />
|
||||
<Kpi ico="hash" label="正式行" value={formalRows} unit="行"
|
||||
hint="各声明表里已过放行门、真正生效的行数合计。声明了但一次没写过(未物化)的表按 0 计。" />
|
||||
<Kpi ico="activity" label="在办行" value={draftRows} unit="行" {...(draftRows > 0 ? { tone: 'brand' } : {})}
|
||||
hint="还躺在各单草稿层、尚未放行的行数合计 —— 单子放行后,这些行才并入正式行。" />
|
||||
</Kpis>
|
||||
{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 (
|
||||
<section key={a.activityId} className="g-actdata">
|
||||
<div className="g-actdata-h">
|
||||
<Ico name="list-checks" s={15} />
|
||||
<span className="g-actdata-i">
|
||||
<Ico name="list-checks" s={14} />
|
||||
</span>
|
||||
<b>{a.name}</b>
|
||||
<span className="g-mono">
|
||||
{a.activityId}
|
||||
@@ -119,7 +151,10 @@ export function DataHome({ nav }: HomeProps) {
|
||||
<b>{t.label ?? t.object}</b>
|
||||
{badge !== undefined && <i className={'g-tbl-k t-' + badge.tone}>{badge.label}</i>}
|
||||
</span>
|
||||
<span className="g-tbl-a g-mono">
|
||||
<span
|
||||
className="g-tbl-a g-mono"
|
||||
title={`${t.domain}.${t.object}${t.primaryKey.length > 0 ? ` · 主键 ${t.primaryKey.join(' + ')}` : ''}`}
|
||||
>
|
||||
{t.domain}.{t.object}
|
||||
{t.primaryKey.length > 0 && <em> · 主键 {t.primaryKey.join(' + ')}</em>}
|
||||
</span>
|
||||
@@ -134,6 +169,9 @@ export function DataHome({ nav }: HomeProps) {
|
||||
)}
|
||||
{t.drafts > 0 && <i className="draft">在办 {t.drafts} 行</i>}
|
||||
</span>
|
||||
<span className="g-tbl-x">
|
||||
<Ico name="chevron-right" s={14} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -157,39 +195,12 @@ export function DataHome({ nav }: HomeProps) {
|
||||
);
|
||||
})}
|
||||
|
||||
{unclaimed.length > 0 && (
|
||||
<section className="g-actdata t-u">
|
||||
<div className="g-actdata-h">
|
||||
<Ico name="help-circle" s={15} />
|
||||
<b>无人声明</b>
|
||||
<span className="n">{unclaimed.length} 张表</span>
|
||||
<p>库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。</p>
|
||||
</div>
|
||||
<div className="g-actdata-tables">
|
||||
{unclaimed.map((o) => (
|
||||
<button
|
||||
key={`${o.domain}.${o.object}`}
|
||||
type="button"
|
||||
className="g-tbl"
|
||||
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
|
||||
>
|
||||
<span className="g-tbl-t">
|
||||
<b>{o.label ?? o.object}</b>
|
||||
</span>
|
||||
<span className="g-tbl-a g-mono">
|
||||
{o.domain}.{o.object}
|
||||
</span>
|
||||
<span className="g-tbl-n">{o.rows === undefined ? <em className="none">读不到</em> : <><b>{o.rows}</b> 行</>}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{/* 「无人声明」的壳表不在此呈现(2026-08-24 用户定:隐藏) —— 数据仍在 unclaimed 里,要看时把这段渲染加回来 */}
|
||||
|
||||
{withTables.length === 0 && unclaimed.length === 0 && (
|
||||
{withTables.length === 0 && (
|
||||
<Clear>还没有活动声明台账 —— 装一个活动包,它声明的表就出现在这里;第一次写入时表才真建出来。</Clear>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
@@ -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 <Note>对象未物化。</Note>;
|
||||
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 (
|
||||
<>
|
||||
<div className="g-obj">
|
||||
<PageHead
|
||||
ico="table"
|
||||
kicker={`${domainShort(domain ?? '')}域 · ${domain}.${object}`}
|
||||
@@ -272,20 +287,14 @@ export function ObjPage({ id }: { id: string }) {
|
||||
}
|
||||
desc="声明物化:最大化合并、只增不破坏——不删列、不改窄、不换主键。此处只读正式版,跨任务只见过门的事实。"
|
||||
/>
|
||||
<Card ico="columns" title="模式">
|
||||
<div className="g-rows">
|
||||
{columns.map((c) => (
|
||||
<div key={c.name}>
|
||||
{/* 人读名在前、字段键在后:填表的人看名字,写映射的人看键 */}
|
||||
<em>{c.label ?? c.name}</em>
|
||||
<b className="mono">
|
||||
{c.name} · {c.type}
|
||||
</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Sec ico="table" title="记录浏览" extra={`前 ${(page.data?.rows ?? []).length} 行`}>
|
||||
<Sec
|
||||
ico="table"
|
||||
title="记录浏览"
|
||||
/* 接口只回前 50 行:截断时写明「前 N / 共 M」,全量就在这页时不虚报「前」(2026-08-24 用户定) */
|
||||
extra={(page.data?.total ?? 0) > (page.data?.rows ?? []).length
|
||||
? `前 ${(page.data?.rows ?? []).length} 行 · 共 ${page.data?.total ?? 0} 行`
|
||||
: `共 ${(page.data?.rows ?? []).length} 行`}
|
||||
>
|
||||
{(page.data?.rows ?? []).length === 0 ? (
|
||||
<Clear>这张表还没有记录 —— 任务放行后,过门的事实才落进来。</Clear>
|
||||
) : (
|
||||
@@ -293,25 +302,40 @@ export function ObjPage({ id }: { id: string }) {
|
||||
<table className="g-dbtable">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.name} title={`${c.name} · ${c.type}`}>
|
||||
{showCols.map((c) => (
|
||||
<th key={c.name} className={isNumCol(c.type) ? 'num' : undefined} title={`${c.name} · ${c.type}`}>
|
||||
{/* 主键列名前挂一枚小钥匙 —— 全表只有它能当「这一行是谁」 */}
|
||||
{pkSet.has(c.name) && <Ico name="key" s={10} />}
|
||||
{c.label ?? c.name}
|
||||
</th>
|
||||
))}
|
||||
{/* 操作列钉在最右(2026-08-25 用户定):整行数据的入口,三级页看全量 */}
|
||||
<th className="op">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(page.data?.rows ?? []).map((row, i) => (
|
||||
<tr key={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 (
|
||||
<td key={c.name} className="g-mono" title={long ? full : undefined}>
|
||||
<td
|
||||
key={c.name}
|
||||
className={(isNumCol(c.type) ? 'g-mono num' : 'g-mono') + (empty ? ' empty' : '')}
|
||||
title={long ? full : undefined}
|
||||
>
|
||||
{long ? `${full.slice(0, 120)}…` : full}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="op">
|
||||
<button type="button" className="g-rowbtn" onClick={() => nav.push({ k: 'row', id: `${id}.${i}` })}>
|
||||
查看详情
|
||||
<Ico name="chevron-right" s={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -319,6 +343,125 @@ export function ObjPage({ id }: { id: string }) {
|
||||
</div>
|
||||
)}
|
||||
</Sec>
|
||||
</>
|
||||
<Sec ico="columns" title="模式">
|
||||
{/* 转置矩阵(2026-08-24 用户定):行 = 参数名/字段/数据类型,列 = 每个字段;
|
||||
一列就是一列的完整定义,横着扫就是整表的结构 */}
|
||||
<div className="g-sx">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
{columns.map((c) => (
|
||||
<td key={c.name} className="nm">
|
||||
{c.label ?? c.name}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<th>字段</th>
|
||||
{columns.map((c) => (
|
||||
<td key={c.name} className="g-mono key">
|
||||
{c.name}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<th>数据类型</th>
|
||||
{columns.map((c) => (
|
||||
<td key={c.name}>
|
||||
<i className={'ty' + tyTone(c.type)}>{c.type}</i>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Sec>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 记录详情(三级页) ==================== */
|
||||
|
||||
/**
|
||||
* 一行记录的全量视图(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 <Note>对象未物化。</Note>;
|
||||
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 <Note tone="warn">这条记录不在当前页里 —— 数据可能已变动,返回「记录浏览」重新进入。</Note>;
|
||||
}
|
||||
/** 标题优先摆主键值 —— 那是「这一行是谁」;没有主键就摆行号 */
|
||||
const pkText =
|
||||
row === undefined || pkSet.size === 0
|
||||
? ''
|
||||
: [...pkSet]
|
||||
.map((k) => cellText(row[k]))
|
||||
.filter((s) => s !== '-')
|
||||
.join(' · ');
|
||||
return (
|
||||
<div className="g-obj g-rowpage">
|
||||
<PageHead
|
||||
ico="table"
|
||||
kicker={`${domainShort(domain ?? '')}域 · ${domain}.${object} · ${page.data?.label ?? object ?? ''}`}
|
||||
title={pkText !== '' ? pkText : `第 ${rowIndex + 1} 行记录`}
|
||||
chips={
|
||||
<>
|
||||
<Chip ico="hash">第 {rowIndex + 1} 行</Chip>
|
||||
<Chip ico="columns">{columns.length} 列</Chip>
|
||||
<Chip ico="key">主键 {(page.data?.primaryKey ?? []).join(', ') || '—'}</Chip>
|
||||
</>
|
||||
}
|
||||
desc="这条记录的全部字段 —— 值完整呈现、不加截断;结构口径以「模式」为准,此处只读正式版。"
|
||||
/>
|
||||
<Sec ico="list" title="字段明细" extra={`共 ${columns.length} 列`}>
|
||||
<div className="g-dbwrap">
|
||||
<table className="g-dbtable g-fdt">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>字段</th>
|
||||
<th>数据类型</th>
|
||||
<th>值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((c) => {
|
||||
const empty = row === undefined || row[c.name] === null || row[c.name] === undefined;
|
||||
return (
|
||||
<tr key={c.name}>
|
||||
<td className="nm">
|
||||
{pkSet.has(c.name) && <Ico name="key" s={10} />}
|
||||
{c.label ?? c.name}
|
||||
</td>
|
||||
<td className="g-mono key">{c.name}</td>
|
||||
<td>
|
||||
<i className={'ty' + tyTone(c.type)}>{c.type}</i>
|
||||
</td>
|
||||
<td className={'g-mono val' + (empty ? ' empty' : '')}>{row === undefined ? '' : cellText(row[c.name])}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Sec>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Note>
|
||||
<b>审批点</b>是流程层的通用人裁节点,与活动、网关、触发器、流程包并列为编排层五要素——它是
|
||||
<b>系统内置要素</b>,声明即用、零开发、不进活动库。它卡到审批人工位产出<b>审批单</b>:通过 /
|
||||
驳回,附依据;没有预算、没有 Harness、没有任务日志,所以不是任务。
|
||||
</Note>
|
||||
<NList>
|
||||
{rows.map((a) => (
|
||||
<NRow
|
||||
key={a.id}
|
||||
tone={APR_ST[a.state].tone}
|
||||
ico="shield-check"
|
||||
title={a.title}
|
||||
sub={`${a.workflowSubject} · ${a.id}`}
|
||||
cols={[
|
||||
{ k: '审批人', v: a.approverName },
|
||||
]}
|
||||
right={
|
||||
<St tone={APR_ST[a.state].tone} ico={APR_ST[a.state].ico}>
|
||||
{APR_ST[a.state].label}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'wf', id: a.workflowId })}
|
||||
/>
|
||||
))}
|
||||
{rows.length === 0 && <Clear>暂无审批单——流上还没有走到审批点。</Clear>}
|
||||
</NList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab === 'list') {
|
||||
return (
|
||||
<>
|
||||
<NList>
|
||||
{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 (
|
||||
<NRow
|
||||
key={t.id}
|
||||
tone="brand"
|
||||
ico="repeat"
|
||||
title={t.title}
|
||||
sub={`${t.id.slice(0, 8)} · 活动 ${t.activityId}`}
|
||||
cols={[
|
||||
{ k: '负责人', v: t.ownerName },
|
||||
{
|
||||
k: '步数占用',
|
||||
v: (
|
||||
<span className="g-mono">
|
||||
{t.usedSteps}/{t.capSteps}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ k: '起始', v: <span className="g-mono">{atMinute(t.startedAt)}</span> },
|
||||
]}
|
||||
right={
|
||||
<St tone={st.tone} ico={st.ico}>
|
||||
{label}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'task', id: t.id })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tasks.length === 0 && <Clear>暂无任务——起单后这里会出现。</Clear>}
|
||||
</NList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<QueryGate query={q}>
|
||||
<div className="g-todo">
|
||||
<div className="g-kpis">
|
||||
<Kpi
|
||||
ico="activity"
|
||||
tone="brand"
|
||||
label="在途工作流"
|
||||
value={workflows.filter((w) => w.state === 'running' || w.state === 'waiting').length}
|
||||
sub="运行中 + 等待中"
|
||||
/>
|
||||
<Kpi ico="alert-triangle" tone="danger" label="异常工作流" value={abnormal.length} sub="失败走向未消化,待人工处置" />
|
||||
<Kpi ico="alert-circle" tone="warn" label="任务需处置" value={stuck.length} sub="受阻待人 + 预算耗尽 + 停滞熔断" />
|
||||
<Kpi
|
||||
ico="circle-check"
|
||||
tone="ok"
|
||||
label="放行完成"
|
||||
value={tasks.filter((t) => t.terminal === 'released').length}
|
||||
sub="唯一成功终态"
|
||||
/>
|
||||
<div className="g-actv">
|
||||
<div className="g-shelf">
|
||||
{items.map((a) => (
|
||||
<div key={a.id} className={'g-fcard' + (a.hue === 'blue' ? '' : ` h-${a.hue}`) + (a.status === 'disabled' ? ' off' : '')}>
|
||||
{/* 整卡热区 → L2 活动详情(2026-08-25 接) */}
|
||||
<button className="hit" aria-label={`打开 ${a.name}`} onClick={() => nav.push({ k: 'actd', id: a.id })} />
|
||||
<div className="hd">
|
||||
<span className="ico">
|
||||
<Ico name={a.ico} s={17} />
|
||||
</span>
|
||||
<div className="ttl">
|
||||
{/* 活动名 = 卡上最重要的数据,全部显示不截断(2026-08-25 用户定) */}
|
||||
<b>{a.name}</b>
|
||||
<em>
|
||||
{a.id} · {a.biz}
|
||||
</em>
|
||||
</div>
|
||||
<span className={'pill' + (a.status === 'active' ? ' on' : '')}>
|
||||
{a.status === 'active' ? '激活中' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
{/* 输入/输出契约不上卡(2026-08-25 用户定):它是 L2 详情的内容,卡上只留识别信息 */}
|
||||
<div className="plate">
|
||||
<span>
|
||||
<b>{a.instances}</b>
|
||||
<i>实例</i>
|
||||
</span>
|
||||
<span>
|
||||
<b>{a.owner}</b>
|
||||
<i>负责人</i>
|
||||
</span>
|
||||
<span>
|
||||
<b>{a.createdAt}</b>
|
||||
<i>创建于</i>
|
||||
</span>
|
||||
{/* 版本进底板第四格(2026-08-25 用户定):与实例/负责人/创建并列 */}
|
||||
<span>
|
||||
<b>{a.version === undefined ? '—' : `v${a.version}`}</b>
|
||||
<i>版本</i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/*
|
||||
幽灵卡 = 创建入口 —— 2026-08-25 用户定:设计功能暂缓,先做运营部分,
|
||||
创建活动的幽灵卡先注释掉;恢复时把这段 JSX 解开即可(样式 .g-fcard.ghost 保留着)
|
||||
<div className="g-fcard ghost" role="button" aria-disabled="true" title="创建活动 —— 功能即将开放">
|
||||
<Ico name="plus" s={18} />
|
||||
创建活动
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
{abnormal.map((w) => (
|
||||
<NRow
|
||||
key={w.id}
|
||||
tone="danger"
|
||||
ico="alert-triangle"
|
||||
title={`${w.subject} · 异常`}
|
||||
sub="节点以非完成终态结束且失败走向未消化,按声明为「终止流」,需人工处置。"
|
||||
onClick={() => nav.push({ k: 'wf', id: w.id })}
|
||||
/>
|
||||
))}
|
||||
{stuck.map((t) => (
|
||||
<NRow
|
||||
key={t.id}
|
||||
tone={t.terminal === 'exhausted' ? 'warn' : 'danger'}
|
||||
ico={TERMINAL_ST[t.terminal!].ico}
|
||||
title={`${t.title} · ${TERMINAL_STATE_LABEL[t.terminal!]}`}
|
||||
sub={`${t.id.slice(0, 8)} · 处置 = 改派 / 重开新实例 / 终止`}
|
||||
onClick={() => nav.push({ k: 'task', id: t.id })}
|
||||
/>
|
||||
))}
|
||||
{abnormal.length + stuck.length === 0 && <Clear>无待处置——在途实例全部正常推进。</Clear>}
|
||||
</div>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 活动详情(L2,2026-08-25 重构模版) ==================== */
|
||||
|
||||
/** 实例状态五态(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰 */
|
||||
type InstanceState = '进行中' | '待执行' | '已完成' | '驳回' | '失败';
|
||||
const INSTANCE_ST: Readonly<Record<InstanceState, string>> = {
|
||||
进行中: '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<Record<InstanceFilterKey, string>> = {
|
||||
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<readonly DemoInstance[] | undefined>(undefined);
|
||||
const [page, setPage] = useState(0);
|
||||
/* 检索条件(2026-08-26 用户定):条件下拉 + 随条件换控件;值一变回第一页 */
|
||||
const [fKey, setFKey] = useState<InstanceFilterKey>('id');
|
||||
const [fText, setFText] = useState('');
|
||||
const [fOwner, setFOwner] = useState('');
|
||||
const [fState, setFState] = useState<InstanceState | ''>('');
|
||||
const [fFrom, setFFrom] = useState('');
|
||||
const [fTo, setFTo] = useState('');
|
||||
if (a === undefined) return <Note tone="warn">活动不存在 —— 它可能已被调整,返回活动清单看看。</Note>;
|
||||
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 (
|
||||
<div className={'g-actvd' + (a.hue === 'blue' ? '' : ` h-${a.hue}`)}>
|
||||
<PageHead
|
||||
ico={a.ico}
|
||||
kicker={`${a.biz} · ${a.id}`}
|
||||
title={a.name}
|
||||
chips={
|
||||
<>
|
||||
<Chip tone={st === 'active' ? 'ok' : 'mute'}>{st === 'active' ? '激活中' : '已禁用'}</Chip>
|
||||
{a.version !== undefined && <Chip ico="tag">v{a.version}</Chip>}
|
||||
{/* 绑定的 LLM 上头版(2026-08-25 用户定):每个活动一个专属 agent */}
|
||||
<Chip ico="cpu">{a.llm}</Chip>
|
||||
<Chip ico="user">{a.owner}</Chip>
|
||||
<Chip ico="calendar-days">创建于 {a.createdAt}</Chip>
|
||||
</>
|
||||
}
|
||||
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 && (
|
||||
<Note tone="warn">
|
||||
无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。
|
||||
</Note>
|
||||
)}
|
||||
|
||||
<Sec ico="repeat" title="历史活动" extra={`共 ${instances.length} 个 · 点行进 L3(即将开放)`}>
|
||||
{/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)——
|
||||
左:总数 + 五态灰字合计(状态色只进 6px 小圆点);右:条件下拉 + 随条件换控件 */}
|
||||
<div className="g-ihead">
|
||||
<div className="g-statline">
|
||||
<span>
|
||||
总数 <b>{instances.length}</b>
|
||||
</span>
|
||||
{(Object.keys(INSTANCE_ST) as InstanceState[]).map((s) => (
|
||||
<span key={s}>
|
||||
<i className={`dot t-${INSTANCE_ST[s]}`} />
|
||||
{s} <b>{countOf(s)}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="g-ifilter">
|
||||
<Ico name="search" s={13} />
|
||||
<select
|
||||
className="g-isel"
|
||||
value={fKey}
|
||||
aria-label="检索条件"
|
||||
onChange={(e) => {
|
||||
setFKey(e.target.value as InstanceFilterKey);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{(Object.keys(INSTANCE_FILTER_LABEL) as InstanceFilterKey[]).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{INSTANCE_FILTER_LABEL[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fKey === 'id' && (
|
||||
<input
|
||||
className="g-iselect-input"
|
||||
value={fText}
|
||||
placeholder="按实例号模糊搜,如 INS-2417"
|
||||
onChange={(e) => {
|
||||
setFText(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{fKey === 'owner' && (
|
||||
<select
|
||||
className="g-isel"
|
||||
value={fOwner}
|
||||
aria-label="选择负责人"
|
||||
onChange={(e) => {
|
||||
setFOwner(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<option value="">全部负责人</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{fKey === 'state' && (
|
||||
<select
|
||||
className="g-isel"
|
||||
value={fState}
|
||||
aria-label="选择状态"
|
||||
onChange={(e) => {
|
||||
setFState(e.target.value as InstanceState | '');
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{(Object.keys(INSTANCE_ST) as InstanceState[]).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{fKey === 'time' && (
|
||||
<span className="g-ifilter-range">
|
||||
<input
|
||||
type="date"
|
||||
value={fFrom}
|
||||
aria-label="起始日期"
|
||||
onChange={(e) => {
|
||||
setFFrom(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
<i>–</i>
|
||||
<input
|
||||
type="date"
|
||||
value={fTo}
|
||||
aria-label="截止日期"
|
||||
onChange={(e) => {
|
||||
setFTo(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{filtering && (
|
||||
<>
|
||||
<span className="g-ifilter-n">
|
||||
筛出 <b>{filtered.length}</b> / 共 {instances.length} 个
|
||||
</span>
|
||||
<button type="button" className="g-rowbtn" onClick={clearFilter}>
|
||||
清空
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 列表式清单(2026-08-26 用户定:卡片撤了改列表)——
|
||||
行 = 一次实例,列齐好扫;状态色只进 6px 圆点(色盲可读:状态词在点旁边);
|
||||
行内操作常驻(查看详情是主入口,不藏) */}
|
||||
<div className="g-itab">
|
||||
{/* 列序(2026-08-26 用户定):实例 / 负责人 / 输入 / 输出 / 启动时间 / 状态 / 操作(操作栏也有标题) */}
|
||||
<div className="g-itab-h">
|
||||
<span>实例</span>
|
||||
<span>负责人</span>
|
||||
<span>输入</span>
|
||||
<span>输出</span>
|
||||
<span>启动时间</span>
|
||||
<span>状态</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{pageRows.map((r) => (
|
||||
<div key={r.id} className={`g-irow t-${INSTANCE_ST[r.state]}`}>
|
||||
<span className="id g-mono">{r.id}</span>
|
||||
<span className="ow">{r.owner}</span>
|
||||
<span className="in" title={r.input}>
|
||||
{r.input}
|
||||
</span>
|
||||
<span className="out g-mono" title={r.output}>
|
||||
{r.output}
|
||||
</span>
|
||||
<span className="at g-mono">{r.startedAt}</span>
|
||||
{/* key 带状态:状态翻转(终止)时弹出一次高亮 —— 状态变化要看得见 */}
|
||||
<span key={r.state} className="st">
|
||||
<i className="dot" />
|
||||
{r.state}
|
||||
</span>
|
||||
<span className="a">
|
||||
{r.state === '进行中' && (
|
||||
<button type="button" className="g-rowbtn danger" onClick={() => stopInstance(r.id)}>
|
||||
终止活动
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="g-rowbtn" title="L3 实例详情即将开放">
|
||||
查看详情
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 检索无结果:如实摆一句话,不留一张只剩表头的空表 */}
|
||||
{pageRows.length === 0 && (
|
||||
<div className="g-irow-empty">
|
||||
<Ico name="search" s={14} />
|
||||
没有匹配的实例 —— 换个条件,或清空重看全部。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 分页(2026-08-25 用户定:单页最多 8 条)—— 页少不上页码器,翻页两枚就够;计数跟着筛出的集合走 */}
|
||||
<div className="g-pager">
|
||||
<span className="g-pager-n">
|
||||
第 {cur + 1} / {pageCount} 页 · {filtering ? `筛出 ${filtered.length} / 共 ${instances.length} 个` : `共 ${filtered.length} 个`}
|
||||
</span>
|
||||
<button type="button" className="g-rowbtn" disabled={cur === 0} onClick={() => setPage(cur - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<button type="button" className="g-rowbtn" disabled={cur >= pageCount - 1} onClick={() => setPage(cur + 1)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</Sec>
|
||||
|
||||
{/*
|
||||
* 沙箱(2026-08-25 用户定):加载服务器返回的活动视图 HTML。
|
||||
* 隔离:iframe + sandbox="allow-scripts" —— 不透明源,禁同源/表单/弹窗,视图只能渲染与跑自己的脚本;
|
||||
* v1 静态占位页(/sandbox-demo/activity-view.html),src 以后来自接口(活动定义里的视图地址);
|
||||
* 待议:内容高度 postMessage 自适应(现在固定高 480)、加载失败态(接口 404/超时)。
|
||||
*/}
|
||||
<Sec ico="dashboard" title="活动视图 · 沙箱" extra="服务器返回的 HTML · 隔离渲染">
|
||||
<div className="g-sandbox">
|
||||
{/* 仿浏览器 chrome 条:让人一眼认出「这是嵌进来的外部视图」—— 全页唯一一处隐喻,不多用 */}
|
||||
<div className="g-sandbox-bar">
|
||||
<span className="dots">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span className="addr">
|
||||
<Ico name="lock" s={10} />
|
||||
{a.id} · 活动视图(沙箱隔离)
|
||||
</span>
|
||||
</div>
|
||||
{!frameLoaded && (
|
||||
<div className="g-sandbox-load">
|
||||
<Ico name="clock" s={14} />
|
||||
视图载入中…
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
title={`${a.name} · 活动视图`}
|
||||
className="g-sandbox-frame"
|
||||
src="/sandbox-demo/activity-view.html"
|
||||
sandbox="allow-scripts"
|
||||
onLoad={() => setFrameLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</Sec>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* fetch 发出前要先过一道 `freshToken()` 微任务(ADR-012 挂 Bearer,auth.ts)——
|
||||
* 断言「发了几笔」之前先让那几拍微任务落完,否则看到的永远是 0 笔。
|
||||
*/
|
||||
const flushToken = () => vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
describe('callKey —— 差一个字就是另一笔', () => {
|
||||
it('方法 + 落地地址 + 报文,三样合起来才是身份', () => {
|
||||
expect(callKey(write, { params: { commId: 'a' } })).toBe('POST /api/desk/comms/a/read ');
|
||||
@@ -52,6 +58,7 @@ describe('在途去重', () => {
|
||||
const g = gateway();
|
||||
const a = call(write, { params: { commId: 'a' } });
|
||||
const b = call(write, { params: { commId: 'a' } });
|
||||
await flushToken();
|
||||
expect(g.calls).toHaveLength(1);
|
||||
g.settleAll();
|
||||
expect(await a).toEqual(await b);
|
||||
@@ -61,6 +68,7 @@ describe('在途去重', () => {
|
||||
const g = gateway();
|
||||
void call(write, { params: { commId: 'a' } });
|
||||
void call(write, { params: { commId: 'b' } });
|
||||
await flushToken();
|
||||
expect(g.calls).toEqual(['POST /api/desk/comms/a/read', 'POST /api/desk/comms/b/read']);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -70,6 +78,7 @@ describe('在途去重', () => {
|
||||
const g = gateway();
|
||||
void call(read);
|
||||
void call(read);
|
||||
await flushToken();
|
||||
expect(g.calls).toHaveLength(1);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -80,6 +89,7 @@ describe('落地后的双击窗 —— 只管写请求', () => {
|
||||
it('**刚成功那一笔在窗内原样重来,交回上一次的结果、不再发**', async () => {
|
||||
const g = gateway();
|
||||
const first = call(write, { params: { commId: 'a' } });
|
||||
await flushToken();
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
@@ -91,11 +101,13 @@ describe('落地后的双击窗 —— 只管写请求', () => {
|
||||
it('过了窗口就是新的一次点击,照发', async () => {
|
||||
const g = gateway();
|
||||
const first = call(write, { params: { commId: 'a' } });
|
||||
await flushToken();
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
vi.advanceTimersByTime(DOUBLE_CLICK_MS + 1);
|
||||
void call(write, { params: { commId: 'a' } });
|
||||
await flushToken();
|
||||
expect(g.calls).toHaveLength(2);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -104,10 +116,12 @@ describe('落地后的双击窗 —— 只管写请求', () => {
|
||||
it('**读请求不设这个窗** —— 写完立刻重取的那一下不能被当成余震压掉', async () => {
|
||||
const g = gateway();
|
||||
const first = call(read);
|
||||
await flushToken();
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
void call(read);
|
||||
await flushToken();
|
||||
expect(g.calls).toHaveLength(2);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 统一鉴权中心(Keycloak)的前端落点(ADR-012;手册 `docs/keycloak-frontend-guide` §4)。
|
||||
*
|
||||
* 后端是**资源服务器**:不做登录、没有会话 cookie,每个请求带
|
||||
* `Authorization: Bearer <access_token>`。token 的取 / 刷 / 登出全部在这里,
|
||||
* 业务代码只见 `freshToken()` 一个口子。
|
||||
*
|
||||
* 硬性约束(手册 §16):
|
||||
* · token 只放内存(keycloak-js 默认),**不进 localStorage** —— 刷新靠 SSO 会话静默恢复;
|
||||
* · 授权 URL 与 PKCE 一律由 keycloak-js 完成,不自己拼;
|
||||
* · 登出必须走 `keycloak.logout()` 结束 SSO 会话,只清本地状态会「登不出去」。
|
||||
*/
|
||||
import Keycloak from 'keycloak-js';
|
||||
|
||||
export const keycloak = new Keycloak({
|
||||
url: import.meta.env.VITE_KEYCLOAK_URL,
|
||||
realm: import.meta.env.VITE_KEYCLOAK_REALM,
|
||||
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
|
||||
});
|
||||
|
||||
const REQUIRED_ROLE = import.meta.env.VITE_REQUIRED_ROLE as string;
|
||||
|
||||
/** init 是否已跑过 —— 测试环境(无 Keycloak)下 freshToken 据此直接空手而归 */
|
||||
let ready = false;
|
||||
|
||||
export type AuthState = { authenticated: boolean; authorized: boolean };
|
||||
|
||||
/**
|
||||
* 初始化:静默探测浏览器是否已有 Keycloak 会话(隐藏 iframe,用户无感)。
|
||||
* 不发跳转 —— 未登录交回 `authenticated: false`,由壳层决定摆登录页。
|
||||
*/
|
||||
export async function initAuth(): Promise<AuthState> {
|
||||
const authenticated = await keycloak.init({
|
||||
onLoad: 'check-sso',
|
||||
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
|
||||
pkceMethod: 'S256',
|
||||
checkLoginIframe: false,
|
||||
});
|
||||
ready = true;
|
||||
|
||||
if (!authenticated) return { authenticated: false, authorized: false };
|
||||
|
||||
// access token 只有 5 分钟:到期前用 refresh token 悄悄续,续不上就重新登录
|
||||
keycloak.onTokenExpired = () => {
|
||||
keycloak.updateToken(30).catch(() => keycloak.login());
|
||||
};
|
||||
|
||||
return { authenticated: true, authorized: keycloak.hasRealmRole(REQUIRED_ROLE) };
|
||||
}
|
||||
|
||||
export function login(): Promise<void> {
|
||||
return keycloak.login();
|
||||
}
|
||||
|
||||
/** 必须用这个登出:只清本地状态的话 Keycloak 的 SSO 会话还在,下次会静默登回来 */
|
||||
export function logout(): Promise<void> {
|
||||
return keycloak.logout({ redirectUri: window.location.origin });
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求前调用,拿回一个保证没过期的 token(剩余有效期不足 30 秒才真刷新)。
|
||||
* 未到阈值时 `updateToken` 不发网络请求,每个请求都调是安全的。
|
||||
* 未登录 / 未初始化(测试)时回 `undefined`,请求就不带头 —— 让后端照实回 401。
|
||||
*/
|
||||
export async function freshToken(): Promise<string | undefined> {
|
||||
if (!ready || !keycloak.authenticated) return undefined;
|
||||
await keycloak.updateToken(30);
|
||||
return keycloak.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 401 之后的强刷(typed-fetch 用):常规 `freshToken` 在阈值内不发请求,
|
||||
* 而后端已经明说这一枚不行 —— 传一个极大的 minValidity 逼它真换一枚。
|
||||
* 刷不出来(SSO 会话也没了)回 false,调用方照旧把 401 抛给页面。
|
||||
*/
|
||||
export async function forceRefreshToken(): Promise<boolean> {
|
||||
if (!ready || !keycloak.authenticated) return false;
|
||||
try {
|
||||
await keycloak.updateToken(Number.MAX_SAFE_INTEGER);
|
||||
return keycloak.token !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,18 @@
|
||||
* 三者都是纯函数,壳层只消费 —— 配置错了在这里就红,不必等到点开界面才发现。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Session } from '@contracts';
|
||||
import { sessionSchema } from '@contracts';
|
||||
import type { Principal } from '@contracts';
|
||||
import { principalSchema } from '@contracts';
|
||||
import { GOV_ZONES } from '../gov-route.ts';
|
||||
import { SURFACE_ZONES, canSeeSurface, legacyGovTarget, surfaceOfZone } from '../surfaces.ts';
|
||||
|
||||
/** 经 schema 构造合法会话 —— §12.5 禁 `as unknown as`,测试同样遵守 */
|
||||
const sessionWith = (fns: readonly string[]): Session =>
|
||||
sessionSchema.parse({
|
||||
/** 经 schema 构造合法 Principal —— §12.5 禁 `as unknown as`,测试同样遵守 */
|
||||
const meWith = (ends: readonly string[]): Principal =>
|
||||
principalSchema.parse({
|
||||
staff: { id: 's-1', account: 'a', name: 'n', positionId: 'p-1', state: 'active' },
|
||||
position: { id: 'p-1', code: 'PM', name: '项目经理' },
|
||||
grants: fns.map((fn) => ({
|
||||
positionId: 'p-1',
|
||||
object: { kind: 'governance', function: fn },
|
||||
action: 'read',
|
||||
})),
|
||||
roles: ['user'],
|
||||
ends,
|
||||
});
|
||||
|
||||
describe('SURFACE_ZONES —— 每个区恰属一面', () => {
|
||||
@@ -41,27 +38,20 @@ describe('SURFACE_ZONES —— 每个区恰属一面', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSeeSurface —— 入口按授权亮灯', () => {
|
||||
it('持有该面任一功能的授权即亮', () => {
|
||||
expect(canSeeSurface(sessionWith(['process']), 'design')).toBe(true);
|
||||
expect(canSeeSurface(sessionWith(['task']), 'ops')).toBe(true);
|
||||
describe('canSeeSurface —— 入口按 ends 亮灯(ADR-012)', () => {
|
||||
it('ends 含该端即亮', () => {
|
||||
expect(canSeeSurface(meWith(['design', 'ops', 'workbench']), 'design')).toBe(true);
|
||||
expect(canSeeSurface(meWith(['ops']), 'ops')).toBe(true);
|
||||
});
|
||||
|
||||
it('只有另一面的授权不亮', () => {
|
||||
expect(canSeeSurface(sessionWith(['task']), 'design')).toBe(false);
|
||||
expect(canSeeSurface(sessionWith(['activity']), 'ops')).toBe(false);
|
||||
it('ends 不含不亮;工作台身份进不了治理端', () => {
|
||||
expect(canSeeSurface(meWith(['workbench']), 'design')).toBe(false);
|
||||
expect(canSeeSurface(meWith(['design']), 'ops')).toBe(false);
|
||||
});
|
||||
|
||||
it('trigger 与 data 两面都算 —— 定义归设计,处置与行数据归运营', () => {
|
||||
expect(canSeeSurface(sessionWith(['trigger']), 'design')).toBe(true);
|
||||
expect(canSeeSurface(sessionWith(['trigger']), 'ops')).toBe(true);
|
||||
expect(canSeeSurface(sessionWith(['data']), 'design')).toBe(true);
|
||||
expect(canSeeSurface(sessionWith(['data']), 'ops')).toBe(true);
|
||||
});
|
||||
|
||||
it('无会话不亮;无治理授权不亮', () => {
|
||||
it('无身份不亮;ends 为空不亮', () => {
|
||||
expect(canSeeSurface(undefined, 'design')).toBe(false);
|
||||
expect(canSeeSurface(sessionWith([]), 'ops')).toBe(false);
|
||||
expect(canSeeSurface(meWith([]), 'ops')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ export type Route =
|
||||
| { k: 'act'; id: string }
|
||||
| { k: 'tool'; id: string }
|
||||
| { k: 'obj'; id: string }
|
||||
/* 单条记录(三级页,2026-08-25 用户定):id = `<domain>.<object>.<行序号>` ——
|
||||
行序号是当前浏览页内的下标;没有单行接口,详情页复用台账浏览的同一份缓存取行 */
|
||||
| { k: 'row'; id: string }
|
||||
/* 活动定义详情(运营中心 · 活动模块 L2,2026-08-25 重构):id = 活动 id;
|
||||
与设计中心的 `act`(活动库)是两回事 —— 那是技术安装面,这是业务运营面 */
|
||||
| { k: 'actd'; id: string }
|
||||
| { k: 'llm'; id: string }
|
||||
| { k: 'chan'; id: string };
|
||||
|
||||
@@ -65,7 +71,7 @@ export const ZONES: ReadonlyArray<{ key: GovZone; label: string; icon: string }>
|
||||
{ key: 'testbed', label: '测试台', icon: 'flask-conical' },
|
||||
{ key: 'cockpit', label: '驾驶舱', icon: 'gauge' },
|
||||
{ key: 'runs', label: '工作流', icon: 'activity' },
|
||||
{ key: 'tasks', label: '任务', icon: 'list-checks' },
|
||||
{ key: 'tasks', label: '活动', icon: 'list-checks' },
|
||||
{ key: 'people', label: '员工', icon: 'users' },
|
||||
{ key: 'ledgers', label: '台账', icon: 'table' },
|
||||
{ key: 'system', label: '系统', icon: 'settings' },
|
||||
@@ -98,7 +104,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
desc: '流程包的运行时:在途工作流的监控,和每个包的运行配置——起单开关、命中后要不要人工确认、谁来确认、每个节点归谁负责。设计中心定义流程长什么样,这里管它今天怎么跑。',
|
||||
},
|
||||
tasks: {
|
||||
title: '任务管理',
|
||||
title: '活动管理',
|
||||
icon: 'list-checks',
|
||||
accent: 'linear-gradient(140deg,#3370ff,#245bdb)',
|
||||
desc: '活动的运行时:任务 = 活动被调起的一次运行,恰有一名负责人。这里做任务与审批单的在途监控、干预与控制——暂停可恢复、叫停归负责人、终止级联作废。工作流一级去「工作流」区看。',
|
||||
@@ -193,10 +199,12 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
|
||||
{ key: 'board', label: '在途工作流', ico: 'activity' },
|
||||
{ key: 'conf', label: '运行配置', ico: 'settings' },
|
||||
],
|
||||
/* 页签二分(2026-08-25 用户定):活动按运营状态分「激活中 / 已禁用」——
|
||||
激活/禁用是运营态,L2 里可改;「已发布/审核中」是设计部分的功能,暂缓,
|
||||
等运营功能做好再回头改 */
|
||||
tasks: [
|
||||
{ key: 'list', label: '任务', ico: 'list-checks' },
|
||||
{ key: 'apr', label: '审批单', ico: 'shield-check' },
|
||||
{ key: 'todo', label: '需处置', ico: 'flag' },
|
||||
{ key: 'active', label: '激活中', ico: 'zap' },
|
||||
{ key: 'disabled', label: '已禁用', ico: 'ban' },
|
||||
],
|
||||
people: [
|
||||
{ key: 'emp', label: '员工', ico: 'users' },
|
||||
@@ -231,6 +239,10 @@ export function routeLabel(r: Route): string {
|
||||
return '工具';
|
||||
case 'obj':
|
||||
return '数据对象';
|
||||
case 'row':
|
||||
return '记录详情';
|
||||
case 'actd':
|
||||
return '活动详情';
|
||||
case 'llm':
|
||||
return 'LLM 实例';
|
||||
case 'chan':
|
||||
|
||||
@@ -72,6 +72,7 @@ export function Kpi({
|
||||
unit,
|
||||
sub,
|
||||
dial,
|
||||
hint,
|
||||
}: {
|
||||
tone?: string;
|
||||
ico: string;
|
||||
@@ -80,6 +81,8 @@ export function Kpi({
|
||||
unit?: string;
|
||||
sub?: string;
|
||||
dial?: number;
|
||||
/** 角标说明:右上角一枚 info,悬停出气泡解释这个数的口径(纯展示,aria 同源) */
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={'g-kpi' + (tone ? ' t-' + tone : '')}>
|
||||
@@ -87,6 +90,11 @@ export function Kpi({
|
||||
<Ico name={ico} s={12} />
|
||||
{label}
|
||||
</span>
|
||||
{hint !== undefined && (
|
||||
<span className="g-kpi-hint" data-tip={hint} role="img" aria-label={hint}>
|
||||
<Ico name="info" s={11} />
|
||||
</span>
|
||||
)}
|
||||
{dial !== undefined ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Dial v={dial} tone={tone ?? 'brand'} />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 产品分三面:设计中心定义企业、运营中心看企业跑、工作台在企业里干活。
|
||||
* 区归属、入口亮灯、旧链接改写都从这里出 —— 壳层只消费,不各写各的。
|
||||
*/
|
||||
import type { GovernanceFunction, Session } from '@contracts';
|
||||
import type { Principal } from '@contracts';
|
||||
import { GOV_ZONES } from './gov-route.ts';
|
||||
import type { GovZone } from './gov-route.ts';
|
||||
|
||||
@@ -31,26 +31,13 @@ export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 亮灯功能集 —— 授权模型不动(仍是九功能 × 读/写/治),这里只说「哪个功能属哪一面」。
|
||||
* 台面入口是否亮灯(ADR-012)。
|
||||
*
|
||||
* `trigger` 与 `data` 两面都算:触发器**定义归设计、启停归运营**;
|
||||
* 数据**结构归设计、行数据归运营**。同一功能的两种动作各归其位,不拆授权。
|
||||
* 授权模型已换成「角色 → 端」:后端不再有功能级授权,`SURFACE_FNS` 随之删除。
|
||||
* 判据就一条 —— `me.ends` 含不含这个端;进去之后哪条接口能调由后端按路由归属自己判。
|
||||
*/
|
||||
export const SURFACE_FNS: Readonly<Record<Surface, readonly GovernanceFunction[]>> = {
|
||||
design: ['process', 'activity', 'tool', 'data', 'trigger', 'system'],
|
||||
ops: ['task', 'staff', 'memory', 'system', 'trigger', 'data'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 台面入口是否亮灯。
|
||||
*
|
||||
* 判据是「持有该面任一功能的**任意**授权」—— 动作蕴含 govern ⊃ write ⊃ read,
|
||||
* 有 grant 即至少可读,入口就该看得见;进去之后动作仍逐功能校验。
|
||||
*/
|
||||
export function canSeeSurface(session: Session | undefined, surface: Surface): boolean {
|
||||
if (session === undefined) return false;
|
||||
const fns = SURFACE_FNS[surface];
|
||||
return session.grants.some((g) => g.object.kind === 'governance' && fns.includes(g.object.function));
|
||||
export function canSeeSurface(me: Principal | undefined, surface: Surface): boolean {
|
||||
return me !== undefined && (me.ends as readonly string[]).includes(surface);
|
||||
}
|
||||
|
||||
export function surfaceOfZone(zone: GovZone): Surface {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 查询键 —— 纯数据,自 `app/query.ts` 析出(2026-08-25):
|
||||
* feature 层(desk-me)也要按同一批键共享缓存,而 feature 不许回引 app 内部(§11.3),
|
||||
* 键放 shared 两面都能取。键名仍以此处为唯一出处,不手写字面量。
|
||||
*/
|
||||
export const queryKeys = {
|
||||
/** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */
|
||||
me: ['me'] 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,
|
||||
};
|
||||
@@ -13,6 +13,7 @@
|
||||
* 否则屏上摆的是改之前的数(界面不撒谎)。
|
||||
*/
|
||||
import type { RouteContract } from '@contracts';
|
||||
import { forceRefreshToken, freshToken } from './auth.ts';
|
||||
|
||||
export class ApiCallError extends Error {
|
||||
constructor(
|
||||
@@ -75,13 +76,30 @@ export function resetCallGuards(): void {
|
||||
justDone.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发一笔请求(ADR-012):统一鉴权中心后**不再靠 cookie**,每笔都挂
|
||||
* `Authorization: Bearer <token>`;token 由 `auth.ts` 管(取 / 刷 / 登出)。
|
||||
*
|
||||
* 401 先强刷一次 token 重试 —— 可能只是手里这枚过期而 SSO 会话还在;
|
||||
* 仍 401 就如实抛出,由壳层(Gate)落登录页。**403 不重试**:那是端不够,
|
||||
* 重登没用,抛给页面按 `detail.need` / `detail.ends` 提示。
|
||||
*/
|
||||
async function send<T>(route: RouteContract, options: CallOptions): Promise<T> {
|
||||
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
|
||||
method: route.method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
||||
});
|
||||
const url = resolvePath(route.path, options.params) + queryString(options.query);
|
||||
const raw = async () => {
|
||||
const token = await freshToken();
|
||||
return fetch(url, {
|
||||
method: route.method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
||||
},
|
||||
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
||||
});
|
||||
};
|
||||
|
||||
let response = await raw();
|
||||
if (response.status === 401 && (await forceRefreshToken())) response = await raw();
|
||||
|
||||
const payload: unknown = await response.json().catch(() => undefined);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -34,7 +34,8 @@ export default defineConfig({
|
||||
*/
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: true },
|
||||
// 默认连测试服务器;本地起 API 时用 API_PROXY_TARGET=http://127.0.0.1:8787 覆盖
|
||||
'/api': { target: process.env.API_PROXY_TARGET ?? 'http://150.158.83.131:46100', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 项目约定
|
||||
|
||||
本目录存放前端设计相关手册与规范。
|
||||
|
||||
## 强制约定
|
||||
|
||||
凡涉及页面新建或前端修改(任何 HTML/CSS/前端代码产出),必须先读取并严格遵守 `DESIGN-SPEC.md`:
|
||||
|
||||
- 改前按「第 15 节」诊断;
|
||||
- 改中遵守「第二部分 设计规范」的默认值与硬规则;
|
||||
- 改完按「第 17 节」三层 18 条清单验收,并按「第 18 节」输出 token 表。
|
||||
|
||||
规范未提及的部分保持现状,不做顺手优化。
|
||||
|
||||
## 参考手册(规范的依据,需要查原句/术语时阅读)
|
||||
|
||||
- `前端UI描述术语手册.html`:UI 描述词汇库,13 个维度 155 个术语,含可直接复制的提示词;
|
||||
- `PC端产品UI审美心智指导手册.html`:审美六条标准与 PC 端细则,含症状诊断与训练题。
|
||||
|
||||
## 若修改了 DESIGN-SPEC.md
|
||||
|
||||
同步更新 `DESIGN-SPEC.html`(阅读版,单文件 HTML)。
|
||||
@@ -0,0 +1,216 @@
|
||||
# 前端设计规范(PC 端产品)
|
||||
|
||||
> 依据《前端UI描述术语手册》与《PC端产品UI审美心智指导手册》提炼。
|
||||
> 本文件是所有页面新建/修改的执行依据:改前按「设计逻辑」诊断,改中按「设计规范」执行,改完按「评审清单」验收。
|
||||
> 规范中未提及的部分,保持现状不变。
|
||||
|
||||
---
|
||||
|
||||
## 第一部分 设计逻辑(怎么想)
|
||||
|
||||
### 1. 三层金字塔(评审顺序永远自下而上)
|
||||
|
||||
1. **不出错**(底层):对齐、对比度、状态齐全、无装饰噪音;
|
||||
2. **有秩序**(中层):层级清楚、节奏合理、分组明确;
|
||||
3. **有气质**(顶层):字体/配色有个性、有一处记忆点。
|
||||
|
||||
底层没过,不谈顶层。AI 产出的典型问题就是底层一堆错、套着「气质」的外壳。
|
||||
|
||||
### 2. 产品界面 ≠ 海报
|
||||
|
||||
海报追求「三秒抓住你」,产品界面追求「八小时不打扰你」。渐变、光晕、毛玻璃、彩色大图标属于营销页手段,**产品界面里的合理数量是零,最多一处**。
|
||||
|
||||
### 3. 六条可检查标准(每条都能回答「是/否」)
|
||||
|
||||
| 标准 | 一句话 | 检查方法 |
|
||||
|---|---|---|
|
||||
| 对齐 | 专业 vs 业余八成取决于此 | 画一条左线:标题、卡片、表格首列、表单标签是否从同一竖线开始 |
|
||||
| 层级 | 一屏一个主角 | 眯眼:深色块是否只剩 2–3 个重点 |
|
||||
| 克制 | 做减法 | 数:1 个彩色、2 种字体、1 套圆角、2 档阴影、1 个图标库、0 装饰 |
|
||||
| 一致 | 像一个人做的 | 同一组件全站是否只有一种长相;不一样的地方必须有含义 |
|
||||
| 节奏 | 组内紧、组间松 | 量间距:是否三级逐级放大,还是均匀/随意 |
|
||||
| 细节 | 专业感的来源 | 数字对齐、状态齐全、输入框三件套 |
|
||||
|
||||
### 4. 五个第一眼测试(改完页面先过这关,每个 10 秒)
|
||||
|
||||
1. **眯眼测试**:模糊后深色块 ≤3 个,且主角是最该突出的东西;
|
||||
2. **三秒测试**:三秒内能说出「这是什么页」「最重要的操作在哪」;
|
||||
3. **安静测试**:饱和色块/渐变/emoji/感叹号/彩色描边加起来不超过两处;
|
||||
4. **同一人测试**:同类元素(按钮圆角/高度/字号/描边)只有一种处理;
|
||||
5. **空间测试**:宽屏下内容不撑满不飘着——限宽居中或多栏。
|
||||
|
||||
### 5. 工作方式五条(描述与修改的纪律)
|
||||
|
||||
1. **先骨架,后皮肤**:结构 → 布局 → 间距 → 组件 → 字体/色彩/形状 → 状态/动效;
|
||||
2. **用名词加数值,不用形容词**:「大一点」→「按钮高从 32 改 36」;
|
||||
3. **每次只改一个维度**:修改指令 = 改哪里 + 改成什么 + 其余不变;每轮改动 ≤3 处并编号;
|
||||
4. **先列问题再改**:诊断先于动手,说出是哪条标准没过;
|
||||
5. **默认值先声明**:字体、圆角系统、间距系统、强调色、图标库——不声明就会被填进默认值(AI 味)。
|
||||
|
||||
---
|
||||
|
||||
## 第二部分 设计规范(默认值与硬规则)
|
||||
|
||||
> 以下默认值适用于 PC 端后台/工具类产品。营销页/落地页的变体见附录 A。
|
||||
|
||||
### 6. 全局默认值(开工前先声明这五件事)
|
||||
|
||||
- **字体**:系统无衬线栈(中文 PingFang SC / Microsoft YaHei,英文 Inter 或 system-ui);数字用 `tabular-nums`;需要技术感处用等宽;
|
||||
- **圆角系统**:按钮/输入框 6px、卡片 8px、弹窗 12px、标签/头像全圆;嵌套内圆角 = 外圆角 − 内边距;整站不出现 16px 以上;
|
||||
- **间距系统**:只用 8 的倍数(4/8/12/16/24/32/48/64);三级边距 = 页面 24 / 卡片内 16 / 单元格 8–12;
|
||||
- **强调色**:一个(默认蓝,如 #2563EB),只出现在:主操作、链接、状态、图表四处;
|
||||
- **图标库**:一个(默认 Lucide),只选一种风格(默认 Outline 1.5px 描边)。
|
||||
|
||||
### 7. 色彩
|
||||
|
||||
- **三级灰文字**:主文字 #161A22 / 次要 #4A5160 / 辅助 #8B93A3;**禁用比 #8B93A3 更浅的文字色**;
|
||||
- **对比度**:正文 ≥4.5:1,大标题 ≥3:1(WCAG AA);
|
||||
- **底色**:主区背景 #F6F7F9 + 白卡片(1px #DFE3EA 描边);
|
||||
- **语义色**:Success/Warning/Error/Info 各配浅底深字,只用在徽标/提示里;
|
||||
- **图表**:同系列单色;多系列用主色深浅三档 + 一灰;**禁止彩虹色**;
|
||||
- **深色模式**(如需要):不是反色。背景 #0B0D10、卡片 #15181D、弹层 #1E2229(越高层越亮)、描边 rgba(255,255,255,.08)、文字 #E5E7EB / 次要 #8A91A0、去阴影、强调色提亮一档降饱和;
|
||||
- **禁止**:紫蓝渐变按钮、渐变背景、光晕、毛玻璃(营销页除外,见附录 A)。
|
||||
|
||||
### 8. 排版
|
||||
|
||||
- **三档字号 + 两档字重**够用:标题 16px/600、正文 14px/400、元信息 12px 灰;指标卡大数字 28px/600 等宽;
|
||||
- **中文正文**:字号 ≥14px、行高 1.75;标题行高 1.2–1.3;
|
||||
- **层级手段按成本排序**:灰度 < 字重 < 字号 < 颜色——先用便宜的,不用五种字号;
|
||||
- **标题左对齐**,与主按钮同行(标题左上、主按钮右上);居中只给空态和弹窗;
|
||||
- 中英文/数字之间加空格;标点全角;**中文不用斜体**;
|
||||
- 行长 ≤75 字符,超出限宽。
|
||||
|
||||
### 9. 布局与骨架
|
||||
|
||||
- **应用外壳**:顶栏 + 侧栏 + 主内容区,外壳不滚动;
|
||||
- **安全配色组合**:深侧栏 #14171E(当前项 8% 白底)+ 浅主区 #F6F7F9 + 白卡 1px 描边;若浅色侧栏则 #F3F4F7 / 主区纯白 + 1px 分割线;
|
||||
- **限宽**:主内容 1280px、整页上限 1440px;
|
||||
- **宽屏用多栏**:详情页主从 2:1(左主体右辅助)、列表+详情、工具类三面板;
|
||||
- **一条左线**:页面标题、卡片标题、表格第一列、表单标签从同一竖线开始;看文字不看卡片边框;
|
||||
- **节奏**:组内紧(8–16)、组间松(32–48);分组标题离上一组的距离 > 离自己内容的距离;均匀间距是错的;
|
||||
- **密度跟用途走**:扫描型(表格/列表/日志)紧凑 32–36 行高;阅读/填写型舒适 44–48;引导型(空态/向导)宽松。
|
||||
|
||||
### 10. 形状与表面
|
||||
|
||||
- **描边优于阴影**:静态卡片 1px #DFE3EA 描边;阴影只给下拉、弹窗、拖拽中的元素;hover 时描边变深,不浮起;
|
||||
- **一张卡只用一种分层手段**:描边/阴影/底色三选一;
|
||||
- **分割线**:能用间距分组就不用线;要用就内缩对齐文字,不通到边缘。
|
||||
|
||||
### 11. 组件硬规则
|
||||
|
||||
- **按钮**:一屏只有一个实心主按钮;危险操作(删除)永不实心;低频操作收进「⋯」;文案用具体动词(「保存更改」不是「确定」);高度三档 28/36/44;
|
||||
- **表格**:文字左对齐、**数字右对齐 + 等宽 + 小数位统一**、货币单位进表头;行高 36px、单元格左右内边距 12px、字号 13px、表头吸顶;
|
||||
- **输入框三件套**:标签(框上方)、占位符(写示例值)、错误提示(描边变红 + 下方写明原因和改法)——三者职责不可互替;
|
||||
- **图标不是装饰**:只有约定俗成含义(搜索、设置、删除)的才保留;遮住图标无损失就删;**禁止「⚡极速 🔒安全 🚀高效」式三列图标卡**;
|
||||
- **空态** = 一句说明 + 主操作按钮,是引导不是道歉;
|
||||
- **错误文案** = 发生了什么 + 怎么办。
|
||||
|
||||
### 12. 状态(效果图和产品的区别就在「第二种状态」)
|
||||
|
||||
- **控件六态**:Default / Hover(加深 8%)/ Active / Focus / Disabled(45% 透明)/ Loading(转圈 + 禁重复点击);
|
||||
- **Focus 必须可见**:2px 主色外环 + 2px 间隔;禁止因为「不好看」删焦点环;
|
||||
- **每个数据区域四态**:Loading(骨架屏,形状与真实内容一致)/ Empty / Error(原因 + 重试)/ Ideal,再加 Partial;
|
||||
- **悬停是 PC 的层级工具**:行内操作平时隐藏,悬停/键盘聚焦时出现(两个图标 + 「⋯」);
|
||||
- **危险操作**:能撤销(Toast + Undo)就别弹确认;必须确认时,严重后果要求输入名称;
|
||||
- **键盘**:搜索框标 ⌘K、主要操作标快捷键、Tab 顺序与视觉顺序一致。
|
||||
|
||||
### 13. 动效(只说三件事:动多久、怎么加减速、动哪个属性)
|
||||
|
||||
- **时长**:hover/按钮 100–150ms;弹层/展开 200–300ms;页面切换 ≤500ms;
|
||||
- **缓动**:进场 ease-out、退场 ease-in;禁止线性位移;
|
||||
- **进出场有来由**:弹层从触发点展开、抽屉从边缘滑入;退出是进入的反向且更快;
|
||||
- **克制**:一个页面最多一处值得记住的动效;尊重 prefers-reduced-motion。
|
||||
|
||||
### 14. 响应式(PC 为主,但要能缩)
|
||||
|
||||
- 三档:>1024 桌面 / 768–1024 平板 / <768 手机;
|
||||
- 降级路径:卡片 3→2→1 列;侧栏→图标栏→抽屉;顶部导航→汉堡;表格→卡片列表或横滚+首列固定;分栏→上下堆叠;
|
||||
- 触控目标 ≥44px。
|
||||
|
||||
---
|
||||
|
||||
## 第三部分 执行流程(每次改页面都走一遍)
|
||||
|
||||
### 15. 改前:诊断
|
||||
|
||||
1. 过五个第一眼测试,写下「不对劲」的地方;
|
||||
2. 按六条标准逐条归因——说「对齐有问题:卡片标题和表格没对齐到一条左线」,不说「看着乱」;
|
||||
3. 查症状对照(下表)确认处方。
|
||||
|
||||
**症状 → 病因 → 处方**
|
||||
|
||||
| 症状 | 病因 | 处方 |
|
||||
|---|---|---|
|
||||
| 看着廉价 | 效果堆叠、灰字太浅 | 卡片改 1px 描边去阴影;最浅灰提到 #8B93A3 |
|
||||
| 像模板/AI 味 | 居中标题、三列图标卡、紫蓝渐变 | 标题左对齐;删无意义图标;去渐变;圆角收 8 |
|
||||
| 看着乱 | 多条左线、间距随意 | 统一一条左线;间距只取 8 的倍数,组内紧组间松 |
|
||||
| 看着空/飘 | 宽屏拉伸 | 限宽 1280 居中,或改主从两栏 |
|
||||
| 看着挤/累 | 密度不匹配、行高小 | 密度按用途调档;正文行高 1.75 |
|
||||
| 找不到重点 | 多个实心按钮、层级反了 | 只留一个实心按钮;用灰度字重重建层级 |
|
||||
| 太花/太吵 | 彩色超一、彩虹图表、emoji | 彩色收敛到四处;图表单色阶+灰;删 emoji |
|
||||
| 太单调/死板 | 减法做过头 | 加一处对比即可(一个强调色块或一处衬线标题) |
|
||||
| 像网页不像软件 | 无外壳/悬停/快捷键 | 补应用外壳、悬停操作、焦点环、⌘K |
|
||||
| 像效果图不像产品 | 只有默认态 | 补齐 Loading/Empty/Error/Partial |
|
||||
| 不专业/不严谨 | 数字没对齐 | 数字列右对齐、等宽、小数位统一 |
|
||||
| 深色模式难看 | 直接反色 | 按第 7 节深色模式色板重做 |
|
||||
| 说不上来哪里怪 | 1px 级光学对齐 | 放大 200% 检查图标文字中线、按钮文字垂直居中 |
|
||||
|
||||
### 16. 改中:纪律
|
||||
|
||||
- 修改指令 = 改哪里 + 改成什么(数值)+ 其余保持不变;
|
||||
- 每轮 ≤3 处,编号发出;
|
||||
- 结构没确认前不动皮肤——必要时先要黑白线框确认结构;
|
||||
- 新增组件前先查第 6 节默认值和第 11 节组件规则,不重新设计已有组件。
|
||||
|
||||
### 17. 改后:验收清单(三层 18 条,全过才算完)
|
||||
|
||||
**底层 · 不出错(8 条)**
|
||||
- [ ] 同一条左线;同行卡片等高、右缘对齐
|
||||
- [ ] 同屏只有一个实心按钮,危险操作非实心
|
||||
- [ ] 整页一个强调色,状态色只在徽标里
|
||||
- [ ] 按钮/输入框/卡片/图标各只有一种样式
|
||||
- [ ] 最浅灰字 ≥#8B93A3,对比度达标
|
||||
- [ ] 数字列右对齐、等宽、小数位对齐
|
||||
- [ ] 无渐变/光晕/毛玻璃/无意义图标/emoji
|
||||
- [ ] 按钮文字垂直居中、图标与文字中线对齐
|
||||
|
||||
**中层 · 有秩序(6 条)**
|
||||
- [ ] 眯眼深色块 ≤3,视线路径清楚
|
||||
- [ ] 三秒说出页面主题和主操作
|
||||
- [ ] 间距组内紧组间松、三级边距统一
|
||||
- [ ] 密度匹配用途
|
||||
- [ ] 字号 ≤3 档,靠灰度字重分层
|
||||
- [ ] 宽屏限宽或多栏,无拉伸扁条
|
||||
|
||||
**顶层 · 像软件(4 条)**
|
||||
- [ ] 悬停/禁用/加载/空/错误态齐全
|
||||
- [ ] 焦点环可见、有快捷键提示、行内操作悬停出现
|
||||
- [ ] 文案动词前后一致、写具体动作
|
||||
- [ ] 有且仅有一处记忆点
|
||||
|
||||
### 18. 交付要求
|
||||
|
||||
- 改完输出所用 token 表:颜色、字阶、圆角、间距——与第 6–10 节默认值不符处必须说明理由;
|
||||
- 语义化 HTML、键盘可达;icon-only 按钮配 tooltip;
|
||||
- 未提及的部分保持原样,不顺手「优化」。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A 营销页/落地页变体
|
||||
|
||||
场景不同,手段放宽,但纪律不变:
|
||||
|
||||
- **允许**:Hero 大标题(可到 40–56px)居中、一处渐变或氛围动效、Elevated 阴影卡、插画;仍然单强调色、一套圆角(可放宽到 8–16)、一个图标库;
|
||||
- **区块流**:Hero → Logo 墙 → 功能 → 评价 → 定价 → FAQ → CTA → 页脚;
|
||||
- **容器**:1280–1440;密度宽松(区块间距 64–96);
|
||||
- **动效**:scroll reveal 一次性、错落 ≤8 项、氛围动效只在 Hero 一处;
|
||||
- 六条标准照样检查——营销页只是「气质」层权重更高,底层「不出错」不打折。
|
||||
|
||||
## 附录 B 完全不懂数字时的说法(兜底)
|
||||
|
||||
- 指着选:「同内容出三版(紧凑专业/舒适通用/宽松友好),我挑一个」;
|
||||
- 拿实物比:「像微信卡片那么圆」「像 Linear 那种密度」;
|
||||
- 说相对:「圆角收一档」「间距松一档」「灰色深一档」;
|
||||
- 圆角对照:4=专业密集 / 8=网页默认 / 12=微信卡片 / 16=iOS 弹窗;
|
||||
- 字号对照:正文只有 14(后台)和 16(大众)两个选择;
|
||||
- 间距对照:一个字≈16px(同组)、三个字≈48px(小节)、六个字≈96px(大区块)。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 设计中心 · 数据域页面改版记录(2026-08-25)
|
||||
|
||||
> 范围:设计中心 · 数据域首页(`DataHome`)与对象详情子页(`ObjPage`)。
|
||||
> 约束:不改功能、不改接口,只改布局与 UI;全程按 `docs/DESIGN-SPEC.md` 走
|
||||
> (§15 诊断 → 第二部分规范 → §17 验收 → §18 token 表),用户逐轮验收定稿。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 改动性质 |
|
||||
|---|---|
|
||||
| `apps/web/src/features/gov-data/internal/panel.tsx` | DOM 结构与类名、汇总带、模式转置矩阵、取值呈现(空值/时间格式);无逻辑与取数改动 |
|
||||
| `apps/web/src/app/styles/console.css` | 重写 `.g-actdata*`/`.g-tbl*`,新增 `.g-data` 与 `.g-obj` 作用域;`.g-kpi` 色条改 inset 阴影 |
|
||||
| `apps/web/src/shared/gov/primitives.tsx` | `Kpi` 加可选 `hint` 属性(角标 info 气泡);其余件零改动 |
|
||||
| `apps/web/vite.config.ts` | `/api` 代理默认指向测试服务器 `150.158.83.131:46100`,`API_PROXY_TARGET` 环境变量可覆盖(开发环境配置,非页面) |
|
||||
|
||||
共享组件(`.g-card`/`.g-rows`/`.g-dbtable`/`.g-dh` 等)本体未动,其余治理面板不受影响
|
||||
—— 所有覆盖都在 `.g-data` / `.g-obj` 作用域内。
|
||||
|
||||
## 数据域首页(DataHome)
|
||||
|
||||
1. **隐藏「无人声明」区**(2026-08-24 用户定):壳表数据仍在接口 `unclaimed` 里,
|
||||
只是不渲染;空态判断相应只看有声明表的活动。
|
||||
2. **页顶汇总带**:声明活动 / 声明表 / 正式行 / 在办行四个 KPI,纯前端合计已有数据
|
||||
(不另取数);口径只含活动声明的表,无人声明的壳表不计入。
|
||||
每盒右上角 info 角标,悬停出纯 CSS 气泡(`data-tip` + `::after`)讲口径。
|
||||
3. **表块形态**:灰底格子 → 清单行(一版)→ **白卡片**(用户定稿)—— 1px 描边 `#DFE3EA`、
|
||||
圆角 8;卡内自上而下 = 表名+性质徽(右上 chevron)→ 22px 大数字行数(`tabular-nums`)→
|
||||
mono 地址+主键单行截断(全文在 `title`)。
|
||||
4. **卡片交互**:hover 三层反馈(描边变深 `#C4CCD8` + 洗底 `#F6F7F9` + 行数染主色),
|
||||
active `scale(.99)` 按压感,箭头染色前移 2px;全部 `0.16s ease-out`。
|
||||
5. **节奏**:活动卡组间距合成 32px(组内紧组间松),间距全取 8 的倍数。
|
||||
|
||||
## 对象详情子页(ObjPage)
|
||||
|
||||
1. **上下结构**(用户定稿):记录浏览在上整宽(进来先看行数据),模式在下整宽;
|
||||
主从两栏试过一版,两栏互相挤压,撤了。
|
||||
2. **两个小节同构**:小标题都在盒子外(`Sec` 段标题),下面各一个白底描边圆角盒;
|
||||
「模式」从 `Card` 换成 `Sec`,盒子描边挪给矩阵容器 `.g-sx`。
|
||||
3. **记录浏览表**:套上白卡;斑马纹(偶数行 `#F8FAFC`)+ 行线 `#E2E7EE` +
|
||||
列间发丝竖线 `#EEF1F5`;长 JSON 单元格单行截断(超宽横滚);
|
||||
**数字列按 schema 类型右对齐 + 等宽**;**主键列表头挂蓝色小钥匙图标**;
|
||||
空值(null/undefined)统一显示灰色 `-`。
|
||||
4. **ISO 时间格式化**:取值函数 `cellText` 识别 ISO 8601 时间串,转本地
|
||||
`YYYY-MM-DD HH:MM:SS`(如信号日期 `2026-05-31T16:00:00.000Z` → `2026-06-01 00:00:00`,
|
||||
UTC+8),所有表的日期列同享。
|
||||
5. **行数文案**:接口硬编码 `limit: 50` —— 截断时写「前 50 行 · 共 M 行」,
|
||||
全量在页内写「共 N 行」,不虚报「前」。
|
||||
6. **模式矩阵**:转置(用户定)—— 行 = 参数名/字段/数据类型,列 = 每个字段;
|
||||
最左窄行标签列吸左(横滚不丢);斑马纹与记录表同套;**类型徽分色**
|
||||
(number/integer 蓝、json 紫、text 灰);**悬停整列高亮**(`::after` 纵幕 +
|
||||
`mix-blend-mode: multiply` 护住文字,`pointer-events: none`)。
|
||||
|
||||
## 踩过的坑(留给后来者)
|
||||
|
||||
- **`.g-kpi` 的 `overflow: hidden` 会裁掉内部气泡**(用户报):色条从 `::before` 伪元素
|
||||
改 `inset box-shadow`(自动贴合圆角、无需裁切),撤掉 overflow。
|
||||
注意 `.g-data .g-kpi` 的 `box-shadow` 覆盖必须保留 inset 色条,不能 `none` 一抹了之。
|
||||
- **悬停幕直接画会糊住文字**:`::after` 定位元素绘制序在文字之上,
|
||||
加 `mix-blend-mode: multiply` —— 深字几乎不变色,只染亮底。
|
||||
|
||||
## Token 表(§18)
|
||||
|
||||
| 类别 | 取值 | 说明 |
|
||||
|---|---|---|
|
||||
| 强调色 | `#3370FF`(站点既有飞书蓝) | 未换规范默认 #2563EB —— 整站令牌一致优先 |
|
||||
| 文字灰阶 | `#1F2329` / `#646A73` / `#8F959E` | 同上,沿用站点令牌 |
|
||||
| 卡片 | 白底 + 1px `#DFE3EA` 描边,无阴影 | §10 描边优于阴影 |
|
||||
| 圆角 | 卡 8 / 块与徽 6 / 状态 chip 全圆 / 图标块 10 | §6,整站无 16px 以上 |
|
||||
| 间距 | 8 / 12 / 16 / 32 | §6 全 8 倍数 |
|
||||
| 数字 | `tabular-nums`,数字列右对齐 | §6 / §11 |
|
||||
| 语义色 | 只进徽标(性质徽三色、类型徽蓝/紫、run chips 四色) | §7 |
|
||||
| 动效 | 全部 `0.16s ease-out`,只动 transform/opacity/color | §13 |
|
||||
|
||||
## 验收(§17 三层 18 条)
|
||||
|
||||
底层 8 条、中层 6 条全过;顶层 4 条中「快捷键提示」本页无操作不涉及,其余齐
|
||||
(hover/active/focus 环、空态、title 全文、aria 同源)。
|
||||
每轮改动 `tsc --noEmit -p tsconfig.web.json` + `eslint` 均通过,并在浏览器实测截图验收。
|
||||
@@ -0,0 +1,104 @@
|
||||
# 运营中心 · 活动模块重构 —— 需求记录
|
||||
|
||||
> 记录时间:2026-08-25
|
||||
> 背景:活动模块(原「任务」模块)内容已全部清空(页签、布局、取数均已撤除,区入口与区标题保留),
|
||||
> 本次重构从零定义这个模块「是什么、给谁看、管什么」。
|
||||
> 本文档随用户口述逐条记录,定稿后整理为功能表。
|
||||
|
||||
## 一、原始需求记录
|
||||
|
||||
### 2026-08-25 · 核心概念:活动的定义 与 活动的实例(用户口述)
|
||||
|
||||
一个活动分成两个概念:
|
||||
|
||||
1. **活动的定义**
|
||||
- 来源:把一个业务拆分成多个部分,其中的**一部分内容**成为一个活动;
|
||||
- 活动要做的事:把**输入的数据**经过**一系列的动作**,变成**输出的数据**;
|
||||
- 「数据」的形态不限:可能是一个 JSON 串、一个文件、一个统计结果,也可能是一个字符串;
|
||||
- 一个活动**只会有一个定义**。
|
||||
2. **活动的实例**
|
||||
- 真正输入了一段数据给这个活动,活动经过一系列动作,产生了一个输出的数据;
|
||||
- 一个活动可以有**多个实例**。
|
||||
|
||||
类比(用户原话的类比):好比 Java 中的对象 —— 要使用一个对象,先**声明**这个类、声明里面有几个参数(对应**活动的定义**);要真正用它,就 **new 一个对象并赋值**(对应**活动的实例**)。
|
||||
|
||||
补充确认(2026-08-25 用户定):**活动创建的时候,输入与输出就已经定好了** —— 数据形态虽不限(JSON/文件/统计结果/字符串),但一个具体活动的定义,其输入输出的结构是声明清楚的(像 Java 参数带类型),不是跑到实例时才看。
|
||||
|
||||
### 2026-08-25 · L2 规划确认与补充(用户口述)
|
||||
|
||||
- **实例状态共 5 个**:进行中 / 待执行 / 已完成 / 驳回 / 失败;
|
||||
- **创建实例**:先把入口留出来,功能待做;
|
||||
- **运营概览小统计**:需要;
|
||||
- **验收动作在工作台**,活动模块不管验收 —— L2 只展示状态;
|
||||
- **激活/禁用都需要二次确认**;禁用会访问接口,**有实例时接口会提示无法禁用**(本期只做前端,暂不接接口,前端先把这个规则的样子做出来);
|
||||
- **L2 需要一个沙箱**:用来加载服务器返回的 HTML 页面(活动视图),先做一个版本看效果再改。
|
||||
|
||||
### 2026-08-25 · 版本号与版本迭代入口(用户口述)
|
||||
|
||||
- 活动卡片要展示**版本号**:表示当前已发布的活动属于哪个版本(有版本才显示);
|
||||
- 背景逻辑:因为活动**没有编辑和删除功能**,所以演进只能靠发新版本 —— 子页面(L2)应该有**版本迭代的入口**;
|
||||
- 版本迭代功能怎么设计**回头再讨论**,本期只先把版本号加到卡片里。
|
||||
|
||||
### 2026-08-25 · L1 页面:两个页签(用户口述)
|
||||
|
||||
L1(模块首页 · 活动清单)需要一个**标签页**切换,因为活动分成两类:
|
||||
|
||||
1. **已发布上线**的
|
||||
2. **审核中**的
|
||||
|
||||
### 2026-08-25 · 活动板块的六个功能(用户口述)
|
||||
|
||||
1. **活动的创建**(即创建定义)
|
||||
2. **活动的展示**
|
||||
3. **活动实例的创建**
|
||||
4. **活动实例的展示**
|
||||
5. **活动实例的编辑**
|
||||
6. **活动实例的删除**
|
||||
|
||||
配套的规则(用户定):
|
||||
|
||||
- **活动(定义)只允许创建,不允许编辑、不允许删除**;
|
||||
- **活动创建需要审核,审核通过后才能展示**(进展示列表);
|
||||
- 活动实例**可能也不允许删除** —— 存疑,但先把删除规划出来,后续根据需求再调整。
|
||||
|
||||
### 2026-08-25 · 平台定位纠正(用户口述,推翻我此前的理解)
|
||||
|
||||
- 要做的**不是**只针对一个业务的生产环境系统,而是一个**平台**:以**活动为最小基点**,能串联整个业务的工作流;后续会有**多个类型不同的业务**接入平台。
|
||||
- 目的:**减少人的工作量**,让 AI agent 在整个业务里做执行者。
|
||||
- **每个活动有一个单独的 agent**,负责把这个活动的流程跑通。
|
||||
- 人机分工:
|
||||
- 业务的**规划**部分 → agent 来做;
|
||||
- 规划出来但 **agent 无法完成的执行**部分 → AI 把执行层任务分配给某个人,这个人 = **活动负责人**;
|
||||
- 负责人干两件事:① 做 AI 安排的执行任务;② 对 AI 做的规划任务、统计任务进行**验收**(责任承担 —— AI 不能承担责任,人担)。
|
||||
- 系统架构已经完成;现在对**活动模块**重新规划:活动模块要能**根据业务需求创建各种各样的活动**,做的是「**活动的设计和运营**」。
|
||||
|
||||
## 二、澄清与决策
|
||||
|
||||
- 2026-08-25 · 纠正:产品形态是**可接多类业务的平台**,不是单一业务系统(界面顶栏「单组织 · ≤50 人 · 生产环境」是旧定位的遗留文案,理解时不要被它带偏)。
|
||||
- 2026-08-25 · 活动模块的重新定位:**活动的设计和运营** —— 不再只是「任务的在途监控」(旧版任务模块的心智)。
|
||||
- 2026-08-25 · **双台面合并方向(架构级决策)**:现有「设计中心 / 运营中心」的划分不合理,未来**只保留运营中心,设计是运营的一部分**。本次活动模块重构就按这个思路做 —— 模块内同时包含「设计」与「运营」;活动模块先做样板,**其他模块后续也按这个方式逐一重做**。
|
||||
- 2026-08-25 · **需求工作方式(用户定的规矩)**:不拆讨论稿、不预设结构 —— 旧版活动模块的内容全部抛弃、暂不考虑;由用户口述「活动板块应该是什么样的」,我负责记录、之后再规划。旧内容只在用户明确说「这一步可以参考以前的东西」时才去翻。
|
||||
- 2026-08-25 · **层级划分(用户确认)**:活动模块 = **三级页面**,层级关系即概念关系(定义 → 实例,类 → 对象):
|
||||
- **L1 模块首页 · 活动清单(定义层)**:两个页签 —— **已发布上线 / 审核中**(2026-08-25 用户定);所有活动定义(名称 / 输入输出契约摘要 / 审核状态 / 实例数)→ 落功能 2;功能 1(创建定义)入口在此;
|
||||
- **L2 活动详情 · 一个定义 + 它的实例清单**:定义完整信息(输入/输出结构、动作说明、审核信息)+ 该定义的全部实例(输入摘要 / 状态 / 产出摘要 / 时间)→ 落功能 4(清单);功能 3(实例创建)入口在此(new 实例必须挂在某个定义下);
|
||||
- **L3 实例详情 · 一次运行的完整视图**:完整输入 / 动作过程记录 / 最终输出 / 当前状态 → 落功能 4(详情)、功能 5(编辑)、功能 6(删除),编辑删除收在最深层,不污染清单。
|
||||
- 待定点:审核入口放 L1 还是单列;文件类输入的实例创建入口形态。
|
||||
- 2026-08-25 · **L1 模版已落地(静态占位版)**:页签「已发布 / 审核中」、活动卡片(名称 / 标识·业务 / 状态丸 / 实例·负责人·时间·版本四格底板)、业务色相视觉增强(卡顶色签 / 图标块 / 悬停色影)。数据为静态假数据,接口设计定稿后整体替换。卡片点击(进 L2)未接。
|
||||
- 2026-08-25 · **优先级调整(用户定)**:**先做运营部分,设计功能(创建活动)暂缓** —— L1 的「创建活动」幽灵卡已从页面注释掉(JSX 注释保留在代码里,样式未删,恢复即解开)。
|
||||
- 2026-08-25 · **页签改为运营态(用户定)**:L1 页签「已发布 / 审核中」→「**激活中 / 已禁用**」——每个活动有激活/禁用状态,**在 L2 里修改**;发布与审核属于设计部分的功能,暂缓,等运营功能做好再回头改。禁用卡片走灰底弱化(复用 `.g-fcard.off`)。
|
||||
- 2026-08-25 · **文案口径(用户定)**:创建实例 → **启动活动**;实例清单 → **历史活动**;停止 → **终止活动**。
|
||||
- 2026-08-26 · **L2 历史活动改列表(用户定)**:卡片式清单撤掉,改表格列(实例 / 负责人 / 输入 / 输出 / 启动时间 / 状态 / 操作,操作栏带标题),隔行浅斑马 + 悬停亮行,行内操作(终止活动 / 查看详情)常驻;分页单页 8 条不变。
|
||||
- 2026-08-26 · **L2 历史活动加检索(用户定)**:列表上方检索条 —— 条件下拉四选(实例 / 负责人 / 状态 / 启动时间),控件随条件换:实例=按实例号模糊输入、负责人=人员下拉(候选取自现有实例,接接口后换人员接口)、状态=五态下拉、启动时间=日期段(含两端整日);筛中显示「筛出 X / 共 Y」并可一键清空;分页按筛出的集合走。
|
||||
|
||||
## 三、功能表(整理稿)
|
||||
|
||||
(待需求说完后输出:功能项 / 说明 / 数据来源(接口) / 交互 / 优先级)
|
||||
|
||||
## 四、待确认问题
|
||||
|
||||
- ~~活动模块里的「设计」与设计中心「活动库」是什么关系?~~ **已答(2026-08-25)**:不再有「设计中心 vs 运营中心」的分工 —— 设计并入运营,活动模块自带设计能力;设计中心各区后续按同思路并入运营中心重做。
|
||||
- ~~「设计」在活动模块里做到什么程度?~~ **已答(2026-08-25)**:六功能已界定 —— 活动(定义)的创建与展示 + 实例的创建/展示/编辑/删除;定义只许创建不许编删,创建要过审核。
|
||||
- 活动创建的**审核**:谁来审、在模块内审还是别处、审什么(输入输出契约的合理性?)?(待用户展开)
|
||||
- 实例的**编辑**具体指编辑什么:实例的输入数据?进行中的干预(暂停/叫停)?还是产出修正?(待用户展开)
|
||||
- 实例删除是否真放开 —— 用户已标注存疑,先规划,后续按需求调。(悬着,不阻塞)
|
||||
- 「运营」侧还要不要监控/验收类功能(此前提过负责人验收规划与统计产出)—— 六功能里未直接出现,是否归在「实例的展示/编辑」里,还是另有入口?(待用户展开)
|
||||
@@ -0,0 +1,45 @@
|
||||
# 统一鉴权中心(Keycloak)接入记录 —— 2026-08-26
|
||||
|
||||
依据 `BPOS-MVP/docs/` 三份文档(keycloak-frontend-guide / API文档-鉴权与人员 / 前端对接说明-统一鉴权中心,ADR-012)完成前端侧改造。后端以测试服 `150.158.83.131:46100` 实测:`/api/me` 无头 401「缺少 Bearer token」、`/api/login` 410 退役,Keycloak `corp` realm 在线。
|
||||
|
||||
## 机制
|
||||
|
||||
- 本端 = 用户端 `web-portal`(public client,无 secret,PKCE S256);要求 realm 角色 `user`。
|
||||
- token 只在 keycloak-js 内存里,刷新页面靠 SSO 会话静默恢复;每个请求经 `freshToken()` 挂 `Authorization: Bearer`。
|
||||
- 准入只看 `GET /api/me` 的 `ends`(角色 → 端):入口亮灯、深链保护、个人中心展示都读它;`ends: []` 摆「未授权」页而不是登录页。
|
||||
- 401 先强刷一次 token 重试,仍 401 落登录页;403 不重试(端不够,重登没用)。
|
||||
- SSE 与「下载原件」走 60 秒票据(`GET /api/auth/ticket` → `?ticket=`);SSE 断线后重新取票据重建(浏览器自动重连的旧 URL 必 401)。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/contracts/src/org.ts` | 加 `SURFACES`/`surfaceSchema`/`principalSchema`;`sessionSchema` 标 @deprecated(本仓库 apps/api 仍是旧后端,契约暂留) |
|
||||
| `packages/contracts/src/api/workbench.ts` | 加 `meRoute`(`GET /api/me`)、`authTicketRoute`(`GET /api/auth/ticket`);session/login/logout 三条标 @deprecated |
|
||||
| `apps/web/package.json` | + `keycloak-js@26.2.4` |
|
||||
| `apps/web/.env.development` / `.env.production`(新) | `VITE_KEYCLOAK_URL/REALM/CLIENT_ID=web-portal/VITE_REQUIRED_ROLE=user` |
|
||||
| `apps/web/public/silent-check-sso.html`(新) | 静默 SSO 探测落点页 |
|
||||
| `apps/web/src/shared/auth.ts`(新) | keycloak 实例 + `initAuth/login/logout/freshToken/forceRefreshToken`;未初始化(测试)时 `freshToken` 回 undefined 不带头 |
|
||||
| `apps/web/src/shared/typed-fetch.ts` | 去 cookie,挂 Bearer;401 → `forceRefreshToken` 后重试一次 |
|
||||
| `apps/web/src/app/main.tsx` | 模块加载即 `initAuth()`;Gate 三分支(未登录→登录页 / 角色不够→Denied+登出 / 正常→`/api/me`)+ ends 深链保护(落到能进的第一个端) |
|
||||
| `apps/web/src/features/auth/…/LoginPage.tsx` | 删账号口令表单,改「前往统一登录」跳 Keycloak |
|
||||
| `apps/web/src/shared/gov/surfaces.ts` | `canSeeSurface(me, surface)` = `me.ends.includes(surface)`;删 `SURFACE_FNS` |
|
||||
| `apps/web/src/app/app-shell.tsx` / `gov-shell.tsx` / `desk-me/…/MePage.tsx` | session→me(`queryKeys.me`);登出 = 清缓存 + `keycloak.logout()`;MePage「我的权限」改「我的准入」(角色 + 可进的端,端可直接点过去) |
|
||||
| `apps/web/src/app/subscribe.ts` | SSE 走票据,error 时关旧连接、重取票据、1s 后重建 |
|
||||
| `apps/web/src/features/desk-task/…/FileViewer.tsx` | 「下载原件」点击时现取票据拼 `?ticket=` 再跳转 |
|
||||
| `apps/web/src/shared/query-keys.ts` | `session` → `me` |
|
||||
| `vitest.config.ts` | 补 `@web-shared` / `@features` 别名(与 web 的 vite.config 同口径) |
|
||||
| `apps/web/src/shared/__tests__/typed-fetch.test.ts` | 断言前加 `flushToken()`(fetch 前多了一道取 token 的微任务) |
|
||||
| `apps/web/src/shared/gov/__tests__/surfaces.test.ts` | `canSeeSurface` 改测 ends 口径 |
|
||||
|
||||
## 验证
|
||||
|
||||
- `tsc --noEmit`(web + node)、`eslint` 全部改动文件、`vitest run` 全仓 1691 过。
|
||||
- 浏览器实测:localhost:5173 → 登录页(前往统一登录)→ 跳 `keycloak.akhr-ai.com/realms/corp/...auth?client_id=web-portal&…&code_challenge_method=S256`,登录页正常渲染(无 redirect_uri 报错)。
|
||||
- 真人账号登录 → `/api/me` → 各端落点这后半段需要有权限的账号走一遍(判据见对接说明 §9)。
|
||||
|
||||
## 已知遗留
|
||||
|
||||
- 运营中心「员工」页还在调四条已退役接口(建人/停用/停用前置/岗位授权,410),需按对接说明 §7 换成同步名册 / 指派岗位 / 在途工作 / 角色→端 —— 未做,等指令。
|
||||
- `grantSchema` / `Session` 等旧类型契约里标了 @deprecated,前端已不 import(LoginPage/main/shells/surfaces 全换完),契约物理删除留待后端侧一起收。
|
||||
- Keycloak 侧若要收回 localhost 回调(上线清单 §14),找管理员改 client 配置,前端不用动。
|
||||
@@ -4,12 +4,16 @@
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { RouteContract } from './http.ts';
|
||||
import { sessionSchema } from '../org.ts';
|
||||
import { principalSchema, sessionSchema } from '../org.ts';
|
||||
import { chatMessageSchema, deliverableSchema, fileDocSchema, formFieldSchema, stageSchema, viewBlockSchema, workOrderSchema } from '../card.ts';
|
||||
import { attributionSideSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
|
||||
import { staffIdSchema, taskIdSchema } from '../id.ts';
|
||||
|
||||
/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */
|
||||
/**
|
||||
* 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010)
|
||||
*
|
||||
* @deprecated 统一鉴权中心(ADR-012)后已退役(410):改用 `meRoute`。
|
||||
*/
|
||||
export const sessionRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/session',
|
||||
@@ -19,6 +23,9 @@ export const sessionRoute = {
|
||||
/**
|
||||
* 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。
|
||||
* 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。
|
||||
*
|
||||
* @deprecated 统一鉴权中心(ADR-012)后已退役(410):登录由 Keycloak 完成,
|
||||
* 请求带 `Authorization: Bearer <access_token>`。
|
||||
*/
|
||||
export const loginRoute = {
|
||||
method: 'POST',
|
||||
@@ -27,13 +34,42 @@ export const loginRoute = {
|
||||
output: sessionSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */
|
||||
/**
|
||||
* 登出 —— 服务端会话立刻作废,不等 cookie 到期
|
||||
*
|
||||
* @deprecated 统一鉴权中心(ADR-012)后已退役(410):
|
||||
* 登出 = 前端丢弃 token + Keycloak end-session。
|
||||
*/
|
||||
export const logoutRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/logout',
|
||||
output: z.object({ ok: z.boolean() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 我是谁、能进哪几个端(ADR-012)—— access: `any`(登录即可)。
|
||||
*
|
||||
* `ends` 为空也 200:前端据此摆「未授权」页,不落登录页。
|
||||
* 副作用:第一次见到这个 `sub` 后端会在名册建档(岗位 NONE 未分配)。
|
||||
*/
|
||||
export const meRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/me',
|
||||
output: principalSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 换一枚 60 秒票据(ADR-012)—— access: `any`。
|
||||
*
|
||||
* 给 `EventSource` 与 `<a href download>` 这两条带不了 Authorization 头的路:
|
||||
* 只在 GET 的 `?ticket=` 上认;票据里没有权限,后端按它对应的 sub 回库现算。
|
||||
*/
|
||||
export const authTicketRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/auth/ticket',
|
||||
output: z.object({ ticket: z.string(), expiresAt: z.string() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const healthRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/health',
|
||||
|
||||
@@ -101,6 +101,10 @@ export type DisableCheck = z.infer<typeof disableCheckSchema>;
|
||||
|
||||
/* ── 会话 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* @deprecated 统一鉴权中心(ADR-012)后退役:`GET /api/session` 已 410,
|
||||
* 改用 `principalSchema`(`GET /api/me`)。本仓库 apps/api 仍是旧后端,契约暂留。
|
||||
*/
|
||||
export const sessionSchema = z.object({
|
||||
staff: staffSchema,
|
||||
position: positionSchema,
|
||||
@@ -108,6 +112,31 @@ export const sessionSchema = z.object({
|
||||
});
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
|
||||
/* ── 统一鉴权中心(ADR-012,2026-08-26)───────────────────────────────── */
|
||||
|
||||
/** 三个端(台面):权限只到端,端内接口对能进的人全开 */
|
||||
export const SURFACES = ['design', 'ops', 'workbench'] as const;
|
||||
export const surfaceSchema = z.enum(SURFACES);
|
||||
export type Surface = z.infer<typeof surfaceSchema>;
|
||||
|
||||
/**
|
||||
* 「我是谁、能进哪几个端」—— `GET /api/me` 的出参。
|
||||
*
|
||||
* `staff.id` 就是 Keycloak 的 `sub`(UUID);`roles` 是 realm 角色快照
|
||||
* (噪声角色已被后端滤掉);`ends` 是「角色 → 端」表的并集,可以为空 —
|
||||
* 空也 200,前端据此摆「未授权」页而不是登录页。
|
||||
*/
|
||||
export const principalSchema = z.object({
|
||||
staff: staffSchema.extend({
|
||||
email: z.string().optional(),
|
||||
roles: z.array(z.string()).default([]),
|
||||
}),
|
||||
position: positionSchema,
|
||||
roles: z.array(z.string()),
|
||||
ends: z.array(surfaceSchema),
|
||||
});
|
||||
export type Principal = z.infer<typeof principalSchema>;
|
||||
|
||||
/* ── 工作组(ADR-011) ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
Generated
+10
@@ -51,6 +51,8 @@ importers:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jsdom@30.0.1)(tsx@4.23.1)(yaml@2.9.0)
|
||||
|
||||
activities/brand-content-toolkit: {}
|
||||
|
||||
activities/crm-relationship-coverage: {}
|
||||
|
||||
activities/quote-alternative: {}
|
||||
@@ -157,6 +159,9 @@ importers:
|
||||
diagram-js:
|
||||
specifier: ^15.23.0
|
||||
version: 15.23.0
|
||||
keycloak-js:
|
||||
specifier: 26.2.4
|
||||
version: 26.2.4
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.8
|
||||
@@ -2656,6 +2661,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
keycloak-js@26.2.4:
|
||||
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -6195,6 +6203,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
keycloak-js@26.2.4: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
@@ -11,6 +11,10 @@ export default defineConfig({
|
||||
'@sfeel': p('./packages/sfeel/src/index.ts'),
|
||||
'@ui': p('./packages/ui-kit/src/index.ts'),
|
||||
'@utils': p('./packages/utils/src/index.ts'),
|
||||
// apps/web 内部的两个别名 —— 与 apps/web/vite.config.ts 同口径,
|
||||
// 否则 web 侧测试碰不到挂了这两个前缀的模块(2026-08-26 subscribe.ts 接入票据后踩到)
|
||||
'@web-shared': p('./apps/web/src/shared'),
|
||||
'@features': p('./apps/web/src/features'),
|
||||
'@testing': p('./tools/it-database.ts'),
|
||||
'@modules': p('./apps/api/src/modules'),
|
||||
'@shared': p('./apps/api/src/shared'),
|
||||
|
||||
Reference in New Issue
Block a user