Archived
接入统一鉴权中心(Keycloak,ADR-012);活动模块 L1/L2 运营态模版(历史活动列表+检索+沙箱);数据域与工作台界面改版;个人中心 /me
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user