提交 383ef870 authored 作者: 张伊明's avatar 张伊明

写报新增翻译栏,新增markdown标签跳转

上级 eba3a093
......@@ -26,6 +26,7 @@
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"pdfjs-dist": "^5.4.449",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
......@@ -1646,7 +1647,6 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
......@@ -1763,7 +1763,6 @@
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz",
"integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.3",
"@vue/compiler-core": "3.5.21",
......@@ -1792,6 +1791,30 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.21",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.21.tgz",
......@@ -2222,6 +2245,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
......@@ -2459,6 +2491,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/copy-descriptor": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
......@@ -2503,7 +2550,6 @@
"resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
......@@ -2901,7 +2947,6 @@
"resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
......@@ -3159,7 +3204,6 @@
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
......@@ -4054,6 +4098,12 @@
"node": ">=12.0.0"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
......@@ -4238,6 +4288,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-windows": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/is-windows/-/is-windows-1.0.2.tgz",
......@@ -4393,15 +4455,13 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
......@@ -4449,7 +4509,6 @@
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"peer": true,
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
......@@ -5120,6 +5179,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mixin-deep": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/mixin-deep/-/mixin-deep-1.3.2.tgz",
......@@ -5422,6 +5487,12 @@
"@napi-rs/canvas": "^0.1.81"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
......@@ -5450,6 +5521,36 @@
"node": ">=6"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
......@@ -5697,6 +5798,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz",
......@@ -5709,7 +5816,6 @@
"integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
......@@ -6054,6 +6160,15 @@
"deprecated": "See https://github.com/lydell/source-map-url#deprecated",
"license": "MIT"
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/split-string/-/split-string-3.1.0.tgz",
......@@ -6163,6 +6278,18 @@
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz",
......@@ -6643,7 +6770,6 @@
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
......@@ -6703,7 +6829,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21",
......
......@@ -35,6 +35,7 @@
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"pdfjs-dist": "^5.4.449",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
......
......@@ -294,7 +294,15 @@ export function useMarkdownStream() {
const md = createMd()
// 预处理内容
// const processedContent = preprocessMarkdown(rawContent.value)
return md.render(rawContent.value)
let content = rawContent.value || ''
// 将 ==n== 转换为按钮样式的 HTML
// 使用正向预读和反向预读确保只匹配被 == 包裹的数字
content = content.replace(/==(\d+)==/g, (match, p1) => {
return `<button class="clause-ref-btn" data-clause="${p1}">${p1}</button>`
})
return md.render(content)
})
// 自动滚动
......
......@@ -11,11 +11,14 @@ import "./styles/elui.css";
import "./styles/main.css";
import '@/assets/fonts/font.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
// 引入 Pinia 实例
import pinia from './stores'
const app = createApp(App);
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
app.component(key, component);
}
// 注册全局函数
......@@ -24,5 +27,6 @@ app.use(router);
app.use(ElementPlus, {
locale: zhCn,
})
app.use(pinia) // 挂载 Pinia
app.component("CardTitle", CardTitle);
app.mount("#app");
// src/stores/index.js
import { createPinia } from 'pinia'
// 创建 Pinia 实例
const pinia = createPinia()
// 导出实例,供 main.js 挂载
export default pinia
\ No newline at end of file
import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus'
export const useWrittingAsstaintStore = defineStore('writtingAsstaint', {
state: () => ({
// 基础状态
isGenerating: false,
isShowProcess: false, // 初始隐藏执行步骤面板
isShowSteps: false, // 步骤侧边栏显隐控制
isShowClauseTranslation: false, // 翻译侧边栏显隐控制
isEditMode: true,
writtingTitle: '',
descText: '',
reportContent: '', // 报文内容(核心)
curTempTitle: '政令',
tempActiveIndex: 0,
curAgentTool: '',
uploadFileList: [],
// 路由相关状态
_isDisableTemplate: false,
routeQuery: {},
// 步骤日志(用于流式更新)
processLog: '',
// 条款翻译相关状态
clauseTranslationMessages: [], // 存收到的条款翻译消息
highlightClauseId: null, // 当前高亮的条款 ID (clause_number)
isShowOriginal: true, // 是否显示原文内容
// 静态数据
tabList: [
{ name: '写报', active: true },
{ name: '收藏', active: false },
{ name: '问答', active: false }
],
tempList: [
{ title: '政令', desc: '基于政令内容生成各维度的综合分析报告' },
{ title: '法案', desc: '基于政令内容生成各维度的综合分析报告' },
{ title: '智库', desc: '基于智库内容生成各维度的综合分析报告' },
{ title: '清单', desc: '基于清单内容生成各维度的综合分析报告' }
],
// 内部控制器
abortController: null
}),
getters: {
isDisableTemplate: (state) => state._isDisableTemplate,
formattedTime: () => {
const now = new Date()
const pad = n => n.toString().padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
},
actions: {
// ========== 基础状态修改 ==========
toggleEditMode() {
this.isEditMode = !this.isEditMode
},
resetGenerateState() {
this.isGenerating = false;
this.isShowProcess = false;
this.isShowSteps = false;
this.curAgentTool = '';
this.processLog = '';
this.clauseTranslationMessages = [];
this.isShowClauseTranslation = false;
this.highlightClauseId = null;
this.isShowOriginal = true;
this.abortController?.abort();
this.abortController = null;
},
updateTempActiveIndex(index, title) {
this.tempActiveIndex = index;
this.curTempTitle = title;
},
// ========== 路由参数处理 ==========
setRouteParams(query) {
this.routeQuery = { ...query };
this._isDisableTemplate = Object.keys(query).length > 0;
if (Object.keys(query).length > 0) {
const { topic, fileId } = query;
if (topic && fileId) {
this.curTempTitle = topic;
this.tempActiveIndex = this.tempList.findIndex(item => item.title === topic);
}
}
},
// ========== 文件上传相关 ==========
handleExceed(files, uploadRef) {
if (uploadRef) {
uploadRef.clearFiles();
const file = files[0];
file.uid = Date.now();
uploadRef.handleStart(file);
}
},
handleFileChange(file, files) {
this.uploadFileList = files.length > 1 ? [file] : files;
},
// ========== PDF 解析 SSE(仅更新执行步骤) ==========
async fetchPdfData(selectedFile) {
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
this.processLog = '';
try {
const formData = new FormData();
formData.append('pdf', selectedFile);
const { fetchEventSource } = await import('@microsoft/fetch-event-source');
await fetchEventSource('/pdfSse/api/v1/order/pdf/extract/report/sse', {
method: 'POST',
body: formData,
signal: this.abortController.signal,
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
openWhenHidden: true,
retryDelay: 1000,
maxRetries: 3,
onopen: async (response) => {
if (response.ok && response.headers.get('content-type')?.includes('text/event-stream')) {
} else {
ElMessage.warning('SSE连接格式异常,即将断开');
this.abortController.abort();
}
},
onmessage: async (event) => {
if (!event || !event.data || event.data.trim() === '') return;
let jsonData = null;
try {
jsonData = JSON.parse(event.data.trim());
} catch (parseError) {
console.warn('SSE消息JSON解析失败', parseError, event.data);
return;
}
switch (event.event) {
case 'progress':
// 仅更新执行步骤
if (jsonData.message) {
this.processLog += `${this.formattedTime}:${jsonData.message}\r\n`;
}
break;
case 'clause_translation':
// 保存条款翻译消息并显示侧边栏
if (jsonData) {
this.clauseTranslationMessages.push(jsonData);
this.isShowClauseTranslation = true;
this.isShowSteps = true; // 翻译出现时,步骤侧边栏也显示
}
break;
case 'result':
if (jsonData && Object.keys(jsonData).length) {
await this.fetchReportData({
query: this.writtingTitle,
desc: this.descText,
topic: this.curTempTitle,
result: jsonData
});
}
break;
default:
console.debug('未处理的SSE事件类型', event.event);
break;
}
},
onerror: (error) => {
console.error('SSE连接错误', error);
ElMessage.warning('写报生成报错!');
if (error.name !== 'AbortError') return true;
this.resetGenerateState();
},
onclose: () => {
console.log('SSE连接正常关闭');
this.isGenerating = false;
}
});
} catch (error) {
if (error.name !== 'AbortError') {
ElMessage.error(`PDF解析请求失败:${error.message}`);
console.error('PDF SSE请求异常', error);
}
this.resetGenerateState();
throw error;
}
},
// ========== AI 生成报文 SSE(更新报文内容 + 执行步骤) ==========
async fetchReportData(params) {
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
this.processLog = '';
// 用于把 SSE 的分片内容先聚合,再按“句子/段落边界”一次性提交到 reportContent
// 这样可以还原老版“一句完整再显示”的渲染效果,避免分片逐条渲染
let streamBuffer = '';
let lastFlushedIndex = 0;
const flushToReport = (force = false) => {
// 强制 flush 时,把剩余 buffer 全部提交;否则只提交到 lastFlushedIndex
const endIndex = force ? streamBuffer.length : lastFlushedIndex;
if (endIndex <= 0) return;
const chunk = streamBuffer.slice(0, endIndex);
if (!chunk) return;
this.reportContent += chunk;
streamBuffer = streamBuffer.slice(endIndex);
lastFlushedIndex = 0;
if (this.reportContent.includes('./out/img')) {
this.reportContent = this.reportContent.replaceAll('./out/img', 'http://172.19.21.9:8003/out/img');
}
};
// 尝试找到一个“合适的边界”再 flush:
// 1) 中文/英文句末标点 + 可选换行
// 2) 双换行(段落)
// 3) markdown 列表/标题等常见行结束
const updateFlushIndexByBoundary = () => {
// 优先按段落(双换行)
const paraIdx = streamBuffer.lastIndexOf('\n\n');
if (paraIdx !== -1) {
lastFlushedIndex = Math.max(lastFlushedIndex, paraIdx + 2);
}
// 其次按句末标点
// eslint-disable-next-line no-useless-escape
const sentenceRe = /[。!?!?;;](?:\s|\n|$)/g;
let m;
while ((m = sentenceRe.exec(streamBuffer)) !== null) {
lastFlushedIndex = Math.max(lastFlushedIndex, m.index + m[0].length);
}
// 再次按单行结束(避免长时间不刷新)
const lineIdx = streamBuffer.lastIndexOf('\n');
if (lineIdx !== -1 && streamBuffer.length > 200) {
lastFlushedIndex = Math.max(lastFlushedIndex, lineIdx + 1);
}
};
try {
const { fetchEventSource } = await import('@microsoft/fetch-event-source');
await fetchEventSource('/sseWrite/api/v1/workflow/invoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
signal: this.abortController.signal,
openWhenHidden: true,
onopen: (res) => {
console.log('流式回答开始', res);
},
onmessage: (res) => {
if (!res.data) return;
let msgData;
try {
msgData = JSON.parse(res.data);
} catch (e) {
return;
}
const str = msgData.data || '';
if (msgData.event_type === 'stream_agent_out') {
if (str !== '[DONE]') {
// SSE 分片先进入 buffer(仅用于报文内容)
streamBuffer += str;
updateFlushIndexByBoundary();
flushToReport(false);
} else {
// 结束时把剩余内容强制 flush
flushToReport(true);
this.isGenerating = false;
this.isShowSteps = false; // 报文生成结束后关闭步骤侧边栏
ElMessage.success('报文生成结束');
// 这里不再调用 resetGenerateState,因为可能需要保留翻译内容
}
} else if (msgData.event_type === 'workflow_complete') {
// complete 也做一次强制 flush,避免尾巴丢失
flushToReport(true);
this.isGenerating = false;
this.isShowSteps = false; // 报文生成结束后关闭步骤侧边栏
ElMessage.success('报文生成结束');
} else if ((msgData.event_type || '').toLowerCase().includes('error')) {
ElMessage.error('生成失败:' + str);
this.resetGenerateState();
} else {
// 老版 --index.vue 行为:步骤栏直接追加服务端发来的完整步骤内容,不加时间戳、不强行换行
// 这样可以避免 SSE 分片导致的“步骤破碎”(一条步骤被拆成多条显示)
if (str) {
this.processLog += str;
}
this.curAgentTool = msgData.tool || '无';
}
},
onerror: (error) => {
ElMessage.warning('写报生成报错!');
this.resetGenerateState();
throw new Error(error);
}
});
} catch (error) {
ElMessage.warning('写报生成报错!');
this.resetGenerateState();
throw error;
}
},
// ========== 业务入口 ==========
async generateReport() {
// 路由参数优先
this.isGenerating = true
this.isShowProcess = true
if (Object.keys(this.routeQuery).length !== 0) {
const params = {
query: this.writtingTitle,
desc: this.descText,
topic: this.curTempTitle,
report_id: this.routeQuery.fileId
};
await this.fetchReportData(params);
} else {
// 政令模板需要先解析PDF
if (this.curTempTitle === '政令') {
if (this.uploadFileList.length === 0) {
throw new Error('请上传政令文件');
}
const rawFile = this.uploadFileList[0].raw;
if (!rawFile) {
throw new Error('文件解析失败,请重新选择');
}
await this.fetchPdfData(rawFile);
} else {
const params = {
query: this.writtingTitle,
desc: this.descText,
topic: this.curTempTitle
};
await this.fetchReportData(params);
}
}
},
// ========== 导出Markdown ==========
exportContent() {
if (!this.reportContent) {
ElMessage.warning('暂无可导出的报文内容');
return;
}
const blob = new Blob([this.reportContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `markdown-${new Date().getTime()}.md`;
a.click();
URL.revokeObjectURL(url);
},
// ========== 清理 ==========
cleanup() {
this.resetGenerateState();
}
}
});
\ No newline at end of file
<template>
<div class="writting-wrapper">
<div class="writting-header">
<div class="tab-box">
<div class="tab" :class="{ tabActive: item.active }" v-for="(item, index) in tabList" :key="index">
{{ item.name }}
</div>
</div>
<div class="edit-box"></div>
<div class="btn-box">
<div class="btn" @click="exportContent">
<div class="icon">
<img src="./assets/images/export-icon.png" alt="" />
</div>
<div class="text">{{ "导出" }}</div>
</div>
<div class="btn" @click="handleSwitchMode">
<div class="icon">
<img v-if="isEditMode" src="./assets/images/preview-icon.png" alt="" />
<img v-else src="./assets/images/edit.png" alt="" />
</div>
<div class="text">{{ isEditMode ? "预览" : "编辑" }}</div>
</div>
<div class="btn btn1">
<div class="icon">
<img src="./assets/images/save-icon.png" alt="" />
</div>
<div class="text text1">{{ "保存" }}</div>
</div>
</div>
</div>
<div class="writting-main">
<div class="left-box">
<div class="left-box-input">
<div class="process-box" v-if="isShowProcess">
<div class="back" @click="handleBack">{{ "< 返回" }}</div>
<div class="process-main-box">
<div class="steps-box">
<div class="steps-header">
<div class="icon">
<img src="./assets/images/right-arrow.png" alt="" />
</div>
<div class="text">{{ "执行步骤:" }}</div>
</div>
<div class="steps-content" ref="scrollProcessContainer" v-html="renderedProcess"></div>
</div>
<div class="tool-box">
<div class="tool-header">{{ "工具调用" }}</div>
<div class="tool-main">当前智能体工具:{{ curAgentTool ? curAgentTool : "无" }}</div>
</div>
</div>
</div>
<div class="sider" v-else>
<div class="sider-box">
<div class="header">报文主题</div>
<div class="title-box">
<div class="title">主题名称</div>
<el-input
:disabled="true"
style="width: 476px; height: 32px"
class="title-input"
placeholder="输入主题名称,如:大而美法案"
v-model="writtingTitle"
/>
</div>
<div class="description-box">
<div class="title">主题描述</div>
<el-input
:disabled="true"
class="description-input"
type="textarea"
style="width: 476px"
:rows="8"
placeholder="输入报文主题描述,如:从科技领域方面分析大而美法案通过后对中国可能产生的影响"
v-model="descText"
/>
</div>
</div>
<div class="sider-box">
<div class="header">报文模板</div>
<div class="template-box">
<div
class="template"
:class="{
tempActive: tempActiveIndex === index,
disableTemplate: tempActiveIndex !== index && Object.keys(route.query).length > 0
}"
v-for="(temp, index) in tempList"
:key="index"
@click="handleClickTemp(temp, index)"
>
<div class="header">
<div class="title">{{ temp.title }}</div>
<div class="icon">
<img src="./assets/images/template-icon.png" alt="" />
</div>
</div>
<div class="content">{{ temp.desc }}</div>
<div class="active-icon" v-if="tempActiveIndex === index">
<img src="./assets/images/active-icon.png" alt="" />
</div>
<div class="selected-icon" v-if="tempActiveIndex === index">
<img src="./assets/images/selected-icon.png" alt="" />
</div>
</div>
</div>
</div>
<div class="sider-box">
<div class="header">加载本地文件</div>
<el-upload
action=""
:auto-upload="false"
accept=".pdf"
limit="1"
:on-exceed="handleExceed"
ref="upload"
:on-change="handleFileChange"
:file-list="uploadFileList"
:disabled="Object.keys(route.query).length !== 0"
>
<el-button
class="sider-upload-btn"
type="primary"
:disabled="Object.keys(route.query).length !== 0"
>
<el-icon class="sider-upload-btn-text">
<Upload />
</el-icon>
<span class="sider-upload-btn-text">上传文件</span>
</el-button>
<template #tip>
<div class="sider-upload-btn-tip">支持扩展名:.doc .docx .pdf</div>
</template>
</el-upload>
</div>
</div>
</div>
<div class="submit-area">
<div class="tips">
<div class="tips-icon">
<img src="./assets/images/tips-icon.png" alt="" />
</div>
<div class="tips-text">内容由AI生成,无法确保真实准确,仅供参考</div>
</div>
<div class="process-footer-box" v-if="isShowProcess">
<div class="footer-left">
{{ isGenerating ? "报文生成中..." : "报文已生成" }}
</div>
<div class="footer-right">
<div class="icon"></div>
<div class="text" @click="handleGenerate">{{ "停止" }}</div>
</div>
</div>
<div class="submit-btn" @click="getStreamChat" v-else>
<div class="submit-icon">
<img src="./assets/images/ai.png" alt="" />
</div>
<div class="submit-text">生成报文</div>
</div>
</div>
</div>
<div class="main-box">
<div v-if="isEditMode" class="edit-panel">
<v-md-editor
v-model="reportContent"
height="calc(100% - 40px)"
:disabled-menus="[]"
@upload-image="handleUploadImage"
@save="handleSave"
left-toolbar="undo redo clear | h bold italic strikethrough quote | ul ol table hr | link image code | save"
right-toolbar="preview toc sync-scroll fullscreen"
/>
</div>
<div v-else class="content-box" ref="scrollContainer" v-html="renderedContent"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { useMarkdownStream } from "@/hooks/useMarkdownStream";
import { useStream } from "@/hooks/useStream";
import { fetchEventSource } from "@microsoft/fetch-event-source";
import VMdEditor from "@kangc/v-md-editor";
import "@kangc/v-md-editor/lib/style/base-editor.css";
import vuepressTheme from "@kangc/v-md-editor/lib/theme/vuepress.js";
import "@kangc/v-md-editor/lib/theme/style/vuepress.css";
// 引入 Prism 相关依赖
import Prism from "prismjs";
import { ElButton, ElIcon, ElInput, ElMessage, ElUpload, genFileId } from "element-plus";
import { useRoute } from "vue-router";
import { useWrittingAsstaintStore } from "@/stores/writtingAsstaintStore";
// 获取 Store 实例
const writingStore = useWrittingAsstaintStore();
const route = useRoute();
const isGenerating = ref(false);
const isShowProcess = ref(true);
const uploadFileList = ref([]);
const upload = ref();
// 流程
const scrollProcessContainer = ref(null);
const processContent = ref("");
// 报文
const scrollContainer = ref(null);
const reportContent = ref("");
const curTempTitle = ref("政令");
const abortController = ref(null);
// 当前调用工具
const curAgentTool = ref("");
const writtingTitle = ref("");
const descText = ref("");
const tabList = ref([
{
name: "写报",
active: true
},
{
name: "收藏",
active: false
},
{
name: "问答",
active: false
}
]);
const tempList = ref([
{
title: "政令",
desc: "基于政令内容生成各维度的综合分析报告"
},
{
title: "法案",
desc: "基于政令内容生成各维度的综合分析报告"
},
{
title: "智库",
desc: "基于智库内容生成各维度的综合分析报告"
},
{
title: "清单",
desc: "基于清单内容生成各维度的综合分析报告"
}
]);
const tempActiveIndex = ref(0);
VMdEditor.use(vuepressTheme, {
Prism
});
//新上传文件替换
const handleExceed = files => {
if (upload.value) {
upload.value.clearFiles();
const file = files[0];
file.uid = genFileId();
upload.value.handleStart(file);
}
};
const handleFileChange = (file, files) => {
// 只保留最后选中的1个文件(覆盖原有文件)
if (files.length > 1) {
uploadFileList.value = [file];
} else {
uploadFileList.value = files;
}
};
const handleBack = () => {
handleGenerate();
};
const isEditMode = ref(true);
const handleSwitchMode = () => {
isEditMode.value = !isEditMode.value;
if (!isEditMode.value) {
console.log(reportContent.value);
updateContent(reportContent.value, scrollContainer.value);
}
};
// 保存处理函数
const handleSave = (text, html) => {
console.log("保存内容:", {
markdown: text,
html: html
});
reportContent.value = text;
// 实际保存逻辑
// saveToLocalStorage(text);
ElMessage.success("保存成功!");
};
// 保存到本地存储
const saveToLocalStorage = text => {
localStorage.setItem("markdown-content", text);
};
const { renderedContent, updateContent } = useMarkdownStream();
const { renderedProcess, updateProcess } = useStream();
// 停止生成
const handleGenerate = () => {
isShowProcess.value = false;
processContent.value = "";
abortController.value.abort();
};
const getStreamChat = async (search, inputValue) => {
if (Object.keys(route.query).length !== 0) {
const params = {
query: writtingTitle.value, // "输出一篇报文"
desc: descText.value,
topic: curTempTitle.value,
report_id: route.query.fileId // 政令、智库、法案、清单
};
callSseWithAi(params);
} else {
if (curTempTitle.value === "政令") {
if (uploadFileList.value.length === 0) {
ElMessage.error("请上传政令文件");
return;
}
const rawFile = uploadFileList.value[0].raw;
if (!rawFile) {
ElMessage.error("文件解析失败,请重新选择");
return;
}
callSseWithPdf(rawFile);
} else {
const params = {
query: writtingTitle.value, // "输出一篇报文"
desc: descText.value,
topic: curTempTitle.value // 政令、智库、法案、清单
};
callSseWithAi(params);
}
}
};
const getFormattedTime = () => {
const now = new Date();
// 补零函数:确保单个数字补为两位(如 1 → 01,9 → 09)
const pad = n => n.toString().padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(
now.getMinutes()
)}:${pad(now.getSeconds())}`;
};
// 核心SSE调用函数(修复后)
const callSseWithPdf = async selectedFile => {
// 重置中断控制器
if (abortController.value) {
abortController.value.abort();
}
abortController.value = new AbortController();
// 状态重置
isGenerating.value = false;
isShowProcess.value = false;
try {
const formData = new FormData();
formData.append("pdf", selectedFile);
await fetchEventSource("/pdfSse/api/v1/order/pdf/extract/report/sse", {
method: "POST",
body: formData,
signal: abortController.value.signal,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache"
},
openWhenHidden: true,
retryDelay: 1000,
maxRetries: 3,
// 连接打开回调
async onopen(response) {
console.log("流式回答开始", response);
// 校验响应状态
if (response.ok && response.headers.get("content-type")?.includes("text/event-stream")) {
isGenerating.value = true;
isShowProcess.value = true;
} else {
ElMessage.warning("SSE连接格式异常,即将断开");
abortController.value.abort();
}
},
// 核心修复:消息处理回调
async onmessage(event) {
// 1. 严格过滤空消息(解决失焦时空消息问题)
if (!event || !event.data || event.data.trim() === "") {
console.debug("收到空SSE消息,忽略", event);
return;
}
let jsonData = null;
try {
// 2. 容错处理:防止非JSON格式的消息导致解析报错
jsonData = JSON.parse(event.data.trim());
} catch (parseError) {
console.warn("SSE消息JSON解析失败", parseError, event.data);
return;
}
// 3. 按事件类型处理业务逻辑
switch (event.event) {
case "progress":
// 校验数据完整性
if (jsonData.message) {
processContent.value += `${getFormattedTime()}:${jsonData.message}\r\n`;
updateProcess(processContent.value, scrollProcessContainer.value);
}
break;
case "result":
// 确保result数据有效
if (jsonData && Object.keys(jsonData).length) {
await callSseWithAi({
query: writtingTitle.value,
desc: descText.value,
topic: curTempTitle.value,
result: jsonData // 传递解析后的对象而非原始字符串
});
}
break;
default:
console.debug("未处理的SSE事件类型", event.event);
break;
}
},
// 错误处理优化
onerror(error) {
console.error("SSE连接错误", error);
ElMessage.warning("写报生成报错!");
// 只在非主动中断时重连/终止
if (error.name !== "AbortError") {
// 触发库的自动重连机制(返回true)
return true;
}
// 主动中断时清理控制器
abortController.value.abort();
abortController.value = new AbortController();
},
// 连接关闭回调
onclose() {
console.log("SSE连接正常关闭");
isGenerating.value = false;
}
});
} catch (error) {
// 全局异常捕获
if (error.name !== "AbortError") {
ElMessage.error(`请求失败:${error.message}`);
console.error("SSE请求异常", error);
}
// 重置状态
isGenerating.value = false;
isShowProcess.value = false;
// 清理控制器
abortController.value.abort();
abortController.value = new AbortController();
}
};
const callSseWithAi = async params => {
abortController.value = new AbortController();
fetchEventSource("/sseWrite/api/v1/workflow/invoke", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(params),
signal: abortController.value.signal,
openWhenHidden: true,
async onopen(res) {
console.log("流式回答开始", res);
isGenerating.value = true;
isShowProcess.value = true;
},
async onmessage(res) {
let msgData = JSON.parse(res.data);
console.log("resss", msgData.data);
console.log("msgData", msgData);
let str = msgData.data;
if (msgData.event_type === "stream_agent_out") {
if (str !== "[DONE]") {
reportContent.value += str;
if (reportContent.value.includes("./out/img")) {
// reportContent.value = reportContent.value.replaceAll("./out/img", "http://8.140.26.4:10017/out/img");
reportContent.value = reportContent.value.replaceAll("./out/img", "http://172.19.21.9:8003/out/img");
// console.log(111, reportContent.value);
}
updateContent(reportContent.value, scrollContainer.value);
} else {
isGenerating.value = false;
ElMessage.success("报文生成结束");
abortController.value.abort();
abortController.value = new AbortController();
}
} else if (msgData.event_type === "workflow_complete") {
ElMessage.success("报文生成结束");
isGenerating.value = false;
abortController.value.abort();
abortController.value = new AbortController();
} else if (msgData.event_type.toLowerCase().includes("error")) {
} else {
processContent.value += str;
curAgentTool.value = msgData.tool;
updateProcess(processContent.value, scrollProcessContainer.value);
}
},
onerror(error) {
ElMessage({
message: "写报生成报错!",
type: "warning"
});
abortController.value.abort();
abortController.value = new AbortController();
throw new Error(error);
}
}).catch(error => {
ElMessage({
message: "写报生成报错!",
type: "warning"
});
abortController.value.abort();
abortController.value = new AbortController();
throw new Error(error);
});
};
const handleClickTemp = (item, index) => {
if (Object.keys(route.query).length !== 0) return;
tempActiveIndex.value = index;
curTempTitle.value = item.title;
};
// 导出
const exportContent = () => {
const blob = new Blob([reportContent.value], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `markdown-${new Date().getTime()}.md`;
a.click();
URL.revokeObjectURL(url);
};
onMounted(() => {
if (route && Object.keys(route.query).length !== 0) {
const { topic, fileId } = route.query;
if (!topic || !fileId) return;
curTempTitle.value = topic;
tempActiveIndex.value = tempList.value.findIndex((item, index) => {
return item.title === topic;
});
}
});
onUnmounted(() => {
if (abortController.value) {
abortController.value.abort();
}
});
</script>
<style lang="scss" scoped>
.writting-wrapper {
width: 100%;
height: 100%;
.writting-header {
height: 60px;
box-sizing: border-box;
border-bottom: 1px solid rgba(234, 236, 238, 1);
border-top: 1px solid rgba(234, 236, 238, 1);
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
background: rgba(255, 255, 255, 1);
display: flex;
position: relative;
.tab-box {
display: flex;
margin-left: 130px;
margin-top: 13px;
width: 260px;
height: 46px;
justify-content: space-between;
.tab {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 20px;
font-weight: 400;
line-height: 30px;
}
.tabActive {
color: rgba(5, 95, 194, 1);
font-family: Microsoft YaHei;
font-size: 20px;
font-weight: 700;
line-height: 30px;
border-bottom: 4px solid rgba(5, 95, 194, 1);
}
}
.btn-box {
position: absolute;
top: 12px;
right: 39px;
display: flex;
gap: 12px;
.btn {
display: flex;
justify-content: center;
align-items: center;
width: 88px;
height: 36px;
gap: 8px;
box-sizing: border-box;
border: 1px solid rgba(230, 231, 232, 1);
border-radius: 6px;
background: rgba(255, 255, 255, 1);
cursor: pointer;
.icon {
width: 16px;
height: 16px;
img {
width: 100%;
height: 100%;
}
}
.text {
width: 32px;
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.text1 {
color: #fff;
}
}
.btn1 {
background: rgba(5, 95, 194, 1);
}
}
}
.writting-main {
display: flex;
height: calc(100% - 60px);
position: relative;
.left-box {
display: flex;
flex-direction: column;
width: 520px;
padding-top: 22px;
padding-bottom: 29px;
box-sizing: border-box;
border-right: 1px solid rgba(234, 236, 238, 1);
border-top: 1px solid rgba(234, 236, 238, 1);
background: rgba(255, 255, 255, 1);
.left-box-input {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: none;
padding-inline: 22px;
.process-box {
display: flex;
flex-direction: column;
gap: 20px;
.back {
height: 24px;
line-height: 24px;
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
cursor: pointer;
}
.process-main-box {
width: 476px;
.analysis-box {
.analysis-header {
display: flex;
.icon {
margin-top: 5px;
width: 20px;
height: 20px;
img {
width: 100%;
height: 100%;
}
}
.text {
height: 30px;
line-height: 30px;
font-family: Microsoft YaHei;
color: #333;
font-size: 20px;
font-weight: 700;
margin-left: 5px;
}
}
.analysis-content {
height: 90px;
line-height: 30px;
color: #555;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
text-indent: 32px;
}
}
.steps-box {
.steps-header {
display: flex;
.icon {
margin-top: 5px;
width: 20px;
height: 20px;
img {
width: 100%;
height: 100%;
}
}
.text {
height: 30px;
line-height: 30px;
font-family: Microsoft YaHei;
color: #333;
font-size: 20px;
font-weight: 700;
margin-left: 5px;
}
}
.steps-content {
width: 100%;
min-height: 20px;
max-height: 480px;
overflow-x: hidden;
overflow-y: auto;
}
}
.doing-box {
height: 40px;
line-height: 40px;
color: #555;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
margin-left: 20px;
}
.tool-box {
width: 440px;
height: 160px;
margin: 10px auto;
background: #f6f9fe;
border-radius: 10px;
.tool-header {
height: 30px;
line-height: 30px;
margin-left: 20px;
color: var(--color-main-active);
}
.tool-main {
height: 110px;
width: 400px;
margin: 0 auto;
background: #fff;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 40px;
}
}
}
}
.sider {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 23px;
.sider-box {
.header {
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
}
.template-box {
margin-top: 19px;
height: 260px;
display: flex;
flex-wrap: wrap;
gap: 16px;
.template {
width: 229px;
height: 120px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 4px;
position: relative;
cursor: pointer;
.active-icon {
width: 24px;
height: 24px;
position: absolute;
top: 0;
right: 0;
z-index: 99;
img {
width: 100%;
height: 100%;
}
}
.selected-icon {
width: 8px;
height: 6px;
position: absolute;
top: -4px;
right: 3px;
z-index: 100;
img {
width: 8px;
height: 6px;
}
}
.header {
display: flex;
justify-content: space-between;
height: 50px;
.title {
height: 24px;
// color: rgba(59, 65, 75, 1);
color: #333;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
margin-left: 15px;
margin-top: 16px;
}
.icon {
margin-top: 15px;
margin-right: 16px;
width: 30px;
height: 30px;
border-radius: 15px;
background: rgba(231, 243, 255, 1);
img {
width: 17px;
height: 14px;
margin-top: 8px;
margin-left: 7px;
}
}
}
.content {
margin: 0 auto;
width: 200px;
height: 48px;
margin-top: 10px;
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
.disableTemplate {
width: 230px;
height: 120px;
box-sizing: border-box;
border: 1px solid #e4e7ed;
border-radius: 4px;
position: relative;
cursor: not-allowed;
background-color: #f5f7fa;
.active-icon {
width: 24px;
height: 24px;
position: absolute;
top: 0;
right: 0;
z-index: 99;
img {
width: 100%;
height: 100%;
}
}
.selected-icon {
width: 8px;
height: 6px;
position: absolute;
top: -4px;
right: 3px;
z-index: 100;
img {
width: 8px;
height: 6px;
}
}
.header {
display: flex;
justify-content: space-between;
height: 50px;
.title {
height: 24px;
color: #c0c4cc;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
margin-left: 15px;
margin-top: 16px;
}
.icon {
margin-top: 15px;
margin-right: 16px;
width: 30px;
height: 30px;
border-radius: 15px;
background: #f0f2f5;
img {
width: 17px;
height: 14px;
margin-top: 8px;
margin-left: 7px;
opacity: 0.5;
}
}
}
.content {
margin: 0 auto;
width: 200px;
height: 48px;
margin-top: 10px;
color: #dcdfe6;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
.tempActive {
border: 1px solid rgba(5, 95, 194, 1);
background: rgba(246, 250, 255, 1);
}
}
.title-box {
margin-top: 15px;
.title {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.title-input {
margin-top: 15px;
}
}
.description-box {
margin-top: 24px;
.title {
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.description-input {
margin-top: 12px;
height: 200px;
}
}
.sider-upload-btn {
width: 476px;
height: 36px;
box-sizing: border-box;
border: 1px dashed rgba(234, 236, 238, 1);
border-radius: 6px;
background: rgba(247, 248, 249, 1);
margin-top: 19px;
}
.sider-upload-btn-text {
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-style: Regular;
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
}
.sider-upload-btn-tip {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-style: Regular;
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
margin-top: 8px;
}
}
}
}
.submit-area {
margin-top: auto;
width: 100%;
min-height: 74px;
padding-inline: 22px;
.tips {
height: 22px;
display: flex;
margin-bottom: 18px;
.tips-icon {
width: 14px;
height: 16px;
margin-top: 3px;
img {
width: 100%;
height: 100%;
}
}
.tips-text {
margin-left: 8px;
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 14px;
font-weight: 400;
line-height: 22px;
}
}
.process-footer-box {
height: 36px;
display: flex;
position: absolute;
.footer-left {
width: 348px;
height: 36px;
border-radius: 4px;
background: rgba(234, 236, 238, 1);
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
text-align: center;
line-height: 36px;
}
.footer-right {
margin-left: 8px;
display: flex;
width: 120px;
height: 36px;
box-sizing: border-box;
border: 1px solid var(--color-main-active);
border-radius: 4px;
background: rgba(246, 250, 255, 1);
justify-content: center;
align-items: center;
gap: 8px;
cursor: pointer;
.icon {
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--color-main-active);
}
.text {
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
}
.submit-btn {
width: 476px;
height: 36px;
border-radius: 4px;
background: rgba(5, 95, 194, 1);
display: flex;
justify-content: center;
cursor: pointer;
&:hover {
background: rgba(5, 95, 194, 0.8);
}
.submit-icon {
/* AI-logo */
width: 21px;
height: 15px;
margin-top: 10px;
img {
width: 100%;
height: 100%;
}
}
.submit-text {
height: 24px;
margin-top: 5px;
color: rgba(255, 255, 255, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-left: 8px;
}
}
}
}
.main-box {
flex: 1;
background: #f7f8f9;
.edit-panel {
width: calc(100% - 170px);
height: calc(100% - 40px);
margin: 20px 50px;
}
.content-box {
width: 1069px;
height: 100%;
overflow-y: auto;
padding: 20px 80px;
line-height: 1.7;
font-size: 16px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 10px;
background: rgba(255, 255, 255, 1);
margin: 17px auto 0 auto;
img {
width: 300px;
height: auto;
}
}
}
}
}
:deep(.el-input__wrapper) {
background: #f7f8f9;
}
:deep(.el-input__inner::placeholder) {
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
:deep(.el-input__inner) {
color: #333;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
:deep(.el-textarea__inner) {
background: #f7f8f9;
}
:deep(.el-textarea__inner) {
color: #333;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
:deep(.el-textarea__inner::placeholder) {
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
</style>
\ No newline at end of file
<template>
<div class="left-box-wrapper">
<div class="back" @click="store.resetGenerateState" v-if="store.isGenerating">&lt; 返回</div>
<div class="left-box process" v-if="!store.isShowClauseTranslation && !store.isShowSteps">
<div class="left-box-input">
<!-- 表单输入区域 -->
<div class="sider" v-if="!store.isGenerating">
<!-- 报文主题 -->
<div class="sider-box">
<div class="header">报文主题</div>
<div class="title-box">
<div class="title">主题名称</div>
<el-input :disabled="store.isDisableTemplate" style="width: 476px; height: 32px"
class="title-input" placeholder="输入主题名称,如:大而美法案" v-model="store.writtingTitle" />
</div>
<div class="description-box">
<div class="title">主题描述</div>
<el-input :disabled="store.isDisableTemplate" class="description-input" type="textarea"
style="width: 476px" :rows="8" placeholder="输入报文主题描述,如:从科技领域方面分析大而美法案通过后对中国可能产生的影响"
v-model="store.descText" />
</div>
</div>
<!-- 报文模板 -->
<div class="sider-box">
<div class="header">报文模板</div>
<div class="template-box">
<div class="template" :class="{
tempActive: store.tempActiveIndex === index,
disableTemplate: store.tempActiveIndex !== index && store.isDisableTemplate
}" v-for="(temp, index) in store.tempList" :key="index"
@click="!store.isDisableTemplate && store.updateTempActiveIndex(index, temp.title)">
<div class="header">
<div class="title">{{ temp.title }}</div>
<div class="icon">
<img src="../assets/images/template-icon.png" alt="" />
</div>
</div>
<div class="content">{{ temp.desc }}</div>
<div class="active-icon" v-if="store.tempActiveIndex === index">
<img src="../assets/images/active-icon.png" alt="" />
</div>
<div class="selected-icon" v-if="store.tempActiveIndex === index">
<img src="../assets/images/selected-icon.png" alt="" />
</div>
</div>
</div>
</div>
<!-- 加载本地文件 -->
<div class="sider-box">
<div class="header">加载本地文件</div>
<el-upload action="" :auto-upload="false" accept=".pdf" limit="1"
:on-exceed="files => store.handleExceed(files, uploadRef)" ref="uploadRef"
:on-change="(file, files) => store.handleFileChange(file, files)"
:file-list="store.uploadFileList" :disabled="store.isDisableTemplate">
<el-button class="sider-upload-btn" type="primary" :disabled="store.isDisableTemplate">
<el-icon class="sider-upload-btn-text">
<Upload />
</el-icon>
<span class="sider-upload-btn-text">上传文件</span>
</el-button>
<template #tip>
<div class="sider-upload-btn-tip">支持扩展名:.pdf</div>
</template>
</el-upload>
</div>
</div>
<!-- 生成进度展示 -->
<div class="process-box" v-else>
<div class="process-main-box">
<div class="steps-box">
<div class="steps-header">
<div class="icon">
<img src="../assets/images/right-arrow.png" alt="" />
</div>
<div class="text">执行步骤:</div>
</div>
<div class="steps-content" ref="processContainerRef" v-html="renderedProcess"></div>
</div>
<div class="tool-box">
<div class="tool-header">工具调用</div>
<div class="tool-main">当前智能体工具:{{ store.curAgentTool || "无" }}</div>
</div>
</div>
</div>
</div>
<!-- 提交区域 -->
<div class="submit-area">
<div class="tips">
<div class="tips-icon">
<img src="../assets/images/tips-icon.png" alt="" />
</div>
<div class="tips-text">内容由AI生成,无法确保真实准确,仅供参考</div>
</div>
<!-- 生成按钮 -->
<div class="submit-btn" @click="triggerGenerate">
<div class="submit-icon">
<img src="../assets/images/ai.png" alt="" />
</div>
<div class="submit-text">生成报文</div>
</div>
</div>
</div>
<!-- 步骤侧边栏(拆分出来) -->
<div class="left-box process" v-if="store.isShowSteps">
<div class="left-box-input">
<div class="process-box">
<div class="process-main-box">
<div class="steps-box">
<div class="steps-header">
<div class="icon">
<img src="../assets/images/right-arrow.png" alt="" />
</div>
<div class="text">执行步骤:</div>
</div>
<div class="steps-content" ref="processContainerRef" v-html="renderedProcess"></div>
</div>
<div class="tool-box">
<div class="tool-header">工具调用</div>
<div class="tool-main">当前智能体工具:{{ store.curAgentTool || "无" }}</div>
</div>
</div>
</div>
</div>
<div class="submit-area">
<div class="tips">
<div class="tips-icon">
<img src="../assets/images/tips-icon.png" alt="" />
</div>
<div class="tips-text">内容由AI生成,无法确保真实准确,仅供参考</div>
</div>
<!-- 生成中状态 -->
<div class="process-footer-box">
<div class="footer-left">
{{ store.isGenerating ? "报文生成中..." : "报文已生成" }}
</div>
<div class="footer-right" @click="store.resetGenerateState">
<div class="icon"></div>
<div class="text">停止</div>
</div>
</div>
</div>
</div>
<!-- 条款翻译侧边栏 -->
<div class="left-box translation-box" v-if="store.isShowClauseTranslation">
<div class="translation-main-box">
<div class="translation-header-new">
<div class="header-left">共{{ store.clauseTranslationMessages.length }}章节</div>
<div class="header-right">
<el-switch
v-model="store.isShowOriginal"
inline-prompt
active-text=""
inactive-text=""
/>
<span class="switch-label">显示原文</span>
</div>
</div>
<div class="translation-content">
<div
class="translation-item"
:class="{ active: store.highlightClauseId === item.payload?.clause_number }"
:data-clause-number="item.payload?.clause_number"
v-for="(item, index) in store.clauseTranslationMessages"
:key="index"
>
<div class="item-header">
<div class="index-badge">{{ item.payload?.clause_number }}</div>
<div class="clause-title">第{{ getChineseNumber(item.payload?.clause_number) }}节 .{{ item.payload?.clause_section }}</div>
</div>
<div class="item-body">
<div class="translated-text">{{ item.payload?.clause_content_zh }}</div>
<div class="original-text" v-if="store.isShowOriginal">{{ item.payload?.clause_content }}</div>
</div>
</div>
</div>
</div>
<!-- 步骤侧边栏显隐按钮 -->
<div class="toggle-steps-btn" @click="store.isShowSteps = !store.isShowSteps">
<div class="arrow" :class="{ 'is-active': store.isShowSteps }"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
import { ElButton, ElIcon, ElInput, ElUpload, ElSwitch } from "element-plus";
import { Upload } from "@element-plus/icons-vue";
import { useWrittingAsstaintStore } from "@/stores/writtingAsstaintStore";
import { useStream } from "@/hooks/useStream";
// 子组件直接获取Pinia Store(核心优化)
const store = useWrittingAsstaintStore();
// 组件内部引用
const uploadRef = ref(null);
const processContainerRef = ref(null);
const emit = defineEmits(["generate"]);
const triggerGenerate = () => {
emit("generate");
};
// 数字转中文序号
const getChineseNumber = (num) => {
const zh = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const n = parseInt(num);
if (n <= 10) return zh[n];
if (n < 20) return '十' + zh[n % 10];
if (n < 100) {
return zh[Math.floor(n / 10)] + '十' + (n % 10 === 0 ? '' : zh[n % 10]);
}
return num;
};
// Stream处理(纯渲染)
const { renderedProcess, updateProcess, clearContent } = useStream();
// 监听条款高亮并滚动
watch(
() => store.highlightClauseId,
(newId) => {
if (newId) {
const item = document.querySelector(`.translation-item[data-clause-number="${newId}"]`);
if (item) {
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
);
// 监听 store.processLog 变化,更新步骤内容并滚动
watch(
() => store.processLog,
async (newLog) => {
if (newLog !== undefined && newLog !== null) {
await updateProcess(newLog, processContainerRef.value);
}
},
{ immediate: true }
);
// 监听 isShowProcess 变为 false 时清空步骤内容
watch(
() => store.isShowSubmit,
(newVal) => {
if (!newVal) {
clearContent();
}
}
);
defineExpose({
processContainerRef
});
</script>
<style lang="scss" scoped>
.left-box-wrapper {
display: flex;
flex-direction: row;
height: 100%;
position: relative;
.left-box.process {
padding-top: 20px;
}
.left-box {
display: flex;
flex-direction: column;
width: 520px;
height: 100%;
padding-top: 22px;
padding-bottom: 29px;
box-sizing: border-box;
border-right: 1px solid rgba(234, 236, 238, 1);
border-top: 1px solid rgba(234, 236, 238, 1);
background: rgba(255, 255, 255, 1);
position: relative;
.left-box-input {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: none;
padding-inline: 22px;
margin-top: 20px;
.process-box {
display: flex;
flex-direction: column;
gap: 20px;
.process-main-box {
width: 476px;
.steps-box {
.steps-header {
display: flex;
.icon {
margin-top: 5px;
width: 20px;
height: 20px;
img {
width: 100%;
height: 100%;
}
}
.text {
height: 30px;
line-height: 30px;
font-family: Microsoft YaHei;
color: #333;
font-size: 20px;
font-weight: 700;
margin-left: 5px;
}
}
.steps-content {
width: 100%;
min-height: 20px;
max-height: 480px;
overflow-x: hidden;
overflow-y: auto;
}
}
.tool-box {
width: 440px;
height: 160px;
margin: 10px auto;
background: #f6f9fe;
border-radius: 10px;
.tool-header {
height: 30px;
line-height: 30px;
margin-left: 20px;
color: var(--color-main-active);
}
.tool-main {
height: 110px;
width: 400px;
margin: 0 auto;
background: #fff;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 40px;
}
}
}
}
.sider {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 23px;
.sider-box {
.header {
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
}
.template-box {
margin-top: 19px;
height: 260px;
display: flex;
flex-wrap: wrap;
gap: 16px;
.template {
width: 229px;
height: 120px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 4px;
position: relative;
cursor: pointer;
.active-icon {
width: 24px;
height: 24px;
position: absolute;
top: 0;
right: 0;
z-index: 99;
img {
width: 100%;
height: 100%;
}
}
.selected-icon {
width: 8px;
height: 6px;
position: absolute;
top: -4px;
right: 3px;
z-index: 100;
img {
width: 8px;
height: 6px;
}
}
.header {
display: flex;
justify-content: space-between;
height: 50px;
.title {
height: 24px;
color: #333;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
margin-left: 15px;
margin-top: 16px;
}
.icon {
margin-top: 15px;
margin-right: 16px;
width: 30px;
height: 30px;
border-radius: 15px;
background: rgba(231, 243, 255, 1);
img {
width: 17px;
height: 14px;
margin-top: 8px;
margin-left: 7px;
}
}
}
.content {
margin: 0 auto;
width: 200px;
height: 48px;
margin-top: 10px;
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
.disableTemplate {
width: 230px;
height: 120px;
box-sizing: border-box;
border: 1px solid #e4e7ed;
border-radius: 4px;
position: relative;
cursor: not-allowed;
background-color: #f5f7fa;
.active-icon {
width: 24px;
height: 24px;
position: absolute;
top: 0;
right: 0;
z-index: 99;
img {
width: 100%;
height: 100%;
}
}
.selected-icon {
width: 8px;
height: 6px;
position: absolute;
top: -4px;
right: 3px;
z-index: 100;
img {
width: 8px;
height: 6px;
}
}
.header {
display: flex;
justify-content: space-between;
height: 50px;
.title {
height: 24px;
color: #c0c4cc;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
margin-left: 15px;
margin-top: 16px;
}
.icon {
margin-top: 15px;
margin-right: 16px;
width: 30px;
height: 30px;
border-radius: 15px;
background: #f0f2f5;
img {
width: 17px;
height: 14px;
margin-top: 8px;
margin-left: 7px;
opacity: 0.5;
}
}
}
.content {
margin: 0 auto;
width: 200px;
height: 48px;
margin-top: 10px;
color: #dcdfe6;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
.tempActive {
border: 1px solid rgba(5, 95, 194, 1);
background: rgba(246, 250, 255, 1);
}
}
.title-box {
margin-top: 15px;
.title {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.title-input {
margin-top: 15px;
}
}
.description-box {
margin-top: 24px;
.title {
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.description-input {
margin-top: 12px;
height: 200px;
}
}
.sider-upload-btn {
width: 476px;
height: 36px;
box-sizing: border-box;
border: 1px dashed rgba(234, 236, 238, 1);
border-radius: 6px;
background: rgba(247, 248, 249, 1);
margin-top: 19px;
}
.sider-upload-btn-text {
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-style: Regular;
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
}
.sider-upload-btn-tip {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-style: Regular;
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
margin-top: 8px;
}
}
}
}
.translation-main-box {
width: 520px;
gap: 20px;
padding-inline: 22px;
.translation-header-new {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
.header-left {
color: #3b414b;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
.switch-label {
color: #5f656c;
font-family: Microsoft YaHei;
font-size: 14px;
}
}
}
.translation-content {
width: 100%;
max-height: 780px;
overflow-x: hidden;
overflow-y: auto;
padding-right: 10px;
.translation-item {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #f2f3f5;
&:last-child {
border-bottom: none;
}
.item-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
.index-badge {
flex-shrink: 0;
width: 24px;
height: 24px;
background: #e7f3ff;
color: #055fc2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.clause-title {
color: #3b414b;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
}
}
.item-body {
padding-left: 36px;
display: flex;
flex-direction: column;
gap: 16px;
.translated-text {
color: #3b414b;
font-family: Microsoft YaHei;
font-size: 15px;
font-weight: 400;
line-height: 1.8;
}
.original-text {
color: #84888e;
font-family: Microsoft YaHei;
font-size: 14px;
font-weight: 400;
line-height: 1.6;
}
}
&.active {
background: #f6faff;
border-radius: 8px;
padding: 16px;
margin-left: -16px;
margin-right: -16px;
border-bottom: none;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
}
}
}
}
.back {
height: 24px;
line-height: 24px;
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
cursor: pointer;
position: absolute;
z-index: 1;
top: 11px;
left: 21px;
}
.translation-box {
.toggle-steps-btn {
position: absolute;
right: -12px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 60px;
background: #fff;
border: 1px solid rgba(234, 236, 238, 1);
border-left: none;
border-radius: 0 6px 6px 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
.arrow {
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 6px solid var(--color-main-active);
transform: rotate(0deg);
}
.arrow.is-active {
transform: rotate(180deg);
}
}
}
}
.submit-area {
margin-top: auto;
width: 100%;
min-height: 74px;
padding-inline: 22px;
.tips {
height: 22px;
display: flex;
margin-bottom: 18px;
.tips-icon {
width: 14px;
height: 16px;
margin-top: 3px;
img {
width: 100%;
height: 100%;
}
}
.tips-text {
margin-left: 8px;
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 14px;
font-weight: 400;
line-height: 22px;
}
}
.process-footer-box {
height: 36px;
display: flex;
position: absolute;
.footer-left {
width: 348px;
height: 36px;
border-radius: 4px;
background: rgba(234, 236, 238, 1);
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
text-align: center;
line-height: 36px;
}
.footer-right {
margin-left: 8px;
display: flex;
width: 120px;
height: 36px;
box-sizing: border-box;
border: 1px solid var(--color-main-active);
border-radius: 4px;
background: rgba(246, 250, 255, 1);
justify-content: center;
align-items: center;
gap: 8px;
cursor: pointer;
.icon {
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--color-main-active);
}
.text {
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
}
.submit-btn {
width: 476px;
height: 36px;
border-radius: 4px;
background: rgba(5, 95, 194, 1);
display: flex;
justify-content: center;
cursor: pointer;
opacity: 1;
&:hover {
background: rgba(5, 95, 194, 0.8);
}
&[disabled] {
background: #ccc;
cursor: not-allowed;
opacity: 0.6;
}
.submit-icon {
width: 21px;
height: 15px;
margin-top: 10px;
img {
width: 100%;
height: 100%;
}
}
.submit-text {
height: 24px;
margin-top: 5px;
color: rgba(255, 255, 255, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-left: 8px;
}
}
}
</style>
\ No newline at end of file
<template>
<div class="main-box">
<!-- 编辑模式 -->
<div v-if="store.isEditMode" class="edit-panel">
<v-md-editor
v-model="store.reportContent"
height="calc(100% - 40px)"
:disabled-menus="[]"
@upload-image="handleUploadImage"
@save="handleSave"
left-toolbar="undo redo clear | h bold italic strikethrough quote | ul ol table hr | link image code | save"
right-toolbar="preview toc sync-scroll fullscreen"
/>
</div>
<!-- 预览模式 -->
<div v-else class="content-box" ref="contentContainerRef" v-html="renderedContent"></div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from "vue";
import { ElMessage } from "element-plus";
import VMdEditor from "@kangc/v-md-editor";
import "@kangc/v-md-editor/lib/style/base-editor.css";
import vuepressTheme from "@kangc/v-md-editor/lib/theme/vuepress.js";
import "@kangc/v-md-editor/lib/theme/style/vuepress.css";
import Prism from "prismjs";
import { useWrittingAsstaintStore } from "@/stores/writtingAsstaintStore";
import { useMarkdownStream } from "@/hooks/useMarkdownStream";
// 子组件直接获取Pinia Store
// 1. 绑定content渲染容器的ref
const contentContainerRef = ref(null);
const store = useWrittingAsstaintStore();
// 处理按钮点击事件
const handleGlobalClick = (e) => {
const btn = e.target.closest('.clause-ref-btn');
if (btn) {
const clauseId = btn.getAttribute('data-clause');
if (clauseId) {
store.highlightClauseId = clauseId;
// 翻译栏一直显示,所以这里只需要确保它在视图内
}
}
};
onMounted(() => {
window.addEventListener('click', handleGlobalClick);
});
onUnmounted(() => {
window.removeEventListener('click', handleGlobalClick);
});
defineExpose({
contentContainerRef
});
// Markdown流处理
const { renderedContent, updateContent } = useMarkdownStream();
// 初始化编辑器
VMdEditor.use(vuepressTheme, { Prism });
// 保存处理
const handleSave = (text, html) => {
console.log("保存内容:", { markdown: text, html: html });
store.reportContent = text;
localStorage.setItem("markdown-content", text);
ElMessage.success("保存成功!");
};
// 上传图片处理
const handleUploadImage = async (event, insertImage, files) => {
ElMessage.info("图片上传功能待实现");
// 实际项目中可调用Pinia中的上传方法
// await store.uploadImage(files[0]);
};
// 监听Store内容变化,同步更新预览
watch(
() => store.reportContent,
async (newVal) => {
await updateContent(newVal, contentContainerRef.value);
},
{ immediate: true }
);
</script>
<style lang="scss" scoped>
.main-box {
flex: 1;
height: 100%;
background: #f7f8f9;
.edit-panel {
width: calc(100% - 170px);
height: calc(100% - 40px);
margin: 20px 50px;
}
.content-box {
width: 1069px;
height: 100%;
overflow-y: auto;
padding: 20px 80px;
line-height: 1.7;
font-size: 16px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 10px;
background: rgba(255, 255, 255, 1);
margin: 17px auto 0 auto;
img {
width: 300px;
height: auto;
}
:deep(.clause-ref-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.5em;
min-width: 1.5em;
padding: 0 4px;
margin: 0 2px;
font-size: 0.9em;
line-height: 1;
color: #fff;
background-color: var(--color-main-active, #055fc2);
border: none;
border-radius: 4px;
cursor: pointer;
vertical-align: middle;
transition: background-color 0.2s;
&:hover {
background-color: #044da5;
}
&:active {
transform: translateY(1px);
}
}
}
}
</style>
\ No newline at end of file
<template>
<div class="writting-wrapper">
<!-- 头部区域 -->
<div class="writting-header">
<div class="tab-box">
<div class="tab" :class="{ tabActive: item.active }" v-for="(item, index) in tabList" :key="index">
<div class="tab" :class="{ tabActive: item.active }" v-for="(item, index) in store.tabList"
:key="index">
{{ item.name }}
</div>
</div>
<div class="edit-box"></div>
<div class="btn-box">
<div class="btn" @click="exportContent">
<div class="btn" @click="store.exportContent">
<div class="icon">
<img src="./assets/images/export-icon.png" alt="" />
</div>
<div class="text">{{ "导出" }}</div>
<div class="text">导出</div>
</div>
<div class="btn" @click="handleSwitchMode">
<div class="btn" @click="store.toggleEditMode">
<div class="icon">
<img v-if="isEditMode" src="./assets/images/preview-icon.png" alt="" />
<img v-if="store.isEditMode" src="./assets/images/preview-icon.png" alt="" />
<img v-else src="./assets/images/edit.png" alt="" />
</div>
<div class="text">{{ isEditMode ? "预览" : "编辑" }}</div>
<div class="text">{{ store.isEditMode ? "预览" : "编辑" }}</div>
</div>
<div class="btn btn1">
<div class="icon">
<img src="./assets/images/save-icon.png" alt="" />
</div>
<div class="text text1">{{ "保存" }}</div>
<div class="text text1">保存</div>
</div>
</div>
</div>
<!-- 主体区域:子组件 -->
<div class="writting-main">
<div class="left-box">
<div class="left-box-input">
<div class="process-box" v-if="isShowProcess">
<div class="back" @click="handleBack">{{ "< 返回" }}</div>
<div class="process-main-box">
<div class="steps-box">
<div class="steps-header">
<div class="icon">
<img src="./assets/images/right-arrow.png" alt="" />
</div>
<div class="text">{{ "执行步骤:" }}</div>
</div>
<div class="steps-content" ref="scrollProcessContainer" v-html="renderedProcess"></div>
</div>
<div class="tool-box">
<div class="tool-header">{{ "工具调用" }}</div>
<div class="tool-main">当前智能体工具:{{ curAgentTool ? curAgentTool : "无" }}</div>
</div>
</div>
</div>
<div class="sider" v-else>
<div class="sider-box">
<div class="header">报文主题</div>
<div class="title-box">
<div class="title">主题名称</div>
<el-input
:disabled="true"
style="width: 476px; height: 32px"
class="title-input"
placeholder="输入主题名称,如:大而美法案"
v-model="writtingTitle"
/>
</div>
<div class="description-box">
<div class="title">主题描述</div>
<el-input
:disabled="true"
class="description-input"
type="textarea"
style="width: 476px"
:rows="8"
placeholder="输入报文主题描述,如:从科技领域方面分析大而美法案通过后对中国可能产生的影响"
v-model="descText"
/>
</div>
</div>
<div class="sider-box">
<div class="header">报文模板</div>
<div class="template-box">
<div
class="template"
:class="{
tempActive: tempActiveIndex === index,
disableTemplate: tempActiveIndex !== index && Object.keys(route.query).length > 0
}"
v-for="(temp, index) in tempList"
:key="index"
@click="handleClickTemp(temp, index)"
>
<div class="header">
<div class="title">{{ temp.title }}</div>
<div class="icon">
<img src="./assets/images/template-icon.png" alt="" />
</div>
</div>
<div class="content">{{ temp.desc }}</div>
<div class="active-icon" v-if="tempActiveIndex === index">
<img src="./assets/images/active-icon.png" alt="" />
</div>
<div class="selected-icon" v-if="tempActiveIndex === index">
<img src="./assets/images/selected-icon.png" alt="" />
</div>
</div>
</div>
</div>
<div class="sider-box">
<div class="header">加载本地文件</div>
<el-upload
action=""
:auto-upload="false"
accept=".pdf"
limit="1"
:on-exceed="handleExceed"
ref="upload"
:on-change="handleFileChange"
:file-list="uploadFileList"
:disabled="Object.keys(route.query).length !== 0"
>
<el-button
class="sider-upload-btn"
type="primary"
:disabled="Object.keys(route.query).length !== 0"
>
<el-icon class="sider-upload-btn-text">
<Upload />
</el-icon>
<span class="sider-upload-btn-text">上传文件</span>
</el-button>
<template #tip>
<div class="sider-upload-btn-tip">支持扩展名:.doc .docx .pdf</div>
</template>
</el-upload>
</div>
</div>
<!-- 左侧子组件:绑定ref -->
<writtingleftBox ref="leftBoxRef" @generate="handleGenerate" />
<!-- 右侧子组件:绑定ref -->
<writtingMainBox v-show="!!store.reportContent" ref="mainBoxRef" :report-content="store.reportContent" />
<!-- 无数据时显示占位图 -->
<div v-show="!store.reportContent" class="main-placeholder">
<img src="./assets/images/container-image.png" alt="无数据占位图" />
<div class="placeholder-text">
<div v-if="store.isGenerating">智能体写报任务执行中...</div>
<div v-else>上传文件后点击“生成报文”开始写报...</div>
</div>
<div class="submit-area">
<div class="tips">
<div class="tips-icon">
<img src="./assets/images/tips-icon.png" alt="" />
</div>
<div class="tips-text">内容由AI生成,无法确保真实准确,仅供参考</div>
</div>
<div class="process-footer-box" v-if="isShowProcess">
<div class="footer-left">
{{ isGenerating ? "报文生成中..." : "报文已生成" }}
</div>
<div class="footer-right">
<div class="icon"></div>
<div class="text" @click="handleGenerate">{{ "停止" }}</div>
</div>
</div>
<div class="submit-btn" @click="getStreamChat" v-else>
<div class="submit-icon">
<img src="./assets/images/ai.png" alt="" />
</div>
<div class="submit-text">生成报文</div>
</div>
</div>
</div>
<div class="main-box">
<div v-if="isEditMode" class="edit-panel">
<v-md-editor
v-model="reportContent"
height="calc(100% - 40px)"
:disabled-menus="[]"
@upload-image="handleUploadImage"
@save="handleSave"
left-toolbar="undo redo clear | h bold italic strikethrough quote | ul ol table hr | link image code | save"
right-toolbar="preview toc sync-scroll fullscreen"
/>
</div>
<div v-else class="content-box" ref="scrollContainer" v-html="renderedContent"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { useMarkdownStream } from "@/hooks/useMarkdownStream";
import { useStream } from "@/hooks/useStream";
import { fetchEventSource } from "@microsoft/fetch-event-source";
import VMdEditor from "@kangc/v-md-editor";
import "@kangc/v-md-editor/lib/style/base-editor.css";
import vuepressTheme from "@kangc/v-md-editor/lib/theme/vuepress.js";
import "@kangc/v-md-editor/lib/theme/style/vuepress.css";
// 引入 Prism 相关依赖
import Prism from "prismjs";
import { ElButton, ElIcon, ElInput, ElMessage, ElUpload, genFileId } from "element-plus";
import { onMounted, onUnmounted, ref, nextTick } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const isGenerating = ref(false);
const isShowProcess = ref(false);
const uploadFileList = ref([]);
const upload = ref();
// 流程
const scrollProcessContainer = ref(null);
const processContent = ref("");
// 报文
const scrollContainer = ref(null);
const reportContent = ref("");
const curTempTitle = ref("政令");
const abortController = ref(null);
// 当前调用工具
const curAgentTool = ref("");
const writtingTitle = ref("");
const descText = ref("");
const tabList = ref([
{
name: "写报",
active: true
},
{
name: "收藏",
active: false
},
{
name: "问答",
active: false
}
]);
const tempList = ref([
{
title: "政令",
desc: "基于政令内容生成各维度的综合分析报告"
},
{
title: "法案",
desc: "基于政令内容生成各维度的综合分析报告"
},
{
title: "智库",
desc: "基于智库内容生成各维度的综合分析报告"
},
{
title: "清单",
desc: "基于清单内容生成各维度的综合分析报告"
}
]);
const tempActiveIndex = ref(0);
VMdEditor.use(vuepressTheme, {
Prism
});
//新上传文件替换
const handleExceed = files => {
if (upload.value) {
upload.value.clearFiles();
const file = files[0];
file.uid = genFileId();
upload.value.handleStart(file);
}
};
const handleFileChange = (file, files) => {
// 只保留最后选中的1个文件(覆盖原有文件)
if (files.length > 1) {
uploadFileList.value = [file];
} else {
uploadFileList.value = files;
}
};
const handleBack = () => {
handleGenerate();
};
const isEditMode = ref(true);
const handleSwitchMode = () => {
isEditMode.value = !isEditMode.value;
if (!isEditMode.value) {
console.log(reportContent.value);
updateContent(reportContent.value, scrollContainer.value);
}
};
// 保存处理函数
const handleSave = (text, html) => {
console.log("保存内容:", {
markdown: text,
html: html
});
reportContent.value = text;
// 实际保存逻辑
// saveToLocalStorage(text);
ElMessage.success("保存成功!");
};
// 保存到本地存储
const saveToLocalStorage = text => {
localStorage.setItem("markdown-content", text);
};
const { renderedContent, updateContent } = useMarkdownStream();
const { renderedProcess, updateProcess } = useStream();
// 停止生成
const handleGenerate = () => {
isShowProcess.value = false;
processContent.value = "";
abortController.value.abort();
};
const getStreamChat = async (search, inputValue) => {
if (Object.keys(route.query).length !== 0) {
const params = {
query: writtingTitle.value, // "输出一篇报文"
desc: descText.value,
topic: curTempTitle.value,
report_id: route.query.fileId // 政令、智库、法案、清单
};
callSseWithAi(params);
} else {
if (curTempTitle.value === "政令") {
if (uploadFileList.value.length === 0) {
ElMessage.error("请上传政令文件");
return;
}
const rawFile = uploadFileList.value[0].raw;
if (!rawFile) {
ElMessage.error("文件解析失败,请重新选择");
return;
}
callSseWithPdf(rawFile);
} else {
const params = {
query: writtingTitle.value, // "输出一篇报文"
desc: descText.value,
topic: curTempTitle.value // 政令、智库、法案、清单
};
callSseWithAi(params);
}
}
};
const getFormattedTime = () => {
const now = new Date();
// 补零函数:确保单个数字补为两位(如 1 → 01,9 → 09)
const pad = n => n.toString().padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(
now.getMinutes()
)}:${pad(now.getSeconds())}`;
};
// 核心SSE调用函数(修复后)
const callSseWithPdf = async selectedFile => {
// 重置中断控制器
if (abortController.value) {
abortController.value.abort();
}
abortController.value = new AbortController();
import { ElMessage } from "element-plus";
import { useWrittingAsstaintStore } from "@/stores/writtingAsstaintStore";
import writtingleftBox from "./components/WrittingLeftBox.vue";
import writtingMainBox from "./components/WrittingMainBox.vue";
// 状态重置
isGenerating.value = false;
isShowProcess.value = false;
// 获取路由实例(组件内读取)
const route = useRoute();
// 获取Pinia Store实例
const leftBoxRef = ref(null); // 左侧子组件ref
const mainBoxRef = ref(null); // 右侧子组件ref
const store = useWrittingAsstaintStore();
// 2. 核心:触发生成流程
const handleGenerate = async () => {
try {
const formData = new FormData();
formData.append("pdf", selectedFile);
// 先打开"执行步骤"面板,确保步骤容器被渲染
if (!store.isShowSubmit) {
store.isShowSubmit = true;
}
await fetchEventSource("/pdfSse/api/v1/order/pdf/extract/report/sse", {
method: "POST",
body: formData,
signal: abortController.value.signal,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache"
},
openWhenHidden: true,
retryDelay: 1000,
maxRetries: 3,
// 连接打开回调
async onopen(response) {
console.log("流式回答开始", response);
// 校验响应状态
if (response.ok && response.headers.get("content-type")?.includes("text/event-stream")) {
isGenerating.value = true;
isShowProcess.value = true;
} else {
ElMessage.warning("SSE连接格式异常,即将断开");
abortController.value.abort();
}
},
// 核心修复:消息处理回调
async onmessage(event) {
// 1. 严格过滤空消息(解决失焦时空消息问题)
if (!event || !event.data || event.data.trim() === "") {
console.debug("收到空SSE消息,忽略", event);
return;
}
// 等待DOM更新(确保子组件DOM已挂载)
await nextTick();
let jsonData = null;
try {
// 2. 容错处理:防止非JSON格式的消息导致解析报错
jsonData = JSON.parse(event.data.trim());
} catch (parseError) {
console.warn("SSE消息JSON解析失败", parseError, event.data);
return;
}
// 3. 按事件类型处理业务逻辑
switch (event.event) {
case "progress":
// 校验数据完整性
if (jsonData.message) {
processContent.value += `${getFormattedTime()}:${jsonData.message}\r\n`;
updateProcess(processContent.value, scrollProcessContainer.value);
}
break;
case "result":
// 确保result数据有效
if (jsonData && Object.keys(jsonData).length) {
await callSseWithAi({
query: writtingTitle.value,
desc: descText.value,
topic: curTempTitle.value,
result: jsonData // 传递解析后的对象而非原始字符串
});
}
break;
default:
console.debug("未处理的SSE事件类型", event.event);
break;
}
},
// 错误处理优化
onerror(error) {
console.error("SSE连接错误", error);
ElMessage.warning("写报生成报错!");
// 只在非主动中断时重连/终止
if (error.name !== "AbortError") {
// 触发库的自动重连机制(返回true)
return true;
}
// 主动中断时清理控制器
abortController.value.abort();
abortController.value = new AbortController();
},
// 连接关闭回调
onclose() {
console.log("SSE连接正常关闭");
isGenerating.value = false;
}
});
// 调用Pinia的生成方法,不再传递DOM容器,由组件层自行渲染
await store.generateReport();
} catch (error) {
// 全局异常捕获
if (error.name !== "AbortError") {
ElMessage.error(`请求失败:${error.message}`);
console.error("SSE请求异常", error);
}
// 重置状态
isGenerating.value = false;
isShowProcess.value = false;
// 清理控制器
abortController.value.abort();
abortController.value = new AbortController();
ElMessage.error(error.message);
console.error("生成报文失败:", error);
}
};
const callSseWithAi = async params => {
abortController.value = new AbortController();
fetchEventSource("/sseWrite/api/v1/workflow/invoke", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(params),
signal: abortController.value.signal,
openWhenHidden: true,
async onopen(res) {
console.log("流式回答开始", res);
isGenerating.value = true;
isShowProcess.value = true;
},
async onmessage(res) {
let msgData = JSON.parse(res.data);
console.log("resss", msgData.data);
console.log("msgData", msgData);
let str = msgData.data;
if (msgData.event_type === "stream_agent_out") {
if (str !== "[DONE]") {
reportContent.value += str;
if (reportContent.value.includes("./out/img")) {
// reportContent.value = reportContent.value.replaceAll("./out/img", "http://8.140.26.4:10017/out/img");
reportContent.value = reportContent.value.replaceAll("./out/img", "http://172.19.21.9:8003/out/img");
// console.log(111, reportContent.value);
}
updateContent(reportContent.value, scrollContainer.value);
} else {
isGenerating.value = false;
ElMessage.success("报文生成结束");
abortController.value.abort();
abortController.value = new AbortController();
}
} else if (msgData.event_type === "workflow_complete") {
ElMessage.success("报文生成结束");
isGenerating.value = false;
abortController.value.abort();
abortController.value = new AbortController();
} else if (msgData.event_type.toLowerCase().includes("error")) {
} else {
processContent.value += str;
curAgentTool.value = msgData.tool;
updateProcess(processContent.value, scrollProcessContainer.value);
}
},
onerror(error) {
ElMessage({
message: "写报生成报错!",
type: "warning"
});
abortController.value.abort();
abortController.value = new AbortController();
throw new Error(error);
}
}).catch(error => {
ElMessage({
message: "写报生成报错!",
type: "warning"
});
abortController.value.abort();
abortController.value = new AbortController();
throw new Error(error);
});
};
const handleClickTemp = (item, index) => {
if (Object.keys(route.query).length !== 0) return;
tempActiveIndex.value = index;
curTempTitle.value = item.title;
};
// 导出
const exportContent = () => {
const blob = new Blob([reportContent.value], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `markdown-${new Date().getTime()}.md`;
a.click();
URL.revokeObjectURL(url);
};
// 生命周期
onMounted(() => {
if (route && Object.keys(route.query).length !== 0) {
const { topic, fileId } = route.query;
if (!topic || !fileId) return;
curTempTitle.value = topic;
tempActiveIndex.value = tempList.value.findIndex((item, index) => {
return item.title === topic;
});
}
// 组件初始化时:读取路由参数,传递给Pinia
store.setRouteParams(route.query);
});
onUnmounted(() => {
if (abortController.value) {
abortController.value.abort();
}
// 组件卸载时清理请求
store.cleanup();
});
</script>
......@@ -596,6 +125,7 @@ onUnmounted(() => {
font-size: 20px;
font-weight: 400;
line-height: 30px;
cursor: pointer;
}
.tabActive {
......@@ -664,570 +194,32 @@ onUnmounted(() => {
height: calc(100% - 60px);
position: relative;
.left-box {
.main-placeholder {
flex: 1;
display: flex;
flex-direction: column;
width: 525px;
padding-top: 22px;
padding-bottom: 29px;
box-sizing: border-box;
border-right: 1px solid rgba(234, 236, 238, 1);
border-top: 1px solid rgba(234, 236, 238, 1);
background: rgba(255, 255, 255, 1);
.left-box-input {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: none;
padding-inline: 22px;
.process-box {
display: flex;
flex-direction: column;
gap: 20px;
.back {
height: 24px;
line-height: 24px;
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
cursor: pointer;
}
.process-main-box {
width: 476px;
.analysis-box {
.analysis-header {
display: flex;
.icon {
margin-top: 5px;
width: 20px;
height: 20px;
img {
width: 100%;
height: 100%;
}
}
.text {
height: 30px;
line-height: 30px;
font-family: Microsoft YaHei;
color: #333;
font-size: 20px;
font-weight: 700;
margin-left: 5px;
}
}
.analysis-content {
height: 90px;
line-height: 30px;
color: #555;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
text-indent: 32px;
}
}
.steps-box {
.steps-header {
display: flex;
.icon {
margin-top: 5px;
width: 20px;
height: 20px;
img {
width: 100%;
height: 100%;
}
}
.text {
height: 30px;
line-height: 30px;
font-family: Microsoft YaHei;
color: #333;
font-size: 20px;
font-weight: 700;
margin-left: 5px;
}
}
.steps-content {
width: 100%;
min-height: 20px;
max-height: 480px;
overflow-x: hidden;
overflow-y: auto;
}
}
.doing-box {
height: 40px;
line-height: 40px;
color: #555;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
margin-left: 20px;
}
.tool-box {
width: 440px;
height: 160px;
margin: 10px auto;
background: #f6f9fe;
border-radius: 10px;
.tool-header {
height: 30px;
line-height: 30px;
margin-left: 20px;
color: var(--color-main-active);
}
.tool-main {
height: 110px;
width: 400px;
margin: 0 auto;
background: #fff;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 40px;
}
}
}
}
.sider {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 23px;
.sider-box {
.header {
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
}
.template-box {
margin-top: 19px;
height: 260px;
display: flex;
flex-wrap: wrap;
gap: 16px;
.template {
width: 230px;
height: 120px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 4px;
position: relative;
cursor: pointer;
.active-icon {
width: 24px;
height: 24px;
position: absolute;
top: 0;
right: 0;
z-index: 99;
img {
width: 100%;
height: 100%;
}
}
.selected-icon {
width: 8px;
height: 6px;
position: absolute;
top: -4px;
right: 3px;
z-index: 100;
img {
width: 8px;
height: 6px;
}
}
.header {
display: flex;
justify-content: space-between;
height: 50px;
.title {
height: 24px;
// color: rgba(59, 65, 75, 1);
color: #333;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
margin-left: 15px;
margin-top: 16px;
}
.icon {
margin-top: 15px;
margin-right: 16px;
width: 30px;
height: 30px;
border-radius: 15px;
background: rgba(231, 243, 255, 1);
img {
width: 17px;
height: 14px;
margin-top: 8px;
margin-left: 7px;
}
}
}
.content {
margin: 0 auto;
width: 200px;
height: 48px;
margin-top: 10px;
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
.disableTemplate {
width: 230px;
height: 120px;
box-sizing: border-box;
border: 1px solid #e4e7ed;
border-radius: 4px;
position: relative;
cursor: not-allowed;
background-color: #f5f7fa;
.active-icon {
width: 24px;
height: 24px;
position: absolute;
top: 0;
right: 0;
z-index: 99;
img {
width: 100%;
height: 100%;
}
}
.selected-icon {
width: 8px;
height: 6px;
position: absolute;
top: -4px;
right: 3px;
z-index: 100;
img {
width: 8px;
height: 6px;
}
}
.header {
display: flex;
justify-content: space-between;
height: 50px;
.title {
height: 24px;
color: #c0c4cc;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 700;
line-height: 24px;
margin-left: 15px;
margin-top: 16px;
}
.icon {
margin-top: 15px;
margin-right: 16px;
width: 30px;
height: 30px;
border-radius: 15px;
background: #f0f2f5;
img {
width: 17px;
height: 14px;
margin-top: 8px;
margin-left: 7px;
opacity: 0.5;
}
}
}
.content {
margin: 0 auto;
width: 200px;
height: 48px;
margin-top: 10px;
color: #dcdfe6;
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
.tempActive {
border: 1px solid rgba(5, 95, 194, 1);
background: rgba(246, 250, 255, 1);
}
}
.title-box {
margin-top: 15px;
.title {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.title-input {
margin-top: 15px;
}
}
.description-box {
margin-top: 24px;
.title {
height: 24px;
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.description-input {
margin-top: 12px;
height: 200px;
}
}
.sider-upload-btn {
width: 476px;
height: 36px;
box-sizing: border-box;
border: 1px dashed rgba(234, 236, 238, 1);
border-radius: 6px;
background: rgba(247, 248, 249, 1);
margin-top: 19px;
}
.sider-upload-btn-text {
color: rgba(59, 65, 75, 1);
font-family: Microsoft YaHei;
font-style: Regular;
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
}
.sider-upload-btn-tip {
color: rgba(95, 101, 108, 1);
font-family: Microsoft YaHei;
font-style: Regular;
font-size: 14px;
font-weight: 400;
line-height: 22px;
letter-spacing: 0px;
text-align: left;
margin-top: 8px;
}
}
}
}
.submit-area {
margin-top: auto;
width: 100%;
min-height: 74px;
padding-inline: 22px;
.tips {
height: 22px;
display: flex;
margin-bottom: 18px;
.tips-icon {
width: 14px;
height: 16px;
margin-top: 3px;
img {
width: 100%;
height: 100%;
}
}
.tips-text {
margin-left: 8px;
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 14px;
font-weight: 400;
line-height: 22px;
}
}
.process-footer-box {
height: 36px;
display: flex;
position: absolute;
.footer-left {
width: 348px;
height: 36px;
border-radius: 4px;
background: rgba(234, 236, 238, 1);
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
text-align: center;
line-height: 36px;
}
.footer-right {
margin-left: 8px;
display: flex;
width: 120px;
height: 36px;
box-sizing: border-box;
border: 1px solid var(--color-main-active);
border-radius: 4px;
background: rgba(246, 250, 255, 1);
justify-content: center;
align-items: center;
gap: 8px;
cursor: pointer;
.icon {
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--color-main-active);
}
.text {
color: var(--color-main-active);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
}
.submit-btn {
width: 476px;
height: 36px;
border-radius: 4px;
background: rgba(5, 95, 194, 1);
display: flex;
justify-content: center;
cursor: pointer;
&:hover {
background: rgba(5, 95, 194, 0.8);
}
.submit-icon {
/* AI-logo */
width: 21px;
height: 15px;
margin-top: 10px;
img {
width: 100%;
height: 100%;
}
}
.submit-text {
height: 24px;
margin-top: 5px;
color: rgba(255, 255, 255, 1);
font-family: Microsoft YaHei;
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-left: 8px;
}
}
}
}
.main-box {
flex: 1;
align-items: center;
justify-content: center;
gap: 16px;
background: #f7f8f9;
.edit-panel {
width: calc(100% - 170px);
height: calc(100% - 40px);
margin: 20px 50px;
img {
max-width: 100%;
max-height: 80%;
object-fit: contain;
display: block;
}
.content-box {
width: 1069px;
height: 100%;
overflow-y: auto;
padding: 20px 80px;
line-height: 1.7;
.placeholder-text {
color: rgba(132, 136, 142, 1);
font-family: Microsoft YaHei;
font-size: 16px;
box-sizing: border-box;
border: 1px solid rgba(234, 236, 238, 1);
border-radius: 10px;
background: rgba(255, 255, 255, 1);
margin: 17px auto 0 auto;
img {
width: 300px;
height: auto;
}
font-weight: 400;
line-height: 24px;
}
}
}
}
:deep(.el-input__wrapper) {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论