فهرست منبع

fix: AI 聊天关系图谱按对象关系分析页只读版对齐

- 移除自研详情侧栏,恢复原页面的节点/关系右键菜单:查看详情、仅看流入、仅看流出、适配视图;去掉复制/粘贴/删除/连线/组合/新增等编辑项。- 详情查询改回原页面口径:节点详情走 /graph/nodeDetail + QueryTableModal/BasicModal,通话与交易关系分别走原 CallRecordModal/TransRecordModal 和原 buildGraphCallDetailQuery/buildGraphTransDetailQuery/fetchGraph* 逻辑。- 工具栏恢复原页面 icon 工具条、查找/过滤面板、缩放/适配/居中/智能布局/全屏与画布状态栏;去掉工具箱、画布 Tab、历史记录、保存、新增/编辑/删除、标记、关联对象等已要求移除的功能。- 移除不再使用的 GraphNodeDetailModal.vue、graphDetailApi.ts;GraphAnalysisBlock 去掉重复的头部适配/重排/全屏按钮,保留图表容器信息。
cc 2 روز پیش
والد
کامیت
49d2463a31

+ 1 - 22
ai-electron/frontend/src/ai/components/graph/GraphAnalysisBlock.vue

@@ -6,17 +6,6 @@
       <span v-if="metaText" class="ai-graph__meta" :title="metaText">{{ metaText }}</span>
       <span class="ai-graph__spacer"></span>
       <span v-if="model" class="ai-graph__count">节点 {{ model.nodes.length }} · 关系 {{ model.edges.length }}</span>
-      <button class="ai-graph__btn" type="button" :disabled="!renderable" @click="fitView">适配画布</button>
-      <button class="ai-graph__btn" type="button" :disabled="!renderable" @click="relayout">重新布局</button>
-      <button
-        class="ai-graph__btn"
-        type="button"
-        :title="isFullscreen ? '退出全屏(Esc)' : '全屏查看'"
-        @click="toggleFullscreen"
-      >
-        <Icon :icon="isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'" :size="13" />
-        {{ isFullscreen ? '退出全屏' : '全屏查看' }}
-      </button>
     </header>
 
     <div v-if="model?.truncated" class="ai-graph__notice" :title="model.note">
@@ -25,7 +14,7 @@
 
     <div ref="bodyRef" class="ai-graph__body">
       <template v-if="model && renderable">
-        <AsyncGraphViewer v-if="visible" ref="viewerRef" :model="model" />
+        <AsyncGraphViewer v-if="visible" ref="viewerRef" :model="model" @toggle-fullscreen="toggleFullscreen" />
         <div v-else class="ai-graph__placeholder">
           <div class="ai-skeleton" :style="{ height: '18px', width: '38%' }"></div>
           <div class="ai-skeleton" :style="{ height: '14px', width: '62%' }"></div>
@@ -121,16 +110,6 @@
     await nextTick();
     await viewerRef.value?.resizeAndFit?.();
   });
-
-  /* ---------------- 工具条动作 ---------------- */
-
-  function fitView() {
-    void viewerRef.value?.fitView?.();
-  }
-
-  function relayout() {
-    void viewerRef.value?.relayout?.();
-  }
 </script>
 
 <style lang="less" scoped>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 960 - 223
ai-electron/frontend/src/ai/components/graph/GraphAnalysisViewer.vue


+ 0 - 361
ai-electron/frontend/src/ai/components/graph/GraphNodeDetailModal.vue

@@ -1,361 +0,0 @@
-<template>
-  <Modal
-    :open="open"
-    :title="title"
-    :width="920"
-    :footer="null"
-    :z-index="1015"
-    :destroy-on-close="true"
-    wrap-class-name="ai-graph-node-detail-modal"
-    @cancel="close"
-  >
-    <div class="ai-node-detail">
-      <div class="ai-node-detail__filters">
-        <span class="ai-node-detail__filter-label">筛选</span>
-        <RangePicker
-          v-model:value="dateRange"
-          class="ai-node-detail__range"
-          value-format="YYYY-MM-DD"
-          :allow-clear="true"
-          @change="handleSearch"
-        />
-        <template v-if="isCard">
-          <Select v-model:value="amountOperator" class="ai-node-detail__operator" :options="operatorOptions" />
-          <InputNumber
-            v-model:value="amount"
-            class="ai-node-detail__amount"
-            :min="0"
-            placeholder="金额"
-            @press-enter="handleSearch"
-          />
-        </template>
-        <Button type="primary" @click="handleSearch">查询</Button>
-        <Button @click="handleReset">重置</Button>
-      </div>
-
-      <div v-if="loading" class="ai-node-detail__loading">正在加载详情…</div>
-      <template v-else>
-        <Table
-          v-if="tableRows.length"
-          class="ai-node-detail__table"
-          size="small"
-          bordered
-          :columns="tableColumns"
-          :data-source="tableRows"
-          :pagination="false"
-          :row-key="rowKey"
-          :scroll="{ x: 'max-content', y: 360 }"
-        />
-        <Empty v-else description="暂无详情信息" />
-
-        <div v-if="total > 0" class="ai-node-detail__pager">
-          <span class="ai-node-detail__total">共 {{ total }} 条</span>
-          <Select
-            v-model:value="pageSize"
-            class="ai-node-detail__size"
-            :options="pageSizeOptions"
-            @change="handlePageSizeChange"
-          />
-          <Button size="small" :disabled="page <= 1" @click="handlePrev">上一页</Button>
-          <span class="ai-node-detail__page">{{ page }} / {{ totalPages }}</span>
-          <Button size="small" :disabled="page >= totalPages" @click="handleNext">下一页</Button>
-        </div>
-      </template>
-    </div>
-  </Modal>
-</template>
-
-<script lang="ts" setup>
-  /**
-   * 节点详情弹框(AI 聊天图谱专用,独立实现)
-   *
-   * 数据来自 /graph/nodeDetail:服务端可能返回「表头 + 分页记录」,也可能返回
-   * 一个普通对象;前者渲染表格,后者退化为字段/值列表。分页、日期、金额筛选在客户端组装。
-   */
-  import { computed, ref, watch } from 'vue';
-  import { Button, DatePicker, Empty, InputNumber, Modal, Select, Table } from 'ant-design-vue';
-  import { useMessage } from '@/core/hooks/web/useMessage';
-  import type { GraphModelNode } from './graphData';
-  import { buildNodeDetailQuery, fetchNodeDetail } from './graphDetailApi';
-
-  const RangePicker = DatePicker.RangePicker;
-
-  const props = defineProps<{
-    open: boolean;
-    node: GraphModelNode | null;
-  }>();
-
-  const emit = defineEmits<{
-    'update:open': [value: boolean];
-  }>();
-
-  const { showMessage } = useMessage();
-
-  const loading = ref(false);
-  const columns = ref<any[]>([]);
-  const rows = ref<Record<string, any>[]>([]);
-  const summaryRows = ref<Record<string, any>[]>([]);
-  const total = ref(0);
-  const page = ref(1);
-  const pageSize = ref(20);
-  const dateRange = ref<any>(null);
-  const amount = ref<number | undefined>(undefined);
-  const amountOperator = ref('GE');
-
-  const operatorOptions = [
-    { label: '>=', value: 'GE' },
-    { label: '<=', value: 'LE' },
-    { label: '>', value: 'GT' },
-    { label: '<', value: 'LT' },
-    { label: '=', value: 'EQ' },
-  ];
-  const pageSizeOptions = [20, 50, 100].map((value) => ({ label: `${value} 条/页`, value }));
-
-  const isCard = computed(() => {
-    const clazz = String(props.node?.raw?.clazz ?? props.node?.raw?.class ?? '').toLowerCase();
-    return props.node?.kind === 'bank' || clazz.includes('card');
-  });
-
-  const title = computed(() => (props.node ? `节点详情 · ${props.node.label}` : '节点详情'));
-
-  const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
-
-  const tableColumns = computed<any[]>(() => {
-    if (summaryRows.value.length) {
-      return [
-        { title: '字段', dataIndex: 'field', key: 'field', width: 180, ellipsis: true },
-        { title: '值', dataIndex: 'value', key: 'value', ellipsis: true },
-      ];
-    }
-    return columns.value;
-  });
-
-  const tableRows = computed(() => (summaryRows.value.length ? summaryRows.value : rows.value));
-
-  const rowKey = '__key';
-
-  function close() {
-    emit('update:open', false);
-  }
-
-  function isIndexColumn(item: Record<string, any>): boolean {
-    const titleText = String(item.title ?? '')
-      .trim()
-      .toLowerCase();
-    const dataIndex = String(item.dataIndex ?? '')
-      .trim()
-      .toLowerCase();
-    const key = String(item.key ?? '')
-      .trim()
-      .toLowerCase();
-    return (
-      ['序号', 'index', 'xh'].includes(titleText) ||
-      ['index', 'xh'].includes(dataIndex) ||
-      ['index', 'xh'].includes(key)
-    );
-  }
-
-  function columnWidth(item: Record<string, any>, titleText: string): number {
-    const width = Number.parseFloat(String(item.width ?? ''));
-    if (Number.isFinite(width) && width > 0) return width;
-    if (titleText === '序号') return 72;
-    if (titleText.includes('时间') || titleText.includes('日期')) return 180;
-    if (titleText.includes('地址') || titleText.includes('内容') || titleText.includes('摘要')) return 220;
-    return 160;
-  }
-
-  function buildColumns(head: Record<string, any>[]): any[] {
-    return head
-      .filter((item) => !isIndexColumn(item))
-      .map((item, index) => {
-        const titleText = String(item.title ?? item.key ?? `字段${index + 1}`);
-        const dataIndex = String(item.dataIndex ?? item.key ?? `field_${index}`);
-        const key = String(item.key ?? dataIndex);
-        return {
-          title: titleText,
-          dataIndex,
-          key,
-          width: columnWidth(item, titleText),
-          ellipsis: true,
-        };
-      });
-  }
-
-  function buildSummaryRows(raw: Record<string, any>): Record<string, any>[] {
-    return Object.entries(raw || {})
-      .filter(([, value]) => value !== undefined && value !== null && value !== '')
-      .slice(0, 24)
-      .map(([field, value], index) => ({
-        __key: String(index),
-        field,
-        value: typeof value === 'object' ? JSON.stringify(value) : String(value),
-      }));
-  }
-
-  function applyResponse(response: any) {
-    const root = response && typeof response === 'object' ? response : {};
-    const data = root?.data && typeof root.data === 'object' ? root.data : root;
-    const head = Array.isArray(data?.head) ? (data.head as Record<string, any>[]) : [];
-    const pages = data?.pages && typeof data.pages === 'object' ? data.pages : undefined;
-    const records = Array.isArray(pages?.records)
-      ? (pages!.records as Record<string, any>[])
-      : Array.isArray(data?.records)
-        ? (data.records as Record<string, any>[])
-        : [];
-
-    if (head.length) {
-      columns.value = buildColumns(head);
-      rows.value = records.map((item, index) => ({ __key: String(index), ...item }));
-      summaryRows.value = [];
-      const current = Number(pages?.current ?? page.value);
-      const size = Number(pages?.size ?? pageSize.value);
-      const count = Number(pages?.total ?? records.length);
-      page.value = Number.isFinite(current) && current > 0 ? current : page.value;
-      pageSize.value = Number.isFinite(size) && size > 0 ? size : pageSize.value;
-      total.value = Number.isFinite(count) && count >= 0 ? count : records.length;
-      return;
-    }
-
-    columns.value = [];
-    rows.value = [];
-    summaryRows.value = buildSummaryRows(data as Record<string, any>);
-    total.value = 0;
-  }
-
-  async function load() {
-    if (!props.node) return;
-    loading.value = true;
-    try {
-      const response = await fetchNodeDetail(
-        buildNodeDetailQuery(props.node, {
-          page: page.value,
-          limit: pageSize.value,
-          startDate: dateRange.value?.[0],
-          endDate: dateRange.value?.[1],
-          transAmount: isCard.value ? amount.value : undefined,
-          transAmountSymbol: amountOperator.value,
-        }),
-      );
-      applyResponse(response);
-    } catch (error) {
-      console.error('load ai graph node detail failed', error);
-      showMessage('节点详情加载失败', 'warning');
-      columns.value = [];
-      rows.value = [];
-      summaryRows.value = [];
-      total.value = 0;
-    } finally {
-      loading.value = false;
-    }
-  }
-
-  function handleSearch() {
-    page.value = 1;
-    void load();
-  }
-
-  function handleReset() {
-    dateRange.value = null;
-    amount.value = undefined;
-    amountOperator.value = 'GE';
-    page.value = 1;
-    void load();
-  }
-
-  function handlePrev() {
-    if (page.value <= 1) return;
-    page.value -= 1;
-    void load();
-  }
-
-  function handleNext() {
-    if (page.value >= totalPages.value) return;
-    page.value += 1;
-    void load();
-  }
-
-  function handlePageSizeChange() {
-    page.value = 1;
-    void load();
-  }
-
-  watch(
-    () => props.open,
-    (open) => {
-      if (!open) return;
-      page.value = 1;
-      void load();
-    },
-  );
-
-  watch(
-    () => props.node?.id,
-    () => {
-      dateRange.value = null;
-      amount.value = undefined;
-      amountOperator.value = 'GE';
-      page.value = 1;
-      if (props.open) void load();
-    },
-  );
-</script>
-
-<style lang="less" scoped>
-  .ai-node-detail {
-    &__filters {
-      display: flex;
-      flex-wrap: wrap;
-      align-items: center;
-      gap: 8px;
-      margin-bottom: 12px;
-    }
-
-    &__filter-label {
-      font-size: 12px;
-      color: var(--ai-text-2, #64748b);
-    }
-
-    &__range {
-      width: 260px;
-    }
-
-    &__operator {
-      width: 72px;
-    }
-
-    &__amount {
-      width: 120px;
-    }
-
-    &__loading {
-      padding: 32px 0;
-      font-size: 13px;
-      color: var(--ai-text-2, #64748b);
-      text-align: center;
-    }
-
-    &__table {
-      font-size: 12px;
-    }
-
-    &__pager {
-      display: flex;
-      align-items: center;
-      justify-content: flex-end;
-      gap: 8px;
-      margin-top: 10px;
-      font-size: 12px;
-      color: var(--ai-text-2, #64748b);
-    }
-
-    &__size {
-      width: 108px;
-    }
-
-    &__page {
-      min-width: 60px;
-      font-variant-numeric: tabular-nums;
-      text-align: center;
-    }
-  }
-</style>

+ 0 - 161
ai-electron/frontend/src/ai/components/graph/graphDetailApi.ts

@@ -1,161 +0,0 @@
-/**
- * AI 聊天关系图谱 —— 独立明细查询封装
- *
- * 与「对象关系分析」页面的 graph/api/analysis.ts 完全独立:只请求后端既有的
- * /graph/nodeDetail、/graph/callDetail、/graph/transDetail 三个只读接口。
- */
-import { defHttp } from '@/core/utils/http/axios';
-import { useGlobSetting } from '@/core/hooks/setting';
-import { useUserStoreWithOut } from '@/core/store/modules/case';
-import { normalizeText, type GraphModelEdge, type GraphModelNode } from './graphData';
-
-const { adminPath } = useGlobSetting();
-
-function resolveCaseId(): string | number | undefined {
-  const raw: any = useUserStoreWithOut().getCaseInfo?.id;
-  if (raw === undefined || raw === null || raw === '') return undefined;
-  return typeof raw === 'number' ? raw : String(raw).trim() || undefined;
-}
-
-function withCaseId(data: Record<string, any>): Record<string, any> {
-  const caseId = data.caseId ?? resolveCaseId();
-  return caseId === undefined ? data : { ...data, caseId };
-}
-
-function removeEmpty(data: Record<string, any>): Record<string, any> {
-  const result: Record<string, any> = {};
-  Object.entries(data).forEach(([key, value]) => {
-    if (value === undefined || value === null || value === '') return;
-    result[key] = value;
-  });
-  return result;
-}
-
-async function postGraph(url: string, data: Record<string, any>): Promise<any> {
-  return defHttp.postJson(
-    {
-      url: adminPath + url,
-      data: withCaseId(removeEmpty(data)),
-    },
-    { skipSuccessMessage: true },
-  );
-}
-
-function defaultClazz(node: GraphModelNode): string {
-  if (node.kind === 'person') return 'sfz';
-  if (node.kind === 'phone') return 'tel';
-  if (node.kind === 'bank') return 'card';
-  if (node.kind === 'house') return 'dz_fc';
-  if (node.kind === 'car') return 'car';
-  if (node.kind === 'group') return 'obj';
-  return 'x';
-}
-
-function nodeToken(node: GraphModelNode): string {
-  const raw = node.raw || {};
-  return normalizeText(raw.node ?? raw.bm ?? raw.sfz ?? raw.id) || node.id;
-}
-
-function nodeClazz(node: GraphModelNode): string {
-  const raw = node.raw || {};
-  return normalizeText(raw.clazz ?? raw.class) || defaultClazz(node);
-}
-
-function edgeEndpoint(edge: GraphModelEdge, endpoint: 'source' | 'target'): string {
-  const raw = edge.raw || {};
-  const value = endpoint === 'source' ? (raw.node1 ?? raw.dx1) : (raw.node2 ?? raw.dx2);
-  return normalizeText(value) || (endpoint === 'source' ? edge.source : edge.target);
-}
-
-export interface NodeDetailExtra {
-  page?: number;
-  limit?: number;
-  startDate?: string;
-  endDate?: string;
-  transAmount?: number | string;
-  transAmountSymbol?: string;
-}
-
-/** 节点详情查询(非人节点:卡/号码/车/房/机构等) */
-export function buildNodeDetailQuery(node: GraphModelNode, extra: NodeDetailExtra = {}): Record<string, any> {
-  const raw = node.raw || {};
-  const lx = Number(raw.lx);
-  const fx = Number(raw.fx);
-  return {
-    node: nodeToken(node),
-    clazz: nodeClazz(node),
-    lx: Number.isFinite(lx) ? lx : 0,
-    fx: Number.isFinite(fx) ? fx : 0,
-    page: extra.page,
-    limit: extra.limit,
-    startDate: extra.startDate,
-    endDate: extra.endDate,
-    transAmount: extra.transAmount,
-    transAmountSymbol: extra.transAmount ? extra.transAmountSymbol || 'GE' : undefined,
-  };
-}
-
-/** 电话节点 → 通话明细 */
-export function buildCallDetailQueryFromNode(node: GraphModelNode): Record<string, any> {
-  const phone = nodeToken(node);
-  return {
-    personPhone: phone,
-    personPhones: [phone],
-  };
-}
-
-/** 通话关系边 → 通话明细 */
-export function buildCallDetailQueryFromEdge(edge: GraphModelEdge): Record<string, any> {
-  const dx1 = edgeEndpoint(edge, 'source');
-  const dx2 = edgeEndpoint(edge, 'target');
-  return {
-    dx1,
-    dx2,
-    personPhone: dx1,
-    otherPhone: dx2,
-    personPhones: [dx1],
-    otherPhones: [dx2],
-    clazz: edge.clazz,
-    edgeId: edge.id,
-  };
-}
-
-/** 银行卡节点 → 交易明细 */
-export function buildTransDetailQueryFromNode(node: GraphModelNode): Record<string, any> {
-  const card = nodeToken(node);
-  return {
-    personCardNo: card,
-    bankCardNo: card,
-    cardNo: card,
-    bm: card,
-  };
-}
-
-/** 交易关系边 → 交易明细 */
-export function buildTransDetailQueryFromEdge(edge: GraphModelEdge): Record<string, any> {
-  const raw = edge.raw || {};
-  const dx1 = edgeEndpoint(edge, 'source');
-  const dx2 = edgeEndpoint(edge, 'target');
-  const lx = Number(raw.lx);
-  return {
-    personCardNo: dx1,
-    otherCardNo: dx2,
-    bankCardNo: dx1,
-    cardNo: dx1,
-    bm: dx1,
-    lx: Number.isFinite(lx) ? lx : 1,
-    edgeId: edge.id,
-  };
-}
-
-export function fetchNodeDetail(data: Record<string, any>): Promise<any> {
-  return postGraph('/graph/nodeDetail', data);
-}
-
-export function fetchCallDetail(data: Record<string, any>): Promise<any> {
-  return postGraph('/graph/callDetail', data);
-}
-
-export function fetchTransDetail(data: Record<string, any>): Promise<any> {
-  return postGraph('/graph/transDetail', data);
-}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است