feat: route background ai workloads by model

This commit is contained in:
2026-07-31 17:25:16 +08:00
parent 0008903e8d
commit da313f88ed
22 changed files with 714 additions and 63 deletions

View File

@@ -305,7 +305,15 @@ async function loadDashboard() {
async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } }
function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; }
function formatMoney(value?: number | null, currency = "CNY") { if (typeof value !== "number") return "-"; return `${currency || "CNY"} ${value.toFixed(6)}`; }
function formatMoney(value?: number | null, currency = "CNY") { if (typeof value !== "number") return "-"; if (currency === "MIXED") return "多币种,见明细"; return `${currency || "CNY"} ${value.toFixed(6)}`; }
function modelSceneLabel(scene: string) {
return ({
background_report: "周期报告",
background_summary: "摘要沉淀",
knowledge_grounded: "知识问答",
general_chat: "通用对话",
} as Record<string, string>)[scene] || scene || "未分类";
}
async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); }
async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value}`); await loadCurrentMenu(); }
@@ -711,12 +719,25 @@ function applyQuickProvider(provider: string) {
quickModelForm.modelName = target.models[0]?.value ?? "";
}
async function enableModel(id: number) {
await api.enableModel(id);
ElMessage.success("模型已启用");
async function setModelAvailability(row: ModelItem, enabled: number) {
await api.setModelAvailability(row.id, enabled);
ElMessage.success(enabled === 1 ? "模型已加入可用池" : "模型已停用");
await loadCurrentMenu();
}
async function setDefaultModel(row: ModelItem) {
await api.setDefaultModel(row.id);
ElMessage.success("默认主模型已更新");
await loadCurrentMenu();
}
async function handleModelCommand(command: string, row: ModelItem) {
if (command === "enable") return setModelAvailability(row, 1);
if (command === "disable") return setModelAvailability(row, 0);
if (command === "default") return setDefaultModel(row);
return deleteModel(row.id);
}
async function deleteModel(id: number) {
try {
await ElMessageBox.confirm("确认删除该模型?删除后不可恢复。", "删除模型", {
@@ -1108,6 +1129,23 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div class="stat"><span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong></div>
<div class="stat"><span>估算成本</span><strong>{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || 'CNY') }}</strong></div>
</div>
<section class="cost-breakdown-panel">
<div class="storage-panel-head">
<div><h3>模型使用与成本</h3><p>按实际调用场景和模型统计报告摘要分流是否生效可在这里核对</p></div>
</div>
<el-table :data="stats?.costBreakdown || []" empty-text="当前日期范围内暂无模型调用">
<el-table-column label="调用场景" min-width="130">
<template #default="{ row }">{{ modelSceneLabel(row.scene) }}</template>
</el-table-column>
<el-table-column prop="modelName" label="实际模型" min-width="180" show-overflow-tooltip />
<el-table-column prop="requestCount" label="请求数" width="100" />
<el-table-column prop="inputToken" label="输入 Token" width="130" />
<el-table-column prop="outputToken" label="输出 Token" width="130" />
<el-table-column label="估算成本" width="170">
<template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || 'CNY') }}</template>
</el-table-column>
</el-table>
</section>
<section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计{{ storageStats?.createdAt || '尚未统计' }}</small></section>
</template>
@@ -1322,7 +1360,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<template v-if="activeMenu === 'models'">
<div class="page-head inline">
<div><h2>模型管理</h2><p>DeepSeekMiniMax 可快速添加其他供应商按 OpenAI 兼容或 Anthropic Messages 协议添加</p></div>
<div><h2>模型管理</h2><p>可同时启用多个模型但只保留一个默认主模型正式聊天始终使用主模型报告和摘要按能力标签分流并自动回退主模型</p></div>
<el-button @click="resetModelForm">清空表单</el-button>
</div>
@@ -1444,13 +1482,27 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-table-column label="估算单价" width="180">
<template #default="{ row }">{{ row.currency || 'CNY' }} {{ row.inputPricePer1k ?? '-' }}/{{ row.outputPricePer1k ?? '-' }}</template>
</el-table-column>
<el-table-column prop="usageScenarios" label="适用场景" min-width="160" show-overflow-tooltip />
<el-table-column label="分流能力" min-width="220">
<template #default="{ row }">
<div class="model-capability-tags">
<el-tag v-if="row.allowDeepChat === 1" size="small">深度对话</el-tag>
<el-tag v-if="row.allowFixedInfo === 1" size="small">固定信息</el-tag>
<el-tag v-if="row.allowSummary === 1" size="small" type="success">摘要</el-tag>
<el-tag v-if="row.allowReport === 1" size="small" type="warning">报告</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column prop="enabled" label="启用" width="90" />
<el-table-column label="运行状态" width="150">
<template #default="{ row }">
<el-tag :type="row.enabled === 1 ? 'success' : 'info'">{{ row.enabled === 1 ? '可用' : '停用' }}</el-tag>
<el-tag v-if="row.isDefault === 1" class="model-default-tag">主模型</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<TableRowActions><el-button size="small" @click="editModel(row)">编辑</el-button><el-button size="small" @click="testModel(row.id)">测试</el-button><el-dropdown trigger="click" teleported @command="(command: string) => command === 'enable' ? enableModel(row.id) : deleteModel(row.id)"><el-button size="small">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="enable">启用</el-dropdown-item><el-dropdown-item command="delete" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions>
<TableRowActions><el-button size="small" @click="editModel(row)">编辑</el-button><el-button size="small" @click="testModel(row.id)">测试</el-button><el-dropdown trigger="click" teleported @command="(command: string) => handleModelCommand(command, row)"><el-button size="small">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="row.enabled !== 1" command="enable">加入可用池</el-dropdown-item><el-dropdown-item v-if="row.enabled === 1 && row.isDefault !== 1" command="default">设为主模型</el-dropdown-item><el-dropdown-item v-if="row.enabled === 1 && row.isDefault !== 1" command="disable">停用</el-dropdown-item><el-dropdown-item v-if="row.isDefault !== 1" command="delete" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions>
</template>
</el-table-column>
</el-table>

View File

@@ -207,8 +207,10 @@ export const api = {
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
updateModel: (id: number, payload: Record<string, unknown>) =>
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
enableModel: (modelId: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
setModelAvailability: (modelId: number, enabled: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId, enabled }) }),
setDefaultModel: (modelId: number) =>
request<null>("/admin/model/default", { method: "POST", body: JSON.stringify({ modelId }) }),
deleteModel: (modelId: number) =>
request<null>(`/admin/model/${modelId}`, { method: "DELETE" }),
testModel: (modelId: number) =>

View File

@@ -525,12 +525,15 @@ textarea {
gap: 14px;
}
.storage-panel { margin-top: 18px; padding: 18px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.cost-breakdown-panel { margin-top: 18px; padding: 18px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.storage-panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
.storage-panel-head h3, .storage-panel-head p { margin: 0 0 6px; }
.storage-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.storage-grid > div { padding: 14px; background: #f6f8f7; border-radius: 8px; }
.storage-grid span, .storage-grid strong { display: block; }
.storage-grid strong { margin-top: 8px; font-size: 20px; }
.model-capability-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.model-default-tag { margin-left: 6px; }
.cleanup-controls { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.retention-panel { display: grid; grid-template-columns: minmax(260px, 1fr) 180px auto auto; align-items: center; gap: 12px; padding: 14px 16px; margin-bottom: 14px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.retention-panel div span { display: block; margin-top: 4px; color: #667a73; font-size: 12px; }

View File

@@ -29,6 +29,16 @@ export interface DashboardStats {
totalToken: number;
estimatedCost?: number | null;
costCurrency?: string | null;
costBreakdown: Array<{
scene: string;
modelName: string;
requestCount: number;
inputToken: number;
outputToken: number;
totalToken: number;
estimatedCost: number;
currency?: string | null;
}>;
}
export interface PromptDetail {
@@ -195,6 +205,7 @@ export interface ModelItem {
remark?: string | null;
timeoutSecond: number;
enabled: number;
isDefault: number;
inputPricePer1k?: number | null;
outputPricePer1k?: number | null;
currency: string;