@@ -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 : 18 px ; }
. config - head - actions { display : flex ; gap : 10 px ; }
. content - type - tabs { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; }
. content - type - tabs button { min - height : 78 px ; display : grid ; gap : 5 px ; padding : 15 px 18 px ; border : 1 px solid # dfe8e4 ; border - radius : 16 px ; background : # fff ; color : # 65746 f ; text - align : left ; transition : border - color .18 s ease , background .18 s ease , transform .18 s ease ; }
. content - type - tabs button : hover { border - color : # 91 c8b5 ; transform : translateY ( - 1 px ) ; }
. content - type - tabs button . active { border - color : # 43 a783 ; background : # eff8f4 ; color : # 176 c51 ; box - shadow : 0 8 px 24 px rgba ( 23 , 108 , 81 , .08 ) ; }
. content - type - tabs strong { font - size : 16 px ; }
. content - type - tabs span { font - size : 12 px ; }
. generation - editor - layout { display : grid ; grid - template - columns : minmax ( 0 , 1.15 fr ) minmax ( 360 px , .85 fr ) ; gap : 16 px ; align - items : start ; }
. generation - editor - column , . generation - preview - column { min - width : 0 ; display : grid ; gap : 16 px ; }
. generation - panel , . locked - rules { padding : 18 px ; border : 1 px solid # dfe8e4 ; border - radius : 16 px ; background : # fff ; }
. generation - panel > header { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 14 px ; margin - bottom : 14 px ; }
. generation - panel h3 { margin : 0 ; color : # 1 d342d ; font - size : 17 px ; }
. generation - panel header p { margin : 5 px 0 0 ; color : # 75847 f ; font - size : 12 px ; line - height : 1.6 ; }
. generation - panel > small { display : block ; margin - top : 7 px ; color : # 8 a9893 ; font - size : 11 px ; text - align : right ; }
. variable - list { display : flex ; flex - wrap : wrap ; gap : 7 px ; margin - bottom : 12 px ; }
. variable - list button { padding : 6 px 9 px ; border : 1 px solid # d8e6e0 ; border - radius : 9 px ; background : # f7faf9 ; color : # 49665 c ; font - size : 12 px ; }
. variable - list button : hover { border - color : # 62 b596 ; background : # edf7f3 ; }
. variable - list code { margin - left : 3 px ; color : # 17805 f ; font - size : 11 px ; }
. locked - rules { border - color : # e9dfbd ; background : # fffaf0 ; }
. locked - rules strong { color : # 705 d2e ; }
. locked - rules p { margin : 9 px 0 4 px ; color : # 655 b43 ; font - size : 13 px ; line - height : 1.75 ; white - space : pre - wrap ; }
. locked - rules small { color : # 8 c805f ; font - size : 11 px ; }
. preview - panel { position : sticky ; top : 18 px ; }
. preview - panel pre , . generation - history - detail pre { max - height : 520 px ; overflow : auto ; margin : 0 ; padding : 16 px ; border : 1 px solid # e3ebe8 ; border - radius : 13 px ; background : # f8fbfa ; color : # 324 b43 ; font : inherit ; font - size : 13 px ; line - height : 1.8 ; white - space : pre - wrap ; }
. preview - panel . el - alert { margin - top : 12 px ; }
. test - panel . el - button { width : 100 % ; margin - top : 12 px ; }
. generation - history - list { display : grid ; gap : 10 px ; }
. generation - history - list article { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) auto ; align - items : center ; gap : 14 px ; padding : 13 px 14 px ; border : 1 px solid # e4ebe8 ; border - radius : 13 px ; }
. generation - history - list article . current { border - color : # 83 c8ae ; background : # f1f8f5 ; }
. generation - history - list article > div { min - width : 0 ; }
. generation - history - list strong { display : flex ; align - items : center ; gap : 7 px ; color : # 2 b4039 ; font - size : 13 px ; }
. generation - history - list p { overflow : hidden ; margin : 5 px 0 ; color : # 64736 e ; font - size : 12 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. generation - history - list small { color : # 89958 f ; font - size : 11 px ; }
. generation - history - detail { min - height : 180 px ; }
. generation - history - detail dl { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; margin : 0 0 18 px ; }
. generation - history - detail dl div { padding : 10 px ; border - radius : 10 px ; background : # f5f8f7 ; }
. generation - history - detail dt { color : # 89958 f ; font - size : 11 px ; }
. generation - history - detail dd { margin : 5 px 0 0 ; color : # 30483 f ; font - size : 13 px ; }
. generation - history - detail h4 { margin : 18 px 0 8 px ; }
@ media ( max - width : 1100 px ) { . generation - editor - layout { grid - template - columns : 1 fr ; } . preview - panel { position : static ; } }
@ media ( max - width : 720 px ) { . content - type - tabs { grid - template - columns : 1 fr ; } . config - head - actions { width : 100 % ; } . config - head - actions . el - button { flex : 1 ; } . generation - history - detail dl { grid - template - columns : 1 fr 1 fr ; } }
< / style >