初始化询价智能体新仓:同步本地工程并排除密钥与 .env。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 14:30:44 +08:00
co-authored by Cursor
commit 2a14def882
2621 changed files with 393165 additions and 0 deletions
@@ -0,0 +1,11 @@
/**
* 分析看板 API。
*/
import { defHttp } from '/@/utils/http/axios';
enum Api {
metrics = '/inquiry/dashboard/metrics',
}
export const getDashboardMetrics = (range = 'today') =>
defHttp.get({ url: Api.metrics, params: { range } });
@@ -0,0 +1,547 @@
<script setup lang="ts">
/**
* 分析看板:图标 / 帮助气泡 / 图表悬停提示对齐原型。
* 顶栏工具条用 Teleport defer 挂到布局 #page-topbar-actions。
*/
import { computed, onMounted, onUnmounted, ref } from "vue";
import { message } from "ant-design-vue";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import {
todayMetrics,
inquiryTrend,
rankingPeople,
rankingDepts,
costRoutes,
type RankingPerson,
} from "/@/views/inquiry/mock/dashboard";
const dateDisplay = ref("2026-07-01——2026-07-31");
const dateFrom = ref("2026-07-01");
const dateTo = ref("2026-07-31");
const dateFromMonth = ref("2026-07");
const dateToMonth = ref("2026-07");
const dateFromYear = ref("2026");
const dateToYear = ref("2026");
const pickerOpen = ref(false);
const pickerMode = ref<"day" | "month" | "year">("day");
const updatedTime = ref("00:00");
const routeFilterOpen = ref(false);
const selectedRoutes = ref<string[]>(costRoutes.map((r) => r.id));
const rankingRole = ref<"all" | "sales" | "route" | "product">("all");
const rankingDept = ref("");
const chartReady = ref(false);
const chartTip = ref({
visible: false,
left: "0px",
top: "0px",
date: "",
value: "",
lines: [] as string[],
});
const costTip = ref({
visible: false,
left: "0px",
top: "0px",
date: "",
value: "",
lines: [] as string[],
});
const activePoint = ref<string | null>(null);
const INQUIRY_PLOT_TOP = 36;
const INQUIRY_PLOT_BOTTOM = 188;
const inquirySvg = computed(() => {
const points = inquiryTrend;
const xs = points.map((_, i) => (points.length === 1 ? 380 : 36 + (654 / (points.length - 1)) * i));
const values = points.map((p) => p.value);
const max = Math.max(...values);
const min = Math.min(...values);
const range = Math.max(max - min, 1);
const plotH = INQUIRY_PLOT_BOTTOM - INQUIRY_PLOT_TOP;
const yFor = (v: number) => INQUIRY_PLOT_BOTTOM - ((v - min) / range) * plotH;
const coords = points.map((p, i) => ({
x: xs[i],
y: yFor(p.value),
date: p.date,
value: p.value,
}));
const line = coords.map((c) => `${c.x.toFixed(1)} ${c.y.toFixed(1)}`).join(" L");
const area = `M${line} L${coords[coords.length - 1].x.toFixed(1)} ${INQUIRY_PLOT_BOTTOM} L${coords[0].x.toFixed(1)} ${INQUIRY_PLOT_BOTTOM} Z`;
return { coords, line, area };
});
const filteredRoutes = computed(() => costRoutes.filter((r) => selectedRoutes.value.includes(r.id)));
const rankingRows = computed(() => {
let rows: RankingPerson[] = [...rankingPeople];
if (rankingRole.value !== "all") rows = rows.filter((p) => p.role === rankingRole.value);
if (rankingDept.value) rows = rows.filter((p) => p.dept === rankingDept.value);
return rows.sort((a, b) => b.volumeNum - a.volumeNum || a.name.localeCompare(b.name, "zh-CN"));
});
const costDates = ["06-12", "06-13", "06-14", "06-15", "06-16", "06-17", "06-18"];
const costXs = [36, 145, 254, 363, 472, 581, 690];
function applyDateRange() {
if (pickerMode.value === "day") dateDisplay.value = `${dateFrom.value}——${dateTo.value}`;
else if (pickerMode.value === "month") dateDisplay.value = `${dateFromMonth.value}——${dateToMonth.value}`;
else dateDisplay.value = `${dateFromYear.value}——${dateToYear.value}`;
pickerOpen.value = false;
}
function refreshData() {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
updatedTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
message.success("数据已更新");
}
function toggleRoute(id: string) {
const set = new Set(selectedRoutes.value);
if (set.has(id)) set.delete(id);
else set.add(id);
selectedRoutes.value = [...set];
}
function selectAllRoutes() {
selectedRoutes.value = costRoutes.map((r) => r.id);
}
function clearRoutes() {
selectedRoutes.value = [];
}
function showInquiryTip(date: string, value: number, x: number, y: number, wrapEl: HTMLElement | null) {
if (!wrapEl) return;
const box = wrapEl.getBoundingClientRect();
activePoint.value = date;
chartTip.value = {
visible: true,
left: `${(x / 760) * box.width}px`,
top: `${(y / 240) * box.height}px`,
date,
value: `${value} 单/日`,
lines: [],
};
}
function hideInquiryTip() {
activePoint.value = null;
chartTip.value.visible = false;
}
function showCostTip(date: string, x: number, wrapEl: HTMLElement | null) {
if (!wrapEl) return;
const box = wrapEl.getBoundingClientRect();
const routes = filteredRoutes.value;
const lines =
routes.length === 0
? ["未选择线路"]
: [
...routes.slice(0, 3).map((r) => r.label),
...(routes.length > 3 ? [`等 ${routes.length} 条`] : []),
];
costTip.value = {
visible: true,
left: `${(x / 760) * box.width}px`,
top: `${(36 / 220) * box.height}px`,
date,
value: "",
lines,
};
}
function hideCostTip() {
costTip.value.visible = false;
}
function exportRanking() {
const roleLabels: Record<string, string> = { all: "全部", route: "航线", product: "产品", sales: "销售" };
const header = ["排名", "人员", "部门", "岗位", "处理量"];
const body = rankingRows.value.map((row, index) => [
String(index + 1),
row.name,
row.dept,
roleLabels[row.role] || row.role,
`${row.volumeNum}单`,
]);
const csv = [header, ...body]
.map((cols) => cols.map((col) => `"${String(col).replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([`\ufeff${csv}`], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `人员排名_${roleLabels[rankingRole.value]}_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
message.success("已导出排名 CSV");
}
function onDocClick(e: MouseEvent) {
const t = e.target as HTMLElement;
if (!t.closest(".month-range-picker")) pickerOpen.value = false;
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
}
const inquiryWrap = ref<HTMLElement | null>(null);
const costWrap = ref<HTMLElement | null>(null);
onMounted(() => {
chartReady.value = true;
document.addEventListener("click", onDocClick);
});
onUnmounted(() => document.removeEventListener("click", onDocClick));
</script>
<template>
<!-- defer:等布局挂好 #page-topbar-actions 再传送,避免顶栏工具丢失 -->
<Teleport defer to="#page-topbar-actions">
<div class="dash-topbar-tools">
<div class="month-range-picker" @click.stop>
<div class="month-range-input" @click="pickerOpen = !pickerOpen">
<span>{{ dateDisplay }}</span>
<ProtoIcon name="calendar" :size="14" style="color: #94a3b8" />
</div>
<div v-show="pickerOpen" class="month-range-dropdown dash-picker-dropdown">
<div class="month-range-tabs">
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'day' }" @click="pickerMode = 'day'">按日</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'month' }" @click="pickerMode = 'month'">按月</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'year' }" @click="pickerMode = 'year'">按年</button>
</div>
<div class="dash-picker-body">
<template v-if="pickerMode === 'day'">
<input v-model="dateFrom" type="date" class="dash-picker-input" />
<input v-model="dateTo" type="date" class="dash-picker-input" />
</template>
<template v-else-if="pickerMode === 'month'">
<input v-model="dateFromMonth" type="month" class="dash-picker-input" />
<input v-model="dateToMonth" type="month" class="dash-picker-input" />
</template>
<template v-else>
<input v-model="dateFromYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="起始年" />
<input v-model="dateToYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="结束年" />
</template>
<button type="button" class="btn btn-primary" style="width: 100%; min-height: 32px; height: 32px" @click="applyDateRange">确定</button>
</div>
</div>
</div>
<div class="dashboard-data-note" aria-live="polite">
<span class="dashboard-data-note__icon"><ProtoIcon name="clock" :size="13" /></span>
<span class="dashboard-data-note__time">数据更新时间{{ updatedTime }}</span>
</div>
<button type="button" class="btn btn-primary dash-refresh-btn" @click="refreshData">
<ProtoIcon name="refresh" :size="14" />
更新数据
</button>
</div>
</Teleport>
<!-- 1. 九指标 -->
<div class="ref-surface ref-surface--metrics">
<div class="ref-header ref-header--compact">
<div>
<h2 class="ref-title">询价运营表现总览</h2>
</div>
</div>
<div class="ref-metrics-grid">
<div v-for="m in todayMetrics" :key="m.key" class="ref-metric-card">
<div class="ref-metric-card__label">
<span>{{ m.label }}</span>
<button type="button" class="ref-metric-help" :aria-label="m.label + '计算逻辑'">
<ProtoIcon name="help" :size="12" />
<span class="ref-metric-tip">{{ m.tip }}</span>
</button>
</div>
<div class="ref-metric-card__value" :class="{ 'is-time': m.isTime }">{{ m.value }}</div>
</div>
</div>
</div>
<!-- 2. 双图 -->
<div class="ref-surface">
<div class="ref-charts-section">
<div class="ref-chart-panel">
<div class="ref-chart-panel__head">
<div class="ref-section-title dash-chart-title">
<ProtoIcon name="lineChart" :size="14" class="dash-chart-title__icon" />
询价单量趋势图
</div>
</div>
<div class="ref-chart-panel__body">
<div ref="inquiryWrap" class="ref-chart-wrap">
<svg v-if="chartReady" class="ref-chart" viewBox="0 0 760 240" role="img" aria-label="近七天询价趋势">
<defs>
<linearGradient id="refTrendFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#155eef" stop-opacity="0.18" />
<stop offset="100%" stop-color="#155eef" stop-opacity="0" />
</linearGradient>
</defs>
<path :d="inquirySvg.area" fill="url(#refTrendFill)" />
<path
:d="`M${inquirySvg.line}`"
fill="none"
stroke="#155eef"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M36 188 H724 M36 137 H724 M36 86 H724 M36 36 H724" stroke="#e8edf4" stroke-width="1" />
<g fill="#64748b" font-size="8" font-weight="800" text-anchor="middle">
<text v-for="c in inquirySvg.coords" :key="'d-' + c.date" :x="c.x" y="218">{{ c.date }}</text>
</g>
<g fill="#0f172a" font-size="8" font-weight="800" text-anchor="middle">
<text v-for="c in inquirySvg.coords" :key="'v-' + c.date" :x="c.x" :y="c.y - 11">{{ c.value }}</text>
</g>
<g
v-for="c in inquirySvg.coords"
:key="'p-' + c.date"
class="ref-chart-point"
:class="{ 'is-active': activePoint === c.date }"
@mouseenter="showInquiryTip(c.date, c.value, c.x, c.y, inquiryWrap)"
@mouseleave="hideInquiryTip"
>
<!-- 加大热区,方便悬停出提示 -->
<circle :cx="c.x" :cy="c.y" r="14" fill="transparent" />
<circle :cx="c.x" :cy="c.y" r="4" fill="#155eef" stroke="#ffffff" stroke-width="3" />
</g>
</svg>
<div
class="ref-chart-tooltip"
:class="{ 'is-visible': chartTip.visible }"
:style="{ left: chartTip.left, top: chartTip.top }"
>
<div class="date">{{ chartTip.date }}</div>
<div class="value">{{ chartTip.value }}</div>
</div>
</div>
</div>
</div>
<div class="ref-chart-panel">
<div class="ref-chart-panel__head">
<div class="ref-section-title dash-chart-title">
<ProtoIcon name="trending" :size="14" class="dash-chart-title__icon" />
成本趋势图
</div>
<div class="ref-route-filter" @click.stop>
<button
type="button"
class="ref-route-filter__trigger"
:class="{ 'is-open': routeFilterOpen }"
@click="routeFilterOpen = !routeFilterOpen"
>
<ProtoIcon name="route" :size="14" />
<span>线路筛选</span>
<span class="ref-route-filter__badge">{{ selectedRoutes.length }}</span>
<ProtoIcon name="chevron" :size="14" class="ref-route-filter__arrow" />
</button>
<div v-show="routeFilterOpen" class="ref-route-filter__menu dash-route-menu">
<div class="ref-route-filter__menu-head">
<div>
<div class="ref-route-filter__menu-title">选择对比线路</div>
<div class="ref-route-filter__menu-sub">已选 {{ selectedRoutes.length }} / {{ costRoutes.length }}</div>
</div>
<div class="tabs-pill ref-route-filter__actions">
<button type="button" @click="selectAllRoutes">全选</button>
<button type="button" @click="clearRoutes">清空</button>
</div>
</div>
<div class="ref-route-filter__chips">
<label
v-for="r in costRoutes"
:key="r.id"
class="ref-route-chip"
@click.prevent="toggleRoute(r.id)"
>
<input type="checkbox" :checked="selectedRoutes.includes(r.id)" />
<span class="ref-route-chip__dot" :style="{ '--route-color': r.color }" />
<span class="ref-route-chip__text">{{ r.label }}</span>
</label>
</div>
</div>
</div>
</div>
<div class="ref-chart-panel__body">
<div ref="costWrap" id="costChartWrap" class="ref-chart-wrap">
<svg class="ref-chart" viewBox="0 0 760 220" role="img" aria-label="成本趋势图">
<path d="M36 186 H724 M36 136 H724 M36 86 H724 M36 36 H724" stroke="#e8edf4" stroke-width="1" />
<g fill="#94a3b8" font-size="8" font-weight="700" text-anchor="end">
<text x="30" y="40">高</text>
<text x="30" y="90">—</text>
<text x="30" y="140">—</text>
<text x="30" y="190">低</text>
</g>
<path
v-for="r in filteredRoutes"
:key="r.id"
class="cost-line"
:d="r.path"
fill="none"
:stroke="r.color"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<g
v-for="(x, i) in costXs"
:key="'cx-' + costDates[i]"
class="cost-point"
@mouseenter="showCostTip(costDates[i], x, costWrap)"
@mouseleave="hideCostTip"
>
<rect :x="x - 40" y="28" width="80" height="165" fill="transparent" style="cursor: pointer" />
</g>
<g fill="#64748b" font-size="8" font-weight="800" text-anchor="middle">
<text v-for="(d, i) in costDates" :key="d" :x="costXs[i]" y="214">{{ d }}</text>
</g>
</svg>
<div
class="ref-chart-tooltip is-cost"
:class="{ 'is-visible': costTip.visible }"
:style="{ left: costTip.left, top: costTip.top }"
>
<div class="date">{{ costTip.date }}</div>
<div class="value">
<span v-for="(line, i) in costTip.lines" :key="i" class="value-line">{{ line }}</span>
</div>
</div>
<div class="dash-cost-footnote">成本数据来源于 TMS</div>
</div>
</div>
</div>
</div>
</div>
<!-- 3. 人员排名 -->
<div class="ref-surface">
<div class="ref-panel-head ref-ranking-panel-head">
<div class="ref-section-title ref-ranking-section-title">
<ProtoIcon name="gauge" :size="16" class="ref-ranking-icon dash-chart-title__icon" />
人员排名
</div>
<div class="ref-ranking-panel-actions">
<select v-model="rankingDept" class="ref-ranking-dept-select" aria-label="部门筛选">
<option value="">全部部门</option>
<option v-for="d in rankingDepts" :key="d" :value="d">{{ d }}</option>
</select>
<div class="tabs-pill" role="tablist" aria-label="人员角色">
<button type="button" :class="{ 'is-active': rankingRole === 'all' }" @click="rankingRole = 'all'">全部</button>
<button type="button" :class="{ 'is-active': rankingRole === 'sales' }" @click="rankingRole = 'sales'">销售</button>
<button type="button" :class="{ 'is-active': rankingRole === 'route' }" @click="rankingRole = 'route'">航线</button>
<button type="button" :class="{ 'is-active': rankingRole === 'product' }" @click="rankingRole = 'product'">产品</button>
</div>
<button type="button" class="btn btn-secondary ref-ranking-export-btn" @click="exportRanking">
<ProtoIcon name="download" :size="16" />
导出
</button>
</div>
</div>
<div class="ref-ranking-table-wrap">
<div class="ref-ranking-list">
<div class="ref-ranking-head">
<div class="ref-ranking-info">
<span class="col-rank">排名</span>
<span class="col-name">人员</span>
<span class="col-dept">部门</span>
</div>
<span class="col-volume">处理量</span>
</div>
<div v-if="!rankingRows.length" class="ref-ranking-empty">暂无符合条件的人员数据</div>
<div v-for="(row, index) in rankingRows" :key="row.name + row.dept" class="ref-ranking-row">
<div class="ref-ranking-info">
<span class="col-rank"><span class="ref-rank" :class="{ top: index < 2 }">{{ index + 1 }}</span></span>
<span class="col-name">{{ row.name }}</span>
<span class="col-dept">{{ row.dept }}</span>
</div>
<span class="col-volume">{{ row.volumeNum }} 单</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dash-topbar-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.dash-picker-dropdown {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: 300px;
z-index: 80;
}
.dash-picker-body {
display: grid;
gap: 8px;
padding-top: 4px;
}
.dash-picker-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #dfe5ee;
border-radius: 7px;
font-size: 11px;
font-weight: 700;
}
.dash-refresh-btn {
height: 34px;
min-height: 34px;
}
.dash-chart-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 900;
margin: 0;
}
.dash-chart-title__icon {
color: #1d4ed8;
}
.dash-route-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
}
.dash-cost-footnote {
margin-top: 12px;
font-size: 10px;
font-weight: 700;
color: #94a3b8;
}
.ref-surface--metrics,
.ref-surface--metrics .ref-metrics-grid,
.ref-surface--metrics .ref-metric-card {
overflow: visible;
}
.ref-surface--metrics .ref-metric-card:hover {
z-index: 5;
}
.ref-chart-wrap {
position: relative;
overflow: visible;
}
/* 悬停提示改为不撑开文档宽度 */
.ref-chart-tooltip {
max-width: min(168px, 40vw);
}
</style>