Archived
基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/** feature: auth —— 唯一出口(编码规范 §9.1)。 */
|
||||
export { LoginPage } from './internal/LoginPage.tsx';
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 登录页(ADR-010)—— 两份原型都没有这一屏,是新造的。
|
||||
*
|
||||
* 它在两个台面**之前**渲染,拿不到任一台面的皮肤(工作台补充样式通篇作用域 `.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';
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jc-login">
|
||||
<form
|
||||
className="jc-login-card"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<button className="jc-login-go" type="submit" disabled={busy || !ready}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
</button>
|
||||
<p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ 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';
|
||||
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const STATE_CHIP = {
|
||||
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
|
||||
@@ -96,8 +96,10 @@ export function ApprovalDesk() {
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && (
|
||||
<ListEmpty icon="circle-check">
|
||||
{filter === 'todo' ? '待批清空了 —— 没有等你放行的事项' : '暂无已处理记录'}
|
||||
<ListEmpty icon="circle-check" title={filter === 'todo' ? '待批清空了' : '还没有处理记录'}>
|
||||
{filter === 'todo'
|
||||
? '没有等你放行的事项 —— 有新的审批单会第一时间到这里。'
|
||||
: '你处理过的审批会留在这里,随时回看当时的依据与裁决。'}
|
||||
</ListEmpty>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@@ -105,7 +107,12 @@ export function ApprovalDesk() {
|
||||
|
||||
{sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="shield-check">没有卡在你工位上的审批点</ListEmpty>
|
||||
<EmptyState
|
||||
ico="shield-check"
|
||||
tone="ok"
|
||||
title="没有等你放行的事项"
|
||||
desc="流程走到审批点时,审批单会连同它的依据一起送到这里 —— 你只需要看一眼就能拍板。"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
@@ -125,10 +132,10 @@ export function ApprovalDesk() {
|
||||
/>
|
||||
|
||||
<Section icon="clipboard-check" title="依据快照" extra="调用必附上下文 · 裸调用不得发出">
|
||||
<div className="jc-rows">
|
||||
<div className="jc-kv">
|
||||
{sel.basis.map((b) => (
|
||||
<div key={b.k} className="jc-row">
|
||||
<em>{b.k}</em>
|
||||
<div key={b.k} className="jc-kv-i">
|
||||
<i>{b.k}</i>
|
||||
<b className="jc-mono">{b.v}</b>
|
||||
</div>
|
||||
))}
|
||||
@@ -149,7 +156,7 @@ export function ApprovalDesk() {
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { answerMessageRoute, listMessagesRoute, markMessageReadRoute } from '@co
|
||||
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';
|
||||
import { Chip, DocPanel, Hero, EmptyState, 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: '系统 · 通知' },
|
||||
@@ -90,13 +90,22 @@ export function MessageDesk() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && <ListEmpty icon="circle-check">没有{filter === 'unread' ? '未读' : ''}消息</ListEmpty>}
|
||||
{visible.length === 0 && (
|
||||
<ListEmpty icon="circle-check" title={filter === 'unread' ? '没有未读' : '还没有消息'}>
|
||||
别人请你补的信息、系统的回执与提醒,到了都会亮在这里。
|
||||
</ListEmpty>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="messages-square">选一条消息</ListEmpty>
|
||||
<EmptyState
|
||||
ico="messages-square"
|
||||
tone="brand"
|
||||
title="没有新消息"
|
||||
desc="别人请你补的信息、系统的回执与提醒都会到这里。需要你答的,就地答完即可,不用跑去别人的任务里。"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
@@ -183,14 +192,14 @@ function ElicitationForm({
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { appendResolutionRoute, createMinuteRoute, listMinutesRoute, registerRes
|
||||
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';
|
||||
import { Avatar, AvatarStack, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
export function MinuteDesk() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -114,7 +114,11 @@ export function MinuteDesk() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{minutes.length === 0 && <ListEmpty icon="notebook-pen">还没有纪要 —— 新建一场把团队沉淀记下来</ListEmpty>}
|
||||
{minutes.length === 0 && (
|
||||
<ListEmpty icon="notebook-pen" title="还没有纪要">
|
||||
记下一场会,把定下的事写成决议 —— 登记起单后责任才落到人。
|
||||
</ListEmpty>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
@@ -129,13 +133,13 @@ export function MinuteDesk() {
|
||||
/>
|
||||
<Section icon="pen-line" title="纪要正文">
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
placeholder="标题,如「7 月报价例会」"
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
placeholder="会议日期 YYYY-MM-DD"
|
||||
value={draft.occurredOn}
|
||||
onChange={(e) => setDraft({ ...draft, occurredOn: e.target.value })}
|
||||
@@ -174,7 +178,13 @@ export function MinuteDesk() {
|
||||
</DocPanel>
|
||||
) : sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="notebook-pen">选一场纪要,或新建一场</ListEmpty>
|
||||
<EmptyState
|
||||
ico="notebook-pen"
|
||||
tone="brand"
|
||||
title="还没有会议纪要"
|
||||
desc="记下一场会,把会上定的事写成决议 —— 纪要是团队沉淀,决议登记起单才产生责任。"
|
||||
hint="左上角「新建纪要」开始记"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
@@ -218,9 +228,9 @@ export function MinuteDesk() {
|
||||
已起单 · {r.workflowId?.slice(0, 8)}
|
||||
</span>
|
||||
) : registerFor === r.id ? (
|
||||
<span className="jc-res-reg">
|
||||
<span className="jc-custom-row">
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
value={packageId}
|
||||
onChange={(e) => setPackageId(e.target.value)}
|
||||
placeholder="流程包 id"
|
||||
@@ -247,7 +257,7 @@ export function MinuteDesk() {
|
||||
))}
|
||||
{sel.resolutions.length === 0 && <span className="jc-dim">这场纪要还没有决议</span>}
|
||||
</div>
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
|
||||
<NoteBand>
|
||||
登记触发 = 人工起单的统一形态:选流程包 → 按调用接口 schema 填表(表单自动渲染)→
|
||||
提交即起单;这条工作流的来源就记为登记人。
|
||||
@@ -266,7 +276,7 @@ export function MinuteDesk() {
|
||||
function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void; busy: boolean }) {
|
||||
const [text, setText] = useState('');
|
||||
return (
|
||||
<div className="jc-res-append">
|
||||
<div className="jc-cfill">
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={2}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { REPORT_PERIOD_LABEL, draftReportRoute, submitReportRoute } from '@contr
|
||||
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';
|
||||
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
|
||||
{ k: 'day', ico: 'calendar-days' },
|
||||
@@ -58,7 +58,13 @@ export function ReportDesk() {
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{report !== undefined && (
|
||||
{/* 没放行过一件的草案不上榜 —— 榜上一条 = 一份真有内容的汇报 */}
|
||||
{(report === undefined || report.counts.released === 0) && (
|
||||
<ListEmpty icon="bar-chart" title="本期还没有汇报">
|
||||
办成的事会按{REPORT_PERIOD_LABEL[period]}自动聚成一份,你确认即提交。
|
||||
</ListEmpty>
|
||||
)}
|
||||
{report !== undefined && report.counts.released > 0 && (
|
||||
<button className="jc-mcard selected">
|
||||
<span className="jc-mcard-datebox">
|
||||
<b>{report.window.from.slice(8, 10)}</b>
|
||||
@@ -83,9 +89,18 @@ export function ReportDesk() {
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{report === undefined ? (
|
||||
{/*
|
||||
汇报是「我做成了什么」,不是「我在忙什么」—— 一件都还没放行,就没有可汇报的。
|
||||
这不是占位:它是这个台面**真正的空态**,办成第一件事的当天就自动有了内容。
|
||||
*/}
|
||||
{report === undefined || report.counts.released === 0 ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="bar-chart">正在聚合…</ListEmpty>
|
||||
<EmptyState
|
||||
ico="bar-chart"
|
||||
tone="brand"
|
||||
title="还没有可汇报的内容"
|
||||
desc="日报、周报、月报由系统按任务日志自动聚合,不用你手写 —— 办成第一件事,这里当天就有了。"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={`${period}:${report.id}`}>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* feature: desk-task —— 唯一出口(编码规范 §9.1)。
|
||||
* 任务要素四区:任务列表 / 标题区 / 工作区(工单两模式)/ 对话区(卡族 + composer 锁)。
|
||||
*
|
||||
* 工作台正处在「一步一步描述、一步一步接」的阶段:
|
||||
* 已挂载的是三区的**框架**(任务清单 / 工作区 / 对话区),各区正文按描述逐个接回。
|
||||
* 下面两组是**备着待接**的渲染件,自原型平移、已按皮肤对齐,接回时直接用或按新口径改。
|
||||
*/
|
||||
export type { TaskCard, TaskDetail } from './contract.ts';
|
||||
export { TaskList } from './internal/TaskList.tsx';
|
||||
export { ViewRegion } from './internal/ViewRegion.tsx';
|
||||
export { TaskChat } from './internal/TaskChat.tsx';
|
||||
|
||||
/* ── 备着待接 ─────────────────────────────────────────────────────── */
|
||||
export { WorkOrderReport, GuideCard } from './internal/work-order-report.tsx';
|
||||
export { ChatStream } from './internal/chat-bubbles.tsx';
|
||||
export { FileViewer } from './internal/FileViewer.tsx';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 成果文件的二级查看界面 —— 自原型 `_components/FileViewer.tsx` 平移。
|
||||
*
|
||||
* 按文件类型选渲染器,内容驱动、不解析二进制:
|
||||
* html → **容器沙箱**(`@ui` 的 ViewContainer,无脚本执行权,AM 07)
|
||||
* excel → 电子表格网格 · word → A4 文档 · pdf → 内嵌 · markdown → 原文纸面
|
||||
*/
|
||||
import type { FileDoc } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ViewContainer } from '@ui';
|
||||
|
||||
const KIND: Readonly<Record<FileDoc['kind'], { label: string; icon: string }>> = {
|
||||
html: { label: 'HTML', icon: 'file-text' },
|
||||
markdown: { label: 'Markdown', icon: 'file-text' },
|
||||
excel: { label: 'Excel', icon: 'file-spreadsheet' },
|
||||
word: { label: 'Word', icon: 'file-type' },
|
||||
pdf: { label: 'PDF', icon: 'file-text' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 二级界面**长在工作区内部**:顶部四个页签始终不变,
|
||||
* 「返回」由页签条右槽给出(见 ViewRegion)—— 所以这里的标题栏不带返回钮,只报文件本身。
|
||||
*/
|
||||
export function FileViewer({ doc }: { doc: FileDoc }) {
|
||||
const k = KIND[doc.kind];
|
||||
return (
|
||||
<div className={'fileview k-' + doc.kind}>
|
||||
<div className="fileview-bar">
|
||||
<div className="fileview-name">
|
||||
<Ico name={k.icon} s={15} />
|
||||
<strong>{doc.name}</strong>
|
||||
</div>
|
||||
<span className={'fileview-kind k-' + doc.kind}>{k.label}</span>
|
||||
</div>
|
||||
<div className="fileview-body">
|
||||
{doc.kind === 'html' && <ViewContainer html={doc.html} title={doc.name} />}
|
||||
{doc.kind === 'markdown' && (
|
||||
// 富文本渲染(GFM 表格 / 内联 SVG)尚未接入 —— 先以原文纸面呈现,不假装渲染过(见 debt)
|
||||
<article className="doc-paper doc-full">
|
||||
<pre className="md">{doc.md}</pre>
|
||||
</article>
|
||||
)}
|
||||
{doc.kind === 'excel' && <ExcelView doc={doc} />}
|
||||
{doc.kind === 'word' && <WordView doc={doc} />}
|
||||
{doc.kind === 'pdf' && <iframe className="pdf-frame" src={doc.src} title={doc.name} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExcelView({ doc }: { doc: Extract<FileDoc, { kind: 'excel' }> }) {
|
||||
const cols = Math.max(...doc.rows.map((r) => r.length), 1);
|
||||
const letters = Array.from({ length: cols }, (_, i) => String.fromCharCode(65 + i));
|
||||
return (
|
||||
<div className="xls">
|
||||
<table className="xls-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="xls-corner" />
|
||||
{letters.map((c) => (
|
||||
<th key={c} className="xls-colh">
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
<td className="xls-rowh">{ri + 1}</td>
|
||||
{letters.map((_, ci) => (
|
||||
<td key={ci} className={'xls-cell' + (ri === 0 ? ' xls-head' : '')}>
|
||||
{row[ci] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="xls-tabs">
|
||||
<span className="xls-tab active">{doc.sheet}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WordView({ doc }: { doc: Extract<FileDoc, { kind: 'word' }> }) {
|
||||
return (
|
||||
<article className="doc-paper word-paper">
|
||||
{doc.blocks.map((b, i) => {
|
||||
if (b.t === 'ul') {
|
||||
return (
|
||||
<ul key={i}>
|
||||
{b.items.map((it, j) => (
|
||||
<li key={j}>{it}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (b.t === 'h1') return <h1 key={i}>{b.text}</h1>;
|
||||
if (b.t === 'h2') return <h2 key={i}>{b.text}</h2>;
|
||||
return <p key={i}>{b.text}</p>;
|
||||
})}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +1,45 @@
|
||||
/**
|
||||
* 对话区 —— 自原型 `_components/TaskChat.tsx` 平移。
|
||||
* 对话区 —— 「对话流是**一切回环输入的唯一入口**」(OS 4.1)。
|
||||
*
|
||||
* 「对话区**只承载非结构化消息 + 决策选择题**;事件由系统 Agent(灰头像)发进来。」
|
||||
* 对话流是**一切回环输入的唯一入口**(OS 4.1);输入侧不参与控制权转交(AM 07)。
|
||||
* 壳(`jc-tch` 题头 / 滚动区 / composer)照原型留着;气泡与卡族备在 `chat-bubbles.tsx`。
|
||||
* 空态文案从用人的角度写 —— 空对话是这一单刚开始时的**常态**,不是缺陷。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { ChatMessage } from '@contracts';
|
||||
import { AI_TAG_LABEL, EVENT_TYPE_LABEL } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
|
||||
export type TaskChatProps = {
|
||||
readonly chat: readonly ChatMessage[];
|
||||
readonly locked: boolean;
|
||||
onSay(text: string): void;
|
||||
onDecide(cardId: string, value: string, note?: string): void;
|
||||
};
|
||||
|
||||
export function TaskChat({ chat, locked, onSay, onDecide }: TaskChatProps) {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const submit = () => {
|
||||
const text = draft.trim();
|
||||
if (text === '' || locked) return;
|
||||
onSay(text);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
export function TaskChat() {
|
||||
return (
|
||||
<section className="session-chat">
|
||||
<header className="jc-chat-head">
|
||||
<span className="jc-chat-ico">
|
||||
<Ico name="messages-square" s={16} />
|
||||
<div className="jc-tch">
|
||||
<header className="jc-tch-head">
|
||||
<span className="jc-tch-mark">
|
||||
<Ico name="bot" s={15} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>任务助理</h2>
|
||||
<span>对话流 · 唯一回环输入口</span>
|
||||
</div>
|
||||
<b className="jc-tch-name">任务助理</b>
|
||||
<em className="jc-tch-tag">Agent</em>
|
||||
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
|
||||
</header>
|
||||
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="jc-chat-body">
|
||||
{chat.map((msg) => (
|
||||
<Bubble key={msg.id} msg={msg} onDecide={onDecide} />
|
||||
))}
|
||||
{chat.length === 0 && (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="messages-square" s={18} />
|
||||
还没有对话
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* composer 锁:有卡在等作答时锁住自由输入,逼回卡上作答 */}
|
||||
<footer className={'jc-composer' + (locked ? ' locked' : '')}>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder={locked ? '有卡在等你作答 —— 请先在卡上回答' : '说点什么…(Enter 发送)'}
|
||||
disabled={locked}
|
||||
rows={2}
|
||||
/>
|
||||
<button className="jc-btn primary" onClick={submit} disabled={locked || draft.trim() === ''}>
|
||||
发送
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 气泡二元制:AI / 我;系统事件走灰头像 */
|
||||
function Bubble({ msg, onDecide }: { msg: ChatMessage; onDecide: TaskChatProps['onDecide'] }) {
|
||||
if (msg.k === 'ai') {
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body">
|
||||
{msg.tag !== undefined && <b className={'jc-tag tag-' + msg.tag}>{AI_TAG_LABEL[msg.tag]}</b>}
|
||||
<p>{msg.text}</p>
|
||||
<div className="jc-tch-scroll">
|
||||
<div className="jc-empty t-brand">
|
||||
<span className="jc-empty-art">
|
||||
<i className="r1" />
|
||||
<i className="r2" />
|
||||
<span className="jc-empty-ico">
|
||||
<Ico name="messages-square" s={26} />
|
||||
</span>
|
||||
</span>
|
||||
<b>还没有消息</b>
|
||||
<p>AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'human') {
|
||||
return (
|
||||
<div className="jc-bubble side-me">
|
||||
<div className="jc-bubble-body">
|
||||
<p>{msg.text}</p>
|
||||
<div className="copilot-composer session-composer">
|
||||
<div className="composer-row">
|
||||
<textarea rows={2} disabled placeholder="有什么要补充或交代的?" />
|
||||
<button className="copilot-send" disabled>
|
||||
<Ico name="send" s={14} /> 发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'system') {
|
||||
return (
|
||||
<div className="jc-bubble side-sys">
|
||||
<span className="jc-ava sys">
|
||||
<Ico name="bot" s={14} />
|
||||
</span>
|
||||
<div className="jc-bubble-body">
|
||||
<b className="jc-tag tag-event">{EVENT_TYPE_LABEL[msg.type]}事件</b>
|
||||
<p>{msg.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'decision') {
|
||||
// 对话区只做选择题 —— 决策卡
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body jc-card-decide">
|
||||
<p className="jc-card-ask">{msg.ask}</p>
|
||||
<dl className="jc-basis">
|
||||
{msg.basis.map((b, i) => (
|
||||
<div key={i}>
|
||||
<dt>{b.k}</dt>
|
||||
<dd>{b.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="jc-options">
|
||||
{msg.options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={'jc-opt' + (o.recommended === true ? ' recommended' : '') + (msg.chosen?.value === o.value ? ' chosen' : '')}
|
||||
disabled={msg.chosen !== undefined}
|
||||
onClick={() => onDecide(msg.id, o.value)}
|
||||
>
|
||||
<b>{o.label}</b>
|
||||
{o.detail !== undefined && <span>{o.detail}</span>}
|
||||
{o.consequence !== undefined && <em>{o.consequence}</em>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'elicitation') {
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body jc-card-fill">
|
||||
<p className="jc-card-ask">{msg.ask}</p>
|
||||
<ul>
|
||||
{msg.fields.map((f) => (
|
||||
<li key={f.key}>{f.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
{msg.done !== undefined && <p className="jc-card-done">已作答</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body jc-card-rate">
|
||||
<p className="jc-card-ask">{msg.ask}</p>
|
||||
{msg.done !== undefined && <p className="jc-card-done">已评价 {msg.done.score} 分</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useState } from 'react';
|
||||
import type { TaskCard } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ListEmpty } from '@web-shared/desk/module-kit.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
|
||||
const TASK_COLORS = ['#3370ff', '#ff8800', '#7f3bf5', '#0aa294', '#f14ba9'];
|
||||
@@ -83,12 +84,20 @@ export function TaskList({ tasks, selectedId, collapsed, onPick, onCollapse }: T
|
||||
</div>
|
||||
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{visible.length === 0 && (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="search" s={18} />
|
||||
无匹配任务
|
||||
</div>
|
||||
)}
|
||||
{visible.length === 0 &&
|
||||
(query !== '' ? (
|
||||
<ListEmpty icon="search" title="没找到">
|
||||
换个关键词试试 —— 可搜任务名、活动名、工作流主题。
|
||||
</ListEmpty>
|
||||
) : filter === 'done' ? (
|
||||
<ListEmpty icon="circle-check" title="还没有完成的任务">
|
||||
办结的任务会归到这里,随时回看它的成果与日志。
|
||||
</ListEmpty>
|
||||
) : (
|
||||
<ListEmpty icon="list-checks" title="手上没有任务">
|
||||
分派给你的任务会出现在这里 —— 到了会有角标提醒。
|
||||
</ListEmpty>
|
||||
))}
|
||||
{visible.map((task, i) => (
|
||||
<TaskCardView
|
||||
key={task.id}
|
||||
|
||||
@@ -1,232 +1,384 @@
|
||||
/**
|
||||
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(四页签结构)。
|
||||
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(2026-07-26 定稿)。
|
||||
*
|
||||
* 「视图区 = 全部结构化交互:HTML 交互界面(表单读写 + 图形展示)+ 工作事项列表 + 过程成果」;
|
||||
* 容器渲染活动推送的内容,**无脚本执行权**(AM 07);渲染一律经 @ui 的 ViewContainer。
|
||||
* 「视图区**承担全部结构化交互**」:表单读写 + 过程成果的图形展示,全在这里完成;
|
||||
* 决策不在这里 —— 它是对话区的选择题(AM 07)。
|
||||
*
|
||||
* 五页签:待处理 / 沟通记录 / 工作成果 / 任务详情 / 工作日志。
|
||||
* DOM 与类名逐字照搬原型 —— 皮肤是原型的原样,自造类名等于没样式。
|
||||
*
|
||||
* **空态即常态**:文案从用人的角度写「你手上现在没有什么、将会有什么」,
|
||||
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { TaskDetail, ViewBlock, WorkOrder } from '@contracts';
|
||||
import { ACTION_EFFECT_LABEL, WORK_ORDER_MODE_LABEL } from '@contracts';
|
||||
import type { TaskDetail } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { ViewBlocks, ViewContainer } from '@ui';
|
||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||
import { ViewBlocks } from '@ui';
|
||||
|
||||
type Tab = 'todo' | 'result' | 'detail' | 'log';
|
||||
|
||||
const TABS: ReadonlyArray<{ key: Tab; label: string; icon: string }> = [
|
||||
{ key: 'todo', label: '待处理', icon: 'list-checks' },
|
||||
{ key: 'result', label: '工作成果', icon: 'file-check' },
|
||||
{ key: 'detail', label: '任务详情', icon: 'bar-chart' },
|
||||
{ key: 'log', label: '工作日志', icon: 'notebook-pen' },
|
||||
];
|
||||
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
|
||||
|
||||
export type ViewRegionProps = {
|
||||
readonly detail: TaskDetail;
|
||||
onReply(reply: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
export function ViewRegion({ detail, onReply }: ViewRegionProps) {
|
||||
const [tab, setTab] = useState<Tab>('todo');
|
||||
const open = detail.workOrders.filter((o) => o.done === undefined);
|
||||
|
||||
export function ViewRegion({ detail }: ViewRegionProps) {
|
||||
const [view, setView] = useState<Tab>('open');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const orders = detail.workOrders;
|
||||
const openList = orders.filter((o) => o.done === undefined);
|
||||
const pos = Math.min(page, Math.max(0, openList.length - 1));
|
||||
const cur = openList[pos];
|
||||
|
||||
const go = (to: Tab) => {
|
||||
setView(to);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
|
||||
|
||||
return (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<header className="jc-vr-head">
|
||||
<div className="jc-vr-title">
|
||||
<h1>{detail.card.title}</h1>
|
||||
<span className="jc-vr-sub">{detail.card.workflowSubject}</span>
|
||||
<main className="execution-panel">
|
||||
{/* ① 标题区 —— **不滚**,两行定高:主题 · 工作流 · 状态 · 谁做 */}
|
||||
<header className="jc-topbar" aria-label="标题区">
|
||||
<div className="jc-tb-crumb">
|
||||
<span className="jc-tb-tag">
|
||||
<Ico name="list-checks" s={12} />
|
||||
{detail.card.activity}
|
||||
</span>
|
||||
<span className="jc-tb-wf" title={detail.card.workflowSubject}>
|
||||
<Ico name="git-branch" s={12} />
|
||||
<b>{detail.card.workflowSubject}</b>
|
||||
</span>
|
||||
<span className="jc-tb-sp" />
|
||||
<span className={'jc-cp-run-st' + (terminalLabel === undefined ? '' : ' done')}>
|
||||
{terminalLabel === undefined ? (
|
||||
<>
|
||||
<i className="jc-pulse-dot" />
|
||||
AI 推进中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ico name="circle-check" s={13} />
|
||||
{terminalLabel}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="jc-tb-owner" title={`负责人 · ${detail.owner.name} · ${detail.owner.role}`}>
|
||||
<em>{detail.owner.name.charAt(0)}</em>
|
||||
{detail.owner.name} · {detail.owner.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="jc-vr-budget jc-mono" title="预算与熔断由基座在环外强制">
|
||||
<span>步 {detail.budget.steps}</span>
|
||||
<span>token {detail.budget.tokens}</span>
|
||||
<span>时长 {detail.budget.activeMinutes}</span>
|
||||
<span>调人 {detail.budget.humanCalls}</span>
|
||||
<div className="jc-tb-titlerow">
|
||||
<span className="jc-tb-tico">
|
||||
<Ico name="file-text" s={16} />
|
||||
</span>
|
||||
<h2 className="jc-tb-title" title={detail.card.title}>
|
||||
{detail.card.title}
|
||||
</h2>
|
||||
<span className="jc-tb-node jc-mono">
|
||||
{detail.card.position}/{detail.card.total}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="jc-vr-tabs" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} className={tab === t.key ? 'active' : ''} onClick={() => setTab(t.key)} role="tab">
|
||||
<Ico name={t.icon} s={14} />
|
||||
<span>{t.label}</span>
|
||||
{t.key === 'todo' && open.length > 0 ? <b className="jc-vr-tab-badge">{open.length}</b> : null}
|
||||
{/* ② 工作区 —— 页签与点位条**不滚**,只有正文滚 */}
|
||||
<section className="wf-card jc-wc-fill">
|
||||
<div className="jc-tnav">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'open'}
|
||||
className={'jc-tnav-tab' + (view === 'open' ? ' on' : '')}
|
||||
onClick={() => go('open')}
|
||||
>
|
||||
<Ico name="list-checks" s={14} /> 待处理{openList.length > 0 && <em>{openList.length}</em>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'comms'}
|
||||
className={'jc-tnav-tab' + (view === 'comms' ? ' on' : '')}
|
||||
onClick={() => go('comms')}
|
||||
>
|
||||
<Ico name="mail" s={14} /> 沟通记录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'done'}
|
||||
className={'jc-tnav-tab' + (view === 'done' ? ' on' : '')}
|
||||
onClick={() => go('done')}
|
||||
>
|
||||
<Ico name="circle-check" s={14} /> 工作成果
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'wf'}
|
||||
className={'jc-tnav-tab' + (view === 'wf' ? ' on' : '')}
|
||||
onClick={() => go('wf')}
|
||||
>
|
||||
<Ico name="git-branch" s={14} /> 任务详情
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'log'}
|
||||
className={'jc-tnav-tab' + (view === 'log' ? ' on' : '')}
|
||||
onClick={() => go('log')}
|
||||
>
|
||||
<Ico name="archive" s={14} /> 工作日志
|
||||
</button>
|
||||
<span className="jc-tnav-sp" />
|
||||
{/*
|
||||
翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。
|
||||
按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。
|
||||
*/}
|
||||
{view === 'open' && (
|
||||
<span className="jc-tnav-pg" aria-label="翻页">
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={pos === 0}
|
||||
onClick={() => setPage(pos - 1)}
|
||||
aria-label="上一件"
|
||||
>
|
||||
<Ico name="chevron-left" s={15} />
|
||||
</button>
|
||||
<b className="jc-tnav-n jc-mono">
|
||||
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
|
||||
</b>
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={pos >= openList.length - 1}
|
||||
onClick={() => setPage(pos + 1)}
|
||||
aria-label="下一件"
|
||||
>
|
||||
<Ico name="chevron-right" s={15} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="jc-vr-body">
|
||||
{tab === 'todo' &&
|
||||
(open.length === 0 ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
当前没有等你的工作事项
|
||||
</div>
|
||||
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
|
||||
<ScrollArea
|
||||
key={`${view}:${cur?.id ?? '-'}`}
|
||||
className="jc-sa--fill"
|
||||
vpClassName="wf-body"
|
||||
inset={-6}
|
||||
>
|
||||
{view === 'comms' ? (
|
||||
/* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */
|
||||
<EmptyState
|
||||
ico="mail"
|
||||
tone="brand"
|
||||
title="还没有往来记录"
|
||||
desc="与客户、供应商的邮件和消息都会归到这里,原件一并存档 —— 谈过什么、什么时候谈的,回头都查得到。"
|
||||
/>
|
||||
) : view === 'wf' ? (
|
||||
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
|
||||
) : view === 'log' ? (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有进展"
|
||||
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
|
||||
/>
|
||||
) : view === 'done' ? (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有成果"
|
||||
desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
|
||||
/>
|
||||
) : (
|
||||
open.map((order) => <WorkOrderCard key={order.id} order={order} onReply={onReply} />)
|
||||
))}
|
||||
|
||||
{/*
|
||||
活动推的 HTML 优先:那是它按当下局面组织的**完整一屏**;
|
||||
没推 HTML 时退回结构化块。两者都经 ViewContainer 渲染 —— feature 不碰 innerHTML。
|
||||
*/}
|
||||
{tab === 'result' &&
|
||||
(detail.viewHtml === undefined ? (
|
||||
<ViewBlocks blocks={detail.view as ViewBlock[]} />
|
||||
) : (
|
||||
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 视图`} />
|
||||
))}
|
||||
|
||||
{tab === 'detail' && (
|
||||
<div className="jc-vr-detail">
|
||||
<ViewBlocks blocks={detail.view as ViewBlock[]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'log' && (
|
||||
<ol className="jc-log">
|
||||
{detail.log.map((line) => (
|
||||
<li key={line.id} className={'jc-log-item side-' + line.side}>
|
||||
<span className="jc-log-side">{line.sideLabel}</span>
|
||||
<span className="jc-log-title">{line.title}</span>
|
||||
<time className="jc-mono">{line.at.slice(11, 19)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<EmptyState
|
||||
ico="list-checks"
|
||||
tone="brand"
|
||||
title="手上没有待办"
|
||||
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
|
||||
hint="AI 正在往下推进,轮到你时会第一时间出现"
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** 工单两模式:执行 = 指导书 + 空表单;验收 = 可修订报告 + 判据核对 + 放行三选一 */
|
||||
function WorkOrderCard({ order, onReply }: { order: WorkOrder; onReply(reply: Record<string, unknown>): void }) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
(order.mode === 'execution' ? order.fields : order.fields).map((f) => [f.key, f.ai ?? '']),
|
||||
),
|
||||
);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const set = (key: string, value: string) => setValues((v) => ({ ...v, [key]: value }));
|
||||
/* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */
|
||||
|
||||
function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) {
|
||||
return (
|
||||
<article className="jc-wo jc-panel-card">
|
||||
<header className="jc-wo-head">
|
||||
<span className={'jc-wo-mode mode-' + order.mode}>{WORK_ORDER_MODE_LABEL[order.mode]}</span>
|
||||
<h3>{order.title}</h3>
|
||||
</header>
|
||||
|
||||
{/* 调用必附上下文 —— 依据快照 */}
|
||||
<dl className="jc-wo-basis">
|
||||
{order.basis.map((b, i) => (
|
||||
<div key={i}>
|
||||
<dt>{b.k}</dt>
|
||||
<dd>{b.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{order.mode === 'execution' ? (
|
||||
<>
|
||||
<p className="jc-wo-ask">{order.ask}</p>
|
||||
{order.standard !== undefined && <p className="jc-wo-standard">完成标准:{order.standard}</p>}
|
||||
{order.steps !== undefined && (
|
||||
<ol className="jc-wo-steps">
|
||||
{order.steps.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<div className="jc-wo-form">
|
||||
{order.fields.map((f) => (
|
||||
<label key={f.key} className={'jc-field' + (f.cols === 2 ? ' half' : '')}>
|
||||
<span>
|
||||
{f.label}
|
||||
{f.required === true ? <i className="jc-req">*</i> : null}
|
||||
</span>
|
||||
{f.kind === 'textarea' ? (
|
||||
<textarea value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} rows={3} />
|
||||
) : f.kind === 'radio' || f.kind === 'select' ? (
|
||||
<select value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{(f.options ?? []).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} placeholder={f.placeholder} />
|
||||
)}
|
||||
{f.hint !== undefined && <em className="jc-hint">{f.hint}</em>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<footer className="jc-wo-foot">
|
||||
<button
|
||||
className="jc-btn primary"
|
||||
onClick={() => onReply({ mode: 'execution', workOrderId: order.id, values })}
|
||||
>
|
||||
{order.submitLabel ?? '提交'}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 判据逐条核对 —— 成果清单的成色标记 */}
|
||||
<ul className="jc-criteria">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'pass' : 'fail'}>
|
||||
<b className="jc-lv">{c.level}</b>
|
||||
<span>{c.label}</span>
|
||||
<i>{c.pass ? '✓' : '○'}</i>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{order.actions !== undefined && order.actions.length > 0 && (
|
||||
<ul className="jc-actions" title="预检全过才发卡:这里是已验证的动作清单">
|
||||
{order.actions.map((a, i) => (
|
||||
<li key={i}>
|
||||
<b className={'jc-eff eff-' + a.effect}>{ACTION_EFFECT_LABEL[a.effect]}</b>
|
||||
<span>{a.label}</span>
|
||||
<em>{a.detail}</em>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label className="jc-field">
|
||||
<span>意见(驳回必填)</span>
|
||||
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} />
|
||||
</label>
|
||||
<footer className="jc-wo-foot">
|
||||
{/* 三选一:放行通过 / 驳回重做 / 驳回迭代 —— 完成只出自放行通过 */}
|
||||
<button
|
||||
className="jc-btn primary"
|
||||
onClick={() =>
|
||||
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'pass', values, selectedPorts: {} })
|
||||
}
|
||||
>
|
||||
{order.passLabel ?? '放行通过'}
|
||||
</button>
|
||||
<button
|
||||
className="jc-btn"
|
||||
onClick={() =>
|
||||
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'revise', values, comment, selectedPorts: {} })
|
||||
}
|
||||
>
|
||||
驳回迭代
|
||||
</button>
|
||||
<button
|
||||
className="jc-btn danger"
|
||||
onClick={() =>
|
||||
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'redo', values, comment, selectedPorts: {} })
|
||||
}
|
||||
>
|
||||
驳回重做
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
<div className="jc-wfd-sec">
|
||||
<span className="jc-wfd-sec-ico">
|
||||
<Ico name={ico} s={12} />
|
||||
</span>
|
||||
<b>{label}</b>
|
||||
<i className="ln" />
|
||||
{aside}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WfDetail({ detail, openN, doneN }: { detail: TaskDetail; openN: number; doneN: number }) {
|
||||
return (
|
||||
<div className="jc-wfd-body">
|
||||
<KpiBand budget={detail.budget} />
|
||||
|
||||
<WSec
|
||||
ico="git-branch"
|
||||
label="流程链"
|
||||
aside={
|
||||
<span className="jc-wfd-pos">
|
||||
<i className="jc-pulse-dot" />
|
||||
当前 · {detail.card.position}/{detail.card.total} {detail.card.activity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="jc-wfg2">
|
||||
{detail.chain.map((n, k) => (
|
||||
<div
|
||||
key={n.nodeId}
|
||||
className={`jc-map-n s-${n.state === 'current' ? 'cur' : n.state}${n.kind === 'approval' ? ' apr' : ''}`}
|
||||
title={`${n.nodeId} ${n.label} · ${n.ownerName} · ${n.ownerRole}${n.kind === 'approval' ? '(审批点)' : ''}`}
|
||||
>
|
||||
{n.state === 'current' && (
|
||||
<span className="jc-map-me">
|
||||
<i className="jc-pulse-dot" />
|
||||
你在这
|
||||
</span>
|
||||
)}
|
||||
<div className="jc-map-nh">
|
||||
<em className="jc-mono">{k + 1}</em>
|
||||
{n.state === 'done' ? (
|
||||
<span className="jc-map-ok">
|
||||
<Ico name="check" s={11} />
|
||||
</span>
|
||||
) : n.kind === 'approval' ? (
|
||||
<span className="jc-map-ap">
|
||||
<Ico name="shield-check" s={11} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<b>{n.label}</b>
|
||||
<span className="jc-map-who">
|
||||
<i>{n.ownerName.charAt(0)}</i>
|
||||
{n.ownerName} · {n.ownerRole}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{detail.chain.length === 0 && <p className="jc-ck-none">这单不在流程上(直接起单)</p>}
|
||||
</div>
|
||||
|
||||
<div className="jc-duo2">
|
||||
<div className="jc-panel">
|
||||
<div className="jc-panel-h">
|
||||
<span className="jc-panel-ico">
|
||||
<Ico name="bar-chart" s={12} />
|
||||
</span>
|
||||
<b>过程视图</b>
|
||||
<em>活动推送</em>
|
||||
</div>
|
||||
<ViewBlocks blocks={detail.view} />
|
||||
</div>
|
||||
<div className="jc-panel">
|
||||
<div className="jc-panel-h">
|
||||
<span className="jc-panel-ico">
|
||||
<Ico name="notebook-pen" s={12} />
|
||||
</span>
|
||||
<b>上下文</b>
|
||||
<em className="jc-mono">{detail.card.id}</em>
|
||||
</div>
|
||||
<div className="jc-file-rows">
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="target" s={13} />
|
||||
</i>
|
||||
<em>本单目标</em>
|
||||
<p>{detail.goal}</p>
|
||||
</div>
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="git-branch" s={13} />
|
||||
</i>
|
||||
<em>所属工作流</em>
|
||||
<p>
|
||||
{detail.card.workflowSubject}
|
||||
<span>
|
||||
序位 {detail.card.position}/{detail.card.total}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="activity" s={13} />
|
||||
</i>
|
||||
<em>运行状态</em>
|
||||
<p>
|
||||
<b>已派 {openN + doneN} 件</b> · 办完 {doneN} · 待办 {openN}
|
||||
<span>AI 边推进边派工,件数还会增加</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="hash" s={13} />
|
||||
</i>
|
||||
<em>编号</em>
|
||||
<p className="jc-mono">
|
||||
{detail.card.id}
|
||||
<span className="jc-mono">起于 {detail.card.startedAt.slice(0, 16).replace('T', ' ')}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI 指标带:四层卡解剖(维度徽 → 指标名 → 大数 → 限额条);限额是基座在环外强制的 */
|
||||
function KpiBand({ budget }: { budget: TaskDetail['budget'] }) {
|
||||
const meter = (p: string) => {
|
||||
const [a = '0', z = '0'] = p.split('/').map((x) => x.trim());
|
||||
const r = Math.max(0, Math.min(1, Number(a) / (Number(z) || 1)));
|
||||
return { a, z, r, tone: r >= 0.85 ? 'hot' : r >= 0.6 ? 'warm' : 'cool' };
|
||||
};
|
||||
const KPI = [
|
||||
{ dim: '步数', icon: 'activity', ...meter(budget.steps) },
|
||||
{ dim: 'Tokens', icon: 'cpu', ...meter(budget.tokens) },
|
||||
{ dim: '持续时长', icon: 'clock', ...meter(budget.activeMinutes) },
|
||||
{ dim: '调人次数', icon: 'users', ...meter(budget.humanCalls) },
|
||||
];
|
||||
return (
|
||||
<div className="jc-kpis">
|
||||
{KPI.map((k) => (
|
||||
<div key={k.dim} className="jc-kpi">
|
||||
<span className="jc-kpi-dim d-act">
|
||||
<Ico name={k.icon} s={10} />
|
||||
当前活动
|
||||
</span>
|
||||
<em>{k.dim}</em>
|
||||
<b className="jc-mono">
|
||||
{k.a}
|
||||
<i>/ {k.z}</i>
|
||||
</b>
|
||||
<span className={'jc-ck-bar t-' + k.tone}>
|
||||
<i style={{ width: `${Math.round(k.r * 100)}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═════════════════ 工单报告页 ═════════════════ */
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 对话流的气泡与卡族 —— 自原型 `_components/TaskChat.tsx` 平移(气泡二元制,2026-07-26 定稿)。
|
||||
*
|
||||
* **当前未挂载**:对话区正处在「一步一步描述、一步一步接」的阶段,暂为空占位。
|
||||
* 这一份留着等接回 —— 描述到对话该长什么样时直接用,或按新口径改。
|
||||
*
|
||||
* 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。
|
||||
* AI 的内容只有两种形态、不允许中间态:
|
||||
* · 纯文本气泡 —— 结论 / 说明 / 回执
|
||||
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 评价单
|
||||
* 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。
|
||||
* ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。
|
||||
*
|
||||
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
||||
|
||||
/** 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径) */
|
||||
const TAG_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
|
||||
conclusion: { label: '阶段结论', cls: 'auto' },
|
||||
guidance: { label: '派了工单', cls: 'exe' },
|
||||
criterion: { label: '请你验收', cls: 'acc' },
|
||||
};
|
||||
|
||||
/** ISO → HH:MM(署名行只要时分) */
|
||||
const hm = (iso: string) => iso.slice(11, 16);
|
||||
|
||||
export type TaskChatProps = {
|
||||
readonly chat: readonly ChatMessage[];
|
||||
readonly orders: readonly WorkOrder[];
|
||||
/** AI 正在回话 —— 请求在途即亮「输入中」 */
|
||||
readonly thinking: boolean;
|
||||
onSay(text: string): void;
|
||||
onReplyCard(reply: ChatCardReply): void;
|
||||
onReplyOrder(reply: WorkOrderReply): void;
|
||||
/** 点卡上的「打开工单」→ 视图区定位到那张工单 */
|
||||
onLocate(orderId: string): void;
|
||||
};
|
||||
|
||||
export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReplyOrder, onLocate }: TaskChatProps) {
|
||||
const [text, setText] = useState('');
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
boxRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' });
|
||||
}, [chat.length, thinking]);
|
||||
|
||||
// ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单
|
||||
const blocked = chat.some(
|
||||
(m) => (m.k === 'elicitation' && m.done === undefined) || (m.k === 'decision' && m.chosen === undefined),
|
||||
);
|
||||
|
||||
const send = () => {
|
||||
const t = text.trim();
|
||||
if (t === '' || blocked) return;
|
||||
setText('');
|
||||
onSay(t);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jc-tch">
|
||||
<header className="jc-tch-head">
|
||||
<span className="jc-tch-mark">
|
||||
<Ico name="bot" s={15} />
|
||||
</span>
|
||||
<b className="jc-tch-name">任务助理</b>
|
||||
<em className="jc-tch-tag">Agent</em>
|
||||
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
|
||||
</header>
|
||||
|
||||
<div className="jc-tch-scroll" ref={boxRef}>
|
||||
{chat.map((m) => (
|
||||
<Msg
|
||||
key={m.id}
|
||||
m={m}
|
||||
orders={orders}
|
||||
onReplyCard={onReplyCard}
|
||||
onReplyOrder={onReplyOrder}
|
||||
onLocate={onLocate}
|
||||
/>
|
||||
))}
|
||||
{thinking && (
|
||||
<article className="jc-ag">
|
||||
<ChatAvatar who="ai" />
|
||||
<div className="jc-ag-col">
|
||||
<div className="jc-msg-meta">AI</div>
|
||||
<span className="jc-typing">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="copilot-composer session-composer">
|
||||
{blocked && (
|
||||
<div className="jc-comp-lock">
|
||||
<Ico name="lock" s={12} /> 有一张卡在等你(补全 / 决策)——处理完就能继续对话
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-row">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={text}
|
||||
disabled={blocked}
|
||||
placeholder={blocked ? '先处理上面的卡片…' : '有什么要补充或交代的?(Enter 发送)'}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}>
|
||||
<Ico name="send" s={14} /> 发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Agent 侧包装:头像 + (署名行 → 内容流) ── */
|
||||
function Ag({ meta, tag, children }: { meta: string; tag?: AiTag | undefined; children: React.ReactNode }) {
|
||||
const t = tag === undefined ? undefined : TAG_UI[tag];
|
||||
return (
|
||||
<article className="jc-ag">
|
||||
<ChatAvatar who="ai" />
|
||||
<div className="jc-ag-col">
|
||||
<div className="jc-msg-meta">
|
||||
{meta}
|
||||
{t !== undefined && <b className={'jc-mtag m-' + t.cls}>{t.label}</b>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Msg({
|
||||
m,
|
||||
orders,
|
||||
onReplyCard,
|
||||
onReplyOrder,
|
||||
onLocate,
|
||||
}: {
|
||||
m: ChatMessage;
|
||||
orders: readonly WorkOrder[];
|
||||
onReplyCard: (reply: ChatCardReply) => void;
|
||||
onReplyOrder: (reply: WorkOrderReply) => void;
|
||||
onLocate: (orderId: string) => void;
|
||||
}) {
|
||||
// 人的消息:右侧品牌气泡(纯文本)
|
||||
if (m.k === 'human') {
|
||||
return (
|
||||
<article className="copilot-msg human">
|
||||
<div className="jc-msg-col r">
|
||||
<div className="jc-msg-meta r">{hm(m.at)}</div>
|
||||
<div className="copilot-bubble">
|
||||
<p className="jc-content">{m.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChatAvatar who="user" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// 系统事件:灰头像 + 灰调文本气泡。三类唤醒事件在章上标明是哪一类
|
||||
if (m.k === 'system') {
|
||||
return (
|
||||
<article className="jc-ag">
|
||||
<div className="chat-avatar jc-ava-sys" aria-label="系统">
|
||||
<Ico name="server" s={15} />
|
||||
</div>
|
||||
<div className="jc-ag-col">
|
||||
<div className="jc-msg-meta">
|
||||
系统 · {hm(m.at)}
|
||||
<b className="jc-mtag m-sys">{EVENT_TYPE_LABEL[m.type]}事件</b>
|
||||
</div>
|
||||
<div className="jc-ag-b sys">
|
||||
<p className="jc-content">{m.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 发言:一次回复 = 一个气泡 —— 文字在上、表单卡嵌在**同一气泡**里
|
||||
if (m.k === 'ai') {
|
||||
const order = m.ref === undefined ? undefined : orders.find((o) => o.id === m.ref);
|
||||
const settled = order?.done !== undefined;
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`} tag={m.tag}>
|
||||
<div className={'jc-ag-b' + (order === undefined ? '' : ' card')}>
|
||||
<p className="jc-content">{m.text}</p>
|
||||
{order !== undefined &&
|
||||
(order.mode === 'acceptance' && m.tag === 'criterion' && !settled ? (
|
||||
<AcceptForm order={order} onReplyOrder={onReplyOrder} onLocate={onLocate} />
|
||||
) : (
|
||||
<WoCard order={order} onLocate={onLocate} />
|
||||
))}
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
// 补全:问题文字在上,表单卡嵌在同一气泡里
|
||||
if (m.k === 'elicitation') {
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`}>
|
||||
<div className="jc-ag-b card">
|
||||
<p className="jc-content">{m.ask}</p>
|
||||
<div className="jc-fc t-fill">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="square-pen" s={13} />
|
||||
<b>请你补全</b>
|
||||
<em>{m.fields.length} 项</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
|
||||
{m.done === undefined ? (
|
||||
<FillForm
|
||||
fields={m.fields}
|
||||
onDone={(values) => onReplyCard({ k: 'elicitation', cardId: m.id, values })}
|
||||
onCant={(reason) => onReplyCard({ k: 'elicitation', cardId: m.id, values: {}, reason })}
|
||||
/>
|
||||
) : m.done.reason === undefined ? (
|
||||
<p className="jc-chosen">
|
||||
<Ico name="circle-check" s={14} /> 你补了:
|
||||
<b>{m.fields.map((f) => m.done?.values[f.key] ?? '—').join(';')}</b>
|
||||
<code className="jc-mono">{hm(m.done.at)}</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="jc-chosen cant">
|
||||
<Ico name="alert-circle" s={14} /> 你说明了没法填:
|
||||
<b>{m.done.reason}</b>
|
||||
<code className="jc-mono">{hm(m.done.at)}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
// 任务评价:星级打分 + 改进建议,并入活动记忆
|
||||
if (m.k === 'rating') {
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`}>
|
||||
<div className="jc-ag-b card">
|
||||
<p className="jc-content">{m.ask}</p>
|
||||
<div className="jc-fc t-rate">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="star" s={13} />
|
||||
<b>任务评价</b>
|
||||
<em>5 分制</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
|
||||
{m.done === undefined ? (
|
||||
<RateForm
|
||||
onDone={(score, note) =>
|
||||
onReplyCard({ k: 'rating', cardId: m.id, score, ...(note === '' ? {} : { note }) })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className="jc-chosen">
|
||||
<span className="jc-stars ro">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<i key={n} className={'jc-star' + (n <= (m.done?.score ?? 0) ? ' on' : '')}>
|
||||
<Ico name="star" s={16} />
|
||||
</i>
|
||||
))}
|
||||
</span>
|
||||
<b>{m.done.score} 分</b>
|
||||
{m.done.note !== undefined && <span className="jc-rate-note">{m.done.note}</span>}
|
||||
<code className="jc-mono">{hm(m.done.at)}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
// 决策:问题文字在上,选择卡嵌在同一气泡里
|
||||
const chosenLabel =
|
||||
m.chosen === undefined
|
||||
? ''
|
||||
: (m.options.find((o) => o.value === m.chosen?.value)?.label ??
|
||||
(m.chosen.value.startsWith('custom:') ? m.chosen.value.slice(7) : m.chosen.value));
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`}>
|
||||
<div className="jc-ag-b card">
|
||||
<p className="jc-content">{m.ask}</p>
|
||||
<div className="jc-fc t-dec">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="git-branch" s={13} />
|
||||
<b>需要你定</b>
|
||||
<em>
|
||||
{m.options.length + (m.allowCustom ? 1 : 0)} 选 1
|
||||
</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
<Kv items={m.basis} />
|
||||
{m.recommend !== undefined && (
|
||||
<p className="jc-fc-note">
|
||||
<Ico name="sparkles" s={13} />
|
||||
<span>
|
||||
<b>AI 建议</b>
|
||||
{m.recommend}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{m.chosen === undefined ? (
|
||||
<div className="jc-branches">
|
||||
{m.options.map((o, i) => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
|
||||
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
|
||||
>
|
||||
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
|
||||
<span className="jc-branch-bd">
|
||||
<b>
|
||||
{o.label}
|
||||
{o.recommended === true && <em>推荐</em>}
|
||||
</b>
|
||||
{o.detail !== undefined && <p>{o.detail}</p>}
|
||||
{o.consequence !== undefined && (
|
||||
<p className="jc-conseq">
|
||||
<Ico name="arrow-right" s={12} /> {o.consequence}
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
<Ico name="chevron-right" s={14} />
|
||||
</button>
|
||||
))}
|
||||
{m.allowCustom && (
|
||||
<CustomOption
|
||||
n={String.fromCharCode(65 + m.options.length)}
|
||||
onPick={(txt) => onReplyCard({ k: 'decision', cardId: m.id, value: 'custom:' + txt, note: txt })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="jc-chosen">
|
||||
<Ico name="circle-check" s={14} /> 你选了:
|
||||
<b>{chosenLabel}</b>
|
||||
<code className="jc-mono">{hm(m.chosen.at)}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
/* 参考信息:表单卡内的结构化 kv 表 */
|
||||
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
|
||||
return (
|
||||
<div className="jc-fc-kv">
|
||||
<i>参考信息</i>
|
||||
<div className="jc-fc-kv-g">
|
||||
{items.map((b, i) => (
|
||||
<span key={i}>
|
||||
<em>{b.k}</em>
|
||||
<b>{b.v}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 工单卡:题头 + 单体 + 打开动作 —— 派单 / 已裁决的验收都用它 */
|
||||
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
|
||||
const result = order.done?.result;
|
||||
const bad = result !== undefined && isRejection(result);
|
||||
const exe = order.mode === 'execution';
|
||||
return (
|
||||
<div className={'jc-fc ' + (exe ? 't-exe' : 't-acc')}>
|
||||
<div className="jc-fc-h">
|
||||
<Ico name={exe ? 'file-text' : 'shield-check'} s={13} />
|
||||
<b>工单 · {exe ? '执行' : '验收'}</b>
|
||||
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
|
||||
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
|
||||
</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
<p className="jc-fc-q strong">{order.title}</p>
|
||||
</div>
|
||||
<div className="jc-fc-ft">
|
||||
<button className="jc-btn jc-btn-ghost jc-fc-open" onClick={() => onLocate(order.id)}>
|
||||
<Ico name="panel-left-open" s={13} /> 打开工单 <Ico name="arrow-right" s={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 验收单:报告入口 + 判据表 + 落地动作 + 裁决(通过 / 重做)—— 驳回必附意见 */
|
||||
function AcceptForm({
|
||||
order,
|
||||
onReplyOrder,
|
||||
onLocate,
|
||||
}: {
|
||||
order: Extract<WorkOrder, { mode: 'acceptance' }>;
|
||||
onReplyOrder: (reply: WorkOrderReply) => void;
|
||||
onLocate: (id: string) => void;
|
||||
}) {
|
||||
const [why, setWhy] = useState('');
|
||||
const passN = order.criteria.filter((c) => c.pass).length;
|
||||
return (
|
||||
<div className="jc-fc t-acc">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="shield-check" s={13} />
|
||||
<b>请你验收</b>
|
||||
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
|
||||
{passN}/{order.criteria.length} 达标
|
||||
</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
|
||||
<span className="jc-fc-doc-ico">
|
||||
<Ico name="file-text" s={15} />
|
||||
</span>
|
||||
<span className="jc-fc-doc-bd">
|
||||
<em>成果报告 · 视图区可读可编辑</em>
|
||||
<b>{order.title}</b>
|
||||
</span>
|
||||
<Ico name="chevron-right" s={14} />
|
||||
</button>
|
||||
<ul className="jc-rp-crit sm">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'ok' : 'no'}>
|
||||
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
|
||||
<span className="t">{c.label}</span>
|
||||
<span className="r">
|
||||
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
|
||||
{c.pass ? '达标' : '未达标'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{order.actions !== undefined && order.actions.length > 0 && (
|
||||
<Kv
|
||||
items={order.actions.map((a) => ({
|
||||
k: a.effect === 'write' ? '通过即执行 · 写' : '通过即执行 · 读',
|
||||
v: a.label,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
className="jc-note"
|
||||
placeholder="要重做?写明原因(也可以在下面对话里聊)"
|
||||
value={why}
|
||||
onChange={(e) => setWhy(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="jc-fc-ft">
|
||||
<button
|
||||
className="jc-btn jc-btn-bad"
|
||||
disabled={why.trim() === ''}
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
mode: 'acceptance',
|
||||
workOrderId: order.id,
|
||||
verdict: 'redo',
|
||||
values: {},
|
||||
comment: why.trim(),
|
||||
selectedPorts: {},
|
||||
})
|
||||
}
|
||||
>
|
||||
重做
|
||||
</button>
|
||||
<span className="jc-fc-sp" />
|
||||
<button
|
||||
className="jc-btn jc-btn-ok"
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
mode: 'acceptance',
|
||||
workOrderId: order.id,
|
||||
verdict: 'pass',
|
||||
values: {},
|
||||
selectedPorts: {},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 决策的收尾项:自定义口径 */
|
||||
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
|
||||
const [txt, setTxt] = useState('');
|
||||
return (
|
||||
<div className="jc-branch jc-branch-custom">
|
||||
<span className="jc-branch-n jc-mono">{n}</span>
|
||||
<span className="jc-branch-bd">
|
||||
<b>都不合适,我来定</b>
|
||||
<span className="jc-custom-row">
|
||||
<input
|
||||
value={txt}
|
||||
placeholder="写下你的口径…"
|
||||
onChange={(e) => setTxt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
|
||||
}}
|
||||
/>
|
||||
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}>
|
||||
就按这个
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 补全卡的小表单:就地作答;「没法填」是并列的第二条路(两条必走其一) */
|
||||
function FillForm({
|
||||
fields,
|
||||
onDone,
|
||||
onCant,
|
||||
}: {
|
||||
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined }>;
|
||||
onDone: (values: Record<string, string>) => void;
|
||||
onCant: (reason: string) => void;
|
||||
}) {
|
||||
const [vals, setVals] = useState<Record<string, string>>({});
|
||||
const [cant, setCant] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const ok = fields.every((f) => (vals[f.key] ?? '').trim() !== '');
|
||||
|
||||
if (cant) {
|
||||
return (
|
||||
<div className="jc-cfill">
|
||||
<label className="jc-cfill-f">
|
||||
<span>没法填的原因(AI 会据此调整需求)</span>
|
||||
<textarea
|
||||
className="jc-note"
|
||||
rows={2}
|
||||
value={reason}
|
||||
autoFocus
|
||||
placeholder="如:没和客户谈过价位 / 这个数据在别人手里…"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="jc-cfill-acts">
|
||||
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
|
||||
← 还是填吧
|
||||
</button>
|
||||
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}>
|
||||
<Ico name="send" s={13} /> 让 AI 调整需求
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="jc-cfill">
|
||||
{fields.map((f) => (
|
||||
<label key={f.key} className="jc-cfill-f">
|
||||
<span>{f.label}</span>
|
||||
<input
|
||||
value={vals[f.key] ?? ''}
|
||||
placeholder={f.placeholder}
|
||||
onChange={(e) => setVals({ ...vals, [f.key]: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && ok) onDone(vals);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="jc-cfill-acts">
|
||||
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
|
||||
没法填?告诉 AI 原因
|
||||
</button>
|
||||
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}>
|
||||
<Ico name="check" s={13} /> 补给 AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
|
||||
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
|
||||
const [score, setScore] = useState(0);
|
||||
const [hover, setHover] = useState(0);
|
||||
const [note, setNote] = useState('');
|
||||
const TXT = ['', '很不顺畅,返工严重', '不顺畅,返工明显', '一般,多处需人工介入', '顺畅,个别节点需修正', '非常顺畅,全程无需返工'];
|
||||
const cur = hover !== 0 ? hover : score;
|
||||
return (
|
||||
<div className="jc-cfill">
|
||||
<div className="jc-stars" role="radiogroup" aria-label="整体评分">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={'jc-star' + (n <= cur ? ' on' : '')}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => setScore(n)}
|
||||
aria-label={`${n} 分`}
|
||||
>
|
||||
<Ico name="star" s={20} />
|
||||
</button>
|
||||
))}
|
||||
<em className="jc-stars-txt">{cur !== 0 ? `${cur} 分 · ${TXT[cur]}` : '点星打分'}</em>
|
||||
</div>
|
||||
<label className="jc-cfill-f">
|
||||
<span>改进建议(会进入活动记忆,用于优化下一单)</span>
|
||||
<textarea
|
||||
className="jc-note"
|
||||
rows={2}
|
||||
value={note}
|
||||
placeholder="如:报价台账自动关联客户项目号;英文报价邮件模板固化为标准件"
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="jc-cfill-acts">
|
||||
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}>
|
||||
<Ico name="check" s={13} /> 提交评价
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 工单报告页 —— 一张工单的完整呈现:题头 / 元信息 / 依据指标带 / 正文 / 填报表单 / 落款动作。
|
||||
*
|
||||
* 自原型 `_components/ViewRegion.tsx` 的 `Report` 与卡组二平移,DOM 与类名逐字照搬。
|
||||
*
|
||||
* **当前未挂载**:工作区正处在「一步一步描述、一步一步接」的阶段,待处理页签暂为空占位。
|
||||
* 这一份留在这里等着接回来 —— 描述到工单该长什么样时,直接用或按新口径改,不必重写。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { FormField, TaskDetail, ViewBlock, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
import { ACTION_EFFECT_LABEL, WORK_ORDER_RESULT_LABEL, isRejection } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ViewBlocks, ViewContainer } from '@ui';
|
||||
|
||||
function Part({ label, aside }: { label: string; aside?: string | undefined }) {
|
||||
return (
|
||||
<div className="jc-rp-part">
|
||||
<b>{label}</b>
|
||||
<i className="ln" />
|
||||
{aside !== undefined && <em>{aside}</em>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 控件原始值 → 报告正文文字(AI 拟值 / 人改值都经它落到报告上) */
|
||||
function fieldText(f: FormField, raw: string): string {
|
||||
if (raw === '') return '—';
|
||||
if (f.kind === 'radio' || f.kind === 'select') return f.options?.find((o) => o.value === raw)?.label ?? raw;
|
||||
if (f.kind === 'check')
|
||||
return (
|
||||
raw
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((v) => f.options?.find((o) => o.value === v)?.label ?? v)
|
||||
.join('、') || '—'
|
||||
);
|
||||
if (f.kind === 'money' || f.kind === 'number') return raw + (f.unit === undefined ? '' : ' ' + f.unit);
|
||||
if (f.kind === 'file') return raw.split('|').filter(Boolean).join('、') || '—';
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡组一 · 报告 —— 待办区两种模式:
|
||||
* 验收 = **报告模式**:AI 给出填好的报告;「修改」进编辑态,裁决在对话区。
|
||||
* 执行 = **表单模式**:作业指导书(卡组二)+ 待填写表单;「提交」即生成执行报告。
|
||||
*/
|
||||
export function WorkOrderReport({
|
||||
order,
|
||||
detail,
|
||||
typeLine,
|
||||
blocks,
|
||||
html,
|
||||
srcTitle,
|
||||
values,
|
||||
onField,
|
||||
onReply,
|
||||
}: {
|
||||
order: WorkOrder;
|
||||
detail: TaskDetail;
|
||||
typeLine: string;
|
||||
blocks: readonly ViewBlock[];
|
||||
/**
|
||||
* 活动推的那一屏 —— 它**既是独立文件**(工作成果里一行一份、点开单看),
|
||||
* **也是交互视图的组成部分**:办这张工单时,人要在同一页里看着它填、照着它判。
|
||||
*/
|
||||
html?: string | undefined;
|
||||
srcTitle?: string | undefined;
|
||||
values: Readonly<Record<string, string>>;
|
||||
onField: (key: string, value: string) => void;
|
||||
onReply: (reply: WorkOrderReply) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const isAcc = order.mode === 'acceptance';
|
||||
const disabled = order.done !== undefined;
|
||||
const fields = isAcc ? [] : order.fields;
|
||||
/** 执行 = 空占位表单:值只来自人填(或办结留痕),不吃 AI 拟值 */
|
||||
const val = (f: FormField) => values[f.key] ?? order.done?.values?.[f.key] ?? '';
|
||||
const ok = fields.every((f) => f.required !== true || val(f).trim() !== '');
|
||||
const result = order.done?.result;
|
||||
const rejected = result !== undefined && isRejection(result);
|
||||
const stampTxt = result === undefined ? null : WORK_ORDER_RESULT_LABEL[result];
|
||||
|
||||
const groups: Array<{ name: string | null; list: FormField[] }> = [];
|
||||
fields.forEach((f) => {
|
||||
const g = f.group ?? null;
|
||||
const last = groups[groups.length - 1];
|
||||
if (last !== undefined && last.name === g) last.list.push(f);
|
||||
else groups.push({ name: g, list: [f] });
|
||||
});
|
||||
|
||||
return (
|
||||
<article className={'jc-report' + (disabled ? ' sealed' : '')}>
|
||||
<header className="jc-rp-head">
|
||||
<div className="jc-rp-badge">
|
||||
<Ico name={isAcc ? 'shield-check' : 'file-text'} s={17} />
|
||||
</div>
|
||||
<div className="jc-rp-tit">
|
||||
<em>
|
||||
{detail.card.activity} · {typeLine}
|
||||
</em>
|
||||
<h3>{order.title}</h3>
|
||||
</div>
|
||||
{stampTxt === null ? (
|
||||
<span className="jc-rp-wait">
|
||||
<i className="jc-pulse-dot" />
|
||||
{isAcc ? '待验收' : '待执行'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={'jc-rp-stamp' + (rejected ? ' bad' : '')}>{stampTxt}</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="jc-rp-meta">
|
||||
<span>
|
||||
<i>编号</i>
|
||||
<b className="jc-mono">{order.id.toUpperCase()}</b>
|
||||
</span>
|
||||
<span>
|
||||
<i>所属</i>
|
||||
<b className="jc-mono">{detail.card.id}</b>
|
||||
</span>
|
||||
<span>
|
||||
<i>编制</i>
|
||||
<b>{isAcc ? `AI 拟 · ${detail.owner.name}审定` : `${detail.owner.name}填报 · AI 定稿`}</b>
|
||||
</span>
|
||||
<span>
|
||||
<i>派发</i>
|
||||
<b className="jc-mono">{order.at.slice(5, 16).replace('T', ' ')}</b>
|
||||
</span>
|
||||
{order.done !== undefined && (
|
||||
<span>
|
||||
<i>办结</i>
|
||||
<b className="jc-mono">{order.done.at.slice(5, 16).replace('T', ' ')}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关键指标带 —— 调用必附上下文(OS 05) */}
|
||||
{order.basis.length > 0 && (
|
||||
<div className="jc-rp-stats">
|
||||
{order.basis.map((b, i) => (
|
||||
<div key={i} className="jc-rp-stat">
|
||||
<i>{b.k}</i>
|
||||
<b>{b.v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="jc-rp-bd">
|
||||
{disabled && isAcc && (
|
||||
<p className={'jc-rp-verdict' + (rejected ? ' bad' : '')}>
|
||||
<Ico name={rejected ? 'circle-x' : 'circle-check'} s={15} />
|
||||
<b>{rejected ? '退回重做' : '验收通过 · 系统已执行落地动作'}</b>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 验收报告的判据逐条核对 —— 成果清单的成色标记 */}
|
||||
{isAcc && order.criteria.length > 0 && (
|
||||
<>
|
||||
<Part label="验收判据" aside={`${order.criteria.filter((c) => c.pass).length}/${order.criteria.length} 达标`} />
|
||||
<ul className="jc-rp-crit">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'ok' : 'no'}>
|
||||
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
|
||||
<span className="t">{c.label}</span>
|
||||
<span className="r">
|
||||
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
|
||||
{c.pass ? '达标' : '未达标'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预检全过才发卡:这里是**已验证**的落地动作清单(OS 7.3) */}
|
||||
{isAcc && order.actions !== undefined && order.actions.length > 0 && (
|
||||
<>
|
||||
<Part label="通过即执行的落地动作" />
|
||||
<div className="jc-kv">
|
||||
{order.actions.map((a, i) => (
|
||||
<div key={i} className="jc-kv-i">
|
||||
<i>
|
||||
{ACTION_EFFECT_LABEL[a.effect]} · {a.label}
|
||||
</i>
|
||||
<b>{a.detail}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{html !== undefined && (
|
||||
<div>
|
||||
<Part label={isAcc ? '成果内容' : '数据与分析'} aside="活动汇报报告" />
|
||||
<ViewContainer html={html} title={`${detail.card.activity} · 汇报报告`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blocks.length > 0 && (
|
||||
<div>
|
||||
<Part label={html === undefined ? (isAcc ? '成果内容' : '数据与分析') : '结构化明细'} aside={srcTitle} />
|
||||
<ViewBlocks blocks={blocks} editing={isAcc && editing && !disabled} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
<Part label={g.name ?? '填报内容'} />
|
||||
{!disabled ? (
|
||||
<div className="jc-grid">
|
||||
{g.list.map((f) => (
|
||||
<div key={f.key} className={'jc-fld' + (f.cols === 2 ? ' half' : '')}>
|
||||
<label className="jc-fld-l" htmlFor={f.key}>
|
||||
{f.label}
|
||||
{f.required === true && <em className="jc-req">必填</em>}
|
||||
</label>
|
||||
{f.hint !== undefined && <p className="jc-fld-h">{f.hint}</p>}
|
||||
<Control f={f} v={val(f)} onField={onField} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="jc-rp-vals">
|
||||
{g.list.map((f) => (
|
||||
<div key={f.key} className="row">
|
||||
<dt>{f.label}</dt>
|
||||
<dd>{fieldText(f, val(f).trim())}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="jc-rp-foot">
|
||||
<div className="jc-rp-sign">
|
||||
<span className="jc-rp-ava">{detail.owner.name.charAt(0)}</span>
|
||||
<div>
|
||||
<b>
|
||||
{detail.owner.name} · {detail.owner.role}
|
||||
</b>
|
||||
<span>
|
||||
{disabled
|
||||
? `已于 ${(order.done?.at ?? order.at).slice(5, 16).replace('T', ' ')} 办结 · 已记入工作日志`
|
||||
: isAcc
|
||||
? editing
|
||||
? '改完记得保存 —— 裁决在对话区的验收单上'
|
||||
: '可修改报告内容;验收裁决在对话区'
|
||||
: '按作业指导书填写,提交即生成执行报告'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled &&
|
||||
(isAcc ? (
|
||||
editing ? (
|
||||
<div className="jc-rp-acts2">
|
||||
<button className="jc-btn jc-btn-ghost" onClick={() => setEditing(false)}>
|
||||
完成编辑
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="jc-btn jc-btn-ghost jc-rp-act" onClick={() => setEditing(true)}>
|
||||
<Ico name="pen-line" s={13} /> 修改
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
className="jc-btn jc-rp-submit"
|
||||
disabled={!ok}
|
||||
onClick={() =>
|
||||
onReply({
|
||||
mode: 'execution',
|
||||
workOrderId: order.id,
|
||||
values: Object.fromEntries(fields.map((f) => [f.key, val(f)])),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Ico name="check" s={14} /> {order.submitLabel ?? '提交'}
|
||||
</button>
|
||||
))}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═════════════════ 卡组二 · 作业指导书 ═════════════════ */
|
||||
|
||||
export function GuideCard({ order }: { order: Extract<WorkOrder, { mode: 'execution' }> }) {
|
||||
return (
|
||||
<section className="jc-side jc-side-exe">
|
||||
<header className="jc-side-h">
|
||||
<span className="jc-side-ico">
|
||||
<Ico name="clipboard-check" s={14} />
|
||||
</span>
|
||||
<b>作业指导书</b>
|
||||
{order.steps !== undefined && order.steps.length > 0 && <em>{order.steps.length} 步</em>}
|
||||
</header>
|
||||
<div className="jc-side-b">
|
||||
<p className="jc-side-ask">{order.ask}</p>
|
||||
{order.steps !== undefined && order.steps.length > 0 && (
|
||||
<ol className="jc-steps">
|
||||
{order.steps.map((x, i) => (
|
||||
<li key={i}>
|
||||
<em>{i + 1}</em>
|
||||
<span>{x}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{order.standard !== undefined && (
|
||||
<p className="jc-rp-std">
|
||||
<Ico name="target" s={13} />
|
||||
<span>
|
||||
<b>算做完的标准</b>
|
||||
{order.standard}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* 单个控件:8 种 kind */
|
||||
function Control({ f, v, onField }: { f: FormField; v: string; onField: (k: string, v: string) => void }) {
|
||||
const set = (x: string) => onField(f.key, x);
|
||||
|
||||
if (f.kind === 'radio')
|
||||
return (
|
||||
<div className="jc-radios">
|
||||
{f.options?.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={'jc-radio' + (v === o.value ? ' on' : '') + (o.negative === true ? ' neg' : '')}
|
||||
onClick={() => set(o.value)}
|
||||
>
|
||||
<Ico name={v === o.value ? 'circle-check' : 'circle-dot'} s={13} />
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'check')
|
||||
return (
|
||||
<div className="jc-checks">
|
||||
{f.options?.map((o) => {
|
||||
const on = v.split(',').filter(Boolean).includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={'jc-check' + (on ? ' on' : '')}
|
||||
onClick={() => {
|
||||
const cur = v.split(',').filter(Boolean);
|
||||
set((on ? cur.filter((x) => x !== o.value) : [...cur, o.value]).join(','));
|
||||
}}
|
||||
>
|
||||
<span className="jc-check-box">{on && <Ico name="check" s={11} />}</span>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'select')
|
||||
return (
|
||||
<div className="jc-sel-wrap">
|
||||
<select id={f.key} className="jc-sel" value={v} onChange={(e) => set(e.target.value)}>
|
||||
<option value="">{f.placeholder ?? '请选择…'}</option>
|
||||
{f.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Ico name="chevron-down" s={14} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'textarea')
|
||||
return (
|
||||
<textarea
|
||||
id={f.key}
|
||||
className="jc-ta"
|
||||
rows={3}
|
||||
placeholder={f.placeholder}
|
||||
value={v}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (f.kind === 'number' || f.kind === 'money')
|
||||
return (
|
||||
<div className="jc-num">
|
||||
{f.kind === 'money' && <span className="jc-num-pre">¥</span>}
|
||||
<input
|
||||
id={f.key}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
placeholder={f.placeholder}
|
||||
value={v}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
{f.unit !== undefined && <span className="jc-num-unit">{f.unit}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'file') {
|
||||
const files = v.split('|').filter(Boolean);
|
||||
return (
|
||||
<div className="jc-up">
|
||||
{files.map((n, i) => (
|
||||
<span key={i} className="jc-up-chip">
|
||||
<Ico name="paperclip" s={12} />
|
||||
{n}
|
||||
<button type="button" aria-label="移除" onClick={() => set(files.filter((_, x) => x !== i).join('|'))}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<label className="jc-up-drop">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const ns = [...(e.target.files ?? [])].map((x) => x.name);
|
||||
if (ns.length > 0) set([...files, ...ns].join('|'));
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Ico name="upload" s={14} /> 点击上传文件(可多个)
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (f.kind === 'date')
|
||||
return <input id={f.key} type="date" className="jc-inp" value={v} onChange={(e) => set(e.target.value)} />;
|
||||
|
||||
return (
|
||||
<input id={f.key} className="jc-inp" placeholder={f.placeholder} value={v} onChange={(e) => set(e.target.value)} />
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user