Files
tuanwei-uniapp/src/pages/activity-outline/index.vue
T
weicw-dshsa d99f4406fe fix(ai): 生成收尾不再只依赖 __END__,并清掉 WS 死代码
与后台两页保持一致:

- handleGenerate 收尾改 finally:loading 一定复位,不会再出现生成完按钮永久
  不可点(issue 20);提示按「失败 / 无正文 / 成功」区分
- 删掉已不会调用的 WebSocket 栈:connectWebSocket / disconnectWebSocket /
  refreshUser / socketTask / socketConnectPromise / socketHeartbeatStop /
  wsConnected / activeConversationId / activeMessageId,以及 attachHeartbeat、
  buildWebSocketUrl、getCurrentUser 与未使用的 manuscriptGen 导入
2026-10-23 12:57:12 +08:00

708 lines
18 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 EVENT PLANNING OUTLINE</view>
<view class="hero-title">活动策划大纲生成</view>
<view class="hero-desc">填写活动主题、受众、时间和预算,AI 给出一份结构完整的策划大纲。</view>
<view class="hero-actions">
<view class="hero-action" @tap="fillExample('volunteer')">志愿服务</view>
<view class="hero-action" @tap="fillExample('salon')">主题沙龙</view>
<view class="hero-action" @tap="fillExample('sports')">校园赛事</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.event_theme"
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.people"
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.event_time"
class="field-input"
placeholder="例如:2026年4月18日 14:30-17:30"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">活动资金</view>
<uv-input
v-model="form.event_funding"
class="field-input"
placeholder="例如:预算 5000 元,来源为校团委专项经费"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">活动形式</view>
<uv-input
v-model="form.event_type"
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.event_location"
class="field-input"
placeholder="例如:大学生活动中心一楼报告厅"
placeholder-style="color: #c0c4cc;"
:maxlength="-1"
/>
</view>
<view class="field-item">
<view class="field-label">备注</view>
<textarea
v-model="form.event_meme"
class="field-textarea"
placeholder="补充活动背景、目标、关键限制、预期成果或希望体现的亮点"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
/>
</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>
<view class="panel-card result-card">
<view class="result-head">
<view class="panel-title">生成结果</view>
<view class="result-status" :class="loading ? 'result-status--loading' : hasOutlineContent ? 'result-status--done' : ''">
{{ loading ? '生成中' : hasOutlineContent ? '已生成' : '待生成' }}
</view>
</view>
<view v-if="loading" class="loading-panel">
<view class="loading-title">正在生成活动策划大纲…</view>
</view>
<view v-if="showThinkingIndicator" class="outline-thinking">
<view class="thinking-dot"></view>
<text>正在生成正文…</text>
</view>
<view v-if="hasOutlineContent || loading" class="field-item">
<view class="field-label">策划大纲</view>
<textarea
v-model="editableOutlineMarkdown"
class="result-textarea"
placeholder="这里可以直接修改大纲"
placeholder-style="color: #c0c4cc;"
maxlength="-1"
:disabled="loading"
/>
</view>
<view v-else class="result-empty">
填写活动信息后开始生成
</view>
<view class="result-actions">
<view class="result-btn" @tap="copyOutline">复制内容</view>
<view class="result-btn" @tap="clearResult">清空结果</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import {
streamActivityOutlineGen
} 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 createDefaultForm = () => ({
event_theme: '',
people: '',
event_time: '',
event_funding: '',
event_type: '',
event_location: '',
event_meme: ''
})
export default {
data() {
return {
form: createDefaultForm(),
loading: false,
result: null,
outlineMarkdown: '',
editableOutlineMarkdown: '',
streamStarted: false,
streamFailed: false,
streamResolver: null,
}
},
computed: {
canSubmit() {
return !!(
String(this.form.event_theme || '').trim() &&
String(this.form.people || '').trim() &&
String(this.form.event_time || '').trim() &&
String(this.form.event_funding || '').trim()
)
},
parsedOutlineContent() {
return this.parseAssistantContent(this.outlineMarkdown)
},
visibleOutlineMarkdown() {
return this.parsedOutlineContent.content
},
hasOutlineContent() {
return !!String(this.editableOutlineMarkdown || '').trim()
},
showThinkingIndicator() {
return this.loading && this.parsedOutlineContent.thinking
}
},
onUnload() {
this.clearStreamResolver()
},
methods: {
showToast(title) {
uni.showToast({
title,
icon: 'none'
})
},
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
}
},
syncEditableOutline() {
this.editableOutlineMarkdown = this.visibleOutlineMarkdown
},
buildPayload() {
return {
event_theme: String(this.form.event_theme || '').trim(),
people: String(this.form.people || '').trim(),
event_time: String(this.form.event_time || '').trim(),
event_funding: String(this.form.event_funding || '').trim(),
event_type: String(this.form.event_type || '').trim(),
event_location: String(this.form.event_location || '').trim(),
event_meme: String(this.form.event_meme || '').trim()
}
},
pickRawString() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (typeof value === 'string') {
return value
}
}
return undefined
},
parseOutline(payload) {
if (!payload) {
return ''
}
// 失败态优先:此时 answer 可能为空或就是 workflow_run_id,
// 且 raw 里含内部标识符,绝不能作为内容源。
if (isFailedPayload(payload)) {
return resolveDisplayText({ payload, scope: 'activity-outline' })
}
const answer = pickDisplayable(
payload.answer,
payload.raw && payload.raw.answer,
payload.raw && payload.raw.data && payload.raw.data.answer,
payload.raw && payload.raw.output,
payload.raw && payload.raw.result,
payload.raw && payload.raw.content,
payload.raw && payload.raw.message
)
if (answer) {
return answer
}
// 原实现是 return this.stringifyUnknown((payload && payload.raw) || payload),
// 即**主动**把后端整包当兜底内容渲染 —— 这是本 issue 最严重的一处。
// 现改为可读文案,原始载荷只进日志。
return resolveDisplayText({ payload, scope: 'activity-outline' })
},
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
}
})
},
appendOutlineChunk(chunk, reset) {
if (!chunk) {
return
}
if (!this.streamStarted || reset) {
this.streamStarted = true
// reset 表示后端判定失败、本条覆盖之前的流式残片
this.outlineMarkdown = ''
}
if (reset) {
// 记住失败态,供收尾时给出正确提示而不是「生成完成」
this.streamFailed = true
}
this.outlineMarkdown += chunk
this.syncEditableOutline()
},
finishStream() {
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
}
// reset 需在 loading 之前读取:失败消息不带 loading,
// 但语义上必须先清掉流式残片(见 issue 03)。
const reset = !!(data && (data.reset === 'true' || data.reset === true))
if (data && (data.loading === 'true' || data.loading === true)) {
return
}
const chunk = this.pickRawString(data && data.notice, data && data.answer)
if (typeof chunk === 'string') {
this.appendOutlineChunk(chunk, reset)
}
} catch (error) {
this.appendOutlineChunk(raw)
}
},
clearResult() {
this.result = null
this.outlineMarkdown = ''
this.editableOutlineMarkdown = ''
this.streamFailed = false
},
resetForm() {
this.form = createDefaultForm()
this.clearResult()
},
async handleGenerate() {
if (this.loading) {
return
}
if (!this.canSubmit) {
this.showToast('请先填写全部必填项')
return
}
this.loading = true
this.clearResult()
try {
// 一律走 HTTP 流式(/stream),不再先连 WebSocket:
// 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的
// 60s 空闲超时掐成 504 Gateway Time-out。/stream 每 15s 有后端
// 心跳续命,帧内容与 WS 完全一致。
const payload = this.buildPayload()
const waitStream = this.createStreamWaiter()
await streamActivityOutlineGen(payload, {
onMessage: (raw) => this.handleSocketMessage(raw)
}).promise
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
this.resolveStream()
await waitStream
if (this.streamFailed) {
// 后端判定工作流失败,正文区已显示失败文案,这里同步提示
this.showToast('生成失败,请稍后重试')
} else if (!this.visibleOutlineMarkdown) {
this.showToast('这次没有生成出内容,请重试')
} else {
this.showToast('活动策划大纲生成完成')
}
} catch (error) {
this.showToast((error && error.message) || '生成失败')
} finally {
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
// 上游中途报错时都不会有 __END__,只把 loading 交给 __END__
// 会把生成按钮永久卡住。
this.clearStreamResolver()
this.loading = false
}
},
copyOutline() {
if (!this.editableOutlineMarkdown) {
this.showToast('暂无可复制内容')
return
}
uni.setClipboardData({
data: this.editableOutlineMarkdown,
success: () => {
this.showToast('内容已复制')
}
})
},
fillExample(type) {
if (type === 'volunteer') {
this.form.event_theme = '青春志愿行 绿美校园环保行动'
this.form.people = '全校团员青年、学生骨干、青年志愿者'
this.form.event_time = '2026年4月18日 14:30-17:30'
this.form.event_funding = '预算 5000 元,来源为校团委专项经费'
this.form.event_type = '志愿服务 + 分组清洁 + 环保倡议'
this.form.event_location = '校园主干道、教学楼周边和青年林'
this.form.event_meme = '突出劳动教育、志愿服务精神和绿色校园建设,生成含活动背景、流程、分工、宣传和风险预案的大纲。'
return
}
if (type === 'salon') {
this.form.event_theme = '青春思享汇 新时代青年成长主题沙龙'
this.form.people = '学生干部、团支部书记、青年学生代表'
this.form.event_time = '2026年5月10日 19:00-21:00'
this.form.event_funding = '预算 3000 元,包含物料、海报和场地布置'
this.form.event_type = '主题分享 + 圆桌讨论 + 互动提问'
this.form.event_location = '大学生活动中心多功能厅'
this.form.event_meme = '希望突出思想引领、青年交流和成果展示,生成适合汇报审批的结构化活动方案。'
return
}
this.form.event_theme = '青春杯校园三人篮球赛'
this.form.people = '各学院学生代表队、体育类社团、观赛学生'
this.form.event_time = '2026年4月下旬周末全天'
this.form.event_funding = '预算 8000 元,含裁判、奖品、宣传和后勤保障'
this.form.event_type = '校园赛事 + 开幕仪式 + 分组淘汰赛'
this.form.event_location = '学校室外篮球场'
this.form.event_meme = '需要体现赛事组织、赛程安排、安全保障、医疗预案和宣传报道计划。'
}
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(51, 97, 164, 0.14), transparent 26%),
linear-gradient(180deg, #f7f9fc 0%, #f2f4f8 100%);
}
.page-scroll {
height: 100vh;
padding: 28rpx 24rpx 40rpx;
}
.hero-card {
padding: 34rpx 30rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #16315f 0%, #26508e 52%, #4b79bb 100%);
box-shadow: 0 18rpx 44rpx rgba(33, 67, 121, 0.18);
color: #eef5ff;
}
.hero-kicker {
font-size: 22rpx;
letter-spacing: 4rpx;
color: rgba(238, 245, 255, 0.72);
}
.hero-title {
margin-top: 18rpx;
font-size: 42rpx;
line-height: 1.35;
font-weight: 700;
}
.hero-desc {
margin-top: 16rpx;
font-size: 24rpx;
line-height: 1.8;
color: rgba(238, 245, 255, 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: #f4f8ff;
}
.panel-card {
margin-top: 24rpx;
padding: 26rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.96);
border: 1rpx solid rgba(51, 97, 164, 0.08);
box-shadow: 0 10rpx 28rpx rgba(61, 73, 95, 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-textarea {
width: 100%;
min-height: 220rpx;
padding: 22rpx 24rpx;
border-radius: 22rpx;
background: #f6f8fb;
border: 1rpx solid #dde4ef;
font-size: 24rpx;
line-height: 1.8;
color: #2a2e35;
box-sizing: border-box;
}
: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;
}
.submit-bar,
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 24rpx;
}
.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: #f6f7fa;
color: #50617a;
border: 1rpx solid #d8dfeb;
}
.primary-btn {
background: linear-gradient(135deg, #1f4f8f 0%, #3f70b5 100%);
color: #fff;
}
.primary-btn--disabled {
opacity: 0.6;
}
.result-btn {
background: #eef4fb;
color: #24508f;
}
.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: #eef1f5;
color: #7e8792;
font-size: 20rpx;
font-weight: 600;
}
.result-status--loading {
background: #edf5ff;
color: #1554ad;
}
.result-status--done {
background: #eef9f1;
color: #1f8f49;
}
.loading-panel {
margin-top: 22rpx;
padding: 26rpx;
border-radius: 26rpx;
background: linear-gradient(135deg, #f5f9ff 0%, #eef4fb 100%);
}
.loading-title {
font-size: 26rpx;
font-weight: 700;
color: #23446f;
}
.outline-thinking {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 20rpx;
font-size: 22rpx;
color: #35527d;
}
.thinking-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #4b79bb;
}
.result-textarea {
width: 100%;
min-height: 560rpx;
padding: 24rpx;
border-radius: 24rpx;
background: #f8fafc;
border: 1rpx solid #e1e7f0;
font-size: 24rpx;
line-height: 1.85;
color: #2b3037;
box-sizing: border-box;
}
.result-empty {
margin-top: 22rpx;
padding: 32rpx 24rpx;
border-radius: 24rpx;
background: #f8fafc;
font-size: 24rpx;
line-height: 1.75;
color: #8a94a0;
text-align: center;
}
</style>