完善模板编辑与模型管理体验
This commit is contained in:
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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('请先选择模板')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user