前端:审批卡按审批当场拼出待办(不再为一张卡取一次待办,收到中断就出卡;有 task_id 走待办接口,没有走老的审批接口);矮窗口导航两档高度不再叠加,480px 放得下九项;待办数随目录启动时就取、外壳订阅时当场记底,派给自己的新待办通知不再漏;头一回取目录的卡住从启动算起;外壳横向裁掉悬停提示的占位;证据抽屉同一指标在取时不另发请求;编辑器去掉第二个 main、检查一次只跑一遍;记录页对不上待办的审批照旧画审批卡,待办行写 runName 和节点名;检查脚本按新页面更新断言(沙箱没有待办时用合成的两条、未保存与已删除工作流分开认、生效记录页签、能力子页签)

This commit is contained in:
ThearchyHelios
2026-10-14 23:57:43 +08:00
parent 49f6f42df2
commit c6a29bfd7c
21 changed files with 355 additions and 154 deletions
+25 -7
View File
@@ -126,7 +126,9 @@ export default function App() {
if (booting) return <BootScreen onEnterOffline={() => setOfflineOk(true)} /> if (booting) return <BootScreen onEnterOffline={() => setOfflineOk(true)} />
return ( return (
<div className="flex h-full"> // 横向裁掉:导航项的悬停提示(不可见时也占位)在窄屏上伸出视口,整页就能左右滚出一块空白。clip 不建滚动容器,
// 粘性定位和纵向都照旧
<div className="flex h-full overflow-x-clip">
<Nav stalled={stalled} /> <Nav stalled={stalled} />
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
@@ -319,9 +321,9 @@ function Nav({ stalled }: { stalled: CatalogCheck[] }) {
const pages = usePages() const pages = usePages()
// 整条导航约 640px 高(九个导航项),窗口再矮,最底下的遥测点和署名就被切掉、够不着。 // 整条导航约 640px 高(九个导航项),窗口再矮,最底下的遥测点和署名就被切掉、够不着。
// 矮窗口(≤660px)按高度收紧到 500px 左右(导航项 36px),≤520px 再收一档到 460px 左右(导航项 32px、 // 矮窗口(521–660px)按高度收紧到 500px 左右(导航项 36px),≤520px 再收一档到 460px 左右(导航项 32px、
// 图标和字之间只留 2px),而不是让导航滚动:导航一旦 overflow,挂在右侧的悬停提示和遥测浮层也会被 // 图标和字之间只留 2px),而不是让导航滚动:导航一旦 overflow,挂在右侧的悬停提示和遥测浮层也会被
// 一起裁掉。加导航项时在 600px、480px 高的窗口里量一遍(check-shell 的「窗口高 …px」) // 一起裁掉。加导航项时在 600px、520px、480px 高的窗口里量一遍(check-shell 的「窗口高 …px」)
return ( return (
<nav aria-label="主导航" className="relative z-30 flex w-16 shrink-0 flex-col border-r bg-panel"> <nav aria-label="主导航" className="relative z-30 flex w-16 shrink-0 flex-col border-r bg-panel">
<Link <Link
@@ -345,7 +347,9 @@ function Nav({ stalled }: { stalled: CatalogCheck[] }) {
aria-keyshortcuts={ariaShortcut(p.shortcut)} aria-keyshortcuts={ariaShortcut(p.shortcut)}
className={({ isActive }) => clsx( className={({ isActive }) => clsx(
'group relative flex h-12 w-full flex-col items-center justify-center gap-1 rounded-md text-2xs leading-none outline-none', 'group relative flex h-12 w-full flex-col items-center justify-center gap-1 rounded-md text-2xs leading-none outline-none',
'[@media(max-height:660px)]:h-9 [@media(max-height:520px)]:h-8 [@media(max-height:520px)]:gap-0.5', // 两档的高度不能叠在同一个窗口高度上:同时命中时后出的那条赢,而 Tailwind 把 660 那条排在后面,
// ≤520px 的 32px 就一直没生效(九项在 480px 高的窗口里放不下)。所以 36px 那档只管 521–660px
'[@media(min-height:521px)_and_(max-height:660px)]:h-9 [@media(max-height:520px)]:h-8 [@media(max-height:520px)]:gap-0.5',
'transition-colors focus-visible:ring-2 focus-visible:ring-[var(--accent)]', 'transition-colors focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
isActive ? 'bg-hover font-medium text-fg' : 'text-dim hover:bg-hover hover:text-fg', isActive ? 'bg-hover font-medium text-fg' : 'text-dim hover:bg-hover hover:text-fg',
)} )}
@@ -621,6 +625,10 @@ const NO_CHECKS: CatalogCheck[] = []
/** 请求发出去这么久还没回来,就不算「还在加载」,算卡住了 */ /** 请求发出去这么久还没回来,就不算「还在加载」,算卡住了 */
const STALL_MS = 3000 const STALL_MS = 3000
/** 页面开始加载外壳的时刻(头一回取目录的卡住从这里算,见 useStalledChecks) */
const BOOT_AT = performance.now()
/** 头一回取目录还没有着落 */
let bootLoad = true
/** /**
* 卡着没回来的 catalog 请求(后端没断时)。 * 卡着没回来的 catalog 请求(后端没断时)。
@@ -636,10 +644,17 @@ function useStalledChecks(): CatalogCheck[] {
const checks = useCatalog((s) => s.checks) const checks = useCatalog((s) => s.checks)
const [stalled, setStalled] = useState(false) const [stalled, setStalled] = useState(false)
useEffect(() => { useEffect(() => {
if (!waiting) return if (!waiting) {
const t = setTimeout(() => setStalled(true), STALL_MS) // 头一回取目录有了着落(取回来了或出错了):之后的刷新都从发出请求算
if (checks.length) bootLoad = false
return
}
// 启动时先认人(最多等 AUTH_WAIT_MS)再取目录:头一回加载从启动算起,认人等掉的那段也算——人已经在启动页上
// 等了那么久,进来以后不能还说「在线」
const wait = bootLoad ? Math.max(0, STALL_MS - (performance.now() - BOOT_AT)) : STALL_MS
const t = setTimeout(() => setStalled(true), wait)
return () => { clearTimeout(t); setStalled(false) } return () => { clearTimeout(t); setStalled(false) }
}, [waiting]) }, [waiting, checks.length])
// 断开时归离线横幅说,这里不再叠一层 // 断开时归离线横幅说,这里不再叠一层
return stalled && waiting && !down ? checks.filter((c) => c.state === 'pending') : NO_CHECKS return stalled && waiting && !down ? checks.filter((c) => c.state === 'pending') : NO_CHECKS
} }
@@ -1190,6 +1205,9 @@ function watchTasks(go: Go): () => void {
if (again) { again = false; void pull() } if (again) { again = false; void pull() }
} }
} }
// 待办数随目录在启动时就取了,常常早于外壳挂上这个订阅:已经有数时当场记底,别等到它下一次变——那时新来的
// 已经在列表里,会被当成「已有的」记进底里,一条通知都不发
if (useCatalog.getState().taskCounts && !needsLogin(useAuth.getState().me)) void pull()
return useCatalog.subscribe((s, p) => { return useCatalog.subscribe((s, p) => {
if (s.taskCounts === p.taskCounts || !s.taskCounts) return if (s.taskCounts === p.taskCounts || !s.taskCounts) return
if (seen && s.taskCounts.mine === p.taskCounts?.mine && s.taskCounts.latest === p.taskCounts?.latest) return if (seen && s.taskCounts.mine === p.taskCounts?.mine && s.taskCounts.latest === p.taskCounts?.latest) return
+17 -1
View File
@@ -81,6 +81,22 @@ export function EvidenceDrawer({ target, onClose }: { target: EvidenceTarget | n
) )
} }
/**
* 正在取的指标链,按缓存键记一份:同一个指标块在取的途中又挂了一次(开发时 StrictMode 的二次挂载、上层重画换了 target
* 对象)不另发一次请求,等同一个结果。取完(成也好败也好)就摘掉,重试时重新发
*/
const inflight = new Map<string, ReturnType<typeof api.apps.metricEvidence>>()
function fetchMetric(key: string, ev: ViewEvidence) {
let p = inflight.get(key)
if (!p) {
p = api.apps.metricEvidence(ev.run_id, ev.node!, ev.metric!)
inflight.set(key, p)
const drop = () => { if (inflight.get(key) === p) inflight.delete(key) }
p.then(drop, drop)
}
return p
}
function MetricChain({ target, onClose }: { target: EvidenceTarget; onClose: () => void }) { function MetricChain({ target, onClose }: { target: EvidenceTarget; onClose: () => void }) {
const ev = target.evidence const ev = target.evidence
const segId = segIdOf(ev) const segId = segIdOf(ev)
@@ -94,7 +110,7 @@ function MetricChain({ target, onClose }: { target: EvidenceTarget; onClose: ()
if (seeded) return if (seeded) return
let alive = true let alive = true
setError(null) setError(null)
api.apps.metricEvidence(ev.run_id, ev.node!, ev.metric!).then( fetchMetric(key, ev).then(
(detail) => { (detail) => {
if (!alive) return if (!alive) return
// 接口答的是这个指标,不是报告里的片段:片段的说法换成这一份文档里的,面板才不会当成「另一份报告」 // 接口答的是这个指标,不是报告里的片段:片段的说法换成这一份文档里的,面板才不会当成「另一份报告」
+6 -29
View File
@@ -1,39 +1,16 @@
/** /**
* 预览里合成的审批带上它对应的待办(应用平台 X4:一次中断对应一条 Approval 和一条 Task,审批卡内容在 run/TaskCard, * 预览里合成的审批带上待办 id(同服务端审批列表上的 task_id)和待办本身。审批卡(RunPanel 的 ApprovalCard)按审批当场拼出
* 回复走 /api/tasks/{id}/respond)。审批卡(RunPanel 的 ApprovalCard)看到审批上带着 task 就直接用,不再按运行去取待办 * 待办来渲染(run/approvalTask),带着 task_id 时回复走 /api/tasks/{id}/respond;检查脚本按 task-<审批 id> 伪造这个接口。
* ——预览页没有服务端,按运行取只会落到「未能读取这条审批对应的待办」。 * 带上 task 是给想换掉某几项(表单、会签)的样例用的:审批卡看到它就照用
*
* 种类同服务端 process/tasks.kind_of:人工节点按 mode,工具审批、代码审批、双盲复算、动作确认按中断内容的 kind。
* 检查脚本按 task-<审批 id> 伪造 /api/tasks/{id}/respond
*/ */
import type { Approval } from '../types' import type { Approval } from '../types'
import type { Task, TaskKind } from '../typesApp' import type { Task } from '../typesApp'
import { taskFromApproval } from '../run/approvalTask'
const KIND_OF: Record<string, TaskKind> = {
tool_approval: 'tool', code_approval: 'code', dual_check: 'dual_check', action_confirm: 'action_confirm',
}
export const taskIdOf = (approvalId: string) => `task-${approvalId}` export const taskIdOf = (approvalId: string) => `task-${approvalId}`
export function taskOf(a: Approval, patch: Partial<Task> = {}): Task { export function taskOf(a: Approval, patch: Partial<Task> = {}): Task {
const p: Record<string, any> = a.payload ?? {} return taskFromApproval(a, taskIdOf(a.id), patch)
const mode = String(p.mode ?? a.mode ?? 'approve')
const kind: TaskKind = p.kind === 'human_node'
? (mode === 'input' || mode === 'edit' ? mode : 'approve')
: KIND_OF[String(p.kind)] ?? 'approve'
const pending = a.status === 'pending'
return {
id: taskIdOf(a.id), approval_id: a.id, run_id: a.run_id, node_id: a.node_id, node_label: a.node_label ?? null,
run_class: a.run_class ?? null, workflow_id: a.workflow_id ?? null, workflow_name: a.workflow_name ?? null,
app_id: null, app_version: null, kind, title: a.title, form: null, policy: { kind: 'any' },
status: pending ? 'pending' : 'resolved', due_at: null, escalation_level: 0, payload: p,
assignments: [{
id: `as-${a.id}`, principal_kind: 'anyone', principal: '', source: 'anyone', status: pending ? 'pending' : 'responded',
responded_by: a.resolved_by ?? null, responded_at: a.resolved_at ?? null, verified: false,
}],
can_respond: pending, outcome: pending ? null : (a.response ?? {}), resolved_by: a.resolved_by ?? null,
resolved_at: a.resolved_at ?? null, created_at: a.created_at ?? new Date().toISOString(), ...patch,
}
} }
/** 审批带上待办和待办 id(同服务端审批列表上的 task_id) */ /** 审批带上待办和待办 id(同服务端审批列表上的 task_id) */
-2
View File
@@ -4937,8 +4937,6 @@ export const TASK_LOG_TEXT = {
assignTo: (who: string[]) => `派给 ${who.join('、')}`, assignTo: (who: string[]) => `派给 ${who.join('、')}`,
anyone: '任何人', anyone: '任何人',
initiator: '发起人', initiator: '发起人',
loadFailed: '未能读取这条审批对应的待办',
retry: '重试',
// ---- 时间线(task.* 事件) // ---- 时间线(task.* 事件)
opened: (title: string) => `待办「${title}」已打开`, opened: (title: string) => `待办「${title}」已打开`,
responded: (who: string, decision: string, i?: number, n?: number) => responded: (who: string, decision: string, i?: number, n?: number) =>
+3 -2
View File
@@ -245,7 +245,8 @@ function EditApp({ appId }: { appId: string }) {
</Link> </Link>
))} ))}
</nav> </nav>
<main className="min-w-0 flex-1 overflow-y-auto" data-app-pane={section}> {/* 外壳已经有 <main>,这里再套一个就是两个主区域地标(同记录页) */}
<section className="min-w-0 flex-1 overflow-y-auto" data-app-pane={section} aria-label={T.sections[section]}>
{section === 'pins' && <PinsSection />} {section === 'pins' && <PinsSection />}
{section === 'pages' && (ctx.catalog ? <PagesSection /> : <div className="p-4"><Skeleton rows={4} /></div>)} {section === 'pages' && (ctx.catalog ? <PagesSection /> : <div className="p-4"><Skeleton rows={4} /></div>)}
{section === 'forms' && <FormsSection />} {section === 'forms' && <FormsSection />}
@@ -255,7 +256,7 @@ function EditApp({ appId }: { appId: string }) {
{section === 'access' && <AccessSection />} {section === 'access' && <AccessSection />}
{section === 'hooks' && <HooksSection />} {section === 'hooks' && <HooksSection />}
{section === 'notify' && <NotifySection />} {section === 'notify' && <NotifySection />}
</main> </section>
{assistant && ( {assistant && (
<aside className="flex w-[360px] shrink-0 flex-col border-l bg-panel" aria-label={T.assistant}> <aside className="flex w-[360px] shrink-0 flex-col border-l bg-panel" aria-label={T.assistant}>
<AppAssistant /> <AppAssistant />
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertTriangle, CheckCircle2, Crosshair, RotateCw, ShieldCheck, XCircle } from 'lucide-react' import { AlertTriangle, CheckCircle2, Crosshair, RotateCw, ShieldCheck, XCircle } from 'lucide-react'
import { api } from '../../../api/client' import { api } from '../../../api/client'
import { EmptyState, ErrorState, Field, Modal, Notice, SectionBar, Skeleton, Spinner, toast } from '../../../components/ui' import { EmptyState, ErrorState, Field, Modal, Notice, SectionBar, Skeleton, Spinner, toast } from '../../../components/ui'
@@ -80,7 +80,11 @@ export function CheckSection() {
const [result, setResult] = useState<{ issues: LintIssue[]; level: string | null } | null>(null) const [result, setResult] = useState<{ issues: LintIssue[]; level: string | null } | null>(null)
const [error, setError] = useState<unknown>(null) const [error, setError] = useState<unknown>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
// 一次只跑一遍:上一遍没回来又来一次(开发时 StrictMode 的二次挂载)不另发,免得两份结果先后覆盖
const running = useRef(false)
const run = useCallback(async () => { const run = useCallback(async () => {
if (running.current) return
running.current = true
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
@@ -89,6 +93,7 @@ export function CheckSection() {
} catch (e) { } catch (e) {
setError(e) setError(e)
} finally { } finally {
running.current = false
setBusy(false) setBusy(false)
} }
}, [appId, dirty, save]) }, [appId, dirty, save])
+11 -4
View File
@@ -77,7 +77,8 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
const run = d.run const run = d.run
// 待办卡:这次运行待处理的待办(含没有审批的核实动作结果、等外部系统)。待审批、运行状态、待办和动作的事件变了就重新取 // 待办卡:这次运行待处理的待办(含没有审批的核实动作结果、等外部系统)。待审批、运行状态、待办和动作的事件变了就重新取
const taskEvents = d.events.filter((e) => e.type.startsWith('task.') || e.type.startsWith('action.')).length const taskEvents = d.events.filter((e) => e.type.startsWith('task.') || e.type.startsWith('action.')).length
const runTasks = useRunTasks(runId, `${(d.pending ?? []).map((a) => a.id).join(',')}|${run?.status ?? ''}|${taskEvents}`) const pendingIds = (d.pending ?? []).map((a) => a.id)
const runTasks = useRunTasks(runId, `${pendingIds.join(',')}|${run?.status ?? ''}|${taskEvents}`, pendingIds)
// 停着等人的不只有审批:动作结果未知时运行挂起、开一条核实待办,那也是在等人,不是服务重启打断的「已挂起」 // 停着等人的不只有审批:动作结果未知时运行挂起、开一条核实待办,那也是在等人,不是服务重启打断的「已挂起」
const pendingFlag = d.pending == null ? undefined : d.pending.length > 0 || !!runTasks.tasks?.length const pendingFlag = d.pending == null ? undefined : d.pending.length > 0 || !!runTasks.tasks?.length
// 接着流时事件就是事实;不接流时把 GET 到的状态和有没有待审批交给解码器收尾: // 接着流时事件就是事实;不接流时把 GET 到的状态和有没有待审批交给解码器收尾:
@@ -527,9 +528,15 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
: phase !== 'waiting' && clocks.wall != null ? { elapsedMs: paused ? clocks.active ?? clocks.wall : clocks.wall } : {}), : phase !== 'waiting' && clocks.wall != null ? { elapsedMs: paused ? clocks.active ?? clocks.wall : clocks.wall } : {}),
} }
// 待办卡(TaskCard):一次中断一条待办;凑齐了运行恢复,接上实时流。没凑齐(会签里记下一票)只刷新这几张卡 // 待办卡(TaskCard):一次中断一条待办;凑齐了运行恢复,接上实时流。没凑齐(会签里记下一票)只刷新这几张卡。
const cards = runTasks.tasks?.length ? ( // 待审批里对不上待办的(开待办出过错还没补上、老服务端没有待办接口)按审批画审批卡。只认取待办那一刻就在的审批:
<RunTaskCards tasks={runTasks.tasks} onResolved={async (task, resolved) => { // 刚来的那条,待办还在路上,不先按审批画一张、取回来又换成待办卡
const covered = new Set((runTasks.tasks ?? []).map((t) => t.approval_id).filter(Boolean))
const orphans = (d.pending ?? []).filter((a) => runTasks.asked.has(a.id) && !covered.has(a.id))
const cards = runTasks.tasks?.length || orphans.length ? (
<RunTaskCards tasks={runTasks.tasks ?? []} approvals={orphans}
onApprovalResolved={async (a) => { await onResolved(a); runTasks.reload() }}
onResolved={async (task, resolved) => {
if (!resolved) { runTasks.reload(); return } if (!resolved) { runTasks.reload(); return }
const approval = task.approval_id ? d.pending?.find((a) => a.id === task.approval_id) : undefined const approval = task.approval_id ? d.pending?.find((a) => a.id === task.approval_id) : undefined
if (approval) await onResolved(approval) if (approval) await onResolved(approval)
+6 -4
View File
@@ -533,7 +533,9 @@ export function TaskRows({ queue, filter, onOpen, selectedId, dupNames }: {
const overdue = due != null && due.getTime() < now const overdue = due != null && due.getTime() < now
const responded = t.assignments.filter((a) => a.status === 'responded').length const responded = t.assignments.filter((a) => a.status === 'responded').length
const counted = t.assignments.filter((a) => a.status !== 'withdrawn').length const counted = t.assignments.filter((a) => a.status !== 'withdrawn').length
const name = t.workflow_name ?? '' // 应用里的待办写应用名;别处的同审批行:没保存就跑的写「未保存的工作流」,不露服务端记的「临时图」
const name = t.app_name || runName(t)
const unsaved = !t.app_name && isUnsaved(t)
return ( return (
<button <button
key={t.id} key={t.id}
@@ -566,9 +568,9 @@ export function TaskRows({ queue, filter, onOpen, selectedId, dupNames }: {
</span> </span>
</div> </div>
<div className="min-w-0 truncate text-2xs text-dim"> <div className="min-w-0 truncate text-2xs text-dim">
<span>{name || '—'}</span> <span className={unsaved ? 'text-faint' : undefined}>{name || '—'}</span>
{t.node_id && <><span className="text-faint"> · 节点</span>「{t.node_id}」</>} {t.node_id && <><span className="text-faint"> · 节点</span>「{t.node_label ?? t.node_id}」</>}
{name && dupNames.has(name) && <span className="mono text-faint"> {idTail(t.run_id)}</span>} {name && !unsaved && dupNames.has(name) && <span className="mono text-faint"> {idTail(t.run_id)}</span>}
</div> </div>
<div className="flex items-center gap-2 text-2xs text-faint"> <div className="flex items-center gap-2 text-2xs text-faint">
<span className="chip shrink-0" data-task-kind-chip="">{TASK_KIND_LABEL[t.kind]}</span> <span className="chip shrink-0" data-task-kind-chip="">{TASK_KIND_LABEL[t.kind]}</span>
+40 -13
View File
@@ -5,6 +5,8 @@ import { EmptyState, ErrorState, Skeleton } from '../../components/ui'
import { formatDateTime } from '../../lib/format' import { formatDateTime } from '../../lib/format'
import { ACTION_ENV_LABEL, ACTION_LOG_TEXT, ACTION_STATE_LABEL, ACTION_TEXT } from '../../lib/terms' import { ACTION_ENV_LABEL, ACTION_LOG_TEXT, ACTION_STATE_LABEL, ACTION_TEXT } from '../../lib/terms'
import { TaskCard } from '../../run/TaskCard' import { TaskCard } from '../../run/TaskCard'
import { ApprovalCard } from '../../run/RunPanel'
import type { Approval } from '../../types'
import type { ActionRecordRow, Task } from '../../typesApp' import type { ActionRecordRow, Task } from '../../typesApp'
/* /*
@@ -12,41 +14,66 @@ import type { ActionRecordRow, Task } from '../../typesApp'
* *
* 待办卡:这次运行待处理的待办,一条一张 TaskCard。以前按待审批画 ApprovalCard,可核实结果未知的动作、等外部系统 * 待办卡:这次运行待处理的待办,一条一张 TaskCard。以前按待审批画 ApprovalCard,可核实结果未知的动作、等外部系统
* 回调这两种待办没有审批,时间线上就没有地方处理它们。卡片挂 data-approval-card(有审批的写审批 id):等待横幅的 * 回调这两种待办没有审批,时间线上就没有地方处理它们。卡片挂 data-approval-card(有审批的写审批 id):等待横幅的
* 「去处理」按它定位。 * 「去处理」按它定位。待审批里对不上待办的(开待办出过错、老服务端)照旧按审批画审批卡,不能让等着的审批无处处理。
* *
* 生效记录:这次运行对外部系统的每次写入(权威记录在服务端 action_records),状态、环境、哈希前 8 位、回执。 * 生效记录:这次运行对外部系统的每次写入(权威记录在服务端 action_records),状态、环境、哈希前 8 位、回执。
* 时间线上的 action.* 事件只是它的影子:核实、重新发送都改这里的记录 * 时间线上的 action.* 事件只是它的影子:核实、重新发送都改这里的记录
*/ */
/** 这次运行待处理的待办。sig 变了(待审批列表变了、运行状态变了)就重新取 */ /**
export function useRunTasks(runId: string, sig: string): { tasks: Task[] | null; reload: () => void } { * 这次运行待处理的待办。sig 变了(待审批列表变了、运行状态变了)就重新取。
const [tasks, setTasks] = useState<Task[] | null>(null) *
* asked:取这一次时手上有哪几条待审批。待审批里对不上待办的只在这几条里认——刚来的审批,它的待办还在路上,
* 等这次取回来再说,不先按审批画一张、取回来又换成待办卡
*/
export function useRunTasks(runId: string, sig: string, pendingIds: string[] = []): {
tasks: Task[] | null; asked: ReadonlySet<string>; reload: () => void
} {
const [got, setGot] = useState<{ tasks: Task[]; asked: ReadonlySet<string> } | null>(null)
const [epoch, setEpoch] = useState(0) const [epoch, setEpoch] = useState(0)
useEffect(() => { useEffect(() => {
let alive = true let alive = true
const asked = new Set(pendingIds)
api.tasks.list({ run_id: runId, assigned: 'all', status: 'pending' }) api.tasks.list({ run_id: runId, assigned: 'all', status: 'pending' })
.then(({ items }) => { if (alive) setTasks(items.filter((t) => t.status === 'pending' || t.status === 'resolving')) }) .then(({ items }) => {
.catch(() => { if (alive) setTasks([]) }) if (alive) setGot({ tasks: items.filter((t) => t.status === 'pending' || t.status === 'resolving'), asked })
})
.catch(() => { if (alive) setGot({ tasks: [], asked }) })
return () => { alive = false } return () => { alive = false }
}, [runId, sig, epoch]) }, [runId, sig, epoch]) // eslint-disable-line react-hooks/exhaustive-deps
return { tasks, reload: useCallback(() => setEpoch((n) => n + 1), []) } return { tasks: got?.tasks ?? null, asked: got?.asked ?? NONE, reload: useCallback(() => setEpoch((n) => n + 1), []) }
} }
export function RunTaskCards({ tasks, onResolved }: { const NONE: ReadonlySet<string> = new Set()
const CARD_STYLE = { borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))' }
export function RunTaskCards({ tasks, approvals = [], onResolved, onApprovalResolved }: {
tasks: Task[] tasks: Task[]
/**
* 待审批里对不上待办的那几条:服务端开待办时出过错还没补上(老接口 /decide 照旧能处理)、老服务端没有待办接口。
* 按审批画审批卡(run/RunPanel 的 ApprovalCard,当场按审批拼出待办),不让等待横幅的「去审批卡」落空
*/
approvals?: Approval[]
/** 回复记下了。resolved:凑齐了、运行会恢复(调用方重新接流);没凑齐只刷新这几张卡 */ /** 回复记下了。resolved:凑齐了、运行会恢复(调用方重新接流);没凑齐只刷新这几张卡 */
onResolved: (task: Task, resolved: boolean) => void | Promise<void> onResolved: (task: Task, resolved: boolean) => void | Promise<void>
/** 按审批画的那几张:运行恢复了(审批卡只在凑齐时回调) */
onApprovalResolved?: (approval: Approval) => void | Promise<void>
}) { }) {
if (!tasks.length) return null if (!tasks.length && !approvals.length) return null
return ( return (
<div className="mt-3 space-y-2" data-run-tasks={tasks.length}> <div className="mt-3 space-y-2" data-run-tasks={tasks.length + approvals.length}>
{tasks.map((t) => ( {tasks.map((t) => (
<div key={t.id} data-approval-card={t.approval_id ?? t.id} data-task-card={t.id} <div key={t.id} data-approval-card={t.approval_id ?? t.id} data-task-card={t.id}
className="relative overflow-hidden rounded-lg border" className="relative overflow-hidden rounded-lg border" style={CARD_STYLE}>
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))' }}>
<TaskCard task={t} onResolved={(r) => onResolved(r.task, r.resolved)} /> <TaskCard task={t} onResolved={(r) => onResolved(r.task, r.resolved)} />
</div> </div>
))} ))}
{approvals.map((a) => (
<div key={a.id} data-approval-card={a.id} className="relative overflow-hidden rounded-lg border" style={CARD_STYLE}>
<ApprovalCard approval={a} onResolved={() => onApprovalResolved?.(a)} />
</div>
))}
</div> </div>
) )
} }
+44 -57
View File
@@ -1,14 +1,13 @@
import { useEffect, useState } from 'react' import { useMemo } from 'react'
import { api } from '../api/client' import { api } from '../api/client'
import { useStudio } from '../store/studio' import { useStudio } from '../store/studio'
import { useCatalog } from '../store/catalog' import { useCatalog } from '../store/catalog'
import { NODE_DEFS } from '../canvas/nodeDefs' import { NODE_DEFS } from '../canvas/nodeDefs'
import { Skeleton, StatusDot } from '../components/ui' import { StatusDot } from '../components/ui'
import { errorMessage } from '../lib/errors'
import { TASK_LOG_TEXT } from '../lib/terms'
import { TaskCard } from './TaskCard' import { TaskCard } from './TaskCard'
import { taskFromApproval } from './approvalTask'
import type { Approval } from '../types' import type { Approval } from '../types'
import type { Task } from '../typesApp' import type { Task, TaskRespond } from '../typesApp'
// 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在 // 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在
// 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的 // 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的
@@ -17,72 +16,60 @@ import type { Task } from '../typesApp'
/** /**
* 审批卡:现在只是一层包装(X4)。审批卡的内容——报告核对概况、反方审稿、双盲复算、质询、动作确认的哈希卡、 * 审批卡:现在只是一层包装(X4)。审批卡的内容——报告核对概况、反方审稿、双盲复算、质询、动作确认的哈希卡、
* 核实选项、会签进度——都搬进了 run/TaskCard,所有人的决定都经待办服务(/api/tasks/{id}/respond)处理。 * 核实选项、签批人——都在 run/TaskCard。
* *
* 这里只做两件事:按审批找到它对应的那条待办(一次中断对应一条 Approval 和一条 Task),以及回复之后的接流—— * 这里做三件事:
* onResolved 给了就听它的(对话页、运行页各自重新接流),没给就是画布:恢复后由 studio 重新接上事件流。 * - 按审批当场拼出待办(run/approvalTask):待办上给人看的内容都原样来自中断内容,审批上全有,收到中断就出卡,
* 只有凑齐了(resolved)运行才会恢复;会签里记下一票时运行还停着,不接流。 * 不为一张卡再去取一次待办。审批带着 task(预览的桩数据)就照用;
* - 回复走哪里:审批上有 task_id(服务端在审批列表上给)就经待办服务(/api/tasks/{id}/respond);没有(老服务端、
* 合成样例)照旧走 /api/approvals/{id}/decide,「始终允许」照旧走 /runs/{id}/resume 带 always;
* - 回复之后的接流:onResolved 给了就听它的(对话页、运行页各自重新接流),没给就是画布:恢复后由 studio 重新接上
* 事件流。只有凑齐了(resolved)运行才会恢复;会签里记下一票时运行还停着,不接流。
*/ */
export function ApprovalCard({ approval, onResolved }: { export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
approval: Approval | any approval: Approval | any
onResolved?: (runId: string) => void | Promise<void> onResolved?: (runId: string) => void | Promise<void>
/** 老调用方传的:卡头写不写工作流名现在由 TaskCard 自己定(待办上带着工作流名),这里不再用它 */ /** 画布上工作流就是眼前这张,卡头不用再说一遍 */
showWorkflow?: boolean showWorkflow?: boolean
}) { }) {
const refreshApprovals = useCatalog((s) => s.refreshApprovals) const refreshApprovals = useCatalog((s) => s.refreshApprovals)
const attachRun = useStudio((s) => s.attachRun) const attachRun = useStudio((s) => s.attachRun)
const found = useApprovalTask(approval) const given: Task | undefined = approval.task
if (found.state === 'loading') return <div className="border-b p-3"><Skeleton rows={2} height={20} /></div> const task = useMemo(() => given ?? taskFromApproval(approval), [approval, given])
if (found.state === 'error') { const send = approval.task_id ? undefined : (body: TaskRespond) => decideLegacy(approval, task, body)
return (
<div className="border-b p-3 text-2xs" data-approval={approval.id} data-approval-task="missing">
<span style={{ color: 'var(--st-failed)' }}>{TASK_LOG_TEXT.loadFailed}{found.message ? `:${found.message}` : ''}</span>
<button type="button" className="btn btn-sm ml-2" onClick={found.reload}>{TASK_LOG_TEXT.retry}</button>
</div>
)
}
return ( return (
<div data-approval={approval.id} data-task={found.task.id}> <TaskCard task={task} send={send} showOwner={showWorkflow} onResolved={async (result) => {
<TaskCard task={found.task} onResolved={async (result) => { await refreshApprovals()
await refreshApprovals() if (!result.resolved) return
if (!result.resolved) return if (onResolved) await onResolved(approval.run_id)
if (onResolved) await onResolved(approval.run_id) else await attachRun(approval.run_id)
else await attachRun(approval.run_id) }} />
}} />
</div>
) )
} }
type ApprovalTask =
| { state: 'loading' }
| { state: 'ok'; task: Task }
| { state: 'error'; message: string; reload: () => void }
/** /**
* 一条审批对应的待办。审批带着 task(预览的桩数据)就直接用;带着 task_id(服务端在审批列表上给,D-r)按 id 取那一条; * 老的审批接口:服务端没给 task_id 时用。请求体照 /approvals/{id}/decide:approved、note,补充输入、改稿、双盲裁决和
* 都没有(老服务端)才按运行取待办、按 approval_id 对上。升级时服务端为存量的待审批补建了待办,找不到说明服务端还没建 * 表单带 value;「始终允许」那里没有 always,走 /runs/{id}/resume 并带上 approval_id 指明是哪一条。老接口只当一票、
* (旧版本)或者已经处理掉了 * 一票就定,回成「已处理」
*/ */
function useApprovalTask(approval: Approval & { task?: Task }): ApprovalTask { async function decideLegacy(approval: Approval, task: Task, body: TaskRespond): Promise<{ task: Task; resolved: boolean }> {
const [state, setState] = useState<ApprovalTask>(approval.task ? { state: 'ok', task: approval.task } : { state: 'loading' }) const approved = body.decision === 'approve' || body.decision === 'submit'
const [epoch, setEpoch] = useState(0) const values = body.values ?? {}
useEffect(() => { const note = body.note ?? ''
if (approval.task) { setState({ state: 'ok', task: approval.task }); return } if (approved && values.always === true) {
let alive = true await api.runs.resume(approval.run_id, { approved: true, always: true, note }, approval.id)
const reload = () => setEpoch((n) => n + 1) } else {
const found = approval.task_id const keys = Object.keys(values)
? api.tasks.get(approval.task_id) const value = keys.length === 1 && keys[0] === 'value' ? values.value : keys.length ? values : undefined
: api.tasks.list({ run_id: approval.run_id, assigned: 'all' }) await api.approvals.decide(approval.id, {
.then(({ items }) => items.find((t) => t.approval_id === approval.id) ?? null) approved, note, ...(approved && value !== undefined && task.kind !== 'tool' ? { value } : {}),
found ...(task.kind === 'tool' && values.args && typeof values.args === 'object' ? { args: values.args } : {}),
.then((task) => { })
if (!alive) return }
setState(task ? { state: 'ok', task } : { state: 'error', message: '', reload }) return {
}) task: { ...task, status: 'resolved', can_respond: false, outcome: { decision: body.decision } },
.catch((e) => { if (alive) setState({ state: 'error', message: errorMessage(e), reload }) }) resolved: true,
return () => { alive = false } }
}, [approval.id, approval.run_id, approval.task, approval.task_id, epoch])
return state
} }
// Timeline / OutputView / describe / EVENT_META / IssuanceBanner / RawEvents // Timeline / OutputView / describe / EVENT_META / IssuanceBanner / RawEvents
+22 -7
View File
@@ -49,6 +49,13 @@ export interface TaskCardProps {
onResolved: (result: { task: Task; resolved: boolean }) => void onResolved: (result: { task: Task; resolved: boolean }) => void
/** 列表里用:不画「批了会怎样」的说明、签批人那一行和底部的链接 */ /** 列表里用:不画「批了会怎样」的说明、签批人那一行和底部的链接 */
compact?: boolean compact?: boolean
/**
* 把回复发出去。默认 POST /api/tasks/{id}/respond;审批卡在老服务端(审批上没有 task_id)上改走老的审批接口,
* 按这里的形状交回结果
*/
send?: (body: TaskRespond) => Promise<{ task: Task; resolved: boolean }>
/** 卡头写不写应用或工作流名。画布上工作流就是眼前这张,不用再说一遍 */
showOwner?: boolean
} }
const KIND_ICON: Record<TaskKind, typeof Hand> = { const KIND_ICON: Record<TaskKind, typeof Hand> = {
@@ -59,7 +66,7 @@ const KIND_ICON: Record<TaskKind, typeof Hand> = {
/** 一天没人处理就用琥珀色写等了多久,同审批卡 */ /** 一天没人处理就用琥珀色写等了多久,同审批卡 */
const STALE_MS = 24 * 3_600_000 const STALE_MS = 24 * 3_600_000
export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) { export function TaskCard({ task, onResolved, compact = false, send, showOwner = true }: TaskCardProps) {
const toast = useToast() const toast = useToast()
const payload: Record<string, any> = task.payload ?? {} const payload: Record<string, any> = task.payload ?? {}
const kind = task.kind const kind = task.kind
@@ -71,6 +78,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
const [errors, setErrors] = useState<FormError[]>([]) const [errors, setErrors] = useState<FormError[]>([])
const [values, setValues] = useState<Record<string, unknown>>(() => (task.form ? withDefaults(task.form, {}) : {})) const [values, setValues] = useState<Record<string, unknown>>(() => (task.form ? withDefaults(task.form, {}) : {}))
const [text, setText] = useState(() => (payload.draft != null ? String(payload.draft) : '')) 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);老服务端不给时:应用里的运行一律是固定版本的正式运行,别处的看中断内容 // 运行类别服务端在待办上给(run_class);老服务端不给时:应用里的运行一律是固定版本的正式运行,别处的看中断内容
// 自己带的运行类别,都没有就不画那一行 // 自己带的运行类别,都没有就不画那一行
@@ -89,7 +97,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
if (busy) return if (busy) return
setBusy(true) setBusy(true)
try { try {
const res = await api.tasks.respond(task.id, { ...body, ...(note.trim() && !body.note ? { note: note.trim() } : {}) }) const res = await post({ ...body, ...(note.trim() && !body.note ? { note: note.trim() } : {}) })
setErrors([]) setErrors([])
void useCatalog.getState().refreshApprovals() void useCatalog.getState().refreshApprovals()
toast(res.resolved ? done : TASK_TEXT.doneVote, 'ok') toast(res.resolved ? done : TASK_TEXT.doneVote, 'ok')
@@ -135,7 +143,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
style={{ color: pending ? 'var(--warn)' : 'var(--text-dim)' }}> 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> <Icon size={13} aria-hidden /> <span className="min-w-0 truncate">{task.title || TASK_TEXT.untitled}</span>
</div> </div>
<TaskMeta task={task} /> <TaskMeta task={task} showOwner={showOwner} />
{!pending && <Resolved task={task} />} {!pending && <Resolved task={task} />}
{(task.assignments.length > 1 || task.policy.kind !== 'any') && <Signoffs task={task} />} {(task.assignments.length > 1 || task.policy.kind !== 'any') && <Signoffs task={task} />}
@@ -153,7 +161,7 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
{kind === 'dual_check' && ( {kind === 'dual_check' && (
<DualCheckApproval approval={asApproval(task, runClass)} <DualCheckApproval approval={asApproval(task, runClass)}
decide={async (ok, reply) => { decide={async (ok, reply) => {
const res = await api.tasks.respond(task.id, ok const res = await post(ok
? { decision: 'submit', values: { value: reply }, note: reply.note } ? { decision: 'submit', values: { value: reply }, note: reply.note }
: { decision: 'reject', note: reply.note }) : { decision: 'reject', note: reply.note })
void useCatalog.getState().refreshApprovals() void useCatalog.getState().refreshApprovals()
@@ -217,7 +225,8 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
</button> </button>
{trustKey && ( {trustKey && (
<button type="button" className="btn flex-1 justify-center" disabled={busy || inquiring} data-approval-always="" <button type="button" className="btn flex-1 justify-center" disabled={busy || inquiring} data-approval-always=""
title={TOOL_TRUST_TEXT.alwaysHint(trustKey)} onClick={() => void 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} <CheckCheck size={12} aria-hidden /> {TOOL_TRUST_TEXT.alwaysButton}
</button> </button>
)} )}
@@ -238,6 +247,12 @@ export function TaskCard({ task, onResolved, compact = false }: TaskCardProps) {
{binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint} {binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint}
</div> </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' && ( {pending && !task.can_respond && kind !== 'signal' && (
@@ -263,7 +278,7 @@ function asApproval(task: Task, runClass: 'formal' | 'exploratory' | null): Appr
// ---------------------------------------------------------------- 卡头 // ---------------------------------------------------------------- 卡头
/** 种类 · 应用或工作流 · 节点 · 什么时候发起、等了多久 · 时限 · 升级 */ /** 种类 · 应用或工作流 · 节点 · 什么时候发起、等了多久 · 时限 · 升级 */
function TaskMeta({ task }: { task: Task }) { function TaskMeta({ task, showOwner }: { task: Task; showOwner: boolean }) {
const now = useTicker(60_000) const now = useTicker(60_000)
const created = parseServerTime(task.created_at) const created = parseServerTime(task.created_at)
const age = created ? now - created.getTime() : undefined const age = created ? now - created.getTime() : undefined
@@ -271,7 +286,7 @@ function TaskMeta({ task }: { task: Task }) {
// 节点的名字:服务端在待办上给(node_label),老的中断内容里也可能带着 // 节点的名字:服务端在待办上给(node_label),老的中断内容里也可能带着
const label = task.node_label ?? task.payload?.node_label const label = task.node_label ?? task.payload?.node_label
const where = typeof label === 'string' && label ? label : task.node_id const where = typeof label === 'string' && label ? label : task.node_id
const owner = task.app_name ? task.app_name : task.workflow_name ? runName(task) : null const owner = !showOwner ? null : task.app_name ? task.app_name : task.workflow_name ? runName(task) : null
return ( return (
<div className="tnum mb-2 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-2xs text-dim"> <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> <span>{TASK_KIND_LABEL[task.kind] ?? task.kind}</span>
+48
View File
@@ -0,0 +1,48 @@
/**
* 审批 → 待办卡要的 Task 形状(应用平台 X4)。一次中断对应一条 Approval 和一条 Task,待办上给人看的内容(标题、中断内容、
* 表单)都原样来自中断内容,审批上全有:审批卡(RunPanel 的 ApprovalCard)当场按审批拼出来渲染,不再为一张卡多发一次请求
* 去取待办——收到中断就得出卡,预览和老服务端也没有待办可取。
*
* 种类、标题、表单的认法同服务端 process/tasks.open_task:人工节点按 mode(派给外部系统的是 signal),工具审批、代码审批、
* 双盲复算、动作确认按中断内容的 kind。派给谁、时限这些只在待办上有,这里按「任何人、无时限」拼——画布、问数据里的运行
* 不属于应用,服务端派的也是任何人;真要按会签、时限看,去记录页或待办箱看服务端的待办。
*/
import type { Approval } from '../types'
import type { Task, TaskKind } from '../typesApp'
const KIND_OF: Record<string, TaskKind> = {
tool_approval: 'tool', code_approval: 'code', dual_check: 'dual_check', action_confirm: 'action_confirm',
}
/** 中断内容 → 待办种类,同服务端 process/tasks.kind_of */
export function taskKindOf(payload: Record<string, any> | null | undefined, mode?: string): TaskKind {
const p = payload ?? {}
if (p.kind === 'human_node') {
if (p.assignee && typeof p.assignee === 'object' && p.assignee.kind === 'hook') return 'signal'
const m = String(p.mode ?? mode ?? 'approve')
return m === 'input' || m === 'edit' ? m : 'approve'
}
return KIND_OF[String(p.kind)] ?? 'approve'
}
/**
* 按审批拼一条待办。id 给审批上的 task_id(服务端在审批列表上给);老服务端没有就用审批 id 占位——这时回复不走
* /api/tasks,由调用方改走老的审批接口
*/
export function taskFromApproval(a: Approval, id: string = a.task_id || a.id, patch: Partial<Task> = {}): Task {
const p: Record<string, any> = a.payload ?? {}
const pending = a.status === 'pending'
return {
id, approval_id: a.id, run_id: a.run_id, node_id: a.node_id || null, node_label: a.node_label ?? null,
run_class: a.run_class ?? null, workflow_id: a.workflow_id ?? null, workflow_name: a.workflow_name ?? null,
app_id: null, app_version: null, kind: taskKindOf(p, a.mode), title: a.title || String(p.title ?? ''),
form: p.form && typeof p.form === 'object' && !Array.isArray(p.form) ? p.form : null,
policy: { kind: 'any' }, status: pending ? 'pending' : 'resolved', due_at: null, escalation_level: 0, payload: p,
assignments: [{
id: `as-${a.id}`, principal_kind: 'anyone', principal: '', source: 'anyone', status: pending ? 'pending' : 'responded',
responded_by: a.resolved_by ?? null, responded_at: a.resolved_at ?? null, verified: false,
}],
can_respond: pending, outcome: pending ? null : (a.response ?? {}), resolved_by: a.resolved_by ?? null,
resolved_at: a.resolved_at ?? null, created_at: a.created_at ?? new Date().toISOString(), ...patch,
}
}
+14 -1
View File
@@ -150,6 +150,14 @@ const FETCHERS: Record<CatalogKey, () => Promise<unknown[]>> = {
export const useCatalog = create<CatalogState>((set, get) => { export const useCatalog = create<CatalogState>((set, get) => {
let backoffStep = 0 let backoffStep = 0
// 待办数:启动时随目录取一次、之后每拍心跳取。后发的请求先回来时,先发的那份晚到了不再盖掉它
let countReq = 0
let countApplied = 0
const applyCounts = (seq: number, counts: TaskCounts) => {
if (seq < countApplied) return
countApplied = seq
set({ taskCounts: counts })
}
const markDown = (error: string) => { const markDown = (error: string) => {
// 已经断着再失败一次,就把下一次重试往后推一档。所以"发现断开"的各条路 // 已经断着再失败一次,就把下一次重试往后推一档。所以"发现断开"的各条路
@@ -235,6 +243,10 @@ export const useCatalog = create<CatalogState>((set, get) => {
}) })
} }
} }
// 待办数和目录一起取一次:导航上「应用」「记录」的徽标、新待办通知的记底都等它,不该等到第一拍心跳(4 秒后)。
// 不算进清单:取不到时徽标退回按待审批数,下一拍心跳再取
const countSeq = ++countReq
void api.tasks.count(catalogOpts).then((c) => applyCounts(countSeq, c)).catch(() => undefined)
const t0 = performance.now() const t0 = performance.now()
await Promise.all(keys.map(track)) await Promise.all(keys.map(track))
if (!get().loaded) set({ loaded: true }) if (!get().loaded) set({ loaded: true })
@@ -260,11 +272,12 @@ export const useCatalog = create<CatalogState>((set, get) => {
// 留空,「记录」的徽标退回按待审批数 // 留空,「记录」的徽标退回按待审批数
const builder = atLeast(useAuth.getState().me, 'builder') const builder = atLeast(useAuth.getState().me, 'builder')
const seq = ++fetchSeq const seq = ++fetchSeq
const countSeq = ++countReq
const [approvals, counts] = await Promise.allSettled([ const [approvals, counts] = await Promise.allSettled([
builder ? api.approvals.list('pending', catalogOpts) : Promise.resolve(null), builder ? api.approvals.list('pending', catalogOpts) : Promise.resolve(null),
api.tasks.count(catalogOpts), api.tasks.count(catalogOpts),
]) ])
if (counts.status === 'fulfilled') set({ taskCounts: counts.value }) if (counts.status === 'fulfilled') applyCounts(countSeq, counts.value)
// 心跳测的是这一拍的往返;使用者不取待审批,看待办数那一个请求 // 心跳测的是这一拍的往返;使用者不取待审批,看待办数那一个请求
const probe = builder ? approvals : counts const probe = builder ? approvals : counts
if (probe.status === 'fulfilled') set({ latencyMs: Math.round(performance.now() - t0), lastOkAt: Date.now() }) if (probe.status === 'fulfilled') set({ latencyMs: Math.round(performance.now() - t0), lastOkAt: Date.now() })
+4 -2
View File
@@ -285,11 +285,13 @@ await section('检查:和发布同一套规则,点一条定位到组件', as
check('列出错误和提醒', await waitFor(page, async () => await issues.count() === 3)) check('列出错误和提醒', await waitFor(page, async () => await issues.count() === 3))
await page.locator('[data-app-issue="app.binding_target_missing"] [data-app-issue-locate]').click() await page.locator('[data-app-issue="app.binding_target_missing"] [data-app-issue-locate]').click()
await page.waitForURL('**/edit/pages') await page.waitForURL('**/edit/pages')
check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await page.locator('[data-app-inspector="gmv"]').count() === 1) // 页面一节要等组件目录到了才画页面树和属性面板:等它出来再判,别读到骨架屏
const shows = (sel) => page.locator(sel).waitFor({ timeout: 4000 }).then(async () => await page.locator(sel).count() === 1, () => false)
check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await shows('[data-app-inspector="gmv"]'))
await page.goto(`${WEB}/apps/app-ae/edit/check`, { waitUntil: 'networkidle' }) await page.goto(`${WEB}/apps/app-ae/edit/check`, { waitUntil: 'networkidle' })
await page.locator('[data-app-issue="app.expr_arith"] [data-app-issue-locate]').click() await page.locator('[data-app-issue="app.expr_arith"] [data-app-issue-locate]').click()
await page.waitForURL('**/edit/pages') await page.waitForURL('**/edit/pages')
check('JSON 指针的 path 也认得', await page.locator('[data-app-inspector="warn"]').count() === 1) check('JSON 指针的 path 也认得', await shows('[data-app-inspector="warn"]'))
}) })
await section('发布:固定版本升了版,必须为每一项选处置', async () => { await section('发布:固定版本升了版,必须为每一项选处置', async () => {
+4 -2
View File
@@ -945,9 +945,11 @@ await section('正式运行:入口看 published_version;运行期间画布
await page.waitForTimeout(400) await page.waitForTimeout(400)
const pop = (await page.locator('.sf-pop').innerText()).replace(/\s+/g, ' ') const pop = (await page.locator('.sf-pop').innerText()).replace(/\s+/g, ' ')
check('提示不含画布改动', pop.includes('不含画布改动'), pop.slice(0, 80)) check('提示不含画布改动', pop.includes('不含画布改动'), pop.slice(0, 80))
// 发起表单统一用 SchemaForm 画(X2):字段按名字挂 data-field,id 是 run-formal-<字段名>
check('表单字段取已发布版本的(topic),不取画布的(q)', check('表单字段取已发布版本的(topic),不取画布的(q)',
await page.locator('.sf-pop textarea#run-field-topic').count() === 1 await page.locator('.sf-pop [data-launch-form] [data-field="topic"] #run-formal-topic').count() === 1
&& await page.locator('.sf-pop textarea#run-field-q').count() === 0) && await page.locator('.sf-pop [data-field="q"]').count() === 0,
(await page.locator('.sf-pop [data-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-field')))).join(','))
const formalPop = await marks('.sf-pop') const formalPop = await marks('.sf-pop')
check('正式运行浮层的标题和确认按钮都是实心圆点', formalPop.formal === 2 && formalPop.exploratory === 0 && formalPop.shield === 0, check('正式运行浮层的标题和确认按钮都是实心圆点', formalPop.formal === 2 && formalPop.exploratory === 0 && formalPop.shield === 0,
JSON.stringify(formalPop)) JSON.stringify(formalPop))
+8 -3
View File
@@ -191,13 +191,17 @@ const browser = await chromium.launch({ executablePath: CHROME })
// ================================================================ // ================================================================
await section('能力列表:效果、筛选、同步', async () => { await section('能力列表:效果、筛选、同步', async () => {
const { page, state, errors } = await open('/tools/capabilities') const { page, state, errors } = await open('/tools/capabilities')
check('工具页有「能力」页签', await page.locator('[role=tab]', { hasText: '能力' }).count() === 1) // 「能力」页签里还有「全部能力 / 待审阅」两个子页签(D-i),按名字整字匹配
check('工具页有「能力」页签', await page.getByRole('tab', { name: '能力', exact: true }).count() === 1,
(await page.locator('[role=tab]').allInnerTexts()).join(' | '))
check('列出全部能力', await page.locator('[data-capability]').count() === CAPS.length) check('列出全部能力', await page.locator('[data-capability]').count() === CAPS.length)
const pay = page.locator('[data-capability="erp_pay"] [data-capability-effect]') const pay = page.locator('[data-capability="erp_pay"] [data-capability-effect]')
check('治理后的效果和同步得出的不一致时标出来', (await pay.innerText()).includes('写入') && (await pay.innerText()).includes('*')) check('治理后的效果和同步得出的不一致时标出来', (await pay.innerText()).includes('写入') && (await pay.innerText()).includes('*'))
check('没治理过的按同步得出的效果显示', (await page.locator('[data-capability="erp_query"] [data-capability-effect]').innerText()).includes('只读')) check('没治理过的按同步得出的效果显示', (await page.locator('[data-capability="erp_query"] [data-capability-effect]').innerText()).includes('只读'))
await page.locator('[data-capability="mcp:crm/find"]').click() await page.locator('[data-capability="mcp:crm/find"]').click()
check('能力名带斜杠也能打开(地址转义)', await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1) // 右栏的治理设置是点开之后另取的:等它出来再判
check('能力名带斜杠也能打开(地址转义)', await waitFor(page, async () =>
await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1), page.url())
await page.locator('[data-capability-sync]').click() await page.locator('[data-capability-sync]').click()
check('立即同步', await waitFor(page, async () => state.syncs === 1)) check('立即同步', await waitFor(page, async () => state.syncs === 1))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
@@ -273,7 +277,8 @@ await section('能力:待审阅复用数据目录的审阅队列,成批确
check('冲突的那项照实标出(说的是能力,不是表)', await waitFor(page, async () => check('冲突的那项照实标出(说的是能力,不是表)', await waitFor(page, async () =>
(await page.locator('[data-review-summary]').innerText()).includes('所在能力刚被修改过'))) (await page.locator('[data-review-summary]').innerText()).includes('所在能力刚被修改过')))
await rows.first().locator('[data-review-open]').click() await rows.first().locator('[data-review-open]').click()
check('点开一项:右栏是这个能力的治理设置,标出那个位置', await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1) check('点开一项:右栏是这个能力的治理设置,标出那个位置', await waitFor(page, async () =>
await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1), page.url())
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
}) })
+6 -3
View File
@@ -1994,14 +1994,17 @@ await section('应用平台:发布成功做成应用;已发布版本的使
}) })
await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) => await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(usage) })) route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(usage) }))
await page.keyboard.press('Alt+h') // 编排页一进来焦点在助手输入框里,那时的 ⌥H 是打字:先把焦点放回页面(同 check-studio)
await page.evaluate(() => document.activeElement?.blur?.())
await page.keyboard.press('Alt+KeyH')
const sheet = page.locator('[role="dialog"][aria-label="版本历史"]') const sheet = page.locator('[role="dialog"][aria-label="版本历史"]')
await sheet.waitFor() await sheet.waitFor()
check('版本列表标出已退役的版本', await sheet.locator('[data-version-retired-chip]').count() === 1) check('版本列表标出已退役的版本', await sheet.locator('[data-version-retired-chip]').count() === 1)
await sheet.locator('ol button', { hasText: 'v4' }).first().click() await sheet.locator('ol button', { hasText: 'v4' }).first().click()
const cur = sheet.locator('[data-version-lifecycle="4"]') const cur = sheet.locator('[data-version-lifecycle="4"]')
check('展开当前发布版:写使用情况', await waitFor(page, async () => await cur.count() === 1) // 使用情况是展开之后另取的(账本接口):等它回来再读,别读到占位的「…」
&& (await cur.innerText()).includes('被 1 个应用固定')) check('展开当前发布版:写使用情况', await waitFor(page, async () => await cur.count() === 1
&& (await cur.innerText().catch(() => '')).includes('被 1 个应用固定')), await cur.innerText().catch(() => ''))
check('……当前发布版的退役按钮不可点,写明原因', await cur.locator('[data-version-retire]').isDisabled() check('……当前发布版的退役按钮不可点,写明原因', await cur.locator('[data-version-retire]').isDisabled()
&& (await cur.innerText()).includes('当前发布版不能退役')) && (await cur.innerText()).includes('当前发布版不能退役'))
await sheet.locator('ol button', { hasText: 'v3' }).first().click() await sheet.locator('ol button', { hasText: 'v3' }).first().click()
+71 -12
View File
@@ -131,6 +131,28 @@ const listTop = await getJson('/runs?limit=50')
const wfHost = (await getJson('/workflows'))[0] const wfHost = (await getJson('/workflows'))[0]
const base = allRuns.find((r) => r.status === 'succeeded' && !r.workflow_id) const base = allRuns.find((r) => r.status === 'succeeded' && !r.workflow_id)
/**
* 合成的两条待处理待办(沙箱里一条都没有时用):挂在沙箱里真实的运行上——最近的一条,和排在第 100 名之后的一条
* (原来只取 100 条时找不到的那种)。一条等了 8 天,一条刚来
*/
function syntheticTasks() {
const picks = [allRuns[0], allRuns[Math.min(allRuns.length - 1, 120)]].filter((r, i, a) => r && a.indexOf(r) === i)
return picks.map((r, i) => {
const at = new Date(Date.now() - (i ? 8 * 86400_000 : 3 * 60_000)).toISOString()
return { id: `fake0task0list${i}`, approval_id: `fake0appr0list${i}`, run_id: r.id, node_id: 'gate', node_label: '人工审批',
run_class: r.run_class ?? 'exploratory', workflow_id: r.workflow_id ?? null, workflow_name: r.workflow_name || '订单日报',
app_id: null, app_version: null, kind: i ? 'input' : 'approve', title: i ? '补一下预算上限' : '这份报表可以发出吗?', form: null,
policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0, payload: {}, can_respond: true,
assignments: [{ id: `fake0as${i}`, principal_kind: 'anyone', principal: '', source: 'anyone', status: 'pending',
responded_by: null, responded_at: null, verified: false }],
outcome: null, resolved_by: null, resolved_at: null, created_at: at }
})
}
const syntheticApprovals = (tasks) => tasks.map((t) => ({ id: t.approval_id, run_id: t.run_id, node_id: t.node_id,
mode: t.kind === 'input' ? 'input' : 'approve', title: t.title, payload: { kind: 'human_node' }, status: 'pending', response: {},
created_at: t.created_at, workflow_id: t.workflow_id, workflow_name: t.workflow_name, node_label: t.node_label,
run_status: 'interrupted', run_class: t.run_class, task_id: t.id }))
// ------------------------------------------------------------------ 页签 // ------------------------------------------------------------------ 页签
await section('页签与地址', async () => { await section('页签与地址', async () => {
@@ -182,30 +204,50 @@ await section('待办页签', async () => {
ws.send(JSON.stringify({ seq: lastSeq + 2, type: 'node.started', node_id: target.node_id, ts: t + 0.05, data: { resumed: true } })) ws.send(JSON.stringify({ seq: lastSeq + 2, type: 'node.started', node_id: target.node_id, ts: t + 0.05, data: { resumed: true } }))
}) })
} }
// 沙箱是开发库的拷贝:手上的待办刚好都处理完时一条都没有,列表这几项就只剩空态可看。那时拿两条合成的待办来验列表
// (挂在沙箱里真实的运行上,一条排在最近 100 条之外);点进详情、别处批掉这几段要真待审批,照旧只在有的时候跑
const synthetic = !pendingTasks.length && allRuns.length > 0
const listTasks = synthetic ? syntheticTasks() : pendingTasks
if (synthetic) {
console.log(' · 沙箱里没有待处理的待办:列表这几项用合成的 2 条')
fakes.set('GET /api/tasks', (url) => ({ status: 200, json: { items: url.searchParams.get('run_id') ? [] : listTasks } }))
fakes.set('GET /api/approvals', (url) => ({ status: 200, json: url.searchParams.get('run_id') ? [] : syntheticApprovals(listTasks) }))
}
await page.goto(`${WEB}/runs?tab=approvals`, { waitUntil: 'networkidle' }) await page.goto(`${WEB}/runs?tab=approvals`, { waitUntil: 'networkidle' })
await page.waitForTimeout(400) await page.waitForTimeout(400)
check('页签叫「待办」', (await page.locator('[role=tab][data-tab=approvals]').innerText()).includes('待办')) check('页签叫「待办」', (await page.locator('[role=tab][data-tab=approvals]').innerText()).includes('待办'))
const taskRows = page.locator('[data-tasks-list] [data-task-id]') const taskRows = page.locator('[data-tasks-list] [data-task-id]')
const shownTasks = await taskRows.evaluateAll((els) => els.map((e) => e.getAttribute('data-task-id'))) const shownTasks = await taskRows.evaluateAll((els) => els.map((e) => e.getAttribute('data-task-id')))
check('沙箱里不属于应用的待办都在页签里', pendingTasks.length > 0 && pendingTasks.every((t) => shownTasks.includes(t.id)), check(synthetic ? '不属于应用的待办都在页签里(合成的 2 条)' : '沙箱里不属于应用的待办都在页签里',
`API ${pendingTasks.length} 条 / 页面 ${shownTasks.length} 条`) listTasks.length > 0 && listTasks.every((t) => shownTasks.includes(t.id)),
`API ${listTasks.length} 条 / 页面 ${shownTasks.length} 条`)
check('存量的待审批都补建了待办', pending.every((a) => pendingTasks.some((t) => t.approval_id === a.id)), check('存量的待审批都补建了待办', pending.every((a) => pendingTasks.some((t) => t.approval_id === a.id)),
`${pending.filter((a) => !pendingTasks.some((t) => t.approval_id === a.id)).length} 条没有对应的待办`) `${pending.filter((a) => !pendingTasks.some((t) => t.approval_id === a.id)).length} 条没有对应的待办`)
const deep = pendingTasks.filter((t) => allRuns.findIndex((r) => r.id === t.run_id) >= 100) const deep = listTasks.filter((t) => allRuns.findIndex((r) => r.id === t.run_id) >= 100)
check('排在最近 100 条之外的待办也看得到(原来的死路)', deep.every((t) => shownTasks.includes(t.id)), check('排在最近 100 条之外的待办也看得到(原来的死路)', deep.every((t) => shownTasks.includes(t.id)),
`${deep.length} 条在第 100 名之后`) `${deep.length} 条在第 100 名之后`)
check('每条都写着等了多久', await page.locator('[data-tasks-list] [data-task-age]').count() === shownTasks.length) check('每条都写着等了多久', await page.locator('[data-tasks-list] [data-task-age]').count() === shownTasks.length)
check('每条标着待办的种类', await page.locator('[data-tasks-list] [data-task-kind-chip]').count() === shownTasks.length) check('每条标着待办的种类', await page.locator('[data-tasks-list] [data-task-kind-chip]').count() === shownTasks.length)
if (pendingTasks.length) { if (listTasks.length) {
const rowText = await page.locator(`[data-task-id="${pendingTasks[0].id}"]`).innerText() const first = listTasks[0]
const hasWf = rowText.includes(pendingTasks[0].workflow_name ?? '') const rowText = await page.locator(`[data-task-id="${first.id}"]`).innerText()
// 应用里的写应用名;没保存就跑的写「未保存的工作流」、不露「临时图」(同审批行的 runName)
const unsaved = !first.workflow_id && (!first.workflow_name || ['临时图', '未保存的工作流'].includes(first.workflow_name))
const wfName = first.app_name || (unsaved ? '未保存的工作流' : first.workflow_name ?? '')
const hasWf = rowText.includes(wfName) && !rowText.includes('临时图')
const hasNode = rowText.includes(`「${first.node_label ?? first.node_id}」`)
// 名字来自沙箱库,只报在不在 // 名字来自沙箱库,只报在不在
check('每条写着工作流名', hasWf, `工作流名 ${hasWf ? '在' : '缺'}`) check('每条写着工作流名和节点(节点写名字,不写 id)', hasWf && hasNode,
`工作流名 ${hasWf ? '在' : '缺'} · 节点 ${hasNode ? '在' : '缺'}`)
check('每条有处理入口', rowText.includes('去处理')) check('每条有处理入口', rowText.includes('去处理'))
} }
check('列表下面写明应用里的待办在哪里处理', (await page.locator('[data-tasks-list]').innerText().catch(() => '')).includes('应用 → 待办')) check('列表下面写明应用里的待办在哪里处理', (await page.locator('[data-tasks-list]').innerText().catch(() => '')).includes('应用 → 待办'))
const badge = await page.locator('[role=tab][data-tab=approvals]').innerText() const badge = await page.locator('[role=tab][data-tab=approvals]').innerText()
check('页签上的数和待办条数一致', badge.includes(String(pendingTasks.length)), badge.replace(/\s+/g, ' ')) check('页签上的数和待办条数一致', badge.includes(String(listTasks.length)), badge.replace(/\s+/g, ' '))
if (synthetic) {
fakes.delete('GET /api/tasks')
fakes.delete('GET /api/approvals')
}
if (pending.length) { if (pending.length) {
const target = pending[0] const target = pending[0]
const task = pendingTasks.find((t) => t.approval_id === target.id) const task = pendingTasks.find((t) => t.approval_id === target.id)
@@ -1563,8 +1605,21 @@ await section('工作流筛选:未保存的工作流', async () => {
if (wfOptions.includes('__none__')) { if (wfOptions.includes('__none__')) {
await listAfter(() => page.locator('[data-runs-workflow]').selectOption('__none__'), (p) => p.get('workflow_id') === '__none__') await listAfter(() => page.locator('[data-runs-workflow]').selectOption('__none__'), (p) => p.get('workflow_id') === '__none__')
check('地址记下 ?wf=__none__', new URL(page.url()).searchParams.get('wf') === '__none__') check('地址记下 ?wf=__none__', new URL(page.url()).searchParams.get('wf') === '__none__')
const names = await page.locator('[data-runs-list] [data-run-id] > div:first-of-type').evaluateAll((els) => els.map((e) => e.textContent ?? '')) const rows = await page.locator('[data-runs-list] [data-run-id]').evaluateAll((els) => els.map((e) => ({
check('筛出来的都是未保存的工作流', names.length > 0 && names.every((n) => n.includes('未保存的工作流')), `${names.length} 行`) id: e.getAttribute('data-run-id'), name: e.querySelector(':scope > div:first-of-type')?.textContent ?? '' })))
// 没有工作流的运行有两种:没保存就跑的(记的名字是「临时图」、空,或已经是「未保存的工作流」),和工作流后来删掉了的
// (记着当时的名字)。前一种一律写「未保存的工作流」、不露「临时图」;后一种照记下的名字写。名字来自沙箱库,只报条数
const byId = new Map((await getJson('/runs?workflow_id=__none__&limit=200')).map((r) => [r.id, r]))
const unsavedName = (r) => !r.workflow_name || ['临时图', '未保存的工作流'].includes(r.workflow_name)
const wrong = rows.filter(({ id, name }) => {
const r = byId.get(id)
if (!r || r.workflow_id) return true
return unsavedName(r) ? !name.includes('未保存的工作流') : !name.includes(r.workflow_name)
})
check('筛出来的都是没有工作流的运行:没保存就跑的写「未保存的工作流」,不露「临时图」',
rows.length > 0 && !wrong.length && rows.every((r) => !r.name.includes('临时图'))
&& rows.some((r) => r.name.includes('未保存的工作流')),
`${rows.length} 行 · ${rows.filter((r) => unsavedName(byId.get(r.id) ?? {})).length} 行是没保存就跑的 · ${wrong.length} 行不对`)
await page.locator('[data-runs-workflow]').selectOption('') await page.locator('[data-runs-workflow]').selectOption('')
await settle() await settle()
} }
@@ -1886,8 +1941,10 @@ await section('证据页签:左边报告、右边常驻面板、下方审计
}) })
await page.goto(`${WEB}/runs/${EV}`, { waitUntil: 'networkidle' }) await page.goto(`${WEB}/runs/${EV}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor() await page.locator('[data-run-detail]').waitFor()
// 应用平台在工件后面加了「生效记录」(§3.8)
const tabOrder = (await page.locator('[data-run-detail] [role=tab]').evaluateAll((els) => els.map((e) => e.getAttribute('data-tab')))).join(',')
check('详情多了「证据」页签(在航迹和工件之间)', await viewTab('evidence').count() === 1 check('详情多了「证据」页签(在航迹和工件之间)', await viewTab('evidence').count() === 1
&& (await page.locator('[data-run-detail] [role=tab]').evaluateAll((els) => els.map((e) => e.getAttribute('data-tab')))).join(',') === 'stream,trace,evidence,artifacts') && tabOrder === 'stream,trace,evidence,artifacts,effects', tabOrder)
check('没打开证据页签之前不取证据图和审计表', !evRequests.some((u) => u.includes(EV)), evRequests.join(' ')) check('没打开证据页签之前不取证据图和审计表', !evRequests.some((u) => u.includes(EV)), evRequests.join(' '))
await viewTab('evidence').click() await viewTab('evidence').click()
await page.locator('[data-view-pane=evidence] [data-evidence-pane=cited]').waitFor({ timeout: 8000 }).catch(() => {}) await page.locator('[data-view-pane=evidence] [data-evidence-pane=cited]').waitFor({ timeout: 8000 }).catch(() => {})
@@ -2294,7 +2351,9 @@ await section('待办页签:审批带着 task_id,新来的审批按 id 补
const byId = [] const byId = []
const onRequest = (req) => { const onRequest = (req) => {
const u = new URL(req.url()) const u = new URL(req.url())
if (u.pathname === '/api/tasks') lists.push(u.search) // 只数这个页签的整表重取(assigned=all)。外壳的新待办通知按「派给我的」(assigned=me)取,那是另一回事:
// 它在派给我的待办数变了、或刚打开页面记底时取一次
if (u.pathname === '/api/tasks' && u.searchParams.get('assigned') !== 'me') lists.push(u.search)
const m = u.pathname.match(/^\/api\/tasks\/([^/]+)$/) const m = u.pathname.match(/^\/api\/tasks\/([^/]+)$/)
if (m && m[1] !== 'count') byId.push(m[1]) if (m && m[1] !== 'count') byId.push(m[1])
} }
+10 -2
View File
@@ -92,11 +92,13 @@ async function open({ theme = 'dark', state }) {
// 主题以本地为准,别让沙箱库里存的偏好把这次检查的主题改掉 // 主题以本地为准,别让沙箱库里存的偏好把这次检查的主题改掉
if (url.pathname === '/api/settings') return route.fulfill({ json: { ui: { theme } } }) if (url.pathname === '/api/settings') return route.fulfill({ json: { ui: { theme } } })
if (url.pathname === '/api/approvals') return route.fulfill({ json: state.approvals }) if (url.pathname === '/api/approvals') return route.fulfill({ json: state.approvals })
// 导航上的两个徽标按来源分开数:「记录」数不属于应用的(other),「应用」数应用里的(apps)。这里的待审批都不属于应用 // 导航上的两个徽标按来源分开数:「记录」数不属于应用的(other),「应用」数应用里的(apps)。这里的待审批都不属于应用。
// latest 同服务端:派给我的待处理待办里最新一条的开出时间(数不变、却换了一条时,新待办通知看它)
if (url.pathname === '/api/tasks/count') { if (url.pathname === '/api/tasks/count') {
const other = state.approvals.filter((a) => a.status === 'pending').length const other = state.approvals.filter((a) => a.status === 'pending').length
const apps = state.appTasks ?? 0 const apps = state.appTasks ?? 0
return route.fulfill({ json: { mine: state.tasks ? state.tasks.length : other + apps, apps, other } }) const latest = state.tasks?.length ? state.tasks.map((t) => t.created_at).sort().at(-1) : null
return route.fulfill({ json: { mine: state.tasks ? state.tasks.length : other + apps, apps, other, latest } })
} }
// 派给我的待办(外壳只在待办数变了时取一次,拿来判断哪些是新来的) // 派给我的待办(外壳只在待办数变了时取一次,拿来判断哪些是新来的)
if (url.pathname === '/api/tasks' && state.tasks) return route.fulfill({ json: { items: state.tasks } }) if (url.pathname === '/api/tasks' && state.tasks) return route.fulfill({ json: { items: state.tasks } })
@@ -566,6 +568,12 @@ await section('新的待办', '\n=== 派给自己的新待办:后台时发系
const notes = await page.evaluate(() => window.__notes) const notes = await page.evaluate(() => window.__notes)
check('已按待审批通知过的那一条不再发第二条', notes.some((n) => n.tag === 'approval:shell-ap-task') check('已按待审批通知过的那一条不再发第二条', notes.some((n) => n.tag === 'approval:shell-ap-task')
&& !notes.some((n) => n.tag === 'task:shell-task-ap'), notes.map((n) => n.tag).join(',')) && !notes.some((n) => n.tag === 'task:shell-task-ap'), notes.map((n) => n.tag).join(','))
// 数不变、却换了一条(核实的那条处理掉了,又来一条新的):派给我的还是 2 条,靠最新一条的时间(latest)认出来
state.tasks = [state.tasks[1], taskOf('shell-task-swap', { title: '退款结果未知,请核实', created_at: new Date().toISOString() })]
const swapped = await page.waitForFunction(() => window.__notes.some((n) => n.tag === 'task:shell-task-swap'), null, { timeout: 12000 })
.then(() => true, () => false)
check('数不变、换了一条:按最新一条的时间认出新来的,照样发通知', swapped,
(await page.evaluate(() => window.__notes.map((n) => n.tag))).join(','))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
await ctx.close() await ctx.close()
}) })
+2 -1
View File
@@ -2901,7 +2901,8 @@ await section('应用平台:人工节点的流程配置、入口字段的类
nodes: [ nodes: [
node('in', 'input', 0, 200, '入口', { fields: [{ name: 'amount', required: true }, { name: 'kind' }] }), node('in', 'input', 0, 200, '入口', { fields: [{ name: 'amount', required: true }, { name: 'kind' }] }),
node('h', 'human', 300, 200, '财务审批', { mode: 'approve', title: '审批', message: '{{ input.amount }}' }), node('h', 'human', 300, 200, '财务审批', { mode: 'approve', title: '审批', message: '{{ input.amount }}' }),
node('pay', 'tool', 600, 200, '付款', { args: {} }), // 选了工具:调用工具节点没选工具时「运行」按钮被挡住(错误级问题),发起表单就打不开
node('pay', 'tool', 600, 200, '付款', { tool: 'http_request', args: {} }),
node('out', 'output', 900, 200, '成果', { fields: [{ name: '结果', value: '{{ last_message }}' }] }), node('out', 'output', 900, 200, '成果', { fields: [{ name: '结果', value: '{{ last_message }}' }] }),
], ],
edges: [edge('in', 'h'), edge('h', 'pay', 'approved'), edge('pay', 'out')], edges: [edge('in', 'h'), edge('h', 'pay', 'approved'), edge('pay', 'out')],
+8 -1
View File
@@ -118,7 +118,14 @@ async function countNotice(page, text, settle = 1200) {
} }
await section('画布助手栏', async () => { await section('画布助手栏', async () => {
const { page, errors } = await visit('/studio') // 守的是空态(还没跟助手说过话):/studio 落到最近改过的那张图,真数据里它常常已经有几轮对话,空态就不出来。
// 这张图的助手对话按「没有」回,和一张新图一样(建对话的 POST 照旧被写闸门掐掉)
const { page, errors } = await newPage()
await page.route((url) => url.pathname === '/api/conversations' && url.searchParams.get('kind') === 'canvas',
(route) => (route.request().method() === 'GET'
? route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) : route.fallback()))
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
await page.waitForTimeout(700)
check('没有运行时报错', errors.length === 0, errors.join(' | ')) check('没有运行时报错', errors.length === 0, errors.join(' | '))
const body = await page.locator('body').innerText() const body = await page.locator('body').innerText()