|
|
@@ -102,6 +102,8 @@
|
|
|
<button class="ai-graph__btn" type="button" @click="activeNode = null">关闭</button>
|
|
|
</div>
|
|
|
<dl class="ai-graph__detail-list">
|
|
|
+ <dt>类型</dt>
|
|
|
+ <dd>{{ activeNodeTypeLabel }}</dd>
|
|
|
<template v-if="activeNode.category">
|
|
|
<dt>分类</dt>
|
|
|
<dd>{{ activeNode.category }}</dd>
|
|
|
@@ -117,14 +119,16 @@
|
|
|
</dl>
|
|
|
|
|
|
<!--
|
|
|
- 节点没有像连线那样的类型文字,无法一眼看出该查通话还是交易,
|
|
|
- 因此两个入口都给出;若该节点的关联边全是同一种类型,则把那个入口做成主按钮。
|
|
|
+ 两个入口都给出,但可用性取决于节点的实体类型:号码节点只能查通话、卡号节点只能查交易
|
|
|
+ (对应弹框没有该类型的查询字段时置灰,并把原因写进 title)。
|
|
|
+ 样式上两个按钮一律保持无背景色,只有悬停时才给出反馈。
|
|
|
-->
|
|
|
<div class="ai-graph__detail-actions">
|
|
|
<button
|
|
|
class="ai-graph__btn"
|
|
|
- :class="{ 'is-primary': activeNodeKind === 'call' }"
|
|
|
type="button"
|
|
|
+ :disabled="nodeRecordDisabled('call')"
|
|
|
+ :title="nodeRecordTitle('call')"
|
|
|
@click="openActiveRecords('call')"
|
|
|
>
|
|
|
<Icon icon="mdi:phone-outline" :size="13" />
|
|
|
@@ -132,8 +136,9 @@
|
|
|
</button>
|
|
|
<button
|
|
|
class="ai-graph__btn"
|
|
|
- :class="{ 'is-primary': activeNodeKind === 'trans' }"
|
|
|
type="button"
|
|
|
+ :disabled="nodeRecordDisabled('trans')"
|
|
|
+ :title="nodeRecordTitle('trans')"
|
|
|
@click="openActiveRecords('trans')"
|
|
|
>
|
|
|
<Icon icon="mdi:bank-transfer" :size="13" />
|
|
|
@@ -154,7 +159,7 @@
|
|
|
-->
|
|
|
<div v-if="chooser" ref="chooserRef" class="ai-graph__chooser" :style="chooserStyle">
|
|
|
<div class="ai-graph__chooser-head">
|
|
|
- {{ chooser.a }} ⇄ {{ chooser.b }}
|
|
|
+ {{ nodeDisplayName(chooser.source) }} ⇄ {{ nodeDisplayName(chooser.target) }}
|
|
|
<span v-if="chooser.label" class="ai-graph__chooser-label">{{ chooser.label }}</span>
|
|
|
</div>
|
|
|
<div v-if="chooser.hint" class="ai-graph__chooser-hint">{{ chooser.hint }}</div>
|
|
|
@@ -205,6 +210,7 @@
|
|
|
} from '@relation-graph/vue';
|
|
|
import '@relation-graph/vue/style.css';
|
|
|
import Icon from '@/core/components/Icon/src/Icon.vue';
|
|
|
+ import { useMessage } from '@/core/hooks/web/useMessage';
|
|
|
import CallRecordModal from '@/call/views/components/CallRecordModal.vue';
|
|
|
import { getCallRecord } from '@/call/api/callApi';
|
|
|
import TransRecordModal from '@/trans/views/components/TransRecordModal.vue';
|
|
|
@@ -214,6 +220,14 @@
|
|
|
interface GraphNode {
|
|
|
id: string;
|
|
|
name?: string;
|
|
|
+ /**
|
|
|
+ * 节点实体类型,由后端 render_graph 规范化后输出:person(人)/ phone(电话号码)/
|
|
|
+ * card(银行卡号)/ other(其他实体)。
|
|
|
+ * 它决定点击该节点查明细时用哪个查询参数(见 SIDE_FIELDS):
|
|
|
+ * 人按 personName、号码按 personPhones、卡号按 personCardNo。
|
|
|
+ * 老图谱没有这个字段,此时按 person 处理。
|
|
|
+ */
|
|
|
+ type?: string;
|
|
|
category?: string;
|
|
|
value?: number | string;
|
|
|
desc?: string;
|
|
|
@@ -253,6 +267,8 @@
|
|
|
*/
|
|
|
const CATEGORY_COLORS = ['#2a50ec', '#1a7f37', '#bc4c00', '#cf222e', '#8250df', '#0969da', '#6e7781', '#9a6700'];
|
|
|
|
|
|
+ const { showMessage } = useMessage();
|
|
|
+
|
|
|
/**
|
|
|
* 图谱实例句柄。
|
|
|
*
|
|
|
@@ -375,11 +391,123 @@
|
|
|
*/
|
|
|
const RECORD_MODAL_Z_INDEX = 1015;
|
|
|
|
|
|
- /** 两点双向合并查询时,单方向最多取多少条(分页只能在客户端做) */
|
|
|
- const PAIR_FETCH_LIMIT = 500;
|
|
|
+ /** 合并取数时每组条件最多取多少条(分页只能在客户端做) */
|
|
|
+ const MERGE_FETCH_LIMIT = 500;
|
|
|
|
|
|
type RecordKind = 'call' | 'trans';
|
|
|
|
|
|
+ const RECORD_KIND_LABELS: Record<RecordKind, string> = { call: '通话记录', trans: '交易记录' };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 节点的实体类型(后端 GraphNodeType 的 code)。
|
|
|
+ *
|
|
|
+ * 与边的 type 不同:边的 type 决定「打开哪个弹框」,节点的 type 决定「用哪个字段查」。
|
|
|
+ */
|
|
|
+ type NodeType = 'person' | 'phone' | 'card' | 'other';
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 节点类型别名。归一化后再查表(去空白/下划线/连字符 + 转小写),
|
|
|
+ * 因此表里的键都是紧凑小写形式;中文与项目内部旧写法(obj / tel)一并兜住。
|
|
|
+ */
|
|
|
+ const NODE_TYPE_ALIASES = new Map<string, NodeType>([
|
|
|
+ ['person', 'person'],
|
|
|
+ ['obj', 'person'],
|
|
|
+ ['people', 'person'],
|
|
|
+ ['ren', 'person'],
|
|
|
+ ['personobj', 'person'],
|
|
|
+ ['人', 'person'],
|
|
|
+ ['人员', 'person'],
|
|
|
+ ['自然人', 'person'],
|
|
|
+ ['phone', 'phone'],
|
|
|
+ ['tel', 'phone'],
|
|
|
+ ['mobile', 'phone'],
|
|
|
+ ['phoneno', 'phone'],
|
|
|
+ ['telno', 'phone'],
|
|
|
+ ['电话', 'phone'],
|
|
|
+ ['号码', 'phone'],
|
|
|
+ ['手机号', 'phone'],
|
|
|
+ ['手机号码', 'phone'],
|
|
|
+ ['电话号码', 'phone'],
|
|
|
+ ['card', 'card'],
|
|
|
+ ['bankcard', 'card'],
|
|
|
+ ['cardno', 'card'],
|
|
|
+ ['卡', 'card'],
|
|
|
+ ['卡号', 'card'],
|
|
|
+ ['银行卡', 'card'],
|
|
|
+ ['银行卡号', 'card'],
|
|
|
+ ['账号', 'card'],
|
|
|
+ ['账户', 'card'],
|
|
|
+ ['other', 'other'],
|
|
|
+ ['其他', 'other'],
|
|
|
+ ['其它', 'other'],
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|
|
+ person: '人',
|
|
|
+ phone: '电话号码',
|
|
|
+ card: '银行卡号',
|
|
|
+ other: '其他',
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 节点实体类型;未标识或标识不认识时按人处理(与老图谱的既有行为一致) */
|
|
|
+ function resolveNodeType(node?: GraphNode | null): NodeType {
|
|
|
+ const key = String(node?.type ?? '')
|
|
|
+ .trim()
|
|
|
+ .toLowerCase()
|
|
|
+ .replace(/[\s_-]+/g, '');
|
|
|
+ return NODE_TYPE_ALIASES.get(key) ?? 'person';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 详情面板展示用的节点类型名 */
|
|
|
+ const activeNodeTypeLabel = computed(() => NODE_TYPE_LABELS[resolveNodeType(activeNode.value)]);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一个实体在某个弹框里的查询字段。
|
|
|
+ *
|
|
|
+ * <p>array=true 的字段后端收数组(通话的 personPhones / otherPhones 是 {@code List<String>})。
|
|
|
+ */
|
|
|
+ interface SideField {
|
|
|
+ field: string;
|
|
|
+ array?: boolean;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 弹框 × 节点类型 → 本方(mine)/ 对方(theirs)查询字段。
|
|
|
+ *
|
|
|
+ * <p>字段名严格对齐两个弹框的取数契约({@code CallRecordModal} / {@code TransRecordModal} 的 buildQuery 透传项):
|
|
|
+ * <ul>
|
|
|
+ * <li>通话记录(CallRecordQuery)支持 personName / otherName / personPhones / otherPhones;</li>
|
|
|
+ * <li>交易记录(TransRecordQuery)支持 personName / otherName / personCardNo / otherCardNo。</li>
|
|
|
+ * </ul>
|
|
|
+ * 因此卡片号在通话记录里、号码在交易记录里都没有对应字段,缺项即表示「该弹框查不了这种节点」。
|
|
|
+ */
|
|
|
+ const SIDE_FIELDS: Record<RecordKind, Partial<Record<NodeType, { mine: SideField; theirs: SideField }>>> = {
|
|
|
+ call: {
|
|
|
+ person: { mine: { field: 'personName' }, theirs: { field: 'otherName' } },
|
|
|
+ phone: { mine: { field: 'personPhones', array: true }, theirs: { field: 'otherPhones', array: true } },
|
|
|
+ },
|
|
|
+ trans: {
|
|
|
+ person: { mine: { field: 'personName' }, theirs: { field: 'otherName' } },
|
|
|
+ card: { mine: { field: 'personCardNo' }, theirs: { field: 'otherCardNo' } },
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 一组查询条件(可含多个字段,例如「人是本方 且 卡是对方」) */
|
|
|
+ type RecordConditions = Record<string, unknown>;
|
|
|
+
|
|
|
+ /** 会被条件组覆盖的字段:合并取数时要先从公共查询条件里摘掉,否则两路会被同一组条件锁死 */
|
|
|
+ const CONDITION_FIELDS = [
|
|
|
+ 'personName',
|
|
|
+ 'personNames',
|
|
|
+ 'otherName',
|
|
|
+ 'personPhones',
|
|
|
+ 'otherPhones',
|
|
|
+ 'personPhone',
|
|
|
+ 'otherPhone',
|
|
|
+ 'personCardNo',
|
|
|
+ 'otherCardNo',
|
|
|
+ ];
|
|
|
+
|
|
|
/**
|
|
|
* 边的语义类型判定结果:
|
|
|
* - `call` / `trans`:可直接打开对应明细弹框
|
|
|
@@ -464,10 +592,15 @@
|
|
|
const callBaseQuery = ref<Record<string, unknown>>({});
|
|
|
const transBaseQuery = ref<Record<string, unknown>>({});
|
|
|
/**
|
|
|
- * 「两点之间」查询上下文;为 null 表示按单人(节点)查询。
|
|
|
- * 有值时启用自定义取数:把 A→B 与 B→A 两个方向合并后再分页。
|
|
|
+ * 自定义取数上下文:有值时按 conditions 里的多组条件分别取数再合并(客户端分页)。
|
|
|
+ *
|
|
|
+ * 两种情况都会走这里:
|
|
|
+ * ① 点击连线 —— 两点之间,正反两个方向各一组条件;
|
|
|
+ * ② 点击节点 —— 单个实体,本方侧与对方侧各一组条件。
|
|
|
+ * 卡号/号码既可能落在记录的本方字段也可能落在对方字段,只查一侧会漏数据
|
|
|
+ * (项目图谱页对 card 节点同样是「该卡作为本方卡或对方卡都算」的口径)。
|
|
|
*/
|
|
|
- const pairContext = ref<{ kind: RecordKind; a: string; b: string } | null>(null);
|
|
|
+ const mergeContext = ref<{ kind: RecordKind; conditions: RecordConditions[] } | null>(null);
|
|
|
|
|
|
/**
|
|
|
* 连线点击时 relation-graph 只交出库内部的 line 对象,用 id 反查原始边。
|
|
|
@@ -481,10 +614,71 @@
|
|
|
return edges.value.find((item) => String(item.source) === from && String(item.target) === to);
|
|
|
}
|
|
|
|
|
|
- /** 节点 id → 节点显示名(后端记录表里存的是人名,不是图节点 id) */
|
|
|
- function nodeDisplayName(id: string): string {
|
|
|
- const found = nodes.value.find((item) => String(item.id) === String(id));
|
|
|
- return String(found?.name ?? found?.id ?? '').trim();
|
|
|
+ /** 节点 id → 节点对象 */
|
|
|
+ function findNode(id: string): GraphNode | undefined {
|
|
|
+ return nodes.value.find((item) => String(item.id) === String(id));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 节点显示名(后端记录表里存的是人名 / 号码 / 卡号,不是图节点 id) */
|
|
|
+ function nodeDisplayName(node?: GraphNode | null): string {
|
|
|
+ return String(node?.name ?? node?.id ?? '').trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 节点用于查明细的关键字:必须取记录表里的原始值,所以优先 name、其次 id */
|
|
|
+ function nodeKeyword(node?: GraphNode | null): string {
|
|
|
+ return String(node?.name ?? node?.id ?? '').trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildCondition(side: SideField, value: string): RecordConditions {
|
|
|
+ return { [side.field]: side.array ? [value] : value };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 单个实体:本方侧 + 对方侧各一组条件 */
|
|
|
+ function buildNodeConditions(kind: RecordKind, node: GraphNode): RecordConditions[] {
|
|
|
+ const fields = SIDE_FIELDS[kind][resolveNodeType(node)];
|
|
|
+ const value = nodeKeyword(node);
|
|
|
+ if (!fields || !value) return [];
|
|
|
+ return [buildCondition(fields.mine, value), buildCondition(fields.theirs, value)];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 连线两端:正反两个方向各一组条件(A 本方·B 对方,以及 B 本方·A 对方)。
|
|
|
+ *
|
|
|
+ * 任一端在该弹框里没有对应字段时(例如用通话记录查卡号节点),
|
|
|
+ * 退化成「只按能表达的那一端查」,而不是整条边都查不了。
|
|
|
+ */
|
|
|
+ function buildPairConditions(kind: RecordKind, source?: GraphNode, target?: GraphNode): RecordConditions[] {
|
|
|
+ const sourceFields = source ? SIDE_FIELDS[kind][resolveNodeType(source)] : undefined;
|
|
|
+ const targetFields = target ? SIDE_FIELDS[kind][resolveNodeType(target)] : undefined;
|
|
|
+ const a = nodeKeyword(source);
|
|
|
+ const b = nodeKeyword(target);
|
|
|
+ const conditions: RecordConditions[] = [];
|
|
|
+ if (sourceFields && targetFields && a && b) {
|
|
|
+ conditions.push({ ...buildCondition(sourceFields.mine, a), ...buildCondition(targetFields.theirs, b) });
|
|
|
+ conditions.push({ ...buildCondition(targetFields.mine, b), ...buildCondition(sourceFields.theirs, a) });
|
|
|
+ return conditions;
|
|
|
+ }
|
|
|
+ if (sourceFields && a) conditions.push(buildCondition(sourceFields.mine, a));
|
|
|
+ if (targetFields && b) conditions.push(buildCondition(targetFields.mine, b));
|
|
|
+ return conditions;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 该弹框查不了这种节点时的提示文案 */
|
|
|
+ function unsupportedHint(kind: RecordKind, node?: GraphNode | null): string {
|
|
|
+ return `${RECORD_KIND_LABELS[kind]}不支持按「${NODE_TYPE_LABELS[resolveNodeType(node)]}」查询,该弹框没有对应字段`;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 详情面板的某个明细入口是否可用(该弹框是否有这种节点的查询字段) */
|
|
|
+ function nodeRecordDisabled(kind: RecordKind): boolean {
|
|
|
+ const node = activeNode.value;
|
|
|
+ return !node || !SIDE_FIELDS[kind][resolveNodeType(node)];
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 详情面板入口的悬停说明:可用时写清按哪个字段查、查哪两侧;不可用时说明原因 */
|
|
|
+ function nodeRecordTitle(kind: RecordKind): string {
|
|
|
+ const node = activeNode.value;
|
|
|
+ if (nodeRecordDisabled(kind)) return unsupportedHint(kind, node);
|
|
|
+ return `${RECORD_KIND_LABELS[kind]}(按${NODE_TYPE_LABELS[resolveNodeType(node)]}查询,含本方与对方两侧)`;
|
|
|
}
|
|
|
|
|
|
function openCallModal(query: Record<string, unknown>, title: string) {
|
|
|
@@ -501,45 +695,48 @@
|
|
|
transOpen.value = true;
|
|
|
}
|
|
|
|
|
|
- /** 两点之间:双向合并查询 */
|
|
|
- function openPairRecords(kind: RecordKind, a: string, b: string) {
|
|
|
- pairContext.value = { kind, a, b };
|
|
|
- if (kind === 'call') openCallModal({ personName: a, otherName: b }, `通话记录 · ${a} ⇄ ${b}`);
|
|
|
- else openTransModal({ personName: a, otherName: b }, `交易记录 · ${a} ⇄ ${b}`);
|
|
|
+ /** 打开明细弹框:条件组交给自定义取数,第一组条件同时作为弹框的初始筛选值 */
|
|
|
+ function openRecords(kind: RecordKind, conditions: RecordConditions[], title: string) {
|
|
|
+ mergeContext.value = { kind, conditions };
|
|
|
+ const baseQuery = { ...conditions[0] };
|
|
|
+ if (kind === 'call') openCallModal(baseQuery, title);
|
|
|
+ else openTransModal(baseQuery, title);
|
|
|
}
|
|
|
|
|
|
- /** 单个人:交给弹框默认取数,走服务端分页 */
|
|
|
- function openPersonRecords(kind: RecordKind, name: string) {
|
|
|
- pairContext.value = null;
|
|
|
- if (kind === 'call') openCallModal({ personName: name }, `通话记录 · ${name}`);
|
|
|
- else openTransModal({ personName: name }, `交易记录 · ${name}`);
|
|
|
+ /** 连线:两端节点的实体类型决定查询字段 */
|
|
|
+ function openPairRecords(kind: RecordKind, source?: GraphNode, target?: GraphNode) {
|
|
|
+ const conditions = buildPairConditions(kind, source, target);
|
|
|
+ if (!conditions.length) {
|
|
|
+ showMessage(unsupportedHint(kind, source ?? target), 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ openRecords(
|
|
|
+ kind,
|
|
|
+ conditions,
|
|
|
+ `${RECORD_KIND_LABELS[kind]} · ${nodeDisplayName(source)} ⇄ ${nodeDisplayName(target)}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 节点:该实体的类型决定查询字段 */
|
|
|
+ function openNodeRecords(kind: RecordKind, node: GraphNode) {
|
|
|
+ const conditions = buildNodeConditions(kind, node);
|
|
|
+ if (!conditions.length) {
|
|
|
+ showMessage(unsupportedHint(kind, node), 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ openRecords(kind, conditions, `${RECORD_KIND_LABELS[kind]} · ${nodeDisplayName(node)}`);
|
|
|
}
|
|
|
|
|
|
function openActiveRecords(kind: RecordKind) {
|
|
|
const node = activeNode.value;
|
|
|
if (!node) return;
|
|
|
- openPersonRecords(kind, String(node.name ?? node.id));
|
|
|
+ openNodeRecords(kind, node);
|
|
|
}
|
|
|
|
|
|
- /** 详情节点的关联边类型:关联边全是同一种时返回该类型,混合或识别不出返回空 */
|
|
|
- const activeNodeKind = computed<RecordKind | ''>(() => {
|
|
|
- const node = activeNode.value;
|
|
|
- if (!node) return '';
|
|
|
- const kinds = new Set<RecordKind>();
|
|
|
- for (const edge of edges.value) {
|
|
|
- if (String(edge.source) !== String(node.id) && String(edge.target) !== String(node.id)) continue;
|
|
|
- const kind = resolveEdgeKind(edge);
|
|
|
- // other 表示明确没有对应明细,不参与「该节点该查哪种明细」的判断
|
|
|
- if (kind === 'call' || kind === 'trans') kinds.add(kind);
|
|
|
- }
|
|
|
- const [firstKind] = kinds;
|
|
|
- return kinds.size === 1 ? (firstKind ?? '') : '';
|
|
|
- });
|
|
|
-
|
|
|
/** 兜底菜单:类型判不出(老图谱)或明确是「其他」时,固定在鼠标位置让用户手选 */
|
|
|
const chooser = ref<{
|
|
|
- a: string;
|
|
|
- b: string;
|
|
|
+ source: GraphNode;
|
|
|
+ target: GraphNode;
|
|
|
label: string;
|
|
|
hint: string;
|
|
|
x: number;
|
|
|
@@ -576,14 +773,14 @@
|
|
|
function onLineClick(line: any, _link: any, e?: MouseEvent) {
|
|
|
const edge = resolveEdge(line);
|
|
|
if (!edge) return;
|
|
|
- const a = nodeDisplayName(String(edge.source));
|
|
|
- const b = nodeDisplayName(String(edge.target));
|
|
|
- if (!a || !b) return;
|
|
|
+ const source = findNode(String(edge.source));
|
|
|
+ const target = findNode(String(edge.target));
|
|
|
+ if (!source || !target) return;
|
|
|
|
|
|
const kind = resolveEdgeKind(edge);
|
|
|
if (kind === 'call' || kind === 'trans') {
|
|
|
closeChooser();
|
|
|
- openPairRecords(kind, a, b);
|
|
|
+ openPairRecords(kind, source, target);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
@@ -591,8 +788,8 @@
|
|
|
const x = Math.min(Math.max(Number(e?.clientX ?? 0) + 4, 8), Math.max(window.innerWidth - 198, 8));
|
|
|
const y = Math.min(Math.max(Number(e?.clientY ?? 0) + 4, 8), Math.max(window.innerHeight - 118, 8));
|
|
|
chooser.value = {
|
|
|
- a,
|
|
|
- b,
|
|
|
+ source,
|
|
|
+ target,
|
|
|
label: String(edge.label || ''),
|
|
|
hint:
|
|
|
kind === 'other'
|
|
|
@@ -606,7 +803,7 @@
|
|
|
function pickChooser(kind: RecordKind) {
|
|
|
const target = chooser.value;
|
|
|
closeChooser();
|
|
|
- if (target) openPairRecords(kind, target.a, target.b);
|
|
|
+ if (target) openPairRecords(kind, target.source, target.target);
|
|
|
}
|
|
|
|
|
|
/** 兼容弹框内部的列表兜底:后端 Result 包裹层级与裸数组两种形态都出现过 */
|
|
|
@@ -640,33 +837,33 @@
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 两点之间的明细取数:同时查 A→B 与 B→A 再合并去重。
|
|
|
+ * 多条件合并取数:conditions 里每组条件各查一次再合并去重。
|
|
|
*
|
|
|
- * 后端 personName / otherName 都是精确匹配,只查一个方向会漏掉反向记录
|
|
|
- * (项目图谱页用的是同样的双向合并思路,见 GraphTransDetailQuery 的 lx=1)。
|
|
|
- * 代价是分页只能在客户端做,所以单方向限取 PAIR_FETCH_LIMIT 条。
|
|
|
+ * 后端这些字段都是精确匹配,只看一个方向(或一侧)会漏记录 —— 项目图谱页用的是同样的
|
|
|
+ * 双向合并思路(见 GraphTransDetailQuery 的 lx=1)。代价是分页只能在客户端做,
|
|
|
+ * 因此每组条件限取 MERGE_FETCH_LIMIT 条。
|
|
|
*/
|
|
|
- async function fetchPairRecords(kind: RecordKind, a: string, b: string, query: any) {
|
|
|
+ async function fetchMergedRecords(kind: RecordKind, conditions: RecordConditions[], query: any) {
|
|
|
const api: (data: any) => Promise<any> = kind === 'call' ? getCallRecord : getTransRecord;
|
|
|
const shared: Record<string, unknown> = { ...(query || {}) };
|
|
|
- delete shared.personName;
|
|
|
- delete shared.otherName;
|
|
|
- delete shared.personNames;
|
|
|
+ // 条件组自带这些字段,必须从公共条件里摘掉,否则每组都会被同一个值锁死
|
|
|
+ for (const field of CONDITION_FIELDS) delete shared[field];
|
|
|
delete shared.page;
|
|
|
delete shared.limit;
|
|
|
|
|
|
- const [forward, backward] = await Promise.all([
|
|
|
- api({ ...shared, personName: a, otherName: b, page: 1, limit: PAIR_FETCH_LIMIT }),
|
|
|
- api({ ...shared, personName: b, otherName: a, page: 1, limit: PAIR_FETCH_LIMIT }),
|
|
|
- ]);
|
|
|
+ const responses = await Promise.all(
|
|
|
+ conditions.map((condition) => api({ ...shared, ...condition, page: 1, limit: MERGE_FETCH_LIMIT })),
|
|
|
+ );
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
const rows: any[] = [];
|
|
|
- for (const row of [...toRecordList(forward), ...toRecordList(backward)]) {
|
|
|
- const key = row?.id === undefined || row?.id === null ? JSON.stringify(row) : String(row.id);
|
|
|
- if (seen.has(key)) continue;
|
|
|
- seen.add(key);
|
|
|
- rows.push(row);
|
|
|
+ for (const response of responses) {
|
|
|
+ for (const row of toRecordList(response)) {
|
|
|
+ const key = row?.id === undefined || row?.id === null ? JSON.stringify(row) : String(row.id);
|
|
|
+ if (seen.has(key)) continue;
|
|
|
+ seen.add(key);
|
|
|
+ rows.push(row);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
const orderKey = String(query?.orderKey || '');
|
|
|
@@ -694,17 +891,17 @@
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- /** 只有「两点之间」模式才接管取数;按单人查询时返回 undefined,让弹框走默认接口 */
|
|
|
+ /** 有自定义取数上下文时接管取数(条件组在外部按节点类型算好),否则返回 undefined 让弹框走默认接口 */
|
|
|
const callFetchApi = computed(() => {
|
|
|
- const context = pairContext.value;
|
|
|
+ const context = mergeContext.value;
|
|
|
if (context?.kind !== 'call') return undefined;
|
|
|
- return (query: any) => fetchPairRecords('call', context.a, context.b, query);
|
|
|
+ return (query: any) => fetchMergedRecords('call', context.conditions, query);
|
|
|
});
|
|
|
|
|
|
const transFetchApi = computed(() => {
|
|
|
- const context = pairContext.value;
|
|
|
+ const context = mergeContext.value;
|
|
|
if (context?.kind !== 'trans') return undefined;
|
|
|
- return (query: any) => fetchPairRecords('trans', context.a, context.b, query);
|
|
|
+ return (query: any) => fetchMergedRecords('trans', context.conditions, query);
|
|
|
});
|
|
|
|
|
|
function fitView() {
|
|
|
@@ -980,13 +1177,6 @@
|
|
|
background: var(--ai-primary-soft);
|
|
|
}
|
|
|
|
|
|
- /* 详情面板里推断出的主入口(该节点关联边全是同一种类型时) */
|
|
|
- &.is-primary {
|
|
|
- color: var(--ai-primary);
|
|
|
- border-color: fade(@primary-color, 45%);
|
|
|
- background: var(--ai-primary-soft);
|
|
|
- }
|
|
|
-
|
|
|
&:disabled {
|
|
|
opacity: 0.45;
|
|
|
cursor: not-allowed;
|