| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672 |
- <script setup>
- import { computed, onMounted, reactive, ref, watch } from 'vue'
- import { TemplateAPI } from './services/api'
- const templates = ref([])
- const categories = ref([])
- const selectedTemplateId = ref(null)
- const previewResult = ref('')
- const toast = reactive({ type: '', message: '' })
- const loading = ref(false)
- const defaultEditor = () => ({
- name: '',
- category: '',
- description: '',
- output_description: '',
- call_method: '',
- template_body: '',
- input_variables: []
- })
- const editor = reactive(defaultEditor())
- const parameterValues = reactive({})
- const groupedTemplates = computed(() => {
- const map = {}
- templates.value.forEach((tpl) => {
- map[tpl.category] = map[tpl.category] ?? []
- map[tpl.category].push(tpl)
- })
- return map
- })
- const mode = computed(() => (selectedTemplateId.value ? 'update' : 'create'))
- const currentTemplate = computed(() =>
- templates.value.find((tpl) => tpl.id === selectedTemplateId.value)
- )
- const callMethodSnippet = computed(() => {
- const templateId = selectedTemplateId.value || '<template-id>'
- const parameters = editor.input_variables.reduce((acc, variable, index) => {
- const key = (variable.name || `variable_${index + 1}`).trim()
- const valueKey = variable.name?.trim()
- const previewValue =
- (valueKey ? parameterValues[valueKey] : undefined) ?? variable.default ?? ''
- acc[key] = previewValue
- return acc
- }, {})
- const body = JSON.stringify(
- {
- template_id: templateId,
- parameters
- },
- null,
- 2
- )
- return ['POST /api/templates/render', 'Content-Type: application/json', '', body].join('\n')
- })
- function setToast(message, type = 'success', timeout = 2600) {
- toast.type = type
- toast.message = message
- if (!message) return
- setTimeout(() => {
- toast.message = ''
- }, timeout)
- }
- async function loadTemplates() {
- loading.value = true
- try {
- const [tplList, { categories: categoryList }] = await Promise.all([
- TemplateAPI.listTemplates(),
- TemplateAPI.listCategories()
- ])
- templates.value = tplList
- categories.value = categoryList
- if (!selectedTemplateId.value && tplList.length) {
- selectTemplate(tplList[0])
- }
- } catch (err) {
- setToast(err.message ?? '加载失败', 'error', 4000)
- } finally {
- loading.value = false
- }
- }
- function resetEditor() {
- Object.assign(editor, defaultEditor())
- Object.keys(parameterValues).forEach((key) => delete parameterValues[key])
- previewResult.value = ''
- }
- function hydrateEditor(template) {
- Object.assign(editor, JSON.parse(JSON.stringify(template)))
- template.input_variables?.forEach((variable) => {
- parameterValues[variable.name] =
- parameterValues[variable.name] ?? variable.default ?? ''
- })
- }
- function selectTemplate(template) {
- selectedTemplateId.value = template.id
- resetEditor()
- hydrateEditor(template)
- previewResult.value = ''
- }
- function startCreate() {
- selectedTemplateId.value = null
- resetEditor()
- }
- function addVariable() {
- editor.input_variables.push({
- name: '',
- label: '',
- description: '',
- data_type: 'string',
- required: false,
- default: ''
- })
- }
- function removeVariable(index) {
- const [removed] = editor.input_variables.splice(index, 1)
- if (removed?.name) {
- delete parameterValues[removed.name]
- }
- }
- async function saveTemplate() {
- try {
- const payload = JSON.parse(JSON.stringify(editor))
- payload.call_method = callMethodSnippet.value
- if (mode.value === 'update') {
- await TemplateAPI.updateTemplate(selectedTemplateId.value, payload)
- setToast('模板已更新')
- } else {
- const created = await TemplateAPI.createTemplate(payload)
- selectedTemplateId.value = created.id
- setToast('模板已创建')
- }
- await loadTemplates()
- } catch (err) {
- setToast(err.message ?? '保存失败', 'error')
- }
- }
- async function removeTemplate() {
- if (!selectedTemplateId.value) return
- if (!confirm('确定要删除当前模板吗?')) return
- try {
- await TemplateAPI.deleteTemplate(selectedTemplateId.value)
- setToast('模板已删除', 'success')
- startCreate()
- await loadTemplates()
- } catch (err) {
- setToast(err.message ?? '删除失败', 'error')
- }
- }
- async function previewTemplate() {
- if (!editor.template_body) {
- setToast('请先填写模板正文', 'error')
- return
- }
- try {
- const result = await TemplateAPI.previewTemplate({
- template_body: editor.template_body,
- parameters: parameterValues
- })
- previewResult.value = result.rendered_text
- } catch (err) {
- setToast(err.message ?? '预览失败', 'error', 4000)
- }
- }
- async function renderTemplate() {
- if (!selectedTemplateId.value) {
- setToast('请先保存模板', 'error')
- return
- }
- try {
- const result = await TemplateAPI.renderTemplate({
- template_id: selectedTemplateId.value,
- parameters: parameterValues
- })
- previewResult.value = result.rendered_text
- setToast('已根据后端模板生成', 'success')
- } catch (err) {
- setToast(err.message ?? '生成失败', 'error', 4000)
- }
- }
- onMounted(() => {
- loadTemplates()
- startCreate()
- })
- watch(
- () => editor.input_variables.map((variable) => variable.name),
- (names) => {
- Object.keys(parameterValues).forEach((param) => {
- if (!names.includes(param)) {
- delete parameterValues[param]
- }
- })
- },
- { deep: true }
- )
- </script>
- <template>
- <div class="page">
- <header class="page-header">
- <div>
- <p class="eyebrow">洗衣机语音播报模板运营系统</p>
- <h1>自定义模板 · 分类管理 · 一键生成播报</h1>
- <p class="subtitle">
- 在可视化界面配置 Jinja 模板,实时预览变量效果,并把调用方法、输入输出标准化给运营或研发使用。
- </p>
- </div>
- <div class="header-actions">
- <button @click="startCreate">+ 新建模板</button>
- </div>
- </header>
- <section v-if="toast.message" class="toast" :class="toast.type">
- {{ toast.message }}
- </section>
- <div class="layout">
- <aside class="sidebar" :class="{ loading: loading }">
- <h3>模板清单</h3>
- <p class="muted">按分类浏览已有模板,点击可切换编辑。</p>
- <div v-if="!Object.keys(groupedTemplates).length" class="empty-state">
- 暂无模板,点击右上角「新建模板」开始配置。
- </div>
- <div v-else class="category-list">
- <div v-for="(items, category) in groupedTemplates" :key="category" class="category-block">
- <div class="category-title">
- <span>{{ category }}</span>
- <span class="tag">{{ items.length }}</span>
- </div>
- <div class="template-list">
- <button
- v-for="item in items"
- :key="item.id"
- class="template-item"
- :class="{ active: selectedTemplateId === item.id }"
- @click="selectTemplate(item)"
- >
- <div>
- <strong>{{ item.name }}</strong>
- <p>{{ item.description }}</p>
- </div>
- </button>
- </div>
- </div>
- </div>
- </aside>
- <main class="content">
- <section class="card">
- <div class="card-header">
- <div>
- <p class="eyebrow">模板基础信息</p>
- <h2>定义模板 & 调用说明</h2>
- </div>
- <div class="gap-8">
- <button class="ghost" v-if="selectedTemplateId" @click="removeTemplate">删除模板</button>
- <button @click="saveTemplate">{{ mode === 'update' ? '保存修改' : '创建模板' }}</button>
- </div>
- </div>
- <div class="form-grid">
- <label>
- 模板名称
- <input v-model="editor.name" placeholder="如:智能推荐播报" />
- </label>
- <label>
- 分类
- <input v-model="editor.category" placeholder="如:洗衣机推荐" />
- </label>
- <label>
- 调用方式
- <p class="muted">系统已根据模板 ID 和输入变量生成示例,无需手动填写。</p>
- <pre class="call-snippet">{{ callMethodSnippet }}</pre>
- </label>
- <label>
- 模板说明
- <textarea v-model="editor.description" placeholder="描述模板的使用场景、亮点" rows="3" />
- </label>
- <label>
- 输出说明
- <textarea v-model="editor.output_description" placeholder="向运营/研发说明生成结果结构、段落" rows="3" />
- </label>
- </div>
- </section>
- <section class="card">
- <div class="card-header">
- <div>
- <p class="eyebrow">输入变量</p>
- <h2>定义前端可视化填写项</h2>
- </div>
- <button class="ghost" @click="addVariable">+ 新增变量</button>
- </div>
- <div v-if="!editor.input_variables.length" class="empty-state">
- 还没有变量,点击「新增变量」来定义前端可配置项。
- </div>
- <div v-else class="variable-grid">
- <div v-for="(variable, index) in editor.input_variables" :key="index" class="variable-item">
- <div class="variable-header">
- <strong>{{ variable.label || '未命名变量' }}</strong>
- <button class="ghost" @click="removeVariable(index)">移除</button>
- </div>
- <div class="variable-fields">
- <label>
- 变量名
- <input v-model="variable.name" placeholder="在模板中引用的变量名" />
- </label>
- <label>
- 展示名称
- <input v-model="variable.label" placeholder="用于前端输入表单的名称" />
- </label>
- <label>
- 类型
- <select v-model="variable.data_type">
- <option value="string">文本</option>
- <option value="number">数字</option>
- <option value="boolean">布尔</option>
- <option value="options">枚举</option>
- </select>
- </label>
- <label class="inline-label">
- <input type="checkbox" v-model="variable.required" />
- 必填
- </label>
- <label>
- 默认值
- <input v-model="variable.default" placeholder="可选默认值,便于预览" />
- </label>
- <label>
- 变量描述
- <textarea v-model="variable.description" rows="2" placeholder="提示运营如何填写" />
- </label>
- </div>
- </div>
- </div>
- </section>
- <section class="card">
- <div class="card-header">
- <div>
- <p class="eyebrow">模板正文</p>
- <h2>编写 Jinja 播报模版</h2>
- </div>
- <div class="gap-8">
- <button class="ghost" @click="previewTemplate">本地预览</button>
- <button class="ghost" @click="saveTemplate">
- {{ mode === 'update' ? '保存修改' : '创建模板' }}
- </button>
- <button @click="renderTemplate">调用后端生成</button>
- </div>
- </div>
- <div class="editor-grid">
- <label>
- 模板正文 (支持 Jinja2)
- <textarea v-model="editor.template_body" placeholder="在此编写模板,可引用 {{ variable }} 等变量" />
- </label>
- <div>
- <h3>变量填充区</h3>
- <p class="muted">输入样例值,便于预览和对接。</p>
- <div v-if="!editor.input_variables.length" class="empty-state small">
- 添加变量后可在此填充预览数据。
- </div>
- <div class="parameter-grid" v-else>
- <label v-for="variable in editor.input_variables" :key="variable.name || variable.label">
- {{ variable.label || variable.name }}
- <span class="tag">{{ variable.data_type }}</span>
- <input
- :placeholder="variable.description"
- v-model="parameterValues[variable.name]"
- />
- </label>
- </div>
- </div>
- </div>
- <div class="preview-panel" v-if="previewResult">
- <div class="preview-header">
- <h3>生成结果</h3>
- <span class="muted">{{ editor.output_description || '播报示例' }}</span>
- </div>
- <pre>{{ previewResult }}</pre>
- </div>
- </section>
- </main>
- </div>
- </div>
- </template>
- <style scoped>
- .page {
- padding: 2.5rem;
- max-width: 1400px;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- gap: 1.5rem;
- }
- .page-header {
- background: #fff;
- border-radius: 16px;
- padding: 1.75rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 1.5rem;
- border: 1px solid #e2e8f3;
- }
- .eyebrow {
- font-size: 0.85rem;
- text-transform: uppercase;
- letter-spacing: 0.08rem;
- color: #64748b;
- margin-bottom: 0.25rem;
- }
- .subtitle {
- color: #475467;
- margin-top: 0.4rem;
- }
- .header-actions {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- }
- .layout {
- display: grid;
- grid-template-columns: 320px 1fr;
- gap: 1.5rem;
- }
- .sidebar {
- background: #fff;
- border-radius: 16px;
- padding: 1.5rem;
- border: 1px solid #e2e8f3;
- min-height: 500px;
- }
- .sidebar.loading {
- opacity: 0.6;
- }
- .category-list {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- margin-top: 1rem;
- }
- .category-title {
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-weight: 600;
- color: #1f2937;
- }
- .template-list {
- display: flex;
- flex-direction: column;
- gap: 0.6rem;
- margin-top: 0.7rem;
- }
- .template-item {
- width: 100%;
- text-align: left;
- border-radius: 10px;
- padding: 0.75rem;
- border: 1px solid transparent;
- background: #f8fafc;
- }
- .template-item strong {
- display: block;
- color:#1f2937;
- margin-bottom: 0.25rem;
- }
- .template-item p {
- margin: 0;
- color: #475467;
- font-size: 0.85rem;
- }
- .template-item.active {
- background: #eef2ff;
- border-color: #4338ca;
- }
- .content {
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
- }
- .card {
- background: #fff;
- border-radius: 16px;
- border: 1px solid #e2e8f3;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1rem;
- }
- .card-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 1rem;
- }
- .gap-8 {
- display: flex;
- gap: 0.75rem;
- }
- .form-grid {
- display: grid;
- gap: 1rem;
- }
- .call-snippet {
- background: #0f172a;
- color: #e2e8f0;
- font-family: "JetBrains Mono", Consolas, monospace;
- padding: 0.75rem;
- border-radius: 10px;
- font-size: 0.9rem;
- white-space: pre-wrap;
- border: 1px solid #1e293b;
- margin-top: 0.5rem;
- }
- .variable-grid {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- }
- .variable-item {
- border: 1px solid #e2e8f3;
- border-radius: 12px;
- padding: 1rem;
- }
- .variable-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1rem;
- }
- .variable-fields {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 0.75rem;
- }
- .inline-label {
- display: flex;
- align-items: center;
- gap: 0.4rem;
- margin-top: 1.6rem;
- font-size: 0.85rem;
- color: #1f2937;
- }
- .editor-grid {
- display: grid;
- grid-template-columns: 1fr 320px;
- gap: 1rem;
- }
- .parameter-grid {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- margin-top: 0.5rem;
- }
- .preview-panel {
- border: 1px solid #c7d2fe;
- border-radius: 12px;
- padding: 1rem;
- background: #f8f9ff;
- }
- .preview-panel pre {
- white-space: pre-wrap;
- margin: 0;
- font-family: "JetBrains Mono", Consolas, monospace;
- font-size: 0.95rem;
- }
- .preview-header {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- margin-bottom: 0.8rem;
- }
- .muted {
- color: #6b7280;
- font-size: 0.9rem;
- }
- .toast {
- padding: 0.85rem 1rem;
- border-radius: 10px;
- font-weight: 600;
- }
- .toast.success {
- background: #ecfdf3;
- color: #027a48;
- border: 1px solid #a7f3d0;
- }
- .toast.error {
- background: #fef3f2;
- color: #b42318;
- border: 1px solid #fecdcf;
- }
- .empty-state {
- padding: 1rem;
- background: #f8fafc;
- border: 1px dashed #cbd5f5;
- border-radius: 10px;
- color: #475467;
- }
- .empty-state.small {
- padding: 0.8rem;
- }
- @media (max-width: 1100px) {
- .layout {
- grid-template-columns: 1fr;
- }
- .editor-grid {
- grid-template-columns: 1fr;
- }
- }
- </style>
|