Files
tuanwei-admin/src/views/gxmu/manuscriptGen/index.vue
T
weicw-dshsa d8ef29afe7 feat(ai): 写作模式拆成自由创作与模板两条路(issue 18)
自由创作不再解析自然语言:原话整段作为 userContent 下发,docType 留空,由后端转
「AI 创意助手」。选了文体才提交 docType 等结构化条件,走「内容创作与宣传助手」。

- writing-params.ts 删掉 parseWritingRequest、字段别名表与字数兜底表,只留选项常量。
- chatgpt/index.vue:字数选择器只在选中文体后出现;撤掉图片比例选择器(上游开始
  节点无比例变量,只能拼进 user_content,等于替上游写 prompt);结果不再用入参补标题。
- gxmu/manuscriptGen:新增「自由创作」入口与 freeContent 文本域,模板模式才展开活动
  信息等可选项。
- 顺带删除 chatgpt/index.bak.vue 旧备份。
2026-10-21 12:39:54 +08:00

1283 lines
37 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/manuscriptGen` 发起生成任务,并通过 WebSocket 实时接收返回内容,支持继续编辑与复制。
</p>
</div>
<div class="hero-actions">
<a-button type="primary" ghost @click="goChatPage">进入问答助手</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('speech')">发言稿提纲</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="例如:帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右"
/>
<div class="upload-tip">
不套模板时,这段内容会作为 `user_content` 交给「AI 创意助手」;选上面的文稿模板后可填写活动信息等可选参数。
</div>
</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 class="upload-tip">
支持图片、文档等附件,上传后按 `files` 字段一并提交给接口。
</div>
<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>
<div class="hint-block">
<div class="hint-title">提交说明</div>
<div class="hint-text">
未选模板(自由创作)时只提交一段内容,后端转「AI 创意助手」;选了模板才提交
`docType` 等结构化条件。两种都会先 `POST /ai/manuscriptGen`,再按 `/ai` 页面的方式
通过 WebSocket 流式接收生成结果;如果后端仍然直接返回正文,页面也会自动兼容解析。
</div>
</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 :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="正文内容">
<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-form-item>
</a-form>
<!-- <div v-if="responseSummary" class="response-meta">-->
<!-- <div class="meta-label">接口摘要</div>-->
<!-- <div class="meta-value">{{ responseSummary }}</div>-->
<!-- </div>-->
</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 } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
import { 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 { manuscriptGen } from '@/api/ai/manuscriptGen';
import type {
ManuscriptGenParams,
ManuscriptGenResult
} from '@/api/ai/manuscriptGen/model';
import { uploadFile } from '@/api/system/file';
import type { FileRecord } from '@/api/system/file/model';
import { API_BASE_URL } from '@/config/setting';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
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;
summary: string;
}
type DemoType = 'news' | 'wechat' | 'speech' | 'free';
type StreamResolver = {
resolve: () => void;
reject: (error: Error) => void;
};
const { push } = useRouter();
const userStore = useUserStore();
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
const loading = ref(false);
const wsConnected = ref(false);
const generatedTitle = ref('');
const generatedContent = ref('');
const responseSummary = ref('');
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
const activeConversationId = ref<string>();
const webSocketRef = ref<WebSocket | null>(null);
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
const streamStarted = ref(false);
const streamResolverRef = ref<StreamResolver | null>(null);
const loginUser = computed(() => userStore.info ?? {});
const STREAM_END_TEXT = '__END__';
const SOCKET_CONNECTED_TEXT = '连接成功';
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
});
const docTypeOptions = [
// 自由创作:表单只留一个内容输入框,提交时不带 docType
{ label: '自由创作(不套模板)', value: DOC_TYPE_FREE },
{ label: '新闻稿', value: '新闻稿' },
{ label: '公众号推文', value: '公众号推文' },
{ 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,
summary: '接口直接返回文本内容'
};
}
// 失败态优先:后端失败时 answer 是失败文案(且带 status=failed),
// 不先判失败就会把失败文案当正文渲染(见 issue 10)。
if (isFailedPayload(payload)) {
return {
title: form.theme.trim(),
content: FAILED_TEXT,
summary: '上游工作流执行失败'
};
}
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
);
const summary = Object.keys(payload).length
? `已解析返回字段:${Object.keys(payload).join('、')}`
: '返回对象为空';
return {
title: title || form.theme.trim(),
// 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等
// 内部标识符,且此 content 会直接进编辑器(见 issue 10)。
content: content || resolveDisplayText({
payload,
scope: 'gxmu/manuscriptGen.parseResult',
candidates: []
}),
summary
};
};
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,
defaultSummary = '已通过 WebSocket 接收生成结果'
) => {
const parsed = parseResult(payload);
generatedTitle.value = parsed.title || generatedTitle.value || form.theme.trim();
generatedContent.value = parsed.content;
responseSummary.value = parsed.summary || defaultSummary;
};
const shouldTreatAsDirectResult = (payload: unknown) => {
if (!payload) {
return false;
}
if (typeof payload === 'string') {
const text = payload.trim();
if (!text) {
return false;
}
return (
text.startsWith('{') ||
text.startsWith('[') ||
text.includes('\n') ||
text.length > 80
);
}
return typeof payload === 'object';
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
const resolveStream = () => {
streamResolverRef.value?.resolve();
clearStreamResolver();
};
const rejectStream = (error: Error) => {
streamResolverRef.value?.reject(error);
clearStreamResolver();
};
const createStreamWaiter = () => {
streamStarted.value = false;
return new Promise<void>((resolve, reject) => {
streamResolverRef.value = { resolve, reject };
});
};
const disconnectWebSocket = () => {
wsConnectPromiseRef.value = null;
wsConnected.value = false;
const socket = webSocketRef.value;
webSocketRef.value = null;
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close();
}
};
const appendAssistantChunk = (
chunk: string,
conversationId?: string,
reset?: boolean
) => {
if (!chunk) {
return;
}
if (conversationId) {
activeConversationId.value = conversationId;
}
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;
}
responseSummary.value = '正在通过 WebSocket 接收流式内容';
};
const finishStream = (conversationId?: string) => {
if (conversationId) {
activeConversationId.value = conversationId;
}
const payload = parseSerializedResult(generatedContent.value);
applyParsedResult(payload, '已通过 WebSocket 接收生成结果');
loading.value = false;
resolveStream();
disconnectWebSocket();
};
const handleSocketMessage = (raw: string) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.answer === STREAM_END_TEXT) {
finishStream(data.conversationId);
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?.conversationId,
data?.reset === 'true' || data?.reset === true
);
}
} catch (e) {
appendAssistantChunk(raw);
}
};
const connectWebSocket = async () => {
const userId = loginUser.value.userId;
if (!userId) {
return Promise.reject(new Error('未获取到当前登录用户'));
}
const currentSocket = webSocketRef.value;
if (currentSocket?.readyState === WebSocket.OPEN && wsConnected.value) {
return;
}
if (
(currentSocket?.readyState === WebSocket.OPEN ||
currentSocket?.readyState === WebSocket.CONNECTING) &&
wsConnectPromiseRef.value
) {
return wsConnectPromiseRef.value;
}
let cleanupPending = () => undefined;
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
}
};
socket.onopen = () => {
wsConnected.value = false;
};
socket.onmessage = (event) => {
const raw = String(event.data || '');
if (raw === SOCKET_CONNECTED_TEXT) {
wsConnected.value = true;
if (!settled) {
settled = true;
cleanupPending();
resolve();
}
return;
}
handleSocketMessage(raw);
};
socket.onclose = () => {
wsConnected.value = false;
if (webSocketRef.value === socket) {
webSocketRef.value = null;
}
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('稿件生成连接已关闭'));
return;
}
if (loading.value) {
rejectStream(new Error('稿件生成连接已关闭'));
loading.value = false;
}
cleanupPending();
};
socket.onerror = () => {
wsConnected.value = false;
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('稿件生成连接失败'));
return;
}
if (loading.value) {
rejectStream(new Error('稿件生成连接失败'));
loading.value = false;
}
};
webSocketRef.value = socket;
});
wsConnectPromiseRef.value = connectPromise;
return connectPromise;
};
const clearGenerated = () => {
generatedTitle.value = '';
generatedContent.value = '';
responseSummary.value = '';
};
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 = '在学校共青团工作会议上的发言提纲';
form.docType = '发言稿';
form.eventType = '会议论坛';
form.time = '2026年春季学期工作会议';
form.location = '学校综合楼报告厅';
form.participants = '各学院团委书记、团干部代表';
form.highlights = '总结上阶段工作,分析问题不足,部署下一阶段思想引领、基层组织建设和实践育人重点任务。';
form.number = 1500;
};
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();
responseSummary.value = '正在建立 WebSocket 连接...';
try {
await connectWebSocket();
responseSummary.value = templateMode.value
? '连接成功,已提交生成任务,等待流式返回...'
: '自由创作已提交给 AI 创意助手,生成完成后一次性返回...';
const waitStream = createStreamWaiter();
const result = await manuscriptGen(buildPayload());
if (!streamStarted.value && shouldTreatAsDirectResult(result)) {
applyParsedResult(
typeof result === 'string' ? parseSerializedResult(result) : (result as ManuscriptGenResult),
'接口直接返回生成结果'
);
loading.value = false;
resolveStream();
disconnectWebSocket();
} else {
await waitStream;
}
message.success('内容生成完成');
} catch (e: any) {
disconnectWebSocket();
clearStreamResolver();
loading.value = false;
message.error(e.message || '内容生成失败');
}
};
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');
};
onBeforeUnmount(() => {
clearStreamResolver();
disconnectWebSocket();
});
</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;
}
.upload-tip {
margin-top: 10px;
color: #5f7f9f;
font-size: 12px;
line-height: 1.7;
}
.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;
}
.hint-block {
margin-top: 18px;
padding: 14px 16px;
border-radius: 14px;
background: #f5fbff;
border: 1px solid #c7e8ff;
}
.hint-title {
margin-bottom: 6px;
font-weight: 600;
color: #1590ff;
}
.hint-text {
color: #5f7f9f;
line-height: 1.7;
font-size: 13px;
}
.result-card {
min-height: 100%;
}
.result-title {
display: flex;
align-items: center;
gap: 10px;
}
.response-meta {
padding: 12px 14px;
border-radius: 14px;
background: #f5fbff;
}
.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;
}
.meta-label {
margin-bottom: 4px;
font-weight: 600;
color: #1f2d3d;
}
.meta-value {
color: #5f7f9f;
line-height: 1.7;
word-break: break-all;
}
.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>