|
|
@@ -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>
|