| <template> |
| <v-dialog v-model="dialog" max-width="1400px" persistent scrollable> |
| <template v-slot:activator="{ props }"> |
| <v-btn |
| v-bind="props" |
| variant="outlined" |
| color="primary" |
| size="small" |
| :loading="loading" |
| > |
| {{ tm('t2iTemplateEditor.buttonText') }} |
| </v-btn> |
| </template> |
| |
| <v-card> |
| <v-card-title class="d-flex align-center justify-space-between"> |
| <span>{{ tm('t2iTemplateEditor.dialogTitle') }}</span> |
| <v-spacer></v-spacer> |
| <div class="d-flex align-center gap-2" style="width: 60%"> |
| <v-text-field |
| v-if="isCreatingNew" |
| v-model="editingName" |
| :label="tm('t2iTemplateEditor.newTemplateNameLabel')" |
| density="compact" |
| hide-details |
| variant="outlined" |
| class="flex-grow-1" |
| autofocus |
| :rules="[v => !!v || tm('t2iTemplateEditor.nameRequired')]" |
| ></v-text-field> |
| <v-select |
| v-else |
| v-model="selectedTemplate" |
| :items="templates" |
| item-title="name" |
| item-value="name" |
| :label="tm('t2iTemplateEditor.selectTemplateLabel')" |
| density="compact" |
| hide-details |
| variant="outlined" |
| class="flex-grow-1" |
| :loading="loading" |
| > |
| <template v-slot:item="{ props, item }"> |
| <v-list-item v-bind="props" :title="item.raw.name"> |
| <template v-slot:append> |
| <v-chip |
| v-if="item.raw.name === activeTemplate" |
| color="success" |
| variant="tonal" |
| size="small" |
| class="ml-2" |
| > |
| {{ tm('t2iTemplateEditor.applied') }} |
| </v-chip> |
| <v-btn |
| v-else |
| variant="text" |
| color="primary" |
| size="small" |
| class="ml-2" |
| @click.stop="setActiveTemplate(item.raw.name)" |
| :loading="applyLoading" |
| > |
| {{ tm('t2iTemplateEditor.apply') }} |
| </v-btn> |
| </template> |
| </v-list-item> |
| </template> |
| </v-select> |
| <v-btn |
| variant="text" |
| icon |
| @click="closeDialog" |
| > |
| <v-icon>mdi-close</v-icon> |
| </v-btn> |
| </div> |
| </v-card-title> |
| |
| <v-card-text class="pa-0"> |
| <v-row no-gutters style="height: 70vh;"> |
| |
| <v-col cols="6" class="d-flex flex-column"> |
| <v-toolbar density="compact" color="surface-variant"> |
| <v-toolbar-title class="text-subtitle-2">{{ tm('t2iTemplateEditor.templateEditor') }}</v-toolbar-title> |
| <v-spacer></v-spacer> |
| <div class="d-flex align-center pa-1" style="border: 1px solid rgba(0,0,0,0.1); border-radius: 8px;"> |
| <v-btn |
| variant="text" |
| size="small" |
| @click="newTemplate" |
| color="success" |
| > |
| <v-icon left>mdi-plus</v-icon> |
| {{ tm('t2iTemplateEditor.new') }} |
| </v-btn> |
| <v-divider vertical class="mx-1"></v-divider> |
| <v-btn |
| variant="text" |
| size="small" |
| @click="resetToDefault" |
| :loading="resetLoading" |
| color="warning" |
| > |
| {{ tm('t2iTemplateEditor.resetBase') }} |
| </v-btn> |
| <v-btn |
| variant="text" |
| size="small" |
| @click="promptDelete" |
| color="error" |
| :disabled="isCreatingNew || selectedTemplate === 'base' || !selectedTemplate" |
| > |
| {{ tm('t2iTemplateEditor.delete') }} |
| </v-btn> |
| <v-divider vertical class="mx-1"></v-divider> |
| <v-btn |
| variant="text" |
| size="small" |
| @click="saveTemplate" |
| :loading="saveLoading" |
| color="primary" |
| :disabled="(isCreatingNew && !editingName) || (!isCreatingNew && !selectedTemplate)" |
| > |
| {{ tm('t2iTemplateEditor.save') }} |
| </v-btn> |
| </div> |
| </v-toolbar> |
| <div class="flex-grow-1" style="border-right: 1px solid rgba(0,0,0,0.1);"> |
| <VueMonacoEditor |
| v-model:value="templateContent" |
| :theme="editorTheme" |
| language="html" |
| :options="editorOptions" |
| style="height: 100%;" |
| /> |
| </div> |
| </v-col> |
| |
| |
| <v-col cols="6" class="d-flex flex-column"> |
| <v-toolbar density="compact" color="surface-variant"> |
| <v-toolbar-title class="text-subtitle-2">{{ tm('t2iTemplateEditor.livePreview') }}</v-toolbar-title> |
| <v-spacer></v-spacer> |
| <v-btn |
| variant="text" |
| size="small" |
| @click="refreshPreview" |
| :loading="previewLoading" |
| > |
| {{ tm('t2iTemplateEditor.refreshPreview') }} |
| </v-btn> |
| </v-toolbar> |
| <div class="flex-grow-1 preview-container"> |
| <iframe |
| ref="previewFrame" |
| :srcdoc="previewContent" |
| style="width: 100%; height: 100%; border: none; zoom: 0.6;" |
| /> |
| </div> |
| </v-col> |
| </v-row> |
| </v-card-text> |
| |
| <v-card-actions class="px-6 py-4"> |
| <v-row no-gutters class="align-center"> |
| <v-col> |
| <div class="text-caption text-grey"> |
| <v-icon size="16" class="mr-1">mdi-information</v-icon> |
| {{ tm('t2iTemplateEditor.syntaxHint') }} |
| </div> |
| </v-col> |
| <v-col cols="auto"> |
| <v-btn |
| variant="text" |
| @click="closeDialog" |
| > |
| {{ t('core.common.cancel') }} |
| </v-btn> |
| <v-btn |
| color="primary" |
| @click="promptApplyAndClose" |
| :loading="saveLoading" |
| :disabled="isCreatingNew || !selectedTemplate" |
| > |
| {{ tm('t2iTemplateEditor.saveAndApply') }} |
| </v-btn> |
| </v-col> |
| </v-row> |
| </v-card-actions> |
| </v-card> |
| |
| |
| <v-dialog v-model="resetDialog" max-width="400px"> |
| <v-card> |
| <v-card-title>{{ tm('t2iTemplateEditor.confirmReset') }}</v-card-title> |
| <v-card-text> |
| {{ tm('t2iTemplateEditor.confirmResetMessage') }} |
| </v-card-text> |
| <v-card-actions> |
| <v-spacer></v-spacer> |
| <v-btn text @click="resetDialog = false">{{ t('core.common.cancel') }}</v-btn> |
| <v-btn color="warning" @click="confirmReset" :loading="resetLoading">{{ tm('t2iTemplateEditor.confirmResetButton') }}</v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| |
| <v-dialog v-model="deleteDialog" max-width="400px"> |
| <v-card> |
| <v-card-title>{{ tm('t2iTemplateEditor.confirmDelete') }}</v-card-title> |
| <v-card-text> |
| {{ tm('t2iTemplateEditor.confirmDeleteMessage', { name: selectedTemplate }) }} |
| </v-card-text> |
| <v-card-actions> |
| <v-spacer></v-spacer> |
| <v-btn text @click="deleteDialog = false">{{ t('core.common.cancel') }}</v-btn> |
| <v-btn color="error" @click="confirmDelete" :loading="saveLoading">{{ tm('t2iTemplateEditor.confirmDeleteButton') }}</v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| |
| <v-dialog v-model="applyAndCloseDialog" max-width="500px"> |
| <v-card> |
| <v-card-title>{{ tm('t2iTemplateEditor.confirmAction') }}</v-card-title> |
| <v-card-text> |
| {{ tm('t2iTemplateEditor.confirmApplyMessage', { name: selectedTemplate }) }} |
| </v-card-text> |
| <v-card-actions> |
| <v-spacer></v-spacer> |
| <v-btn text @click="applyAndCloseDialog = false">{{ t('core.common.cancel') }}</v-btn> |
| <v-btn color="primary" @click="confirmApplyAndClose" :loading="saveLoading">{{ t('core.common.confirm') }}</v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| </v-dialog> |
| </template> |
| |
| <script setup> |
| import { ref, computed, nextTick, watch } from 'vue' |
| import { VueMonacoEditor } from '@guolao/vue-monaco-editor' |
| import { useI18n, useModuleI18n } from '@/i18n/composables' |
| import axios from 'axios' |
| |
| const { t } = useI18n() |
| const { tm } = useModuleI18n('core.shared') |
| |
| |
| const dialog = ref(false) |
| const loading = ref(false) |
| const saveLoading = ref(false) |
| const resetLoading = ref(false) |
| const previewLoading = ref(false) |
| const applyLoading = ref(false) |
| |
| |
| const templates = ref([]) |
| const activeTemplate = ref('base') |
| const selectedTemplate = ref(null) |
| const editingName = ref('') |
| const templateContent = ref('') |
| const isCreatingNew = ref(false) |
| |
| |
| const resetDialog = ref(false) |
| const deleteDialog = ref(false) |
| const applyAndCloseDialog = ref(false) |
| |
| const previewFrame = ref(null) |
| |
| |
| const editorTheme = computed(() => 'vs-light') |
| const editorOptions = { |
| automaticLayout: true, |
| fontSize: 12, |
| lineNumbers: 'on', |
| wordWrap: 'on', |
| minimap: { enabled: false }, |
| scrollBeyondLastLine: false, |
| } |
| |
| |
| const previewVersion = ref('v4.0.0') |
| const syncPreviewVersion = async () => { |
| try { |
| const res = await axios.get('/api/stat/version') |
| const rawVersion = res?.data?.data?.version || res?.data?.version |
| if (rawVersion) { |
| previewVersion.value = rawVersion.startsWith('v') ? rawVersion : `v${rawVersion}` |
| } |
| } catch (error) { |
| console.warn('Failed to fetch version:', error) |
| } |
| } |
| |
| const previewData = computed(() => ({ |
| text: tm('t2iTemplateEditor.previewText') || '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。', |
| version: previewVersion.value |
| })) |
| |
| const previewContent = computed(() => { |
| try { |
| let content = templateContent.value |
| content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.value.text) |
| content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.value.version) |
| return content |
| } catch (error) { |
| return `<div style="color: red; padding: 20px;">模板渲染错误: ${error.message}</div>` |
| } |
| }) |
| |
| |
| const loadInitialData = async () => { |
| loading.value = true |
| try { |
| const [listRes, activeRes] = await Promise.all([ |
| axios.get('/api/t2i/templates'), |
| axios.get('/api/t2i/templates/active') |
| ]) |
| |
| if (listRes.data.status === 'ok') { |
| templates.value = listRes.data.data |
| } else { |
| console.error('加载模板列表失败:', listRes.data.message) |
| } |
| |
| if (activeRes.data.status === 'ok') { |
| activeTemplate.value = activeRes.data.data.active_template |
| } else { |
| console.error('加载活动模板失败:', activeRes.data.message) |
| } |
| |
| |
| if (templates.value.length > 0) { |
| selectedTemplate.value = activeTemplate.value |
| } |
| |
| } catch (error) { |
| console.error('加载初始数据失败:', error) |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| const loadTemplateContent = async (name) => { |
| if (!name) return |
| previewLoading.value = true |
| try { |
| const response = await axios.get(`/api/t2i/templates/${name}`) |
| if (response.data.status === 'ok') { |
| templateContent.value = response.data.data.content |
| } else { |
| console.error(`加载模板 '${name}' 失败:`, response.data.message) |
| } |
| } catch (error) { |
| console.error(`加载模板 '${name}' 失败:`, error) |
| } finally { |
| previewLoading.value = false |
| } |
| } |
| |
| const saveTemplate = async () => { |
| saveLoading.value = true |
| try { |
| if (isCreatingNew.value) { |
| |
| if (!editingName.value) return |
| const response = await axios.post('/api/t2i/templates/create', { |
| name: editingName.value, |
| content: templateContent.value |
| }) |
| await loadInitialData() |
| selectedTemplate.value = response.data.data.name |
| isCreatingNew.value = false |
| } else { |
| |
| if (!selectedTemplate.value) return |
| await axios.put(`/api/t2i/templates/${selectedTemplate.value}`, { |
| content: templateContent.value |
| }) |
| } |
| } catch (error) { |
| console.error('保存模板失败:', error) |
| |
| } finally { |
| saveLoading.value = false |
| } |
| } |
| |
| const setActiveTemplate = async (name) => { |
| applyLoading.value = true |
| try { |
| await axios.post('/api/t2i/templates/set_active', { name }) |
| activeTemplate.value = name |
| } catch (error) { |
| console.error(`应用模板 '${name}' 失败:`, error) |
| } finally { |
| applyLoading.value = false |
| } |
| } |
| |
| const confirmDelete = async () => { |
| if (!selectedTemplate.value || selectedTemplate.value === 'base') return |
| saveLoading.value = true |
| try { |
| const nameToDelete = selectedTemplate.value |
| await axios.delete(`/api/t2i/templates/${nameToDelete}`) |
| deleteDialog.value = false |
| |
| |
| if (activeTemplate.value === nameToDelete) { |
| await setActiveTemplate('base') |
| } |
| await loadInitialData() |
| selectedTemplate.value = 'base' |
| } catch (error) { |
| console.error(`删除模板 '${selectedTemplate.value}' 失败:`, error) |
| } finally { |
| saveLoading.value = false |
| } |
| } |
| |
| const confirmReset = async () => { |
| resetLoading.value = true |
| try { |
| await axios.post('/api/t2i/templates/reset_default') |
| resetDialog.value = false |
| if (selectedTemplate.value === 'base') { |
| await loadTemplateContent('base') |
| } |
| if (activeTemplate.value !== 'base') { |
| await setActiveTemplate('base') |
| } |
| } catch (error) { |
| console.error('重置模板失败:', error) |
| } finally { |
| resetLoading.value = false |
| } |
| } |
| |
| |
| |
| const resetToDefault = () => { |
| resetDialog.value = true |
| } |
| |
| const newTemplate = () => { |
| isCreatingNew.value = true |
| selectedTemplate.value = null |
| editingName.value = '' |
| templateContent.value = `<!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8"/> |
| <title>New Template</title> |
| </head> |
| <body> |
| <!-- 从这里开始编辑 --> |
| <article>{{ text | safe }}</article> |
| </body> |
| </html> |
| ` |
| } |
| |
| const promptDelete = () => { |
| if (selectedTemplate.value && selectedTemplate.value !== 'base') { |
| deleteDialog.value = true |
| } |
| } |
| |
| const promptApplyAndClose = () => { |
| if (!isCreatingNew.value && selectedTemplate.value) { |
| applyAndCloseDialog.value = true |
| } |
| } |
| |
| const confirmApplyAndClose = async () => { |
| if (isCreatingNew.value) return |
| |
| await saveTemplate() |
| await setActiveTemplate(selectedTemplate.value) |
| applyAndCloseDialog.value = false |
| closeDialog() |
| } |
| |
| const refreshPreview = () => { |
| previewLoading.value = true |
| syncPreviewVersion() |
| nextTick(() => { |
| if (previewFrame.value) { |
| previewFrame.value.contentWindow.location.reload() |
| } |
| setTimeout(() => previewLoading.value = false, 500) |
| }) |
| } |
| |
| const closeDialog = () => { |
| dialog.value = false |
| } |
| |
| // --- 监听器和生命周期 --- |
| |
| watch(dialog, (newVal) => { |
| if (newVal) { |
| syncPreviewVersion() |
| loadInitialData() |
| } else { |
| // 关闭时重置状态 |
| selectedTemplate.value = null |
| templateContent.value = '' |
| isCreatingNew.value = false |
| } |
| }) |
| |
| watch(selectedTemplate, (newName) => { |
| if (newName) { |
| isCreatingNew.value = false |
| loadTemplateContent(newName) |
| } |
| }) |
| |
| defineExpose({ |
| openDialog: () => { |
| dialog.value = true |
| } |
| }) |
| </script> |
| |
| <style scoped> |
| .preview-container { |
| background-color: #f5f5f5; |
| position: relative; |
| } |
| |
| .preview-container::before { |
| content: ''; |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background-image: |
| linear-gradient(45deg, #ccc 25%, transparent 25%), |
| linear-gradient(-45deg, #ccc 25%, transparent 25%), |
| linear-gradient(45deg, transparent 75%, #ccc 75%), |
| linear-gradient(-45deg, transparent 75%, #ccc 75%); |
| background-size: 20px 20px; |
| background-position: 0 0, 0 10px, 10px -10px, -10px 0px; |
| opacity: 0.1; |
| pointer-events: none; |
| } |
| |
| code { |
| background-color: rgba(0,0,0,0.05); |
| padding: 2px 4px; |
| border-radius: 3px; |
| font-size: 0.875em; |
| } |
| </style> |
| |