forked from b2894lxlx/tuanwei-admin
d8ef29afe7
自由创作不再解析自然语言:原话整段作为 userContent 下发,docType 留空,由后端转 「AI 创意助手」。选了文体才提交 docType 等结构化条件,走「内容创作与宣传助手」。 - writing-params.ts 删掉 parseWritingRequest、字段别名表与字数兜底表,只留选项常量。 - chatgpt/index.vue:字数选择器只在选中文体后出现;撤掉图片比例选择器(上游开始 节点无比例变量,只能拼进 user_content,等于替上游写 prompt);结果不再用入参补标题。 - gxmu/manuscriptGen:新增「自由创作」入口与 freeContent 文本域,模板模式才展开活动 信息等可选项。 - 顺带删除 chatgpt/index.bak.vue 旧备份。
1283 lines
37 KiB
Vue
1283 lines
37 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/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, '&')
|
||
.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,
|
||
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>
|