feat: support configurable card variables and layouts

This commit is contained in:
2026-08-14 13:36:53 +08:00
parent f952d6dc58
commit 77399df060
14 changed files with 619 additions and 88 deletions

View File

@@ -8,8 +8,10 @@ import type {
ContentGenerationConfigDetail,
ContentGenerationHistoryItem,
ContentGenerationType,
ContentGenerationVariable,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import ContentGenerationVariableEditor from "./ContentGenerationVariableEditor.vue";
const activeType = ref<ContentGenerationType>("help_card");
const loading = ref(false);
@@ -26,15 +28,22 @@ const previewContent = ref("");
const testUsedFallback = ref(false);
const selectedHistory = ref<ContentGenerationConfigDetail | null>(null);
const historyDialogOpen = ref(false);
const activeSection = ref<"variables" | "layout" | "test">("variables");
const templateInputRef = ref<InputInstance>();
const form = reactive({ templateContent: "", instructionContent: "" });
const saved = reactive({ templateContent: "", instructionContent: "" });
const form = reactive<{ templateContent: string; instructionContent: string; variables: ContentGenerationVariable[] }>({
templateContent: "",
instructionContent: "",
variables: [],
});
const saved = reactive({ templateContent: "", instructionContent: "", variablesJson: "[]" });
const sampleText = ref(
"我第一次参加带练,不太确定练习顺序。做到一半身体有些紧,我会担心自己是不是做错了,想请老师确认什么时候应该暂停。",
);
const dirty = computed(
() => form.templateContent !== saved.templateContent || form.instructionContent !== saved.instructionContent,
() => form.templateContent !== saved.templateContent
|| form.instructionContent !== saved.instructionContent
|| JSON.stringify(form.variables) !== saved.variablesJson,
);
const typeLabel = computed(() => activeType.value === "help_card" ? "老师求助卡" : "班级分享稿");
@@ -62,8 +71,10 @@ function applyConfig(config: ContentGenerationConfigDetail) {
current.value = config;
form.templateContent = config.templateContent;
form.instructionContent = config.instructionContent;
form.variables = config.variables.map((item) => ({ ...item }));
saved.templateContent = config.templateContent;
saved.instructionContent = config.instructionContent;
saved.variablesJson = JSON.stringify(config.variables);
}
async function switchType(type: ContentGenerationType) {
@@ -83,6 +94,7 @@ async function switchType(type: ContentGenerationType) {
historyPage.value = 1;
previewContent.value = "";
testUsedFallback.value = false;
activeSection.value = "variables";
await loadAll();
}
@@ -111,6 +123,7 @@ async function refreshPreview() {
const result = await api.previewContentGeneration({
configType: activeType.value,
templateContent: form.templateContent,
variables: form.variables,
});
previewContent.value = result.content;
testUsedFallback.value = false;
@@ -129,6 +142,7 @@ async function testGeneration() {
templateContent: form.templateContent,
instructionContent: form.instructionContent,
sampleText: sampleText.value,
variables: form.variables,
});
previewContent.value = result.content;
testUsedFallback.value = result.usedFallback;
@@ -147,6 +161,7 @@ async function saveConfig() {
const config = await api.saveContentGenerationConfig(activeType.value, {
templateContent: form.templateContent,
instructionContent: form.instructionContent,
variables: form.variables,
});
applyConfig(config);
ElMessage.success("已保存并立即发布为新版本");
@@ -247,7 +262,7 @@ function changeTypeLabel(value: string) {
<div class="page-head inline">
<div>
<h2>内容生成配置</h2>
<p>管理求助卡和分享稿的模板与 AI 整理规则每次保存立即生效并自动保留可回滚版本</p>
<p>自定义卡片变量AI 提炼含义和内容排版每次保存立即生效并自动保留可回滚版本</p>
</div>
<div class="config-head-actions">
<el-button :loading="previewing" @click="refreshPreview">刷新预览</el-button>
@@ -264,50 +279,81 @@ function changeTypeLabel(value: string) {
</button>
</nav>
<section class="generation-editor-layout">
<nav class="workflow-tabs" aria-label="内容配置步骤">
<button type="button" :class="{ active: activeSection === 'variables' }" @click="activeSection = 'variables'">
<span>1</span><div><strong>变量设置</strong><small>定义卡片字段和提炼含义</small></div>
</button>
<button type="button" :class="{ active: activeSection === 'layout' }" @click="activeSection = 'layout'; refreshPreview()">
<span>2</span><div><strong>卡片排版</strong><small>组合变量并查看实际效果</small></div>
</button>
<button type="button" :class="{ active: activeSection === 'test' }" @click="activeSection = 'test'">
<span>3</span><div><strong>AI 测试</strong><small>用模拟材料验证提炼质量</small></div>
</button>
</nav>
<section v-if="activeSection === 'variables'" 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>
<ContentGenerationVariableEditor
v-model="form.variables"
:source-options="current?.sourceOptions || []"
/>
</article>
<article class="generation-panel">
<header><div><h3>全局 AI 整理规则</h3><p>这里控制整张卡片的语气和边界每个字段具体提炼什么由上方变量含义决定</p></div></header>
<el-input v-model="form.instructionContent" type="textarea" :rows="7" resize="vertical" />
<small>{{ form.instructionContent.length }}/10000 字符</small>
</article>
</section>
<section v-else-if="activeSection === 'layout'" class="generation-editor-layout">
<div class="generation-editor-column">
<article class="generation-panel">
<header>
<div><h3>卡片模板</h3><p>点击变量可添加到模板未知变量或缺少必要变量时不能发布</p></div>
<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)">
<button v-for="item in form.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" />
<el-input ref="templateInputRef" v-model="form.templateContent" type="textarea" :rows="21" 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>
<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>
<header>
<div><h3>排版效果预览</h3><p>使用每个变量设置的预览示例填充正式生成时会换成真实提炼内容</p></div>
<el-button :loading="previewing" @click="refreshPreview">刷新</el-button>
</header>
<pre>{{ previewContent || "点击“刷新”查看效果" }}</pre>
</article>
</aside>
</section>
<section v-else class="generation-editor-layout test-layout">
<article class="generation-panel test-panel">
<header><div><h3>测试材料</h3><p>粘贴一段模拟用户表达AI 会按照当前自定义变量和变量含义进行提炼</p></div></header>
<el-input v-model="sampleText" type="textarea" :rows="14" resize="vertical" maxlength="20000" show-word-limit />
<el-button type="primary" :loading="testing" @click="testGeneration">运行 AI 提炼测试</el-button>
</article>
<article class="generation-panel preview-panel test-result">
<header><div><h3>测试结果</h3><p>本次测试不会保存配置也不会生成正式用户卡片</p></div></header>
<pre>{{ previewContent || "运行测试后在这里查看结果" }}</pre>
<el-alert v-if="testUsedFallback" title="本次测试使用了安全回退结果,模型未返回有效结构化内容。" type="warning" :closable="false" show-icon />
</article>
</section>
<section class="generation-history generation-panel">
<header>
<div><h3>版本记录</h3><p>当前配置{{ current?.updatedByName }} · {{ formatTime(current?.updatedAt) }}</p></div>
@@ -342,6 +388,10 @@ function changeTypeLabel(value: string) {
<div><dt>操作人</dt><dd>{{ selectedHistory.updatedByName }}</dd></div>
<div><dt>时间</dt><dd>{{ formatTime(selectedHistory.updatedAt) }}</dd></div>
</dl>
<h4>变量定义</h4>
<div class="history-variables">
<span v-for="item in selectedHistory.variables" :key="item.name"><strong>{{ item.label }}</strong><code>{{ variableToken(item.name) }}</code></span>
</div>
<h4>卡片模板</h4><pre>{{ selectedHistory.templateContent }}</pre>
<h4>AI 整理规则</h4><pre>{{ selectedHistory.instructionContent }}</pre>
</template>
@@ -363,6 +413,15 @@ function changeTypeLabel(value: string) {
.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; }
.workflow-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: hidden; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; }
.workflow-tabs button { display: flex; align-items: center; gap: 11px; min-height: 68px; padding: 12px 18px; border: 0; border-right: 1px solid #e7eeeb; background: transparent; color: #73827d; text-align: left; }
.workflow-tabs button:last-child { border-right: 0; }
.workflow-tabs button > span { display: grid; flex: 0 0 28px; height: 28px; place-items: center; border-radius: 9px; background: #eef3f1; font-size: 12px; font-weight: 700; }
.workflow-tabs button > div { display: grid; gap: 3px; }
.workflow-tabs strong { font-size: 14px; }
.workflow-tabs small { font-size: 11px; }
.workflow-tabs button.active { background: #f0f8f5; color: #176c51; }
.workflow-tabs button.active > span { background: #42a782; color: #fff; }
.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; }
@@ -382,6 +441,8 @@ function changeTypeLabel(value: string) {
.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; }
.test-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.test-result { position: static; }
.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; }
@@ -395,6 +456,9 @@ function changeTypeLabel(value: string) {
.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; }
.history-variables { display: flex; flex-wrap: wrap; gap: 7px; }
.history-variables span { display: flex; gap: 6px; padding: 7px 9px; border-radius: 9px; background: #f2f7f5; color: #466158; font-size: 12px; }
.history-variables code { color: #16805e; }
@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; } }
@media (max-width: 720px) { .content-type-tabs, .workflow-tabs { grid-template-columns: 1fr; } .workflow-tabs button { border-right: 0; border-bottom: 1px solid #e7eeeb; } .workflow-tabs button:last-child { border-bottom: 0; } .config-head-actions { width: 100%; } .config-head-actions .el-button { flex: 1; } .generation-history-detail dl { grid-template-columns: 1fr 1fr; } }
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { ContentGenerationSourceOption, ContentGenerationVariable } from "../types/api";
const props = defineProps<{
modelValue: ContentGenerationVariable[];
sourceOptions: ContentGenerationSourceOption[];
}>();
const emit = defineEmits<{ "update:modelValue": [value: ContentGenerationVariable[]] }>();
function update(index: number, patch: Partial<ContentGenerationVariable>) {
emit("update:modelValue", props.modelValue.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
}
function addVariable() {
const names = new Set(props.modelValue.map((item) => item.name));
let sequence = 1;
while (names.has(`custom_field_${sequence}`)) sequence += 1;
emit("update:modelValue", [
...props.modelValue,
{
name: `custom_field_${sequence}`,
label: `自定义内容 ${sequence}`,
description: "请说明 AI 应该从对话材料中提炼什么内容,以及期望的表达方式",
valueSource: "ai",
sourceKey: null,
sampleValue: "这里显示预览示例",
},
]);
}
function removeVariable(index: number) {
if (props.modelValue.length <= 1) return;
emit("update:modelValue", props.modelValue.filter((_, itemIndex) => itemIndex !== index));
}
function token(name: string) {
return `{{${name || "field_name"}}}`;
}
</script>
<template>
<div class="variable-editor">
<div class="variable-editor-head">
<div>
<strong>变量清单</strong>
<span>卡片最多配置 30 个变量AI 会严格按照含义说明提炼对应内容</span>
</div>
<el-button type="primary" plain @click="addVariable">+ 新增变量</el-button>
</div>
<article v-for="(item, index) in modelValue" :key="`${item.name}-${index}`" class="variable-card">
<header>
<div class="variable-index"><span>{{ index + 1 }}</span><strong>{{ item.label || "未命名变量" }}</strong></div>
<el-button text type="danger" :disabled="modelValue.length <= 1" @click="removeVariable(index)">删除</el-button>
</header>
<div class="variable-fields">
<label>
<span>变量名称 <em>展示给管理员</em></span>
<el-input :model-value="item.label" maxlength="50" @update:model-value="update(index, { label: String($event) })" />
</label>
<label>
<span>变量标识 <em>用于排版例如 <code>{{ token(item.name) }}</code></em></span>
<el-input :model-value="item.name" maxlength="40" @update:model-value="update(index, { name: String($event).toLowerCase().replace(/[^a-z0-9_]/g, '') })" />
</label>
<label>
<span>取值方式</span>
<el-select :model-value="item.valueSource" @update:model-value="update(index, { valueSource: $event as 'ai' | 'context', sourceKey: $event === 'ai' ? null : (item.sourceKey || sourceOptions[0]?.key || null) })">
<el-option label="AI 根据变量含义提炼" value="ai" />
<el-option label="直接使用系统上下文字段" value="context" />
</el-select>
</label>
<label v-if="item.valueSource === 'context'">
<span>系统字段</span>
<el-select :model-value="item.sourceKey" @update:model-value="update(index, { sourceKey: String($event) })">
<el-option v-for="option in sourceOptions" :key="option.key" :label="option.label" :value="option.key" />
</el-select>
</label>
<label class="full">
<span>变量含义 <em>越具体AI 提炼越稳定</em></span>
<el-input
:model-value="item.description"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
@update:model-value="update(index, { description: String($event) })"
/>
</label>
<label class="full">
<span>预览示例 <em>只用于排版预览不会进入正式卡片</em></span>
<el-input :model-value="item.sampleValue" maxlength="1000" @update:model-value="update(index, { sampleValue: String($event) })" />
</label>
</div>
</article>
</div>
</template>
<style scoped>
.variable-editor { display: grid; gap: 12px; }
.variable-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 2px 2px 8px; }
.variable-editor-head > div { display: grid; gap: 4px; }
.variable-editor-head strong { color: #243d35; font-size: 15px; }
.variable-editor-head span { color: #768780; font-size: 12px; }
.variable-card { padding: 16px; border: 1px solid #dfe9e5; border-radius: 14px; background: #fbfdfc; }
.variable-card header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 13px; }
.variable-index { display: flex; align-items: center; gap: 9px; color: #29453b; }
.variable-index > span { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 8px; background: #e5f4ee; color: #188060; font-size: 12px; }
.variable-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px 14px; }
.variable-fields label { display: grid; min-width: 0; gap: 7px; }
.variable-fields label.full { grid-column: 1 / -1; }
.variable-fields label > span { color: #42574f; font-size: 12px; font-weight: 600; }
.variable-fields em { margin-left: 5px; color: #8a9993; font-size: 11px; font-style: normal; font-weight: 400; }
.variable-fields .el-select { width: 100%; }
@media (max-width: 760px) { .variable-fields { grid-template-columns: 1fr; } .variable-fields label.full { grid-column: auto; } .variable-editor-head { align-items: flex-start; } }
</style>

View File

@@ -17,6 +17,7 @@ import type {
ChatRecord,
ChatRecordQuery,
ContentGenerationConfigDetail,
ContentGenerationVariable,
ContentGenerationHistoryItem,
ContentGenerationType,
DashboardStats,
@@ -232,7 +233,7 @@ export const api = {
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}`),
saveContentGenerationConfig: (
configType: ContentGenerationType,
payload: { templateContent: string; instructionContent: string },
payload: { templateContent: string; instructionContent: string; variables: ContentGenerationVariable[] },
) => request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}`, {
method: "PUT",
body: JSON.stringify(payload),
@@ -245,13 +246,14 @@ export const api = {
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 }) =>
previewContentGeneration: (payload: { configType: ContentGenerationType; templateContent: string; variables: ContentGenerationVariable[] }) =>
request<{ content: string }>("/admin/content-generation/preview", { method: "POST", body: JSON.stringify(payload) }),
testContentGeneration: (payload: {
configType: ContentGenerationType;
templateContent: string;
instructionContent: string;
sampleText: string;
variables: ContentGenerationVariable[];
}) => request<{ content: string; usedFallback: boolean }>("/admin/content-generation/test", {
method: "POST",
body: JSON.stringify(payload),

View File

@@ -151,6 +151,15 @@ export type ContentGenerationType = "help_card" | "share_draft";
export interface ContentGenerationVariable {
name: string;
label: string;
description: string;
valueSource: "ai" | "context";
sourceKey: string | null;
sampleValue: string;
}
export interface ContentGenerationSourceOption {
key: string;
label: string;
}
export interface ContentGenerationConfigDetail {
@@ -161,6 +170,7 @@ export interface ContentGenerationConfigDetail {
instructionContent: string;
lockedFooter: string;
variables: ContentGenerationVariable[];
sourceOptions: ContentGenerationSourceOption[];
changeType: "default" | "save" | "reset" | "restore";
sourceConfigId?: number | null;
updatedByName: string;