no message
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user