mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:11:57 +00:00
823 lines
44 KiB
TypeScript
823 lines
44 KiB
TypeScript
/**
|
||
* 待办卡(应用平台 X4):从 RunPanel 的 ApprovalCard 抽出,和画布状态解耦——onResolved 必填,内部调 /api/tasks/{id}/respond。
|
||
*
|
||
* 用在待办箱、实例页、记录页、画布、问数据。审批卡原有的全部能力都在这里:报告核对概况、反方审稿、双盲复算、质询、
|
||
* 补充输入和改稿、工具审批的参数(探索运行里的「始终允许」)、代码审批。另加流程的几样:
|
||
* - 会签进度:派给了谁、谁同意了、谁驳回了,按策略(任一 / 全体 / 至少 m 人)说清还差什么;
|
||
* - 时限与升级:剩多久、超了多久、升级过几次;超时只会提醒、升级或驳回,不会自动批准;
|
||
* - 动作确认的哈希卡:遮罩后的预览、内容哈希前 8 位、环境;确认时原样带回哈希,服务端发送前重算,对不上不发(P8);
|
||
* - 核实待办:已生效(填外部单号)/ 未生效重新发送 / 未生效放弃,结果未知永远不会自动重发;
|
||
* - 等外部系统(signal):说明由入站钩子完成,不在这里处理;
|
||
* - 审核个人应用(review,docs/design/normal-user-apps.md N7):同意不在这里点,处理人取一份共享副本、在副本上试运行和
|
||
* 走发布单(发布单做完这条待办自动记为已发布);不发布就退回。
|
||
* - 口径召回(recall,docs/design/next-round/02-口径账本.md 做法 7):旧口径、新口径、两版之差和原答案当时的值都点得开,
|
||
* 「按新版重新出具」「知道了」(run/RecallTask.tsx)。
|
||
*
|
||
* 不碰画布的 store:恢复之后谁去重新接事件流、刷新哪张列表,由调用方在 onResolved 里决定。会签时一个人的回复记下了、
|
||
* 还没凑齐(resolved 为假),照样回调——调用方据此刷新卡片。
|
||
*
|
||
* 回复的内容(TaskRespond.values):有表单(task.form)时是表单的值;没有表单的补充输入、改稿、双盲复算是 {value: …},
|
||
* 服务端照旧把 value 交给节点;「始终允许」是 {always: true}。
|
||
*/
|
||
import { useMemo, useState } from 'react'
|
||
import type { ReactNode } from 'react'
|
||
import { Link, useNavigate } from 'react-router-dom'
|
||
import {
|
||
Ban, Check, CheckCheck, Clock, Code, Copy, ExternalLink, Hand, History, Keyboard, PenLine, Send, SearchCheck,
|
||
TriangleAlert, UserCheck, Webhook, Wrench,
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
import { ApiError, api } from '../api/client'
|
||
import { useCatalog } from '../store/catalog'
|
||
import { principalName, useAtLeast, useLoginOn, usePrincipal } from '../store/auth'
|
||
import { SchemaForm } from '../components/SchemaForm'
|
||
import { Spinner, useTicker, useToast } from '../components/ui'
|
||
import { useLocalActor } from '../lib/actor'
|
||
import { formatDateTime, formatSpan, formatTime, parseServerTime } from '../lib/format'
|
||
import { cleanValues, formErrorsOf, validateForm, withDefaults } from '../lib/schemaForm'
|
||
import {
|
||
ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, INQUIRY_TEXT, RECONCILE_TEXT, SIGNAL_TEXT, TASK_KIND_LABEL, TASK_STATUS_LABEL,
|
||
TASK_TEXT, TOOL_TRUST_TEXT, TRIAL_TEXT, runName,
|
||
} from '../lib/terms'
|
||
import { REJECT_ROUTE_TEXT, REVIEW_TASK_TEXT, rejectRouteOf } from '../lib/termsApp'
|
||
import { RECALL_TEXT } from '../lib/termsRecall'
|
||
import { blueprintPath } from '../pages/blueprints/shared'
|
||
import type { Approval, ApprovalReport } from '../types'
|
||
import type {
|
||
FormError, ReviewTaskPayload, Task, TaskAssignment, TaskKind, TaskMyResponse, TaskProgress, TaskRespond,
|
||
} from '../typesApp'
|
||
import { Markdown } from './Markdown'
|
||
import { ApprovalReports, ApprovalRunClass } from './ApprovalReport'
|
||
import { ChallengeSummary } from './ChallengeSummary'
|
||
import { DualCheckApproval } from './DualCheckApproval'
|
||
import { useInquiryBusy } from './Inquiry'
|
||
import { RecallTask } from './RecallTask'
|
||
|
||
export interface TaskCardProps {
|
||
task: Task
|
||
/** 回复成功之后(凑齐了、或这个人的那一票记下了)。调用方决定刷新列表还是跳转 */
|
||
onResolved: (result: { task: Task; resolved: boolean }) => void
|
||
/** 列表里用:不画「批了会怎样」的说明、签批人那一行和底部的链接 */
|
||
compact?: boolean
|
||
/**
|
||
* 把回复发出去。默认 POST /api/tasks/{id}/respond;审批卡在老服务端(审批上没有 task_id)上改走老的审批接口,
|
||
* 按这里的形状交回结果
|
||
*/
|
||
send?: (body: TaskRespond) => Promise<{ task: Task; resolved: boolean }>
|
||
/** 卡头写不写应用或工作流名。画布上工作流就是眼前这张,不用再说一遍 */
|
||
showOwner?: boolean
|
||
/** 画不画会签进度。应用页上同一页另有会签组件时服务端说不画(signoffs_on_page),同一页上不写两遍 */
|
||
showSignoffs?: boolean
|
||
/**
|
||
* 试运行里的待办(docs/design/normal-user-apps.md N4):派给了试运行的人自己,按钮写「以试运行身份同意 / 驳回 / 提交」,
|
||
* 别让人以为这是在批一张真单子
|
||
*/
|
||
trial?: boolean
|
||
}
|
||
|
||
const KIND_ICON: Record<TaskKind, typeof Hand> = {
|
||
approve: Hand, input: Keyboard, edit: PenLine, tool: Wrench, code: Code, dual_check: Hand,
|
||
action_confirm: Send, reconcile: SearchCheck, signal: Webhook, review: UserCheck, recall: History,
|
||
}
|
||
|
||
/** 一天没人处理就用琥珀色写等了多久,同审批卡 */
|
||
const STALE_MS = 24 * 3_600_000
|
||
|
||
export function TaskCard({ task, onResolved, compact = false, send, showOwner = true, showSignoffs = true, trial = false }: TaskCardProps) {
|
||
const toast = useToast()
|
||
const payload: Record<string, any> = task.payload ?? {}
|
||
const kind = task.kind
|
||
const pending = task.status === 'pending'
|
||
const canAct = pending && task.can_respond
|
||
const inquiring = useInquiryBusy(task.run_id)
|
||
const [busy, setBusy] = useState(false)
|
||
const [note, setNote] = useState('')
|
||
const [errors, setErrors] = useState<FormError[]>([])
|
||
const [values, setValues] = useState<Record<string, unknown>>(() => (task.form ? withDefaults(task.form, {}) : {}))
|
||
const [text, setText] = useState(() => (payload.draft != null ? String(payload.draft) : ''))
|
||
const post = (body: TaskRespond) => (send ? send(body) : api.tasks.respond(task.id, body))
|
||
|
||
// 运行类别服务端在待办上给(run_class);老服务端不给时:应用里的运行一律是固定版本的正式运行,别处的看中断内容
|
||
// 自己带的运行类别,都没有就不画那一行
|
||
const runClass: 'formal' | 'exploratory' | null = task.run_class ?? (task.app_id ? 'formal'
|
||
: payload.run_class === 'formal' || payload.run_class === 'exploratory' ? payload.run_class : null)
|
||
const binary = kind === 'approve' || kind === 'tool' || kind === 'code'
|
||
// 驳回之后去哪(中断内容的 reject_route):带了的照实说转到哪一步、运行终止还是这一路到此为止;老待办没带的照原来的说法
|
||
const route = rejectRouteOf(payload)
|
||
const stops = route ? 'ends' in route && route.ends === 'run' : !binary
|
||
const trustKey: string | undefined = kind === 'tool' && runClass !== 'formal'
|
||
? (typeof payload.trust_key === 'string' && payload.trust_key) || undefined : undefined
|
||
const reports: ApprovalReport[] = Array.isArray(payload.reports)
|
||
? payload.reports.filter((r: unknown): r is ApprovalReport =>
|
||
!!r && typeof r === 'object' && typeof (r as ApprovalReport).node_id === 'string')
|
||
: []
|
||
|
||
/** 回复一次。422 带 {errors} 的挂到表单上;别的错误交给 toast */
|
||
const respond = async (body: TaskRespond, done: string) => {
|
||
if (busy) return
|
||
setBusy(true)
|
||
try {
|
||
const res = await post({ ...body, ...(note.trim() && !body.note ? { note: note.trim() } : {}) })
|
||
setErrors([])
|
||
void useCatalog.getState().refreshApprovals()
|
||
toast(res.resolved ? done : TASK_TEXT.doneVote, 'ok')
|
||
onResolved(res)
|
||
} catch (e) {
|
||
const fe = e instanceof ApiError && e.status === 422 ? formErrorsOf(e) : null
|
||
if (fe) setErrors(fe)
|
||
else toast.error(e)
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
/** 批准 / 提交:有表单的先按子集校验,不合规不发 */
|
||
const approve = () => {
|
||
if (task.form) {
|
||
const local = validateForm(task.form, values)
|
||
if (local.length) { setErrors(local); return }
|
||
void respond({ decision: binary ? 'approve' : 'submit', values: cleanValues(task.form, values) },
|
||
binary ? TASK_TEXT.doneApprove : TASK_TEXT.doneSubmit)
|
||
return
|
||
}
|
||
if (binary) void respond({ decision: 'approve' }, TASK_TEXT.doneApprove)
|
||
else void respond({ decision: 'submit', values: { value: text } }, TASK_TEXT.doneSubmit)
|
||
}
|
||
const reject = () => void respond({ decision: 'reject' }, route ? TASK_TEXT.doneRejectRoute(REJECT_ROUTE_TEXT.goes(route))
|
||
: binary ? TASK_TEXT.doneReject : TASK_TEXT.doneRejectStop)
|
||
|
||
/** 「始终允许」:批准这一次,并让服务端把这个工具设成门控把关(工具页的信任档跟着变,目录重拉一次) */
|
||
const always = async () => {
|
||
if (!trustKey) return
|
||
await respond({ decision: 'approve', values: { always: true } }, TOOL_TRUST_TEXT.alwaysDone(trustKey))
|
||
void useCatalog.getState().reload('tools')
|
||
}
|
||
|
||
const Icon = KIND_ICON[kind] ?? Hand
|
||
const showForm = !!task.form && (kind === 'approve' || kind === 'input' || kind === 'edit')
|
||
|
||
return (
|
||
<div className={clsx('fade-up border-b last:border-b-0', compact ? 'p-2.5' : 'p-3')}
|
||
data-task={task.id} data-task-kind={kind} data-task-status={task.status} data-approval={task.approval_id ?? undefined}
|
||
style={pending ? { background: 'color-mix(in srgb, var(--warn) 7%, transparent)' } : undefined}>
|
||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold"
|
||
style={{ color: pending ? 'var(--warn)' : 'var(--text-dim)' }}>
|
||
<Icon size={13} aria-hidden /> <span className="min-w-0 truncate">{task.title || TASK_TEXT.untitled}</span>
|
||
</div>
|
||
<TaskMeta task={task} showOwner={showOwner} />
|
||
|
||
{!pending && <Resolved task={task} />}
|
||
{showSignoffs && (task.assignments.length > 1 || task.policy.kind !== 'any') && <Signoffs task={task} />}
|
||
|
||
{(kind === 'approve' || kind === 'input' || kind === 'edit') && payload.kind === 'human_node' && runClass && (
|
||
<ApprovalRunClass runClass={runClass} approve={kind === 'approve'} />
|
||
)}
|
||
|
||
{payload.message && (
|
||
<div className="mb-2 max-h-40 overflow-y-auto rounded border bg-bg p-2">
|
||
<Markdown text={String(payload.message)} dense />
|
||
</div>
|
||
)}
|
||
|
||
{kind === 'dual_check' && (
|
||
<DualCheckApproval approval={asApproval(task, runClass)}
|
||
decide={async (ok, reply) => {
|
||
const res = await post(ok
|
||
? { decision: 'submit', values: { value: reply }, note: reply.note }
|
||
: { decision: 'reject', note: reply.note })
|
||
void useCatalog.getState().refreshApprovals()
|
||
onResolved(res)
|
||
}} />
|
||
)}
|
||
|
||
<ApprovalReports reports={reports} runId={task.run_id} runClass={runClass} approvalId={task.approval_id ?? undefined}
|
||
askable={pending && !!task.approval_id} />
|
||
<ChallengeSummary reports={reports} runId={task.run_id} runClass={runClass} />
|
||
|
||
{kind === 'tool' && payload.tool && (
|
||
<div className="mb-2 rounded border bg-bg p-2" data-task-tool="">
|
||
<div className="mb-1 flex items-center gap-1.5 text-2xs">
|
||
<Wrench size={11} aria-hidden /> <code className="mono">{String(payload.tool)}</code>
|
||
</div>
|
||
<pre className="mono max-h-32 overflow-auto text-2xs leading-relaxed text-dim">
|
||
{JSON.stringify(payload.args ?? {}, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{kind === 'code' && payload.code && (
|
||
<pre className="mono mb-2 max-h-40 overflow-auto rounded border bg-bg p-2 text-2xs leading-relaxed">
|
||
{String(payload.code)}
|
||
</pre>
|
||
)}
|
||
|
||
{kind === 'action_confirm' && (
|
||
<ActionConfirm task={task} canAct={canAct} busy={busy || inquiring} note={note} setNote={setNote}
|
||
onConfirm={(hash) => void respond({ decision: 'approve', payload_hash: hash }, ACTION_CONFIRM_TEXT.done)}
|
||
onReject={() => void respond({ decision: 'reject' }, route && 'to' in route
|
||
? ACTION_CONFIRM_TEXT.rejectedRoute(REJECT_ROUTE_TEXT.goes(route)) : ACTION_CONFIRM_TEXT.rejected)} />
|
||
)}
|
||
{kind === 'reconcile' && (
|
||
<Reconcile task={task} canAct={canAct} busy={busy} note={note} setNote={setNote}
|
||
onSubmit={(body) => void respond(body, RECONCILE_TEXT.done)} />
|
||
)}
|
||
{kind === 'signal' && <SignalNote payload={payload} />}
|
||
{kind === 'review' && (
|
||
<Review task={task} canAct={canAct} busy={busy} note={note} setNote={setNote}
|
||
onReject={() => void respond({ decision: 'reject' }, REVIEW_TASK_TEXT.rejected)} />
|
||
)}
|
||
{kind === 'recall' && (
|
||
<RecallTask task={task} canAct={canAct} busy={busy}
|
||
onRespond={(decision) => void respond({ decision },
|
||
decision === 'reissue' ? RECALL_TEXT.task.doneReissue : RECALL_TEXT.task.doneAck)} />
|
||
)}
|
||
|
||
{showForm && (
|
||
<div className="mb-2 rounded border bg-bg p-2.5">
|
||
<SchemaForm schema={task.form!} value={values} errors={errors} readOnly={!canAct}
|
||
idPrefix={`task-${task.id}`} onSubmit={canAct ? approve : undefined} fileContext={{ task_id: task.id }}
|
||
onChange={(v) => { setValues(v); if (errors.length) setErrors([]) }} />
|
||
</div>
|
||
)}
|
||
{!showForm && canAct && (kind === 'input' || kind === 'edit') && (
|
||
<textarea className="field mb-2" rows={4} value={text}
|
||
aria-label={kind === 'edit' ? TASK_TEXT.editedContent : TASK_TEXT.yourInput}
|
||
placeholder={kind === 'edit' ? TASK_TEXT.editedContent : TASK_TEXT.yourInput}
|
||
onChange={(e) => setText(e.target.value)} />
|
||
)}
|
||
|
||
{canAct && (binary || kind === 'input' || kind === 'edit') && (
|
||
<>
|
||
<input className="field mb-2" aria-label={TASK_TEXT.note} placeholder={TASK_TEXT.notePlaceholder}
|
||
value={note} onChange={(e) => setNote(e.target.value)} />
|
||
<div className="flex gap-2">
|
||
<button type="button" className="btn btn-primary flex-1 justify-center" disabled={busy || inquiring}
|
||
data-task-approve="" onClick={approve}>
|
||
{busy ? <Spinner size={12} /> : <Check size={12} aria-hidden />}{' '}
|
||
{binary ? (trial ? TRIAL_TEXT.approve : TASK_TEXT.approve) : (trial ? TRIAL_TEXT.submit : TASK_TEXT.submit)}
|
||
</button>
|
||
{trustKey && (
|
||
<button type="button" className="btn flex-1 justify-center" disabled={busy || inquiring} data-approval-always=""
|
||
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} aria-describedby={`task-always-${task.id}`}
|
||
onClick={() => void always()}>
|
||
<CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
|
||
</button>
|
||
)}
|
||
<button type="button" className="btn btn-danger flex-1 justify-center" disabled={busy || inquiring}
|
||
data-task-reject="" onClick={reject}>
|
||
<Ban size={12} aria-hidden />{' '}
|
||
{stops ? (trial ? TRIAL_TEXT.rejectStop : TASK_TEXT.rejectStop) : (trial ? TRIAL_TEXT.reject : TASK_TEXT.reject)}
|
||
</button>
|
||
</div>
|
||
{inquiring && (
|
||
<div className="mt-1.5 flex items-start gap-1 text-2xs font-medium leading-relaxed text-fg" role="status"
|
||
data-approval-inquiry-busy="">
|
||
<span className="mt-[3px] shrink-0" style={{ color: 'var(--st-waiting)' }}><Spinner size={11} /></span>
|
||
<span>{INQUIRY_TEXT.decideBlocked}</span>
|
||
</div>
|
||
)}
|
||
{!compact && (
|
||
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
|
||
{route ? (binary ? TASK_TEXT.approveHintRoute : TASK_TEXT.inputHintRoute)(REJECT_ROUTE_TEXT.goes(route))
|
||
: binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint}
|
||
</div>
|
||
)}
|
||
{/* 「始终允许」会改工具的信任档、影响之后的运行:常显同一句旁注,不只藏在悬停里 */}
|
||
{trustKey && (
|
||
<div id={`task-always-${task.id}`} className="mt-0.5 text-2xs leading-relaxed text-dim">
|
||
{TOOL_TRUST_TEXT.alwaysButton}:{TOOL_TRUST_TEXT.alwaysHint(trustKey)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{pending && !task.can_respond && kind !== 'signal' && kind !== 'review' && (
|
||
task.my_response
|
||
? <div className="mt-1 text-2xs leading-relaxed text-dim" data-task-readonly="responded">{respondedText(task.my_response)}</div>
|
||
: <div className="mt-1 text-2xs leading-relaxed text-dim" data-task-readonly="">{TASK_TEXT.cannotRespond}</div>
|
||
)}
|
||
|
||
{canAct && kind !== 'signal' && !compact && <Signer />}
|
||
{!compact && <TaskLinks task={task} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 审核个人应用:提交人、目的、说明;处理人取一份共享副本去发布(取过的打开自己那一份),不发布就退回。
|
||
* 副本只有这条审核派给的人取得到(服务端 blueprints/personal.copy_for_review),取副本不改原来的个人应用
|
||
*/
|
||
function Review({ task, canAct, busy, note, setNote, onReject }: {
|
||
task: Task; canAct: boolean; busy: boolean; note: string; setNote: (v: string) => void; onReject: () => void
|
||
}) {
|
||
const toast = useToast()
|
||
const navigate = useNavigate()
|
||
const me = usePrincipal()
|
||
const actor = useLocalActor()
|
||
const [copying, setCopying] = useState(false)
|
||
const payload: Partial<ReviewTaskPayload> = task.payload ?? {}
|
||
const blueprintId = typeof payload.blueprint_id === 'string' ? payload.blueprint_id : null
|
||
const copies = Array.isArray(payload.copies) ? payload.copies : []
|
||
// 审核副本归取它的那位搭建者:取过的打开自己那一份(别的处理人取的副本不算)
|
||
const myName = me?.username ?? actor ?? null
|
||
const mine = copies.find((c) => c.owner === myName)
|
||
const submitter = typeof payload.submitted_by === 'string' ? payload.submitted_by : null
|
||
|
||
const copy = async () => {
|
||
if (!blueprintId || copying) return
|
||
setCopying(true)
|
||
try {
|
||
const row = await api.blueprints.copyForReview(blueprintId)
|
||
navigate(blueprintPath(row.id))
|
||
} catch (e) {
|
||
toast.error(e)
|
||
} finally {
|
||
setCopying(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="mb-2 space-y-2" data-task-review="">
|
||
<p className="text-2xs leading-relaxed text-dim">{REVIEW_TASK_TEXT.intro}</p>
|
||
<dl className="space-y-1 rounded border bg-bg p-2 text-2xs leading-relaxed">
|
||
{submitter && <div className="text-dim">{REVIEW_TASK_TEXT.submittedBy(personName(submitter, task.people))}</div>}
|
||
{typeof payload.purpose === 'string' && payload.purpose && (
|
||
<div><dt className="inline text-faint">{REVIEW_TASK_TEXT.purpose}:</dt><dd className="inline">{payload.purpose}</dd></div>
|
||
)}
|
||
{typeof payload.note === 'string' && payload.note && (
|
||
<div><dt className="inline text-faint">{REVIEW_TASK_TEXT.note}:</dt><dd className="inline whitespace-pre-wrap">{payload.note}</dd></div>
|
||
)}
|
||
</dl>
|
||
{canAct && (
|
||
<>
|
||
<div className="flex gap-2">
|
||
{mine
|
||
? <Link className="btn btn-primary flex-1 justify-center" to={blueprintPath(mine.id)} data-task-review-open="">
|
||
<ExternalLink size={12} aria-hidden /> {REVIEW_TASK_TEXT.openCopy}
|
||
</Link>
|
||
: <button type="button" className="btn btn-primary flex-1 justify-center" disabled={busy || copying || !blueprintId}
|
||
onClick={() => void copy()} data-task-review-copy="">
|
||
{copying ? <Spinner size={12} /> : <Copy size={12} aria-hidden />} {copying ? REVIEW_TASK_TEXT.copying : REVIEW_TASK_TEXT.copy}
|
||
</button>}
|
||
<button type="button" className="btn btn-danger flex-1 justify-center" disabled={busy || copying}
|
||
onClick={onReject} data-task-review-reject="">
|
||
<Ban size={12} aria-hidden /> {REVIEW_TASK_TEXT.reject}
|
||
</button>
|
||
</div>
|
||
<input className="field" aria-label={REVIEW_TASK_TEXT.rejectNote} placeholder={REVIEW_TASK_TEXT.rejectNote}
|
||
value={note} onChange={(e) => setNote(e.target.value)} />
|
||
<div className="text-2xs leading-relaxed text-dim">{REVIEW_TASK_TEXT.rejectHint}</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** DualCheckApproval 认的是审批记录:按待办拼一份它要的几项 */
|
||
function asApproval(task: Task, runClass: 'formal' | 'exploratory' | null): Approval {
|
||
return {
|
||
id: task.approval_id ?? task.id, run_id: task.run_id, node_id: task.node_id ?? '', mode: 'input',
|
||
title: task.title, payload: task.payload ?? {}, status: task.status === 'pending' ? 'pending' : 'resolved',
|
||
response: task.outcome ?? {}, created_at: task.created_at, workflow_name: task.workflow_name ?? null,
|
||
run_class: runClass,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 卡头
|
||
|
||
/** 种类 · 应用或工作流 · 节点 · 什么时候发起、等了多久 · 时限 · 升级 */
|
||
function TaskMeta({ task, showOwner }: { task: Task; showOwner: boolean }) {
|
||
const now = useTicker(60_000)
|
||
const created = parseServerTime(task.created_at)
|
||
const age = created ? now - created.getTime() : undefined
|
||
const pending = task.status === 'pending'
|
||
// 节点的名字:服务端在待办上给(node_label),老的中断内容里也可能带着
|
||
const label = task.node_label ?? task.payload?.node_label
|
||
const where = typeof label === 'string' && label ? label : task.node_id
|
||
const owner = !showOwner ? null : task.app_name ? task.app_name : task.workflow_name ? runName(task) : null
|
||
return (
|
||
<div className="tnum mb-2 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-2xs text-dim">
|
||
<span>{TASK_KIND_LABEL[task.kind] ?? task.kind}</span>
|
||
{owner && <><span aria-hidden>·</span><span>{owner}</span></>}
|
||
{where && <><span aria-hidden>·</span><span>{TASK_TEXT.node(where)}</span></>}
|
||
{created && <><span aria-hidden>·</span><span title={formatDateTime(created)}>{TASK_TEXT.createdAt(formatTime(created))}</span></>}
|
||
{pending && age != null && age >= 60_000 && (
|
||
<>
|
||
<span aria-hidden>·</span>
|
||
<span style={age > STALE_MS ? { color: 'var(--st-waiting)', fontWeight: 600 } : undefined}>
|
||
{TASK_TEXT.waited(formatSpan(age, { coarse: true }))}
|
||
</span>
|
||
</>
|
||
)}
|
||
{pending && <Due dueAt={task.due_at} now={now} />}
|
||
{task.escalation_level > 0 && (
|
||
<span className="chip" style={{ color: 'var(--st-waiting)' }} title={TASK_TEXT.escalatedHint}
|
||
data-task-escalated={task.escalation_level}>
|
||
<TriangleAlert size={10} aria-hidden /> {TASK_TEXT.escalated(task.escalation_level)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 时限:还剩多久,超了写超了多久(红)。悬停写明超时只会提醒、升级或驳回 */
|
||
export function Due({ dueAt, now }: { dueAt: string | null; now: number }) {
|
||
const due = parseServerTime(dueAt)
|
||
if (!due) return null
|
||
const left = due.getTime() - now
|
||
const over = left < 0
|
||
return (
|
||
<span className="chip" data-task-due={over ? 'overdue' : 'ok'}
|
||
title={`${TASK_TEXT.due(formatDateTime(due))}。${TASK_TEXT.timeoutNever}`}
|
||
style={{ color: over ? 'var(--err)' : left < 3_600_000 ? 'var(--st-waiting)' : 'var(--text-dim)' }}>
|
||
<Clock size={10} aria-hidden />
|
||
{over ? TASK_TEXT.overdue(formatSpan(-left, { coarse: true })) : TASK_TEXT.dueIn(formatSpan(left, { coarse: true }))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** 处理过的:谁、什么时候、结果(实例页上不是处理人的人看这一步的状态也用它) */
|
||
export function Resolved({ task }: {
|
||
task: Pick<Task, 'status' | 'outcome' | 'resolved_by' | 'resolved_at' | 'people'> & { payload?: Task['payload'] }
|
||
}) {
|
||
const raw = typeof task.outcome?.decision === 'string' ? task.outcome.decision : null
|
||
const said = raw ? TASK_TEXT.decision[raw] ?? raw : null
|
||
// 驳回了的写驳回之后去了哪(中断内容的 reject_route):「驳回,转到「通知申请人」」,不只写「驳回」
|
||
const route = raw === 'reject' || raw === 'expired_reject' ? rejectRouteOf(task.payload) : null
|
||
const decision = said && route ? REJECT_ROUTE_TEXT.outcome(said, route) : said
|
||
const at = task.resolved_at ? formatDateTime(task.resolved_at) : ''
|
||
return (
|
||
<div className="mb-2 flex flex-wrap items-center gap-x-1.5 text-2xs text-dim" data-task-resolved="">
|
||
<span className="chip">{TASK_STATUS_LABEL[task.status] ?? task.status}</span>
|
||
{decision && <span>{TASK_TEXT.outcome}:{decision}</span>}
|
||
{(task.resolved_by || at) && (
|
||
<span title={usernameHint(task.resolved_by, task.people)}>
|
||
{task.resolved_by ? TASK_TEXT.resolvedBy(personName(task.resolved_by, task.people), at) : TASK_TEXT.resolvedAt(at)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 「你已处理:同意(01:06)」:处理过的人再打开、还在等别人时,不说「没有派给你」 */
|
||
export function respondedText(mine: TaskMyResponse): string {
|
||
const decision = TASK_TEXT.decision[mine.decision ?? ''] ?? TASK_TEXT.assignmentStatus.responded
|
||
return TASK_TEXT.youResponded(decision, mine.at ? formatTime(mine.at) : '')
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 会签进度
|
||
|
||
const agreed = (a: TaskAssignment) => a.status === 'responded' && a.decision !== 'reject'
|
||
const rejected = (a: TaskAssignment) => a.status === 'responded' && a.decision === 'reject'
|
||
const escalated = (a: TaskAssignment) => a.source === 'escalation'
|
||
|
||
/** 服务端没给进度时(由审批记录拼出来的待办)按派给谁算:口径同服务端,升级加派的不算进会签人数 */
|
||
function progressOf(task: Pick<Task, 'progress' | 'policy' | 'assignments'>): TaskProgress {
|
||
if (task.progress) return task.progress
|
||
const own = task.assignments.filter((a) => !escalated(a) && a.status !== 'withdrawn')
|
||
const voters = own.length
|
||
const need = task.policy.kind === 'quorum' ? Math.min(task.policy.m, voters) : task.policy.kind === 'all' ? voters : 1
|
||
return { policy: task.policy.kind, voters, need, agreed: own.filter(agreed).length, rejected: own.filter(rejected).length }
|
||
}
|
||
|
||
/** 升级之后的规则:升级给谁、其中一人说了算;有人定了就写谁定了什么 */
|
||
function escalationText(p: TaskProgress, people?: Record<string, string> | null): string | null {
|
||
const e = p.escalation
|
||
if (!e) return null
|
||
if (e.decided && e.by) return TASK_TEXT.escalationDecided(personName(e.by, people), TASK_TEXT.decision[e.decided] ?? e.decided)
|
||
const who = e.slot ? TASK_TEXT.slot(e.slot)
|
||
: e.assignees.map((a) => (a.kind === 'user' ? personName(a.name, people) : a.name) || TASK_TEXT.assignee.anyone).join('、')
|
||
return TASK_TEXT.escalationRule(who)
|
||
}
|
||
|
||
/**
|
||
* 人名:有显示名写显示名(服务端在待办上给 people:用户名 → 显示名),名录里没有的(关登录时自报的署名)照写原名。
|
||
* usernameHint 是悬停给的用户名:显示名和用户名一样时不给
|
||
*/
|
||
export function personName(username: string, people?: Record<string, string> | null): string {
|
||
return people?.[username] || username
|
||
}
|
||
|
||
export function usernameHint(username: string | null | undefined, people?: Record<string, string> | null): string | undefined {
|
||
return username && personName(username, people) !== username ? username : undefined
|
||
}
|
||
|
||
export function assigneeName(a: Pick<TaskAssignment, 'principal_kind' | 'principal' | 'responded_by'>,
|
||
people?: Record<string, string> | null): string {
|
||
if (a.principal_kind === 'anyone') return TASK_TEXT.assignee.anyone
|
||
if (a.principal_kind === 'hook') return TASK_TEXT.assignee.hook
|
||
if (a.principal_kind === 'group') {
|
||
return a.responded_by ? `${personName(a.responded_by, people)}(${TASK_TEXT.assignee.group(a.principal)})` : TASK_TEXT.assignee.group(a.principal)
|
||
}
|
||
return personName(a.principal, people)
|
||
}
|
||
|
||
/** 派给谁的悬停:写的是显示名时给用户名(组里回复的人同理) */
|
||
export function assigneeHint(a: Pick<TaskAssignment, 'principal_kind' | 'principal' | 'responded_by'>,
|
||
people?: Record<string, string> | null): string | undefined {
|
||
if (a.principal_kind === 'user') return usernameHint(a.principal, people)
|
||
if (a.principal_kind === 'group') return usernameHint(a.responded_by, people)
|
||
return undefined
|
||
}
|
||
|
||
/** 会签进度的那一行(几人同意了、几人驳回、规则),升级了另写一行升级后的规则。不列名单:实例页上只看状态的人用它 */
|
||
export function SignoffHead({ task }: { task: Pick<Task, 'progress' | 'policy' | 'assignments' | 'people'> }) {
|
||
const p = progressOf(task)
|
||
const policy = task.policy.kind === 'all' ? TASK_TEXT.policy.all
|
||
: task.policy.kind === 'quorum' ? TASK_TEXT.policy.quorum(task.policy.m) : TASK_TEXT.policy.any
|
||
const up = escalationText(p, task.people)
|
||
return (
|
||
<>
|
||
<div className="mb-1 flex flex-wrap items-center gap-x-2 text-2xs" data-signoff-head="">
|
||
<span className="font-medium text-faint">{TASK_TEXT.signoffs}</span>
|
||
<span className="tnum text-fg">{TASK_TEXT.signoffCount(p.agreed, p.need)}</span>
|
||
{p.rejected > 0 && <span className="tnum" style={{ color: 'var(--err)' }}>{TASK_TEXT.signoffRejected(p.rejected)}</span>}
|
||
<span className="text-dim">{policy}</span>
|
||
</div>
|
||
{up && (
|
||
<div className="mb-1 flex items-start gap-1 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}
|
||
data-signoffs-escalation={p.escalation?.decided ? 'decided' : 'open'}>
|
||
<TriangleAlert size={10} aria-hidden className="mt-[3px] shrink-0" /> <span>{up}</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 会签:策略、几人同意了、每个人的回复。人数按服务端给的进度(task.progress):升级加派的那几份不算进会签人数,它们
|
||
* 一人说了算,单写一行规则、在名单里标「升级」。撤回的(改派、升级、已判定)列在最后、淡显
|
||
*/
|
||
export function Signoffs({ task }: { task: Task }) {
|
||
const p = progressOf(task)
|
||
const ok = p.agreed
|
||
const need = p.need
|
||
const live = task.assignments.filter((a) => a.status !== 'withdrawn')
|
||
const ordered = [...live.filter((a) => !escalated(a)), ...live.filter(escalated),
|
||
...task.assignments.filter((a) => a.status === 'withdrawn')]
|
||
return (
|
||
<div className="mb-2 rounded border bg-bg p-2" data-signoffs="" data-signoffs-agreed={ok} data-signoffs-need={need}>
|
||
<SignoffHead task={task} />
|
||
<div aria-hidden className="mb-1.5 flex h-1 gap-0.5 overflow-hidden rounded-full">
|
||
{Array.from({ length: Math.max(need, 1) }, (_, i) => (
|
||
<span key={i} className="flex-1 rounded-full"
|
||
style={{ background: i < ok ? 'var(--st-done)' : 'var(--border)' }} />
|
||
))}
|
||
</div>
|
||
<ul className="space-y-0.5 text-2xs">
|
||
{ordered.map((a) => (
|
||
<li key={a.id} className={clsx('flex flex-wrap items-center gap-x-1.5', a.status === 'withdrawn' && 'opacity-50')}
|
||
data-assignment={a.status} data-decision={a.decision ?? undefined}>
|
||
<span aria-hidden style={{ color: agreed(a) ? 'var(--st-done)' : rejected(a) ? 'var(--err)' : 'var(--text-faint)' }}>
|
||
{agreed(a) ? '✓' : rejected(a) ? '✕' : '○'}
|
||
</span>
|
||
<span className="text-fg" title={assigneeHint(a, task.people)} data-assignee="">{assigneeName(a, task.people)}</span>
|
||
{escalated(a) && <span className="chip" style={{ color: 'var(--st-waiting)' }}>{TASK_TEXT.escalationTag}</span>}
|
||
<span className="text-dim">
|
||
{a.status === 'responded'
|
||
? TASK_TEXT.decision[a.decision ?? ''] ?? TASK_TEXT.assignmentStatus.responded
|
||
: TASK_TEXT.assignmentStatus[a.status]}
|
||
</span>
|
||
{a.responded_at && <span className="text-faint" title={formatDateTime(a.responded_at)}>{formatTime(a.responded_at)}</span>}
|
||
{a.status === 'responded' && (
|
||
<span className="text-faint">{a.verified ? TASK_TEXT.verified : TASK_TEXT.unverified}</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 动作确认、核实、等外部系统
|
||
|
||
/** 遮罩后的预览:一层键值,嵌套的按 JSON 写。值是服务端遮过的,原样显示 */
|
||
function Preview({ preview }: { preview: unknown }) {
|
||
if (!preview || typeof preview !== 'object') {
|
||
return <div className="text-2xs text-faint">{ACTION_CONFIRM_TEXT.previewEmpty}</div>
|
||
}
|
||
const rows = Object.entries(preview as Record<string, unknown>)
|
||
if (!rows.length) return <div className="text-2xs text-faint">{ACTION_CONFIRM_TEXT.previewEmpty}</div>
|
||
return (
|
||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-2xs" data-action-preview="">
|
||
{rows.map(([k, v]) => (
|
||
<div key={k} className="contents">
|
||
<dt className="mono text-faint">{k}</dt>
|
||
<dd className="mono min-w-0 break-all text-fg">{typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
)
|
||
}
|
||
|
||
function KeyLine({ label, children }: { label: string; children: ReactNode }) {
|
||
return (
|
||
<div className="flex flex-wrap items-baseline gap-x-2 text-2xs">
|
||
<span className="shrink-0 text-faint">{label}</span>
|
||
<span className="min-w-0 text-fg">{children}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const envLabel = (env: unknown) => (typeof env === 'string' ? ACTION_ENV_LABEL[env] ?? env : '')
|
||
|
||
function ActionConfirm({ task, canAct, busy, note, setNote, onConfirm, onReject }: {
|
||
task: Task; canAct: boolean; busy: boolean; note: string; setNote: (v: string) => void
|
||
onConfirm: (hash: string) => void; onReject: () => void
|
||
}) {
|
||
const p: Record<string, any> = task.payload ?? {}
|
||
const hash = typeof p.payload_hash === 'string' ? p.payload_hash : ''
|
||
const prod = p.environment === 'prod'
|
||
return (
|
||
<div className="mb-2 space-y-1.5 rounded border bg-bg p-2.5" data-action-confirm="" data-payload-hash={hash || undefined}>
|
||
<div className="text-xs font-medium text-fg">{ACTION_CONFIRM_TEXT.head}</div>
|
||
<p className="text-2xs leading-relaxed text-dim">{ACTION_CONFIRM_TEXT.intro}</p>
|
||
{p.capability && <KeyLine label={ACTION_CONFIRM_TEXT.capability}><code className="mono">{String(p.capability)}</code></KeyLine>}
|
||
{p.environment && (
|
||
<KeyLine label={ACTION_CONFIRM_TEXT.environment}>
|
||
<span data-action-env={p.environment} style={prod ? { color: 'var(--warn)', fontWeight: 600 } : undefined}>
|
||
{envLabel(p.environment)}
|
||
</span>
|
||
</KeyLine>
|
||
)}
|
||
{typeof p.attempt === 'number' && p.attempt > 1 && (
|
||
<KeyLine label="">{ACTION_CONFIRM_TEXT.attempt(p.attempt)}</KeyLine>
|
||
)}
|
||
<KeyLine label={ACTION_CONFIRM_TEXT.hash}>
|
||
{hash
|
||
? <code className="mono" title={ACTION_CONFIRM_TEXT.hashHint(hash)} data-action-hash="">{hash.slice(0, 8)}</code>
|
||
: <span style={{ color: 'var(--err)' }}>{ACTION_CONFIRM_TEXT.noHash}</span>}
|
||
</KeyLine>
|
||
<div>
|
||
<div className="mb-0.5 text-2xs text-faint">{ACTION_CONFIRM_TEXT.preview}</div>
|
||
<div className="rounded border p-1.5"><Preview preview={p.preview} /></div>
|
||
</div>
|
||
<p className="text-2xs leading-relaxed text-dim">{ACTION_CONFIRM_TEXT.fixed}</p>
|
||
{canAct && (
|
||
<>
|
||
<input className="field" aria-label={TASK_TEXT.note} placeholder={TASK_TEXT.notePlaceholder} value={note}
|
||
onChange={(e) => setNote(e.target.value)} />
|
||
<div className="flex gap-2">
|
||
<button type="button" className="btn btn-primary flex-1 justify-center" disabled={busy || !hash}
|
||
data-action-confirm-send="" onClick={() => onConfirm(hash)}>
|
||
{busy ? <Spinner size={12} /> : <Send size={12} aria-hidden />} {ACTION_CONFIRM_TEXT.confirm}
|
||
</button>
|
||
<button type="button" className="btn btn-danger flex-1 justify-center" disabled={busy}
|
||
data-action-confirm-reject="" onClick={onReject}>
|
||
<Ban size={12} aria-hidden /> {ACTION_CONFIRM_TEXT.reject}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type ReconcileChoice = 'confirmed' | 'not_applied_resend' | 'not_applied_abandon'
|
||
|
||
function Reconcile({ task, canAct, busy, note, setNote, onSubmit }: {
|
||
task: Task; canAct: boolean; busy: boolean; note: string; setNote: (v: string) => void
|
||
onSubmit: (body: TaskRespond) => void
|
||
}) {
|
||
const p: Record<string, any> = task.payload ?? {}
|
||
const [choice, setChoice] = useState<ReconcileChoice | null>(null)
|
||
const [externalId, setExternalId] = useState('')
|
||
const [tried, setTried] = useState(false)
|
||
const name = `reconcile-${task.id}`
|
||
const sentAt = p.sent_at ?? p.request_at
|
||
const missingId = choice === 'confirmed' && !externalId.trim()
|
||
const options: { key: ReconcileChoice; label: string; hint: string }[] = [
|
||
{ key: 'confirmed', label: RECONCILE_TEXT.confirmed, hint: RECONCILE_TEXT.confirmedHint },
|
||
{ key: 'not_applied_resend', label: RECONCILE_TEXT.resend, hint: RECONCILE_TEXT.resendHint },
|
||
{ key: 'not_applied_abandon', label: RECONCILE_TEXT.abandon, hint: RECONCILE_TEXT.abandonHint },
|
||
]
|
||
const submit = () => {
|
||
setTried(true)
|
||
if (!choice || missingId) return
|
||
onSubmit({ decision: choice, ...(choice === 'confirmed' ? { external_id: externalId.trim() } : {}) })
|
||
}
|
||
return (
|
||
<div className="mb-2 space-y-1.5 rounded border bg-bg p-2.5" data-reconcile="">
|
||
<div className="text-xs font-medium text-fg">{RECONCILE_TEXT.head}</div>
|
||
<p className="text-2xs leading-relaxed text-dim">{RECONCILE_TEXT.intro}</p>
|
||
{p.capability && <KeyLine label={ACTION_CONFIRM_TEXT.capability}><code className="mono">{String(p.capability)}</code></KeyLine>}
|
||
{p.environment && <KeyLine label={ACTION_CONFIRM_TEXT.environment}>{envLabel(p.environment)}</KeyLine>}
|
||
{sentAt && <KeyLine label="">{RECONCILE_TEXT.sentAt(formatDateTime(sentAt))}</KeyLine>}
|
||
{p.idem_key && <KeyLine label={RECONCILE_TEXT.idemKey}><code className="mono break-all">{String(p.idem_key)}</code></KeyLine>}
|
||
{typeof p.payload_hash === 'string' && (
|
||
<KeyLine label={ACTION_CONFIRM_TEXT.hash}>
|
||
<code className="mono" title={ACTION_CONFIRM_TEXT.hashHint(p.payload_hash)}>{p.payload_hash.slice(0, 8)}</code>
|
||
</KeyLine>
|
||
)}
|
||
{p.error && <KeyLine label={RECONCILE_TEXT.error}><span className="break-all">{String(p.error)}</span></KeyLine>}
|
||
{p.preview && <div className="rounded border p-1.5"><Preview preview={p.preview} /></div>}
|
||
{canAct && (
|
||
<>
|
||
<fieldset className="space-y-1" role="radiogroup" aria-label={RECONCILE_TEXT.head}>
|
||
{options.map((o) => (
|
||
<label key={o.key} className="flex items-start gap-2 rounded px-1 py-0.5 text-xs hover:bg-hover" data-reconcile-option={o.key}>
|
||
<input type="radio" name={name} className="mt-0.5" checked={choice === o.key} onChange={() => setChoice(o.key)} />
|
||
<span className="min-w-0">
|
||
<span className="text-fg">{o.label}</span>
|
||
<span className="block text-2xs text-dim">{o.hint}</span>
|
||
</span>
|
||
</label>
|
||
))}
|
||
</fieldset>
|
||
{choice === 'confirmed' && (
|
||
<div>
|
||
<label className="label" htmlFor={`${name}-ext`}>
|
||
{RECONCILE_TEXT.externalId}<span className="ml-0.5 text-[var(--err)]" aria-hidden>*</span>
|
||
</label>
|
||
<input id={`${name}-ext`} className="field mono" value={externalId} aria-required
|
||
aria-invalid={tried && missingId ? true : undefined} data-reconcile-external=""
|
||
onChange={(e) => setExternalId(e.target.value)} />
|
||
{tried && missingId && <div className="mt-1 text-2xs text-[var(--err)]">{RECONCILE_TEXT.externalIdRequired}</div>}
|
||
</div>
|
||
)}
|
||
{tried && !choice && <div className="text-2xs text-[var(--err)]">{RECONCILE_TEXT.pick}</div>}
|
||
<input className="field" aria-label={TASK_TEXT.note} placeholder={TASK_TEXT.notePlaceholder} value={note}
|
||
onChange={(e) => setNote(e.target.value)} />
|
||
<button type="button" className="btn btn-primary w-full justify-center" disabled={busy} data-reconcile-submit=""
|
||
onClick={submit}>
|
||
{busy ? <Spinner size={12} /> : <Check size={12} aria-hidden />} {RECONCILE_TEXT.submit}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SignalNote({ payload }: { payload: Record<string, any> }) {
|
||
const correlation = payload.correlation ?? payload.correlation_key
|
||
return (
|
||
<div className="mb-2 space-y-1 rounded border border-dashed bg-bg p-2.5" data-task-signal="">
|
||
<div className="flex items-center gap-1.5 text-xs font-medium text-fg">
|
||
<Webhook size={12} aria-hidden /> {SIGNAL_TEXT.head}
|
||
</div>
|
||
<p className="text-2xs leading-relaxed text-dim">{SIGNAL_TEXT.intro}</p>
|
||
{correlation && <KeyLine label={SIGNAL_TEXT.correlation}><code className="mono">{String(correlation)}</code></KeyLine>}
|
||
{payload.hook && <KeyLine label={SIGNAL_TEXT.hook}><code className="mono">{String(payload.hook)}</code></KeyLine>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 底部
|
||
|
||
/**
|
||
* 以谁的名义处理:开了登录写登录身份(核实过的,没有署名框);关登录写本机署名,没署名时提醒——留痕里会是空的
|
||
*/
|
||
function Signer() {
|
||
const loginOn = useLoginOn()
|
||
const me = usePrincipal()
|
||
const actor = useLocalActor()
|
||
if (loginOn) {
|
||
return (
|
||
<div className="mt-1 flex items-center gap-1 text-2xs leading-relaxed text-dim" data-task-signer="verified">
|
||
<PenLine size={10} aria-hidden className="shrink-0" />
|
||
<span>{TASK_TEXT.signAsVerified(principalName(me))}</span>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="mt-1 flex items-center gap-1 text-2xs leading-relaxed" data-task-signer="actor"
|
||
style={actor ? { color: 'var(--text-dim)' } : { color: 'var(--st-waiting)' }}>
|
||
<PenLine size={10} aria-hidden className="shrink-0" />
|
||
{actor
|
||
? <span>{TASK_TEXT.signAs(actor)}</span>
|
||
: (
|
||
<span>
|
||
{TASK_TEXT.unsigned} ·{' '}
|
||
<Link to="/settings/prefs" className="underline underline-offset-2 hover:text-fg">{TASK_TEXT.setActor}</Link>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 去实例页(应用里的)或记录页(别处的,搭建者以上才看得到记录页)。
|
||
*
|
||
* 审核个人应用的待办不给这两个:run_id 是提交人那次个人应用的刷新、app_id 是他的个人应用,只有拥有者读得到(服务端
|
||
* auth/access.private_owner,处理人是管理员、搭建者也不例外),链出去只会是打不开的页面。处理人要看的是取副本之后在副本
|
||
* 蓝图上试运行出来的(Review 里的「取副本,去发布」「打开我的副本」)
|
||
*/
|
||
function TaskLinks({ task }: { task: Task }) {
|
||
const builder = useAtLeast('builder')
|
||
const links = useMemo(() => {
|
||
const out: { to: string; label: string }[] = []
|
||
if (task.kind === 'review') return out
|
||
if (task.app_id) out.push({ to: `/apps/${encodeURIComponent(task.app_id)}/i/${encodeURIComponent(task.run_id)}`, label: TASK_TEXT.openInstance })
|
||
if (builder) out.push({ to: `/runs/${encodeURIComponent(task.run_id)}`, label: TASK_TEXT.openRun })
|
||
return out
|
||
}, [task.kind, task.app_id, task.run_id, builder])
|
||
if (!links.length) return null
|
||
return (
|
||
<div className="mt-1.5 flex flex-wrap gap-x-3 text-2xs">
|
||
{links.map((l) => (
|
||
<Link key={l.to} to={l.to} className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
|
||
<ExternalLink size={10} aria-hidden /> {l.label}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|