1113 lines
28 KiB
Vue
1113 lines
28 KiB
Vue
<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>
|