1153 lines
37 KiB
Vue
1153 lines
37 KiB
Vue
<script setup lang="ts">
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from "vue";
|
||
|
||
import AdminLoginView from "./components/AdminLoginView.vue";
|
||
import TableRowActions from "./components/TableRowActions.vue";
|
||
import {
|
||
systemSettingDefinitions,
|
||
type SystemSettingValue,
|
||
} from "./config/systemSettings";
|
||
import { api, clearToken, getToken, saveToken } from "./services/api";
|
||
import type {
|
||
AdminProfile,
|
||
EntitlementPlan,
|
||
ModelItem,
|
||
SystemConfigItem,
|
||
} from "./types/api";
|
||
|
||
const AgentManagementView = defineAsyncComponent(
|
||
() => import("./components/AgentManagementView.vue"),
|
||
);
|
||
const AttentionManagementView = defineAsyncComponent(
|
||
() => import("./components/AttentionManagementView.vue"),
|
||
);
|
||
const ContentGenerationConfigView = defineAsyncComponent(
|
||
() => import("./components/ContentGenerationConfigView.vue"),
|
||
);
|
||
const DashboardView = defineAsyncComponent(
|
||
() => import("./components/DashboardView.vue"),
|
||
);
|
||
const EntitlementManagementView = defineAsyncComponent(
|
||
() => import("./components/EntitlementManagementView.vue"),
|
||
);
|
||
const KnowledgeManagementView = defineAsyncComponent(
|
||
() => import("./components/KnowledgeManagementView.vue"),
|
||
);
|
||
const RecordAuditView = defineAsyncComponent(
|
||
() => import("./components/RecordAuditView.vue"),
|
||
);
|
||
const RetrievalLogView = defineAsyncComponent(
|
||
() => import("./components/RetrievalLogView.vue"),
|
||
);
|
||
const UserManagementView = defineAsyncComponent(
|
||
() => import("./components/UserManagementView.vue"),
|
||
);
|
||
const SsoIntegrationView = defineAsyncComponent(
|
||
() => import("./components/SsoIntegrationView.vue"),
|
||
);
|
||
const SystemConfigView = defineAsyncComponent(
|
||
() => import("./components/SystemConfigView.vue"),
|
||
);
|
||
const AdminManagementView = defineAsyncComponent(
|
||
() => import("./components/AdminManagementView.vue"),
|
||
);
|
||
const FeedbackManagementView = defineAsyncComponent(() => import("./components/FeedbackManagementView.vue"));
|
||
const UserBehaviorAnalysisView = defineAsyncComponent(() => import("./components/UserBehaviorAnalysisView.vue"));
|
||
|
||
const admin = ref<AdminProfile | null>(null);
|
||
const activeMenu = ref("dashboard");
|
||
const loading = ref(false);
|
||
const passwordDialogVisible = ref(false);
|
||
const passwordSaving = ref(false);
|
||
const passwordForm = reactive({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||
|
||
const can = (permission: string) => Boolean(admin.value?.isSuperAdmin || admin.value?.permissions.includes(permission));
|
||
const menuPermissions: Record<string, string> = {
|
||
dashboard: "dashboard.view", users: "users.view", entitlements: "entitlements.view",
|
||
knowledge: "knowledge.view", prompt: "prompt.view", models: "models.view",
|
||
"content-generation": "content-generation.view", configs: "configs.view", sso: "sso.view",
|
||
records: "records.view", retrievals: "retrievals.view", attention: "attention.view", admins: "admins.view",
|
||
feedback: "feedback.view",
|
||
behavior: "behavior.view",
|
||
};
|
||
|
||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||
const models = ref<ModelItem[]>([]);
|
||
const configs = ref<SystemConfigItem[]>([]);
|
||
const savingSystemSettings = ref(false);
|
||
const systemSettingsBaseline = ref<Record<string, string>>({});
|
||
const previewKnowledgeId = ref<number | null>(null);
|
||
const editingModelId = ref<number | null>(null);
|
||
|
||
const modelForm = reactive({
|
||
provider: "openai-compatible",
|
||
displayName: "",
|
||
apiType: "openai_compatible",
|
||
modelName: "",
|
||
baseUrl: "https://api.openai.com/v1",
|
||
apiUrl: "",
|
||
apiKey: "",
|
||
authType: "bearer",
|
||
apiVersion: "",
|
||
temperature: null as number | null,
|
||
topP: null as number | null,
|
||
topK: null as number | null,
|
||
presencePenalty: null as number | null,
|
||
frequencyPenalty: null as number | null,
|
||
maxToken: 8192 as number | null,
|
||
contextWindow: null as number | null,
|
||
streamEnabled: 1,
|
||
responseFormat: "",
|
||
extraParams: "",
|
||
remark: "",
|
||
timeoutSecond: 30,
|
||
inputPricePer1k: null as number | null,
|
||
outputPricePer1k: null as number | null,
|
||
currency: "CNY",
|
||
usageScenarios: "",
|
||
allowSummary: 1,
|
||
allowReport: 1,
|
||
allowFixedInfo: 1,
|
||
allowSimpleKnowledge: 1,
|
||
allowDeepChat: 1,
|
||
});
|
||
|
||
const quickModelForm = reactive({
|
||
provider: "deepseek",
|
||
modelName: "deepseek-v4-pro",
|
||
apiKey: "",
|
||
});
|
||
|
||
const quickModelProviders = [
|
||
{
|
||
label: "DeepSeek",
|
||
provider: "deepseek",
|
||
apiType: "openai_compatible",
|
||
baseUrl: "https://api.deepseek.com",
|
||
authType: "bearer",
|
||
apiVersion: "",
|
||
temperature: null,
|
||
topP: null,
|
||
maxToken: null,
|
||
contextWindow: null,
|
||
models: [
|
||
{ label: "deepseek-v4-pro", value: "deepseek-v4-pro" },
|
||
{ label: "deepseek-v4-flash", value: "deepseek-v4-flash" },
|
||
],
|
||
},
|
||
{
|
||
label: "MiniMax",
|
||
provider: "minimax",
|
||
apiType: "openai_compatible",
|
||
baseUrl: "https://api.minimaxi.com/v1",
|
||
authType: "bearer",
|
||
apiVersion: "",
|
||
temperature: null,
|
||
topP: null,
|
||
maxToken: null,
|
||
contextWindow: null,
|
||
models: [
|
||
{ label: "MiniMax-Text-01", value: "MiniMax-Text-01" },
|
||
{ label: "MiniMax-M3", value: "MiniMax-M3" },
|
||
],
|
||
},
|
||
] as const;
|
||
|
||
const protocolPresets = [
|
||
{
|
||
label: "OpenAI 兼容协议",
|
||
provider: "openai-compatible",
|
||
apiType: "openai_compatible",
|
||
baseUrl: "https://api.openai.com/v1",
|
||
apiUrl: "",
|
||
authType: "bearer",
|
||
},
|
||
{
|
||
label: "Anthropic Messages 协议",
|
||
provider: "anthropic-compatible",
|
||
apiType: "anthropic_messages",
|
||
baseUrl: "https://api.anthropic.com",
|
||
apiUrl: "",
|
||
authType: "api_key",
|
||
apiVersion: "2023-06-01",
|
||
temperature: null,
|
||
topP: null,
|
||
topK: null,
|
||
},
|
||
];
|
||
|
||
const nullableModelFields = [
|
||
"temperature",
|
||
"topP",
|
||
"topK",
|
||
"presencePenalty",
|
||
"frequencyPenalty",
|
||
"contextWindow",
|
||
"responseFormat",
|
||
"extraParams",
|
||
"remark",
|
||
"inputPricePer1k",
|
||
"outputPricePer1k",
|
||
"usageScenarios",
|
||
] as const;
|
||
|
||
const systemSettingValues = reactive<Record<string, SystemSettingValue>>({});
|
||
systemSettingDefinitions.forEach((setting) => {
|
||
systemSettingValues[setting.key] = setting.defaultValue;
|
||
});
|
||
const systemSettingsDirty = computed(() =>
|
||
systemSettingDefinitions.some(
|
||
(setting) =>
|
||
serializeSystemSettingValue(systemSettingValues[setting.key]) !==
|
||
systemSettingsBaseline.value[setting.key],
|
||
),
|
||
);
|
||
|
||
onMounted(async () => {
|
||
if (!getToken()) return;
|
||
try {
|
||
admin.value = await api.profile();
|
||
passwordDialogVisible.value = admin.value.mustChangePassword;
|
||
if (!admin.value.mustChangePassword) await enterFirstAvailableMenu();
|
||
} catch {
|
||
clearToken();
|
||
}
|
||
});
|
||
|
||
async function login(credentials: { username: string; password: string }) {
|
||
loading.value = true;
|
||
try {
|
||
const result = await api.login(credentials.username, credentials.password);
|
||
saveToken(result.token);
|
||
admin.value = result.admin;
|
||
passwordDialogVisible.value = result.admin.mustChangePassword;
|
||
if (!result.admin.mustChangePassword) await enterFirstAvailableMenu();
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : "登录失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await api.logout();
|
||
} finally {
|
||
clearToken();
|
||
admin.value = null;
|
||
}
|
||
}
|
||
|
||
async function switchMenu(menu: string) {
|
||
if (!can(menuPermissions[menu])) return;
|
||
activeMenu.value = menu;
|
||
await loadCurrentMenu();
|
||
}
|
||
|
||
async function enterFirstAvailableMenu() {
|
||
const first = Object.keys(menuPermissions).find((menu) => can(menuPermissions[menu]));
|
||
activeMenu.value = first ?? "dashboard";
|
||
if (first) await loadCurrentMenu();
|
||
}
|
||
|
||
async function changeInitialPassword() {
|
||
if (passwordForm.newPassword.length < 8) return ElMessage.warning("新密码至少 8 位");
|
||
if (passwordForm.newPassword !== passwordForm.confirmPassword) return ElMessage.warning("两次输入的新密码不一致");
|
||
passwordSaving.value = true;
|
||
try {
|
||
await api.changeAdminPassword({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword });
|
||
if (admin.value) admin.value.mustChangePassword = false;
|
||
passwordDialogVisible.value = false;
|
||
Object.assign(passwordForm, { currentPassword: "", newPassword: "", confirmPassword: "" });
|
||
ElMessage.success("密码已修改,请使用新密码登录");
|
||
await logout();
|
||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "密码修改失败"); }
|
||
finally { passwordSaving.value = false; }
|
||
}
|
||
|
||
async function previewKnowledge(knowledgeId: number) {
|
||
previewKnowledgeId.value = knowledgeId;
|
||
await switchMenu("prompt");
|
||
}
|
||
|
||
function formatMoney(value?: number | null, currency = "CNY") {
|
||
if (typeof value !== "number") return "-";
|
||
if (currency === "MIXED") return "多币种,见明细";
|
||
return `${currency || "CNY"} ${value.toFixed(6)}`;
|
||
}
|
||
|
||
async function loadCurrentMenu() {
|
||
loading.value = true;
|
||
try {
|
||
if (activeMenu.value === "entitlements") await loadEntitlementPlans();
|
||
if (activeMenu.value === "models") models.value = await api.models();
|
||
if (activeMenu.value === "configs") {
|
||
configs.value = await api.configs();
|
||
hydrateSystemSettings();
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadEntitlementPlans() {
|
||
entitlementPlans.value = await api.entitlementPlans(true);
|
||
}
|
||
|
||
async function saveModel() {
|
||
if (!validateModelForm()) return;
|
||
const payload = buildModelPayload();
|
||
if (editingModelId.value) {
|
||
await api.updateModel(editingModelId.value, payload);
|
||
ElMessage.success("模型已更新");
|
||
} else {
|
||
await api.createModel(payload);
|
||
ElMessage.success("模型已新增");
|
||
}
|
||
resetModelForm();
|
||
await loadCurrentMenu();
|
||
}
|
||
|
||
function buildModelPayload() {
|
||
const payload = { ...modelForm } as Record<string, unknown>;
|
||
nullableModelFields.forEach((field) => {
|
||
if (payload[field] === undefined || payload[field] === "") {
|
||
payload[field] = null;
|
||
}
|
||
});
|
||
return payload;
|
||
}
|
||
|
||
const selectedQuickProvider = computed(() => {
|
||
return (
|
||
quickModelProviders.find(
|
||
(item) => item.provider === quickModelForm.provider,
|
||
) ?? quickModelProviders[0]
|
||
);
|
||
});
|
||
|
||
async function quickAddModel() {
|
||
const provider = selectedQuickProvider.value;
|
||
if (!provider) return;
|
||
if (!quickModelForm.apiKey) {
|
||
ElMessage.error("请填写 API Key");
|
||
return;
|
||
}
|
||
|
||
await api.createModel({
|
||
provider: provider.provider,
|
||
displayName: `${provider.label} ${quickModelForm.modelName}`,
|
||
apiType: provider.apiType,
|
||
modelName: quickModelForm.modelName,
|
||
baseUrl: provider.baseUrl,
|
||
apiUrl: "",
|
||
apiKey: quickModelForm.apiKey,
|
||
authType: provider.authType,
|
||
apiVersion: provider.apiVersion,
|
||
temperature: null,
|
||
topP: null,
|
||
topK: null,
|
||
presencePenalty: null,
|
||
frequencyPenalty: null,
|
||
maxToken: 8192,
|
||
contextWindow: null,
|
||
streamEnabled: 1,
|
||
responseFormat: null,
|
||
extraParams: null,
|
||
remark: "快速添加",
|
||
timeoutSecond: 30,
|
||
inputPricePer1k: null,
|
||
outputPricePer1k: null,
|
||
currency: "CNY",
|
||
usageScenarios: "正式对话",
|
||
allowSummary: 1,
|
||
allowReport: 1,
|
||
allowFixedInfo: 1,
|
||
allowSimpleKnowledge: 1,
|
||
allowDeepChat: 1,
|
||
});
|
||
ElMessage.success(`${provider.label} 模型已新增`);
|
||
quickModelForm.apiKey = "";
|
||
await loadCurrentMenu();
|
||
}
|
||
|
||
function applyQuickProvider(provider: string) {
|
||
const target = quickModelProviders.find((item) => item.provider === provider);
|
||
if (!target) return;
|
||
quickModelForm.modelName = target.models[0]?.value ?? "";
|
||
}
|
||
|
||
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("确认删除该模型?删除后不可恢复。", "删除模型", {
|
||
confirmButtonText: "确认删除",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
await api.deleteModel(id);
|
||
ElMessage.success("模型已删除");
|
||
await loadCurrentMenu();
|
||
}
|
||
|
||
async function testModel(id: number) {
|
||
const result = await api.testModel(id);
|
||
if (result.ok) {
|
||
ElMessage.success(result.message);
|
||
} else {
|
||
ElMessage.error(result.message);
|
||
}
|
||
}
|
||
|
||
function applyModelPreset(label: string) {
|
||
const preset = protocolPresets.find((item) => item.label === label);
|
||
if (!preset) return;
|
||
Object.assign(
|
||
modelForm,
|
||
{
|
||
apiVersion: "",
|
||
authType: "bearer",
|
||
temperature: null,
|
||
topP: null,
|
||
topK: null,
|
||
presencePenalty: null,
|
||
frequencyPenalty: null,
|
||
responseFormat: "",
|
||
streamEnabled: 1,
|
||
},
|
||
preset,
|
||
);
|
||
}
|
||
|
||
function editModel(row: ModelItem) {
|
||
editingModelId.value = row.id;
|
||
Object.assign(modelForm, {
|
||
provider: row.provider,
|
||
displayName: row.displayName ?? "",
|
||
apiType: row.apiType,
|
||
modelName: row.modelName,
|
||
baseUrl: row.baseUrl ?? "",
|
||
apiUrl: row.apiUrl,
|
||
apiKey: "",
|
||
authType: row.authType,
|
||
apiVersion: row.apiVersion ?? "",
|
||
temperature: row.temperature ?? null,
|
||
topP: row.topP ?? null,
|
||
topK: row.topK ?? null,
|
||
presencePenalty: row.presencePenalty ?? null,
|
||
frequencyPenalty: row.frequencyPenalty ?? null,
|
||
maxToken: row.maxToken ?? 8192,
|
||
contextWindow: row.contextWindow ?? null,
|
||
streamEnabled: row.streamEnabled,
|
||
responseFormat: row.responseFormat ?? "",
|
||
extraParams: row.extraParams ?? "",
|
||
remark: row.remark ?? "",
|
||
timeoutSecond: row.timeoutSecond,
|
||
inputPricePer1k: row.inputPricePer1k ?? null,
|
||
outputPricePer1k: row.outputPricePer1k ?? null,
|
||
currency: row.currency || "CNY",
|
||
usageScenarios: row.usageScenarios ?? "",
|
||
allowSummary: row.allowSummary ?? 1,
|
||
allowReport: row.allowReport ?? 1,
|
||
allowFixedInfo: row.allowFixedInfo ?? 1,
|
||
allowSimpleKnowledge: row.allowSimpleKnowledge ?? 1,
|
||
allowDeepChat: row.allowDeepChat ?? 1,
|
||
});
|
||
}
|
||
|
||
function resetModelForm() {
|
||
editingModelId.value = null;
|
||
Object.assign(modelForm, {
|
||
provider: "openai-compatible",
|
||
displayName: "",
|
||
apiType: "openai_compatible",
|
||
modelName: "",
|
||
baseUrl: "https://api.openai.com/v1",
|
||
apiUrl: "",
|
||
apiKey: "",
|
||
authType: "bearer",
|
||
apiVersion: "",
|
||
temperature: null,
|
||
topP: null,
|
||
topK: null,
|
||
presencePenalty: null,
|
||
frequencyPenalty: null,
|
||
maxToken: 8192,
|
||
contextWindow: null,
|
||
streamEnabled: 1,
|
||
responseFormat: "",
|
||
extraParams: "",
|
||
remark: "",
|
||
timeoutSecond: 30,
|
||
inputPricePer1k: null,
|
||
outputPricePer1k: null,
|
||
currency: "CNY",
|
||
usageScenarios: "",
|
||
allowSummary: 1,
|
||
allowReport: 1,
|
||
allowFixedInfo: 1,
|
||
allowSimpleKnowledge: 1,
|
||
allowDeepChat: 1,
|
||
});
|
||
}
|
||
|
||
function validateModelForm() {
|
||
if (!modelForm.apiUrl && !modelForm.baseUrl) {
|
||
ElMessage.error("请填写 Base URL 或 API URL");
|
||
return false;
|
||
}
|
||
if (!editingModelId.value && !modelForm.apiKey) {
|
||
ElMessage.error("新增模型必须填写 API Key");
|
||
return false;
|
||
}
|
||
if (modelForm.extraParams.trim()) {
|
||
try {
|
||
const extraParams = JSON.parse(modelForm.extraParams);
|
||
if (
|
||
!extraParams ||
|
||
Array.isArray(extraParams) ||
|
||
typeof extraParams !== "object"
|
||
) {
|
||
throw new Error("高级 JSON 参数必须是对象");
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(
|
||
error instanceof Error ? error.message : "高级 JSON 参数不是合法 JSON",
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function saveSystemSettings() {
|
||
if (savingSystemSettings.value || !systemSettingsDirty.value) return;
|
||
savingSystemSettings.value = true;
|
||
try {
|
||
await Promise.all(
|
||
systemSettingDefinitions.map((setting) =>
|
||
api.saveConfig({
|
||
configKey: setting.key,
|
||
configValue: serializeSystemSettingValue(
|
||
systemSettingValues[setting.key],
|
||
),
|
||
description: setting.description,
|
||
}),
|
||
),
|
||
);
|
||
configs.value = await api.configs();
|
||
hydrateSystemSettings();
|
||
ElMessage.success("系统配置已保存");
|
||
} catch (error) {
|
||
ElMessage.error(
|
||
error instanceof Error ? error.message : "系统配置保存失败,请重试",
|
||
);
|
||
} finally {
|
||
savingSystemSettings.value = false;
|
||
}
|
||
}
|
||
|
||
function hydrateSystemSettings() {
|
||
const configMap = new Map(
|
||
configs.value.map((item) => [item.configKey, item.configValue]),
|
||
);
|
||
systemSettingDefinitions.forEach((setting) => {
|
||
systemSettingValues[setting.key] = normalizeSystemSettingValue(
|
||
setting,
|
||
configMap.get(setting.key),
|
||
);
|
||
});
|
||
systemSettingsBaseline.value = Object.fromEntries(
|
||
systemSettingDefinitions.map((setting) => [
|
||
setting.key,
|
||
serializeSystemSettingValue(systemSettingValues[setting.key]),
|
||
]),
|
||
);
|
||
}
|
||
|
||
function updateSystemSetting(key: string, value: SystemSettingValue) {
|
||
if (systemSettingValues[key] === value) return;
|
||
systemSettingValues[key] = value;
|
||
}
|
||
|
||
function normalizeSystemSettingValue(
|
||
setting: (typeof systemSettingDefinitions)[number],
|
||
rawValue?: string,
|
||
) {
|
||
if (rawValue === undefined || rawValue === "") {
|
||
return setting.defaultValue;
|
||
}
|
||
if (setting.type === "switch") {
|
||
return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase());
|
||
}
|
||
if (
|
||
setting.type === "text" ||
|
||
setting.type === "password" ||
|
||
setting.type === "select"
|
||
) {
|
||
return rawValue;
|
||
}
|
||
const parsed = Number(rawValue);
|
||
if (Number.isFinite(parsed)) {
|
||
return parsed;
|
||
}
|
||
return setting.defaultValue;
|
||
}
|
||
|
||
function serializeSystemSettingValue(value: SystemSettingValue) {
|
||
if (typeof value === "boolean") return value ? "true" : "false";
|
||
return String(value);
|
||
}
|
||
|
||
async function clearFeishuCache() {
|
||
try {
|
||
const result = await api.clearFeishuCache();
|
||
ElMessage.success(result.message || `已清空 ${result.cleared} 条缓存`);
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message || "刷新缓存失败");
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<AdminLoginView v-if="!admin" :loading="loading" @submit="login" />
|
||
|
||
<main v-else class="admin-shell">
|
||
<aside class="sidebar">
|
||
<div class="sidebar-title">大本营千问千答</div>
|
||
<button
|
||
v-if="can('dashboard.view')"
|
||
:class="{ active: activeMenu === 'dashboard' }"
|
||
@click="switchMenu('dashboard')"
|
||
>
|
||
数据看板
|
||
</button>
|
||
<button
|
||
v-if="can('users.view')"
|
||
:class="{ active: activeMenu === 'users' }"
|
||
@click="switchMenu('users')"
|
||
>
|
||
用户管理
|
||
</button>
|
||
<button
|
||
v-if="can('entitlements.view')"
|
||
:class="{ active: activeMenu === 'entitlements' }"
|
||
@click="switchMenu('entitlements')"
|
||
>
|
||
权益管理
|
||
</button>
|
||
<button
|
||
v-if="can('knowledge.view')"
|
||
:class="{ active: activeMenu === 'knowledge' }"
|
||
@click="switchMenu('knowledge')"
|
||
>
|
||
知识库管理
|
||
</button>
|
||
<button
|
||
v-if="can('prompt.view')"
|
||
:class="{ active: activeMenu === 'prompt' }"
|
||
@click="switchMenu('prompt')"
|
||
>
|
||
Agent 管理
|
||
</button>
|
||
<button
|
||
v-if="can('models.view')"
|
||
:class="{ active: activeMenu === 'models' }"
|
||
@click="switchMenu('models')"
|
||
>
|
||
模型管理
|
||
</button>
|
||
<button
|
||
v-if="can('content-generation.view')"
|
||
:class="{ active: activeMenu === 'content-generation' }"
|
||
@click="switchMenu('content-generation')"
|
||
>
|
||
内容生成
|
||
</button>
|
||
<button
|
||
v-if="can('configs.view')"
|
||
:class="{ active: activeMenu === 'configs' }"
|
||
@click="switchMenu('configs')"
|
||
>
|
||
系统配置
|
||
</button>
|
||
<button
|
||
v-if="can('sso.view')"
|
||
:class="{ active: activeMenu === 'sso' }"
|
||
@click="switchMenu('sso')"
|
||
>
|
||
应用接入
|
||
</button>
|
||
<button
|
||
v-if="can('records.view')"
|
||
:class="{ active: activeMenu === 'records' }"
|
||
@click="switchMenu('records')"
|
||
>
|
||
记录审计
|
||
</button>
|
||
<button
|
||
v-if="can('retrievals.view')"
|
||
:class="{ active: activeMenu === 'retrievals' }"
|
||
@click="switchMenu('retrievals')"
|
||
>
|
||
检索日志
|
||
</button>
|
||
<button
|
||
v-if="can('feedback.view')"
|
||
:class="{ active: activeMenu === 'feedback' }"
|
||
@click="switchMenu('feedback')"
|
||
>反馈管理</button>
|
||
<button
|
||
v-if="can('behavior.view')"
|
||
:class="{ active: activeMenu === 'behavior' }"
|
||
@click="switchMenu('behavior')"
|
||
>用户行为分析</button>
|
||
<button
|
||
v-if="can('attention.view')"
|
||
:class="{ active: activeMenu === 'attention' }"
|
||
@click="switchMenu('attention')"
|
||
>
|
||
人工关注
|
||
</button>
|
||
<button
|
||
v-if="admin.isSuperAdmin"
|
||
:class="{ active: activeMenu === 'admins' }"
|
||
@click="switchMenu('admins')"
|
||
>管理员与权限</button>
|
||
</aside>
|
||
|
||
<section class="workspace">
|
||
<header class="topbar">
|
||
<div>
|
||
<strong>{{ admin.name }}</strong>
|
||
<span>管理员</span>
|
||
</div>
|
||
<el-button @click="logout">退出</el-button>
|
||
</header>
|
||
|
||
<section v-loading="loading" class="content">
|
||
<DashboardView v-if="activeMenu === 'dashboard'" />
|
||
|
||
<UserManagementView v-if="activeMenu === 'users'" />
|
||
|
||
<template v-if="activeMenu === 'entitlements'">
|
||
<EntitlementManagementView
|
||
:plans="entitlementPlans"
|
||
@refreshed="loadEntitlementPlans"
|
||
/>
|
||
</template>
|
||
|
||
<template v-if="activeMenu === 'knowledge'">
|
||
<KnowledgeManagementView @preview="previewKnowledge" />
|
||
</template>
|
||
|
||
<template v-if="activeMenu === 'prompt'">
|
||
<AgentManagementView
|
||
:preview-knowledge-id="previewKnowledgeId"
|
||
:can-batch="can('prompt.batch')"
|
||
@consumed-preview="previewKnowledgeId = null"
|
||
/>
|
||
</template>
|
||
|
||
<template v-if="activeMenu === 'models'">
|
||
<div class="page-head inline">
|
||
<div>
|
||
<h2>模型管理</h2>
|
||
<p>
|
||
可同时启用多个模型,但只保留一个默认主模型;正式聊天始终使用主模型,报告和摘要按能力标签分流并自动回退主模型。
|
||
</p>
|
||
</div>
|
||
<el-button @click="resetModelForm">清空表单</el-button>
|
||
</div>
|
||
|
||
<section class="quick-model-panel">
|
||
<div class="quick-model-head">
|
||
<div>
|
||
<h3>快速添加</h3>
|
||
<p>
|
||
适用于 DeepSeek 和 MiniMax,只需要填写 API Key 并选择模型。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="quick-model-grid">
|
||
<el-form-item label="厂商">
|
||
<el-select
|
||
v-model="quickModelForm.provider"
|
||
@change="applyQuickProvider"
|
||
>
|
||
<el-option
|
||
v-for="provider in quickModelProviders"
|
||
:key="provider.provider"
|
||
:label="provider.label"
|
||
:value="provider.provider"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="模型">
|
||
<el-select
|
||
v-model="quickModelForm.modelName"
|
||
:key="quickModelForm.provider"
|
||
>
|
||
<el-option
|
||
v-for="model in selectedQuickProvider.models"
|
||
:key="model.value"
|
||
:label="model.label"
|
||
:value="model.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="API Key">
|
||
<el-input
|
||
v-model="quickModelForm.apiKey"
|
||
placeholder="填写后保存,列表中只展示掩码"
|
||
show-password
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label=" ">
|
||
<el-button
|
||
type="primary"
|
||
class="full-btn"
|
||
@click="quickAddModel"
|
||
>快速新增</el-button
|
||
>
|
||
</el-form-item>
|
||
</div>
|
||
</section>
|
||
|
||
<el-form
|
||
class="model-config-panel"
|
||
label-position="top"
|
||
:model="modelForm"
|
||
>
|
||
<div class="model-config-grid">
|
||
<el-form-item label="协议模板">
|
||
<el-select
|
||
placeholder="选择协议模板"
|
||
clearable
|
||
@change="applyModelPreset"
|
||
>
|
||
<el-option
|
||
v-for="preset in protocolPresets"
|
||
:key="preset.label"
|
||
:label="preset.label"
|
||
:value="preset.label"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="供应商标识">
|
||
<el-input
|
||
v-model="modelForm.provider"
|
||
placeholder="custom-openai / custom-anthropic"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="显示名称">
|
||
<el-input
|
||
v-model="modelForm.displayName"
|
||
placeholder="例如:自建网关生产模型"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="协议类型">
|
||
<el-select v-model="modelForm.apiType">
|
||
<el-option label="OpenAI 兼容" value="openai_compatible" />
|
||
<el-option
|
||
label="Anthropic Messages"
|
||
value="anthropic_messages"
|
||
/>
|
||
<el-option label="MiniMax 官方" value="minimax" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="模型名">
|
||
<el-input
|
||
v-model="modelForm.modelName"
|
||
placeholder="gpt-4.1 / deepseek-chat / claude..."
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="Base URL">
|
||
<el-input
|
||
v-model="modelForm.baseUrl"
|
||
placeholder="https://api.example.com/v1"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="API URL(可覆盖)">
|
||
<el-input
|
||
v-model="modelForm.apiUrl"
|
||
placeholder="留空则按协议自动拼接"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="鉴权方式">
|
||
<el-select v-model="modelForm.authType">
|
||
<el-option label="Bearer Token" value="bearer" />
|
||
<el-option label="x-api-key" value="api_key" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="API Key">
|
||
<el-input
|
||
v-model="modelForm.apiKey"
|
||
placeholder="保存时会覆盖旧 Key"
|
||
show-password
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="API Version">
|
||
<el-input
|
||
v-model="modelForm.apiVersion"
|
||
placeholder="Anthropic 默认 2023-06-01"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="超时秒数">
|
||
<el-input-number
|
||
v-model="modelForm.timeoutSecond"
|
||
:min="1"
|
||
:max="300"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="输入单价/千Token">
|
||
<el-input-number
|
||
v-model="modelForm.inputPricePer1k"
|
||
:min="0"
|
||
:precision="6"
|
||
:step="0.001"
|
||
placeholder="不填则不估算成本"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="输出单价/千Token">
|
||
<el-input-number
|
||
v-model="modelForm.outputPricePer1k"
|
||
:min="0"
|
||
:precision="6"
|
||
:step="0.001"
|
||
placeholder="不填则不估算成本"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="币种">
|
||
<el-input
|
||
v-model="modelForm.currency"
|
||
placeholder="CNY / USD"
|
||
maxlength="10"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="适用场景">
|
||
<el-input
|
||
v-model="modelForm.usageScenarios"
|
||
placeholder="例如:正式对话、摘要、固定信息、报告批处理"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="可用能力">
|
||
<div>
|
||
<div class="entitlement-capabilities">
|
||
<el-checkbox
|
||
v-model="modelForm.allowFixedInfo"
|
||
:true-value="1"
|
||
:false-value="0"
|
||
>固定信息</el-checkbox
|
||
>
|
||
<el-checkbox
|
||
v-model="modelForm.allowSimpleKnowledge"
|
||
:true-value="1"
|
||
:false-value="0"
|
||
>简单知识</el-checkbox
|
||
>
|
||
<el-checkbox
|
||
v-model="modelForm.allowDeepChat"
|
||
:true-value="1"
|
||
:false-value="0"
|
||
>深度对话</el-checkbox
|
||
>
|
||
<el-checkbox
|
||
v-model="modelForm.allowSummary"
|
||
:true-value="1"
|
||
:false-value="0"
|
||
>摘要沉淀</el-checkbox
|
||
>
|
||
<el-checkbox
|
||
v-model="modelForm.allowReport"
|
||
:true-value="1"
|
||
:false-value="0"
|
||
>周期报告</el-checkbox
|
||
>
|
||
</div>
|
||
<small class="model-routing-help"
|
||
>默认主模型勾选某项能力时会优先承担该场景;如需专用模型接管,请取消主模型的对应能力,并为专用模型开启该能力。</small
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="备注">
|
||
<el-input
|
||
v-model="modelForm.remark"
|
||
placeholder="用途、额度、注意事项等"
|
||
/>
|
||
</el-form-item>
|
||
<div class="actions">
|
||
<el-button type="primary" @click="saveModel">{{
|
||
editingModelId ? "保存模型" : "新增模型"
|
||
}}</el-button>
|
||
<el-button @click="resetModelForm">取消编辑</el-button>
|
||
</div>
|
||
</el-form>
|
||
<el-table :data="models" stripe>
|
||
<el-table-column prop="displayName" label="显示名称" width="170" />
|
||
<el-table-column prop="provider" label="Provider" />
|
||
<el-table-column prop="apiType" label="协议" width="170" />
|
||
<el-table-column prop="modelName" label="模型" />
|
||
<el-table-column label="估算单价" width="180">
|
||
<template #default="{ row }"
|
||
>{{ row.currency || "CNY" }} {{ row.inputPricePer1k ?? "-" }}/{{
|
||
row.outputPricePer1k ?? "-"
|
||
}}</template
|
||
>
|
||
</el-table-column>
|
||
<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.allowSimpleKnowledge === 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 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) => 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>
|
||
</template>
|
||
|
||
<ContentGenerationConfigView
|
||
v-if="activeMenu === 'content-generation'"
|
||
/>
|
||
|
||
<SsoIntegrationView v-if="activeMenu === 'sso'" />
|
||
|
||
<SystemConfigView
|
||
v-if="activeMenu === 'configs'"
|
||
:values="systemSettingValues"
|
||
:saving="savingSystemSettings"
|
||
:dirty="systemSettingsDirty"
|
||
@update="updateSystemSetting"
|
||
@save="saveSystemSettings"
|
||
/>
|
||
|
||
<RetrievalLogView v-if="activeMenu === 'retrievals'" />
|
||
<AttentionManagementView v-if="activeMenu === 'attention'" />
|
||
<FeedbackManagementView
|
||
v-if="activeMenu === 'feedback'"
|
||
:can-delete="can('feedback.delete')"
|
||
:can-detail="can('feedback.detail')"
|
||
:can-export="can('feedback.export')"
|
||
/>
|
||
<UserBehaviorAnalysisView v-if="activeMenu === 'behavior'" />
|
||
<RecordAuditView v-if="activeMenu === 'records'" />
|
||
<AdminManagementView v-if="activeMenu === 'admins'" />
|
||
</section>
|
||
</section>
|
||
</main>
|
||
|
||
<el-dialog v-model="passwordDialogVisible" title="首次登录,请修改初始密码" width="460px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||
<el-alert title="为保障账号安全,修改密码后需要重新登录。" type="warning" :closable="false" show-icon />
|
||
<el-form label-position="top" class="password-form">
|
||
<el-form-item label="当前初始密码"><el-input v-model="passwordForm.currentPassword" type="password" show-password /></el-form-item>
|
||
<el-form-item label="新密码"><el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="至少 8 位" /></el-form-item>
|
||
<el-form-item label="确认新密码"><el-input v-model="passwordForm.confirmPassword" type="password" show-password /></el-form-item>
|
||
</el-form>
|
||
<template #footer><el-button @click="logout">退出登录</el-button><el-button type="primary" :loading="passwordSaving" @click="changeInitialPassword">确认修改</el-button></template>
|
||
</el-dialog>
|
||
</template>
|