Files
tuanwei-uniapp/pages/manuscript/index.vue
2026-07-20 11:56:02 +08:00

1113 lines
28 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>
<view class="page">
<scroll-view scroll-y class="page-scroll">
<view class="hero-card">
<view class="hero-kicker">AI CONTENT STUDIO</view>
<view class="hero-title">AI 内容创作与宣传助手</view>
<view class="hero-desc">
面向团委宣传活动报道新闻稿和推文草稿的快速生成场景填写创作条件后页面会调用
`/ai/manuscriptGen` 并通过 WebSocket 实时接收生成内容
</view>
<view class="hero-actions">
<view class="hero-action" @tap="fillDemo('news')">活动新闻稿</view>
<view class="hero-action" @tap="fillDemo('wechat')">公众号推文</view>
<view class="hero-action" @tap="fillDemo('speech')">发言稿提纲</view>
<view class="hero-action hero-action--primary" @tap="goChatPage">进入问答助手</view>
</view>
</view>
<view class="panel-card">
<view class="panel-title">创作配置</view>
<view class="field-list">
<view class="field-item">
<view class="field-label">稿件标题</view>
<uv-input
v-model="form.theme"
class="field-input"
placeholder="例如广西医科大学开展2026年春季志愿服务活动"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">稿件类型</view>
<picker mode="selector" :range="docTypeLabels" :value="docTypeIndex" @change="handleDocTypeChange">
<view class="field-picker">
<text class="field-value">{{ form.docType || '请选择稿件类型' }}</text>
</view>
</picker>
</view>
<view class="field-item">
<view class="field-label">活动类型</view>
<picker mode="selector" :range="eventTypeLabels" :value="eventTypeIndex" @change="handleEventTypeChange">
<view class="field-picker">
<text class="field-value">{{ form.eventType || '请选择活动类型' }}</text>
</view>
</picker>
</view>
<view class="field-item">
<view class="field-label">活动时间</view>
<uv-input
v-model="form.time"
class="field-input"
placeholder="例如2026年4月8日下午"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">活动地点</view>
<uv-input
v-model="form.location"
class="field-input"
placeholder="例如:广西医科大学校史馆广场"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">参与人员</view>
<uv-input
v-model="form.participants"
class="field-input"
placeholder="例如校团委老师、青年志愿者代表共120人"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">建议篇幅</view>
<uv-input
v-model="form.number"
class="field-input"
type="number"
placeholder="请输入建议字数"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">亮点内容</view>
<textarea
v-model="form.highlights"
class="field-textarea"
placeholder="填写活动亮点、流程、成果、典型细节等关键信息"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">附件材料</view>
<view class="upload-bar">
<view class="upload-btn" @tap="chooseFiles">{{ uploading ? '上传中...' : '选择附件' }}</view>
<view class="upload-tip">支持图片文档等附件上传后将按 `files` 字段提交</view>
</view>
<view v-if="uploadedFiles.length" class="file-list">
<view
v-for="(item, index) in uploadedFiles"
:key="`${item.url || item.path || item.name}-${index}`"
class="file-item-card"
>
<view class="file-main">
<view class="file-badge">附件</view>
<view class="file-text">
<view class="file-name">{{ item.name || '未命名文件' }}</view>
<view class="file-url">{{ item.url || item.path || '-' }}</view>
</view>
</view>
<view class="file-remove" @tap="removeUploadedFile(index)">移除</view>
</view>
</view>
</view>
</view>
<view class="submit-bar">
<view class="ghost-btn" @tap="resetForm">重置</view>
<view class="primary-btn" :class="{ 'primary-btn--disabled': !canSubmit || loading }" @tap="handleGenerate">
{{ loading ? '生成中' : '生成内容' }}
</view>
</view>
<view class="hint-block">
<view class="hint-title">提交说明</view>
<view class="hint-text">
页面会先提交 `POST /ai/manuscriptGen`再通过 WebSocket 流式接收生成结果如果后端直接返回正文也会自动兼容显示
</view>
</view>
</view>
<view class="panel-card result-card">
<view class="result-head">
<view class="panel-title">生成结果</view>
<view class="result-status" :class="loading ? 'result-status--loading' : markdownContent ? 'result-status--done' : ''">
{{ loading ? '生成中' : markdownContent ? '已生成' : '待生成' }}
</view>
</view>
<view class="field-item">
<view class="field-label">生成标题</view>
<uv-input
v-model="generatedTitle"
class="field-input"
placeholder="生成结果中的标题会显示在这里"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">正文内容</view>
<view class="result-shell">
<view v-if="loading" class="result-streaming">
{{ markdownContent || 'AI 正在生成内容...' }}
</view>
<zero-markdown-view v-else-if="markdownContent" class="result-content" :markdown="markdownContent" />
<view v-else class="result-empty">填写左侧创作条件后开始生成</view>
</view>
</view>
<view v-if="responseSummary" class="response-meta">
<view class="response-label">接口摘要</view>
<view class="response-value">{{ responseSummary }}</view>
</view>
<view class="result-actions">
<view class="result-btn" @tap="copyGenerated">复制正文</view>
<view class="result-btn" @tap="copyGeneratedTitle">复制标题</view>
<view class="result-btn" @tap="clearGenerated">清空结果</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import {
buildWebSocketUrl,
getCurrentUser,
manuscriptGen,
uploadTempFile
} from './service'
const STREAM_END_TEXT = '__END__'
const SOCKET_CONNECTED_TEXT = '连接成功'
const THINK_OPEN_TAG = '<think>'
const THINK_CLOSE_TAG = '</think>'
const DOC_TYPE_OPTIONS = ['新闻稿', '公众号推文', '宣传稿', '活动总结', '发言稿', '短视频脚本']
const EVENT_TYPE_OPTIONS = ['校园活动', '志愿服务', '会议论坛', '社会实践', '文体活动', '品牌项目']
const createDefaultForm = () => ({
docType: '新闻稿',
eventType: '校园活动',
theme: '',
time: '',
location: '',
participants: '',
highlights: '',
number: '800'
})
export default {
data() {
return {
form: createDefaultForm(),
docTypeLabels: DOC_TYPE_OPTIONS,
eventTypeLabels: EVENT_TYPE_OPTIONS,
loading: false,
uploading: false,
wsConnected: false,
generatedTitle: '',
generatedContent: '',
responseSummary: '',
uploadedFiles: [],
activeConversationId: '',
socketTask: null,
socketConnectPromise: null,
streamStarted: false,
streamResolver: null,
currentUser: {
userId: null
}
}
},
computed: {
docTypeIndex() {
const index = this.docTypeLabels.indexOf(this.form.docType)
return index > -1 ? index : 0
},
eventTypeIndex() {
const index = this.eventTypeLabels.indexOf(this.form.eventType)
return index > -1 ? index : 0
},
canSubmit() {
return !!(
String(this.form.theme || '').trim() ||
String(this.form.highlights || '').trim() ||
this.uploadedFiles.length
)
},
markdownContent() {
return this.parseAssistantContent(this.generatedContent).content.trim()
}
},
onUnload() {
this.clearStreamResolver()
this.disconnectWebSocket()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
handleDocTypeChange(event) {
const index = Number(event.detail.value)
this.form.docType = this.docTypeLabels[index] || this.form.docType
},
handleEventTypeChange(event) {
const index = Number(event.detail.value)
this.form.eventType = this.eventTypeLabels[index] || this.form.eventType
},
parseAssistantContent(value) {
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
}
},
buildPayload() {
return {
docType: this.form.docType,
eventType: this.form.eventType,
theme: String(this.form.theme || '').trim(),
time: String(this.form.time || '').trim(),
location: String(this.form.location || '').trim(),
participants: String(this.form.participants || '').trim(),
highlights: String(this.form.highlights || '').trim(),
number: Number(this.form.number || 0) || 800,
files: this.uploadedFiles.map((item) => ({
id: item.id,
name: item.name,
path: item.path,
url: item.url,
downloadUrl: item.downloadUrl,
contentType: item.contentType
}))
}
},
pickString() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return ''
},
pickRawStreamString() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (typeof value === 'string') {
return value
}
}
return ''
},
stringifyUnknown(value) {
try {
return JSON.stringify(value, null, 2)
} catch (error) {
return String(value || '')
}
},
parseResult(payload) {
if (typeof payload === 'string') {
return {
title: String(this.form.theme || '').trim(),
content: payload,
summary: '接口直接返回文本内容'
}
}
const title = this.pickString(
payload && payload.title,
payload && payload.theme,
payload && payload.name,
payload && payload.subject,
payload && payload.data && payload.data.title
)
const content = this.pickString(
payload && payload.content,
payload && payload.text,
payload && payload.result,
payload && payload.output,
payload && payload.answer,
payload && payload.manuscript,
payload && payload.article,
payload && payload.message,
payload && payload.data && payload.data.content,
payload && payload.data && payload.data.text,
payload && payload.data && payload.data.result,
payload && payload.data && payload.data.output,
payload && payload.choices && payload.choices[0] && payload.choices[0].message && payload.choices[0].message.content
)
const keys = payload && typeof payload === 'object' ? Object.keys(payload) : []
return {
title: title || String(this.form.theme || '').trim(),
content: content || this.stringifyUnknown(payload),
summary: keys.length ? `已解析返回字段:${keys.join('、')}` : '返回对象为空'
}
},
parseSerializedResult(payload) {
const text = String(payload || '').trim()
if (!text) {
return payload
}
if (
(text.startsWith('{') && text.endsWith('}')) ||
(text.startsWith('[') && text.endsWith(']'))
) {
try {
return JSON.parse(text)
} catch (error) {
return payload
}
}
return payload
},
applyParsedResult(payload, defaultSummary) {
const parsed = this.parseResult(payload)
this.generatedTitle = parsed.title || this.generatedTitle || String(this.form.theme || '').trim()
this.generatedContent = parsed.content
this.responseSummary = parsed.summary || defaultSummary
},
shouldTreatAsDirectResult(payload) {
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'
},
clearStreamResolver() {
this.streamResolver = null
},
resolveStream() {
if (this.streamResolver && this.streamResolver.resolve) {
this.streamResolver.resolve()
}
this.clearStreamResolver()
},
rejectStream(error) {
if (this.streamResolver && this.streamResolver.reject) {
this.streamResolver.reject(error)
}
this.clearStreamResolver()
},
createStreamWaiter() {
this.streamStarted = false
return new Promise((resolve, reject) => {
this.streamResolver = {
resolve,
reject
}
})
},
disconnectWebSocket() {
this.socketConnectPromise = null
this.wsConnected = false
const socketTask = this.socketTask
this.socketTask = null
if (socketTask && typeof socketTask.close === 'function') {
try {
socketTask.close({})
} catch (error) {
console.warn('socket close failed', error)
}
}
},
appendAssistantChunk(chunk, conversationId) {
if (!chunk) {
return
}
if (conversationId) {
this.activeConversationId = conversationId
}
if (!this.streamStarted) {
this.streamStarted = true
this.generatedTitle = this.generatedTitle || String(this.form.theme || '').trim()
this.generatedContent = ''
}
this.generatedContent += chunk
this.responseSummary = '正在通过 WebSocket 接收流式内容'
},
finishStream(conversationId) {
if (conversationId) {
this.activeConversationId = conversationId
}
const payload = this.parseSerializedResult(this.generatedContent)
this.applyParsedResult(payload, '已通过 WebSocket 接收生成结果')
this.loading = false
this.resolveStream()
this.disconnectWebSocket()
},
handleSocketMessage(raw) {
if (!raw) {
return
}
try {
const data = JSON.parse(raw)
if (data && data.answer === STREAM_END_TEXT) {
this.finishStream(data.conversationId)
return
}
if (data && (data.loading === 'true' || data.loading === true)) {
return
}
const chunk = this.pickRawStreamString(data && data.notice, data && data.answer)
if (chunk.length > 0) {
this.appendAssistantChunk(chunk, data && data.conversationId)
}
} catch (error) {
this.appendAssistantChunk(raw)
}
},
refreshUser() {
this.currentUser = getCurrentUser()
return this.currentUser
},
connectWebSocket() {
const user = this.refreshUser()
if (!user.userId) {
return Promise.reject(new Error('未获取到当前登录用户'))
}
const currentSocket = this.socketTask
if (currentSocket && this.wsConnected) {
return Promise.resolve()
}
if (currentSocket && this.socketConnectPromise) {
return this.socketConnectPromise
}
if (currentSocket) {
this.disconnectWebSocket()
}
let cleanupPending = () => undefined
const connectPromise = new Promise((resolve, reject) => {
let settled = false
const socketTask = uni.connectSocket({
url: buildWebSocketUrl(user.userId),
complete: () => {}
})
cleanupPending = () => {
if (this.socketConnectPromise === connectPromise) {
this.socketConnectPromise = null
}
}
socketTask.onOpen(() => {
this.wsConnected = false
})
socketTask.onMessage((event) => {
const raw = String((event && event.data) || '')
if (raw === SOCKET_CONNECTED_TEXT) {
this.wsConnected = true
if (!settled) {
settled = true
cleanupPending()
resolve()
}
return
}
this.handleSocketMessage(raw)
})
socketTask.onClose(() => {
this.wsConnected = false
if (this.socketTask === socketTask) {
this.socketTask = null
}
if (!settled) {
settled = true
cleanupPending()
reject(new Error('稿件生成连接已关闭'))
return
}
if (this.loading) {
this.rejectStream(new Error('稿件生成连接已关闭'))
this.loading = false
}
cleanupPending()
})
socketTask.onError(() => {
this.wsConnected = false
if (!settled) {
settled = true
cleanupPending()
reject(new Error('稿件生成连接失败'))
return
}
if (this.loading) {
this.rejectStream(new Error('稿件生成连接失败'))
this.loading = false
}
})
this.socketTask = socketTask
})
this.socketConnectPromise = connectPromise
return connectPromise
},
clearGenerated() {
this.generatedTitle = ''
this.generatedContent = ''
this.responseSummary = ''
},
resetForm() {
this.form = createDefaultForm()
this.uploadedFiles = []
},
fillDemo(type) {
if (type === 'news') {
this.form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动'
this.form.docType = '新闻稿'
this.form.eventType = '志愿服务'
this.form.time = '2026年4月8日下午'
this.form.location = '广西医科大学校园主干道及公共区域'
this.form.participants = '校团委老师、青年志愿者代表共120人'
this.form.highlights = '交代活动时间地点、参与人员、主要服务内容、活动成效和师生反馈,突出青年志愿者精神和劳动教育成效。'
this.form.number = '900'
return
}
if (type === 'wechat') {
this.form.theme = '青春集结!这场校园科技文化节亮点抢先看'
this.form.docType = '公众号推文'
this.form.eventType = '校园活动'
this.form.time = '2026年4月中旬'
this.form.location = '大学生活动中心'
this.form.participants = '全校青年学生、科技社团成员'
this.form.highlights = '开头制造期待感,中间分三部分介绍科技互动展区、舞台展示和报名信息,结尾增加报名引导。'
this.form.number = '1200'
return
}
this.form.theme = '在学校共青团工作会议上的发言提纲'
this.form.docType = '发言稿'
this.form.eventType = '会议论坛'
this.form.time = '2026年春季学期工作会议'
this.form.location = '学校综合楼报告厅'
this.form.participants = '各学院团委书记、团干部代表'
this.form.highlights = '总结上阶段工作,分析问题不足,部署下一阶段思想引领、基层组织建设和实践育人重点任务。'
this.form.number = '1500'
},
async chooseFiles() {
if (this.uploading) {
return
}
try {
const result = await new Promise((resolve, reject) => {
uni.chooseMessageFile({
count: 5,
type: 'all',
success: resolve,
fail: reject
})
})
const tempFiles = (result && result.tempFiles) || []
if (!tempFiles.length) {
return
}
this.uploading = true
uni.showLoading({
title: '上传中',
mask: true
})
for (let index = 0; index < tempFiles.length; index += 1) {
const file = tempFiles[index]
if (file.size && file.size / 1024 / 1024 > 100) {
this.showToast('单个附件不能超过 100MB')
continue
}
const uploaded = await uploadTempFile(file)
this.uploadedFiles = [
...this.uploadedFiles,
{
id: uploaded.id,
name: uploaded.name || file.name,
path: uploaded.path,
url: uploaded.url,
downloadUrl: uploaded.downloadUrl,
contentType: uploaded.contentType
}
]
}
this.showToast('附件上传成功')
} catch (error) {
if (error && error.errMsg && String(error.errMsg).includes('cancel')) {
return
}
this.showToast((error && error.message) || '上传失败')
} finally {
this.uploading = false
uni.hideLoading()
}
},
removeUploadedFile(index) {
this.uploadedFiles = this.uploadedFiles.filter((item, current) => current !== index)
},
async handleGenerate() {
if (this.loading) {
return
}
if (!this.canSubmit) {
this.showToast('请至少填写稿件标题、亮点内容或上传附件中的一项')
return
}
this.loading = true
this.clearGenerated()
this.generatedTitle = String(this.form.theme || '').trim()
this.responseSummary = '正在建立 WebSocket 连接...'
try {
await this.connectWebSocket()
this.responseSummary = '连接成功,已提交生成任务,等待流式返回...'
const waitStream = this.createStreamWaiter()
const result = await manuscriptGen(this.buildPayload())
if (!this.streamStarted && this.shouldTreatAsDirectResult(result)) {
this.applyParsedResult(
typeof result === 'string' ? this.parseSerializedResult(result) : result,
'接口直接返回生成结果'
)
this.loading = false
this.resolveStream()
this.disconnectWebSocket()
} else {
await waitStream
}
this.showToast('内容生成完成')
} catch (error) {
this.disconnectWebSocket()
this.clearStreamResolver()
this.loading = false
this.showToast((error && error.message) || '内容生成失败')
}
},
copyGenerated() {
if (!this.markdownContent) {
this.showToast('暂无可复制的正文内容')
return
}
uni.setClipboardData({
data: this.markdownContent,
success: () => {
this.showToast('正文已复制')
}
})
},
copyGeneratedTitle() {
if (!String(this.generatedTitle || '').trim()) {
this.showToast('暂无可复制的标题')
return
}
uni.setClipboardData({
data: this.generatedTitle.trim(),
success: () => {
this.showToast('标题已复制')
}
})
},
goChatPage() {
uni.switchTab({
url: '/pages/assistant/index'
})
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(20, 150, 242, 0.14), transparent 24%),
linear-gradient(180deg, #f5fbff 0%, #fff 62%);
}
.page-scroll {
height: 100vh;
padding: 28rpx 24rpx 40rpx;
}
.hero-card {
padding: 34rpx 30rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #0c5f9f 0%, #1496f2 48%, #67c7ff 100%);
box-shadow: 0 18rpx 44rpx rgba(20, 118, 194, 0.18);
color: #f4fbff;
}
.hero-kicker {
font-size: 22rpx;
letter-spacing: 4rpx;
color: rgba(255, 244, 233, 0.72);
}
.hero-title {
margin-top: 18rpx;
font-size: 42rpx;
line-height: 1.35;
font-weight: 700;
color: #fffaf6;
}
.hero-desc {
margin-top: 16rpx;
font-size: 24rpx;
line-height: 1.8;
color: rgba(255, 244, 233, 0.88);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 26rpx;
}
.hero-action {
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.12);
font-size: 22rpx;
color: #fff7f1;
}
.hero-action--primary {
background: rgba(255, 255, 255, 0.22);
font-weight: 700;
}
.panel-card {
margin-top: 24rpx;
padding: 26rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.96);
border: 1rpx solid rgba(20, 150, 242, 0.08);
box-shadow: 0 10rpx 28rpx rgba(34, 94, 142, 0.08);
}
.panel-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.field-list {
display: flex;
flex-direction: column;
gap: 22rpx;
margin-top: 20rpx;
}
.field-item {
width: 100%;
}
.field-label {
margin-bottom: 12rpx;
font-size: 25rpx;
font-weight: 600;
color: #2b3037;
}
.field-picker,
.field-textarea {
width: 100%;
box-sizing: border-box;
padding: 22rpx 24rpx;
border-radius: 22rpx;
background: #f4faff;
border: 1rpx solid #d6eafb;
font-size: 24rpx;
color: #2a2e35;
}
.field-textarea {
min-height: 220rpx;
}
.field-value {
color: #303133;
}
:deep(.field-input.uv-input) {
width: 100%;
min-height: 88rpx;
padding: 0 24rpx;
border-radius: 12rpx;
border: 1px solid #dcdfe6;
background: #ffffff;
box-sizing: border-box;
}
:deep(.field-input.uv-input .uv-input__content__field-wrapper__field) {
height: 88rpx;
min-height: 88rpx;
font-size: 28rpx;
line-height: 88rpx;
color: #303133;
}
.upload-bar {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.upload-btn {
align-self: flex-start;
padding: 14rpx 24rpx;
border-radius: 20rpx;
background: rgba(20, 150, 242, 0.1);
color: #1496f2;
font-size: 24rpx;
font-weight: 600;
}
.upload-tip {
font-size: 22rpx;
line-height: 1.7;
color: #8b817c;
}
.file-list {
display: flex;
flex-direction: column;
gap: 14rpx;
margin-top: 16rpx;
}
.file-item-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: #f4faff;
}
.file-main {
display: flex;
align-items: center;
gap: 14rpx;
flex: 1;
min-width: 0;
}
.file-badge {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: #fff;
color: #1496f2;
font-size: 20rpx;
}
.file-text {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 24rpx;
font-weight: 600;
color: #2f3035;
}
.file-url {
margin-top: 8rpx;
font-size: 20rpx;
color: #8e837d;
word-break: break-all;
}
.file-remove {
flex-shrink: 0;
font-size: 22rpx;
color: #1496f2;
}
.submit-bar,
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 24rpx;
}
.result-actions {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ghost-btn,
.primary-btn,
.result-btn {
height: 88rpx;
line-height: 88rpx;
border-radius: 22rpx;
font-size: 28rpx;
font-weight: 700;
text-align: center;
}
.ghost-btn {
background: #edf6ff;
color: #0f7dd1;
border: 1rpx solid #cfe7fb;
}
.primary-btn {
background: linear-gradient(135deg, #0f7dd1 0%, #1496f2 100%);
color: #fff;
}
.primary-btn--disabled {
opacity: 0.6;
}
.result-btn {
background: #edf6ff;
color: #0f7dd1;
}
.hint-block,
.response-meta {
margin-top: 22rpx;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: #f4faff;
border: 1rpx solid rgba(20, 150, 242, 0.08);
}
.hint-title,
.response-label {
font-size: 24rpx;
font-weight: 700;
color: #8b4a12;
}
.hint-text,
.response-value {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.75;
color: #7b6d64;
}
.result-card {
margin-bottom: 20rpx;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.result-status {
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: #f3f0ed;
color: #8b827d;
font-size: 20rpx;
font-weight: 600;
}
.result-status--loading {
background: #edf5ff;
color: #1554ad;
}
.result-status--done {
background: #eef9f1;
color: #1f8f49;
}
.result-shell {
min-height: 360rpx;
padding: 24rpx;
border-radius: 24rpx;
background: #fbf7f2;
border: 1rpx solid #f0e1d5;
}
.result-streaming,
.result-content,
.result-empty {
font-size: 24rpx;
line-height: 1.85;
white-space: pre-wrap;
word-break: break-word;
}
.result-streaming,
.result-content {
color: #2b3037;
}
.result-empty {
color: #9a8f89;
}
</style>