77378a2791
- 三模式各自独立工作区:切模式不丢消息、草稿与当前记录 - 历史面板加类型徽标与筛选(全部/聊天/写作/图片/创意/活动策划),文案改「历史记录」 - 生成成功认领末帧 chatId 并刷新列表;聊天 __END__ 帧的 chatId 同样接受 - 生图回复支持多张  渲染 - 写作/图片记录支持「按原参数再来一次」(只回填、不自动发送) - 三个独立页(文稿/创意/活动策划)加历史入口,/ai?type= 预置筛选 - 后台 aiChatList 加类型列与筛选;历史列表提示仅展示最近 30 条
1074 lines
31 KiB
Vue
1074 lines
31 KiB
Vue
<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 :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>
|
||
</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 { 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';
|
||
|
||
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);
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.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 = '';
|
||
};
|
||
|
||
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 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();
|
||
});
|
||
</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;
|
||
}
|
||
|
||
.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>
|