Files
tuanwei-admin/src/views/gxmu/manuscriptGen/index.vue
T
weicw 6d19cb5a05 feat(gxmu): 文稿工作台新增「一键排版」,按原文排版进富文本编辑器
生成的 markdown 正文现在能一键转成带格式的富文本:
- 正文区改为「Markdown 预览 / 富文本排版」两个 tab,一键排版后自动切到编辑器
- 转换统一走 utils/manuscript-content 的 markdownToHtml,新增 plugins 参数,
  传 gfm 插件还原表格、删除线(不挂 gfm 时表格会漏成一堆 | 管道符)
- 生成标题不拼进正文:标题有独立字段,导出 Word 时才拼进导出件
- 编辑器已有内容且文本不一致时才二次确认覆盖,同一份内容重复排版不打扰
- 富文本下方新增「保存为稿件」,入库写 contentFormat: 'html';
  存过之后按钮置灰,避免连点存出重复草稿
- oa/项目资料 里指向外部排版站的同名按钮改成「公众号排版」,避开同名不同义
2026-11-01 19:27:46 +08:00

1335 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="manuscript-gen-page">
<div class="page-hero">
<div>
<div class="hero-kicker">AI CONTENT STUDIO</div>
<h1>AI内容创作与宣传助手</h1>
<p>写新闻稿、活动总结、公众号推文或通知公告,说清需求就行,AI 先给出一份初稿,可以接着改。</p>
</div>
<div class="hero-actions">
<a-button type="primary" ghost @click="goChatPage">进入问答助手</a-button>
<a-button type="primary" ghost @click="goHistory">历史记录</a-button>
</div>
</div>
<a-row :gutter="[24, 24]">
<a-col :xs="24" :xl="10">
<div class="quick-actions-panel">
<div class="quick-actions-title">快捷模板</div>
<div class="quick-actions">
<a-button block @click="fillDemo('news')">活动新闻稿</a-button>
<a-button block @click="fillDemo('wechat')">公众号推文</a-button>
<a-button block @click="fillDemo('notice')">通知公告</a-button>
<a-button block @click="fillDemo('free')">自由创作示例</a-button>
</div>
</div>
<a-card :bordered="false" class="panel-card">
<template #title>创作配置</template>
<a-form layout="vertical">
<a-form-item label="稿件类型">
<a-select
v-model:value="form.docType"
:options="docTypeOptions"
placeholder="请选择稿件类型"
/>
</a-form-item>
<a-form-item v-if="!templateMode" label="想创作的内容">
<a-textarea
v-model:value="form.freeContent"
:auto-size="{ minRows: 6, maxRows: 12 }"
placeholder="例如:帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右"
/>
</a-form-item>
<template v-if="templateMode">
<a-form-item label="稿件标题">
<a-input
v-model:value="form.theme"
allow-clear
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
/>
</a-form-item>
<a-form-item label="活动类型">
<a-select
v-model:value="form.eventType"
:options="eventTypeOptions"
placeholder="请选择活动类型"
/>
</a-form-item>
<a-form-item label="活动时间">
<a-input
v-model:value="form.time"
allow-clear
placeholder="例如:2026年4月8日下午"
/>
</a-form-item>
<a-form-item label="活动地点">
<a-input
v-model:value="form.location"
allow-clear
placeholder="例如:广西医科大学校史馆广场"
/>
</a-form-item>
<a-form-item label="参与人员">
<a-input
v-model:value="form.participants"
allow-clear
placeholder="例如:校团委老师、青年志愿者代表共120人"
/>
</a-form-item>
<a-form-item label="亮点内容">
<a-textarea
v-model:value="form.highlights"
:auto-size="{ minRows: 5, maxRows: 8 }"
placeholder="填写活动亮点、流程、成果、典型细节等关键信息"
/>
</a-form-item>
<a-form-item label="建议篇幅">
<a-input-number
v-model:value="form.number"
class="full-width"
:min="100"
:max="5000"
:step="100"
placeholder="请输入建议字数"
/>
</a-form-item>
<a-form-item label="附件材料">
<a-upload
:multiple="true"
:show-upload-list="false"
:customRequest="handleUpload"
>
<a-button>
<template #icon>
<UploadOutlined />
</template>
上传附件
</a-button>
</a-upload>
<div v-if="uploadedFiles.length" class="file-list">
<div
v-for="(item, index) in uploadedFiles"
:key="`${item.url || item.path || item.name}-${index}`"
class="file-item"
>
<div class="file-main">
<div class="file-badge">附件</div>
<div class="file-text">
<div class="file-name">{{ item.name || '未命名文件' }}</div>
<div class="file-url">{{ item.url || item.path || '-' }}</div>
</div>
</div>
<a class="file-remove" @click="removeUploadedFile(index)">移除</a>
</div>
</div>
</a-form-item>
</template>
</a-form>
<div class="submit-bar">
<a-button :disabled="loading" @click="resetForm">重置</a-button>
<a-button
type="primary"
:loading="loading"
:disabled="!canSubmit"
@click="handleGenerate"
>
生成内容
</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24" :xl="14">
<a-card :bordered="false" class="panel-card result-card">
<template #title>
<div class="result-title">
<span>生成结果</span>
<a-tag :color="loading ? 'processing' : markdownContent ? 'success' : 'default'">
{{ loading ? '生成中' : markdownContent ? '已生成' : '待生成' }}
</a-tag>
</div>
</template>
<template #extra>
<a-space>
<a-button
type="primary"
:disabled="loading || !markdownContent"
@click="formatRichText"
>
一键排版
</a-button>
<a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
<a-button :disabled="!markdownContent" @click="clearGenerated">清空结果</a-button>
</a-space>
</template>
<a-empty v-if="!markdownContent && !loading" description="填写创作条件后开始生成" />
<template v-else>
<a-form layout="vertical">
<a-form-item label="生成标题">
<a-input
v-model:value="generatedTitle"
allow-clear
placeholder="生成结果中的标题会显示在这里"
/>
</a-form-item>
<a-form-item label="正文内容">
<a-tabs
v-model:activeKey="contentTab"
size="small"
class="content-tabs"
>
<a-tab-pane key="markdown" tab="Markdown 预览">
<div class="markdown-shell">
<div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</div>
<div
v-else-if="markdownContent"
class="markdown-body markdown-viewer"
v-html="renderedMarkdownHtml"
></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div>
</div>
</a-tab-pane>
<a-tab-pane key="rich" tab="富文本排版">
<div class="rich-shell">
<div class="rich-bar">
<span class="rich-bar-tip">
一键排版按原文的标题层级、段落、列表、表格还原,可直接在编辑器里继续改
</span>
<a-button
size="small"
type="primary"
:disabled="saveDisabled"
@click="saveAsManuscript"
>
{{ savedUpToDate ? '已保存为稿件' : '保存为稿件' }}
</a-button>
</div>
<Toolbar
class="rich-toolbar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
mode="default"
/>
<Editor
v-model="richContentHtml"
class="rich-editor"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleEditorCreated"
/>
</div>
</a-tab-pane>
</a-tabs>
</a-form-item>
</a-form>
</template>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import 'bytemd/dist/index.min.css';
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { Modal, message } from 'ant-design-vue';
import { messageLoading } from 'ele-admin-pro';
import { saveAs } from 'file-saver';
import { asBlob } from 'html-docx-js-typescript';
import { getProcessor } from 'bytemd';
import { runManuscriptGenStream } from '@/utils/ai-stream';
import type {
ManuscriptGenParams,
ManuscriptGenResult
} from '@/api/ai/manuscriptGen/model';
import { uploadFile } from '@/api/system/file';
import type { FileRecord } from '@/api/system/file/model';
import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
import { markdownToHtml } from '@/utils/manuscript-content';
import { addManuscript } from '@/api/cms/manuscript';
import type { Manuscript } from '@/api/cms/manuscript/model';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type {
IDomEditor,
IEditorConfig,
IToolbarConfig
} from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
interface FormState {
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
docType: string;
freeContent: string;
eventType: string;
theme: string;
time: string;
location: string;
participants: string;
highlights: string;
number: number;
}
interface ParsedResult {
title: string;
content: string;
}
type DemoType = 'news' | 'wechat' | 'notice' | 'free';
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
type StreamResolver = {
resolve: () => void;
};
const { push } = useRouter();
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
const loading = ref(false);
/** 正文区域展示方式:默认看 Markdown 预览,一键排版后切到富文本 */
const contentTab = ref<'markdown' | 'rich'>('markdown');
/** 富文本编辑器里的 HTML(一键排版灌入,人工可继续改) */
const richContentHtml = ref('');
const savingManuscript = ref(false);
/** 上次保存时的正文文本快照:内容没变就不再让点「保存为稿件」,避免存出重复草稿 */
const savedSnapshot = ref('');
const editorRef = shallowRef<IDomEditor>();
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video']
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: '点上方「一键排版」,把生成结果按原文排版放进这里',
MENU_CONF: {
uploadImage: {
allowedFileTypes: ['image/*'],
maxFileSize: 2 * 1024 * 1024,
base64LimitSize: 0,
async customUpload(file: File, insertFn: (url: string) => void) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片');
return;
}
try {
const result = await uploadFile(file);
insertFn(result.url || '');
} catch (e: any) {
message.error(e.message || '图片上传失败');
}
}
}
}
};
const handleEditorCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
const generatedTitle = ref('');
const generatedContent = ref('');
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
const streamStarted = ref(false);
const streamResolverRef = ref<StreamResolver | null>(null);
const STREAM_END_TEXT = '__END__';
const THINK_OPEN_TAG = '<think>';
const THINK_CLOSE_TAG = '</think>';
/** 「自由创作」的取值:选中它时不下发 docType,后端转「AI 创意助手」 */
const DOC_TYPE_FREE = '自由创作';
const form = reactive<FormState>({
// 默认自由创作:未选模板就说清想写什么,交给「AI 创意助手」
docType: DOC_TYPE_FREE,
freeContent: '',
eventType: '校园活动',
theme: '',
time: '',
location: '',
participants: '',
highlights: '',
number: 800
});
/**
* 选项必须与上游「内容创作与宣传助手」的 doc_type 枚举逐一对应:
* 新闻稿 / 活动总结 / 微信推文 / 通知公告。
* 多给一个上游没有的值,点下去就是一次必失败的提交(实测返回
* `doc_type in input form must be one of the following: [...]`),
* 所以这里不提供「宣传稿」「发言稿」「短视频脚本」这类上游没有的文体。
*/
const docTypeOptions = [
// 自由创作:表单只留一个内容输入框,提交时不带 docType
{ label: '自由创作(不套模板)', value: DOC_TYPE_FREE },
{ label: '新闻稿', value: '新闻稿' },
{ label: '活动总结', value: '活动总结' },
{ label: '公众号推文', value: '微信推文' },
{ label: '通知公告', value: '通知公告' }
];
const eventTypeOptions = [
{ label: '校园活动', value: '校园活动' },
{ label: '志愿服务', value: '志愿服务' },
{ label: '会议论坛', value: '会议论坛' },
{ label: '社会实践', value: '社会实践' },
{ label: '文体活动', value: '文体活动' },
{ label: '品牌项目', value: '品牌项目' }
];
/** 选了文稿模板才展开其余可选参数 */
const templateMode = computed(() => !!form.docType && form.docType !== DOC_TYPE_FREE);
const canSubmit = computed(() => {
if (!templateMode.value) {
return !!form.freeContent.trim();
}
return !!(form.theme.trim() || form.highlights.trim() || uploadedFiles.value.length);
});
const parseAssistantContent = (value: string) => {
if (!value) {
return {
content: '',
thinking: false
};
}
let cursor = 0;
let content = '';
let thinking = false;
while (cursor < value.length) {
if (!thinking) {
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor);
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
cursor = closeIndex + THINK_CLOSE_TAG.length;
continue;
}
if (openIndex === -1) {
content += value.slice(cursor);
break;
}
content += value.slice(cursor, openIndex);
cursor = openIndex + THINK_OPEN_TAG.length;
thinking = true;
continue;
}
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex === -1) {
break;
}
cursor = closeIndex + THINK_CLOSE_TAG.length;
thinking = false;
}
return {
content,
thinking
};
};
const markdownContent = computed(() => parseAssistantContent(generatedContent.value).content.trim());
const renderedMarkdownHtml = computed(() => {
if (!markdownContent.value) {
return '';
}
try {
return String(
getProcessor({
plugins: plugins.value
}).processSync(markdownContent.value)
);
} catch (e) {
console.error(e);
return markdownContent.value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br />');
}
});
const buildPayload = (): ManuscriptGenParams => {
if (!templateMode.value) {
// 自由创作:只发一段内容,后端见 docType 为空就转「AI 创意助手」
return {
docType: '',
userContent: form.freeContent.trim()
};
}
return {
docType: form.docType,
eventType: form.eventType,
theme: form.theme.trim(),
time: form.time.trim(),
location: form.location.trim(),
participants: form.participants.trim(),
highlights: form.highlights.trim(),
number: form.number,
files: uploadedFiles.value.map((item) => ({
id: item.id,
name: item.name,
path: item.path,
url: item.url,
downloadUrl: item.downloadUrl,
contentType: item.contentType
}))
};
};
const pickString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return '';
};
const pickRawStreamString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === 'string') {
return value;
}
}
return '';
};
// 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除:
// 它会把 taskId / workflowRunId 等内部标识符渲染进编辑器。
// 现在统一走 @/utils/ai-result(见 issue 10)。
const parseResult = (payload: ManuscriptGenResult): ParsedResult => {
if (typeof payload === 'string') {
return {
title: form.theme.trim(),
content: payload
};
}
// 失败态优先:后端失败时 answer 是失败文案(且带 status=failed),
// 不先判失败就会把失败文案当正文渲染(见 issue 10)。
if (isFailedPayload(payload)) {
return {
title: form.theme.trim(),
content: FAILED_TEXT
};
}
const title = pickString(
payload.title,
payload.theme,
payload.name,
payload.subject,
payload.data?.title
);
// 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文
const content = pickDisplayable(
payload.content,
payload.text,
payload.result,
payload.output,
payload.answer,
payload.manuscript,
payload.article,
payload.message,
payload.data?.content,
payload.data?.text,
payload.data?.result,
payload.data?.output,
payload.choices?.[0]?.message?.content
);
return {
title: title || form.theme.trim(),
// 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等
// 内部标识符,且此 content 会直接进编辑器(见 issue 10)。
content: content || resolveDisplayText({
payload,
scope: 'gxmu/manuscriptGen.parseResult',
candidates: []
})
};
};
const parseSerializedResult = (payload: string): ManuscriptGenResult => {
const text = payload.trim();
if (!text) {
return payload;
}
if (
(text.startsWith('{') && text.endsWith('}')) ||
(text.startsWith('[') && text.endsWith(']'))
) {
try {
return JSON.parse(text);
} catch (e) {
return payload;
}
}
return payload;
};
const applyParsedResult = (payload: ManuscriptGenResult) => {
const parsed = parseResult(payload);
generatedTitle.value = parsed.title || generatedTitle.value || form.theme.trim();
generatedContent.value = parsed.content;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
const resolveStream = () => {
streamResolverRef.value?.resolve();
clearStreamResolver();
};
const createStreamWaiter = () => {
streamStarted.value = false;
return new Promise<void>((resolve) => {
streamResolverRef.value = { resolve };
});
};
const appendAssistantChunk = (chunk: string, reset?: boolean) => {
if (!chunk) {
return;
}
if (!streamStarted.value) {
streamStarted.value = true;
generatedTitle.value = generatedTitle.value || form.theme.trim();
generatedContent.value = '';
}
// reset=true 表示「丢弃已累积的片段,只用本条内容」。
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
if (reset) {
generatedContent.value = chunk;
} else {
generatedContent.value += chunk;
}
};
const finishStream = () => {
const payload = parseSerializedResult(generatedContent.value);
applyParsedResult(payload);
loading.value = false;
resolveStream();
};
const handleSocketMessage = (raw: string) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.answer === STREAM_END_TEXT) {
finishStream();
return;
}
if (data?.loading === 'true' || data?.loading === true) {
return;
}
const chunk = pickRawStreamString(data?.notice, data?.answer);
if (typeof chunk === 'string' && chunk.length > 0) {
appendAssistantChunk(chunk, data?.reset === 'true' || data?.reset === true);
}
} catch (e) {
appendAssistantChunk(raw);
}
};
const clearGenerated = () => {
generatedTitle.value = '';
generatedContent.value = '';
// 排版结果是这份 markdown 的产物,正文换了就不能留着旧的
richContentHtml.value = '';
savedSnapshot.value = '';
contentTab.value = 'markdown';
};
const resetForm = () => {
// 重置回自由创作,而不是上一次的模板
form.docType = DOC_TYPE_FREE;
form.freeContent = '';
form.eventType = '校园活动';
form.theme = '';
form.time = '';
form.location = '';
form.participants = '';
form.highlights = '';
form.number = 800;
uploadedFiles.value = [];
};
const fillDemo = (type: DemoType) => {
if (type === 'free') {
form.docType = DOC_TYPE_FREE;
form.freeContent =
'帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右,面向大一新生。';
return;
}
if (type === 'news') {
form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动';
form.docType = '新闻稿';
form.eventType = '志愿服务';
form.time = '2026年4月8日下午';
form.location = '广西医科大学校园主干道及公共区域';
form.participants = '校团委老师、青年志愿者代表共120人';
form.highlights =
'交代活动时间地点、参与人员、主要服务内容、活动成效和师生反馈,突出青年志愿者精神和劳动教育成效。';
form.number = 900;
return;
}
if (type === 'wechat') {
form.theme = '青春集结!这场校园科技文化节亮点抢先看';
form.docType = '微信推文';
form.eventType = '校园活动';
form.time = '2026年4月中旬';
form.location = '大学生活动中心';
form.participants = '全校青年学生、科技社团成员';
form.highlights =
'开头制造期待感,中间分三部分介绍科技互动展区、舞台展示和报名信息,结尾增加报名引导。';
form.number = 1200;
return;
}
form.theme = '关于举办2026年广西医科大学青年志愿服务项目大赛的通知';
form.docType = '通知公告';
form.eventType = '志愿服务';
form.time = '2026年4月10日至5月20日';
form.location = '各学院团委、大学生活动中心';
form.participants = '全校各团支部、青年志愿者服务队';
form.highlights =
'说明报名条件、赛程安排、材料提交截止时间和联系方式,语气正式、条目清晰。';
form.number = 600;
};
/** 只留文本、丢掉标签与空白,用来判断两份富文本是不是同一份内容 */
const normalizeForCompare = (html: string) =>
String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, '');
/** 编辑器里的内容是否已经存成稿件(按文本比,编辑器规整过标签属性也算同一份) */
const savedUpToDate = computed(
() =>
!!savedSnapshot.value &&
savedSnapshot.value === normalizeForCompare(richContentHtml.value)
);
/** 保存按钮的可用状态:没内容、正在存、这份已经存过,都不给点 */
const saveDisabled = computed(
() =>
savingManuscript.value ||
!hasRichText(richContentHtml.value) ||
savedUpToDate.value
);
/** 编辑器里的“空”:wangEditor 空内容是一段 `<p><br></p>`,不能只看字符串长度 */
const hasRichText = (html: string) => {
const text = String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !!text || /<img\b/i.test(String(html || ''));
};
/**
* 一键排版:把生成结果按原文的标题层级、段落、列表、表格转成 HTML,
* 灌进富文本编辑器。
* - 用 gfm 插件,和页面上 Markdown 预览同一套解析(表格不会漏成管道符);
* - 不把生成标题拼进正文:标题有独立字段,导出 Word 时也只在导出件里拼;
* - 不落库:保存是用户在编辑器里确认过的第二次动作(见 saveAsManuscript)。
*/
const formatRichText = () => {
if (loading.value) {
message.warning('正在生成中,暂不能排版');
return;
}
if (!markdownContent.value) {
message.warning('暂无可排版的正文内容');
return;
}
if (markdownContent.value === FAILED_TEXT) {
message.error('生成失败的内容不能排版,请重新生成');
return;
}
const nextHtml = markdownToHtml(markdownContent.value, [
gfm({ locale: zh_HansGfm })
]);
if (!nextHtml.trim()) {
message.warning('当前内容转换不出富文本,请检查正文');
return;
}
const apply = () => {
richContentHtml.value = nextHtml;
// 排版换了内容,之前那次保存的状态就作废
savedSnapshot.value = '';
contentTab.value = 'rich';
message.success('已按原文排版放进富文本编辑器,请检查后保存');
};
// 编辑器里有内容且不是同一份排版(用户改过),才需要确认覆盖。
// 比的是「文本」而不是 HTML:wangEditor 会把 setHtml 进来的标签属性、
// 空白重新规整一遍,按 HTML 比会把「原样再排一次」也判成要覆盖。
if (
hasRichText(richContentHtml.value) &&
normalizeForCompare(richContentHtml.value) !== normalizeForCompare(nextHtml)
) {
Modal.confirm({
title: '覆盖编辑器里的内容?',
content: '富文本编辑器里已有内容,重新排版会覆盖掉当前改动。',
okText: '覆盖',
cancelText: '取消',
onOk: apply
});
return;
}
apply();
};
/**
* 保存为稿件:入库一律写 html,和「编辑稿件」弹窗的口径一致。
* add 接口不回 id,所以这里只负责“存成一条新稿件”,不冒充“再编辑同一篇”:
* 存过之后按钮置灰,改过正文才会重新可点(避免连点存出多条草稿)。
*/
const saveAsManuscript = async () => {
const title = (generatedTitle.value || form.theme).trim();
if (!title) {
message.warning('请先填写生成标题,稿件需要标题');
return;
}
if (!hasRichText(richContentHtml.value)) {
message.warning('富文本里还没有内容,请先点「一键排版」');
return;
}
if (savedUpToDate.value) {
message.info('这份排版已经保存过了,改完再点可以另存一条');
return;
}
savingManuscript.value = true;
try {
const msg = await addManuscript({
title,
content: richContentHtml.value,
contentFormat: 'html',
// 后端 status 是 Integer(列表里 0 待审核 / 1 显示),前端类型写成 string 是历史遗留
status: 1 as unknown as Manuscript['status'],
sortNumber: 0
});
savedSnapshot.value = normalizeForCompare(richContentHtml.value);
message.success(msg || '已保存为稿件');
} catch (e: any) {
message.error(e.message || '保存稿件失败');
} finally {
savingManuscript.value = false;
}
};
const removeUploadedFile = (index: number) => {
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
};
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
if (rawFile.size / 1024 / 1024 > 100) {
message.error('单个附件不能超过 100MB');
options.onError?.(new Error('文件过大'));
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
try {
const result = await uploadFile(rawFile);
uploadedFiles.value = [
...uploadedFiles.value,
{
id: result.id,
name: result.name || rawFile.name,
path: result.path,
url: result.url,
downloadUrl: result.downloadUrl,
contentType: result.contentType
}
];
options.onSuccess?.(result);
message.success('附件上传成功');
} catch (e: any) {
message.error(e.message || '上传失败');
options.onError?.(e);
} finally {
hide();
}
};
const handleGenerate = async () => {
if (!canSubmit.value) {
message.warning(
templateMode.value
? '请至少填写稿件标题、亮点内容或上传附件中的一项'
: '请先描述你想创作的内容'
);
return;
}
loading.value = true;
clearGenerated();
generatedTitle.value = form.theme.trim();
try {
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的 60s 空闲
// 超时掐成 504 Gateway Time-out(自由创作、不选文体时最先撞上)。
// /stream 每 15s 有后端心跳续命,正文逐段喂给原来的帧处理,
// 结构化结果从末帧 {type:'final'} 取。
const payload = buildPayload();
const waitStream = createStreamWaiter();
const result = await runManuscriptGenStream(
payload as unknown as Record<string, unknown>,
undefined,
(raw) => handleSocketMessage(raw)
);
if (result) {
applyParsedResult(
typeof result === 'string'
? parseSerializedResult(result)
: (result as ManuscriptGenResult)
);
}
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
resolveStream();
await waitStream;
const text = markdownContent.value.trim();
if (!text) {
message.warning('这次没有生成出内容,请重试');
} else if (text === FAILED_TEXT) {
message.error('生成失败,请稍后重试');
} else {
message.success('内容生成完成');
}
} catch (e: any) {
message.error(e.message || '内容生成失败');
} finally {
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
clearStreamResolver();
loading.value = false;
}
};
const copyGenerated = () => {
if (!markdownContent.value) {
message.warning('暂无可复制的正文内容');
return;
}
copyText(markdownContent.value);
};
const copyGeneratedTitle = () => {
if (!generatedTitle.value.trim()) {
message.warning('暂无可复制的标题');
return;
}
copyText(generatedTitle.value);
};
const buildWordHtml = (bodyHtml: string) => {
const title = generatedTitle.value.trim() || form.theme.trim() || 'AI生成稿件';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
body {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
color: #222;
line-height: 1.8;
font-size: 14px;
padding: 12px 8px;
}
h1, h2, h3, h4, h5, h6 {
color: #111;
line-height: 1.5;
margin: 1.2em 0 0.6em;
}
h1.export-title {
text-align: center;
margin: 0 0 1.5em;
font-size: 24px;
}
p {
margin: 0 0 0.9em;
}
ul, ol {
margin: 0 0 1em 1.5em;
}
blockquote {
margin: 1em 0;
padding: 0.6em 1em;
border-left: 4px solid #d9d9d9;
color: #555;
background: #fafafa;
}
pre, code {
font-family: "SFMono-Regular", Consolas, monospace;
}
pre {
padding: 12px;
background: #f6f8fa;
overflow: hidden;
white-space: pre-wrap;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1em;
}
th, td {
border: 1px solid #d9d9d9;
padding: 8px 10px;
}
img {
max-width: 100%;
}
</style>
</head>
<body>
<h1 class="export-title">${title}</h1>
${bodyHtml}
</body>
</html>`;
};
const exportWord = async () => {
if (loading.value) {
message.warning('正在生成中,暂不能导出');
return;
}
if (!markdownContent.value) {
message.warning('暂无可导出的正文内容');
return;
}
const bodyHtml = renderedMarkdownHtml.value.trim();
if (!bodyHtml) {
message.warning('当前内容尚未完成渲染,请稍后重试');
return;
}
const hide = messageLoading({
content: '正在导出 Word..',
duration: 0,
mask: true
});
try {
const blob = await asBlob(buildWordHtml(bodyHtml), {
margins: {
top: 720,
right: 720,
bottom: 720,
left: 720
}
});
const fileName = `${(generatedTitle.value.trim() || form.theme.trim() || 'AI生成稿件').replace(/[\\\\/:*?\"<>|]/g, '_')}.docx`;
saveAs(blob as Blob, fileName);
message.success('Word 导出成功');
} catch (e: any) {
message.error(e?.message || 'Word 导出失败');
} finally {
hide();
}
};
const goChatPage = () => {
push('/ai');
};
/** 写作记录统一在助手页查看,这里只带类型跳过去 */
const goHistory = () => {
push({ path: '/ai', query: { type: 'writing' } });
};
onBeforeUnmount(() => {
clearStreamResolver();
editorRef.value?.destroy();
});
</script>
<style scoped lang="less">
.manuscript-gen-page {
min-height: 100%;
padding: 24px;
background:
radial-gradient(circle at top right, rgba(245, 124, 0, 0.14), transparent 24%),
linear-gradient(180deg, #f5fbff 0%, #fff 62%);
}
.page-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
padding: 28px 32px;
border-radius: 24px;
background: linear-gradient(135deg, #0b75d1 0%, #1590ff 48%, #32bfff 100%);
color: #f5fbff;
box-shadow: 0 18px 44px rgba(21, 144, 255, 0.18);
}
.hero-kicker {
margin-bottom: 10px;
font-size: 12px;
letter-spacing: 0.22em;
color: rgba(245, 251, 255, 0.72);
}
.page-hero h1 {
margin: 0;
font-size: 32px;
line-height: 1.2;
color: #f5fbff;
}
.page-hero p {
max-width: 720px;
margin: 14px 0 0;
font-size: 14px;
line-height: 1.8;
color: rgba(245, 251, 255, 0.88);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
min-width: 280px;
}
.hero-actions :deep(.ant-btn) {
border-radius: 999px;
}
.panel-card {
border-radius: 20px;
box-shadow: 0 14px 36px rgba(21, 144, 255, 0.08);
}
.quick-actions-panel {
margin-bottom: 14px;
padding: 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14px 36px rgba(21, 144, 255, 0.08);
}
.quick-actions-title {
margin-bottom: 12px;
color: #1f2d3d;
font-size: 14px;
font-weight: 700;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.submit-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #c7e8ff;
border-radius: 14px;
background: #f5fbff;
}
.file-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.file-badge {
flex: none;
padding: 2px 8px;
border-radius: 999px;
background: #e8f4ff;
color: #1590ff;
font-size: 12px;
line-height: 20px;
}
.file-text {
min-width: 0;
flex: 1;
}
.file-name {
color: #1f2d3d;
font-weight: 600;
}
.file-url {
margin-top: 2px;
color: #5f7f9f;
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.file-remove {
flex: none;
color: #1590ff;
}
.result-card {
min-height: 100%;
}
.result-title {
display: flex;
align-items: center;
gap: 10px;
}
.markdown-shell {
min-height: 360px;
border: 1px solid #c7e8ff;
border-radius: 16px;
background: #fffdfb;
overflow: hidden;
}
.markdown-streaming {
min-height: 360px;
padding: 18px 20px;
color: #1f2d3d;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
}
.markdown-empty {
min-height: 360px;
display: flex;
align-items: center;
justify-content: center;
color: #ab8a71;
}
.markdown-viewer {
min-height: 360px;
padding: 18px 20px;
}
.markdown-viewer :deep(.markdown-body) {
background: transparent;
color: #1f2d3d;
}
.content-tabs :deep(.ant-tabs-nav) {
margin-bottom: 10px;
}
.rich-shell {
border: 1px solid #c7e8ff;
border-radius: 16px;
overflow: hidden;
background: #fffdfb;
}
.rich-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #eef4f9;
background: #f5fbff;
}
.rich-bar-tip {
color: #5f7f9f;
font-size: 12px;
line-height: 1.6;
}
.rich-toolbar {
border-bottom: 1px solid #f0f0f0;
}
.rich-editor {
min-height: 360px;
}
.rich-editor :deep(.w-e-text-container) {
min-height: 360px;
}
.full-width {
width: 100%;
}
@media (max-width: 991px) {
.manuscript-gen-page {
padding: 16px;
}
.page-hero {
flex-direction: column;
padding: 22px 20px;
}
.page-hero h1 {
font-size: 26px;
}
.hero-actions {
min-width: 0;
justify-content: flex-start;
}
}
</style>