no message

This commit is contained in:
hajimi
2026-06-12 17:34:00 +08:00
parent b9669269b5
commit bd95eed024
20 changed files with 1383 additions and 112 deletions
+377 -82
View File
@@ -45,7 +45,7 @@
<view class="quick-list">
<view v-for="item in quickQuestions" :key="item" class="quick-list__item" @tap="sendQuick(item)">
{{ item }}
<text class="quick-list__text">{{ item }}</text>
</view>
</view>
@@ -56,37 +56,47 @@
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 v-if="item.role !== 'user'" class="message-row__avatar">AI</view>
<view class="message-row__body">
<view class="message-bubble">
<view v-if="item.pending" class="message-bubble__pending">
<view class="message-bubble__pulse" />
<text class="message-bubble__loading">正在整理站内资料...</text>
<text class="message-bubble__timer">分析用时 {{ analysisDurationText }}</text>
</view>
<text v-else-if="item.role === 'user'" class="message-bubble__text">{{ item.content }}</text>
<view v-else class="message-bubble__markdown">
<u-parse :html="renderAssistantMarkdown(item.content)" />
</view>
<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>
</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 class="input-panel">
<view class="input-bar">
<input
v-model="inputText"
class="input-bar__input"
type="text"
maxlength="500"
:cursor-spacing="18"
: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>
</view>
@@ -104,6 +114,7 @@ import {
type AssistantSession,
type AssistantSource
} from '@/api/assistant'
import { renderMarkdownToHtml } from '@/utils/assistant-markdown'
const STORAGE_CLIENT_ID = 'sbnews_assistant_client_id'
@@ -193,6 +204,8 @@ const sendQuick = (text: string) => {
sendMessage()
}
const renderAssistantMarkdown = (content: string) => renderMarkdownToHtml(content)
const sendMessage = async () => {
const text = inputText.value.trim()
if (!text || sending.value) return
@@ -232,6 +245,8 @@ const sendMessage = async () => {
await loadSessions()
} catch (e: any) {
console.error('[ai_assistant] chat failed:', e)
const recovered = await recoverLatestAssistantReply(text)
if (recovered) return
messages.value.splice(messages.value.length - 1, 1, {
id: Date.now() + 2,
session_id: sessionId.value,
@@ -246,6 +261,29 @@ const sendMessage = async () => {
}
}
const recoverLatestAssistantReply = async (question: string) => {
try {
await loadSessions()
const matchedSession = sessions.value.find((item) => item.last_message === question)
if (!matchedSession?.id) return false
const res = await assistantMessages({ client_id: clientId.value, session_id: matchedSession.id })
const serverMessages = res.messages || []
const hasMatchedQuestion = serverMessages.some((item) => item.role === 'user' && item.content === question)
const hasAssistantReply = serverMessages.some((item) => item.role === 'assistant' && !item.pending)
if (!hasMatchedQuestion || !hasAssistantReply) return false
sessionId.value = res.session?.id || matchedSession.id
messages.value = serverMessages
await loadSessions()
scrollToBottom()
return true
} catch (e) {
console.error('[ai_assistant] recover latest reply failed:', e)
return false
}
}
const getRawAssistantErrorMessage = (error: any) => {
return typeof error === 'string'
? error
@@ -262,7 +300,9 @@ const getAssistantErrorMessage = (error: any) => {
return 'AI助手请求已取消,请重新发送问题。'
}
if (/request:fail|network|网络/i.test(rawMessage)) {
return 'AI助手请求失败,请检查网络后重试。'
return rawMessage
? `AI助手请求失败,请检查网络后重试。错误详情:${rawMessage}`
: 'AI助手请求失败,请检查网络后重试。'
}
return rawMessage || 'AI助手请求失败,请稍后重试。'
@@ -322,7 +362,8 @@ const sourceTypeName = (type: string) => {
post: '社区',
match: '赛事',
lottery: '彩票',
crypto: '行情'
crypto: '行情',
web_news: '外网资讯'
}
return map[type] || '来源'
}
@@ -344,21 +385,23 @@ onUnmounted(() => {
<style lang="scss" scoped>
.assistant-page {
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #eef2f6;
background: #f3f6fa;
overflow: hidden;
}
.assistant-nav {
flex-shrink: 0;
height: 96rpx;
min-height: 96rpx;
padding-left: 20rpx;
padding-right: 20rpx;
background: #fff;
background: rgba(255, 255, 255, 0.96);
display: flex;
align-items: center;
border-bottom: 1rpx solid #e8edf3;
border-bottom: 1rpx solid rgba(211, 220, 232, 0.8);
box-shadow: 0 8rpx 28rpx rgba(31, 41, 55, 0.05);
&__title {
flex: 1;
@@ -378,20 +421,27 @@ onUnmounted(() => {
&__btn {
width: 64rpx;
height: 64rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__btn:active {
background: #eef3fb;
}
}
.history-panel {
flex-shrink: 0;
max-height: 420rpx;
max-height: 46vh;
background: #fff;
border-bottom: 1rpx solid #e8edf3;
border-bottom: 1rpx solid #dbe6f5;
box-shadow: 0 18rpx 36rpx rgba(38, 55, 77, 0.08);
&__header {
height: 72rpx;
height: 80rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
@@ -407,7 +457,7 @@ onUnmounted(() => {
}
&__list {
max-height: 340rpx;
max-height: calc(46vh - 80rpx);
}
&__empty {
@@ -419,8 +469,9 @@ onUnmounted(() => {
}
.history-item {
padding: 22rpx 28rpx;
padding: 22rpx 28rpx 24rpx;
border-top: 1rpx solid #f1f5f9;
background: #fff;
&__title {
display: block;
@@ -443,22 +494,23 @@ onUnmounted(() => {
.message-list {
flex: 1;
min-height: 0;
padding: 24rpx 24rpx 32rpx;
padding: 24rpx 24rpx 36rpx;
box-sizing: border-box;
}
.welcome {
display: flex;
gap: 18rpx;
padding: 24rpx;
padding: 24rpx 24rpx 26rpx;
background: #fff;
border: 1rpx solid #dbe6f5;
border-radius: 8rpx;
border: 1rpx solid rgba(214, 224, 237, 0.92);
border-radius: 16rpx;
box-shadow: 0 12rpx 28rpx rgba(41, 60, 87, 0.06);
&__badge {
width: 56rpx;
height: 56rpx;
border-radius: 8rpx;
width: 60rpx;
height: 60rpx;
border-radius: 14rpx;
background: #185dff;
color: #fff;
font-size: 24rpx;
@@ -476,14 +528,15 @@ onUnmounted(() => {
&__title {
display: block;
font-size: 30rpx;
font-size: 31rpx;
color: #111827;
font-weight: 700;
line-height: 1.35;
}
&__desc {
display: block;
margin-top: 8rpx;
margin-top: 10rpx;
font-size: 24rpx;
color: #6b7280;
line-height: 1.5;
@@ -491,51 +544,111 @@ onUnmounted(() => {
}
.quick-list {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin: 20rpx 0 28rpx;
margin: 22rpx 0 32rpx;
&__item {
max-width: 100%;
padding: 14rpx 20rpx;
border-radius: 8rpx;
min-height: 62rpx;
padding: 12rpx 18rpx;
border-radius: 14rpx;
background: #fff;
border: 1rpx solid #dbe6f5;
border: 1rpx solid rgba(207, 220, 238, 0.95);
color: #185dff;
font-size: 24rpx;
line-height: 1.35;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-shadow: 0 8rpx 20rpx rgba(24, 93, 255, 0.04);
}
&__text {
display: block;
max-width: 100%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__item:active {
background: #edf4ff;
border-color: #b9cff7;
}
}
.message-row {
display: flex;
margin-bottom: 24rpx;
align-items: flex-start;
margin-bottom: 26rpx;
&--user {
justify-content: flex-end;
.message-row__body {
max-width: 82%;
align-items: flex-end;
}
.message-bubble {
background: #185dff;
border-color: #185dff;
color: #fff;
box-shadow: 0 12rpx 28rpx rgba(24, 93, 255, 0.22);
}
}
&--assistant {
justify-content: flex-start;
.message-row__body {
max-width: calc(100% - 78rpx);
align-items: flex-start;
}
.message-bubble {
background: #fff;
color: #111827;
border: 1rpx solid #dbe6f5;
border: 1rpx solid rgba(214, 224, 237, 0.95);
box-shadow: 0 12rpx 30rpx rgba(39, 55, 77, 0.07);
}
}
&__avatar {
width: 58rpx;
height: 58rpx;
margin-right: 16rpx;
border-radius: 14rpx;
background: #111827;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 10rpx 20rpx rgba(17, 24, 39, 0.14);
}
&__body {
min-width: 0;
display: flex;
flex-direction: column;
}
}
.message-bubble {
max-width: 82%;
padding: 20rpx 22rpx;
border-radius: 8rpx;
max-width: 100%;
padding: 20rpx 24rpx;
border-radius: 16rpx;
box-sizing: border-box;
&__pending {
@@ -544,14 +657,27 @@ onUnmounted(() => {
gap: 8rpx;
}
&__pulse {
width: 36rpx;
height: 8rpx;
border-radius: 8rpx;
background: #185dff;
opacity: 0.78;
}
&__text,
&__loading {
&__loading,
&__markdown {
font-size: 28rpx;
line-height: 1.65;
line-height: 1.72;
white-space: pre-wrap;
word-break: break-word;
}
&__markdown {
white-space: normal;
}
&__loading {
color: #6b7280;
}
@@ -559,11 +685,152 @@ onUnmounted(() => {
&__timer {
font-size: 22rpx;
line-height: 1.4;
color: #185dff;
color: #0f7a5f;
font-weight: 600;
}
}
.message-bubble__markdown :deep(*) {
box-sizing: border-box;
}
.message-bubble__markdown :deep(p),
.message-bubble__markdown :deep(ul),
.message-bubble__markdown :deep(ol),
.message-bubble__markdown :deep(blockquote),
.message-bubble__markdown :deep(pre),
.message-bubble__markdown :deep(hr) {
margin: 0 0 18rpx;
}
.message-bubble__markdown :deep(h1),
.message-bubble__markdown :deep(h2),
.message-bubble__markdown :deep(h3),
.message-bubble__markdown :deep(h4),
.message-bubble__markdown :deep(h5),
.message-bubble__markdown :deep(h6) {
margin: 0 0 16rpx;
color: inherit;
font-weight: 700;
line-height: 1.45;
}
.message-bubble__markdown :deep(h1) {
font-size: 34rpx;
}
.message-bubble__markdown :deep(h2) {
font-size: 32rpx;
}
.message-bubble__markdown :deep(h3) {
font-size: 30rpx;
}
.message-bubble__markdown :deep(ul),
.message-bubble__markdown :deep(ol) {
padding-left: 34rpx;
}
.message-bubble__markdown :deep(li) {
margin-bottom: 10rpx;
line-height: 1.72;
}
.message-bubble__markdown :deep(blockquote) {
padding: 14rpx 18rpx;
border-left: 6rpx solid #bfd5ff;
border-radius: 0 12rpx 12rpx 0;
background: #f6f9ff;
color: #4b5563;
}
.message-bubble__markdown :deep(code) {
padding: 2rpx 10rpx;
border-radius: 8rpx;
background: #eff3f8;
color: #0f172a;
font-size: 24rpx;
}
.message-bubble__markdown :deep(pre) {
overflow-x: auto;
padding: 18rpx;
border-radius: 14rpx;
background: #0f172a;
}
.message-bubble__markdown :deep(pre code) {
display: block;
padding: 0;
background: transparent;
color: #f8fafc;
white-space: pre-wrap;
word-break: break-word;
}
.message-bubble__markdown :deep(a) {
color: #185dff;
text-decoration: underline;
word-break: break-all;
}
.message-bubble__markdown :deep(strong) {
font-weight: 700;
}
.message-bubble__markdown :deep(hr) {
border: 0;
border-top: 1rpx solid #dbe4f0;
}
.message-bubble__markdown :deep(img) {
max-width: 100%;
height: auto;
border-radius: 12rpx;
}
.message-bubble__markdown :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0 0 18rpx;
overflow: hidden;
border-radius: 12rpx;
background: #fff;
border: 1rpx solid #dbe4f0;
}
.message-bubble__markdown :deep(th),
.message-bubble__markdown :deep(td) {
padding: 12rpx 14rpx;
border: 1rpx solid #dbe4f0;
text-align: left;
vertical-align: top;
line-height: 1.6;
font-size: 24rpx;
}
.message-bubble__markdown :deep(th) {
background: #f6f9ff;
font-weight: 700;
}
.message-bubble__markdown :deep(p:last-child),
.message-bubble__markdown :deep(ul:last-child),
.message-bubble__markdown :deep(ol:last-child),
.message-bubble__markdown :deep(blockquote:last-child),
.message-bubble__markdown :deep(pre:last-child),
.message-bubble__markdown :deep(table:last-child),
.message-bubble__markdown :deep(hr:last-child),
.message-bubble__markdown :deep(h1:last-child),
.message-bubble__markdown :deep(h2:last-child),
.message-bubble__markdown :deep(h3:last-child),
.message-bubble__markdown :deep(h4:last-child),
.message-bubble__markdown :deep(h5:last-child),
.message-bubble__markdown :deep(h6:last-child) {
margin-bottom: 0;
}
.source-list {
margin-top: 18rpx;
display: flex;
@@ -572,10 +839,23 @@ onUnmounted(() => {
}
.source-item {
padding: 16rpx;
border-radius: 8rpx;
position: relative;
padding: 18rpx 18rpx 18rpx 22rpx;
border-radius: 14rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 18rpx;
bottom: 18rpx;
left: 0;
width: 6rpx;
border-radius: 0 6rpx 6rpx 0;
background: #0f7a5f;
}
&__meta {
display: inline-flex;
@@ -583,9 +863,10 @@ onUnmounted(() => {
height: 34rpx;
padding: 0 12rpx;
border-radius: 6rpx;
background: #e8f0ff;
color: #185dff;
background: #e8f6f1;
color: #0f7a5f;
font-size: 20rpx;
font-weight: 600;
}
&__title {
@@ -606,39 +887,53 @@ onUnmounted(() => {
}
}
.input-bar {
.input-panel {
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;
padding-top: 16rpx;
padding-left: 22rpx;
padding-right: 22rpx;
padding-bottom: calc(18rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.98);
border-top: 1rpx solid rgba(211, 220, 232, 0.86);
box-shadow: 0 -10rpx 28rpx rgba(31, 41, 55, 0.05);
}
&__textarea {
.input-bar {
display: flex;
align-items: center;
gap: 16rpx;
height: 64rpx;
overflow: hidden;
&__input {
flex: 1;
min-height: 72rpx;
max-height: 180rpx;
padding: 18rpx 22rpx;
height: 64rpx;
min-height: 64rpx;
max-height: 64rpx;
padding: 0 20rpx;
box-sizing: border-box;
border-radius: 8rpx;
background: #f3f6fa;
border-radius: 14rpx;
background: #eef3f8;
font-size: 28rpx;
line-height: 1.45;
line-height: 64rpx;
color: #111827;
}
&__send {
width: 72rpx;
height: 72rpx;
border-radius: 8rpx;
width: 64rpx;
height: 64rpx;
border-radius: 14rpx;
background: #185dff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 10rpx 20rpx rgba(24, 93, 255, 0.22);
&--disabled {
background: #b8c5d8;
box-shadow: none;
}
}
}
@@ -429,6 +429,7 @@ import { onLoad, onUnload } from '@dcloudio/uni-app'
import { getMatchDetail, getMatchLiveText, getMatchLineup } from '@/api/match'
import { checkAiUnlock, getMatchPredictSection } from '@/api/ai'
import { useUserStore } from '@/stores/user'
import { getLocalMatchDate } from '@/utils/match-time'
const userStore = useUserStore()
const vipInfo = computed(() => userStore.userInfo?.vip_info || {})
@@ -609,11 +610,10 @@ const formatLiveTime = (ts: any) => {
const dtMatch = s.match(/(\d{2}):(\d{2}):(\d{2})\s*$/)
if (dtMatch) return `${dtMatch[1]}:${dtMatch[2]}:${dtMatch[3]}`
if (/^\d{9,}$/.test(s)) {
const bjMs = Number(s) * 1000 + 8 * 3600 * 1000
const d = new Date(bjMs)
const hh = String(d.getUTCHours()).padStart(2, '0')
const mm = String(d.getUTCMinutes()).padStart(2, '0')
const ss = String(d.getUTCSeconds()).padStart(2, '0')
const d = new Date(Number(s) * 1000)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
return s
@@ -664,7 +664,8 @@ const formatTs = (ts: number) => {
const formatMatchTime = (ts: number) => {
if (!ts) return ''
const d = new Date(ts * 1000)
const d = getLocalMatchDate(ts, match.value)
if (!d) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')