This commit is contained in:
zwt13703
2026-07-02 14:48:05 +08:00
parent a1a314cf99
commit e558733f05
64 changed files with 3258 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>AI 文档模板生成系统</title></head><body>
<div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ai-doc-template-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"ant-design-vue": "^4.1.0",
"@ant-design/icons-vue": "^7.0.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.1.0",
"vue-tsc": "^2.0.0"
}
}
+1384
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
<template>
<a-config-provider :theme="{ token: { colorPrimary: '#5b5bd6', borderRadius: 8 } }">
<a-layout style="min-height:100vh">
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible theme="light" :width="220">
<div class="logo">{{ collapsed ? "A" : "AI 文档模板" }}</div>
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="onMenuClick">
<a-menu-item key="/templates"><folder-outlined /> 模板管理</a-menu-item>
<a-menu-item key="/models"><cpu-outlined /> 模型管理</a-menu-item>
<a-menu-item key="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header style="background:#fff;padding:0 24px;display:flex;align-items:center;border-bottom:1px solid #f0f0f0">
<menu-unfold-outlined v-if="collapsed" @click="collapsed=false" style="font-size:18px;cursor:pointer" />
<menu-fold-outlined v-else @click="collapsed=true" style="font-size:18px;cursor:pointer" />
<div style="flex:1" />
<a-badge :count="0" :overflow-count="99"><bell-outlined style="font-size:18px;color:#999" /></a-badge>
<span style="margin-left:16px;font-size:13px;color:#666">周文涛</span>
</a-layout-header>
<a-layout-content style="margin:0;background:#f5f6f8;overflow:auto">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</a-config-provider>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
FolderOutlined, CpuOutlined, ThunderboltOutlined, ClockCircleOutlined,
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const selectedKeys = ref([route.path])
watch(() => route.path, p => { selectedKeys.value = [p] })
function onMenuClick(info: any) {
router.push(info.key)
}
</script>
<style scoped>
.logo { height: 52px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: #5b5bd6; border-bottom: 1px solid #f0f0f0; }
</style>
+13
View File
@@ -0,0 +1,13 @@
import http from './index'
export const generateApi = {
test: (data: any) => http.post('/generate/test', data),
full: (data: any) => http.post('/generate/full', data),
progress: (id: number) => `/api/v1/generate/progress/${id}`,
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
documents: (params?: any) => http.get('/generate/documents', { params }),
getDocument: (id: number) => http.get(`/generate/documents/${id}`),
deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`),
exportDocx: (id: number) => `/api/v1/export/${id}/docx`,
exportPdf: (id: number) => `/api/v1/export/${id}/pdf`,
}
+14
View File
@@ -0,0 +1,14 @@
import axios from 'axios'
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })
http.interceptors.response.use(
res => res.data,
err => {
const msg = err.response?.data?.message || err.message || '网络错误'
console.error('[API Error]', msg)
return Promise.reject({ code: -1, message: msg })
}
)
export default http
+9
View File
@@ -0,0 +1,9 @@
import http from './index'
export const modelApi = {
list: () => http.get('/models'),
create: (data: any) => http.post('/models', data),
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`),
}
+9
View File
@@ -0,0 +1,9 @@
import http from './index'
export const templateApi = {
list: (params?: any) => http.get('/templates', { params }),
get: (id: number) => http.get(`/templates/${id}`),
upload: (formData: FormData) => http.post('/templates/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
saveParagraphs: (id: number, data: any) => http.put(`/templates/${id}/paragraphs`, data),
delete: (id: number) => http.delete(`/templates/${id}`),
}
+6
View File
@@ -0,0 +1,6 @@
<template><div ref="containerRef" style="overflow-y:auto;padding:24px;display:flex;justify-content:center;background:#f0f1f3;flex:1"><div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08);font-size:14px;line-height:1.8"><slot /></div></div></template>
<script setup lang="ts">
import { ref } from "vue";
const containerRef = ref<HTMLElement|null>(null);
defineExpose({containerRef})
</script>
+6
View File
@@ -0,0 +1,6 @@
<template><a-upload-dragger :multiple="multiple" :beforeUpload="(f)=>{$emit('upload',f);return false}" :showUploadList="showList"><p class="ant-upload-drag-icon"><upload-outlined /></p><p class="ant-upload-text">{{text}}</p><p class="ant-upload-hint">{{hint}}</p></a-upload-dragger></template>
<script setup lang="ts">
import { UploadOutlined } from "@ant-design/icons-vue";
defineProps<{text?:string;hint?:string;multiple?:boolean;showList?:boolean}>()
defineEmits<{upload:[file:File]}>()
</script>
+10
View File
@@ -0,0 +1,10 @@
<template><a-modal v-model:open="visible" :title="isEdit?'编辑模型':'添加模型'" @ok="$emit('save',form)"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI</a-select-option><a-select-option value="anthropic">Anthropic</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></template>
<script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{open:boolean;isEdit:boolean;initialData?:any}>()
const emit = defineEmits<{save:[data:any];close:[]}>()
const visible = ref(false)
const form = ref({name:'',provider:'',api_format:'openai',api_endpoint:'',api_key:''})
watch(()=>props.open,v=>{visible.value=v;if(v&&props.isEdit&&props.initialData)form.value={...form.value,...props.initialData}})
watch(visible,v=>{if(!v)emit('close')})
</script>
+5
View File
@@ -0,0 +1,5 @@
<template><div><a-form layout="vertical"><a-form-item label="编辑方式"><a-select v-model:value="data.edit_mode"><a-select-option value="ai">AI 生成</a-select-option><a-select-option value="manual">人工编辑</a-select-option></a-select></a-form-item><a-form-item v-if="data.edit_mode==='ai'" label="生成模型"><a-select v-model:value="data.model_id" allowClear placeholder="使用默认模型"><a-select-option v-for="m in models" :key="m.id" :value="m.id">{{m.name}}</a-select-option></a-select></a-form-item><a-form-item label="输出格式"><a-select v-model:value="data.output_format"><a-select-option value="text">正式报告段落</a-select-option><a-select-option value="table">表格形式</a-select-option><a-select-option value="mixed">混合内容</a-select-option></a-select></a-form-item><a-form-item label="需要提示词"><a-switch v-model:checked="data.need_prompt" /></a-form-item><a-form-item v-if="data.need_prompt" label="预设提示词"><a-textarea v-model:value="data.prompt_text" :rows="4" /></a-form-item><a-form-item label="需要参考文件"><a-switch v-model:checked="data.need_file" /></a-form-item><a-form-item v-if="data.need_file" label="备注"><a-input v-model:value="data.file_note" placeholder="提示上传什么文件" /></a-form-item></a-form><a-button v-if="data.edit_mode==='ai'" type="primary" block @click="$emit('test')">立即测试</a-button></div></template>
<script setup lang="ts">
defineProps<{data:any;models:any[]}>()
defineEmits<{test:[]}>()
</script>
+6
View File
@@ -0,0 +1,6 @@
<template><div class="para-list"><div v-for="(p,i) in paragraphs" :key="p.id" :class="['para-item',{active:activeId===p.id}]" @click="$emit('select',p.id)"><span class="idx">{{i+1}}</span><span class="title">{{p.title||"段落"+p.id}}</span><a-tag :color="p.edit_mode==='ai'?'blue':'orange'" size="small">{{p.edit_mode==='ai'?'AI':'手动'}}</a-tag></div></div></template>
<script setup lang="ts">
defineProps<{paragraphs:any[];activeId:number}>()
defineEmits<{select:[id:number]}>()
</script>
<style scoped>.para-list{padding:8px}.para-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;margin-bottom:2px;font-size:13px}.para-item:hover{background:#f5f5f5}.para-item.active{background:#f0f0ff;color:#5b5bd6;font-weight:500}.para-item .idx{width:20px;height:20px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.para-item.active .idx{background:#5b5bd6;color:#fff}.para-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}</style>
+13
View File
@@ -0,0 +1,13 @@
<template><a-modal v-model:open="visible" title="段落测试" :footer="null" width="680px"><a-steps :current="step" style="margin-bottom:24px"><a-step title="上传附件" /><a-step title="处理中" /><a-step title="查看结果" /></a-steps><div v-if="step===0"><a-upload-dragger :beforeUpload="(f)=>{uploadFile=f;step=1;return false}"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击上传参考文件</p></a-upload-dragger></div><div v-if="step===1" style="text-align:center;padding:40px"><a-spin size="large" /><p style="margin-top:16px;color:#666">正在解析文件 → 请求 AI 模型...</p></div><div v-if="step===2"><div style="background:#f0f0ff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px"><p>AI 生成结果预览</p><p>{{result}}</p></div></div></a-modal></template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { FileAddOutlined } from "@ant-design/icons-vue";
const props = defineProps<{open:boolean}>()
const emit = defineEmits<{close:[]}>()
const visible = ref(false)
const step = ref(0)
const uploadFile = ref<File|null>(null)
const result = ref("这是 AI 生成的示例内容...")
watch(()=>props.open,v=>{visible.value=v;if(v){step.value=0;uploadFile.value=null}})
watch(visible,v=>{if(!v)emit('close')})
</script>
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
declare module "*.vue" { import type { DefineComponent } from "vue"; const comp: DefineComponent<{}, {}, any>; export default comp; }
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(Antd)
app.mount('#app')
+14
View File
@@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', redirect: '/templates' },
{ path: '/templates', name: 'TemplateList', component: () => import('@/views/TemplateList.vue') },
{ path: '/templates/:id/edit', name: 'TemplateEditor', component: () => import('@/views/TemplateEditor.vue') },
{ path: '/models', name: 'ModelManage', component: () => import('@/views/ModelManage.vue') },
{ path: '/generate', name: 'GeneratePage', component: () => import('@/views/GeneratePage.vue') },
{ path: '/history', name: 'HistoryPage', component: () => import('@/views/HistoryPage.vue') },
{ path: '/preview/:id', name: 'PreviewEdit', component: () => import('@/views/PreviewEdit.vue') },
]
const router = createRouter({ history: createWebHistory(), routes })
export default router
+17
View File
@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { generateApi } from '@/api/generate'
import type { Document } from '@/types'
export const useDocumentStore = defineStore('document', () => {
const documents = ref<Document[]>([])
const currentDoc = ref<Document | null>(null)
const loading = ref(false)
async function fetchList(params?: any) { loading.value = true; try { const r: any = await generateApi.documents(params); documents.value = r.data?.items || r.data || [] } finally { loading.value = false } }
async function generateFull(data: any) { const r: any = await generateApi.full(data); return r.data }
async function cancel(id: number) { await generateApi.cancel(id) }
async function removeDoc(id: number) { await generateApi.deleteDocument(id); await fetchList() }
return { documents, currentDoc, loading, fetchList, generateFull, cancel, removeDoc }
})
+17
View File
@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { modelApi } from '@/api/model'
import type { AiModel } from '@/types'
export const useModelStore = defineStore('model', () => {
const models = ref<AiModel[]>([])
const loading = ref(false)
async function fetchList() { loading.value = true; try { const r: any = await modelApi.list(); models.value = r.data || [] } finally { loading.value = false } }
async function create(data: any) { await modelApi.create(data); await fetchList() }
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) }
return { models, loading, fetchList, create, update, remove, test }
})
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { templateApi } from '@/api/template'
import type { Template, Paragraph } from '@/types'
export const useTemplateStore = defineStore('template', () => {
const templates = ref<Template[]>([])
const currentTemplate = ref<Template | null>(null)
const paragraphs = ref<Paragraph[]>([])
const loading = ref(false)
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 remove(id: number) { await templateApi.delete(id); await fetchList() }
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
})
+27
View File
@@ -0,0 +1,27 @@
export interface Template {
id: number; name: string; description: string; file_path: string
paragraph_count: number; status: string; created_at: string; updated_at: string
}
export interface Paragraph {
id: number; template_id: number; sort_index: number; title: string; content: string
style_json: string; is_table: boolean; table_json: string
edit_mode: 'manual' | 'ai'; model_id: number | null
need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string
output_format: 'text' | 'table' | 'mixed' | 'chart'
}
export interface AiModel {
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
api_endpoint: string; api_key_preview: string; status: 'enabled' | 'disabled'
}
export interface Document {
id: number; template_id: number; name: string
para_count_done: number; para_count_total: number
status: 'pending' | 'generating' | 'completed' | 'failed' | 'cancelled'
file_path: string; error: string
}
export interface ApiResponse<T = any> { code: number; data: T; message: string }
export interface PageData<T = any> { items: T[]; total: number; page: number; page_size: number }
+26
View File
@@ -0,0 +1,26 @@
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { useDocumentStore } from "@/stores/document";
import { message } from "ant-design-vue";
const router = useRouter();
const tplStore = useTemplateStore();
const docStore = useDocumentStore();
const templates = ref<any[]>([]);
const paragraphs = ref<any[]>([]);
const selectedTplId = ref(undefined);
const uploadedFiles = ref<Record<number,string>>({});
const generating = ref(false);
const progress = ref(0);
const progressText = ref("");
const tplInfo = ref<any>({});
const needFileCount = computed(()=>paragraphs.value.filter(p=>p.need_file).length);
const fileCount = computed(()=>Object.keys(uploadedFiles.value).length);
onMounted(async()=>{await tplStore.fetchList();templates.value=tplStore.templates as any});
async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);paragraphs.value=tplStore.paragraphs as any;tplInfo.value={paragraph_count:tpl.paragraph_count,fileCount:paragraphs.value.filter((p:any)=>p.need_file).length}}
function handleFileUpload(paraId:number,file:File){uploadedFiles.value[paraId]=file.name;return false}
async function startGen(){if(!selectedTplId.value){message.warning("请先选择模板");return}generating.value=true;progress.value=0;progressText.value="正在生成...";const data={template_id:selectedTplId.value,file_map:{}};try{const doc=await docStore.generateFull(data);message.success("生成完成");router.push(`/preview/${doc.id}`)}catch(e:any){message.error(e.message||"生成失败")}finally{generating.value=false}}
</script>
<style scoped>.para-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid #f0f0f0;border-radius:8px;margin-bottom:8px}.para-row.needFile{background:#fff;border-color:#d9d9d9}.para-row.noFile{background:#fafafa;border-style:dashed;opacity:.7}.para-info{display:flex;align-items:center;gap:8px}.para-info .idx{width:22px;height:22px;border-radius:50%;background:#f0f0ff;color:#5b5bd6;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.no-file-tag{font-size:12px;color:#999}</style>
+14
View File
@@ -0,0 +1,14 @@
<template><div style="padding:24px"><a-row :gutter="16" style="margin-bottom:24px"><a-col :span="6"><a-card><a-statistic title="总生成" :value="12" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="成功" :value="10" value-style="color:#52c41a" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="中断" :value="1" value-style="color:#faad14" /></a-card></a-col><a-col :span="6"><a-card><a-statistic title="失败" :value="1" value-style="color:#ff4d4f" /></a-card></a-col></a-row><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h3>生成记录</h3><div><a-select style="width:140px;margin-right:8px" placeholder="全部模板" /><a-select style="width:120px" placeholder="全部状态" /></div></div><a-list :dataSource="documents" :grid="{gutter:16,xs:1,sm:1,md:2,lg:2,xl:3,xxl:3}"><template #renderItem="{item}"><a-list-item><a-card hoverable><a-card-meta :title="item.name"><template #description><p>模板{{item.template_id}}</p><p>段落{{item.para_count_done}}/{{item.para_count_total}}</p><p>状态<a-badge :status="item.status==='completed'?'success':item.status==='failed'?'error':'processing'" :text="statusText(item.status)" /></p></template></a-card-meta><template #actions><a-button type="link" @click="preview(item.id)">预览</a-button><a-button type="link" @click="download(item.id)">下载</a-button></template></a-card></a-list-item></template></a-list></div></template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useDocumentStore } from "@/stores/document";
import { generateApi } from "@/api/generate";
const router = useRouter();
const store = useDocumentStore();
const documents = ref<any[]>([]);
function statusText(s:string){const m:Record<string,string>={'completed':'已生成','failed':'失败','cancelled':'中断','generating':'生成中','pending':'等待中'};return m[s]||s}
onMounted(async()=>{await store.fetchList();documents.value=store.documents as any});
function preview(id:number){router.push(`/preview/${id}`)}
function download(id:number){window.open(generateApi.exportDocx(id))}
</script>
+17
View File
@@ -0,0 +1,17 @@
<template><div style="padding:24px"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h2>模型管理</h2><a-button type="primary" @click="openAdd">添加模型</a-button></div><a-row :gutter="[16,16]"><a-col :span="8" v-for="m in models" :key="m.id"><a-card :title="m.name" :extra="<a-button type='link' size='small' @click='openEdit(m)'>编辑</a-button>"><p>厂商:{{m.provider}}</p><p>格式:{{m.api_format}}</p><p>地址:{{m.api_endpoint}}</p><p>状态:<a-switch :checked="m.status==='enabled'" @change="toggleStatus(m)" /></p></a-card></a-col></a-row><a-modal v-model:open="modalOpen" :title="isEdit?'编辑模型':'添加模型'" @ok="saveModel"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI 格式</a-select-option><a-select-option value="anthropic">Anthropic 格式</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" placeholder="https://api.xxx.com" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></div></template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useModelStore } from "@/stores/model";
import { message } from "ant-design-vue";
const store = useModelStore();
const models = ref<any[]>([]);
const modalOpen = ref(false);
const isEdit = ref(false);
const editId = ref(0);
const form = ref({name:"",provider:"",api_format:"openai",api_endpoint:"",api_key:""});
onMounted(async()=>{await store.fetchList();models.value=store.models as any});
function openAdd(){isEdit.value=false;form.value={name:"",provider:"",api_format:"openai",api_endpoint:"",api_key:""};modalOpen.value=true}
function openEdit(m:any){isEdit.value=true;editId.value=m.id;form.value={name:m.name,provider:m.provider,api_format:m.api_format,api_endpoint:m.api_endpoint,api_key:""};modalOpen.value=true}
async function saveModel(){if(isEdit.value){await store.update(editId.value,form.value)}else{await store.create(form.value)}modalOpen.value=false;models.value=store.models as any;message.success("保存成功")}
async function toggleStatus(m:any){m.status=m.status==="enabled"?"disabled":"enabled";await store.update(m.id,{status:m.status});message.success("状态已更新")}
</script>
+19
View File
@@ -0,0 +1,19 @@
<template><div style="padding:24px"><a-card><template #title><span style="display:flex;align-items:center;gap:8px"><file-text-outlined /> 文档编辑</span></template><template #extra><a-button-group><a-button @click="undo"><undo-outlined />撤销</a-button><a-button @click="redo"><redo-outlined />重做</a-button></a-button-group><a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button><a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button></template><div style="display:flex;gap:16px"><div style="flex:1;min-width:0"><div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap"><a-button size="small"><b>B</b></a-button><a-button size="small"><i>I</i></a-button><a-button size="small"><u>U</u></a-button><a-divider type="vertical" /><a-button size="small"><heading-outlined /></a-button><a-button size="small"><ordered-list-outlined /></a-button><a-button size="small"><table-outlined /></a-button></div><div ref="editorRef" contenteditable="true" style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8" v-html="editorHtml" @input="onEdit"></div></div><div style="width:200px;flex-shrink:0"><a-card title="文档结构" size="small"><div v-for="s in structure" :key="s.id" :class="['struct-item',{active:s.active}]" @click="scrollTo(s.id)"><file-text-outlined /> {{s.title}}</div></a-card></div></div></a-card></div></template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { generateApi } from "@/api/generate";
import { message } from "ant-design-vue";
import { FileTextOutlined, UndoOutlined, RedoOutlined, HeadingOutlined, OrderedListOutlined, TableOutlined } from "@ant-design/icons-vue";
const route = useRoute();
const editorRef = ref<HTMLElement|null>(null);
const editorHtml = ref(`<h2 style="text-align:center">2025年第一季度经济活动分析报告</h2><p style="text-align:center;color:#666">某某集团有限公司</p><h3>一、主要经营指标完成情况</h3><div style="background:#f0f0ff;padding:12px;border-left:3px solid #5b5bd6;border-radius:4px;margin:8px 0"><p>本季度营业收入完成<strong>12.35亿元</strong>,同比上升<strong>8.7%</strong>。</p></div><h3>主要经营指标完成情况表</h3><table border="1" style="width:100%;border-collapse:collapse"><tr><th>指标</th><th>完成值</th><th>同比</th></tr><tr><td>营业收入</td><td>12.35亿</td><td>+8.7%</td></tr></table><h3>二、成本费用分析</h3><p>本季度总成本9.87亿元,同比上升6.2%。</p>`);
const structure = ref([{id:'s1',title:'经营指标',active:true},{id:'s2',title:'指标表',active:false},{id:'s3',title:'成本费用',active:false}]);
function scrollTo(id:string){}
function onEdit(){}
function undo(){document.execCommand('undo')}
function redo(){document.execCommand('redo')}
function exportDocx(){const id=route.params.id;window.open(generateApi.exportDocx(Number(id)))}
function exportPdf(){const id=route.params.id;window.open(generateApi.exportPdf(Number(id)))}
</script>
<style scoped>.struct-item{padding:8px;cursor:pointer;border-radius:4px;font-size:13px}.struct-item:hover{background:#f5f5f5}.struct-item.active{background:#f0f0ff;color:#5b5bd6}</style>
+21
View File
@@ -0,0 +1,21 @@
<template><div style="display:flex;height:calc(100vh - 112px)"><div style="width:220px;border-right:1px solid #f0f0f0;overflow-y:auto;padding:12px"><h4 style="margin-bottom:12px">段落列表</h4><div v-for="(p,i) in paragraphs" :key="p.id" :class="['para-item',{active:selectedId===p.id}]" @click="selectPara(p.id)"><span class="idx">{{i+1}}</span><span class="title">{{p.title||'未命名'}}</span><a-tag :color="p.edit_mode==='ai'?'blue':'orange'" style="margin-left:auto">{{p.edit_mode==='ai'?'AI':'手动'}}</a-tag></div></div><div style="flex:1;overflow-y:auto;padding:24px;background:#f0f1f3;display:flex;justify-content:center;align-items:flex-start"><div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08)" v-html="previewHtml"></div></div><div style="width:380px;border-left:1px solid #f0f0f0;overflow-y:auto;padding:16px"><h4>段落配置</h4><div v-if="selectedPara"><a-form layout="vertical"><a-form-item label="编辑方式"><a-select v-model:value="selectedPara.edit_mode"><a-select-option value="ai">AI 生成</a-select-option><a-select-option value="manual">人工编辑</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="m in models" :key="m.id" :value="m.id">{{m.name}}</a-select-option></a-select></a-form-item><a-form-item label="输出格式"><a-select v-model:value="selectedPara.output_format"><a-select-option value="text">正式报告段落</a-select-option><a-select-option value="table">表格形式</a-select-option><a-select-option value="mixed">混合内容</a-select-option><a-select-option value="chart">图表形式</a-select-option></a-select></a-form-item><a-form-item label="需要提示词"><a-switch v-model:checked="selectedPara.need_prompt" /></a-form-item><a-form-item v-if="selectedPara.need_prompt" label="预设提示词"><a-textarea v-model:value="selectedPara.prompt_text" :rows="4" placeholder="在此输入提示词..." /></a-form-item><a-form-item label="需要参考文件"><a-switch v-model:checked="selectedPara.need_file" /></a-form-item><a-form-item v-if="selectedPara.need_file" label="备注说明"><a-textarea v-model:value="selectedPara.file_note" :rows="2" placeholder="提示用户上传什么文件" /></a-form-item></a-form><a-button v-if="selectedPara.edit_mode==='ai'" type="primary" block @click="testPara">立即测试</a-button></div></div></div></template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { useModelStore } from "@/stores/model";
import { message } from "ant-design-vue";
const route = useRoute();
const router = useRouter();
const store = useTemplateStore();
const modelStore = useModelStore();
const paragraphs = ref<any[]>([]);
const models = ref<any[]>([]);
const selectedId = ref(0);
const selectedPara = computed(()=>paragraphs.value.find(p=>p.id===selectedId.value));
const previewHtml = computed(()=>{return paragraphs.value.map(p=>'<div style="margin:8px 0;padding:8px;border:2px solid transparent;border-radius:4px'+(selectedId.value===p.id?';border-color:#5b5bd6;background:#f0f0ff':'')+'"><h3>'+(p.title||"")+'</h3><p>'+(p.content||"点击左侧配置此段落")+'</p></div>').join("")});
onMounted(async()=>{const id = Number(route.params.id);await store.fetchOne(id);paragraphs.value=store.paragraphs as any;await modelStore.fetchList();models.value=modelStore.models as any;if(paragraphs.value.length)selectedId.value=paragraphs.value[0].id});
function selectPara(id:number){selectedId.value=id;const el=document.getElementById('para-'+id);if(el)el.scrollIntoView({behavior:'smooth',block:'center'})}
async function testPara(){message.info('段落测试功能待实现')}
</script>
<style scoped>.para-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;margin-bottom:2px;font-size:13px}.para-item:hover{background:#f5f5f5}.para-item.active{background:#f0f0ff;color:#5b5bd6;font-weight:500}.para-item .idx{width:20px;height:20px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}.para-item.active .idx{background:#5b5bd6;color:#fff}.para-item .title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}</style>
+17
View File
@@ -0,0 +1,17 @@
<template><div style="padding:24px"><h2>模板管理</h2><a-button type="primary" @click="handleUpload">新建模板</a-button><a-table :dataSource="templates" :columns="columns" rowKey="id" style="margin-top:16px" /><a-modal v-model:open="uploadOpen" title="上传模板" @ok="doUpload"><a-upload-dragger :beforeUpload="beforeUpload"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击或拖拽 Word 模板文件到此区域</p></a-upload-dragger></a-modal></div></template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useTemplateStore } from "@/stores/template";
import { FileAddOutlined } from "@ant-design/icons-vue";
const router = useRouter();
const store = useTemplateStore();
const templates = ref([]);
const uploadOpen = ref(false);
const uploadFile = ref<File | null>(null);
const columns = [{title:"名称",dataIndex:"name"},{title:"段落",dataIndex:"paragraph_count"},{title:"状态",dataIndex:"status"},{title:"操作",key:"action"}];
onMounted(async()=>{await store.fetchList();templates.value=store.templates as any});
function handleUpload(){uploadOpen.value=true}
function beforeUpload(file:File){uploadFile.value=file;return false}
async function doUpload(){if(!uploadFile.value)return;await store.upload(uploadFile.value);uploadOpen.value=false;router.push(`/templates/${store.currentTemplate?.id}/edit`)}
</script>
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2020", "module": "ESNext", "moduleResolution": "bundler",
"strict": true, "jsx": "preserve", "resolveJsonModule": true,
"isolatedModules": true, "esModuleInterop": true, "lib": ["ES2020", "DOM"],
"skipLibCheck": true, "noEmit": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "composite": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true },
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/uploads': { target: 'http://localhost:8000', changeOrigin: true },
'/outputs': { target: 'http://localhost:8000', changeOrigin: true }
}
}
})