forked from ChaiHao/BPOS-MVP
M6(4/n): 工作台五要素齐活 —— 审批 / 消息 / 纪要 / 汇报
四个 desk-* feature 自 workbench 原型 ModuleWorkspace.tsx 平移(jc-* 类名原样),
共用件落 shared/desk/module-kit;mock 数据换成服务端投影:
· desk-approval:审批点卡到审批人工位;依据快照、驳回走向(声明式)、驳回必附意见
· desk-message:通知 + 跨任务征答**就地作答**;卡面表单由 card.ts schema 驱动,
与后端回传校验同 schema;跨人卡不占任务清单,不开放任务对话流
· desk-minute:纪要维护 + 决议登记起单;正文可续、决议只增
· desk-report:四周期草案 + 度量八指标 + 确认即提交
左轨角标常驻(待批 / 未读),责任面要一眼看见什么在等我。
修一个真 bug:process.start 从不校验流程包的 callInterface,
先插工作流再由 task.start 撞活动契约 —— 失败就留下一条永远推不动的在途单。
改为**先校验后建单**(CallInterfaceViolationError),一条工作流都不留;
路由回 400 并带上缺哪个字段。已清掉这次触发出的两条孤儿单。
判据 ⑩ 纪要一环跑通:决议 → 登记触发 → 工作流 → A1 落到 BD 冯兵,
登记人即来源已记在单上;同一决议重复登记被 412 挡下。
gates: tsc / eslint / depcruise(316 模块)/ api-report / 380 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 审批要素的路由(编码规范 §3;语义出处 OS 2.1 / 3.2)。
|
||||
*
|
||||
* 「工作台层面的『审批』**专指工作流级的审批点**」——它卡到审批人工位,产出审批单:
|
||||
* 通过 / 驳回,附依据;没有预算、没有 Harness、没有任务日志,所以不是任务。
|
||||
* 驳回走向是**声明式**的(流程包里写死),不由临场决定。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts';
|
||||
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */
|
||||
function rejectRouteOf(pkg: ProcessPackage, reject: Extract<ProcessNode, { kind: 'approval' }>['reject']): string {
|
||||
if (reject.kind !== 'reopen') return '驳回 → 终止流';
|
||||
const target = pkg.nodes.find((n) => n.id === reject.nodeId);
|
||||
return `驳回 → 回 ${target?.label ?? reject.nodeId} 重开新实例`;
|
||||
}
|
||||
|
||||
export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
|
||||
/** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */
|
||||
app.get(listApprovalsForMeRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
const nested = await Promise.all(
|
||||
workflows.map(async (w) => {
|
||||
const pkg = await container.process.getPackage(w.packageId, w.packageVersion);
|
||||
if (pkg === undefined) return [];
|
||||
const instances = await container.process.instancesOf(w.id);
|
||||
return instances
|
||||
.filter((i) => i.activated)
|
||||
.flatMap<ApprovalItem>((i) => {
|
||||
const node = pkg.nodes.find((n) => n.id === i.nodeId);
|
||||
if (node?.kind !== 'approval') return [];
|
||||
// 审批人卡到岗位 —— 不是我的岗位就不进我的审批要素
|
||||
if (node.approver.position !== me.position.code) return [];
|
||||
return [
|
||||
{
|
||||
id: `${w.id}:${i.nodeId}#${i.instance}`,
|
||||
workflowId: w.id,
|
||||
workflowSubject: w.subject,
|
||||
nodeId: i.nodeId,
|
||||
title: node.label,
|
||||
// 依据快照取自上游节点的输出口载荷
|
||||
basis: Object.entries(w.ports[i.nodeId] ?? w.initiation ?? {})
|
||||
.slice(0, 8)
|
||||
.map(([k, v]) => ({ k, v: String(v) })),
|
||||
ask: '请裁决:通过 / 驳回(驳回必附意见)',
|
||||
state:
|
||||
i.terminal === undefined
|
||||
? ('pending' as const)
|
||||
: i.terminal === 'released'
|
||||
? ('passed' as const)
|
||||
: ('rejected' as const),
|
||||
rejectRoute: rejectRouteOf(pkg, node.reject),
|
||||
raisedAt: w.startedAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
}),
|
||||
);
|
||||
return c.json({ approvals: nested.flat() });
|
||||
});
|
||||
|
||||
app.post(decideApprovalRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '裁决不合协议' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
// 驳回必附意见 —— 与人类工具三规矩同一条:否定必须带理由
|
||||
if (parsed.data.verdict === 'reject' && (parsed.data.comment ?? '').trim() === '') {
|
||||
const body: ApiError = { code: 'validation-failed', message: '驳回必附意见' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
|
||||
const workflowId = c.req.param('workflowId');
|
||||
const nodeId = c.req.param('nodeId');
|
||||
|
||||
// 审批权按岗位校验:节点声明的审批岗位必须是我的岗位
|
||||
const workflow = await container.process.getWorkflow(workflowId);
|
||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||
const node = pkg?.nodes.find((n) => n.id === nodeId);
|
||||
if (node?.kind !== 'approval') {
|
||||
const body: ApiError = { code: 'not-found', message: `节点不是审批点:${nodeId}` };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
if (node.approver.position !== me.position.code) {
|
||||
const body: ApiError = { code: 'forbidden', message: `审批点卡在 ${node.approver.position} 工位,岗位 ${me.position.code} 不得裁决` };
|
||||
return c.json(body, 403);
|
||||
}
|
||||
|
||||
const state = await container.process.onApprovalVerdict(workflowId, nodeId, parsed.data.verdict, {
|
||||
by: me.id,
|
||||
...(parsed.data.comment === undefined ? {} : { comment: parsed.data.comment }),
|
||||
});
|
||||
// 审批一裁,流就动了 —— 让在线的浏览器立刻重取投影
|
||||
container.broadcaster.publish({ kind: 'workflow', id: workflowId, reason: 'approval-verdict' });
|
||||
return c.json({ ok: true, workflowState: state });
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
staffIdSchema,
|
||||
submitReportRoute,
|
||||
} from '@contracts';
|
||||
import { CallInterfaceViolationError } from '@modules/process';
|
||||
import type { Minute } from '@modules/minute';
|
||||
import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/minute';
|
||||
import type { Report } from '@modules/report';
|
||||
@@ -150,6 +151,11 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
if (error instanceof CallInterfaceViolationError) {
|
||||
// 不合调用接口契约:一条工作流都没建,直接回 400 让登记人补齐
|
||||
const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.errors };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
if (error instanceof MinuteNotFoundError) {
|
||||
const body: ApiError = { code: 'not-found', message: error.message };
|
||||
return c.json(body, 404);
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { Hono } from 'hono';
|
||||
import type { ApiError, ChatMessage, TaskCard, ViewBlock } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
answerMessageRoute,
|
||||
listMessagesRoute,
|
||||
markMessageReadRoute,
|
||||
listTasksRoute,
|
||||
replyWorkOrderRoute,
|
||||
sayRoute,
|
||||
@@ -185,7 +187,55 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
body: m.body,
|
||||
at: m.at,
|
||||
read: m.readAt !== undefined,
|
||||
// 跨任务征答卡随消息一并出:就地作答,不必跳进别人的任务
|
||||
...(m.card === undefined
|
||||
? {}
|
||||
: {
|
||||
card: {
|
||||
cardId: m.card.cardId,
|
||||
sourceTaskId: m.card.sourceTaskId,
|
||||
fields: (m.card.payload['fields'] as unknown[] | undefined) ?? [],
|
||||
ask: String(m.card.payload['ask'] ?? ''),
|
||||
answered: m.card.answeredAt !== undefined,
|
||||
...(m.card.rejectedReason === undefined ? {} : { rejectedReason: m.card.rejectedReason }),
|
||||
...(m.card.supersededBy === undefined ? {} : { supersededBy: m.card.supersededBy }),
|
||||
},
|
||||
}),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.post(markMessageReadRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
await container.message.markRead(c.req.param('messageId'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* 跨任务征答的**就地作答**(OS 2.1)。
|
||||
* 「作答权限只及卡面与决策包,**不开放任务对话流**」——所以这里只收卡面回传,
|
||||
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
||||
*/
|
||||
app.post(answerMessageRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const parsed = answerMessageRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '回传不合协议' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const answered = await container.message.answer(c.req.param('messageId'), staff.id, parsed.data.answer);
|
||||
// 跨人卡一答,来源任务就可能续跑 —— 让那边的浏览器重取投影
|
||||
if (answered.card !== undefined) {
|
||||
container.broadcaster.publish({ kind: 'task', id: answered.card.sourceTaskId, reason: 'elicitation-answer' });
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { registerDeskRoutes } from './routes/desk.ts';
|
||||
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
||||
import { registerDeskApprovalRoutes } from './routes/desk-approval.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
@@ -46,6 +47,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerDeskRoutes(app, container);
|
||||
registerDeskElementRoutes(app, container);
|
||||
registerDeskApprovalRoutes(app, container);
|
||||
registerGovernanceOpsRoutes(app, container);
|
||||
registerIngestRoutes(app, container);
|
||||
registerStreamRoute(app, container.broadcaster);
|
||||
|
||||
@@ -479,6 +479,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
},
|
||||
},
|
||||
sfeel,
|
||||
schemas: deps.schemas,
|
||||
events: {
|
||||
publish: async (input) => {
|
||||
await deps.event.publish({
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 起单守卫:不合调用接口契约的输入,**一条工作流都不留**。
|
||||
*
|
||||
* 用例层测试 —— 端口给的是**真实实现**(内存版),不 mock 内部机制;
|
||||
* 类型一律走 schema 解析而来的真值,不用断言绕过(§12.5#4)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { processPackageSchema } from '@contracts';
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { CallInterfaceViolationError } from '../contract.ts';
|
||||
import { createProcessService } from '../internal/usecase/process-service.ts';
|
||||
import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts';
|
||||
import type { WorkflowRecord } from '../port.ts';
|
||||
import type { NodeInstance } from '../internal/domain/owner-resolution.ts';
|
||||
|
||||
/** 最小可发布包:一个开始、一个结束、一条边 —— 起单守卫不需要更复杂的图 */
|
||||
const PKG: ProcessPackage = processPackageSchema.parse({
|
||||
id: 'p1',
|
||||
name: '测试包',
|
||||
version: 1,
|
||||
state: 'published',
|
||||
callInterface: {
|
||||
input: { type: 'object', required: ['customerCode'], properties: { customerCode: { type: 'string' } } },
|
||||
output: { type: 'object' },
|
||||
},
|
||||
nodes: [
|
||||
{ kind: 'start', id: 'S', label: '开始' },
|
||||
{ kind: 'end', id: 'E', label: '结束' },
|
||||
],
|
||||
edges: [{ id: 'e1', from: 'S', to: 'E', mapping: [], reopen: false }],
|
||||
variables: [],
|
||||
});
|
||||
|
||||
type Harness = {
|
||||
readonly service: ReturnType<typeof createProcessService>;
|
||||
readonly inserted: WorkflowRecord[];
|
||||
readonly published: unknown[];
|
||||
};
|
||||
|
||||
function harness(validationErrors: readonly string[]): Harness {
|
||||
const inserted: WorkflowRecord[] = [];
|
||||
const published: unknown[] = [];
|
||||
const instances: NodeInstance[] = [];
|
||||
|
||||
const deps: ProcessServiceDeps = {
|
||||
store: {
|
||||
savePackage: async () => undefined,
|
||||
getPackage: async () => PKG,
|
||||
listPackages: async () => [PKG],
|
||||
publishPackage: async () => undefined,
|
||||
insertWorkflow: async (record) => {
|
||||
inserted.push(record);
|
||||
},
|
||||
getWorkflow: async (id) => inserted.find((w) => w.id === id),
|
||||
listWorkflows: async () => inserted,
|
||||
// 内存版要**真的落笔**:用例读回它自己刚写的状态,假实现敷衍就测不出真行为
|
||||
updateWorkflow: async (id, patch) => {
|
||||
const at = inserted.findIndex((w) => w.id === id);
|
||||
if (at < 0) throw new Error(`测试:工作流不存在 ${id}`);
|
||||
const next = { ...inserted[at]!, ...patch };
|
||||
inserted[at] = next;
|
||||
return next;
|
||||
},
|
||||
listInstances: async () => instances,
|
||||
insertInstance: async (_workflowId, instance) => {
|
||||
instances.push(instance);
|
||||
},
|
||||
updateInstance: async () => undefined,
|
||||
findInstanceByTask: async () => undefined,
|
||||
},
|
||||
activities: { faces: async () => new Map() },
|
||||
org: { positions: async () => new Set<string>(), candidates: async () => [] },
|
||||
tasks: { start: async () => ({ id: 'task-1' }) },
|
||||
approvals: { raise: async () => ({ id: 'apr-1' }) },
|
||||
sfeel: { condition: () => true, expression: () => undefined },
|
||||
schemas: {
|
||||
validate: () => ({ ok: validationErrors.length === 0, errors: [...validationErrors] }),
|
||||
},
|
||||
events: {
|
||||
publish: async (input) => {
|
||||
published.push(input);
|
||||
},
|
||||
},
|
||||
receipts: { notify: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-27T00:00:00.000Z') },
|
||||
ids: { next: () => 'wf-1' },
|
||||
};
|
||||
|
||||
return { service: createProcessService(deps), inserted, published };
|
||||
}
|
||||
|
||||
describe('起单:凡单必合契约', () => {
|
||||
const input = {
|
||||
packageId: 'p1',
|
||||
subject: '测试单',
|
||||
source: { kind: 'registration' as const, ref: 'p1', by: 's1' },
|
||||
data: {},
|
||||
};
|
||||
|
||||
it('入参不合调用接口 → 抛错,且**不建工作流、不发事件**', async () => {
|
||||
const { service, inserted, published } = harness(["must have required property 'customerCode'"]);
|
||||
await expect(service.start(input)).rejects.toBeInstanceOf(CallInterfaceViolationError);
|
||||
// 校验若放在建单之后,这里就会留下一条永远推不动的在途单,污染在途口径
|
||||
expect(inserted).toHaveLength(0);
|
||||
expect(published).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('错误带上缺了哪个字段 —— 登记人据此补齐,而不是猜', async () => {
|
||||
const { service } = harness(["must have required property 'customerCode'"]);
|
||||
await expect(service.start(input)).rejects.toThrow(/customerCode/);
|
||||
});
|
||||
|
||||
it('入参合契约 → 正常建单,来源记在单上', async () => {
|
||||
const { service, inserted } = harness([]);
|
||||
const record = await service.start({ ...input, data: { customerCode: 'ACME' } });
|
||||
expect(record.id).toBe('wf-1');
|
||||
expect(inserted).toHaveLength(1);
|
||||
// 凡单必有来源:登记起单的来源就是登记人
|
||||
expect(inserted[0]?.source).toEqual({ kind: 'registration', ref: 'p1', by: 's1' });
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,17 @@ export type ProcessService = {
|
||||
terminate(workflowId: string, reason: string): Promise<WorkflowState>;
|
||||
};
|
||||
|
||||
/** 起单入参不合调用接口契约 —— 在建工作流之前判掉,不留半条在途单 */
|
||||
export class CallInterfaceViolationError extends Error {
|
||||
constructor(
|
||||
readonly packageId: string,
|
||||
readonly errors: readonly string[],
|
||||
) {
|
||||
super(`输入不合调用接口契约,不得起单:${errors.join('; ')}`);
|
||||
this.name = 'CallInterfaceViolationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NotPublishableError extends Error {
|
||||
constructor(readonly report: ClosureReport) {
|
||||
super(`流程包不闭合,不得发布:${report.violations.map((v) => v.message).join('; ')}`);
|
||||
|
||||
@@ -18,7 +18,7 @@ export type {
|
||||
WorkflowRecord,
|
||||
WorkflowState,
|
||||
} from './contract.ts';
|
||||
export { NotPublishableError, PositionVacantError } from './contract.ts';
|
||||
export { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
ActivityDirectory,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts';
|
||||
import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
|
||||
import { NotPublishableError, PositionVacantError } from '../../contract.ts';
|
||||
import { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from '../../contract.ts';
|
||||
import type {
|
||||
ActivityDirectory,
|
||||
ApprovalGateway,
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
OrgDirectory,
|
||||
ProcessStore,
|
||||
ReceiptNotifier,
|
||||
SchemaValidator,
|
||||
Sfeel,
|
||||
TaskGateway,
|
||||
WorkflowRecord,
|
||||
@@ -31,6 +32,7 @@ export type ProcessServiceDeps = {
|
||||
readonly tasks: TaskGateway;
|
||||
readonly approvals: ApprovalGateway;
|
||||
readonly sfeel: Sfeel;
|
||||
readonly schemas: SchemaValidator;
|
||||
readonly events: FlowEventPublisher;
|
||||
readonly receipts: ReceiptNotifier;
|
||||
readonly clock: Clock;
|
||||
@@ -216,6 +218,11 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
const pkg = await requirePackage(input.packageId, input.packageVersion);
|
||||
if (pkg.state !== 'published') throw new Error(`流程包未发布,不得起单:${pkg.id}@${pkg.version}`);
|
||||
|
||||
// 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。
|
||||
// (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。)
|
||||
const check = deps.schemas.validate(pkg.callInterface.input, input.data);
|
||||
if (!check.ok) throw new CallInterfaceViolationError(pkg.id, check.errors);
|
||||
|
||||
const record: WorkflowRecord = {
|
||||
id: deps.ids.next(),
|
||||
packageId: pkg.id,
|
||||
|
||||
@@ -82,6 +82,15 @@ export type ApprovalGateway = {
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 调用接口的入参校验 —— app 注入 ajv。
|
||||
* 「凡单必有来源」的另一半是「凡单必合契约」:不合契约**不得起单**,
|
||||
* 且必须在**建工作流之前**判掉,否则会留下一条永远推不动的在途单。
|
||||
*/
|
||||
export type SchemaValidator = {
|
||||
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
|
||||
};
|
||||
|
||||
/** S-FEEL —— app 注入 `@sfeel`:流转零 LLM */
|
||||
export type Sfeel = {
|
||||
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session, TaskCard, TaskDetail } from '@contracts';
|
||||
import { listMessagesRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, sessionRoute, taskDetailRoute } from '@contracts';
|
||||
import { listApprovalsForMeRoute, listMessagesRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, sessionRoute, taskDetailRoute } from '@contracts';
|
||||
import { TaskChat, TaskList, ViewRegion } 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 { useChatWidth } from '@web-shared/useChatWidth.ts';
|
||||
@@ -43,12 +47,14 @@ export function AppShell() {
|
||||
queryFn: () => call<{ tasks: TaskCard[] }>(listTasksRoute),
|
||||
});
|
||||
|
||||
// 角标常驻:责任面要一眼看见「什么在等我」,不能等切过去才知道
|
||||
const messages = useQuery({
|
||||
queryKey: queryKeys.messages,
|
||||
queryFn: () => call<{ messages: Array<{ id: string; title: string; body: string; at: string; read: boolean }> }>(
|
||||
listMessagesRoute,
|
||||
),
|
||||
enabled: module === 'messages',
|
||||
queryFn: () => call<{ messages: Array<{ id: string; read: boolean }> }>(listMessagesRoute),
|
||||
});
|
||||
const approvals = useQuery({
|
||||
queryKey: ['approvals'],
|
||||
queryFn: () => call<{ approvals: Array<{ state: string }> }>(listApprovalsForMeRoute),
|
||||
});
|
||||
|
||||
const list = tasks.data?.tasks ?? [];
|
||||
@@ -89,7 +95,14 @@ export function AppShell() {
|
||||
</div>
|
||||
<span className="jc-rail-brand">JCAI</span>
|
||||
{NAV_MODULES.map((m) => {
|
||||
const badge = m.key === 'tasks' ? active.length : undefined;
|
||||
const badge =
|
||||
m.key === 'tasks'
|
||||
? active.length
|
||||
: m.key === 'approvals'
|
||||
? (approvals.data?.approvals ?? []).filter((a) => a.state === 'pending').length
|
||||
: m.key === 'messages'
|
||||
? (messages.data?.messages ?? []).filter((x) => !x.read).length
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={m.key}
|
||||
@@ -161,34 +174,14 @@ export function AppShell() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : module === 'approvals' ? (
|
||||
<ApprovalDesk />
|
||||
) : module === 'messages' ? (
|
||||
<MessageDesk />
|
||||
) : module === 'minutes' ? (
|
||||
<MinuteDesk />
|
||||
) : (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<header className="jc-vr-head">
|
||||
<div className="jc-vr-title">
|
||||
<h1>{NAV_MODULES.find((m) => m.key === module)?.label}</h1>
|
||||
<span className="jc-vr-sub">工作台五要素 —— 同一份过程事实的五种投影</span>
|
||||
</div>
|
||||
</header>
|
||||
{module === 'messages' ? (
|
||||
<ul className="jc-msg-list">
|
||||
{(messages.data?.messages ?? []).map((m) => (
|
||||
<li key={m.id} className={m.read ? 'read' : ''}>
|
||||
<b>{m.title}</b>
|
||||
<p>{m.body}</p>
|
||||
<time className="jc-mono">{m.at.slice(0, 19).replace('T', ' ')}</time>
|
||||
</li>
|
||||
))}
|
||||
{(messages.data?.messages ?? []).length === 0 && (
|
||||
<li className="jc-list-empty">暂无消息</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="bar-chart" s={18} />
|
||||
该要素随 M6 后续接入
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<ReportDesk />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `desk-approval` 唯一出口(编码规范 §9.1)。
|
||||
* 审批点收件与裁决:通过 / 驳回,附依据;驳回走向声明式。
|
||||
*/
|
||||
export { ApprovalDesk } from './internal/ApprovalDesk.tsx';
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 审批要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
|
||||
* 的 `ApprovalsModule` / `ApprovalDetail` 平移;mock 数据换成服务端投影。
|
||||
*
|
||||
* 「工作台层面的『审批』**专指工作流级的审批点**」:它卡到审批人工位,产出审批单——
|
||||
* 通过 / 驳回,附依据。**没有预算、没有 Harness、没有任务日志,所以不是任务。**
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ApprovalItem, ListApprovalsForMeOutput } from '@contracts';
|
||||
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const STATE_CHIP = {
|
||||
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
|
||||
passed: { tone: 'ok' as const, label: '已通过', pulse: false },
|
||||
rejected: { tone: 'warn' as const, label: '已退回', pulse: false },
|
||||
};
|
||||
|
||||
export function ApprovalDesk() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selId, setSelId] = useState<string | undefined>(undefined);
|
||||
const [filter, setFilter] = useState<'todo' | 'done'>('todo');
|
||||
const [comment, setComment] = useState('');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['approvals'],
|
||||
queryFn: () => call<ListApprovalsForMeOutput>(listApprovalsForMeRoute),
|
||||
});
|
||||
const approvals = q.data?.approvals ?? [];
|
||||
const todo = approvals.filter((a) => a.state === 'pending');
|
||||
const done = approvals.filter((a) => a.state !== 'pending');
|
||||
const visible = filter === 'todo' ? todo : done;
|
||||
const sel = approvals.find((a) => a.id === selId) ?? visible[0];
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: (input: { item: ApprovalItem; verdict: 'pass' | 'reject' }) =>
|
||||
call(decideApprovalRoute, {
|
||||
params: { workflowId: input.item.workflowId, nodeId: input.item.nodeId },
|
||||
body: { verdict: input.verdict, ...(comment.trim() === '' ? {} : { comment: comment.trim() }) },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setComment('');
|
||||
setError(undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '裁决失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="workspace">
|
||||
<aside className="task-sidebar">
|
||||
<SidebarHead icon="file-check" title="审批中心" sub={`等我放行的事项 · ${todo.length} 件待批`} />
|
||||
<div className="jc-flt-wrap" aria-label="审批筛选">
|
||||
<div className="jc-flt" role="tablist" aria-label="按状态">
|
||||
<button className={filter === 'todo' ? 'active' : ''} onClick={() => setFilter('todo')}>
|
||||
<span>待我审批</span>
|
||||
<strong>{todo.length}</strong>
|
||||
</button>
|
||||
<button className={filter === 'done' ? 'active' : ''} onClick={() => setFilter('done')}>
|
||||
<span>已处理</span>
|
||||
<strong>{done.length}</strong>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{visible.map((a) => {
|
||||
const chip = STATE_CHIP[a.state];
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
className={'jc-mcard jc-acard' + (a.id === sel?.id ? ' selected' : '')}
|
||||
onClick={() => setSelId(a.id)}
|
||||
>
|
||||
<span className="jc-mcard-ico" style={{ background: 'linear-gradient(140deg, #3370ff, #245bdb)' }}>
|
||||
<Ico name="shield-check" s={15} />
|
||||
</span>
|
||||
<span className="jc-mcard-body">
|
||||
<span className="jc-mcard-top">
|
||||
<em>审批点签批</em>
|
||||
<Chip tone={chip.tone} label={chip.label} pulse={chip.pulse} />
|
||||
</span>
|
||||
<strong className="jc-mcard-title jc-clamp2">{a.title}</strong>
|
||||
<span className="jc-acard-from">
|
||||
<Ico name="workflow" s={12} />
|
||||
<b>{a.workflowSubject}</b>
|
||||
<time className="jc-mono">{a.raisedAt.slice(5, 16).replace('T', ' ')}</time>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && (
|
||||
<ListEmpty icon="circle-check">
|
||||
{filter === 'todo' ? '待批清空了 —— 没有等你放行的事项' : '暂无已处理记录'}
|
||||
</ListEmpty>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="shield-check">没有卡在你工位上的审批点</ListEmpty>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
<Hero
|
||||
grad={['#3370ff', '#245bdb']}
|
||||
icon="shield-check"
|
||||
kicker={`审批点 · ${sel.nodeId}`}
|
||||
title={sel.title}
|
||||
meta={
|
||||
<>
|
||||
<Ico name="workflow" s={14} />
|
||||
<b>{sel.workflowSubject}</b>
|
||||
<span className="jc-dim">工作流 {sel.workflowId.slice(0, 8)}</span>
|
||||
</>
|
||||
}
|
||||
chip={<Chip tone={STATE_CHIP[sel.state].tone} label={STATE_CHIP[sel.state].label} />}
|
||||
/>
|
||||
|
||||
<Section icon="clipboard-check" title="依据快照" extra="调用必附上下文 · 裸调用不得发出">
|
||||
<div className="jc-rows">
|
||||
{sel.basis.map((b) => (
|
||||
<div key={b.k} className="jc-row">
|
||||
<em>{b.k}</em>
|
||||
<b className="jc-mono">{b.v}</b>
|
||||
</div>
|
||||
))}
|
||||
{sel.basis.length === 0 && <span className="jc-dim">上游尚无输出口载荷</span>}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section icon="git-branch" title="驳回走向" extra="声明式 · 不由临场决定">
|
||||
<NoteBand>{sel.rejectRoute}</NoteBand>
|
||||
</Section>
|
||||
|
||||
{sel.state === 'pending' ? (
|
||||
<Section icon="pen-line" title="签批意见" extra="驳回必附意见">
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={3}
|
||||
placeholder="写下裁决依据(驳回时必填)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn"
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ item: sel, verdict: 'reject' })}
|
||||
>
|
||||
<Ico name="rotate-ccw" s={13} />
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn primary"
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ item: sel, verdict: 'pass' })}
|
||||
>
|
||||
<Ico name="check" s={13} />
|
||||
通过
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
) : (
|
||||
<div className="jc-mod-actions">
|
||||
<span className="jc-mod-donehint">
|
||||
<Ico name="circle-check" s={15} />
|
||||
已裁决 · 流程按声明继续
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DocPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `desk-message` 唯一出口(编码规范 §9.1)。
|
||||
* 消息:通知、跨任务征答就地作答、死信回执。
|
||||
*/
|
||||
export { MessageDesk } from './internal/MessageDesk.tsx';
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 消息要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
|
||||
* 的 `MessagesModule` / `MessageDetail` 平移;mock 数据换成服务端投影。
|
||||
*
|
||||
* 「消息是**信息**,不产生责任」:系统通知 + 他人任务发来的人类工具卡。
|
||||
* 跨任务卡**就地作答** —— 作答权限只及卡面与决策包,**不开放任务对话流**;
|
||||
* 任务要素永远只装「我负责的任务」,跨人卡是一次性的解答请求,不是责任转移。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { FormField, ListMessagesOutput, SystemMessageView } from '@contracts';
|
||||
import { answerMessageRoute, listMessagesRoute, markMessageReadRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
|
||||
notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' },
|
||||
elicitation: { icon: 'messages-square', grad: ['#3370ff', '#245bdb'], kicker: '跨任务征答' },
|
||||
'dead-letter': { icon: 'alert-triangle', grad: ['#ff8800', '#de7802'], kicker: '死信回执' },
|
||||
unowned: { icon: 'help-circle', grad: ['#f14ba9', '#c92d86'], kicker: '无主入站 · 待认领' },
|
||||
};
|
||||
const metaOf = (kind: string) => KIND_META[kind] ?? KIND_META['notice']!;
|
||||
|
||||
export function MessageDesk() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selId, setSelId] = useState<string | undefined>(undefined);
|
||||
const [filter, setFilter] = useState<'unread' | 'all'>('unread');
|
||||
|
||||
const q = useQuery({ queryKey: ['messages'], queryFn: () => call<ListMessagesOutput>(listMessagesRoute) });
|
||||
const messages = q.data?.messages ?? [];
|
||||
const unread = messages.filter((m) => !m.read);
|
||||
const visible = filter === 'unread' ? unread : messages;
|
||||
const sel = messages.find((m) => m.id === selId) ?? visible[0];
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (messageId: string) => call(markMessageReadRoute, { params: { messageId } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['messages'] }),
|
||||
});
|
||||
|
||||
const pick = (m: SystemMessageView) => {
|
||||
setSelId(m.id);
|
||||
if (!m.read) markRead.mutate(m.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workspace">
|
||||
<aside className="task-sidebar">
|
||||
<SidebarHead icon="messages-square" title="消息" sub={`与我相关 · ${unread.length} 条未读`} />
|
||||
<div className="jc-flt-wrap" aria-label="消息筛选">
|
||||
<div className="jc-flt" role="tablist" aria-label="按状态">
|
||||
<button className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>
|
||||
<span>未读</span>
|
||||
<strong>{unread.length}</strong>
|
||||
</button>
|
||||
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
|
||||
<span>全部</span>
|
||||
<strong>{messages.length}</strong>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{visible.map((m) => {
|
||||
const meta = metaOf(m.kind);
|
||||
const pending = m.card !== undefined && !m.card.answered;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
className={'jc-mcard' + (m.id === sel?.id ? ' selected' : '')}
|
||||
onClick={() => pick(m)}
|
||||
>
|
||||
<span className="jc-mcard-ico" style={{ background: `linear-gradient(140deg, ${meta.grad[0]}, ${meta.grad[1]})` }}>
|
||||
<Ico name={meta.icon} s={15} />
|
||||
</span>
|
||||
<span className="jc-mcard-body">
|
||||
<span className="jc-mcard-top">
|
||||
<em>{meta.kicker}</em>
|
||||
{pending ? (
|
||||
<Chip tone="todo" label="待作答" pulse />
|
||||
) : (
|
||||
<Chip tone={m.read ? 'done' : 'brand'} label={m.read ? '已读' : '未读'} pulse={!m.read} />
|
||||
)}
|
||||
</span>
|
||||
<strong className="jc-mcard-title jc-clamp2">{m.title}</strong>
|
||||
<span className="jc-mcard-foot">
|
||||
<time className="jc-mono">{m.at.slice(5, 16).replace('T', ' ')}</time>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && <ListEmpty icon="circle-check">没有{filter === 'unread' ? '未读' : ''}消息</ListEmpty>}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="messages-square">选一条消息</ListEmpty>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
<Hero
|
||||
grad={metaOf(sel.kind).grad}
|
||||
icon={metaOf(sel.kind).icon}
|
||||
kicker={metaOf(sel.kind).kicker}
|
||||
title={sel.title}
|
||||
meta={<time className="jc-mono">{sel.at.slice(0, 16).replace('T', ' ')}</time>}
|
||||
chip={<Chip tone={sel.read ? 'done' : 'brand'} label={sel.read ? '已读' : '未读'} />}
|
||||
/>
|
||||
|
||||
<Section icon="file-text" title="正文">
|
||||
<div className="jc-summary">{sel.body}</div>
|
||||
</Section>
|
||||
|
||||
{sel.card !== undefined && (
|
||||
<Section icon="messages-square" title="就地作答" extra={`来源任务 ${sel.card.sourceTaskId.slice(0, 8)}`}>
|
||||
{sel.card.rejectedReason !== undefined && (
|
||||
<NoteBand>被驳回重答:{sel.card.rejectedReason}(原卡已闭合,这是新的一条)</NoteBand>
|
||||
)}
|
||||
{sel.card.answered ? (
|
||||
<div className="jc-mod-donehint">
|
||||
<Ico name="circle-check" s={15} />
|
||||
已作答 · 回传已写回来源任务并留痕
|
||||
</div>
|
||||
) : (
|
||||
<ElicitationForm
|
||||
ask={sel.card.ask}
|
||||
fields={sel.card.fields}
|
||||
onAnswer={(answer) =>
|
||||
call(answerMessageRoute, { params: { messageId: sel.id }, body: { answer } }).then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['messages'] });
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NoteBand>
|
||||
跨人卡是<b>一次性的解答请求,不是责任转移</b>:作答权限只及卡面与决策包,
|
||||
不开放任务对话流;它不占你的任务清单。
|
||||
</NoteBand>
|
||||
</Section>
|
||||
)}
|
||||
</DocPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡面表单:字段由 `@contracts/card.ts` 的 schema 驱动渲染,与后端回传校验同 schema(§9.2)。
|
||||
*/
|
||||
function ElicitationForm({
|
||||
ask,
|
||||
fields,
|
||||
onAnswer,
|
||||
}: {
|
||||
ask: string;
|
||||
fields: readonly FormField[];
|
||||
onAnswer: (answer: Record<string, unknown>) => Promise<unknown>;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 必填未回填不得提交 —— 与后端回传校验同一条规矩
|
||||
const missing = fields.filter((f) => f.required === true && (values[f.key] ?? '').trim() === '');
|
||||
|
||||
return (
|
||||
<>
|
||||
{ask !== '' && <p className="jc-ask">{ask}</p>}
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="jc-field">
|
||||
<em>
|
||||
{f.label}
|
||||
{f.required === true && <i className="jc-req">必填</i>}
|
||||
</em>
|
||||
{f.kind === 'textarea' ? (
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={3}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="jc-input"
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn primary"
|
||||
disabled={busy || missing.length > 0}
|
||||
title={missing.length > 0 ? `还缺:${missing.map((f) => f.label).join('、')}` : undefined}
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
onAnswer(values)
|
||||
.catch((e: unknown) => setError((e as { message?: string }).message ?? '回传失败'))
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
>
|
||||
<Ico name="check" s={13} />
|
||||
提交回传
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `desk-minute` 唯一出口(编码规范 §9.1)。
|
||||
* 纪要:团队主动沉淀;决议经登记触发起单,登记人即来源。
|
||||
*/
|
||||
export { MinuteDesk } from './internal/MinuteDesk.tsx';
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 纪要要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
|
||||
* 的 `MinutesModule` 平移;mock 数据换成服务端投影。
|
||||
*
|
||||
* 「纪要是工作台五要素里**唯一的主动动作**」——团队沉淀;
|
||||
* 决议命中**登记触发**即起单,「**登记人即来源**」(凡单必有来源)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ListMinutesOutput, MinuteView } from '@contracts';
|
||||
import { appendResolutionRoute, createMinuteRoute, listMinutesRoute, registerResolutionRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Avatar, AvatarStack, Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
export function MinuteDesk() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selId, setSelId] = useState<string | undefined>(undefined);
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [draft, setDraft] = useState({ title: '', body: '', occurredOn: '', resolution: '' });
|
||||
const [registerFor, setRegisterFor] = useState<string | undefined>(undefined);
|
||||
const [packageId, setPackageId] = useState('unified-quote');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const q = useQuery({ queryKey: ['minutes'], queryFn: () => call<ListMinutesOutput>(listMinutesRoute) });
|
||||
const minutes = q.data?.minutes ?? [];
|
||||
const sel: MinuteView | undefined = minutes.find((m) => m.id === selId) ?? minutes[0];
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['minutes'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
call<MinuteView>(createMinuteRoute, {
|
||||
body: {
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
occurredOn: draft.occurredOn,
|
||||
participants: [],
|
||||
resolutions: draft.resolution.trim() === '' ? [] : [{ text: draft.resolution.trim() }],
|
||||
},
|
||||
}),
|
||||
onSuccess: (m) => {
|
||||
setComposing(false);
|
||||
setDraft({ title: '', body: '', occurredOn: '', resolution: '' });
|
||||
setSelId(m.id);
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const append = useMutation({
|
||||
mutationFn: (input: { minuteId: string; text: string }) =>
|
||||
call<MinuteView>(appendResolutionRoute, { params: { minuteId: input.minuteId }, body: { text: input.text } }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const register = useMutation({
|
||||
mutationFn: (resolutionId: string) =>
|
||||
call<{ workflowId: string }>(registerResolutionRoute, {
|
||||
params: { resolutionId },
|
||||
body: { processPackageId: packageId, data: {} },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setRegisterFor(undefined);
|
||||
setError(undefined);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '登记起单失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="workspace">
|
||||
<aside className="task-sidebar">
|
||||
<SidebarHead icon="notebook-pen" title="会议纪要" sub={`团队沉淀 · ${minutes.length} 场`} />
|
||||
<div className="jc-flt-wrap">
|
||||
<button className="card-btn primary" onClick={() => setComposing(true)}>
|
||||
<Ico name="plus" s={13} />
|
||||
新建纪要
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{minutes.map((m) => {
|
||||
const open = m.resolutions.filter((r) => r.state === 'open').length;
|
||||
const [, month, day] = m.occurredOn.split('-');
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
className={'jc-mcard' + (m.id === sel?.id ? ' selected' : '')}
|
||||
onClick={() => setSelId(m.id)}
|
||||
>
|
||||
<span className="jc-mcard-datebox">
|
||||
<b>{day ?? '—'}</b>
|
||||
<i>{month ?? '—'} 月</i>
|
||||
</span>
|
||||
<span className="jc-mcard-body">
|
||||
<span className="jc-mcard-top">
|
||||
<em>{m.resolutions.length} 条决议</em>
|
||||
<Chip
|
||||
tone={open > 0 ? 'todo' : 'ok'}
|
||||
label={open > 0 ? `${open} 条待登记` : '决议已登记'}
|
||||
pulse={open > 0}
|
||||
/>
|
||||
</span>
|
||||
<strong className="jc-mcard-title jc-clamp2">{m.title}</strong>
|
||||
<span className="jc-mcard-foot">
|
||||
<Avatar name={m.authorName} size={14} />
|
||||
记录 {m.authorName}
|
||||
<i>{m.participants.length} 人</i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{minutes.length === 0 && <ListEmpty icon="notebook-pen">还没有纪要 —— 新建一场把团队沉淀记下来</ListEmpty>}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{composing ? (
|
||||
<DocPanel scrollKey="new">
|
||||
<Hero
|
||||
grad={['#7f3bf5', '#5b21cf']}
|
||||
icon="notebook-pen"
|
||||
kicker="纪要 · 团队沉淀"
|
||||
title="新建纪要"
|
||||
meta={<span className="jc-dim">纪要本身不产生责任;责任只经决议登记起单产生</span>}
|
||||
/>
|
||||
<Section icon="pen-line" title="纪要正文">
|
||||
<input
|
||||
className="jc-input"
|
||||
placeholder="标题,如「7 月报价例会」"
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="jc-input"
|
||||
placeholder="会议日期 YYYY-MM-DD"
|
||||
value={draft.occurredOn}
|
||||
onChange={(e) => setDraft({ ...draft, occurredOn: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={5}
|
||||
placeholder="讨论内容"
|
||||
value={draft.body}
|
||||
onChange={(e) => setDraft({ ...draft, body: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
<Section icon="git-branch" title="决议(可留空)" extra="决议可经登记触发转为工作流的启动">
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={2}
|
||||
placeholder="一条决议"
|
||||
value={draft.resolution}
|
||||
onChange={(e) => setDraft({ ...draft, resolution: e.target.value })}
|
||||
/>
|
||||
</Section>
|
||||
<div className="jc-mod-actions">
|
||||
<button type="button" className="card-btn" onClick={() => setComposing(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn primary"
|
||||
disabled={draft.title.trim() === '' || draft.occurredOn.trim() === '' || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<Ico name="check" s={13} />
|
||||
保存纪要
|
||||
</button>
|
||||
</div>
|
||||
</DocPanel>
|
||||
) : sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="notebook-pen">选一场纪要,或新建一场</ListEmpty>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
<Hero
|
||||
grad={['#7f3bf5', '#5b21cf']}
|
||||
icon="notebook-pen"
|
||||
kicker="纪要 · 团队沉淀"
|
||||
title={sel.title}
|
||||
meta={
|
||||
<>
|
||||
<span className="jc-mono">{sel.occurredOn}</span>
|
||||
<Avatar name={sel.authorName} size={20} />
|
||||
<b>{sel.authorName}</b>
|
||||
<span className="jc-dim">记录</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{sel.participants.length > 0 && (
|
||||
<div className="jc-attendees">
|
||||
<AvatarStack names={sel.participants} />
|
||||
<span className="jc-attendees-txt">
|
||||
<strong>出席 {sel.participants.length} 人</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section icon="file-text" title="纪要正文">
|
||||
<div className="jc-summary">{sel.body}</div>
|
||||
</Section>
|
||||
|
||||
<Section icon="git-branch" title="决议清单" extra="命中登记触发即起单 · 登记人即来源">
|
||||
<div className="jc-res-list">
|
||||
{sel.resolutions.map((r, i) => (
|
||||
<div key={r.id} className="jc-res">
|
||||
<span className="jc-res-n">{i + 1}</span>
|
||||
<p>{r.text}</p>
|
||||
{r.state === 'registered' ? (
|
||||
<span className="jc-chip t-ok">
|
||||
<Ico name="check" s={11} />
|
||||
已起单 · {r.workflowId?.slice(0, 8)}
|
||||
</span>
|
||||
) : registerFor === r.id ? (
|
||||
<span className="jc-res-reg">
|
||||
<input
|
||||
className="jc-input"
|
||||
value={packageId}
|
||||
onChange={(e) => setPackageId(e.target.value)}
|
||||
placeholder="流程包 id"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn primary"
|
||||
disabled={register.isPending}
|
||||
onClick={() => register.mutate(r.id)}
|
||||
>
|
||||
提交起单
|
||||
</button>
|
||||
<button type="button" className="card-btn" onClick={() => setRegisterFor(undefined)}>
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="card-btn primary" onClick={() => setRegisterFor(r.id)}>
|
||||
<Ico name="pen-line" s={13} />
|
||||
登记起单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sel.resolutions.length === 0 && <span className="jc-dim">这场纪要还没有决议</span>}
|
||||
</div>
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
<NoteBand>
|
||||
登记触发 = 人工起单的统一形态:选流程包 → 按调用接口 schema 填表(表单自动渲染)→
|
||||
提交即起单;这条工作流的来源就记为登记人。
|
||||
</NoteBand>
|
||||
</Section>
|
||||
|
||||
<Section icon="plus" title="追加决议" extra="只增不改 · 纪要是沉淀,不是可覆写的文档">
|
||||
<AppendResolution onAppend={(text) => append.mutate({ minuteId: sel.id, text })} busy={append.isPending} />
|
||||
</Section>
|
||||
</DocPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void; busy: boolean }) {
|
||||
const [text, setText] = useState('');
|
||||
return (
|
||||
<div className="jc-res-append">
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={2}
|
||||
placeholder="新增一条决议"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="card-btn primary"
|
||||
disabled={text.trim() === '' || busy}
|
||||
onClick={() => {
|
||||
onAppend(text.trim());
|
||||
setText('');
|
||||
}}
|
||||
>
|
||||
<Ico name="plus" s={13} />
|
||||
追加
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `desk-report` 唯一出口(编码规范 §9.1)。
|
||||
* 汇报:日 / 周 / 月 / 年聚合;确认即提交,零手工汇报。
|
||||
*/
|
||||
export { ReportDesk } from './internal/ReportDesk.tsx';
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 汇报要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
|
||||
* 的 `ReportModule` 平移;mock 数据换成服务端投影。
|
||||
*
|
||||
* 「按日 / 周 / 月 / 年**自动**总结此员工的工作——由本人各任务的**任务日志**聚合生成,
|
||||
* 确认即提交,**零手工汇报**。」结构化部分零 LLM(事件流骨架 + 归侧分栏)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ReportPeriodName, ReportView } from '@contracts';
|
||||
import { REPORT_PERIOD_LABEL, draftReportRoute, submitReportRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
|
||||
{ k: 'day', ico: 'calendar-days' },
|
||||
{ k: 'week', ico: 'calendar-clock' },
|
||||
{ k: 'month', ico: 'calendar-days' },
|
||||
{ k: 'year', ico: 'history' },
|
||||
];
|
||||
|
||||
const pct = (v: number): string => `${Math.round(v * 100)}%`;
|
||||
|
||||
export function ReportDesk() {
|
||||
const queryClient = useQueryClient();
|
||||
const [period, setPeriod] = useState<ReportPeriodName>('day');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['report', period],
|
||||
queryFn: () => call<ReportView>(draftReportRoute, { params: { period } }),
|
||||
});
|
||||
const report = q.data;
|
||||
const submitted = report?.submittedAt !== undefined;
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () =>
|
||||
call<ReportView>(submitReportRoute, {
|
||||
params: { reportId: report!.id },
|
||||
body: note.trim() === '' ? {} : { note: note.trim() },
|
||||
}),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['report'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="workspace">
|
||||
<aside className="task-sidebar">
|
||||
<SidebarHead icon="bar-chart" title="工作汇报" sub="任务日志聚合 · 零手工汇报" />
|
||||
<div className="jc-flt-wrap" aria-label="汇报周期">
|
||||
<div className="jc-flt" role="tablist" aria-label="按周期">
|
||||
{PERIODS.map((p) => (
|
||||
<button key={p.k} className={period === p.k ? 'active' : ''} onClick={() => setPeriod(p.k)}>
|
||||
<span>{REPORT_PERIOD_LABEL[p.k]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{report !== undefined && (
|
||||
<button className="jc-mcard selected">
|
||||
<span className="jc-mcard-datebox">
|
||||
<b>{report.window.from.slice(8, 10)}</b>
|
||||
<i>{report.window.from.slice(5, 7)} 月</i>
|
||||
</span>
|
||||
<span className="jc-mcard-body">
|
||||
<span className="jc-mcard-top">
|
||||
<em>{report.title}</em>
|
||||
<Chip tone={submitted ? 'ok' : 'todo'} label={submitted ? '已提交' : '待确认'} pulse={!submitted} />
|
||||
</span>
|
||||
<strong className="jc-mcard-title jc-wrap">
|
||||
经手 {report.counts.touched} 单 · 放行 {report.counts.released} 单
|
||||
</strong>
|
||||
<span className="jc-mcard-foot">
|
||||
<Ico name="git-branch" s={12} />
|
||||
{report.counts.logLines} 条动作
|
||||
<i>系统生成</i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{report === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="bar-chart">正在聚合…</ListEmpty>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={`${period}:${report.id}`}>
|
||||
<Hero
|
||||
grad={['#0aa294', '#0a6a64']}
|
||||
icon="bar-chart"
|
||||
kicker={`汇报 · ${REPORT_PERIOD_LABEL[period]}`}
|
||||
title={report.title}
|
||||
meta={
|
||||
<>
|
||||
<Ico name="bot" s={14} />
|
||||
<b>系统自动生成</b>
|
||||
<span className="jc-dim">由本人各任务的任务日志聚合</span>
|
||||
</>
|
||||
}
|
||||
chip={<Chip tone={submitted ? 'ok' : 'todo'} label={submitted ? '已提交' : '待确认'} pulse={!submitted} />}
|
||||
/>
|
||||
|
||||
<Section icon="bar-chart" title="度量八指标(零埋点 · 口径挂在归侧标签上)" extra={`共 ${report.counts.logLines} 条日志`}>
|
||||
<div className="jc-stats">
|
||||
{[
|
||||
{ ico: 'bot', label: '自主率', v: pct(report.metrics.autonomy) },
|
||||
{ ico: 'rotate-ccw', label: '纠正率', v: pct(report.metrics.correction) },
|
||||
{ ico: 'gauge', label: '有效自主率', v: pct(report.metrics.effectiveAutonomy) },
|
||||
{ ico: 'hand', label: '打扰率', v: report.metrics.interruption.toFixed(1) },
|
||||
{ ico: 'git-commit', label: 'DMN 覆盖率', v: pct(report.metrics.dmnCoverage) },
|
||||
{ ico: 'circle-dot', label: '断点率', v: pct(report.metrics.breakpoint) },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="jc-stat">
|
||||
<span className="jc-stat-ico">
|
||||
<Ico name={s.ico} s={15} />
|
||||
</span>
|
||||
<span className="jc-stat-v">{s.v}</span>
|
||||
<span className="jc-stat-k">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<NoteBand>
|
||||
自主率 = 机器产出 ÷(机器产出 + 人产出),裁决与等待不进分母;它与纠正率
|
||||
<b>成对读</b>,复合为有效自主率 —— 单看任一都会被优化坏。
|
||||
单位成本 {Math.round(report.metrics.unitCost.tokens)} token /
|
||||
{report.metrics.unitCost.minutes.toFixed(1)} 分钟每放行单,分母只认放行通过。
|
||||
</NoteBand>
|
||||
</Section>
|
||||
|
||||
<Section icon="clock" title="动作明细(结构化日志 · 零 LLM)" extra={`${report.sections.length} 单任务`}>
|
||||
{report.sections.map((s) => (
|
||||
<div key={s.taskId} className="jc-axis">
|
||||
<div className="jc-sec-head">
|
||||
<Ico name="list-checks" s={13} />
|
||||
<span>{s.title}</span>
|
||||
{s.terminalLabel !== undefined && <span className="jc-sec-extra">{s.terminalLabel}</span>}
|
||||
</div>
|
||||
{s.lines.map((line, i) => (
|
||||
<div key={i} className="jc-axis-row">
|
||||
<span className="jc-axis-node" />
|
||||
<p>{line}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{report.sections.length === 0 && <span className="jc-dim">这个周期内没有动作</span>}
|
||||
</Section>
|
||||
|
||||
<Section icon="pen-line" title="员工补充" extra="可选 · 系统不要求手工写汇报">
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={3}
|
||||
disabled={submitted}
|
||||
placeholder="补充主观判断或下阶段计划(可留空)"
|
||||
value={submitted ? (report.note ?? '') : note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="jc-mod-actions">
|
||||
{submitted ? (
|
||||
<span className="jc-mod-donehint">
|
||||
<Ico name="circle-check" s={15} />
|
||||
已确认提交 · 汇报进入组织视图
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="card-btn primary" disabled={submit.isPending} onClick={() => submit.mutate()}>
|
||||
<Ico name="check" s={13} />
|
||||
确认提交
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DocPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 工作台后四要素的共用小件 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
|
||||
* 第 21–160 行平移。**DOM 与 `jc-*` 类名原样保留**(README 平移规则 1/2/4)。
|
||||
*
|
||||
* 四要素同款三栏布局:左清单 `task-sidebar` / 中文档卡 `execution-panel` / 右会话 `session-chat`。
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
|
||||
/** 首字头像:名字 hash 取渐变色;外文名取首字母大写 */
|
||||
const AVA_GRAD: ReadonlyArray<readonly [string, string]> = [
|
||||
['#2bc7d4', '#0e9fd8'],
|
||||
['#3370ff', '#245bdb'],
|
||||
['#7f3bf5', '#5b21cf'],
|
||||
['#ff8800', '#de7802'],
|
||||
['#0aa294', '#0a6a64'],
|
||||
['#f14ba9', '#c92d86'],
|
||||
];
|
||||
|
||||
function avaOf(name: string): { ch: string; grad: readonly [string, string] } {
|
||||
let h = 0;
|
||||
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
const first = name[0] ?? '·';
|
||||
const ch = /[A-Za-z]/.test(first) ? first.toUpperCase() : first;
|
||||
return { ch, grad: AVA_GRAD[h % AVA_GRAD.length]! };
|
||||
}
|
||||
|
||||
export function Avatar({ name, size = 30 }: { name: string; size?: number }) {
|
||||
const { ch, grad } = avaOf(name);
|
||||
return (
|
||||
<span
|
||||
className="jc-ava"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: size * 0.42,
|
||||
background: `linear-gradient(140deg, ${grad[0]}, ${grad[1]})`,
|
||||
}}
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvatarStack({ names, size = 26 }: { names: readonly string[]; size?: number }) {
|
||||
const show = names.slice(0, 5);
|
||||
return (
|
||||
<span className="jc-ava-stack">
|
||||
{show.map((n, i) => (
|
||||
<span key={i} className="jc-ava-slot">
|
||||
<Avatar name={n} size={size} />
|
||||
</span>
|
||||
))}
|
||||
{names.length > show.length && <span className="jc-ava-slot jc-ava-more">+{names.length - show.length}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type Tone = 'todo' | 'doing' | 'done' | 'warn' | 'brand' | 'ok';
|
||||
|
||||
export function Chip({ tone, label, pulse }: { tone: Tone; label: string; pulse?: boolean }) {
|
||||
return (
|
||||
<span className={'jc-chip t-' + tone}>
|
||||
{pulse && <i />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 文档头(渐变图标块 + 标题 + 元信息 + 右上状态章)—— 统一容器,彻底告别贴边 */
|
||||
export function Hero({
|
||||
grad,
|
||||
icon,
|
||||
kicker,
|
||||
title,
|
||||
meta,
|
||||
chip,
|
||||
}: {
|
||||
grad: readonly [string, string];
|
||||
icon: string;
|
||||
kicker?: string;
|
||||
title: string;
|
||||
meta: ReactNode;
|
||||
chip?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="jc-hero">
|
||||
<span className="jc-hero-ico" style={{ background: `linear-gradient(140deg, ${grad[0]}, ${grad[1]})` }}>
|
||||
<Ico name={icon} s={22} />
|
||||
</span>
|
||||
<div className="jc-hero-txt">
|
||||
{kicker && <span className="jc-hero-kicker">{kicker}</span>}
|
||||
<h2>{title}</h2>
|
||||
<div className="jc-hero-meta">{meta}</div>
|
||||
</div>
|
||||
{chip && <div className="jc-hero-chip">{chip}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({
|
||||
icon,
|
||||
title,
|
||||
extra,
|
||||
children,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="jc-sec">
|
||||
<div className="jc-sec-head">
|
||||
<Ico name={icon} s={13} />
|
||||
<span>{title}</span>
|
||||
{extra && <span className="jc-sec-extra">{extra}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 侧栏表头 —— 四要素共用 */
|
||||
export function SidebarHead({ icon, title, sub }: { icon: string; title: string; sub: string }) {
|
||||
return (
|
||||
<div className="sidebar-head jc-wl-head">
|
||||
<span className="jc-wl-ico">
|
||||
<Ico name={icon} s={16} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<span>{sub}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListEmpty({ icon, children }: { icon: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name={icon} s={22} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 中栏文档卡的滚动容器 */
|
||||
export function DocPanel({ scrollKey, children }: { scrollKey: string; children: ReactNode }) {
|
||||
return (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ScrollArea key={scrollKey} className="jc-sa--full" vpClassName="jc-mod-scroll">
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 说明带 —— 原型里用来点明规范口径的那条浅色横条。
|
||||
* 保留它不是为了好看:这些句子是「为什么这么设计」的现场注解。
|
||||
*/
|
||||
export function NoteBand({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="jc-notrig">
|
||||
<Ico name="info" s={15} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user