no message
This commit is contained in:
@@ -6,9 +6,10 @@
|
||||
<view v-if="descText" class="feed-card__desc">{{ descText }}</view>
|
||||
<view class="feed-card__footer">
|
||||
<text class="feed-card__author">{{ authorText }}</text>
|
||||
<text class="feed-card__meta feed-card__meta--center">{{ item.comment_count || item.click || 0
|
||||
}}评论</text>
|
||||
<text class="feed-card__meta feed-card__meta--right">{{ formatTime(item.create_time) }}</text>
|
||||
<view class="feed-card__footer-right">
|
||||
<text class="feed-card__meta">{{ item.comment_count || item.click || 0 }}阅读</text>
|
||||
<text class="feed-card__meta">{{ formatTime(item.create_time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<UImage v-if="item.image" :src="item.image" width="220" height="140" border-radius="8" mode="aspectFill"
|
||||
@@ -103,11 +104,13 @@ const formatTime = (t: string) => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 12rpx;
|
||||
gap: 12rpx;
|
||||
gap: 16rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__author {
|
||||
@@ -120,19 +123,22 @@ const formatTime = (t: string) => {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12rpx;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
white-space: nowrap;
|
||||
|
||||
&--center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&--right {
|
||||
text-align: right;
|
||||
}
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__img {
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { getLocalMatchDate } from '@/utils/match-time'
|
||||
|
||||
const props = defineProps<{
|
||||
match: any
|
||||
@@ -160,7 +161,8 @@ const statusLabel = computed(() => {
|
||||
|
||||
const scheduleTime = computed(() => {
|
||||
if (!props.match.match_time) return ''
|
||||
const d = new Date(props.match.match_time * 1000)
|
||||
const d = getLocalMatchDate(props.match.match_time, props.match)
|
||||
if (!d) return ''
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const date = String(d.getDate()).padStart(2, '0')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name" : "世博头条",
|
||||
"appid" : "__UNI__90671D8",
|
||||
"description" : "世博头条AI分析平台",
|
||||
"versionName" : "1.0.8",
|
||||
"versionCode" : 108,
|
||||
"versionName" : "1.0.9",
|
||||
"versionCode" : 109,
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
@@ -23,7 +23,8 @@
|
||||
"Barcode" : {},
|
||||
"UIWebview" : {},
|
||||
"Webview-x5" : {},
|
||||
"VideoPlayer" : {}
|
||||
"VideoPlayer" : {},
|
||||
"Record" : {}
|
||||
},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
|
||||
@@ -202,6 +202,7 @@ import PlayerRankingPanel from '../pages/worldcup/components/ranking/PlayerRanki
|
||||
import TranslatedArticleCard from '@/components/translated-article-card/translated-article-card.vue'
|
||||
import { getArticleCate, getArticleList } from '@/api/news'
|
||||
import { getWorldCupRankings, getWorldCupSchedule, getWorldCupStandings } from '@/api/match'
|
||||
import { getLocalMatchDate } from '@/utils/match-time'
|
||||
|
||||
type MainTab = 'schedule' | 'rank' | 'bracket' | 'news'
|
||||
type RankingMode = 'standings' | 'goals' | 'assists'
|
||||
@@ -227,6 +228,8 @@ const tabs: { key: MainTab; label: string }[] = [
|
||||
{ key: 'news', label: '新闻' }
|
||||
]
|
||||
|
||||
const WORLD_CUP_MATCH_CONTEXT = { competition_id: 61, league_name: '世界杯' }
|
||||
|
||||
const bracketTabs: { key: BracketTabKey; label: string }[] = [
|
||||
{ key: '1/16决赛', label: '32强' },
|
||||
{ key: '1/8决赛', label: '十六强' },
|
||||
@@ -531,9 +534,14 @@ const goMatchDetail = (match: any) => {
|
||||
uni.navigateTo({ url: `/pages/match_detail/match_detail?id=${id}` })
|
||||
}
|
||||
|
||||
const getWorldCupDisplayDate = (timestamp: number) => {
|
||||
return getLocalMatchDate(timestamp, WORLD_CUP_MATCH_CONTEXT)
|
||||
}
|
||||
|
||||
const formatDateKey = (timestamp: number) => {
|
||||
if (!timestamp) return '0'
|
||||
const d = new Date(timestamp * 1000)
|
||||
const d = getWorldCupDisplayDate(timestamp)
|
||||
if (!d) return '0'
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
@@ -542,7 +550,8 @@ const formatDateKey = (timestamp: number) => {
|
||||
|
||||
const formatDateLabel = (timestamp: number) => {
|
||||
if (!timestamp) return '未分组'
|
||||
const d = new Date(timestamp * 1000)
|
||||
const d = getWorldCupDisplayDate(timestamp)
|
||||
if (!d) return '未分组'
|
||||
const weeks = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${weeks[d.getDay()]}`
|
||||
}
|
||||
@@ -555,13 +564,15 @@ const normalizeGroupName = (value: string) => {
|
||||
|
||||
const formatMatchTime = (timestamp: number) => {
|
||||
if (!timestamp) return '--'
|
||||
const date = new Date(timestamp * 1000)
|
||||
const date = getWorldCupDisplayDate(timestamp)
|
||||
if (!date) return '--'
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatBracketDate = (timestamp: number) => {
|
||||
if (!timestamp) return '--'
|
||||
const date = new Date(timestamp * 1000)
|
||||
const date = getWorldCupDisplayDate(timestamp)
|
||||
if (!date) return '--'
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const escapeAttribute = (value: string) => escapeHtml(value)
|
||||
|
||||
const parseLink = (value: string) => {
|
||||
return value.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, text: string, url: string) => {
|
||||
const safeUrl = escapeAttribute(url)
|
||||
return `<a href="${safeUrl}">${text}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
const parseInlineMarkdown = (value: string) => {
|
||||
let html = escapeHtml(value)
|
||||
html = parseLink(html)
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/(^|[^\*])\*([^\*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
|
||||
return html
|
||||
}
|
||||
|
||||
export const renderMarkdownToHtml = (value: string) => {
|
||||
const source = String(value || '').replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) return ''
|
||||
|
||||
const lines = source.split('\n')
|
||||
const htmlParts: string[] = []
|
||||
const paragraphLines: string[] = []
|
||||
const codeLines: string[] = []
|
||||
const quoteLines: string[] = []
|
||||
const tableLines: string[] = []
|
||||
let listType: 'ul' | 'ol' | '' = ''
|
||||
let inCodeBlock = false
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraphLines.length) return
|
||||
htmlParts.push(`<p>${paragraphLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</p>`)
|
||||
paragraphLines.length = 0
|
||||
}
|
||||
|
||||
const closeList = () => {
|
||||
if (!listType) return
|
||||
htmlParts.push(`</${listType}>`)
|
||||
listType = ''
|
||||
}
|
||||
|
||||
const flushQuote = () => {
|
||||
if (!quoteLines.length) return
|
||||
htmlParts.push(
|
||||
`<blockquote>${quoteLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</blockquote>`
|
||||
)
|
||||
quoteLines.length = 0
|
||||
}
|
||||
|
||||
const flushCodeBlock = () => {
|
||||
if (!codeLines.length) return
|
||||
htmlParts.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
codeLines.length = 0
|
||||
}
|
||||
|
||||
const parseTableCells = (line: string) => {
|
||||
const cells = line
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
return cells.filter((_cell, index) => index < cells.length)
|
||||
}
|
||||
|
||||
const isTableRow = (line: string) => /^\|.*\|$/.test(line) && parseTableCells(line).length >= 2
|
||||
|
||||
const isTableSeparatorLine = (line: string) => {
|
||||
if (!isTableRow(line)) return false
|
||||
return parseTableCells(line).every((cell) => /^:?-{3,}:?$/.test(cell))
|
||||
}
|
||||
|
||||
const flushTable = () => {
|
||||
if (!tableLines.length) return
|
||||
|
||||
if (tableLines.length >= 2 && isTableSeparatorLine(tableLines[1])) {
|
||||
const headers = parseTableCells(tableLines[0])
|
||||
const bodyRows = tableLines.slice(2).map(parseTableCells).filter((cells) => cells.length > 0)
|
||||
const thead = `<thead><tr>${headers.map((cell) => `<th>${parseInlineMarkdown(cell)}</th>`).join('')}</tr></thead>`
|
||||
const tbody = bodyRows.length
|
||||
? `<tbody>${bodyRows
|
||||
.map((cells) => `<tr>${cells.map((cell) => `<td>${parseInlineMarkdown(cell)}</td>`).join('')}</tr>`)
|
||||
.join('')}</tbody>`
|
||||
: ''
|
||||
htmlParts.push(`<table>${thead}${tbody}</table>`)
|
||||
} else {
|
||||
htmlParts.push(`<p>${tableLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</p>`)
|
||||
}
|
||||
|
||||
tableLines.length = 0
|
||||
}
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
|
||||
if (/^```/.test(line)) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
flushQuote()
|
||||
flushTable()
|
||||
if (inCodeBlock) {
|
||||
flushCodeBlock()
|
||||
}
|
||||
inCodeBlock = !inCodeBlock
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeLines.push(rawLine)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
flushQuote()
|
||||
flushTable()
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/)
|
||||
if (headingMatch) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
flushQuote()
|
||||
flushTable()
|
||||
const level = Math.min(6, headingMatch[1].length)
|
||||
htmlParts.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const quoteMatch = line.match(/^>\s?(.*)$/)
|
||||
if (quoteMatch) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
flushTable()
|
||||
quoteLines.push(quoteMatch[1])
|
||||
continue
|
||||
}
|
||||
flushQuote()
|
||||
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
flushTable()
|
||||
htmlParts.push('<hr />')
|
||||
continue
|
||||
}
|
||||
|
||||
const unorderedMatch = line.match(/^[-*+]\s+(.*)$/)
|
||||
if (unorderedMatch) {
|
||||
flushParagraph()
|
||||
flushTable()
|
||||
if (listType !== 'ul') {
|
||||
closeList()
|
||||
listType = 'ul'
|
||||
htmlParts.push('<ul>')
|
||||
}
|
||||
htmlParts.push(`<li>${parseInlineMarkdown(unorderedMatch[1])}</li>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const orderedMatch = line.match(/^\d+\.\s+(.*)$/)
|
||||
if (orderedMatch) {
|
||||
flushParagraph()
|
||||
flushTable()
|
||||
if (listType !== 'ol') {
|
||||
closeList()
|
||||
listType = 'ol'
|
||||
htmlParts.push('<ol>')
|
||||
}
|
||||
htmlParts.push(`<li>${parseInlineMarkdown(orderedMatch[1])}</li>`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isTableRow(line)) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
flushQuote()
|
||||
tableLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
flushTable()
|
||||
closeList()
|
||||
paragraphLines.push(line)
|
||||
}
|
||||
|
||||
flushParagraph()
|
||||
closeList()
|
||||
flushQuote()
|
||||
flushTable()
|
||||
if (inCodeBlock) {
|
||||
flushCodeBlock()
|
||||
}
|
||||
|
||||
return htmlParts.join('')
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
type MatchLike = {
|
||||
competition_id?: number | string
|
||||
league_name?: string
|
||||
}
|
||||
|
||||
const WORLD_CUP_COMPETITION_ID = 61
|
||||
const WORLD_CUP_SOURCE_UTC_OFFSET_SECONDS = 8 * 60 * 60
|
||||
|
||||
export const isWorldCupMatch = (match?: MatchLike | null): boolean => {
|
||||
if (!match) return false
|
||||
return Number(match.competition_id || 0) === WORLD_CUP_COMPETITION_ID
|
||||
|| String(match.league_name || '') === '世界杯'
|
||||
}
|
||||
|
||||
export const resolveMatchTimestamp = (timestamp: number | string | null | undefined, match?: MatchLike | null): number => {
|
||||
const seconds = Number(timestamp || 0)
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return 0
|
||||
|
||||
// World Cup source fields are UTC clock values saved as server-local Unix seconds.
|
||||
if (isWorldCupMatch(match)) return seconds + WORLD_CUP_SOURCE_UTC_OFFSET_SECONDS
|
||||
|
||||
return seconds
|
||||
}
|
||||
|
||||
export const getLocalMatchDate = (timestamp: number | string | null | undefined, match?: MatchLike | null): Date | null => {
|
||||
const seconds = resolveMatchTimestamp(timestamp, match)
|
||||
if (!seconds) return null
|
||||
return new Date(seconds * 1000)
|
||||
}
|
||||
Reference in New Issue
Block a user