Files
tuanwei-uniapp/src/pages/manuscript/index.vue
T
weicw-dshsa a66dee49e1 fix(manuscript): 稿件类型收敛到上游 doc_type 枚举
与 admin 同一处病根:picker 把界面标签直接当 doc_type 下发,
而「公众号推文」「宣传稿」「发言稿」「短视频脚本」上游一个都不认,
选中就是一次必失败的提交。

- 选项收敛为 新闻稿 / 活动总结 / 微信推文 / 通知公告(外加「自由创作」)
- picker 拆成 label / value 两份:界面仍显示「公众号推文」,下发「微信推文」
- 快捷模板「发言稿提纲」换成「通知公告」,补对应示例内容
2026-10-23 14:22:34 +08:00

1060 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 先给出一份初稿。</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('notice')">通知公告</view>
<view class="hero-action" @tap="fillDemo('free')">自由创作示例</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>
<picker mode="selector" :range="docTypeLabels" :value="docTypeIndex" @change="handleDocTypeChange">
<view class="field-picker">
<text class="field-value">{{ docTypeLabel || '请选择稿件类型' }}</text>
</view>
</picker>
</view>
<template v-if="templateMode">
<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="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>
<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>
</template>
<template v-else>
<view class="field-item">
<view class="field-label">想创作什么</view>
<textarea
v-model="form.freeContent"
class="field-textarea field-textarea--free"
placeholder="直接描述你的创作需求,例如:帮我写一段迎新晚会的主持人开场词,口语化一点"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
/>
</view>
</template>
</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>
<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 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 {
streamManuscriptGen,
uploadTempFile
} from './service'
import {
isFailedPayload,
pickDisplayable,
resolveDisplayText
} from '../../utils/ai-result'
const STREAM_END_TEXT = '__END__'
const THINK_OPEN_TAG = '<think>'
const THINK_CLOSE_TAG = '</think>'
const DOC_TYPE_FREE = '自由创作'
// 「自由创作」是默认项:不选具体模板时只填一段内容,后端转「AI 创意助手」。
// 其余选项必须与上游「内容创作与宣传助手」的 doc_type 枚举逐一对应:
// 新闻稿 / 活动总结 / 微信推文 / 通知公告。上游没有的文体(宣传稿、发言稿、
// 短视频脚本)一律不提供 —— 下发过去只会换来一次必失败的提交。
// label 给学生看的叫法,value 是真正下发的 doc_type。
const DOC_TYPE_ITEMS = [
{ label: '自由创作(不套模板)', value: DOC_TYPE_FREE },
{ label: '新闻稿', value: '新闻稿' },
{ label: '活动总结', value: '活动总结' },
{ label: '公众号推文', value: '微信推文' },
{ label: '通知公告', value: '通知公告' }
]
const DOC_TYPE_OPTIONS = DOC_TYPE_ITEMS.map((item) => item.label)
const DOC_TYPE_VALUES = DOC_TYPE_ITEMS.map((item) => item.value)
const EVENT_TYPE_OPTIONS = ['校园活动', '志愿服务', '会议论坛', '社会实践', '文体活动', '品牌项目']
const createDefaultForm = () => ({
docType: DOC_TYPE_FREE,
freeContent: '',
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,
generatedTitle: '',
generatedContent: '',
uploadedFiles: [],
streamStarted: false,
streamFailed: false,
streamResolver: null,
}
},
computed: {
docTypeIndex() {
const index = DOC_TYPE_VALUES.indexOf(this.form.docType)
return index > -1 ? index : 0
},
docTypeLabel() {
// 选中项显示 label,而不是下发的 doc_type(如「公众号推文」→「微信推文」)
const index = DOC_TYPE_VALUES.indexOf(this.form.docType)
return index > -1 ? DOC_TYPE_OPTIONS[index] : this.form.docType
},
eventTypeIndex() {
const index = this.eventTypeLabels.indexOf(this.form.eventType)
return index > -1 ? index : 0
},
freeMode() {
return !this.form.docType || this.form.docType === DOC_TYPE_FREE
},
templateMode() {
return !this.freeMode
},
canSubmit() {
if (this.freeMode) {
return !!String(this.form.freeContent || '').trim()
}
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()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
handleDocTypeChange(event) {
const index = Number(event.detail.value)
this.form.docType = DOC_TYPE_VALUES[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() {
if (this.freeMode) {
// 自由模式:不发 docType,只发一段用户原话。后端据此转「AI 创意助手」。
return {
docType: '',
userContent: String(this.form.freeContent || '').trim()
}
}
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 ''
},
parseResult(payload) {
if (typeof payload === 'string') {
// 注:失败态判定只对对象形态生效;字符串若是未解析成功的
// 序列化报文,其中的字段取不出来,按原文展示,不做失败推断。
return {
title: String(this.form.theme || '').trim(),
content: payload
}
}
if (isFailedPayload(payload)) {
return {
title: String(this.form.theme || '').trim(),
content: resolveDisplayText({ payload, scope: 'manuscript' })
}
}
const title = this.pickString(
payload && payload.title,
payload && payload.theme,
payload && payload.name,
payload && payload.subject,
payload && payload.data && payload.data.title
)
// 用 pickDisplayable 而非 pickString:answer 可能是 workflow_run_id
// 这类内部标识符,原样展示对用户没有意义且构成泄露。
const content = pickDisplayable(
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
)
// 兜底改为可读文案,绝不把整包报文 stringify 进正文区。
// 原始载荷交给 logRawPayload(在 resolveDisplayText 内)记日志。
const resolved = content || resolveDisplayText({ payload, scope: 'manuscript' })
return {
title: title || String(this.form.theme || '').trim(),
content: resolved
}
},
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) {
const parsed = this.parseResult(payload)
this.generatedTitle = parsed.title || this.generatedTitle || String(this.form.theme || '').trim()
this.generatedContent = parsed.content
},
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
this.streamFailed = false
return new Promise((resolve, reject) => {
this.streamResolver = {
resolve,
reject
}
})
},
appendAssistantChunk(chunk, reset) {
if (!chunk) {
return
}
if (!this.streamStarted || reset) {
this.streamStarted = true
// reset 表示后端判定失败、本条覆盖之前的流式残片
this.generatedTitle = this.generatedTitle || String(this.form.theme || '').trim()
this.generatedContent = ''
}
if (reset) {
// 记住失败态,供收尾时给出正确提示而不是「生成完成」
this.streamFailed = true
}
this.generatedContent += chunk
},
finishStream() {
const payload = this.parseSerializedResult(this.generatedContent)
this.applyParsedResult(payload)
this.loading = false
this.resolveStream()
},
handleSocketMessage(raw) {
if (!raw) {
return
}
try {
const data = JSON.parse(raw)
if (data && data.answer === STREAM_END_TEXT) {
this.finishStream()
return
}
const reset = !!(data && (data.reset === 'true' || data.reset === true))
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, reset)
}
} catch (error) {
this.appendAssistantChunk(raw)
}
},
clearGenerated() {
this.generatedTitle = ''
this.generatedContent = ''
},
resetForm() {
this.form = createDefaultForm()
this.uploadedFiles = []
},
fillDemo(type) {
if (type === 'free') {
this.form.docType = DOC_TYPE_FREE
this.form.freeContent = '帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右'
return
}
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 = '关于举办2026年广西医科大学青年志愿服务项目大赛的通知'
this.form.docType = '通知公告'
this.form.eventType = '志愿服务'
this.form.time = '2026年4月10日至5月20日'
this.form.location = '各学院团委、大学生活动中心'
this.form.participants = '全校各团支部、青年志愿者服务队'
this.form.highlights = '说明报名条件、赛程安排、材料提交截止时间和联系方式,语气正式、条目清晰。'
this.form.number = '600'
},
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(this.freeMode
? '请先描述你想创作什么'
: '请至少填写稿件标题、亮点内容或上传附件中的一项')
return
}
this.loading = true
this.clearGenerated()
this.generatedTitle = String(this.form.theme || '').trim()
try {
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的
// 60s 空闲超时掐成 504 Gateway Time-out(自由创作、不选文体时最先撞上)。
// /stream 每 15s 有后端心跳续命,帧内容与 WS 完全一致。
const payload = this.buildPayload()
const waitStream = this.createStreamWaiter()
await streamManuscriptGen(payload, {
onMessage: (raw) => this.handleStreamFrame(raw)
}).promise
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
this.resolveStream()
await waitStream
if (this.streamFailed) {
// 后端判定工作流失败,正文区已显示失败文案,这里同步提示
this.showToast('生成失败,请稍后重试')
} else if (!this.markdownContent) {
this.showToast('这次没有生成出内容,请重试')
} else {
this.showToast('内容生成完成')
}
} catch (error) {
this.showToast((error && error.message) || '内容生成失败')
} finally {
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
this.clearStreamResolver()
this.loading = false
}
},
/**
* SSE 帧分发:末帧带着接口的返回值(自由创作模式整段都不推正文,只能靠它),
* 其余帧与 WebSocket 完全一致,继续交给 handleSocketMessage。
*/
handleStreamFrame(raw) {
let frame = null
try {
frame = JSON.parse(raw)
} catch (error) {
this.handleSocketMessage(raw)
return
}
if (!frame || frame.type !== 'final') {
this.handleSocketMessage(raw)
return
}
const result = frame.result
if (result && Number(result.code) === 0 && result.data) {
this.applyParsedResult(
typeof result.data === 'string' ? this.parseSerializedResult(result.data) : result.data
)
return
}
if (result && result.message) {
this.showToast(result.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-textarea--free {
min-height: 300rpx;
}
.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;
}
.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;
}
.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>