Files
tuanwei-admin/src/views/gxmu/manuscriptGen/index.vue
T
weicw 77378a2791 feat(ai): 助手页支持写作/生图等记录的历史列表与按类型筛选
- 三模式各自独立工作区:切模式不丢消息、草稿与当前记录
- 历史面板加类型徽标与筛选(全部/聊天/写作/图片/创意/活动策划),文案改「历史记录」
- 生成成功认领末帧 chatId 并刷新列表;聊天 __END__ 帧的 chatId 同样接受
- 生图回复支持多张 ![](url) 渲染
- 写作/图片记录支持「按原参数再来一次」(只回填、不自动发送)
- 三个独立页(文稿/创意/活动策划)加历史入口,/ai?type= 预置筛选
- 后台 aiChatList 加类型列与筛选;历史列表提示仅展示最近 30 条
2026-10-24 17:37:46 +08:00

1074 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="manuscript-gen-page">
<div class="page-hero">
<div>
<div class="hero-kicker">AI CONTENT STUDIO</div>
<h1>AI内容创作与宣传助手</h1>
<p>写新闻稿、活动总结、公众号推文或通知公告,说清需求就行,AI 先给出一份初稿,可以接着改。</p>
</div>
<div class="hero-actions">
<a-button type="primary" ghost @click="goChatPage">进入问答助手</a-button>
<a-button type="primary" ghost @click="goHistory">历史记录</a-button>
</div>
</div>
<a-row :gutter="[24, 24]">
<a-col :xs="24" :xl="10">
<div class="quick-actions-panel">
<div class="quick-actions-title">快捷模板</div>
<div class="quick-actions">
<a-button block @click="fillDemo('news')">活动新闻稿</a-button>
<a-button block @click="fillDemo('wechat')">公众号推文</a-button>
<a-button block @click="fillDemo('notice')">通知公告</a-button>
<a-button block @click="fillDemo('free')">自由创作示例</a-button>
</div>
</div>
<a-card :bordered="false" class="panel-card">
<template #title>创作配置</template>
<a-form layout="vertical">
<a-form-item label="稿件类型">
<a-select
v-model:value="form.docType"
:options="docTypeOptions"
placeholder="请选择稿件类型"
/>
</a-form-item>
<a-form-item v-if="!templateMode" label="想创作的内容">
<a-textarea
v-model:value="form.freeContent"
:auto-size="{ minRows: 6, maxRows: 12 }"
placeholder="例如:帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右"
/>
</a-form-item>
<template v-if="templateMode">
<a-form-item label="稿件标题">
<a-input
v-model:value="form.theme"
allow-clear
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
/>
</a-form-item>
<a-form-item label="活动类型">
<a-select
v-model:value="form.eventType"
:options="eventTypeOptions"
placeholder="请选择活动类型"
/>
</a-form-item>
<a-form-item label="活动时间">
<a-input
v-model:value="form.time"
allow-clear
placeholder="例如:2026年4月8日下午"
/>
</a-form-item>
<a-form-item label="活动地点">
<a-input
v-model:value="form.location"
allow-clear
placeholder="例如:广西医科大学校史馆广场"
/>
</a-form-item>
<a-form-item label="参与人员">
<a-input
v-model:value="form.participants"
allow-clear
placeholder="例如:校团委老师、青年志愿者代表共120人"
/>
</a-form-item>
<a-form-item label="亮点内容">
<a-textarea
v-model:value="form.highlights"
:auto-size="{ minRows: 5, maxRows: 8 }"
placeholder="填写活动亮点、流程、成果、典型细节等关键信息"
/>
</a-form-item>
<a-form-item label="建议篇幅">
<a-input-number
v-model:value="form.number"
class="full-width"
:min="100"
:max="5000"
:step="100"
placeholder="请输入建议字数"
/>
</a-form-item>
<a-form-item label="附件材料">
<a-upload
:multiple="true"
:show-upload-list="false"
:customRequest="handleUpload"
>
<a-button>
<template #icon>
<UploadOutlined />
</template>
上传附件
</a-button>
</a-upload>
<div v-if="uploadedFiles.length" class="file-list">
<div
v-for="(item, index) in uploadedFiles"
:key="`${item.url || item.path || item.name}-${index}`"
class="file-item"
>
<div class="file-main">
<div class="file-badge">附件</div>
<div class="file-text">
<div class="file-name">{{ item.name || '未命名文件' }}</div>
<div class="file-url">{{ item.url || item.path || '-' }}</div>
</div>
</div>
<a class="file-remove" @click="removeUploadedFile(index)">移除</a>
</div>
</div>
</a-form-item>
</template>
</a-form>
<div class="submit-bar">
<a-button :disabled="loading" @click="resetForm">重置</a-button>
<a-button
type="primary"
:loading="loading"
:disabled="!canSubmit"
@click="handleGenerate"
>
生成内容
</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24" :xl="14">
<a-card :bordered="false" class="panel-card result-card">
<template #title>
<div class="result-title">
<span>生成结果</span>
<a-tag :color="loading ? 'processing' : markdownContent ? 'success' : 'default'">
{{ loading ? '生成中' : markdownContent ? '已生成' : '待生成' }}
</a-tag>
</div>
</template>
<template #extra>
<a-space>
<a-button :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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br />');
}
});
const buildPayload = (): ManuscriptGenParams => {
if (!templateMode.value) {
// 自由创作:只发一段内容,后端见 docType 为空就转「AI 创意助手」
return {
docType: '',
userContent: form.freeContent.trim()
};
}
return {
docType: form.docType,
eventType: form.eventType,
theme: form.theme.trim(),
time: form.time.trim(),
location: form.location.trim(),
participants: form.participants.trim(),
highlights: form.highlights.trim(),
number: form.number,
files: uploadedFiles.value.map((item) => ({
id: item.id,
name: item.name,
path: item.path,
url: item.url,
downloadUrl: item.downloadUrl,
contentType: item.contentType
}))
};
};
const pickString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return '';
};
const pickRawStreamString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === 'string') {
return value;
}
}
return '';
};
// 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除:
// 它会把 taskId / workflowRunId 等内部标识符渲染进编辑器。
// 现在统一走 @/utils/ai-result(见 issue 10)。
const parseResult = (payload: ManuscriptGenResult): ParsedResult => {
if (typeof payload === 'string') {
return {
title: form.theme.trim(),
content: payload
};
}
// 失败态优先:后端失败时 answer 是失败文案(且带 status=failed),
// 不先判失败就会把失败文案当正文渲染(见 issue 10)。
if (isFailedPayload(payload)) {
return {
title: form.theme.trim(),
content: FAILED_TEXT
};
}
const title = pickString(
payload.title,
payload.theme,
payload.name,
payload.subject,
payload.data?.title
);
// 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文
const content = pickDisplayable(
payload.content,
payload.text,
payload.result,
payload.output,
payload.answer,
payload.manuscript,
payload.article,
payload.message,
payload.data?.content,
payload.data?.text,
payload.data?.result,
payload.data?.output,
payload.choices?.[0]?.message?.content
);
return {
title: title || form.theme.trim(),
// 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等
// 内部标识符,且此 content 会直接进编辑器(见 issue 10)。
content: content || resolveDisplayText({
payload,
scope: 'gxmu/manuscriptGen.parseResult',
candidates: []
})
};
};
const parseSerializedResult = (payload: string): ManuscriptGenResult => {
const text = payload.trim();
if (!text) {
return payload;
}
if (
(text.startsWith('{') && text.endsWith('}')) ||
(text.startsWith('[') && text.endsWith(']'))
) {
try {
return JSON.parse(text);
} catch (e) {
return payload;
}
}
return payload;
};
const applyParsedResult = (payload: ManuscriptGenResult) => {
const parsed = parseResult(payload);
generatedTitle.value = parsed.title || generatedTitle.value || form.theme.trim();
generatedContent.value = parsed.content;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
const resolveStream = () => {
streamResolverRef.value?.resolve();
clearStreamResolver();
};
const createStreamWaiter = () => {
streamStarted.value = false;
return new Promise<void>((resolve) => {
streamResolverRef.value = { resolve };
});
};
const appendAssistantChunk = (chunk: string, reset?: boolean) => {
if (!chunk) {
return;
}
if (!streamStarted.value) {
streamStarted.value = true;
generatedTitle.value = generatedTitle.value || form.theme.trim();
generatedContent.value = '';
}
// reset=true 表示「丢弃已累积的片段,只用本条内容」。
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
if (reset) {
generatedContent.value = chunk;
} else {
generatedContent.value += chunk;
}
};
const finishStream = () => {
const payload = parseSerializedResult(generatedContent.value);
applyParsedResult(payload);
loading.value = false;
resolveStream();
};
const handleSocketMessage = (raw: string) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.answer === STREAM_END_TEXT) {
finishStream();
return;
}
if (data?.loading === 'true' || data?.loading === true) {
return;
}
const chunk = pickRawStreamString(data?.notice, data?.answer);
if (typeof chunk === 'string' && chunk.length > 0) {
appendAssistantChunk(chunk, data?.reset === 'true' || data?.reset === true);
}
} catch (e) {
appendAssistantChunk(raw);
}
};
const clearGenerated = () => {
generatedTitle.value = '';
generatedContent.value = '';
};
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>