Files
tuanwei-admin/src/views/gxmu/manuscriptGen/index.vue
T
2026-07-20 11:50:47 +08:00

1204 lines
33 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>
</div>
</div>
<a-card :bordered="false" class="panel-card">
<template #title>创作配置</template>
<a-form layout="vertical">
<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.docType"
:options="docTypeOptions"
placeholder="请选择稿件类型"
/>
</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>
</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">
页面会先提交 `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';
interface FormState {
docType: 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';
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>';
const form = reactive<FormState>({
docType: '新闻稿',
eventType: '校园活动',
theme: '',
time: '',
location: '',
participants: '',
highlights: '',
number: 800
});
const docTypeOptions = [
{ 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 canSubmit = computed(() => {
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 => {
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 '';
};
const stringifyUnknown = (value: unknown) => {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value ?? '');
}
};
const parseResult = (payload: ManuscriptGenResult): ParsedResult => {
if (typeof payload === 'string') {
return {
title: form.theme.trim(),
content: payload,
summary: '接口直接返回文本内容'
};
}
const title = pickString(
payload.title,
payload.theme,
payload.name,
payload.subject,
payload.data?.title
);
const content = pickString(
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(),
content: content || stringifyUnknown(payload),
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) => {
if (!chunk) {
return;
}
if (conversationId) {
activeConversationId.value = conversationId;
}
if (!streamStarted.value) {
streamStarted.value = true;
generatedTitle.value = generatedTitle.value || form.theme.trim();
generatedContent.value = '';
}
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);
}
} 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 = '新闻稿';
form.eventType = '校园活动';
form.theme = '';
form.time = '';
form.location = '';
form.participants = '';
form.highlights = '';
form.number = 800;
uploadedFiles.value = [];
};
const fillDemo = (type: DemoType) => {
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('请至少填写稿件标题、亮点内容或上传附件中的一项');
return;
}
loading.value = true;
clearGenerated();
generatedTitle.value = form.theme.trim();
responseSummary.value = '正在建立 WebSocket 连接...';
try {
await connectWebSocket();
responseSummary.value = '连接成功,已提交生成任务,等待流式返回...';
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>