完善模板编辑与模型管理体验

This commit is contained in:
zwt13703
2026-07-03 11:14:24 +08:00
parent 52eb058070
commit a796301ae8
19 changed files with 1065 additions and 246 deletions
+1
View File
@@ -6,4 +6,5 @@ export const modelApi = {
update: (id: number, data: any) => http.put(`/models/${id}`, data),
delete: (id: number) => http.delete(`/models/${id}`),
test: (id: number) => http.post(`/models/${id}/test`),
balance: (id: number) => http.get(`/models/${id}/balance`),
}
@@ -0,0 +1,303 @@
<template>
<div :class="['reference-selector', variant]">
<div v-if="variant === 'full'" class="upload-card">
<div v-if="title" class="upload-title">{{ title }}</div>
<div v-if="description" class="upload-desc">{{ description }}</div>
<a-upload-dragger :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
</a-upload-dragger>
</div>
<div v-else class="compact-toolbar">
<a-upload :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<a-button size="small" :loading="uploading">{{ hasSelection ? '继续上传' : '上传文件' }}</a-button>
</a-upload>
<a-button size="small" @click="toggleHistoryPanel">{{ historyPanelOpen ? '收起历史文件' : '选择历史文件' }}</a-button>
</div>
<div v-if="selectedFiles.length" class="selected-list">
<div class="selected-item" v-for="item in selectedFiles" :key="item.file_path">
<span class="selected-name">{{ item.file_name }}</span>
<a-button type="link" size="small" danger @click="removeSelectedFile(item.file_path)">
{{ variant === 'full' ? '移除' : 'x' }}
</a-button>
</div>
</div>
<div v-if="showHistorySection" class="history-card">
<div class="history-head">
<div>
<div class="history-title">历史文件</div>
<div class="history-desc">已上传过的文件会保存在系统里下次可直接选择复用</div>
</div>
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
</div>
<div class="history-search">
<a-input-search
v-model:value="historyKeyword"
placeholder="按文件名搜索历史文件"
allow-clear
@search="fetchReferenceHistory"
/>
</div>
<a-spin :spinning="historyLoading">
<div v-if="referenceHistory.length" class="history-list">
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
<input
type="checkbox"
:checked="isSelected(item.file_path)"
@change="toggleHistoryFile(item)"
/>
<div class="history-item-main">
<div class="history-item-name">{{ item.file_name }}</div>
<div class="history-item-meta">
<span>{{ formatFileSize(item.file_size) }}</span>
<span>{{ formatDateTime(item.created_at) }}</span>
</div>
</div>
</label>
</div>
<a-empty v-else description="暂无历史文件" />
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined } from '@ant-design/icons-vue'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
type SelectedFile = { file_name: string; file_path: string }
const props = withDefaults(defineProps<{
modelValue: SelectedFile[]
title?: string
description?: string
variant?: 'full' | 'compact'
}>(), {
title: '',
description: '',
variant: 'full',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: SelectedFile[]): void
}>()
const uploading = ref(false)
const referenceHistory = ref<ReferenceFile[]>([])
const historyKeyword = ref('')
const historyLoading = ref(false)
const historyPanelOpen = ref(false)
const selectedFiles = computed(() => props.modelValue || [])
const hasSelection = computed(() => selectedFiles.value.length > 0)
const showHistorySection = computed(() => props.variant === 'full' || historyPanelOpen.value)
function updateFiles(files: SelectedFile[]) {
emit('update:modelValue', files)
}
function appendSelectedFile(file: SelectedFile) {
if (selectedFiles.value.some((item) => item.file_path === file.file_path)) return
updateFiles([...selectedFiles.value, file])
}
function removeSelectedFile(filePath: string) {
updateFiles(selectedFiles.value.filter((item) => item.file_path !== filePath))
}
function isSelected(filePath: string) {
return selectedFiles.value.some((item) => item.file_path === filePath)
}
function toggleHistoryFile(item: ReferenceFile) {
if (isSelected(item.file_path)) {
removeSelectedFile(item.file_path)
return
}
appendSelectedFile({ file_name: item.file_name, file_path: item.file_path })
}
async function beforeUpload(file: File) {
try {
uploading.value = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
appendSelectedFile({ file_name: res.data.file_name, file_path: res.data.file_path })
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploading.value = false
}
return false
}
async function fetchReferenceHistory() {
historyLoading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: 1,
page_size: 30,
keyword: historyKeyword.value.trim(),
})
referenceHistory.value = response.data?.items || []
} catch (error: any) {
message.error(error.message || '加载历史文件失败')
} finally {
historyLoading.value = false
}
}
function toggleHistoryPanel() {
historyPanelOpen.value = !historyPanelOpen.value
if (historyPanelOpen.value && !referenceHistory.value.length) {
fetchReferenceHistory()
}
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
onMounted(() => {
if (props.variant === 'full') {
fetchReferenceHistory()
}
})
</script>
<style scoped>
.reference-selector {
display: grid;
gap: 16px;
}
.upload-card {
background: #f0f1f3;
border-radius: 8px;
padding: 16px;
}
.upload-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.upload-desc {
font-size: 12px;
color: #5b626e;
margin-bottom: 12px;
}
.compact-toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
}
.history-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.history-desc {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.history-search {
margin: 12px 0;
}
.history-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow-y: auto;
}
.history-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
cursor: pointer;
}
.history-item-main {
min-width: 0;
flex: 1;
}
.history-item-name {
font-size: 13px;
font-weight: 500;
color: #111827;
word-break: break-all;
}
.history-item-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.selected-list {
display: grid;
gap: 8px;
}
.selected-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: #f5f6f8;
border-radius: 8px;
padding: 8px 12px;
}
.selected-name {
min-width: 0;
font-size: 13px;
word-break: break-all;
}
</style>
+2 -1
View File
@@ -12,6 +12,7 @@ export const useModelStore = defineStore('model', () => {
async function update(id: number, data: any) { await modelApi.update(id, data); await fetchList() }
async function remove(id: number) { await modelApi.delete(id); await fetchList() }
async function test(id: number) { return await modelApi.test(id) }
async function balance(id: number) { return await modelApi.balance(id) }
return { models, loading, fetchList, create, update, remove, test }
return { models, loading, fetchList, create, update, remove, test, balance }
})
+1 -1
View File
@@ -12,7 +12,7 @@ export const useTemplateStore = defineStore('template', () => {
async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } }
async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
async function save(id: number) { const data = paragraphs.value.map(p => ({ id: p.id, sort_index: p.sort_index, title: p.title, edit_mode: p.edit_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) }
async function save(id: number) { const data = paragraphs.value.map((p, index) => ({ id: p.id, sort_index: index + 1, anchor_title: p.anchor_title, title: p.title, content: p.content, edit_mode: p.edit_mode, write_mode: p.write_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) }
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
+2
View File
@@ -5,8 +5,10 @@ export interface Template {
export interface Paragraph {
id: number; template_id: number; sort_index: number; title: string; content: string
anchor_title: string
style_json: string; is_table: boolean; table_json: string
edit_mode: 'manual' | 'ai'; model_id: number | null
write_mode: 'replace_section' | 'append_after_heading' | 'replace_heading_only'
need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string
output_format: 'text' | 'table' | 'mixed' | 'chart'
}
+19 -36
View File
@@ -64,15 +64,10 @@
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div>
<div v-if="paragraph.need_file" class="file-info">
<a-upload :multiple="true" :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }}</a-button>
</a-upload>
<div v-if="uploadedFiles[paragraph.id]?.length" class="uploaded-list">
<span v-for="item in uploadedFiles[paragraph.id]" :key="item.file_path" class="uploaded-name">
{{ item.file_name }}
<button class="remove-file-btn" @click="removeUploadedFile(paragraph.id, item.file_path)">x</button>
</span>
</div>
<ReferenceFileSelector
v-model="uploadedFiles[paragraph.id]"
variant="compact"
/>
</div>
<span v-else class="no-file-tag">无需上传</span>
</div>
@@ -89,12 +84,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template'
import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate'
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute()
const router = useRouter()
@@ -105,8 +100,6 @@ const templates = ref<any[]>([])
const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
const uploadedFilePaths = ref<Record<number, string[]>>({})
const uploadingMap = ref<Record<number, boolean>>({})
const generating = ref(false)
const tplInfo = ref<any>({})
@@ -115,6 +108,19 @@ const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mod
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
watch(
uploadedFiles,
(value) => {
const filePaths = Object.fromEntries(
Object.entries(value).map(([key, items]) => [Number(key), (items || []).map((item) => item.file_path)])
)
uploadedFilePaths.value = filePaths
},
{ deep: true }
)
const uploadedFilePaths = ref<Record<number, string[]>>({})
async function refreshTemplates() {
await tplStore.fetchList()
templates.value = tplStore.templates as any
@@ -140,29 +146,6 @@ async function onTplChange(id: number) {
uploadedFilePaths.value = {}
}
async function handleFileUpload(paragraphId: number, file: File) {
try {
uploadingMap.value[paragraphId] = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
const nextFile = { file_name: res.data.file_name, file_path: res.data.file_path }
uploadedFiles.value[paragraphId] = [...(uploadedFiles.value[paragraphId] || []), nextFile]
uploadedFilePaths.value[paragraphId] = [...(uploadedFilePaths.value[paragraphId] || []), res.data.file_path]
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploadingMap.value[paragraphId] = false
}
return false
}
function removeUploadedFile(paragraphId: number, filePath: string) {
uploadedFiles.value[paragraphId] = (uploadedFiles.value[paragraphId] || []).filter((item) => item.file_path !== filePath)
uploadedFilePaths.value[paragraphId] = (uploadedFilePaths.value[paragraphId] || []).filter((item) => item !== filePath)
}
async function startGen() {
if (!selectedTplId.value) {
message.warning('请先选择模板')
+75 -9
View File
@@ -20,12 +20,21 @@
<div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
<div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div>
<div class="mc-provider">思考模式{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
<div v-if="isDeepSeek(item) && balanceMap[item.id]" class="mc-provider">
余额{{ formatBalanceText(balanceMap[item.id]) }}
</div>
</div>
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
</div>
<div class="mc-actions">
<a-button size="small" @click="runTest(item)">测试</a-button>
<a-button size="small" :loading="testingMap[item.id]" @click="runTest(item)">
<template #icon><reload-outlined /></template>
刷新测试
</a-button>
<a-button v-if="isDeepSeek(item)" size="small" :loading="balanceLoadingMap[item.id]" @click="fetchBalance(item)">
查看余额
</a-button>
<a-button size="small" @click="openEdit(item)">编辑</a-button>
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
</div>
@@ -39,7 +48,13 @@
<a-input v-model:value="form.name" />
</a-form-item>
<a-form-item label="供应厂商">
<a-input v-model:value="form.provider" />
<a-select v-model:value="providerPreset" @change="applyProviderPreset">
<a-select-option value="deepseek">DeepSeek</a-select-option>
<a-select-option value="custom">自定义</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="providerPreset === 'custom'" label="自定义厂商名称">
<a-input v-model:value="form.provider" placeholder="例如 OpenAI / Anthropic / 其他" />
</a-form-item>
<a-form-item label="API 格式">
<a-select v-model:value="form.api_format">
@@ -66,7 +81,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Modal, message } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { useModelStore } from '@/stores/model'
const store = useModelStore()
@@ -75,6 +91,41 @@ const modalOpen = ref(false)
const isEdit = ref(false)
const editId = ref(0)
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false })
const providerPreset = ref<'deepseek' | 'custom'>('custom')
const testingMap = ref<Record<number, boolean>>({})
const balanceLoadingMap = ref<Record<number, boolean>>({})
const balanceMap = ref<Record<number, { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }>>({})
function isDeepSeek(item: any) {
const provider = (item?.provider || '').trim().toLowerCase()
return provider === 'deepseek'
}
function applyProviderPreset(value: 'deepseek' | 'custom') {
if (value === 'deepseek') {
form.value.provider = 'DeepSeek'
form.value.api_format = 'openai'
if (!form.value.api_endpoint || form.value.api_endpoint.includes('deepseek')) {
form.value.api_endpoint = 'https://api.deepseek.com'
}
return
}
if (form.value.provider === 'DeepSeek') {
form.value.provider = ''
}
}
function inferProviderPreset(item?: any) {
providerPreset.value = isDeepSeek(item || form.value) ? 'deepseek' : 'custom'
}
function formatBalanceText(data: { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }) {
const infos = data?.balance_infos || []
if (!infos.length) return data?.is_available ? '可用' : '不可用'
return infos
.map((item) => `${item.currency} ${item.total_balance}(充值 ${item.topped_up_balance} / 赠送 ${item.granted_balance}`)
.join('')
}
async function refreshList() {
await store.fetchList()
@@ -88,6 +139,8 @@ onMounted(async () => {
function openAdd() {
isEdit.value = false
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
providerPreset.value = 'deepseek'
applyProviderPreset('deepseek')
modalOpen.value = true
}
@@ -103,6 +156,7 @@ function openEdit(item: any) {
supports_streaming: !!item.supports_streaming,
enable_reasoning: !!item.enable_reasoning,
}
inferProviderPreset(item)
modalOpen.value = true
}
@@ -124,15 +178,27 @@ async function toggleStatus(item: any) {
}
async function runTest(item: any) {
testingMap.value[item.id] = true
try {
const result: any = await store.test(item.id)
Modal.info({
title: '连接测试结果',
width: 680,
content: JSON.stringify(result.data, null, 2),
})
message.success(result.data?.message || `模型 ${item.name} 测试成功`, 2)
} catch (error: any) {
message.error(error.message || '连接测试失败')
message.error(error.message || '连接测试失败', 2)
} finally {
testingMap.value[item.id] = false
}
}
async function fetchBalance(item: any) {
balanceLoadingMap.value[item.id] = true
try {
const result: any = await store.balance(item.id)
balanceMap.value[item.id] = result.data
message.success(`已刷新 ${item.name} 余额`, 2)
} catch (error: any) {
message.error(error.message || '余额查询失败', 2)
} finally {
balanceLoadingMap.value[item.id] = false
}
}
</script>
+29 -1
View File
@@ -242,6 +242,11 @@ onBeforeUnmount(() => {
<style scoped>
.detail-page {
padding: 24px;
height: calc(100vh - 52px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.detail-head {
@@ -299,26 +304,35 @@ onBeforeUnmount(() => {
.preview-card {
margin-bottom: 16px;
border-radius: 18px;
flex-shrink: 0;
}
.detail-layout {
display: flex;
gap: 16px;
align-items: flex-start;
min-height: 0;
flex: 1;
overflow: hidden;
}
.detail-left {
width: 360px;
flex-shrink: 0;
height: 100%;
min-height: 0;
}
.detail-right {
min-width: 0;
flex: 1;
height: 100%;
min-height: 0;
}
.left-card {
border-radius: 18px;
height: 100%;
}
.status-message {
@@ -416,7 +430,7 @@ onBeforeUnmount(() => {
border-radius: 14px;
background: #fff;
border: 1px solid #e5e7eb;
min-height: 480px;
min-height: 100%;
}
.preview-section-head {
@@ -444,6 +458,20 @@ onBeforeUnmount(() => {
margin-bottom: 14px;
}
:deep(.left-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.preview-card) {
height: 100%;
}
:deep(.preview-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.result-text) {
margin: 0 0 12px;
line-height: 1.8;
+270 -167
View File
@@ -7,6 +7,10 @@
</a>
<span class="divider">|</span>
<span class="editor-title">{{ templateName }}</span>
<a-radio-group v-model:value="editorMode" size="small" button-style="solid">
<a-radio-button value="paragraph">段落配置</a-radio-button>
<a-radio-button value="manual">手动编辑模板</a-radio-button>
</a-radio-group>
<span class="flex-spacer" />
<a-button type="primary" @click="saveTemplate">保存模板</a-button>
</div>
@@ -25,7 +29,7 @@
@click="selectPara(paragraph.id)"
>
<span class="pli-index">{{ paragraph.sort_index }}</span>
<span class="pli-title">{{ paragraph.title || '未命名段落' }}</span>
<span class="pli-title">{{ listTitle(paragraph) }}</span>
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
</span>
@@ -35,30 +39,78 @@
<section class="editor-center">
<div class="center-toolbar">
<span class="tb-btn active"><b>B</b></span>
<span class="tb-btn"><i>I</i></span>
<span class="tb-btn"><u>U</u></span>
<span class="tb-divider" />
<span class="tb-btn"><font-size-outlined /></span>
<span class="tb-btn"><ordered-list-outlined /></span>
<span class="tb-btn"><table-outlined /></span>
<span class="tb-divider" />
<span class="tb-btn"><align-left-outlined /></span>
<span class="tb-btn"><align-center-outlined /></span>
<span class="tb-btn"><align-right-outlined /></span>
<span class="toolbar-hint">点击左侧段落或文档中的段落块查看配置</span>
<template v-if="editorMode === 'paragraph'">
<span class="tb-btn active"><b>B</b></span>
<span class="tb-btn"><i>I</i></span>
<span class="tb-btn"><u>U</u></span>
<span class="tb-divider" />
<span class="tb-btn"><font-size-outlined /></span>
<span class="tb-btn"><ordered-list-outlined /></span>
<span class="tb-btn"><table-outlined /></span>
<span class="tb-divider" />
<span class="tb-btn"><align-left-outlined /></span>
<span class="tb-btn"><align-center-outlined /></span>
<span class="tb-btn"><align-right-outlined /></span>
</template>
<span class="toolbar-hint">
{{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }}
</span>
</div>
<div class="center-scroll">
<div class="doc-edit-page">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
</span>
<div class="sec-title" :style="{ marginTop: paragraph.sort_index === 1 ? '0' : '' }">{{ paragraph.title }}</div>
<p v-if="paragraph.content">{{ paragraph.content }}</p>
<p v-else class="empty-text">点击左侧配置此段落</p>
</div>
<template v-if="editorMode === 'paragraph'">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
</span>
<div class="sec-title" :style="{ marginTop: paragraph.sort_index === 1 ? '0' : '' }">{{ paragraph.title }}</div>
<p v-if="paragraph.content">{{ paragraph.content }}</p>
<p v-else class="empty-text">点击左侧配置此段落</p>
</div>
</template>
<template v-else>
<div
v-for="paragraph in paragraphs"
:key="paragraph.id"
:class="['para-block', 'manual-block', { selected: selectedId === paragraph.id }]"
@click="selectPara(paragraph.id)"
>
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 段落' : '手动段落' }}
</span>
<div class="manual-block-head">
<span class="manual-block-index">段落 {{ paragraph.sort_index }}</span>
<span class="manual-block-anchor" v-if="paragraph.anchor_title && paragraph.anchor_title !== paragraph.title">
原标题{{ paragraph.anchor_title }}
</span>
</div>
<div class="field-label">标题</div>
<a-input
v-model:value="paragraph.title"
class="manual-title-input"
placeholder="输入导出时使用的标题"
@click.stop
/>
<div class="field-label">正文</div>
<a-textarea
v-model:value="paragraph.content"
class="manual-content-input"
:rows="paragraph.write_mode === 'replace_heading_only' ? 3 : 6"
:placeholder="contentPlaceholder(paragraph)"
@click.stop
/>
<div class="manual-block-actions">
<a-button size="small" @click.stop="insertBlockAfter(paragraph, 'manual')">在后面新增固定块</a-button>
<a-button size="small" type="primary" ghost @click.stop="insertBlockAfter(paragraph, 'ai')">在后面新增 AI </a-button>
<a-button size="small" danger :disabled="!canDeleteBlock(paragraph)" @click.stop="removeBlock(paragraph)">删除当前块</a-button>
</div>
<div class="manual-block-meta">
<span class="meta-item">编辑方式{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
<span class="meta-item">写入方式{{ writeModeLabel(paragraph.write_mode) }}</span>
</div>
</div>
</template>
</div>
</div>
</section>
@@ -78,6 +130,13 @@
<a-select-option value="manual">人工编辑</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="写入方式">
<a-select v-model:value="selectedPara.write_mode">
<a-select-option value="replace_section">替换标题下整段</a-select-option>
<a-select-option value="append_after_heading">标题下插入内容</a-select-option>
<a-select-option value="replace_heading_only">仅替换标题</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型">
<a-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
@@ -94,6 +153,31 @@
</a-form>
</div>
<div class="config-section">
<div class="config-section-title">模板内容</div>
<a-alert
type="info"
show-icon
message="这里的标题和正文会作为模板快照保存。你可以把一个大段拆成多个块,再插入 AI 块。系统会按同一原标题锚点,把这些块顺序写回 Word。"
class="template-alert"
/>
<a-form layout="vertical">
<a-form-item label="原标题锚点">
<a-input :value="selectedPara.anchor_title || selectedPara.title" disabled />
</a-form-item>
<a-form-item label="导出标题">
<a-input v-model:value="selectedPara.title" placeholder="输入导出时使用的标题" />
</a-form-item>
<a-form-item label="模板正文">
<a-textarea
v-model:value="selectedPara.content"
:rows="selectedPara.write_mode === 'replace_heading_only' ? 4 : 8"
:placeholder="contentPlaceholder(selectedPara)"
/>
</a-form-item>
</a-form>
</div>
<div class="config-section">
<div class="config-section-title">提示词与文件</div>
<div class="toggle-row">
@@ -134,68 +218,12 @@
</a-steps>
<div v-if="testStep === 0">
<div class="test-paragraph-card">
<div class="test-para-title">{{ selectedPara?.title }}</div>
<div class="test-para-desc">{{ selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。' }}</div>
<a-upload-dragger :multiple="true" :beforeUpload="beforeTestUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
</a-upload-dragger>
</div>
<div v-if="testFiles.length" class="uploaded-list">
<div class="uploaded-item" v-for="file in testFiles" :key="file.uid">
<span class="uploaded-name">{{ file.name }}</span>
<a-button type="link" size="small" danger @click="removeTestFile(file.uid)">移除</a-button>
</div>
</div>
<div class="history-card">
<div class="history-head">
<div>
<div class="history-title">历史文件</div>
<div class="history-desc">已上传过的文件会保存在系统里下次可直接选择复用</div>
</div>
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
</div>
<div class="history-search">
<a-input-search
v-model:value="historyKeyword"
placeholder="按文件名搜索历史文件"
allow-clear
@search="fetchReferenceHistory"
/>
</div>
<a-spin :spinning="historyLoading">
<div v-if="referenceHistory.length" class="history-list">
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
<input
type="checkbox"
:checked="selectedHistoryPaths.includes(item.file_path)"
@change="toggleHistoryFile(item.file_path)"
/>
<div class="history-item-main">
<div class="history-item-name">{{ item.file_name }}</div>
<div class="history-item-meta">
<span>{{ formatFileSize(item.file_size) }}</span>
<span>{{ formatDateTime(item.created_at) }}</span>
</div>
</div>
</label>
</div>
<a-empty v-else description="暂无历史文件" />
</a-spin>
</div>
<div v-if="selectedHistoryItems.length" class="uploaded-list">
<div class="uploaded-item" v-for="item in selectedHistoryItems" :key="item.file_path">
<span class="uploaded-name">{{ item.file_name }}</span>
<a-button type="link" size="small" danger @click="toggleHistoryFile(item.file_path)">取消选择</a-button>
</div>
</div>
<ReferenceFileSelector
v-model="testSelectedFiles"
:title="selectedPara?.title || ''"
:description="selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
variant="full"
/>
<a-button type="primary" block :loading="testing" @click="startTest">开始测试支持多文件</a-button>
</div>
@@ -228,7 +256,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@@ -239,18 +267,11 @@ import {
FontSizeOutlined,
OrderedListOutlined,
TableOutlined,
UploadOutlined,
} from '@ant-design/icons-vue'
import { useTemplateStore } from '@/stores/template'
import { useModelStore } from '@/stores/model'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
interface LocalUploadFile {
uid: string
name: string
raw: File
}
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute()
const router = useRouter()
@@ -261,33 +282,112 @@ const paragraphs = ref<any[]>([])
const models = ref<any[]>([])
const selectedId = ref(0)
const templateName = ref('模板编辑')
const editorMode = ref<'paragraph' | 'manual'>('paragraph')
const testOpen = ref(false)
const testStep = ref(0)
const testing = ref(false)
const testStatusText = ref('正在解析文件内容并请求 AI 模型...')
const testFiles = ref<LocalUploadFile[]>([])
const testSelectedFiles = ref<Array<{ file_name: string; file_path: string }>>([])
const testResultHtml = ref('')
const testResultMessage = ref('')
const testFileSummaries = ref<any[]>([])
const streamedText = ref('')
const referenceHistory = ref<ReferenceFile[]>([])
const selectedHistoryPaths = ref<string[]>([])
const historyKeyword = ref('')
const historyLoading = ref(false)
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
const selectedHistoryItems = computed(() =>
referenceHistory.value.filter((item) => selectedHistoryPaths.value.includes(item.file_path))
)
function selectPara(id: number) {
selectedId.value = id
}
function normalizeSortIndex() {
paragraphs.value = paragraphs.value.map((item, index) => ({
...item,
sort_index: index + 1,
}))
}
function writeModeLabel(mode: string) {
if (mode === 'append_after_heading') return '标题下插入内容'
if (mode === 'replace_heading_only') return '仅替换标题'
return '替换标题下整段'
}
function listTitle(paragraph: any) {
if (!paragraph?.anchor_title || paragraph.anchor_title === paragraph.title) {
return paragraph?.title || '未命名段落'
}
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title}`
}
function canDeleteBlock(paragraph: any) {
return paragraphs.value.filter((item) => item.anchor_title === paragraph.anchor_title).length > 1
}
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
const index = paragraphs.value.findIndex((item) => item === sourceParagraph)
if (index < 0) return
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_title || '未命名段落'
const newBlock = {
id: -Date.now() - Math.floor(Math.random() * 1000),
template_id: sourceParagraph.template_id,
sort_index: sourceParagraph.sort_index + 1,
anchor_title: sourceParagraph.anchor_title || blockTitle,
title: blockTitle,
content: '',
style_json: sourceParagraph.style_json || '{}',
is_table: false,
table_json: '{}',
edit_mode: editMode,
write_mode: 'replace_section',
model_id: editMode === 'ai' ? sourceParagraph.model_id ?? null : null,
need_prompt: editMode === 'ai',
prompt_text: editMode === 'ai' ? sourceParagraph.prompt_text || '' : '',
need_file: false,
file_note: '',
output_format: 'text',
}
paragraphs.value.splice(index + 1, 0, newBlock)
normalizeSortIndex()
nextTick(() => {
selectedId.value = newBlock.id
})
}
function removeBlock(paragraph: any) {
if (!canDeleteBlock(paragraph)) {
message.warning('当前节至少保留一个块')
return
}
const index = paragraphs.value.findIndex((item) => item === paragraph)
if (index < 0) return
paragraphs.value.splice(index, 1)
normalizeSortIndex()
const next = paragraphs.value[index] || paragraphs.value[index - 1] || paragraphs.value[0]
selectedId.value = next?.id || 0
}
function contentPlaceholder(paragraph: any) {
if (paragraph?.edit_mode === 'manual') {
return paragraph?.write_mode === 'replace_heading_only'
? '仅替换标题时,这里的正文仅作为备注保留,不会覆盖原文。'
: '输入人工维护的正文内容,导出时将按写入方式写回 Word。'
}
if (paragraph?.write_mode === 'replace_heading_only') {
return '该段落只更新标题,正文不会被 AI 覆盖。可在这里记录上下文备注。'
}
return '这里可填写模板参考正文、固定说明或给 AI 的上下文。'
}
async function saveTemplate() {
const templateId = Number(route.params.id)
await store.save(templateId)
const template = await store.fetchOne(templateId)
templateName.value = template.name
paragraphs.value = store.paragraphs as any
if (selectedId.value <= 0 && paragraphs.value.length) {
selectedId.value = paragraphs.value[0].id
}
message.success('模板配置已保存')
}
@@ -295,71 +395,21 @@ function openTestModal() {
testOpen.value = true
testStep.value = 0
testing.value = false
testFiles.value = []
testSelectedFiles.value = []
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
selectedHistoryPaths.value = []
fetchReferenceHistory()
}
function resetTestModal() {
testOpen.value = false
testStep.value = 0
testing.value = false
testFiles.value = []
testSelectedFiles.value = []
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
streamedText.value = ''
selectedHistoryPaths.value = []
}
function beforeTestUpload(file: File) {
testFiles.value.push({
uid: `${Date.now()}-${Math.random()}`,
name: file.name,
raw: file,
})
return false
}
function removeTestFile(uid: string) {
testFiles.value = testFiles.value.filter((item) => item.uid !== uid)
}
function toggleHistoryFile(filePath: string) {
if (selectedHistoryPaths.value.includes(filePath)) {
selectedHistoryPaths.value = selectedHistoryPaths.value.filter((item) => item !== filePath)
return
}
selectedHistoryPaths.value = [...selectedHistoryPaths.value, filePath]
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
async function fetchReferenceHistory() {
historyLoading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: 1,
page_size: 30,
keyword: historyKeyword.value.trim(),
})
referenceHistory.value = response.data?.items || []
} catch (error: any) {
message.error(error.message || '加载历史文件失败')
} finally {
historyLoading.value = false
}
}
function renderTestResult(content: any) {
@@ -388,29 +438,17 @@ function renderTestResult(content: any) {
async function startTest() {
if (!selectedPara.value) return
if (selectedPara.value.need_file && !testFiles.value.length && !selectedHistoryPaths.value.length) {
if (selectedPara.value.need_file && !testSelectedFiles.value.length) {
message.warning('请先上传至少一个参考文件')
return
}
testStep.value = 1
testing.value = true
testStatusText.value = '正在上传文件...'
testStatusText.value = '正在整理参考文件...'
try {
const filePaths: string[] = []
for (const item of testFiles.value) {
const formData = new FormData()
formData.append('file', item.raw)
const uploadRes: any = await generateApi.upload(formData)
filePaths.push(uploadRes.data.file_path)
}
for (const filePath of selectedHistoryPaths.value) {
if (!filePaths.includes(filePath)) {
filePaths.push(filePath)
}
}
await fetchReferenceHistory()
const filePaths = testSelectedFiles.value.map((item) => item.file_path)
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
const templateId = Number(route.params.id)
@@ -644,6 +682,7 @@ onMounted(async () => {
padding: 24px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.doc-edit-page {
@@ -652,6 +691,7 @@ onMounted(async () => {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 80px 72px 120px;
min-height: 500px;
flex-shrink: 0;
}
.para-block {
@@ -705,6 +745,65 @@ onMounted(async () => {
color: #9aa1ad;
}
.manual-block {
padding: 20px;
margin-bottom: 16px;
border: 1px solid #e7e9ee;
border-radius: 12px;
}
.manual-block-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.manual-block-index {
font-size: 12px;
font-weight: 600;
color: #5b5bd6;
}
.manual-block-anchor {
font-size: 12px;
color: #7b8190;
}
.field-label {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #30343c;
}
.manual-title-input {
margin-bottom: 12px;
}
.manual-content-input {
margin-bottom: 12px;
}
.manual-block-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.manual-block-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.meta-item {
font-size: 12px;
color: #7b8190;
}
.editor-right {
width: 380px;
flex-shrink: 0;
@@ -739,6 +838,10 @@ onMounted(async () => {
margin-top: 16px;
}
.template-alert {
margin-bottom: 12px;
}
.para-list-item {
display: flex;
align-items: center;