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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user