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
+19 -13
View File
@@ -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')
+4 -3
View File
@@ -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()}`
}
+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')
+208
View File
@@ -0,0 +1,208 @@
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
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('')
}
+29
View File
@@ -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)
}