**测试台**
· 启动输入不再是「调用参数」表 —— 这条流真实的起法就是客户来信,
界面上就该是一封信(发件人 / 收件人 / 主题 / 正文 / BOM 附件)。
服务端拿**真通道的抽键正则** + **真触发器的装配映射**推出调用入参:
测试台自己不解析邮件,否则试通的是测试台而不是这条流。
抽不出必填就如实报「哪个字段没命中、通道的正则要什么」,不静默起空单。
· 一屏两大块:左选流,右两节 —— 01 输入配置、02 节点指派。
· 起单钮只剩一枚(原来页头一枚、RUN 卡里一枚)。
· 改暗色仪表台:调色板集中在 .g-tb/.g-rig 的一组变量上,
TRACE 那张树 + 甘特的结构一行没动,只是纸变成了面板。
**触发器 / 大模型** 列表改卡片:一条规则、一个实例都要一眼看全
(何时起 · 起哪条流 · 开没开 / 谁家的 · 什么型号 · 密钥绑没绑)。
触发器卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。
**同类卡等高**:底栏钉在卡底、说明恒占两行、卡头一行截断。
实测同一货架内 13 张工具卡、6 张活动卡、3 张模型卡各自完全等高。
**删掉 19 条 ⓘ 说教横幅**(「只列有生效版的包…」那一类)。
留下的是三种:确认带里的影响说明(点了动作才出现,是决策依据)、
warn/ok 的真反馈、以及「X 不存在」这类空态。
删掉入站邮件演练整屏 —— 它验的那两步已经并进测试台的起单路径了。
新增一条接缝测试:通道抽出来的键叫 keys.customerCode,触发器映射也得照这名字取;
哪头改了名字单还是能起,只是入参悄悄变空、流走到 G1 才莫名其妙地停。
四闸绿;契约快照随本提交入库。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
/**
|
|
* 仿真试跑台 —— 装配一次试跑 → 起单 → 看穿全程。
|
|
*
|
|
* 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口
|
|
* (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**,
|
|
* 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事:
|
|
* ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态)
|
|
*/
|
|
import { useMemo, useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import type { ProcessNode, ProcessPackage } from '@contracts';
|
|
import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testCurrentRoute, testRunRoute, testStopRoute } from '@contracts';
|
|
import { Ico } from '@web-shared/Ico.tsx';
|
|
import { call } from '@web-shared/typed-fetch.ts';
|
|
import { useGovChannels, useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
|
import { Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
|
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
|
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
|
|
import { TraceSheet } from './trace-sheet.tsx';
|
|
|
|
type RunOutput = { workflowId: string; state: string };
|
|
|
|
/** 只有活动点与审批点会解析负责人 —— 起点终点没有「派给谁」这回事 */
|
|
const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kind === 'approval';
|
|
|
|
const NODE_KIND_LABEL: Readonly<Record<string, string>> = {
|
|
activity: '活动',
|
|
approval: '审批',
|
|
start: '起点',
|
|
end: '终点',
|
|
};
|
|
|
|
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
|
const pkgQ = useGovPackages();
|
|
const staffQ = useGovStaff();
|
|
const chanQ = useGovChannels();
|
|
|
|
/** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */
|
|
const livePkgs = useMemo<ProcessPackage[]>(
|
|
() => (pkgQ.data?.packages ?? []).filter((p) => p.state === 'active'),
|
|
[pkgQ.data],
|
|
);
|
|
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]);
|
|
const staffName = (id: string): string => staff.find((s) => s.id === id)?.name ?? id;
|
|
const inbox = (chanQ.data?.channels ?? []).find((ch) => ch.kind === 'mail' && ch.enabled);
|
|
|
|
const [pickedId, setPicked] = useState('');
|
|
const [mail, setMail] = useState<MailDraft>(SAMPLE_MAIL);
|
|
const [assignments, setAssignments] = useState<Readonly<Record<string, string>>>({});
|
|
const queryClient = useQueryClient();
|
|
const [workflowId, setWorkflowId] = useState<string | undefined>(undefined);
|
|
/*
|
|
* **一次只跑一条**:刷新页面也认得回来 —— 不然仿真在后台跑着,界面上却像没起过,
|
|
* 人会再起一单(而上一单的任务还在别人工作台上挂着)。
|
|
*/
|
|
const current = useQuery({
|
|
queryKey: ['gov', 'testbed', 'current'],
|
|
queryFn: () => call<{ workflowId?: string; subject?: string }>(testCurrentRoute),
|
|
});
|
|
const running = workflowId ?? current.data?.workflowId;
|
|
|
|
/** 停止仿真 = **销毁**:工作流、活动任务、工单、实例、trace 一并拔掉 */
|
|
const stop = useMutation({
|
|
mutationFn: (id: string) => call<{ destroyed: boolean; tasks: number }>(testStopRoute, { body: { workflowId: id } }),
|
|
onSuccess: () => {
|
|
setWorkflowId(undefined);
|
|
run.reset();
|
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['desk'] });
|
|
},
|
|
});
|
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
|
|
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
|
|
const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0];
|
|
const nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]);
|
|
const to = mail.to === '' ? (inbox?.address ?? '') : mail.to;
|
|
const ready = pkg !== undefined && mail.from.trim() !== '' && to !== '' && mail.subject.trim() !== '';
|
|
|
|
const run = useMutation({
|
|
mutationFn: (input: RunInput) => call<RunOutput>(testRunRoute, { body: input }),
|
|
onSuccess: (r) => {
|
|
setWorkflowId(r.workflowId);
|
|
setError(undefined);
|
|
},
|
|
// 后端的判词照抄:「抽不出客户码」「无生效版」都是要人改配置的话,不该被我改写
|
|
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
|
});
|
|
|
|
const fire = (): void => {
|
|
if (pkg === undefined) return;
|
|
run.mutate({
|
|
packageId: pkg.id,
|
|
mail: { ...mail, to },
|
|
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
|
|
});
|
|
};
|
|
|
|
if (running !== undefined) {
|
|
return (
|
|
<Observe id={running} staffName={staffName} nav={nav} stopping={stop.isPending} onBack={() => stop.mutate(running)} />
|
|
);
|
|
}
|
|
|
|
const pinned = Object.values(assignments).filter((v) => v !== '').length;
|
|
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="flask-conical"
|
|
tone="brand"
|
|
kicker="联调测试台 · 装配态"
|
|
title="仿真试跑"
|
|
desc="这条流真实的起法就是一封客户来信 —— 写这封信、指派每个节点归谁,起单看穿全程。任务照旧落到被指派人的工作台。"
|
|
chips={
|
|
<>
|
|
<St tone={pkg === undefined ? 'warn' : 'ok'} ico={pkg === undefined ? 'alert-circle' : 'circle-check'}>
|
|
{pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version}`}
|
|
</St>
|
|
<St tone="mute" ico="user-check">
|
|
指派 {pinned}/{nodes.length}
|
|
</St>
|
|
</>
|
|
}
|
|
acts={[
|
|
{
|
|
label: run.isPending ? '起单中…' : '起单试跑',
|
|
ico: 'play',
|
|
primary: true,
|
|
disabled: !ready || run.isPending,
|
|
onClick: fire,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<QueryGate query={pkgQ}>
|
|
{livePkgs.length === 0 ? (
|
|
<div className="g-rig">
|
|
<div className="g-tb-empty">
|
|
<Ico name="scan" s={26} />
|
|
<b>没有可起单的流程包</b>
|
|
<p>
|
|
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* 左选流,右配置 —— 一屏两大块,不再把装配单摞成一长条 */
|
|
<div className="g-rig">
|
|
<aside className="g-rig-l">
|
|
<div className="g-rig-h">
|
|
<Ico name="workflow" s={11} />
|
|
流程包
|
|
<span className="x">{livePkgs.length}</span>
|
|
</div>
|
|
<div className="g-rig-pk">
|
|
{livePkgs.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
className={'g-pk' + (p.id === pkg?.id ? ' on' : '')}
|
|
onClick={() => {
|
|
setPicked(p.id);
|
|
setAssignments({});
|
|
}}
|
|
>
|
|
<b>{p.name}</b>
|
|
<span className="id">
|
|
{p.id}@v{p.version}
|
|
</span>
|
|
<span className="cnt">
|
|
<i>节点 {p.nodes.length}</i>
|
|
<i>边 {p.edges.length}</i>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="g-rig-r">
|
|
<div className="g-rig-h">
|
|
<span className="n">01</span>
|
|
输入配置
|
|
<span className="x">启动这条流的那封信</span>
|
|
</div>
|
|
<MailForm draft={{ ...mail, to }} onChange={setMail} inbox={inbox?.address} />
|
|
|
|
<div className="g-rig-h">
|
|
<span className="n">02</span>
|
|
节点指派
|
|
<span className="x">
|
|
{pinned}/{nodes.length} 已定 · 留空即照旧轮值
|
|
</span>
|
|
</div>
|
|
<div className="g-asn">
|
|
{nodes.map((n) => (
|
|
<div key={n.id} className={'g-asn-r' + ((assignments[n.id] ?? '') === '' ? '' : ' pinned')}>
|
|
<span className="nid">{n.id}</span>
|
|
<span className="kd">{NODE_KIND_LABEL[n.kind] ?? n.kind}</span>
|
|
<span className="lb">{n.label}</span>
|
|
<select
|
|
className="g-select"
|
|
value={assignments[n.id] ?? ''}
|
|
onChange={(e) => setAssignments((prev) => ({ ...prev, [n.id]: e.target.value }))}
|
|
>
|
|
<option value="">照旧轮值</option>
|
|
{staff.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name} · {s.position.code}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{error !== undefined && (
|
|
<Note tone="warn">
|
|
<b>没起成:</b>
|
|
{error}
|
|
</Note>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
</QueryGate>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ==================== 启动输入:一封信 ==================== */
|
|
|
|
type MailDraft = {
|
|
from: string;
|
|
to: string;
|
|
subject: string;
|
|
body: string;
|
|
attachments: ReadonlyArray<{ name: string; contentType: string; size: number }>;
|
|
};
|
|
|
|
type RunInput = { packageId: string; mail: MailDraft; assignments: Record<string, string> };
|
|
|
|
const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
|
|
/**
|
|
* 预填的是一封**抽得出键**的信 —— 主题里带客户码与询价单号,
|
|
* 通道的抽键正则就是照着这两样写的;改坏了起单会如实报「抽不出哪个」。
|
|
*/
|
|
const SAMPLE_MAIL: MailDraft = {
|
|
from: 'buyer@sz-machinery.com',
|
|
to: '',
|
|
subject: '【询价】C014 / RFQ-2607-001 工控主板 BOM 12 项',
|
|
body: '你好,附件是本次询价的 BOM,共 12 项,请按标准交期报价。\n付款方式沿用上次合同。',
|
|
attachments: [{ name: 'BOM-RFQ-2607-001.xlsx', contentType: XLSX, size: 28_416 }],
|
|
};
|
|
|
|
function MailForm({
|
|
draft,
|
|
onChange,
|
|
inbox,
|
|
}: {
|
|
draft: MailDraft;
|
|
onChange: (next: MailDraft) => void;
|
|
inbox: string | undefined;
|
|
}): ReactNode {
|
|
const set = <K extends keyof MailDraft>(k: K, v: MailDraft[K]): void => onChange({ ...draft, [k]: v });
|
|
return (
|
|
<div className="g-ml">
|
|
<label className="g-ml-r">
|
|
<em>发件人</em>
|
|
<input className="g-ml-i" value={draft.from} onChange={(e) => set('from', e.target.value)} />
|
|
</label>
|
|
<label className="g-ml-r">
|
|
<em>收件人</em>
|
|
<input
|
|
className="g-ml-i"
|
|
value={draft.to}
|
|
placeholder={inbox ?? '公司询价邮箱'}
|
|
onChange={(e) => set('to', e.target.value)}
|
|
/>
|
|
<span className="g-ml-t">投给哪个信箱决定用哪套抽键规则</span>
|
|
</label>
|
|
<label className="g-ml-r">
|
|
<em>主题</em>
|
|
<input className="g-ml-i" value={draft.subject} onChange={(e) => set('subject', e.target.value)} />
|
|
<span className="g-ml-t">客户码与询价单号从这里抽</span>
|
|
</label>
|
|
<label className="g-ml-r tall">
|
|
<em>正文</em>
|
|
<textarea className="g-ml-i" rows={4} value={draft.body} onChange={(e) => set('body', e.target.value)} />
|
|
</label>
|
|
<div className="g-ml-r tall">
|
|
<em>附件</em>
|
|
<div className="g-ml-at">
|
|
{draft.attachments.map((a, i) => (
|
|
<span key={a.name} className="g-att">
|
|
<Ico name="paperclip" s={11} />
|
|
{a.name}
|
|
<i>{(a.size / 1024).toFixed(0)} KB</i>
|
|
<button
|
|
aria-label={`移除 ${a.name}`}
|
|
onClick={() => set('attachments', draft.attachments.filter((_, j) => j !== i))}
|
|
>
|
|
<Ico name="x" s={10} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<button
|
|
className="g-att-add"
|
|
onClick={() =>
|
|
set('attachments', [
|
|
...draft.attachments,
|
|
{ name: `BOM-${draft.attachments.length + 1}.xlsx`, contentType: XLSX, size: 20_480 },
|
|
])
|
|
}
|
|
>
|
|
<Ico name="plus" s={11} />
|
|
加一个 BOM
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Observe({
|
|
id,
|
|
staffName,
|
|
nav,
|
|
onBack,
|
|
stopping,
|
|
}: {
|
|
id: string;
|
|
staffName: (sid: string) => string;
|
|
nav: Nav;
|
|
onBack: () => void;
|
|
stopping: boolean;
|
|
}): ReactNode {
|
|
const wf = useRunWorkflow(id);
|
|
const state = wf.data?.workflow.state;
|
|
// 到终态就停轮询:已经不动的东西没必要每两秒问一遍
|
|
const live = isLive(state);
|
|
const trQ = useRunTrace(id, live);
|
|
|
|
const spans = trQ.data?.spans ?? [];
|
|
const w = wf.data;
|
|
const nodeLabel = (nid: string): string => w?.pkg.nodes.find((n) => n.id === nid)?.label ?? nid;
|
|
/** 节点实例出参里没有受理人,受理人只在 span 的 attributes 上 —— 从 TRACE 反查 */
|
|
const assigneeOfNode = (nid: string): string | undefined => {
|
|
const s = spans.find((x) => x.kind === 'node' && x.nodeId === nid);
|
|
const a = s?.attributes?.['assigneeId'];
|
|
return typeof a === 'string' ? staffName(a) : undefined;
|
|
};
|
|
|
|
const started = w === undefined ? undefined : new Date(w.workflow.startedAt);
|
|
const refresh = (): void => {
|
|
void wf.refetch();
|
|
void trQ.refetch();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="activity"
|
|
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
|
|
kicker="联调测试台 · 观测态"
|
|
title={w?.workflow.subject ?? '试跑中'}
|
|
desc="流已经起了。下面这三样看穿它:全链 TRACE 看它怎么跑,节点实例看它跑到哪,派工带看它把活派给了谁。"
|
|
chips={
|
|
<>
|
|
<St
|
|
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
|
|
ico={state === 'completed' ? 'circle-check' : state === 'abnormal' ? 'alert-triangle' : 'radio'}
|
|
>
|
|
{state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)}
|
|
</St>
|
|
<St tone="mute" ico="hash">
|
|
{id.slice(0, 8)}
|
|
</St>
|
|
{live && (
|
|
<St tone="mute" ico="refresh-cw">
|
|
每 2 秒自动刷新
|
|
</St>
|
|
)}
|
|
</>
|
|
}
|
|
acts={[
|
|
{ label: '刷新', ico: 'refresh-cw', onClick: refresh },
|
|
{ label: stopping ? '销毁中…' : '停止仿真', ico: 'square', primary: true, onClick: onBack },
|
|
]}
|
|
/>
|
|
|
|
<div className="g-tb">
|
|
<QueryGate query={wf}>
|
|
{w !== undefined && (
|
|
<>
|
|
{/* 图签栏:工程图右下角那张表 —— 一条流的身份,一格一件事 */}
|
|
<div className="g-sheet">
|
|
<div className="g-sheet-h">
|
|
<Ico name="file-text" s={12} />
|
|
WF — 工作流
|
|
<span className="x">{w.workflow.source.kind} · {w.workflow.source.ref}</span>
|
|
</div>
|
|
<div className="g-tblock">
|
|
<div>
|
|
<em>工作流号</em>
|
|
<b>{id.slice(0, 8)}</b>
|
|
</div>
|
|
<div>
|
|
<em>状态</em>
|
|
<b className={state === 'running' || state === 'waiting' ? 'run' : 'brass'}>
|
|
{WORKFLOW_STATE_LABEL[w.workflow.state] ?? w.workflow.state}
|
|
</b>
|
|
</div>
|
|
<div>
|
|
<em>流程包</em>
|
|
<b>{w.workflow.packageId}@v{w.workflow.packageVersion}</b>
|
|
</div>
|
|
<div>
|
|
<em>起单时刻</em>
|
|
<b>{started?.toLocaleTimeString('zh-CN', { hour12: false }) ?? '—'}</b>
|
|
</div>
|
|
<div>
|
|
<em>起单人</em>
|
|
<b>{w.workflow.source.by === undefined ? '—' : staffName(w.workflow.source.by)}</b>
|
|
</div>
|
|
<div>
|
|
<em>节点 / 边</em>
|
|
<b>
|
|
{w.pkg.nodes.length} / {w.pkg.edges.length}
|
|
</b>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 派工带:测试台不代答,这句必须说满 */}
|
|
<div className="g-tbdesk">
|
|
<div className="g-tbdesk-h">
|
|
<Ico name="briefcase" s={12} />
|
|
DSK — 任务落到了谁的工作台
|
|
<button className="g-btn xs" onClick={() => nav.jump('tasks', 'list')}>
|
|
<Ico name="activity" s={11} />
|
|
去运营中心 · 任务
|
|
</button>
|
|
</div>
|
|
{w.tasks.length === 0 ? (
|
|
<p>还没有任务落到任何工位 —— 流刚起,或者卡在审批点上(审批单不是任务,见下面的节点实例)。</p>
|
|
) : (
|
|
w.tasks.map((t) => (
|
|
<div key={t.id} className="g-tbdesk-r">
|
|
<span className="nid">{t.nodeId}</span>
|
|
{t.title}
|
|
<span>→</span>
|
|
<b>{t.ownerName} 的工作台</b>
|
|
</div>
|
|
))
|
|
)}
|
|
<p>
|
|
测试台<b>不代答</b>:这些是真任务,要那个人在自己的工作台上答,这条流才会往下走。
|
|
</p>
|
|
</div>
|
|
|
|
<TraceSheet
|
|
spans={spans}
|
|
staffName={staffName}
|
|
extra={
|
|
<button className="g-btn xs" onClick={refresh} disabled={trQ.isFetching}>
|
|
<Ico name="refresh-cw" s={11} />
|
|
{trQ.isFetching ? '取数中' : '刷新'}
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
<div className="g-sheet">
|
|
<div className="g-sheet-h">
|
|
<Ico name="list-checks" s={12} />
|
|
NOD — 节点实例
|
|
<span className="x">{w.instances.length} 个已进入</span>
|
|
</div>
|
|
{w.instances.length === 0 ? (
|
|
<div className="g-tb-empty">
|
|
<Ico name="scan" s={26} />
|
|
<b>还没有节点进入</b>
|
|
<p>起点已过,第一个节点正在派工。</p>
|
|
</div>
|
|
) : (
|
|
<div className="g-nins">
|
|
<div className="g-nins-h">
|
|
<span>节点</span>
|
|
<span>名称</span>
|
|
<span>状态</span>
|
|
<span>终态</span>
|
|
<span>受理人</span>
|
|
</div>
|
|
{w.instances.map((it) => {
|
|
const bad = it.terminal !== undefined && it.terminal !== 'released';
|
|
const cls = bad ? 'bad' : it.running ? 'run' : it.waiting ? 'wait' : it.terminal === undefined ? '' : 'done';
|
|
const who = assigneeOfNode(it.nodeId);
|
|
return (
|
|
<div key={`${it.nodeId}#${it.instance}`} className="g-nins-r">
|
|
<span className="nid">{it.nodeId}</span>
|
|
<span className="lb">{nodeLabel(it.nodeId)}</span>
|
|
<span className={`st ${cls}`}>
|
|
<i />
|
|
{bad ? '异常' : it.running ? '在跑' : it.waiting ? '等人' : it.terminal === undefined ? '已进入' : '已放行'}
|
|
</span>
|
|
<span className="lb">
|
|
{it.terminal === undefined ? '—' : (TERMINAL_STATE_LABEL[it.terminal] ?? it.terminal)}
|
|
</span>
|
|
<span className="who">
|
|
{who === undefined ? '—' : <b>{who}</b>}
|
|
{it.instance > 1 && <span className="ins"> 第 {it.instance} 次</span>}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</QueryGate>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|