feat: 增加内容生成配置管理

This commit is contained in:
2026-08-03 17:23:42 +08:00
parent 3f8ff30bbc
commit 8ee4d65b7b
17 changed files with 1348 additions and 43 deletions

View File

@@ -23,6 +23,9 @@ const AgentManagementView = defineAsyncComponent(
const AttentionManagementView = defineAsyncComponent(
() => import("./components/AttentionManagementView.vue"),
);
const ContentGenerationConfigView = defineAsyncComponent(
() => import("./components/ContentGenerationConfigView.vue"),
);
const DashboardView = defineAsyncComponent(
() => import("./components/DashboardView.vue"),
);
@@ -593,6 +596,12 @@ async function clearFeishuCache() {
>
模型管理
</button>
<button
:class="{ active: activeMenu === 'content-generation' }"
@click="switchMenu('content-generation')"
>
内容生成
</button>
<button
:class="{ active: activeMenu === 'configs' }"
@click="switchMenu('configs')"
@@ -990,6 +999,10 @@ async function clearFeishuCache() {
</el-table>
</template>
<ContentGenerationConfigView
v-if="activeMenu === 'content-generation'"
/>
<template v-if="activeMenu === 'configs'">
<div class="page-head inline">
<div>

View File

@@ -0,0 +1,400 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import type { InputInstance } from "element-plus";
import { computed, nextTick, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type {
ContentGenerationConfigDetail,
ContentGenerationHistoryItem,
ContentGenerationType,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
const activeType = ref<ContentGenerationType>("help_card");
const loading = ref(false);
const saving = ref(false);
const previewing = ref(false);
const testing = ref(false);
const detailLoading = ref(false);
const current = ref<ContentGenerationConfigDetail | null>(null);
const history = ref<ContentGenerationHistoryItem[]>([]);
const historyTotal = ref(0);
const historyPage = ref(1);
const historyPageSize = ref(10);
const previewContent = ref("");
const testUsedFallback = ref(false);
const selectedHistory = ref<ContentGenerationConfigDetail | null>(null);
const historyDialogOpen = ref(false);
const templateInputRef = ref<InputInstance>();
const form = reactive({ templateContent: "", instructionContent: "" });
const saved = reactive({ templateContent: "", instructionContent: "" });
const sampleText = ref(
"我第一次参加带练,不太确定练习顺序。做到一半身体有些紧,我会担心自己是不是做错了,想请老师确认什么时候应该暂停。",
);
const dirty = computed(
() => form.templateContent !== saved.templateContent || form.instructionContent !== saved.instructionContent,
);
const typeLabel = computed(() => activeType.value === "help_card" ? "老师求助卡" : "班级分享稿");
onMounted(loadAll);
async function loadAll() {
loading.value = true;
try {
const [config, page] = await Promise.all([
api.contentGenerationConfig(activeType.value),
api.contentGenerationHistory(activeType.value, { page: historyPage.value, pageSize: historyPageSize.value }),
]);
applyConfig(config);
history.value = page.items;
historyTotal.value = page.total;
await refreshPreview();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "内容生成配置加载失败");
} finally {
loading.value = false;
}
}
function applyConfig(config: ContentGenerationConfigDetail) {
current.value = config;
form.templateContent = config.templateContent;
form.instructionContent = config.instructionContent;
saved.templateContent = config.templateContent;
saved.instructionContent = config.instructionContent;
}
async function switchType(type: ContentGenerationType) {
if (type === activeType.value) return;
if (dirty.value) {
try {
await ElMessageBox.confirm("当前修改尚未保存,切换后将丢失。确定继续吗?", "切换配置", {
confirmButtonText: "继续切换",
cancelButtonText: "留在当前页",
type: "warning",
});
} catch {
return;
}
}
activeType.value = type;
historyPage.value = 1;
previewContent.value = "";
testUsedFallback.value = false;
await loadAll();
}
async function insertVariable(name: string) {
const token = `{{${name}}}`;
const textarea = templateInputRef.value?.textarea;
if (!textarea) {
form.templateContent = `${form.templateContent}${form.templateContent.endsWith("\n") ? "" : "\n"}${token}`;
return;
}
const start = textarea.selectionStart ?? form.templateContent.length;
const end = textarea.selectionEnd ?? start;
form.templateContent = `${form.templateContent.slice(0, start)}${token}${form.templateContent.slice(end)}`;
await nextTick();
textarea.focus();
textarea.setSelectionRange(start + token.length, start + token.length);
}
function variableToken(name: string) {
return `{{${name}}}`;
}
async function refreshPreview() {
previewing.value = true;
try {
const result = await api.previewContentGeneration({
configType: activeType.value,
templateContent: form.templateContent,
});
previewContent.value = result.content;
testUsedFallback.value = false;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "预览生成失败");
} finally {
previewing.value = false;
}
}
async function testGeneration() {
testing.value = true;
try {
const result = await api.testContentGeneration({
configType: activeType.value,
templateContent: form.templateContent,
instructionContent: form.instructionContent,
sampleText: sampleText.value,
});
previewContent.value = result.content;
testUsedFallback.value = result.usedFallback;
ElMessage.success(result.usedFallback ? "模型结果不可用,已展示安全回退结果" : "AI 整理测试完成");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "AI 整理测试失败");
} finally {
testing.value = false;
}
}
async function saveConfig() {
if (!dirty.value || saving.value) return;
saving.value = true;
try {
const config = await api.saveContentGenerationConfig(activeType.value, {
templateContent: form.templateContent,
instructionContent: form.instructionContent,
});
applyConfig(config);
ElMessage.success("已保存并立即发布为新版本");
historyPage.value = 1;
await loadHistory();
await refreshPreview();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "配置保存失败");
} finally {
saving.value = false;
}
}
async function resetConfig() {
try {
await ElMessageBox.confirm(
`确定将${typeLabel.value}恢复为系统默认配置吗?系统会保留当前版本,可随时回滚。`,
"恢复默认配置",
{ confirmButtonText: "恢复默认", cancelButtonText: "取消", type: "warning" },
);
saving.value = true;
const config = await api.resetContentGenerationConfig(activeType.value);
applyConfig(config);
historyPage.value = 1;
await loadHistory();
await refreshPreview();
ElMessage.success("已恢复默认配置");
} catch (error) {
if (error instanceof Error) ElMessage.error(error.message);
} finally {
saving.value = false;
}
}
async function loadHistory() {
const page = await api.contentGenerationHistory(activeType.value, {
page: historyPage.value,
pageSize: historyPageSize.value,
});
history.value = page.items;
historyTotal.value = page.total;
}
async function changeHistoryPagination(page: number, pageSize: number) {
historyPage.value = page;
historyPageSize.value = pageSize;
await loadHistory();
}
async function openHistory(item: ContentGenerationHistoryItem) {
historyDialogOpen.value = true;
detailLoading.value = true;
selectedHistory.value = null;
try {
selectedHistory.value = await api.contentGenerationHistoryDetail(activeType.value, item.id);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "历史版本加载失败");
historyDialogOpen.value = false;
} finally {
detailLoading.value = false;
}
}
async function restoreHistory(id: number) {
try {
await ElMessageBox.confirm("确定回滚到这个版本吗?回滚会生成一个新版本,不会覆盖历史记录。", "回滚配置", {
confirmButtonText: "确认回滚",
cancelButtonText: "取消",
type: "warning",
});
saving.value = true;
const config = await api.restoreContentGenerationConfig(activeType.value, id);
applyConfig(config);
historyDialogOpen.value = false;
historyPage.value = 1;
await loadHistory();
await refreshPreview();
ElMessage.success("已回滚并发布为新版本");
} catch (error) {
if (error instanceof Error) ElMessage.error(error.message);
} finally {
saving.value = false;
}
}
function formatTime(value?: string | null) {
if (!value) return "系统默认";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function changeTypeLabel(value: string) {
return { save: "保存", reset: "恢复默认", restore: "历史回滚", default: "系统默认" }[value] || value;
}
</script>
<template>
<div v-loading="loading" class="content-generation-page">
<div class="page-head inline">
<div>
<h2>内容生成配置</h2>
<p>管理求助卡和分享稿的模板与 AI 整理规则每次保存立即生效并自动保留可回滚版本</p>
</div>
<div class="config-head-actions">
<el-button :loading="previewing" @click="refreshPreview">刷新预览</el-button>
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="saveConfig">保存并发布新版本</el-button>
</div>
</div>
<nav class="content-type-tabs" aria-label="内容生成类型">
<button type="button" :class="{ active: activeType === 'help_card' }" @click="switchType('help_card')">
<strong>老师求助卡</strong><span>给老师确认方向不会自动转人工</span>
</button>
<button type="button" :class="{ active: activeType === 'share_draft' }" @click="switchType('share_draft')">
<strong>班级分享稿</strong><span>用户自行核对复制和分享</span>
</button>
</nav>
<section class="generation-editor-layout">
<div class="generation-editor-column">
<article class="generation-panel">
<header>
<div><h3>卡片模板</h3><p>点击变量可添加到模板未知变量或缺少必要变量时不能发布</p></div>
<el-button text :disabled="saving" @click="resetConfig">恢复系统默认</el-button>
</header>
<div class="variable-list">
<button v-for="item in current?.variables || []" :key="item.name" type="button" @click="insertVariable(item.name)">
{{ item.label }} <code>{{ variableToken(item.name) }}</code>
</button>
</div>
<el-input ref="templateInputRef" v-model="form.templateContent" type="textarea" :rows="18" resize="vertical" />
<small>{{ form.templateContent.length }}/20000 字符</small>
</article>
<article class="generation-panel">
<header><div><h3>AI 整理规则</h3><p>只控制如何提炼本次材料不会修改 Agent 主提示词和知识库检索规则</p></div></header>
<el-input v-model="form.instructionContent" type="textarea" :rows="7" resize="vertical" />
<small>{{ form.instructionContent.length }}/10000 字符</small>
</article>
<article class="locked-rules">
<strong>系统锁定的安全提醒</strong>
<p>{{ current?.lockedFooter }}</p>
<small>这段内容会由系统固定追加管理员不能删除避免卡片被误解为已经转人工或自动发送</small>
</article>
</div>
<aside class="generation-preview-column">
<article class="generation-panel preview-panel">
<header><div><h3>实际效果预览</h3><p>预览始终包含系统锁定的安全提醒</p></div></header>
<pre>{{ previewContent || "点击“刷新预览”查看效果" }}</pre>
<el-alert v-if="testUsedFallback" title="本次测试使用了安全回退结果,模型未返回有效结构化内容。" type="warning" :closable="false" show-icon />
</article>
<article class="generation-panel test-panel">
<header><div><h3>AI 整理测试</h3><p>填写一段模拟用户表达测试当前规则和模板不会保存为正式卡片</p></div></header>
<el-input v-model="sampleText" type="textarea" :rows="7" resize="vertical" maxlength="20000" show-word-limit />
<el-button type="primary" plain :loading="testing" @click="testGeneration">测试 AI 整理效果</el-button>
</article>
</aside>
</section>
<section class="generation-history generation-panel">
<header>
<div><h3>版本记录</h3><p>当前配置{{ current?.updatedByName }} · {{ formatTime(current?.updatedAt) }}</p></div>
<el-tag v-if="current" type="success">{{ changeTypeLabel(current.changeType) }}</el-tag>
</header>
<el-empty v-if="!history.length" description="尚未保存过版本,当前使用系统默认配置" :image-size="72" />
<div v-else class="generation-history-list">
<article v-for="item in history" :key="item.id" :class="{ current: item.isCurrent }">
<div>
<strong>版本 #{{ item.id }} <el-tag v-if="item.isCurrent" size="small" type="success">当前</el-tag></strong>
<p>{{ item.preview }}</p>
<small>{{ changeTypeLabel(item.changeType) }} · {{ item.updatedByName }} · {{ formatTime(item.updatedAt) }}</small>
</div>
<el-button @click="openHistory(item)">查看</el-button>
</article>
</div>
<AdminPagination
v-if="historyTotal > 0"
:page="historyPage"
:page-size="historyPageSize"
:total="historyTotal"
@change="changeHistoryPagination"
/>
</section>
<el-dialog v-model="historyDialogOpen" title="内容生成配置历史版本" width="min(760px, 92vw)" append-to-body>
<div v-loading="detailLoading" class="generation-history-detail">
<template v-if="selectedHistory">
<dl>
<div><dt>版本</dt><dd>#{{ selectedHistory.id }}</dd></div>
<div><dt>操作</dt><dd>{{ changeTypeLabel(selectedHistory.changeType) }}</dd></div>
<div><dt>操作人</dt><dd>{{ selectedHistory.updatedByName }}</dd></div>
<div><dt>时间</dt><dd>{{ formatTime(selectedHistory.updatedAt) }}</dd></div>
</dl>
<h4>卡片模板</h4><pre>{{ selectedHistory.templateContent }}</pre>
<h4>AI 整理规则</h4><pre>{{ selectedHistory.instructionContent }}</pre>
</template>
</div>
<template #footer>
<el-button @click="historyDialogOpen = false">关闭</el-button>
<el-button v-if="selectedHistory" type="primary" :loading="saving" @click="restoreHistory(selectedHistory.id!)">回滚到此版本</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.content-generation-page { display: grid; gap: 18px; }
.config-head-actions { display: flex; gap: 10px; }
.content-type-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.content-type-tabs button { min-height: 78px; display: grid; gap: 5px; padding: 15px 18px; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; color: #65746f; text-align: left; transition: border-color .18s ease, background .18s ease, transform .18s ease; }
.content-type-tabs button:hover { border-color: #91c8b5; transform: translateY(-1px); }
.content-type-tabs button.active { border-color: #43a783; background: #eff8f4; color: #176c51; box-shadow: 0 8px 24px rgba(23, 108, 81, .08); }
.content-type-tabs strong { font-size: 16px; }
.content-type-tabs span { font-size: 12px; }
.generation-editor-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(360px, .85fr); gap: 16px; align-items: start; }
.generation-editor-column, .generation-preview-column { min-width: 0; display: grid; gap: 16px; }
.generation-panel, .locked-rules { padding: 18px; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; }
.generation-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.generation-panel h3 { margin: 0; color: #1d342d; font-size: 17px; }
.generation-panel header p { margin: 5px 0 0; color: #75847f; font-size: 12px; line-height: 1.6; }
.generation-panel > small { display: block; margin-top: 7px; color: #8a9893; font-size: 11px; text-align: right; }
.variable-list { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; }
.variable-list button { padding: 6px 9px; border: 1px solid #d8e6e0; border-radius: 9px; background: #f7faf9; color: #49665c; font-size: 12px; }
.variable-list button:hover { border-color: #62b596; background: #edf7f3; }
.variable-list code { margin-left: 3px; color: #17805f; font-size: 11px; }
.locked-rules { border-color: #e9dfbd; background: #fffaf0; }
.locked-rules strong { color: #705d2e; }
.locked-rules p { margin: 9px 0 4px; color: #655b43; font-size: 13px; line-height: 1.75; white-space: pre-wrap; }
.locked-rules small { color: #8c805f; font-size: 11px; }
.preview-panel { position: sticky; top: 18px; }
.preview-panel pre, .generation-history-detail pre { max-height: 520px; overflow: auto; margin: 0; padding: 16px; border: 1px solid #e3ebe8; border-radius: 13px; background: #f8fbfa; color: #324b43; font: inherit; font-size: 13px; line-height: 1.8; white-space: pre-wrap; }
.preview-panel .el-alert { margin-top: 12px; }
.test-panel .el-button { width: 100%; margin-top: 12px; }
.generation-history-list { display: grid; gap: 10px; }
.generation-history-list article { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 13px 14px; border: 1px solid #e4ebe8; border-radius: 13px; }
.generation-history-list article.current { border-color: #83c8ae; background: #f1f8f5; }
.generation-history-list article > div { min-width: 0; }
.generation-history-list strong { display: flex; align-items: center; gap: 7px; color: #2b4039; font-size: 13px; }
.generation-history-list p { overflow: hidden; margin: 5px 0; color: #64736e; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.generation-history-list small { color: #89958f; font-size: 11px; }
.generation-history-detail { min-height: 180px; }
.generation-history-detail dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0 0 18px; }
.generation-history-detail dl div { padding: 10px; border-radius: 10px; background: #f5f8f7; }
.generation-history-detail dt { color: #89958f; font-size: 11px; }
.generation-history-detail dd { margin: 5px 0 0; color: #30483f; font-size: 13px; }
.generation-history-detail h4 { margin: 18px 0 8px; }
@media (max-width: 1100px) { .generation-editor-layout { grid-template-columns: 1fr; } .preview-panel { position: static; } }
@media (max-width: 720px) { .content-type-tabs { grid-template-columns: 1fr; } .config-head-actions { width: 100%; } .config-head-actions .el-button { flex: 1; } .generation-history-detail dl { grid-template-columns: 1fr 1fr; } }
</style>

View File

@@ -12,6 +12,9 @@ import type {
ChatMessageRecord,
ChatRecord,
ChatRecordQuery,
ContentGenerationConfigDetail,
ContentGenerationHistoryItem,
ContentGenerationType,
DashboardStats,
EntitlementPlan,
EntitlementBatchRenewResult,
@@ -208,6 +211,34 @@ export const api = {
request<PageResult<PromptHistoryItem>>(`/admin/prompt/history${queryString(query)}`),
promptHistoryDetail: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}`),
restorePrompt: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}/restore`, { method: "POST", body: "{}" }),
contentGenerationConfig: (configType: ContentGenerationType) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}`),
saveContentGenerationConfig: (
configType: ContentGenerationType,
payload: { templateContent: string; instructionContent: string },
) => request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
resetContentGenerationConfig: (configType: ContentGenerationType) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}/reset`, { method: "POST", body: "{}" }),
contentGenerationHistory: (configType: ContentGenerationType, query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<ContentGenerationHistoryItem>>(`/admin/content-generation/config/${configType}/history${queryString(query)}`),
contentGenerationHistoryDetail: (configType: ContentGenerationType, id: number) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}/history/${id}`),
restoreContentGenerationConfig: (configType: ContentGenerationType, id: number) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}/history/${id}/restore`, { method: "POST", body: "{}" }),
previewContentGeneration: (payload: { configType: ContentGenerationType; templateContent: string }) =>
request<{ content: string }>("/admin/content-generation/preview", { method: "POST", body: JSON.stringify(payload) }),
testContentGeneration: (payload: {
configType: ContentGenerationType;
templateContent: string;
instructionContent: string;
sampleText: string;
}) => request<{ content: string; usedFallback: boolean }>("/admin/content-generation/test", {
method: "POST",
body: JSON.stringify(payload),
}),
debugAgent: (payload: Record<string, unknown>) =>
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),

View File

@@ -62,6 +62,42 @@ export interface PromptHistoryItem {
isCurrent: boolean;
}
export type ContentGenerationType = "help_card" | "share_draft";
export interface ContentGenerationVariable {
name: string;
label: string;
}
export interface ContentGenerationConfigDetail {
id: number | null;
configType: ContentGenerationType;
label: string;
templateContent: string;
instructionContent: string;
lockedFooter: string;
variables: ContentGenerationVariable[];
changeType: "default" | "save" | "reset" | "restore";
sourceConfigId?: number | null;
updatedByName: string;
updatedAt?: string | null;
templateCharCount: number;
instructionCharCount: number;
}
export interface ContentGenerationHistoryItem {
id: number;
configType: ContentGenerationType;
preview: string;
templateCharCount: number;
instructionCharCount: number;
changeType: "save" | "reset" | "restore";
sourceConfigId?: number | null;
updatedByName: string;
updatedAt: string;
isCurrent: boolean;
}
export interface AdminUser {
id: number;
phone: string;