<template>
  <section class="graph-analysis-section">
    <template v-if="!hideHeader">
      <div class="section-head">
        <div>
          <div class="section-title">图谱分析</div>
        </div>
        <div class="section-summary" v-if="activeGraphTab === 'fund' && graph.edges.length">
          <span>{{ graph.edges.length }} 条资金边</span>
          <span>{{ graph.transactionCount || 0 }} 笔</span>
          <strong>{{ formatMoney(graph.totalAmount) }}</strong>
        </div>
      </div>

      <div v-if="visibleGraphTabs.length > 1" class="graph-tabs">
        <button
          v-if="visibleGraphTabs.includes('fund')"
          class="graph-tab"
          :class="{ active: activeGraphTab === 'fund' }"
          type="button"
          @click="activeGraphTab = 'fund'"
        >
          资金流图谱
        </button>
        <button
          v-if="visibleGraphTabs.includes('relation')"
          class="graph-tab"
          :class="{ active: activeGraphTab === 'relation' }"
          type="button"
          @click="activeGraphTab = 'relation'"
        >
          关系图谱
        </button>
      </div>
    </template>

    <div
      v-show="activeGraphTab === 'fund'"
      class="fund-workbench"
      :class="{
        'fund-workbench--single': shouldHideFundDetailPane,
        'fund-workbench--stacked': stackedFundDetailPane,
      }"
    >
      <div class="fund-left-pane">
        <div class="query-row">
          <el-input
            v-model="query.keyword"
            class="keyword-input"
            clearable
            size="small"
            placeholder="请输入身份证号或姓名"
            @keyup.enter.native="handleQuery"
          />
          <el-date-picker
            v-model="dateRange"
            class="date-range"
            type="daterange"
            size="small"
            value-format="yyyy-MM-dd"
            range-separator="至"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            unlink-panels
          />
          <el-input-number
            v-model="query.minTotalAmount"
            class="min-total-input"
            size="small"
            :min="0"
            :precision="0"
            :controls="false"
            placeholder="最小汇总金额"
          />
          <el-button type="primary" size="small" icon="el-icon-search" @click="handleQuery">查询</el-button>
          <el-button size="small" @click="handleReset">重置</el-button>
          <el-button
            class="manual-flow-btn"
            size="small"
            plain
            icon="el-icon-plus"
            title="新增主体节点及手工资金流向边"
            :disabled="!canOperate"
            @click="handleManualFlowCreate"
          >
            新增资金流向
          </el-button>
        </div>

        <div class="graph-shell">
          <div v-loading="graphLoading || expanding" class="graph-canvas-wrap">
            <div ref="chart" class="graph-canvas" />
            <el-empty
              v-if="!graphLoading && graph.edges.length === 0"
              class="graph-empty"
              :image-size="92"
              :description="emptyText"
            />
          </div>
          <div
            v-if="contextMenu.visible"
            class="graph-context-menu"
            :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
            @click.stop
          >
            <button type="button" @click="handleContextExpand">一层展开</button>
            <button type="button" @click="handleContextCenter">设为中心查询</button>
          </div>
        </div>
      </div>

      <aside v-if="!shouldHideFundDetailPane" class="fund-right-pane">
        <div v-if="!selectedNode && !selectedEdge" class="detail-empty">
          <div class="empty-title">请选择图谱节点或资金边</div>
          <div class="empty-desc">点击节点查看主体字段，点击资金边查看汇总金额和逐笔流水。</div>
        </div>

        <div v-else-if="selectedNode" class="detail-panel">
          <div class="detail-head">
            <div>
              <div class="detail-title">节点详情：{{ selectedNode.nodeName || "-" }}</div>
            </div>
          </div>

          <div class="node-field-list">
            <div v-for="item in selectedNodeRows" :key="item.label" class="node-field-row">
              <span>{{ item.label }}</span>
              <strong>{{ item.value || "-" }}</strong>
            </div>
          </div>

          <div class="node-actions">
            <el-button
              type="primary"
              size="small"
              :disabled="!selectedNode.canExpand || isNodeExpanded(selectedNode)"
              :loading="expanding"
              @click="expandNode(selectedNode)"
            >
              {{ isNodeExpanded(selectedNode) ? "已展开一层" : "展开一层" }}
            </el-button>
            <el-button
              size="small"
              :disabled="!selectedNode.objectKey"
              @click="queryByNode(selectedNode)"
            >
              设为中心查询
            </el-button>
          </div>
        </div>

          <div v-else class="detail-panel edge-panel">
            <div class="detail-head">
              <div>
                <div class="detail-title">{{ selectedEdgeTitle }}</div>
                <div class="detail-subtitle">
                  {{ isManualEdge(selectedEdge) ? "手工录入汇总边，不提供逐笔流水下钻" : "主体汇总边，下钻展示账户层逐笔流水" }}
                </div>
              </div>
            </div>

          <div v-if="showEdgeMetrics" class="edge-metrics">
            <div>
              <span>累计金额</span>
              <strong>{{ formatMoney(selectedEdge.totalAmount) }}</strong>
            </div>
            <div>
              <span>交易笔数</span>
              <strong>{{ selectedEdge.transactionCount || 0 }}</strong>
            </div>
            <div>
              <span>关系</span>
              <strong>{{ selectedEdge.familyRelationType || "普通往来" }}</strong>
            </div>
          </div>

          <div v-if="isManualEdge(selectedEdge)" class="manual-edge-info">
            <div class="manual-tag">手工资金流向</div>
            <div class="manual-row">
              <span>关系说明</span>
              <strong>{{ selectedEdge.relationDesc || "-" }}</strong>
            </div>
            <div class="manual-row">
              <span>来源说明</span>
              <strong>{{ selectedEdge.sourceDesc || "-" }}</strong>
            </div>
            <div class="manual-row">
              <span>备注</span>
              <strong>{{ selectedEdge.remark || "-" }}</strong>
            </div>
          </div>

          <div v-if="!isManualEdge(selectedEdge) && !showEdgeDetailTable" class="manual-edge-info">
            <div class="manual-tag">弹窗速览</div>
            <div class="manual-row">
              <span>说明</span>
              <strong>项目分析弹窗仅展示汇总信息，逐笔流水请到专项排查页查看。</strong>
            </div>
          </div>

          <el-table
            v-if="!isManualEdge(selectedEdge) && showEdgeDetailTable"
            v-loading="detailLoading"
            :data="edgeDetails"
            size="mini"
            :height="edgeDetailTableHeight"
            class="detail-table"
          >
            <template slot="empty">
              <el-empty :image-size="72" description="暂无流水明细" />
            </template>
            <el-table-column label="交易日期" prop="trxDate" width="132" />
            <el-table-column label="本方信息" min-width="132" show-overflow-tooltip>
              <template slot-scope="scope">
                <div class="account-main">{{ scope.row.leAccountName || "-" }}</div>
                <div class="account-sub">{{ scope.row.leAccountNo || "-" }}</div>
              </template>
            </el-table-column>
            <el-table-column label="对手方信息" min-width="142" show-overflow-tooltip>
              <template slot-scope="scope">
                <div class="account-main">{{ scope.row.customerAccountName || "-" }}</div>
                <div class="account-sub">{{ scope.row.customerAccountNo || "-" }}</div>
                <el-tag
                  v-if="scope.row.familyRelationType"
                  size="mini"
                  type="warning"
                  effect="plain"
                >
                  {{ scope.row.familyRelationType }}
                </el-tag>
              </template>
            </el-table-column>
            <el-table-column label="金额/方向" width="112" align="right">
              <template slot-scope="scope">
                <span :class="scope.row.direction === '1' ? 'amount-out' : 'amount-in'">
                  {{ scope.row.direction === "1" ? "-" : "+" }}{{ formatMoney(scope.row.amount) }}
                </span>
              </template>
            </el-table-column>
            <el-table-column label="摘要/交易类型" min-width="126" show-overflow-tooltip>
              <template slot-scope="scope">
                <div class="memo-main">{{ scope.row.userMemo || "-" }}</div>
                <div class="memo-sub">{{ scope.row.cashType || "-" }}</div>
              </template>
            </el-table-column>
          </el-table>
          <pagination
            v-if="showEdgeDetailTable"
            v-show="!isManualEdge(selectedEdge) && detailTotal > 0"
            :total="detailTotal"
            :page.sync="detailQuery.pageNum"
            :limit.sync="detailQuery.pageSize"
            @pagination="loadEdgeDetails"
          />
        </div>
      </aside>

      <el-dialog
        title="新增资金流向"
        :visible.sync="manualDialog.visible"
        width="560px"
        append-to-body
        class="manual-flow-dialog"
      >
        <el-form ref="manualForm" :model="manualForm" :rules="manualRules" label-width="104px" size="small">
          <el-form-item label="起点主体">
            <el-input :value="manualForm.fromName" disabled />
          </el-form-item>
          <el-form-item label="方向" prop="direction">
            <el-radio-group v-model="manualForm.direction">
              <el-radio label="1">起点转出</el-radio>
              <el-radio label="2">起点转入</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="终点名称" prop="toName">
            <el-input v-model="manualForm.toName" clearable placeholder="请输入对手方/主体名称" />
          </el-form-item>
          <el-form-item label="终点证件号">
            <el-input v-model="manualForm.toIdNo" clearable placeholder="有身份证号时会自动复用已有主体" />
          </el-form-item>
          <el-row :gutter="12">
            <el-col :span="12">
              <el-form-item label="金额" prop="amount">
                <el-input-number v-model="manualForm.amount" :min="0" :precision="2" :controls="false" style="width: 100%" />
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="笔数" prop="transactionCount">
                <el-input-number v-model="manualForm.transactionCount" :min="1" :precision="0" :controls="false" style="width: 100%" />
              </el-form-item>
            </el-col>
          </el-row>
          <el-form-item label="关系说明">
            <el-input v-model="manualForm.relationDesc" clearable placeholder="例如：人工核实资金往来" />
          </el-form-item>
          <el-form-item label="来源说明">
            <el-input v-model="manualForm.sourceDesc" clearable placeholder="例如：谈话记录、附件材料、人工研判" />
          </el-form-item>
          <el-form-item label="备注">
            <el-input v-model="manualForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
          </el-form-item>
        </el-form>
        <div slot="footer">
          <el-button size="small" @click="manualDialog.visible = false">取消</el-button>
          <el-button type="primary" size="small" :loading="manualDialog.saving" @click="submitManualFlow">
            保存
          </el-button>
        </div>
      </el-dialog>
    </div>

    <div
      v-show="activeGraphTab === 'relation'"
      class="fund-workbench relation-workbench"
      :class="{ 'fund-workbench--single': shouldHideRelationDetailPane }"
    >
      <div class="fund-left-pane">
        <div class="query-row">
          <el-input
            v-model="relationQuery.keyword"
            class="keyword-input"
            clearable
            size="small"
            placeholder="请输入身份证号、姓名或统一社会信用代码"
            @keyup.enter.native="handleRelationQuery"
          />
          <el-button type="primary" size="small" icon="el-icon-search" @click="handleRelationQuery">查询</el-button>
          <el-button size="small" @click="handleRelationReset">重置</el-button>
        </div>

        <div class="graph-shell">
          <div v-loading="relationGraphLoading" class="graph-canvas-wrap">
            <div ref="relationChart" class="graph-canvas" />
            <el-empty
              v-if="!relationGraphLoading && relationGraph.edges.length === 0"
              class="graph-empty"
              :image-size="92"
              :description="relationEmptyText"
            />
          </div>
        </div>
      </div>

      <aside v-if="!shouldHideRelationDetailPane" class="fund-right-pane">
        <div v-if="!selectedRelationNode && !selectedRelationEdge" class="detail-empty">
          <div class="empty-title">请选择图谱节点或关系边</div>
          <div class="empty-desc">点击节点查看主体字段，点击关系边查看家庭、股东或法人关系详情。</div>
        </div>

        <div v-else-if="selectedRelationNode" class="detail-panel">
          <div class="detail-head">
            <div>
              <div class="detail-title">节点详情：{{ selectedRelationNode.nodeName || "-" }}</div>
              <div class="detail-subtitle">{{ selectedRelationNode.subjectType || "-" }} · {{ selectedRelationNode.sourceType || "-" }}</div>
            </div>
          </div>

          <div class="node-field-list">
            <div v-for="item in selectedRelationNodeRows" :key="item.label" class="node-field-row">
              <span>{{ item.label }}</span>
              <strong>{{ item.value || "-" }}</strong>
            </div>
          </div>

          <div v-if="canQuerySuspectedEnterprises(selectedRelationNode)" class="node-actions">
            <el-button
              type="primary"
              size="small"
              :loading="suspectedEnterpriseDialog.loading"
              @click="openSuspectedEnterprises"
            >
              查看疑似企业
            </el-button>
          </div>
        </div>

        <div v-else class="detail-panel edge-panel">
          <div class="detail-head">
            <div>
              <div class="detail-title">{{ selectedRelationEdgeTitle }}</div>
              <div class="detail-subtitle">{{ relationEdgeSourceText(selectedRelationEdge) }}</div>
            </div>
          </div>

          <div class="relation-edge-badge" :class="relationEdgeClass(selectedRelationEdge)">
            {{ selectedRelationEdge.relationType || "-" }}
          </div>

          <div class="node-field-list">
            <div v-for="item in selectedRelationEdgeRows" :key="item.label" class="node-field-row">
              <span>{{ item.label }}</span>
              <strong>{{ item.value || "-" }}</strong>
            </div>
          </div>
        </div>
      </aside>

      <div
        v-if="suspectedEnterpriseDialog.visible"
        class="suspected-enterprise-panel"
      >
        <div class="suspected-enterprise-head">
          <div>
            <div class="suspected-enterprise-title">疑似同名企业</div>
            <div class="suspected-enterprise-subtitle">
              {{ selectedRelationNode && selectedRelationNode.nodeName ? selectedRelationNode.nodeName : "-" }}
            </div>
          </div>
          <button type="button" class="suspected-enterprise-close" @click="suspectedEnterpriseDialog.visible = false">
            ×
          </button>
        </div>
        <div class="candidate-dialog-tip">
          仅按姓名从工商法人、自然人股东关系中召回，不能直接认定为本人；成立日期可用时已排除成立时未满18岁的候选。
        </div>
        <el-table
          v-loading="suspectedEnterpriseDialog.loading"
          :data="suspectedEnterpriseDialog.rows"
          size="mini"
          height="320"
          border
        >
          <el-table-column prop="companyName" label="企业名称" min-width="170" show-overflow-tooltip />
          <el-table-column prop="creditCode" label="统一社会信用代码" min-width="150" show-overflow-tooltip />
          <el-table-column prop="enterpriseStatus" label="状态" width="76" show-overflow-tooltip />
          <el-table-column prop="industryName" label="行业" width="96" show-overflow-tooltip />
          <el-table-column prop="relationType" label="关系" width="92" />
          <el-table-column prop="stockPercent" label="持股" width="72" />
          <el-table-column prop="establishDate" label="日期" width="104" />
          <el-table-column prop="ageAtEstablish" label="年龄" width="70">
            <template slot-scope="scope">
              {{ scope.row.ageAtEstablish === null || scope.row.ageAtEstablish === undefined ? "-" : scope.row.ageAtEstablish }}
            </template>
          </el-table-column>
          <el-table-column prop="candidateKeyNo" label="人员keyno" width="138" show-overflow-tooltip />
          <el-table-column prop="matchReason" label="筛选说明" min-width="180" show-overflow-tooltip />
        </el-table>
      </div>
    </div>
  </section>
</template>

<script>
import * as echarts from "echarts";
import { getFundGraph, getFundGraphEdgeDetail, saveFundGraphManualEdge } from "@/api/ccdi/graph/fundGraph";
import { getRelationGraph, getRelationGraphSuspectedEnterprises } from "@/api/ccdi/graph/relationGraph";

function emptyGraph() {
  return {
    centerNode: null,
    nodes: [],
    edges: [],
    totalAmount: 0,
    transactionCount: 0,
  };
}

function svgSymbol(svg) {
  return `image://data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}

function clampLabelAngle(angle) {
  return Math.max(-28, Math.min(28, angle));
}

const PERSON_SYMBOL = svgSymbol(`
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
  <path d="M32 5 L59 52 H5 Z" fill="#edf8f3" stroke="#48a57f" stroke-width="3.5" />
  <circle cx="32" cy="27" r="8" fill="#48a57f" />
  <path d="M18 47 C20 37 25 33 32 33 C39 33 44 37 46 47 Z" fill="#48a57f" />
</svg>`);

const PROXY_SYMBOL = svgSymbol(`
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
  <path d="M32 5 L58 24 L48 56 H16 L6 24 Z" fill="#eef5ff" stroke="#4f7fbd" stroke-width="3.5" />
  <circle cx="32" cy="28" r="8" fill="#4f7fbd" />
  <path d="M18 49 C20 39 25 35 32 35 C39 35 44 39 46 49 Z" fill="#4f7fbd" />
</svg>`);

const EXPANDED_SYMBOL = svgSymbol(`
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
  <path d="M32 5 L58 24 L48 56 H16 L6 24 Z" fill="#f3f6fa" stroke="#95a3b6" stroke-width="3.5" />
  <circle cx="32" cy="28" r="8" fill="#7c8a9d" />
  <path d="M18 49 C20 39 25 35 32 35 C39 35 44 39 46 49 Z" fill="#7c8a9d" />
</svg>`);

const COMPANY_SYMBOL = svgSymbol(`
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
  <rect x="10" y="13" width="44" height="42" rx="4" fill="#f7f1e6" stroke="#b88745" stroke-width="3.5" />
  <path d="M20 24 H44 M20 34 H44 M20 44 H35" stroke="#b88745" stroke-width="4" stroke-linecap="round" />
</svg>`);

const RELATION_SYMBOL = svgSymbol(`
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
  <path d="M32 6 L55 19 V45 L32 58 L9 45 V19 Z" fill="#fff6ec" stroke="#d98b3c" stroke-width="3.5" />
  <circle cx="32" cy="27" r="8" fill="#d98b3c" />
  <path d="M18 47 C20 37 25 33 32 33 C39 33 44 37 46 47 Z" fill="#d98b3c" />
</svg>`);

export default {
  name: "FundGraphSection",
  props: {
    initialKeyword: {
      type: String,
      default: "",
    },
    projectId: {
      type: [String, Number],
      default: null,
    },
    canOperate: {
      type: Boolean,
      default: true,
    },
    autoScroll: {
      type: Boolean,
      default: true,
    },
    initialGraphTab: {
      type: String,
      default: "fund",
      validator: (value) => ["fund", "relation"].includes(value),
    },
    hideHeader: {
      type: Boolean,
      default: false,
    },
    graphTabs: {
      type: Array,
      default: () => ["fund", "relation"],
    },
    layoutScale: {
      type: Number,
      default: 1,
    },
    showEdgeDetailTable: {
      type: Boolean,
      default: true,
    },
    edgeDetailTableHeight: {
      type: Number,
      default: 342,
    },
    compactGraphLabels: {
      type: Boolean,
      default: false,
    },
    showEdgeMetrics: {
      type: Boolean,
      default: true,
    },
    stackedFundDetailPane: {
      type: Boolean,
      default: false,
    },
    hideEmptyDetailPane: {
      type: Boolean,
      default: false,
    },
  },
  data() {
    return {
      activeGraphTab: this.initialGraphTab,
      chart: null,
      chartBlankClickHandler: null,
      relationChart: null,
      graphLoading: false,
      relationGraphLoading: false,
      detailLoading: false,
      expanding: false,
      selectedEdge: null,
      selectedNode: null,
      selectedRelationEdge: null,
      selectedRelationNode: null,
      edgeDetails: [],
      detailTotal: 0,
      edgeDetailRequestSeq: 0,
      expandedObjectKeys: [],
      contextMenu: {
        visible: false,
        x: 0,
        y: 0,
        node: null,
      },
      dateRange: [],
      query: {
        keyword: "",
        objectKey: "",
        transactionStartTime: "",
        transactionEndTime: "",
        minTotalAmount: 1000,
        limit: 20,
      },
      relationQuery: {
        keyword: "",
        objectKey: "",
        limit: 80,
      },
      detailQuery: {
        pageNum: 1,
        pageSize: 10,
      },
      manualDialog: {
        visible: false,
        saving: false,
      },
      manualForm: {
        fromObjectKey: "",
        fromName: "",
        toName: "",
        toIdNo: "",
        amount: 0,
        transactionCount: 1,
        direction: "1",
        relationDesc: "",
        sourceDesc: "",
        remark: "",
      },
      manualRules: {
        toName: [{ required: true, message: "请输入终点主体名称", trigger: "blur" }],
        amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
        transactionCount: [{ required: true, message: "请输入笔数", trigger: "blur" }],
        direction: [{ required: true, message: "请选择方向", trigger: "change" }],
      },
      graph: emptyGraph(),
      relationGraph: emptyGraph(),
      suspectedEnterpriseDialog: {
        visible: false,
        loading: false,
        rows: [],
      },
    };
  },
  computed: {
    emptyText() {
      return this.query.keyword || this.query.objectKey ? "暂无资金流图谱数据" : "请输入身份证号或姓名查询资金流图谱";
    },
    selectedEdgeTitle() {
      if (!this.selectedEdge) {
        return "资金边详情";
      }
      return `${this.selectedEdge.fromName || "-"} → ${this.selectedEdge.toName || "-"}`;
    },
    relationEmptyText() {
      return this.relationQuery.keyword || this.relationQuery.objectKey ? "暂无关系图谱数据" : "请输入身份证号、姓名或统一社会信用代码查询关系图谱";
    },
    visibleGraphTabs() {
      const tabs = (this.graphTabs || []).filter((item) => ["fund", "relation"].includes(item));
      return tabs.length ? tabs : ["fund", "relation"];
    },
    shouldHideFundDetailPane() {
      return this.hideEmptyDetailPane && !this.selectedNode && !this.selectedEdge;
    },
    shouldHideRelationDetailPane() {
      return this.hideEmptyDetailPane && !this.selectedRelationNode && !this.selectedRelationEdge;
    },
    selectedRelationEdgeTitle() {
      if (!this.selectedRelationEdge) {
        return "关系边详情";
      }
      return `${this.selectedRelationEdge.fromName || "-"} → ${this.selectedRelationEdge.toName || "-"}`;
    },
    selectedNodeRows() {
      if (!this.selectedNode) {
        return [];
      }
      return [
        { label: "对象标识", value: this.selectedNode.objectKey },
        { label: "证件号", value: this.selectedNode.idNo },
        { label: "主体名称", value: this.selectedNode.nodeName },
        { label: "客户内部号", value: this.selectedNode.cinocsno },
        { label: "主体类型", value: this.selectedNode.idnoType },
        { label: "员工标识", value: this.selectedNode.staffId },
        { label: "来源类型", value: this.selectedNode.sourceType },
        { label: "创建时间", value: this.selectedNode.createdTime },
        { label: "更新时间", value: this.selectedNode.updatedTime },
        { label: "关联账户数", value: String(this.selectedNode.accountCount || 0) },
      ];
    },
    selectedRelationNodeRows() {
      if (!this.selectedRelationNode) {
        return [];
      }
      return [
        { label: "对象标识", value: this.selectedRelationNode.objectKey },
        { label: "节点名称", value: this.selectedRelationNode.nodeName },
        { label: "证件号/统一信用代码", value: this.selectedRelationNode.idNumber },
        { label: "主体类型", value: this.selectedRelationNode.subjectType },
        { label: "来源类型", value: this.selectedRelationNode.sourceType },
        { label: "创建时间", value: this.selectedRelationNode.createdTime },
        { label: "更新时间", value: this.selectedRelationNode.updatedTime },
      ];
    },
    selectedRelationEdgeRows() {
      if (!this.selectedRelationEdge) {
        return [];
      }
      const edge = this.selectedRelationEdge;
      const commonRows = [
        { label: "关系标识", value: edge.objectKey },
        { label: "起点标识", value: edge.fromKey },
        { label: "终点标识", value: edge.toKey },
        { label: "关系类型", value: edge.relationType },
      ];
      if (edge.edgeTable === "lx_rel_family_edge") {
        return commonRows.concat([
          { label: "员工证件号", value: edge.personId },
          { label: "关系人姓名", value: edge.relationName },
          { label: "关系人证件号", value: edge.relationCertNo },
          { label: "关系描述", value: edge.relationDesc },
        ]);
      }
      if (edge.edgeTable === "lx_rel_stock_edge") {
        return commonRows.concat([
          { label: "企业名称", value: edge.companyName },
          { label: "股东名称", value: edge.stockName },
          { label: "持股比例", value: edge.stockPercent },
          { label: "认缴出资额", value: edge.shouldCapi },
          { label: "认缴出资额数值", value: edge.shouldCapiValue },
          { label: "认缴出资额单位", value: edge.shouldCapiUnit },
          { label: "认缴出资日期", value: edge.shoudDate },
          { label: "股东KeyNo", value: edge.pKeyNo },
        ]);
      }
      return commonRows.concat([
        { label: "法定代表人姓名", value: edge.operName },
        { label: "法人KeyNo", value: edge.operKeyNo },
      ]);
    },
  },
  watch: {
    initialGraphTab(value) {
      if (value && value !== this.activeGraphTab && this.visibleGraphTabs.includes(value)) {
        this.activeGraphTab = value;
      }
    },
    dateRange(value) {
      this.query.transactionStartTime = value && value[0] ? value[0] : "";
      this.query.transactionEndTime = value && value[1] ? value[1] : "";
    },
    activeGraphTab(value) {
      if (value === "fund") {
        this.$nextTick(() => {
          this.initChart();
          this.resizeChart();
        });
      } else if (value === "relation") {
        this.$nextTick(() => {
          this.initRelationChart();
          this.resizeRelationChart();
        });
      } else if (this.chart) {
        this.destroyFundChart();
      }
    },
    initialKeyword: {
      immediate: false,
      handler(value) {
        if (value && value !== this.query.keyword) {
          this.focusByKeyword(value);
        }
      },
    },
  },
  mounted() {
    this.initChart();
    window.addEventListener("resize", this.resizeChart);
    window.addEventListener("resize", this.resizeRelationChart);
    document.addEventListener("click", this.hideContextMenu);
    if (this.initialKeyword) {
      this.focusByKeyword(this.initialKeyword);
    }
  },
  beforeDestroy() {
    window.removeEventListener("resize", this.resizeChart);
    window.removeEventListener("resize", this.resizeRelationChart);
    document.removeEventListener("click", this.hideContextMenu);
    this.destroyFundChart();
    if (this.relationChart) {
      this.relationChart.dispose();
      this.relationChart = null;
    }
  },
  methods: {
    focusByKeyword(keyword) {
      if (!keyword) {
        return;
      }
      if (this.activeGraphTab === "relation") {
        this.relationQuery.keyword = keyword;
        this.relationQuery.objectKey = "";
      } else {
        this.query.keyword = keyword;
        this.query.objectKey = "";
      }
      this.$nextTick(() => {
        if (this.autoScroll && this.$el && this.$el.scrollIntoView) {
          this.$el.scrollIntoView({ behavior: "smooth", block: "start" });
        }
        if (this.activeGraphTab === "relation") {
          this.resizeRelationChart();
          this.loadRelationGraph();
        } else {
          this.resizeChart();
          this.loadGraph();
        }
      });
    },
    handleQuery() {
      this.query.objectKey = "";
      this.loadGraph();
    },
    handleReset() {
      this.query = {
        keyword: "",
        objectKey: "",
        transactionStartTime: "",
        transactionEndTime: "",
        minTotalAmount: 1000,
        limit: 20,
      };
      this.dateRange = [];
      this.graph = emptyGraph();
      this.expandedObjectKeys = [];
      this.hideContextMenu();
      this.clearSelection();
      this.renderChart();
    },
    handleManualFlowCreate() {
      if (!this.canOperate) {
        this.$message.warning("当前项目仅可查看，不能新增手工资金流向");
        return;
      }
      const centerNode = this.graph.centerNode || this.selectedNode;
      if (!centerNode || !centerNode.objectKey) {
        this.$message.warning("请先查询或选择一个起点主体");
        return;
      }
      this.manualForm = {
        fromObjectKey: centerNode.objectKey,
        fromName: centerNode.nodeName || "",
        toName: "",
        toIdNo: "",
        amount: 0,
        transactionCount: 1,
        direction: "1",
        relationDesc: "",
        sourceDesc: "",
        remark: "",
      };
      this.manualDialog.visible = true;
      this.$nextTick(() => {
        if (this.$refs.manualForm) {
          this.$refs.manualForm.clearValidate();
        }
      });
    },
    submitManualFlow() {
      if (!this.canOperate) {
        this.$message.warning("当前项目仅可查看，不能新增手工资金流向");
        return;
      }
      if (!this.$refs.manualForm) {
        return;
      }
      this.$refs.manualForm.validate(async (valid) => {
        if (!valid) {
          return;
        }
        this.manualDialog.saving = true;
        try {
          await saveFundGraphManualEdge({
            ...this.manualForm,
            projectId: this.projectId,
          });
          this.$message.success("手工资金流向已保存");
          this.manualDialog.visible = false;
          await this.loadGraph();
        } catch (error) {
          const message = error && error.response && error.response.data && error.response.data.msg;
          this.$message.error(message || "手工资金流向保存失败");
          console.error("手工资金流向保存失败", error);
        } finally {
          this.manualDialog.saving = false;
        }
      });
    },
    queryByNode(node) {
      if (!node || !node.objectKey) {
        return;
      }
      this.query.keyword = node.nodeName;
      this.query.objectKey = node.objectKey;
      this.expandedObjectKeys = [];
      this.hideContextMenu();
      this.clearSelection();
      this.loadGraph();
    },
    async expandNode(node) {
      if (!node || !node.objectKey || this.isNodeExpanded(node)) {
        return;
      }
      this.expanding = true;
      try {
        const response = await getFundGraph({
          ...this.query,
          projectId: this.projectId,
          keyword: node.nodeName,
          objectKey: node.objectKey,
        });
        const nextGraph = (response && response.data) || emptyGraph();
        if (!nextGraph.edges || nextGraph.edges.length === 0) {
          this.$message.warning("该节点暂无可展开的一层流水");
          return;
        }
        const mergedGraph = this.mergeGraph(this.graph, nextGraph);
        const currentNodeCount = (this.graph.nodes || []).length;
        const currentEdgeCount = (this.graph.edges || []).length;
        const hasNewGraphItem = (mergedGraph.nodes || []).length > currentNodeCount
          || (mergedGraph.edges || []).length > currentEdgeCount;
        if (!hasNewGraphItem) {
          this.$message.warning("该节点暂无新的下游资金节点");
          return;
        }
        this.graph = mergedGraph;
        this.expandedObjectKeys = [...this.expandedObjectKeys, node.objectKey];
        this.selectedNode = {
          ...node,
          canExpand: false,
        };
        this.renderChart();
      } catch (error) {
        this.$message.error("节点一层展开失败");
        console.error("节点一层展开失败", error);
      } finally {
        this.expanding = false;
      }
    },
    async loadGraph() {
      if (!this.query.keyword && !this.query.objectKey) {
        this.graph = emptyGraph();
        this.renderChart();
        return;
      }
      this.graphLoading = true;
      try {
        const response = await getFundGraph(this.buildFundGraphQuery());
        this.graph = (response && response.data) || emptyGraph();
        this.expandedObjectKeys = [];
        this.clearSelection();
        this.renderChart();
      } catch (error) {
        this.graph = emptyGraph();
        this.renderChart();
        this.$message.error("资金流图谱查询失败");
        console.error("资金流图谱查询失败", error);
      } finally {
        this.graphLoading = false;
      }
    },
    handleRelationQuery() {
      this.relationQuery.objectKey = "";
      this.loadRelationGraph();
    },
    handleRelationReset() {
      this.relationQuery = {
        keyword: "",
        objectKey: "",
        limit: 80,
      };
      this.relationGraph = emptyGraph();
      this.clearRelationSelection();
      this.$nextTick(() => {
        this.initRelationChart();
        this.renderRelationChart();
      });
    },
    async loadRelationGraph() {
      if (!this.relationQuery.keyword && !this.relationQuery.objectKey) {
        this.relationGraph = emptyGraph();
        this.clearRelationSelection();
        this.$nextTick(() => {
          this.initRelationChart();
          this.renderRelationChart();
        });
        return;
      }
      this.relationGraphLoading = true;
      try {
        const response = await getRelationGraph(this.buildRelationGraphQuery());
        this.relationGraph = (response && response.data) || emptyGraph();
        this.clearRelationSelection();
      } catch (error) {
        this.relationGraph = emptyGraph();
        this.$message.error("关系图谱查询失败");
        console.error("关系图谱查询失败", error);
      } finally {
        this.relationGraphLoading = false;
        this.$nextTick(() => {
          this.initRelationChart();
          this.resizeRelationChart();
        });
      }
    },
    canQuerySuspectedEnterprises(node) {
      if (!node || node.subjectType !== "个人" || !node.nodeName) {
        return false;
      }
      const sourceType = node.sourceType || "";
      const centerObjectKey = this.relationGraph.centerNode && this.relationGraph.centerNode.objectKey;
      return node.objectKey !== centerObjectKey
        && sourceType.includes("家庭");
    },
    async openSuspectedEnterprises() {
      const node = this.selectedRelationNode;
      if (!this.canQuerySuspectedEnterprises(node)) {
        return;
      }
      this.suspectedEnterpriseDialog.loading = true;
      try {
        const response = await getRelationGraphSuspectedEnterprises({
          projectId: this.projectId,
          personName: node.nodeName,
          certNo: node.idNumber,
          limit: 10,
        });
        const data = (response && response.data) || {};
        if (data.blocked) {
          this.suspectedEnterpriseDialog.visible = false;
          this.suspectedEnterpriseDialog.rows = [];
          this.$message.warning(data.message || "同名工商主体过多，请结合其他线索筛选");
          return;
        }
        const rows = data.rows || [];
        if (rows.length === 0) {
          this.suspectedEnterpriseDialog.visible = false;
          this.suspectedEnterpriseDialog.rows = [];
          this.$message.info(data.message || "未发现可展示的疑似同名企业");
          return;
        }
        this.suspectedEnterpriseDialog.rows = rows;
        this.suspectedEnterpriseDialog.visible = true;
      } catch (error) {
        this.$message.error("疑似企业查询失败");
        console.error("疑似企业查询失败", error);
      } finally {
        this.suspectedEnterpriseDialog.loading = false;
      }
    },
    initChart() {
      if (!this.$refs.chart) {
        return;
      }
      if (!this.chart) {
        this.chart = echarts.init(this.$refs.chart);
        this.chartBlankClickHandler = (event) => {
          if (event && event.target) {
            return;
          }
          if (!this.selectedNode && !this.selectedEdge) {
            return;
          }
          this.hideContextMenu();
          this.resetFundSelection();
        };
        this.chart.getZr().on("click", this.chartBlankClickHandler);
        this.chart.on("click", (params) => {
          this.hideContextMenu();
          if (params.dataType === "edge" && params.data && params.data.raw) {
            this.openEdge(params.data.raw);
            return;
          }
          if (params.dataType === "node" && params.data && params.data.raw) {
            this.selectedNode = params.data.raw;
            this.selectedEdge = null;
          }
        });
        this.chart.on("contextmenu", (params) => {
          const event = params.event && params.event.event;
          if (event) {
            event.preventDefault();
          }
          if (params.dataType !== "node" || !params.data || !params.data.raw) {
            this.hideContextMenu();
            return;
          }
          const rect = this.$refs.chart.getBoundingClientRect();
          this.selectedNode = params.data.raw;
          this.selectedEdge = null;
          this.contextMenu = {
            visible: true,
            x: Math.max(8, (event ? event.clientX : rect.left) - rect.left),
            y: Math.max(8, (event ? event.clientY : rect.top) - rect.top),
            node: params.data.raw,
          };
        });
      }
      this.renderChart();
    },
    destroyFundChart() {
      if (!this.chart) {
        this.chartBlankClickHandler = null;
        return;
      }
      if (this.chartBlankClickHandler && this.chart.getZr) {
        this.chart.getZr().off("click", this.chartBlankClickHandler);
      }
      this.chartBlankClickHandler = null;
      this.chart.dispose();
      this.chart = null;
    },
    initRelationChart() {
      if (!this.$refs.relationChart) {
        return;
      }
      if (!this.relationChart) {
        this.relationChart = echarts.init(this.$refs.relationChart);
        this.relationChart.on("click", (params) => {
          if (params.dataType === "edge" && params.data && params.data.raw) {
            this.selectedRelationEdge = params.data.raw;
            this.selectedRelationNode = null;
            return;
          }
          if (params.dataType === "node" && params.data && params.data.raw) {
            this.selectedRelationNode = params.data.raw;
            this.selectedRelationEdge = null;
          }
        });
      }
      this.renderRelationChart();
    },
    renderRelationChart() {
      if (!this.relationChart || this.activeGraphTab !== "relation") {
        return;
      }
      this.syncRelationChartSize();
      const centerObjectKey = this.relationGraph.centerNode && this.relationGraph.centerNode.objectKey;
      const rawNodes = this.relationGraph.nodes || [];
      const rawEdges = this.relationGraph.edges || [];
      if (rawNodes.length === 0) {
        this.relationChart.clear();
        return;
      }
      const chartWidth = this.relationChart.getWidth ? this.relationChart.getWidth() : 900;
      const chartHeight = this.relationChart.getHeight ? this.relationChart.getHeight() : 540;
      const nodeLayout = this.buildRelationNodeLayout(rawNodes, rawEdges, centerObjectKey, chartWidth, chartHeight);
      const nodes = rawNodes.map((node) => {
        const isCenter = node.objectKey === centerObjectKey;
        const isCompany = node.subjectType === "企业";
        const category = isCenter ? 0 : isCompany ? 2 : 1;
        const layout = nodeLayout.get(node.nodeKey) || {};
        return {
          id: node.nodeKey,
          name: node.nodeName,
          symbol: isCompany ? COMPANY_SYMBOL : PERSON_SYMBOL,
          symbolSize: isCenter ? 62 : isCompany ? 54 : 50,
          x: layout.x,
          y: layout.y,
          fixed: true,
          category,
          raw: node,
          itemStyle: this.getRelationNodeStyle(category, this.selectedRelationNode && this.selectedRelationNode.objectKey === node.objectKey),
          emphasis: {
            scale: isCenter ? 1.08 : 1.12,
            itemStyle: {
              shadowBlur: 16,
              shadowOffsetY: 5,
              shadowColor: "rgba(31, 90, 149, 0.2)",
            },
          },
          label: {
            show: true,
            position: "bottom",
            distance: 7,
            color: "#1b1f2a",
            fontSize: isCenter ? 14 : 13,
            fontWeight: isCenter ? 700 : 500,
            formatter: `{name|${node.nodeName || "-"} }\n{tag|${node.subjectType || "-"}}`,
            rich: {
              name: {
                color: "#1b1f2a",
                fontSize: isCenter ? 14 : 13,
                lineHeight: 17,
                fontWeight: isCenter ? 700 : 500,
              },
              tag: {
                color: isCompany ? "#9a6b2f" : "#3f8b6e",
                fontSize: 12,
              },
            },
          },
        };
      });
      const boundaryNodes = this.buildBoundaryNodes(chartWidth, chartHeight);
      const edgeCurves = this.buildRelationEdgeCurves(rawEdges);
      const edges = rawEdges.map((edge, index) => {
        const color = this.relationEdgeColor(edge);
        return {
          source: edge.fromKey,
          target: edge.toKey,
          raw: edge,
          label: {
            show: true,
            position: "middle",
            formatter: edge.relationType || "-",
          },
          lineStyle: {
            color,
            opacity: 0.88,
            width: 1.9,
            curveness: edgeCurves[index],
          },
          emphasis: {
            label: {
              show: true,
              color: "#243646",
              fontWeight: 700,
              backgroundColor: "rgba(255,255,255,0.96)",
            },
            lineStyle: {
              color,
              opacity: 1,
              width: 2.4,
            },
          },
        };
      });

      this.relationChart.setOption({
        animation: false,
        backgroundColor: "transparent",
        tooltip: {
          trigger: "item",
          formatter: (params) => {
            if (params.dataType === "edge") {
              const edge = params.data.raw;
              return `${edge.fromName || "-"} → ${edge.toName || "-"}<br/>关系：${edge.relationType || "-"}`;
            }
            const node = params.data.raw;
            return `${node.nodeName || "-"}<br/>类型：${node.subjectType || "-"}<br/>证件/代码：${node.idNumber || "-"}`;
          },
        },
        legend: {
          top: 12,
          right: 18,
          itemWidth: 10,
          itemHeight: 10,
          textStyle: { color: "#606f82" },
          data: ["中心节点", "个人", "企业"],
        },
        series: [{
          type: "graph",
          layout: "none",
          roam: true,
          top: 42,
          left: 36,
          right: 56,
          bottom: 34,
          categories: [
            { name: "中心节点", itemStyle: { color: "#48a57f" } },
            { name: "个人", itemStyle: { color: "#7fc56d" } },
            { name: "企业", itemStyle: { color: "#b88745" } },
          ],
          edgeSymbol: ["none", "arrow"],
          edgeSymbolSize: 7,
          scaleLimit: {
            min: 0.35,
            max: 2.5,
          },
          edgeLabel: {
            position: "middle",
            color: "#4b5a6a",
            fontSize: 11,
            fontWeight: 600,
            backgroundColor: "rgba(255,255,255,0.92)",
            borderColor: "rgba(205, 216, 228, 0.92)",
            borderWidth: 1,
            borderRadius: 8,
            padding: [3, 7],
          },
          data: nodes.concat(boundaryNodes),
          links: edges,
          emphasis: {
            focus: "adjacency",
            scale: true,
          },
        }],
      }, true);
    },
    renderChart(options = {}) {
      if (!this.chart) {
        return;
      }
      const resetViewport = !!options.resetViewport;
      this.syncChartSize();
      if (resetViewport) {
        this.chart.clear();
      }
      const centerObjectKey = this.graph.centerNode && this.graph.centerNode.objectKey;
      const rawNodes = this.graph.nodes || [];
      const rawEdges = this.graph.edges || [];
      const chartWidth = this.chart.getWidth ? this.chart.getWidth() : 900;
      const chartHeight = this.chart.getHeight ? this.chart.getHeight() : 540;
      const nodeLayout = this.buildNodeLayout(rawNodes, rawEdges, centerObjectKey, chartWidth, chartHeight);
      const selectedEdge = this.selectedEdge;
      const hasFundSelection = !!(this.selectedNode || selectedEdge);
      const nodes = rawNodes.map((node) => {
        const isCenter = node.objectKey === centerObjectKey;
        const isFamily = !!node.relationType;
        const isExpanded = this.expandedObjectKeys.includes(node.objectKey);
        const category = isCenter ? 0 : isFamily ? 1 : isExpanded ? 3 : 2;
        const layout = nodeLayout.get(node.nodeKey) || {};
        const displayName = this.compactGraphLabels
          ? this.truncateGraphText(node.nodeName, isCenter ? 8 : 7)
          : (node.nodeName || "-");
        const isSelectedNode = this.selectedNode && this.selectedNode.objectKey === node.objectKey;
        const inSelectedEdge = this.isNodeInSelectedEdge(node, selectedEdge);
        const shouldDim = selectedEdge && !inSelectedEdge;
        return {
          id: node.nodeKey,
          name: node.nodeName,
          value: node.transactionCount || 0,
          symbol: this.getNodeSymbol(category),
          symbolSize: isCenter ? 62 : isFamily ? 53 : isExpanded ? 46 : 48,
          x: layout.x,
          y: layout.y,
          fixed: true,
          category,
          raw: node,
          itemStyle: this.getNodeStyle(category, isSelectedNode || inSelectedEdge, shouldDim),
          emphasis: {
            scale: isCenter ? 1.08 : 1.12,
            itemStyle: {
              shadowBlur: isCenter ? 18 : 14,
              shadowOffsetY: 5,
              shadowColor: "rgba(31, 90, 149, 0.22)",
            },
            label: {
              color: "#0f1f33",
              fontWeight: 700,
            },
          },
          label: {
            show: true,
            position: "bottom",
            distance: isCenter ? 8 : 6,
            color: shouldDim ? "#8794a6" : "#1b1f2a",
            fontSize: isCenter ? 14 : 13,
            fontWeight: isCenter ? 700 : 500,
            formatter: isFamily ? `{name|${displayName} }\n{tag|${node.relationType}}` : displayName,
            rich: {
              name: {
                color: shouldDim ? "#8794a6" : "#1b1f2a",
                fontWeight: isCenter ? 700 : 500,
                fontSize: isCenter ? 14 : 13,
                lineHeight: 17,
              },
              tag: {
                color: "#3f8b6e",
                fontSize: 12,
              },
            },
          },
        };
      }, true);
      const boundaryNodes = this.buildBoundaryNodes(chartWidth, chartHeight);
      const edgeCurves = this.buildEdgeCurves(rawEdges);
      const edges = rawEdges.map((edge, index) => {
        const isOut = String(edge.direction) === "1";
        const selected = this.isSelectedEdge(edge, selectedEdge);
        const shouldDim = selectedEdge && !selected;
        return {
          source: edge.fromKey,
          target: edge.toKey,
          raw: edge,
          label: {
            show: true,
            position: "middle",
            rotate: 0,
            distance: 22,
            formatter: this.formatEdgeLabel(edge),
          },
          lineStyle: {
            color: this.isExpandedEdge(edge) ? (isOut ? "#4f78ab" : "#3a8f83") : (isOut ? "#245f95" : "#1c7f73"),
            opacity: shouldDim ? 0.22 : selected ? 1 : this.isExpandedEdge(edge) ? 0.72 : 0.9,
            width: selected ? 2.8 : this.isExpandedEdge(edge) ? 1.5 : 1.9,
            curveness: edgeCurves[index],
            type: this.isManualEdge(edge) ? "dashed" : "solid",
          },
          emphasis: {
            label: {
              show: true,
              color: "#243646",
              fontSize: 12,
              fontWeight: 600,
              backgroundColor: "rgba(255,255,255,0.96)",
            },
            lineStyle: {
              color: isOut ? "#1d4f7f" : "#16675f",
              opacity: 0.96,
              width: 2.2,
            },
          },
        };
      });

      this.chart.setOption({
        animation: false,
        backgroundColor: "transparent",
        tooltip: {
          trigger: "item",
          formatter: (params) => {
            if (params.dataType === "edge") {
              const edge = params.data.raw;
              return `${edge.fromName} → ${edge.toName}<br/>金额：${this.formatMoney(edge.totalAmount)}<br/>笔数：${edge.transactionCount || 0}`;
            }
            const node = params.data.raw;
            return `${node.nodeName}<br/>类型：${this.getFundNodeCategoryName(params.data.category)}<br/>object_key：${node.objectKey || "-"}`;
          },
        },
        legend: {
          top: 12,
          right: 18,
          itemWidth: 10,
          itemHeight: 10,
          textStyle: { color: "#606f82" },
          data: ["本人", "关系人", "其他方", "已展开"],
        },
        series: [{
          type: "graph",
          layout: "none",
          roam: true,
          top: 42,
          left: 36,
          right: 56,
          bottom: 34,
          categories: [
            { name: "本人", itemStyle: { color: "#3f9f78" } },
            { name: "关系人", itemStyle: { color: "#d98b3c" } },
            { name: "其他方", itemStyle: { color: "#4f7fbd" } },
            { name: "已展开", itemStyle: { color: "#95a3b6" } },
          ],
          edgeSymbol: ["none", "arrow"],
          edgeSymbolSize: 7,
          scaleLimit: {
            min: 0.35,
            max: 2.5,
          },
          label: {
            color: "#1b1f2a",
            fontSize: 13,
          },
          edgeLabel: {
            position: "middle",
            rotate: 0,
            distance: this.compactGraphLabels ? 18 : 22,
            color: selectedEdge ? "#516070" : "#4b5a6a",
            fontSize: this.compactGraphLabels ? 10 : 11,
            fontWeight: 500,
            fontFamily: "'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif",
            backgroundColor: this.compactGraphLabels ? "rgba(255,255,255,0.86)" : "rgba(255,255,255,0.92)",
            borderColor: "rgba(205, 216, 228, 0.92)",
            borderWidth: 1,
            borderRadius: 8,
            padding: this.compactGraphLabels ? [2, 5] : [3, 7],
          },
          edgeLabelLayout: (params) => this.layoutEdgeLabel(params, nodeLayout, rawEdges, edgeCurves),
          labelLayout: { hideOverlap: false },
          data: nodes.concat(boundaryNodes),
          links: edges,
          emphasis: {
            focus: hasFundSelection ? "none" : "adjacency",
            scale: true,
            lineStyle: {
              opacity: 0.92,
              width: 2.2,
            },
          },
        }],
      }, true);
      if (!resetViewport) {
        this.resizeChart();
      }
    },
    buildBoundaryNodes(chartWidth, chartHeight) {
      const padding = 56;
      const left = padding;
      const right = Math.max(padding + 1, chartWidth - padding);
      const top = padding;
      const bottom = Math.max(padding + 1, chartHeight - padding);
      return [
        { id: "__boundary_lt", name: "", x: left, y: top },
        { id: "__boundary_rt", name: "", x: right, y: top },
        { id: "__boundary_lb", name: "", x: left, y: bottom },
        { id: "__boundary_rb", name: "", x: right, y: bottom },
      ].map((node) => ({
        ...node,
        symbolSize: 0,
        fixed: true,
        silent: true,
        itemStyle: { opacity: 0 },
        label: { show: false },
        tooltip: { show: false },
      }));
    },
    buildNodeLayout(nodes, edges, centerObjectKey, chartWidth, chartHeight) {
      const layout = new Map();
      if (!nodes.length) {
        return layout;
      }
      const centerNode = nodes.find((node) => node.objectKey === centerObjectKey) || nodes[0];
      const marginX = 128;
      const marginY = 108;
      const centerX = chartWidth / 2;
      const centerY = chartHeight / 2;
      const availableWidth = Math.max(320, chartWidth - marginX * 2);
      const availableHeight = Math.max(280, chartHeight - marginY * 2);
      layout.set(centerNode.nodeKey, { x: centerX, y: centerY });

      const directObjectKeys = new Set();
      edges.forEach((edge) => {
        if (edge.fromObjectKey === centerNode.objectKey && edge.toObjectKey) {
          directObjectKeys.add(edge.toObjectKey);
        }
        if (edge.toObjectKey === centerNode.objectKey && edge.fromObjectKey) {
          directObjectKeys.add(edge.fromObjectKey);
        }
      });

      const directNodes = nodes
        .filter((node) => node.nodeKey !== centerNode.nodeKey && directObjectKeys.has(node.objectKey))
        .sort((a, b) => {
          if (!!a.relationType !== !!b.relationType) {
            return a.relationType ? -1 : 1;
          }
          return Number(b.transactionCount || 0) - Number(a.transactionCount || 0);
        });
      const familyNodes = directNodes.filter((node) => node.relationType);
      const counterpartyNodes = directNodes.filter((node) => !node.relationType);
      const scale = this.normalizeLayoutScale();
      const familyRadiusX = Math.max(245 * scale, Math.min(availableWidth * 0.40 * scale, 420 * scale));
      const familyRadiusY = Math.max(150 * scale, Math.min(availableHeight * 0.36 * scale, 230 * scale));
      const counterpartyRadiusX = Math.max(390 * scale, Math.min(availableWidth * 0.58 * scale, 660 * scale));
      const counterpartyRadiusY = Math.max(230 * scale, Math.min(availableHeight * 0.52 * scale, 350 * scale));
      const clampPoint = (x, y) => ({
        x: Math.min(chartWidth - marginX, Math.max(marginX, x)),
        y: Math.min(chartHeight - marginY, Math.max(marginY, y)),
      });
      const placeNodes = (
        targetNodes,
        radiusX,
        radiusY,
        offsetDegree,
        arcDegree = 360,
        maxPerRing = 8,
        ringGapX = 52 * scale,
        ringGapY = 36 * scale
      ) => {
        if (!targetNodes.length) {
          return;
        }
        const ringCount = Math.ceil(targetNodes.length / maxPerRing);
        for (let ringIndex = 0; ringIndex < ringCount; ringIndex += 1) {
          const start = ringIndex * maxPerRing;
          const ringNodes = targetNodes.slice(start, start + maxPerRing);
          const isFullRing = arcDegree === 360;
          const currentRadiusX = radiusX + ringIndex * ringGapX;
          const currentRadiusY = radiusY + ringIndex * ringGapY;
          const ringOffset = offsetDegree + (ringIndex % 2 === 0 ? 0 : Math.min(18, arcDegree * 0.08));
          ringNodes.forEach((node, index) => {
            const angle = ringNodes.length === 1
              ? (isFullRing ? ringOffset : ringOffset + arcDegree / 2)
              : ringOffset + (arcDegree * index) / (isFullRing ? ringNodes.length : ringNodes.length - 1);
            const radians = (angle * Math.PI) / 180;
            layout.set(node.nodeKey, clampPoint(
              centerX + Math.cos(radians) * currentRadiusX,
              centerY + Math.sin(radians) * currentRadiusY
            ));
          });
        }
      };
      placeNodes(familyNodes, familyRadiusX, familyRadiusY, -164, 148, 5, 42 * scale, 28 * scale);
      placeNodes(
        counterpartyNodes,
        counterpartyRadiusX,
        counterpartyRadiusY,
        familyNodes.length ? -22 : -96,
        familyNodes.length ? 236 : 360,
        familyNodes.length ? 7 : 10,
        58 * scale,
        40 * scale
      );

      const radius = Math.min(counterpartyRadiusX, counterpartyRadiusY);
      directNodes
        .filter((node) => !layout.has(node.nodeKey))
        .forEach((node, index) => {
        const angle = -90 + (360 * index) / Math.max(1, directNodes.length);
        const radians = (angle * Math.PI) / 180;
        layout.set(node.nodeKey, clampPoint(
          centerX + Math.cos(radians) * radius,
          centerY + Math.sin(radians) * radius
        ));
      });

      const nodeByObjectKey = new Map(nodes.map((node) => [node.objectKey, node]));
      this.expandedObjectKeys.forEach((parentObjectKey) => {
        const parentNode = nodeByObjectKey.get(parentObjectKey);
        const parentLayout = parentNode && layout.get(parentNode.nodeKey);
        if (!parentLayout) {
          return;
        }
        const childKeys = new Set();
        edges.forEach((edge) => {
          if (edge.fromObjectKey === parentObjectKey && edge.toObjectKey !== centerNode.objectKey) {
            childKeys.add(edge.toObjectKey);
          }
          if (edge.toObjectKey === parentObjectKey && edge.fromObjectKey !== centerNode.objectKey) {
            childKeys.add(edge.fromObjectKey);
          }
        });
        const childNodes = Array.from(childKeys)
          .map((key) => nodeByObjectKey.get(key))
          .filter((node) => node && !layout.has(node.nodeKey));
        const childRadius = Math.min(Math.max(availableHeight * 0.22 * scale, 118 * scale), 154 * scale);
        const start = -40;
        const end = 40;
        childNodes.forEach((node, index) => {
          const parentAngle = Math.atan2(parentLayout.y - centerY, parentLayout.x - centerX) * 180 / Math.PI;
          const spread = childNodes.length === 1
            ? 0
            : start + ((end - start) * index) / (childNodes.length - 1);
          const radians = ((parentAngle + spread) * Math.PI) / 180;
          layout.set(node.nodeKey, clampPoint(
            parentLayout.x + Math.cos(radians) * childRadius,
            parentLayout.y + Math.sin(radians) * childRadius
          ));
        });
      });

      const remainingNodes = nodes.filter((node) => !layout.has(node.nodeKey));
      remainingNodes.forEach((node, index) => {
        const angle = -90 + (180 * (index + 1)) / (remainingNodes.length + 1);
        const radians = (angle * Math.PI) / 180;
        layout.set(node.nodeKey, clampPoint(
          centerX + Math.cos(radians) * counterpartyRadiusX * 0.72,
          centerY + Math.sin(radians) * counterpartyRadiusY * 0.72
        ));
      });

      return layout;
    },
    buildEdgeCurves(edges) {
      const pairDirectionCounts = new Map();
      const pairDirectionIndex = new Map();
      const pairCounts = new Map();
      edges.forEach((edge) => {
        const key = `${[edge.fromKey, edge.toKey].sort().join("__")}__${edge.direction || ""}`;
        pairDirectionCounts.set(key, (pairDirectionCounts.get(key) || 0) + 1);
        const pairKey = [edge.fromKey, edge.toKey].sort().join("__");
        pairCounts.set(pairKey, (pairCounts.get(pairKey) || 0) + 1);
      });
      return edges.map((edge) => {
        const pairKey = [edge.fromKey, edge.toKey].sort().join("__");
        const directionKey = `${pairKey}__${edge.direction || ""}`;
        const directionCount = pairDirectionCounts.get(directionKey) || 1;
        const current = pairDirectionIndex.get(directionKey) || 0;
        const directionSign = String(edge.direction) === "1" ? -1 : 1;
        const hasReverse = (pairCounts.get(pairKey) || 0) > directionCount;
        const baseCurve = directionSign * (hasReverse ? 0.34 : 0.2);
        const spreadCurve = directionCount === 1
          ? 0
          : (current - (directionCount - 1) / 2) * 0.11;
        pairDirectionIndex.set(directionKey, current + 1);
        return baseCurve + spreadCurve;
      });
    },
    layoutEdgeLabel(params, nodeLayout, rawEdges, edgeCurves) {
      const edge = rawEdges[params.dataIndex];
      if (!edge) {
        return { hideOverlap: false };
      }
      const source = nodeLayout.get(edge.fromKey);
      const target = nodeLayout.get(edge.toKey);
      if (!source || !target) {
        return { hideOverlap: false };
      }
      const curve = edgeCurves[params.dataIndex] || 0;
      const dx = target.x - source.x;
      const dy = target.y - source.y;
      const length = Math.hypot(dx, dy) || 1;
      const nx = -dy / length;
      const ny = dx / length;
      const side = curve >= 0 ? 1 : -1;
      const tangentAngle = Math.atan2(dy, dx) * 180 / Math.PI;
      const samePairIndex = rawEdges
        .slice(0, params.dataIndex + 1)
        .filter((item) => {
          const a = [item.fromKey, item.toKey].sort().join("__");
          const b = [edge.fromKey, edge.toKey].sort().join("__");
          return a === b;
        }).length - 1;
      const offset = 18 + samePairIndex * 10;
      const x = (source.x + target.x) / 2 + nx * offset * side;
      const y = (source.y + target.y) / 2 + ny * offset * side;
      const shift = samePairIndex === 0 ? 0 : (samePairIndex % 2 === 0 ? 1 : -1) * Math.ceil(samePairIndex / 2) * 14;
      return {
        x: x + nx * shift,
        y: y + ny * shift,
        rotate: clampLabelAngle(tangentAngle),
        moveOverlap: "shiftY",
        hideOverlap: false,
        draggable: false,
      };
    },
    getNodeSymbol(category) {
      if (category === 0) {
        return PERSON_SYMBOL;
      }
      if (category === 1) {
        return RELATION_SYMBOL;
      }
      if (category === 3) {
        return EXPANDED_SYMBOL;
      }
      return PROXY_SYMBOL;
    },
    getFundNodeCategoryName(category) {
      if (category === 0) {
        return "本人";
      }
      if (category === 1) {
        return "关系人";
      }
      if (category === 3) {
        return "已展开";
      }
      return "其他方";
    },
    getNodeStyle(category, selected, dimmed = false) {
      const isCenter = category === 0;
      return {
        opacity: dimmed ? 0.42 : category === 3 ? 0.88 : 1,
        shadowBlur: selected ? 16 : isCenter ? 10 : 6,
        shadowOffsetY: selected ? 4 : 2,
        shadowColor: selected ? "rgba(42, 111, 174, 0.24)" : "rgba(45, 84, 124, 0.12)",
      };
    },
    openEdge(edge) {
      this.selectedEdge = edge;
      this.selectedNode = null;
      this.detailQuery.pageNum = 1;
      this.renderChart();
      if (this.isManualEdge(edge)) {
        this.edgeDetails = [];
        this.detailTotal = 0;
        return;
      }
      this.loadEdgeDetails();
    },
    async loadEdgeDetails() {
      if (!this.selectedEdge) {
        return;
      }
      const requestSeq = ++this.edgeDetailRequestSeq;
      const selectedEdge = this.selectedEdge;
      if (!selectedEdge.fromKey || !selectedEdge.toKey) {
        this.edgeDetails = [];
        this.detailTotal = 0;
        this.$message.warning("当前边缺少明细查询参数");
        return;
      }
      this.detailLoading = true;
      try {
        const response = await getFundGraphEdgeDetail({
          projectId: this.projectId,
          fromKey: selectedEdge.fromKey,
          toKey: selectedEdge.toKey,
          direction: selectedEdge.direction,
          transactionStartTime: this.query.transactionStartTime,
          transactionEndTime: this.query.transactionEndTime,
          pageNum: this.detailQuery.pageNum,
          pageSize: this.detailQuery.pageSize,
        });
        if (requestSeq !== this.edgeDetailRequestSeq || this.selectedEdge !== selectedEdge) {
          return;
        }
        this.edgeDetails = response.rows || [];
        this.detailTotal = response.total || 0;
      } catch (error) {
        if (requestSeq !== this.edgeDetailRequestSeq || this.selectedEdge !== selectedEdge) {
          return;
        }
        this.edgeDetails = [];
        this.detailTotal = 0;
        this.$message.error("流水明细查询失败");
        console.error("流水明细查询失败", {
          error,
          selectedEdge,
        });
      } finally {
        if (requestSeq === this.edgeDetailRequestSeq && this.selectedEdge === selectedEdge) {
          this.detailLoading = false;
        }
      }
    },
    buildFundGraphQuery(extra = {}) {
      return {
        ...this.query,
        ...extra,
        projectId: this.projectId,
      };
    },
    buildRelationGraphQuery(extra = {}) {
      return {
        ...this.relationQuery,
        ...extra,
        projectId: this.projectId,
      };
    },
    mergeGraph(currentGraph, nextGraph) {
      const nodeMap = new Map();
      const edgeMap = new Map();
      (currentGraph.nodes || []).forEach((node) => nodeMap.set(node.nodeKey, node));
      (nextGraph.nodes || []).forEach((node) => nodeMap.set(node.nodeKey, { ...nodeMap.get(node.nodeKey), ...node }));
      (currentGraph.edges || []).forEach((edge) => edgeMap.set(edge.edgeKey, edge));
      (nextGraph.edges || []).forEach((edge) => edgeMap.set(edge.edgeKey, edge));
      const edges = Array.from(edgeMap.values());
      return {
        ...currentGraph,
        nodes: Array.from(nodeMap.values()),
        edges,
        transactionCount: edges.reduce((sum, edge) => sum + Number(edge.transactionCount || 0), 0),
        totalAmount: edges.reduce((sum, edge) => sum + Number(edge.totalAmount || 0), 0),
      };
    },
    isNodeExpanded(node) {
      return !!(node && node.objectKey && this.expandedObjectKeys.includes(node.objectKey));
    },
    isExpandedEdge(edge) {
      return this.expandedObjectKeys.includes(edge.fromObjectKey) || this.expandedObjectKeys.includes(edge.toObjectKey);
    },
    isManualEdge(edge) {
      return !!(edge && edge.sourceType === "MANUAL");
    },
    isSelectedEdge(edge, selectedEdge) {
      if (!edge || !selectedEdge) {
        return false;
      }
      if (edge.edgeKey && selectedEdge.edgeKey) {
        return edge.edgeKey === selectedEdge.edgeKey;
      }
      return edge.fromKey === selectedEdge.fromKey
        && edge.toKey === selectedEdge.toKey
        && String(edge.direction || "") === String(selectedEdge.direction || "");
    },
    isNodeInSelectedEdge(node, selectedEdge) {
      if (!node || !selectedEdge) {
        return false;
      }
      return node.nodeKey === selectedEdge.fromKey
        || node.nodeKey === selectedEdge.toKey
        || node.objectKey === selectedEdge.fromObjectKey
        || node.objectKey === selectedEdge.toObjectKey;
    },
    buildRelationNodeLayout(nodes, edges, centerObjectKey, chartWidth, chartHeight) {
      const layout = new Map();
      if (!nodes.length) {
        return layout;
      }
      const centerNode = nodes.find((node) => node.objectKey === centerObjectKey) || nodes[0];
      const centerX = chartWidth / 2;
      const centerY = chartHeight / 2;
      const marginX = 126;
      const marginY = 104;
      const scale = this.normalizeLayoutScale();
      const radiusX = Math.max(310 * scale, Math.min((chartWidth - marginX * 2) * 0.48 * scale, 620 * scale));
      const radiusY = Math.max(210 * scale, Math.min((chartHeight - marginY * 2) * 0.48 * scale, 330 * scale));
      layout.set(centerNode.nodeKey, { x: centerX, y: centerY });

      const directKeys = new Set();
      edges.forEach((edge) => {
        if (edge.fromObjectKey === centerNode.objectKey && edge.toObjectKey) {
          directKeys.add(edge.toObjectKey);
        }
        if (edge.toObjectKey === centerNode.objectKey && edge.fromObjectKey) {
          directKeys.add(edge.fromObjectKey);
        }
      });

      const directNodes = nodes
        .filter((node) => node.nodeKey !== centerNode.nodeKey && directKeys.has(node.objectKey))
        .sort((a, b) => {
          if (a.subjectType !== b.subjectType) {
            return a.subjectType === "个人" ? -1 : 1;
          }
          return String(a.nodeName || "").localeCompare(String(b.nodeName || ""), "zh-CN");
        });
      const clampPoint = (x, y) => ({
        x: Math.min(chartWidth - marginX, Math.max(marginX, x)),
        y: Math.min(chartHeight - marginY, Math.max(marginY, y)),
      });
      const maxPerRing = 9;
      const ringCount = Math.ceil(directNodes.length / maxPerRing);
      for (let ringIndex = 0; ringIndex < ringCount; ringIndex += 1) {
        const ringNodes = directNodes.slice(ringIndex * maxPerRing, (ringIndex + 1) * maxPerRing);
        const currentRadiusX = radiusX + ringIndex * 48 * scale;
        const currentRadiusY = radiusY + ringIndex * 34 * scale;
        const ringOffset = -160 + (ringIndex % 2 === 0 ? 0 : 14);
        ringNodes.forEach((node, index) => {
          const angle = ringNodes.length === 1 ? -90 : ringOffset + (320 * index) / (ringNodes.length - 1);
          const radians = (angle * Math.PI) / 180;
          layout.set(node.nodeKey, clampPoint(
            centerX + Math.cos(radians) * currentRadiusX,
            centerY + Math.sin(radians) * currentRadiusY
          ));
        });
      }
      nodes
        .filter((node) => !layout.has(node.nodeKey))
        .forEach((node, index) => {
          const angle = -90 + (360 * index) / Math.max(1, nodes.length);
          const radians = (angle * Math.PI) / 180;
          layout.set(node.nodeKey, clampPoint(
            centerX + Math.cos(radians) * radiusX * 0.72,
            centerY + Math.sin(radians) * radiusY * 0.72
          ));
        });
      return layout;
    },
    buildRelationEdgeCurves(edges) {
      const pairIndex = new Map();
      const pairCount = new Map();
      edges.forEach((edge) => {
        const pairKey = [edge.fromKey, edge.toKey].sort().join("__");
        pairCount.set(pairKey, (pairCount.get(pairKey) || 0) + 1);
      });
      return edges.map((edge) => {
        const pairKey = [edge.fromKey, edge.toKey].sort().join("__");
        const count = pairCount.get(pairKey) || 1;
        const current = pairIndex.get(pairKey) || 0;
        pairIndex.set(pairKey, current + 1);
        if (count === 1) {
          return 0.16;
        }
        return (current - (count - 1) / 2) * 0.14;
      });
    },
    getRelationNodeStyle(category, selected) {
      return {
        shadowBlur: selected ? 16 : category === 0 ? 10 : 6,
        shadowOffsetY: selected ? 4 : 2,
        shadowColor: selected ? "rgba(42, 111, 174, 0.24)" : "rgba(45, 84, 124, 0.12)",
      };
    },
    relationEdgeColor(edge) {
      if (!edge) {
        return "#4f7fbd";
      }
      if (edge.edgeTable === "lx_rel_family_edge") {
        return "#3f8b6e";
      }
      if (edge.edgeTable === "lx_rel_stock_edge") {
        return "#b88745";
      }
      return "#4f7fbd";
    },
    relationEdgeClass(edge) {
      if (!edge) {
        return "";
      }
      if (edge.edgeTable === "lx_rel_family_edge") {
        return "family";
      }
      if (edge.edgeTable === "lx_rel_stock_edge") {
        return "stock";
      }
      return "represent";
    },
    relationEdgeSourceText(edge) {
      if (!edge) {
        return "-";
      }
      if (edge.edgeTable === "lx_rel_family_edge") {
        return "家庭关系边";
      }
      if (edge.edgeTable === "lx_rel_stock_edge") {
        return "工商股东持股边";
      }
      return "工商法定代表人边";
    },
    clearRelationSelection() {
      this.selectedRelationNode = null;
      this.selectedRelationEdge = null;
    },
    hideContextMenu() {
      this.contextMenu = {
        visible: false,
        x: 0,
        y: 0,
        node: null,
      };
    },
    handleContextExpand() {
      const node = this.contextMenu.node;
      this.hideContextMenu();
      this.expandNode(node);
    },
    handleContextCenter() {
      const node = this.contextMenu.node;
      this.hideContextMenu();
      this.queryByNode(node);
    },
    clearSelection() {
      this.edgeDetailRequestSeq += 1;
      this.selectedNode = null;
      this.selectedEdge = null;
      this.edgeDetails = [];
      this.detailTotal = 0;
      this.detailLoading = false;
    },
    resetFundSelection() {
      this.clearChartEmphasis();
      this.clearSelection();
      this.renderChart({ resetViewport: true });
      this.$nextTick(() => {
        this.clearChartEmphasis();
      });
    },
    clearChartEmphasis() {
      if (!this.chart) {
        return;
      }
      this.chart.dispatchAction({ type: "hideTip" });
      this.chart.dispatchAction({
        type: "downplay",
        seriesIndex: 0,
      });
    },
    resizeChart() {
      this.$nextTick(() => {
        this.syncChartSize();
        window.setTimeout(this.syncChartSize, 80);
        window.setTimeout(() => {
          this.syncChartSize();
          this.renderChart();
        }, 180);
      });
    },
    resizeRelationChart() {
      if (this.activeGraphTab !== "relation") {
        return;
      }
      this.$nextTick(() => {
        const resized = this.syncRelationChartSize();
        window.setTimeout(this.syncRelationChartSize, 80);
        window.setTimeout(() => {
          if (this.syncRelationChartSize() || resized) {
            this.renderRelationChart();
          }
        }, 180);
      });
    },
    syncChartSize() {
      if (!this.chart || !this.$refs.chart) {
        return;
      }
      const rect = this.$refs.chart.getBoundingClientRect();
      if (rect.width > 0 && rect.height > 0) {
        this.chart.resize({
          width: Math.round(rect.width),
          height: Math.round(rect.height),
        });
      }
    },
    syncRelationChartSize() {
      if (!this.relationChart || !this.$refs.relationChart) {
        return;
      }
      const rect = this.$refs.relationChart.getBoundingClientRect();
      if (rect.width > 0 && rect.height > 0) {
        this.relationChart.resize({
          width: Math.round(rect.width),
          height: Math.round(rect.height),
        });
        return true;
      }
      return false;
    },
    formatMoney(value) {
      const number = Number(value || 0);
      return `${number.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`;
    },
    formatEdgeLabel(edge) {
      const amount = Number(edge && edge.totalAmount ? edge.totalAmount : 0);
      const count = Number(edge && edge.transactionCount ? edge.transactionCount : 0);
      if (this.compactGraphLabels) {
        return `${this.formatCompactMoney(amount)}, ${count}笔`;
      }
      return `${amount.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}元, ${count}笔`;
    },
    formatCompactMoney(value) {
      const number = Math.abs(Number(value || 0));
      const sign = Number(value || 0) < 0 ? "-" : "";
      if (number >= 100000000) {
        return `${sign}${(number / 100000000).toLocaleString("zh-CN", { maximumFractionDigits: 2 })}亿`;
      }
      if (number >= 10000) {
        return `${sign}${(number / 10000).toLocaleString("zh-CN", { maximumFractionDigits: 2 })}万`;
      }
      return `${sign}${number.toLocaleString("zh-CN", { maximumFractionDigits: 0 })}元`;
    },
    truncateGraphText(value, maxLength) {
      const text = String(value || "-");
      if (text.length <= maxLength) {
        return text;
      }
      return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
    },
    formatShortMoney(value) {
      const number = Number(value || 0);
      return `${number.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}元`;
    },
    normalizeLayoutScale() {
      const value = Number(this.layoutScale);
      if (!Number.isFinite(value)) {
        return 1;
      }
      return Math.max(0.88, Math.min(1.05, value));
    },
  },
};
</script>

<style lang="scss" scoped>
.graph-analysis-section {
  margin-top: 16px;
  background: #fff;
  border: 1px solid #dce7f2;
  border-radius: 14px;
  box-shadow: 0 8px 24px rgba(25, 56, 97, 0.06);
  overflow: hidden;
}

.section-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 18px 20px 0;
}

.section-title {
  font-size: 18px;
  font-weight: 700;
  color: #101c31;
}

.section-summary {
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 12px;
  color: #5f6f86;

  strong {
    color: #1f5a95;
  }
}

.graph-tabs {
  display: flex;
  gap: 6px;
  margin-top: 14px;
  padding: 0 20px;
  border-bottom: 1px solid #dce7f2;
}

.graph-tab {
  height: 40px;
  padding: 0 18px;
  color: #51637c;
  font-weight: 600;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 10px 10px 0 0;
  cursor: pointer;

  &.active {
    color: #1f5a95;
    background: #f7fbff;
    border-color: #dce7f2;
    border-bottom-color: #f7fbff;
    margin-bottom: -1px;
  }
}

.fund-workbench {
  position: relative;
  display: grid;
  width: 100%;
  max-width: none;
  grid-template-columns: minmax(0, 1fr) minmax(460px, 520px);
  min-height: 560px;
  overflow: visible;
}

.fund-workbench--single {
  grid-template-columns: minmax(0, 1fr);
}

.fund-left-pane {
  min-width: 0;
  padding: 16px 22px 18px;
  background: #f7fbff;
  border-right: 1px solid #dce7f2;
  overflow: visible;
}

.fund-right-pane {
  min-width: 0;
  padding: 18px 20px;
  background: #fff;
  overflow: visible;
}

.query-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 14px;
}

.keyword-input {
  width: 260px;
}

.date-range {
  width: 260px;
}

.min-total-input {
  width: 128px;
}

.manual-flow-btn {
  color: #51637c;
  border-color: #d8e3ef;
  background: #fbfdff;

  &:hover,
  &:focus {
    color: #1f5a95;
    border-color: #b9d1e8;
    background: #f5faff;
  }
}

.graph-shell {
  position: relative;
  min-width: 0;
  border: 1px solid #d9e6f2;
  border-radius: 12px;
  background:
    radial-gradient(circle at 50% 48%, rgba(226, 237, 249, 0.72), rgba(248, 251, 255, 0) 34%),
    linear-gradient(180deg, #fbfdff 0%, #f6faff 100%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86);
  overflow: visible;
}

.graph-canvas-wrap {
  position: relative;
  height: auto;
  min-height: 540px;
  aspect-ratio: 16 / 9;
  min-width: 0;
  overflow: visible;
}

.graph-canvas {
  width: 100%;
  height: 100%;
  min-height: 540px;
}

.graph-empty {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.detail-empty {
  height: 100%;
  min-height: 500px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  color: #8a99ad;
  background: #fbfdff;
  border: 1px dashed #dce7f2;
  border-radius: 12px;
}

.empty-title {
  font-size: 15px;
  font-weight: 700;
  color: #415066;
}

.empty-desc {
  margin-top: 8px;
  width: 260px;
  line-height: 1.7;
  font-size: 12px;
}

.detail-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e5edf5;
}

.detail-head > div {
  min-width: 0;
}

.detail-title {
  font-size: 16px;
  font-weight: 700;
  color: #1f2d3d;
}

.detail-subtitle {
  margin-top: 4px;
  font-size: 12px;
  color: #8a99ad;
}

.account-main,
.memo-main {
  color: #24364f;
  font-weight: 600;
  line-height: 1.35;
}

.account-sub,
.memo-sub {
  margin-top: 2px;
  color: #7a8aa0;
  font-size: 12px;
  line-height: 1.3;
}

.node-field-list {
  margin: 14px 0;
  border: 1px solid #dfeaf5;
  border-radius: 10px;
  overflow: hidden;
}

.node-field-row {
  display: grid;
  grid-template-columns: 108px minmax(0, 1fr);
  border-top: 1px solid #edf3f9;
  font-size: 12px;

  &:first-child {
    border-top: 0;
  }

  span {
    padding: 9px 10px;
    color: #6f8096;
    background: #f7fafd;
  }

  strong {
    padding: 9px 10px;
    color: #24364f;
    font-weight: 600;
    word-break: break-all;
  }
}

.node-actions {
  display: flex;
  gap: 10px;
}

.candidate-dialog-tip {
  margin-bottom: 12px;
  padding: 10px 12px;
  color: #5e6f84;
  font-size: 12px;
  line-height: 1.6;
  background: #f7fafd;
  border: 1px solid #dfeaf5;
  border-radius: 8px;
}

.suspected-enterprise-panel {
  position: absolute;
  right: 18px;
  bottom: 18px;
  z-index: 30;
  width: min(720px, calc(100% - 36px));
  padding: 14px;
  background: #fff;
  border: 1px solid #d7e2ee;
  border-radius: 12px;
  box-shadow: 0 18px 44px rgba(22, 45, 76, 0.18);
}

.suspected-enterprise-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 10px;
}

.suspected-enterprise-title {
  color: #1f2d3d;
  font-size: 15px;
  font-weight: 700;
}

.suspected-enterprise-subtitle {
  margin-top: 3px;
  color: #7a8aa0;
  font-size: 12px;
}

.suspected-enterprise-close {
  width: 26px;
  height: 26px;
  color: #7a8aa0;
  font-size: 20px;
  line-height: 22px;
  background: #f7fafd;
  border: 1px solid #dfeaf5;
  border-radius: 8px;
  cursor: pointer;

  &:hover {
    color: #1f5a95;
    background: #eef6ff;
  }
}

.edge-metrics {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
  margin: 14px 0;

  div {
    padding: 10px;
    background: #f7faff;
    border: 1px solid #e2ebf5;
    border-radius: 8px;
  }

  span {
    display: block;
    font-size: 12px;
    color: #8a99ad;
  }

  strong {
    display: block;
    margin-top: 4px;
    font-size: 13px;
    color: #1f2d3d;
  }
}

.detail-table {
  border: 1px solid #e2ebf5;
  border-radius: 10px;
}

.graph-context-menu {
  position: absolute;
  z-index: 20;
  min-width: 132px;
  padding: 6px;
  background: #fff;
  border: 1px solid #d7e2ee;
  border-radius: 8px;
  box-shadow: 0 10px 26px rgba(15, 31, 56, 0.16);

  button {
    display: block;
    width: 100%;
    height: 30px;
    padding: 0 10px;
    color: #17233d;
    text-align: left;
    background: transparent;
    border: 0;
    border-radius: 6px;
    cursor: pointer;

    &:hover {
      color: #1f5a95;
      background: #eef6ff;
    }
  }
}

.manual-edge-info {
  margin-bottom: 14px;
  padding: 12px;
  border: 1px dashed #c8d8e8;
  border-radius: 10px;
  background: #fbfdff;
}

.manual-tag {
  display: inline-flex;
  align-items: center;
  height: 22px;
  padding: 0 8px;
  margin-bottom: 8px;
  color: #1f5a95;
  font-size: 12px;
  font-weight: 600;
  background: #eef6ff;
  border: 1px solid #cfe1f3;
  border-radius: 999px;
}

.manual-row {
  display: grid;
  grid-template-columns: 72px minmax(0, 1fr);
  gap: 8px;
  padding: 7px 0;
  border-top: 1px solid #eef3f8;
  font-size: 12px;

  span {
    color: #7a8aa0;
  }

  strong {
    color: #253852;
    font-weight: 500;
    word-break: break-all;
  }
}

.memo-main {
  color: #26384f;
}

.memo-sub {
  margin-top: 2px;
  color: #8a99ad;
  font-size: 12px;
}

.amount-out {
  color: #c7771a;
  font-weight: 600;
}

.amount-in {
  color: #0f766e;
  font-weight: 600;
}

.relation-edge-badge {
  display: inline-flex;
  align-items: center;
  height: 28px;
  padding: 0 12px;
  margin: 14px 0 0;
  color: #3f8b6e;
  font-size: 13px;
  font-weight: 700;
  background: #edf8f3;
  border: 1px solid #cfe7dc;
  border-radius: 999px;

  &.stock {
    color: #9a6b2f;
    background: #fbf5eb;
    border-color: #ead7ba;
  }

  &.represent {
    color: #3e6899;
    background: #eef5ff;
    border-color: #cbdcf0;
  }
}

.relation-placeholder {
  min-height: 500px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f8fbff;
}

.relation-desc {
  margin-top: 6px;
  color: #8a99ad;
  font-size: 13px;
}

@media (max-width: 1366px) {
  .fund-workbench {
    grid-template-columns: minmax(0, 1fr) 420px;
    max-width: 100%;
  }

  .graph-canvas-wrap {
    min-height: 480px;
  }

  .date-range {
    width: 230px;
  }
}
</style>
