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

882 lines
22 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'
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,
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()
}
},
pickNonEmptyString() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (typeof value === 'string' && value.length > 0) {
return value
}
}
return ''
},
pickRawString() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (typeof value === 'string') {
return value
}
}
return undefined
},
stringifyUnknown(value) {
try {
return JSON.stringify(value, null, 2)
} catch (error) {
return String(value || '')
}
},
parseOutline(payload) {
if (!payload) {
return ''
}
const answer = this.pickNonEmptyString(
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)
},
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) {
if (!chunk) {
return
}
if (conversationId && conversationId !== '0') {
this.activeConversationId = conversationId
}
if (messageId && messageId !== '0') {
this.activeMessageId = messageId
}
if (!this.streamStarted) {
this.streamStarted = true
this.outlineMarkdown = ''
}
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
}
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)
}
} 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 = ''
},
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.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>