no message

This commit is contained in:
hajimi
2026-06-11 12:15:29 +08:00
parent 10ebe39c30
commit 96efa1d905
5859 changed files with 815501 additions and 5 deletions
@@ -0,0 +1,622 @@
<template>
<view class="assistant-page">
<view class="assistant-nav" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="assistant-nav__btn" @tap="goBack">
<u-icon name="arrow-left" size="34" color="#1f2937" />
</view>
<text class="assistant-nav__title">世博头条 AI 助手</text>
<view class="assistant-nav__actions">
<view class="assistant-nav__btn" @tap="toggleHistory">
<u-icon name="clock" size="32" color="#1f2937" />
</view>
<view class="assistant-nav__btn" @tap="newSession">
<u-icon name="plus" size="32" color="#1f2937" />
</view>
</view>
</view>
<view v-if="showHistory" class="history-panel">
<view class="history-panel__header">
<text>历史会话</text>
<text class="history-panel__clear" @tap="clearAllSessions">清空</text>
</view>
<scroll-view scroll-y class="history-panel__list">
<view v-if="!sessions.length" class="history-panel__empty">暂无历史会话</view>
<view v-for="item in sessions" :key="item.id" class="history-item" @tap="openSession(item)">
<text class="history-item__title">{{ item.title || '新的对话' }}</text>
<text class="history-item__desc">{{ item.last_message || item.last_answer }}</text>
</view>
</scroll-view>
</view>
<scroll-view
scroll-y
class="message-list"
:scroll-into-view="scrollIntoView"
scroll-with-animation
>
<view class="welcome">
<view class="welcome__badge">AI</view>
<view class="welcome__body">
<text class="welcome__title">你好我可以帮你查站内内容</text>
<text class="welcome__desc">可以问资讯赛事社区帖子彩票分析和加密行情</text>
</view>
</view>
<view class="quick-list">
<view v-for="item in quickQuestions" :key="item" class="quick-list__item" @tap="sendQuick(item)">
{{ item }}
</view>
</view>
<view
v-for="(item, index) in messages"
:id="'msg-' + index"
:key="item.id || index"
class="message-row"
:class="item.role === 'user' ? 'message-row--user' : 'message-row--assistant'"
>
<view class="message-bubble">
<view v-if="item.pending" class="message-bubble__pending">
<text class="message-bubble__loading">正在整理站内资料...</text>
<text class="message-bubble__timer">分析用时 {{ analysisDurationText }}</text>
</view>
<text v-else class="message-bubble__text">{{ item.content }}</text>
<view v-if="item.sources && item.sources.length" class="source-list">
<view v-for="source in item.sources" :key="source.type + source.source_id + source.title"
class="source-item" @tap="openSource(source)">
<view class="source-item__meta">{{ sourceTypeName(source.type) }}</view>
<text class="source-item__title">{{ source.title }}</text>
<text class="source-item__summary">{{ source.summary }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="input-bar">
<textarea
v-model="inputText"
class="input-bar__textarea"
auto-height
maxlength="500"
:disabled="sending"
placeholder="问问世博头条里的内容"
confirm-type="send"
@confirm="sendMessage"
/>
<view class="input-bar__send" :class="{ 'input-bar__send--disabled': !canSend }" @tap="sendMessage">
<u-icon name="arrow-upward" size="32" color="#fff" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
assistantChat,
assistantClear,
assistantMessages,
assistantSessions,
type AssistantMessage,
type AssistantSession,
type AssistantSource
} from '@/api/assistant'
const STORAGE_CLIENT_ID = 'sbnews_assistant_client_id'
const statusBarHeight = ref(0)
const clientId = ref('')
const sessionId = ref(0)
const inputText = ref('')
const sending = ref(false)
const showHistory = ref(false)
const scrollIntoView = ref('')
const messages = ref<AssistantMessage[]>([])
const sessions = ref<AssistantSession[]>([])
const analysisElapsedSeconds = ref(0)
let analysisTimer: ReturnType<typeof setInterval> | null = null
const quickQuestions = [
'今天有什么值得关注的赛事?',
'帮我总结最新世界杯资讯',
'旧澳六合最近有什么分析线索?',
'BTC 今天行情怎么样?'
]
const canSend = computed(() => inputText.value.trim().length > 0 && !sending.value)
const analysisDurationText = computed(() => {
const total = Math.max(0, Number(analysisElapsedSeconds.value || 0))
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}m${seconds}s`
})
onLoad(async () => {
const sysInfo = uni.getSystemInfoSync()
// #ifdef APP-PLUS || MP
statusBarHeight.value = sysInfo.statusBarHeight || 44
// #endif
// #ifdef H5
statusBarHeight.value = 0
// #endif
clientId.value = getClientId()
await loadSessions()
})
const getClientId = () => {
const cached = uni.getStorageSync(STORAGE_CLIENT_ID)
if (cached) return String(cached)
const id = `c_${Date.now()}_${Math.random().toString(16).slice(2)}`
uni.setStorageSync(STORAGE_CLIENT_ID, id)
return id
}
const loadSessions = async () => {
try {
sessions.value = await assistantSessions({ client_id: clientId.value })
} catch (e) {
sessions.value = []
}
}
const openSession = async (session: AssistantSession) => {
try {
const res = await assistantMessages({ client_id: clientId.value, session_id: session.id })
sessionId.value = res.session?.id || session.id
messages.value = res.messages || []
showHistory.value = false
scrollToBottom()
} catch (e) {
uni.showToast({ title: '会话加载失败', icon: 'none' })
}
}
const newSession = () => {
sessionId.value = 0
messages.value = []
showHistory.value = false
inputText.value = ''
}
const toggleHistory = async () => {
showHistory.value = !showHistory.value
if (showHistory.value) {
await loadSessions()
}
}
const sendQuick = (text: string) => {
inputText.value = text
sendMessage()
}
const sendMessage = async () => {
const text = inputText.value.trim()
if (!text || sending.value) return
const userMessage: AssistantMessage = {
id: Date.now(),
session_id: sessionId.value,
role: 'user',
content: text
}
const pendingMessage: AssistantMessage = {
id: Date.now() + 1,
session_id: sessionId.value,
role: 'assistant',
content: '',
pending: true
}
messages.value.push(userMessage, pendingMessage)
inputText.value = ''
sending.value = true
startAnalysisTimer()
scrollToBottom()
try {
const res = await assistantChat({
message: text,
client_id: clientId.value,
session_id: sessionId.value || undefined
})
sessionId.value = res.session?.id || sessionId.value
const answer = res.message || {
role: 'assistant',
content: '我暂时没有生成成功,请稍后再试。',
sources: res.sources || []
}
messages.value.splice(messages.value.length - 1, 1, answer)
await loadSessions()
} catch (e: any) {
messages.value.splice(messages.value.length - 1, 1, {
id: Date.now() + 2,
session_id: sessionId.value,
role: 'assistant',
content: typeof e === 'string' ? e : 'AI助手请求失败,请稍后重试。',
sources: []
})
} finally {
stopAnalysisTimer()
sending.value = false
scrollToBottom()
}
}
const startAnalysisTimer = () => {
stopAnalysisTimer()
analysisElapsedSeconds.value = 0
const startAt = Date.now()
analysisTimer = setInterval(() => {
analysisElapsedSeconds.value = Math.floor((Date.now() - startAt) / 1000)
}, 1000)
}
const stopAnalysisTimer = () => {
if (analysisTimer) {
clearInterval(analysisTimer)
analysisTimer = null
}
}
const clearAllSessions = () => {
uni.showModal({
title: '清空历史',
content: '确定清空所有助手会话?',
success: async (res) => {
if (!res.confirm) return
try {
await assistantClear({ client_id: clientId.value })
sessions.value = []
newSession()
} catch (e) {
uni.showToast({ title: '清空失败', icon: 'none' })
}
}
})
}
const scrollToBottom = () => {
nextTick(() => {
scrollIntoView.value = 'msg-' + Math.max(messages.value.length - 1, 0)
})
}
const openSource = (source: AssistantSource) => {
if (!source.path) return
if (source.path === '/pages/crypto/crypto' || source.path === '/pages/lottery_analysis/lottery_analysis') {
uni.switchTab({ url: source.path })
return
}
uni.navigateTo({ url: source.path })
}
const sourceTypeName = (type: string) => {
const map: Record<string, string> = {
article: '资讯',
post: '社区',
match: '赛事',
lottery: '彩票',
crypto: '行情'
}
return map[type] || '来源'
}
const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
uni.switchTab({ url: '/pages/index/index' })
}
onUnmounted(() => {
stopAnalysisTimer()
})
</script>
<style lang="scss" scoped>
.assistant-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #eef2f6;
overflow: hidden;
}
.assistant-nav {
flex-shrink: 0;
height: 96rpx;
padding-left: 20rpx;
padding-right: 20rpx;
background: #fff;
display: flex;
align-items: center;
border-bottom: 1rpx solid #e8edf3;
&__title {
flex: 1;
min-width: 0;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
&__actions {
display: flex;
align-items: center;
gap: 8rpx;
}
&__btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
.history-panel {
flex-shrink: 0;
max-height: 420rpx;
background: #fff;
border-bottom: 1rpx solid #e8edf3;
&__header {
height: 72rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
color: #111827;
font-weight: 600;
}
&__clear {
color: #185dff;
font-weight: 500;
}
&__list {
max-height: 340rpx;
}
&__empty {
padding: 48rpx 0;
text-align: center;
font-size: 24rpx;
color: #9ca3af;
}
}
.history-item {
padding: 22rpx 28rpx;
border-top: 1rpx solid #f1f5f9;
&__title {
display: block;
font-size: 28rpx;
color: #111827;
font-weight: 600;
}
&__desc {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.message-list {
flex: 1;
min-height: 0;
padding: 24rpx 24rpx 32rpx;
box-sizing: border-box;
}
.welcome {
display: flex;
gap: 18rpx;
padding: 24rpx;
background: #fff;
border: 1rpx solid #dbe6f5;
border-radius: 8rpx;
&__badge {
width: 56rpx;
height: 56rpx;
border-radius: 8rpx;
background: #185dff;
color: #fff;
font-size: 24rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__body {
flex: 1;
min-width: 0;
}
&__title {
display: block;
font-size: 30rpx;
color: #111827;
font-weight: 700;
}
&__desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #6b7280;
line-height: 1.5;
}
}
.quick-list {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin: 20rpx 0 28rpx;
&__item {
max-width: 100%;
padding: 14rpx 20rpx;
border-radius: 8rpx;
background: #fff;
border: 1rpx solid #dbe6f5;
color: #185dff;
font-size: 24rpx;
box-sizing: border-box;
}
}
.message-row {
display: flex;
margin-bottom: 24rpx;
&--user {
justify-content: flex-end;
.message-bubble {
background: #185dff;
color: #fff;
}
}
&--assistant {
justify-content: flex-start;
.message-bubble {
background: #fff;
color: #111827;
border: 1rpx solid #dbe6f5;
}
}
}
.message-bubble {
max-width: 82%;
padding: 20rpx 22rpx;
border-radius: 8rpx;
box-sizing: border-box;
&__pending {
display: flex;
flex-direction: column;
gap: 8rpx;
}
&__text,
&__loading {
font-size: 28rpx;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
&__loading {
color: #6b7280;
}
&__timer {
font-size: 22rpx;
line-height: 1.4;
color: #185dff;
font-weight: 600;
}
}
.source-list {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.source-item {
padding: 16rpx;
border-radius: 8rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
&__meta {
display: inline-flex;
align-items: center;
height: 34rpx;
padding: 0 12rpx;
border-radius: 6rpx;
background: #e8f0ff;
color: #185dff;
font-size: 20rpx;
}
&__title {
display: block;
margin-top: 10rpx;
font-size: 25rpx;
color: #111827;
font-weight: 600;
line-height: 1.4;
}
&__summary {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #6b7280;
line-height: 1.45;
}
}
.input-bar {
flex-shrink: 0;
padding: 18rpx 22rpx calc(18rpx + env(safe-area-inset-bottom));
background: #fff;
border-top: 1rpx solid #e8edf3;
display: flex;
align-items: flex-end;
gap: 16rpx;
&__textarea {
flex: 1;
min-height: 72rpx;
max-height: 180rpx;
padding: 18rpx 22rpx;
box-sizing: border-box;
border-radius: 8rpx;
background: #f3f6fa;
font-size: 28rpx;
line-height: 1.45;
}
&__send {
width: 72rpx;
height: 72rpx;
border-radius: 8rpx;
background: #185dff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--disabled {
background: #b8c5d8;
}
}
}
</style>