接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me

This commit is contained in:
shanqin
2026-08-26 13:29:36 +08:00
parent f3397a60ef
commit f01286a89d
40 changed files with 2502 additions and 480 deletions
@@ -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>
);
}
+5
View File
@@ -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>
);
}
+1
View File
@@ -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>
+1 -1
View File
@@ -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';
+200 -57
View File
@@ -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>
);
}
+1 -1
View File
@@ -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';
+511 -132
View File
@@ -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>
);
}