Files
tuanwei-uniapp/pages/activity-outline/index.vue
T
weicw-dshsa 024d3dd122 fix(ai): 兜底收敛公共模块,失败态不再 stringify 整包(issue 03)
「拿不到字段就 stringify 整包」的兜底此前在 3 个页面各写一份,
其中 activity-outline:332 显式把 payload.raw 当兜底内容源,是最严重的一例。

- 新增 utils/ai-result.js:getPayloadStatus / isFailedPayload /
  isDisplayableAnswer / pickDisplayable / resolveDisplayText /
  logRawPayload 统一一处;解析不出内容给可读文案,原始报文只进
  console.error 不进 UI;UUID 与无空格长 token 不算可展示内容
- manuscript parseResult:失败态短路、pickDisplayable 挡标识符、
  summary 不再回显内部字段名
- activity-outline parseOutline:删掉 payload.raw 显式兜底,失败态短路
- assistant 页删除自带副本,改为 import 公共模块
- 流式收尾:appendAssistantChunk / appendOutlineChunk 支持 reset——
  后端判定失败时丢弃已累积的半截内容;新增 streamFailed 跟踪,
  失败后不再 toast「内容生成完成」/「活动策划大纲生成完成」
- 删除死代码:两页 stringifyUnknown、大纲页 pickNonEmptyString

验证:verify-other-links.js 27/27(修复前代码跑同脚本 13 失败,判别力已证伪);
verify-frontend-fallback.js 17/17 回归通过。

Refs: .scratch/ai-assistant/issues/03-other-links-raw-payload-fallback.md
2026-09-24 04:22:41 +08:00

899 lines
24 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/activityOutlineGen`
生成结构化活动策划大纲,适合方案初稿、汇报材料和执行拆解场景。
</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 class="hint-block">
<view class="hint-title">提交说明</view>
<view class="hint-text">
必填字段会直接作为 `inputs` 透传给 AI,后端固定触发“活动策划大纲生成”指令,并返回整理后的正文结果。
</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-kicker">OUTLINE GENERATING</view>
<view class="loading-title">AI 正在整理活动策划结构</view>
<view class="loading-desc">
正在综合活动主题、受众、时间与经费信息,生成可直接二次编辑的策划大纲。
</view>
</view>
<view v-if="showThinkingIndicator" class="outline-thinking">
<view class="thinking-dot"></view>
<text>AI 正在生成正文内容,请稍候...</text>
</view>
<view v-if="resultSummary" class="result-alert">
{{ resultSummary }}
</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 {
activityOutlineGen,
buildWebSocketUrl,
getCurrentUser
} from './service'
import {
isFailedPayload,
pickDisplayable,
resolveDisplayText
} from '../../utils/ai-result'
const STREAM_END_TEXT = '__END__'
const SOCKET_CONNECTED_TEXT = '连接成功'
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: '',
resultSummary: '',
activeConversationId: '',
activeMessageId: '',
socketTask: null,
socketConnectPromise: null,
streamStarted: false,
streamFailed: false,
streamResolver: null,
currentUser: {
userId: 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()
this.disconnectWebSocket()
},
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' })
},
buildSummary() {
const parts = []
if (this.activeConversationId) {
parts.push(`conversationId: ${this.activeConversationId}`)
}
if (this.activeMessageId) {
parts.push(`messageId: ${this.activeMessageId}`)
}
return parts.join(' | ')
},
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
const socketTask = this.socketTask
this.socketTask = null
if (socketTask && typeof socketTask.close === 'function') {
try {
socketTask.close({})
} catch (error) {
console.warn('socket close failed', error)
}
}
},
appendOutlineChunk(chunk, conversationId, messageId, reset) {
if (!chunk) {
return
}
if (conversationId && conversationId !== '0') {
this.activeConversationId = conversationId
}
if (messageId && messageId !== '0') {
this.activeMessageId = messageId
}
if (!this.streamStarted || reset) {
this.streamStarted = true
// reset 表示后端判定失败、本条覆盖之前的流式残片
this.outlineMarkdown = ''
}
if (reset) {
// 记住失败态,供收尾时给出正确提示而不是「生成完成」
this.streamFailed = true
}
this.outlineMarkdown += chunk
this.syncEditableOutline()
this.resultSummary = this.buildSummary() || '正在通过 WebSocket 接收流式内容'
},
finishStream(conversationId, messageId) {
if (conversationId && conversationId !== '0') {
this.activeConversationId = conversationId
}
if (messageId && messageId !== '0') {
this.activeMessageId = messageId
}
this.resultSummary = this.buildSummary() || '已通过 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, data.messageId)
return
}
// reset 需在 loading 之前读取:失败消息不带 loading,
// 但语义上必须先清掉流式残片(见 issue 03)。
const reset = !!(data && (data.reset === 'true' || data.reset === true))
if (data && (data.loading === 'true' || data.loading === true)) {
this.resultSummary = 'AI 正在组织活动策划大纲结构...'
return
}
const chunk = this.pickRawString(data && data.notice, data && data.answer)
if (typeof chunk === 'string') {
this.appendOutlineChunk(
chunk,
data && data.conversationId,
data && data.messageId,
reset
)
}
} catch (error) {
this.appendOutlineChunk(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.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.onMessage((event) => {
const raw = String((event && event.data) || '')
if (raw === SOCKET_CONNECTED_TEXT) {
if (!settled) {
settled = true
cleanupPending()
resolve()
}
return
}
this.handleSocketMessage(raw)
})
socketTask.onClose(() => {
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(() => {
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
},
clearResult() {
this.result = null
this.outlineMarkdown = ''
this.editableOutlineMarkdown = ''
this.resultSummary = ''
this.activeConversationId = ''
this.activeMessageId = ''
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()
this.resultSummary = '正在建立 WebSocket 连接...'
try {
await this.connectWebSocket()
this.resultSummary = '连接成功,已提交生成任务,等待流式返回...'
const waitStream = this.createStreamWaiter()
const data = await activityOutlineGen(this.buildPayload())
this.result = data || null
if (!this.streamStarted && data && this.parseOutline(data)) {
this.outlineMarkdown = this.parseOutline(data)
this.syncEditableOutline()
this.resultSummary = this.buildSummary() || '接口直接返回生成结果'
this.loading = false
this.resolveStream()
this.disconnectWebSocket()
} else {
await waitStream
}
if (this.streamFailed) {
// 后端判定工作流失败,正文区已显示失败文案,这里同步提示
this.showToast('生成失败,请稍后重试')
return
}
if (!this.visibleOutlineMarkdown) {
this.showToast('接口已返回,但未解析到正文内容')
return
}
this.showToast('活动策划大纲生成完成')
} catch (error) {
this.disconnectWebSocket()
this.clearStreamResolver()
this.loading = false
this.showToast((error && error.message) || '生成失败')
}
},
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;
}
.hint-block,
.result-alert {
margin-top: 22rpx;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: #f5f8fd;
border: 1rpx solid rgba(51, 97, 164, 0.08);
font-size: 22rpx;
line-height: 1.75;
color: #6d7d93;
}
.hint-title {
font-size: 24rpx;
font-weight: 700;
color: #35527d;
}
.hint-text {
margin-top: 10rpx;
}
.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-kicker {
font-size: 20rpx;
letter-spacing: 3rpx;
color: #6682aa;
}
.loading-title {
margin-top: 10rpx;
font-size: 30rpx;
font-weight: 700;
color: #23446f;
}
.loading-desc {
margin-top: 12rpx;
font-size: 22rpx;
line-height: 1.75;
color: #6d7d93;
}
.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>