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
+1
View File
@@ -27,3 +27,4 @@ bin-release/
/.code-graph/ /.code-graph/
**/.hbuilderx/ **/.hbuilderx/
**/unpackage/ **/unpackage/
qa/
+11 -2
View File
@@ -176,13 +176,20 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From
### 自动增量发布 ### 自动增量发布
如果只是发布最近一次提交的后端改动,可以使用 `--auto-incremental`,脚本会自动对比 `HEAD~1``HEAD` 如果只是发布最近一次提交的后端改动,可以使用 `-AutoIncremental`,脚本会默认对比 `HEAD~1``HEAD`
```powershell ```powershell
cd D:\www\sport-era cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net
``` ```
如果需要一次发布最近 N 个提交的后端改动,可以追加 `-AutoIncrementalCommits <n>`,例如发布最近 3 个提交会对比 `HEAD~3``HEAD`
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net
```
先演练不上传: 先演练不上传:
```powershell ```powershell
@@ -190,7 +197,9 @@ cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun
``` ```
注意:自动增量只包含最近两个提交之间已经提交到 Git 的差异,不会包含工作区未提交文件。如有多次提交需要一起发布,请继续使用 `--from <old_commit> --to <new_commit>` 脚本会在打包和上传前先检查 `server` 工作区;如果 `server` 内存在未提交、删除或未跟踪文件,会先自动提交这些变更,再继续计算增量范围。可以通过 `-AutoCommitMessage "提交说明"` 自定义这次自动提交的说明。若存在合并冲突,需要先手动解决
注意:自动增量只包含指定提交范围内已经提交到 Git 的差异,不会包含工作区未提交文件。需要指定任意起止提交时,继续使用 `-From <old_commit> -To <new_commit>`
如果当前根目录 Git 只把 `server` 记录为 gitlink/submodule,且本机没有 `server/.git` 子仓库元数据,脚本无法拿到 `server` 内部的文件级差异;此时会自动降级为打包当前工作区默认的 5 个 server 目录,保证可以继续发布。 如果当前根目录 Git 只把 `server` 记录为 gitlink/submodule,且本机没有 `server/.git` 子仓库元数据,脚本无法拿到 `server` 内部的文件级差异;此时会自动降级为打包当前工作区默认的 5 个 server 目录,保证可以继续发布。
+9
View File
@@ -15,5 +15,14 @@ DQD_REDIS_PORT=6377
DQD_REDIS_PASSWORD=change-me DQD_REDIS_PASSWORD=change-me
DQD_REDIS_DB=4 DQD_REDIS_DB=4
# PHP kb:enqueue-recent should point to the same Redis database as kb-worker.
AI_KB_REDIS_DB=4
DQD_LOG_LEVEL=INFO DQD_LOG_LEVEL=INFO
DQD_LOG_FILE=/app/logs/crawler.log DQD_LOG_FILE=/app/logs/crawler.log
# AI KB worker embedding config. Keep real key in docker/.env.docker only.
EMBEDDING_API_KEY=change-me
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode
EMBEDDING_MODEL=text-embedding-v4
EMBEDDING_DIM=1024
+19
View File
@@ -18,3 +18,22 @@ services:
- ${CRAWLER_DIR:-../server/public/dongqiudi-crawler}/data:/app/data - ${CRAWLER_DIR:-../server/public/dongqiudi-crawler}/data:/app/data
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
kb-worker:
container_name: sport-era-kb-worker
image: sport-era-crawler:latest
build:
context: ${CRAWLER_DIR:-../server/public/dongqiudi-crawler}
dockerfile: Dockerfile
restart: always
env_file:
- ./.env.docker
environment:
TZ: Asia/Shanghai
DQD_CONFIG_PATH: /app/config/settings.yaml
command: ["python", "scripts/kb_worker.py", "--batch=50", "--block-ms=5000", "--max-retries=3"]
volumes:
- ${CRAWLER_DIR:-../server/public/dongqiudi-crawler}/logs:/app/logs
- ${CRAWLER_DIR:-../server/public/dongqiudi-crawler}/data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
@@ -0,0 +1,76 @@
SET @schema_name := DATABASE();
SET @sql := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `la_article` ADD INDEX `idx_kb_recent_article` (`update_time`, `id`, `is_show`, `delete_time`)',
'SELECT 1'
)
FROM `information_schema`.`statistics`
WHERE `table_schema` = @schema_name
AND `table_name` = 'la_article'
AND `index_name` = 'idx_kb_recent_article'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `la_match` ADD INDEX `idx_kb_recent_match` (`update_time`, `id`, `is_show`, `delete_time`)',
'SELECT 1'
)
FROM `information_schema`.`statistics`
WHERE `table_schema` = @schema_name
AND `table_name` = 'la_match'
AND `index_name` = 'idx_kb_recent_match'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `la_community_post` ADD INDEX `idx_kb_recent_post` (`update_time`, `id`, `status`, `delete_time`)',
'SELECT 1'
)
FROM `information_schema`.`statistics`
WHERE `table_schema` = @schema_name
AND `table_name` = 'la_community_post'
AND `index_name` = 'idx_kb_recent_post'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `la_lottery_draw_result` ADD INDEX `idx_kb_recent_draw` (`update_time`, `id`)',
'SELECT 1'
)
FROM `information_schema`.`statistics`
WHERE `table_schema` = @schema_name
AND `table_name` = 'la_lottery_draw_result'
AND `index_name` = 'idx_kb_recent_draw'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `la_lottery_ai_analysis` ADD INDEX `idx_kb_recent_ai_history` (`update_time`, `id`)',
'SELECT 1'
)
FROM `information_schema`.`statistics`
WHERE `table_schema` = @schema_name
AND `table_name` = 'la_lottery_ai_analysis'
AND `index_name` = 'idx_kb_recent_ai_history'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `la_ai_comment_task` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`target_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '目标类型 article/post',
`target_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '目标内容ID',
`virtual_user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '虚拟账号ID',
`persona_key` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '人设标识',
`selection_sources` TEXT NULL COMMENT '来源集合',
`comment_content` TEXT NULL COMMENT '评论正文',
`status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '状态 0-待执行 1-成功 2-失败',
`error_msg` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '错误信息',
`create_time` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建时间',
`update_time` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_target_status` (`target_type`, `target_id`, `status`) USING BTREE,
KEY `idx_virtual_user` (`virtual_user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Docker AI评论任务表';
@@ -0,0 +1,6 @@
UPDATE `la_dev_crontab`
SET `status` = 0,
`update_time` = UNIX_TIMESTAMP(),
`remark` = CONCAT(IFNULL(`remark`, ''), ' [已迁移到 Docker ai_comment_dispatch]')
WHERE `command` = 'article_ai_comment'
AND `delete_time` IS NULL;
+41
View File
@@ -0,0 +1,41 @@
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT 'auto_comment_enabled', '1', 'Docker AI评论总开关', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_enabled');
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT 'auto_comment_user_pool_size', '100', 'Docker AI评论虚拟账号池数量', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_user_pool_size');
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT 'auto_comment_per_run', '50', 'Docker AI评论单轮投放上限', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_per_run');
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT 'auto_comment_daily_cap', '500', 'Docker AI评论每日成功上限', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_daily_cap');
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT 'auto_comment_concurrency', '4', 'Docker AI评论并发生成数', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_concurrency');
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT 'auto_comment_request_timeout', '180', 'Docker AI评论请求超时秒数', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_request_timeout');
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT
'auto_comment_article_prompt',
'你是一名专业、克制、真实的体育资讯评论员。请根据给定资讯内容生成1条中文评论,要求自然、具体、像真实用户发言,评论长度控制在20到60字之间。不要输出标题、前缀、解释、表情、标签、项目符号,不要提及自己是AI,不要复述全文。',
'Docker AI资讯评论提示词',
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_article_prompt');
INSERT INTO `la_ai_config` (`name`, `value`, `remark`, `create_time`, `update_time`)
SELECT
'auto_comment_post_prompt',
'你是一名活跃在体育与彩票社区的真实用户。请根据给定帖子内容、标签和人物设定,生成1条中文评论。评论要像真实社区回复,长度控制在18到66字之间,允许带轻微情绪和立场,但不要辱骂、不要承诺收益、不要暴露自己是AI。只输出评论正文,不要加前缀、编号、表情或话题标签。',
'Docker AI帖子评论提示词',
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `la_ai_config` WHERE `name` = 'auto_comment_post_prompt');
@@ -0,0 +1,7 @@
INSERT INTO `la_dev_crontab` (`name`, `type`, `system`, `remark`, `command`, `params`, `status`, `expression`, `create_time`, `update_time`)
SELECT 'AI知识库同步消费', 1, 0, '消费AI知识库同步任务,自动补标资讯、赛事、彩票缺失或过期向量文档', 'kb:consume', '--batch=100', 1, '*/5 * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (
SELECT 1 FROM `la_dev_crontab`
WHERE `command` = 'kb:consume'
AND `delete_time` IS NULL
);
@@ -0,0 +1,7 @@
INSERT INTO `la_dev_crontab` (`name`, `type`, `system`, `remark`, `command`, `params`, `status`, `expression`, `create_time`, `update_time`)
SELECT 'AI知识库Redis增量入队', 1, 0, '每分钟扫描最近30分钟资讯、彩票、加密资讯、赛事、社区帖子更新并写入Redis Stream', 'kb:enqueue-recent', '--limit=1000 --minutes=30', 1, '* * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (
SELECT 1 FROM `la_dev_crontab`
WHERE `command` = 'kb:enqueue-recent'
AND `delete_time` IS NULL
);
+65 -1
View File
@@ -6,6 +6,9 @@ param(
[string]$HostName = "sbnews", [string]$HostName = "sbnews",
[string]$SshBin = "ssh.exe", [string]$SshBin = "ssh.exe",
[switch]$AutoIncremental, [switch]$AutoIncremental,
[ValidateRange(1, 1000)]
[int]$AutoIncrementalCommits = 1,
[string]$AutoCommitMessage = "",
[string]$From = "", [string]$From = "",
[string]$To = "", [string]$To = "",
[string]$RemoteTmp = "/tmp", [string]$RemoteTmp = "/tmp",
@@ -192,6 +195,55 @@ function Remove-IfExists {
} }
} }
function Get-DeployPathspec {
if ([string]::IsNullOrEmpty($GitPathPrefix)) {
return @(".")
}
return @($GitPathPrefix)
}
function Commit-SourceChangesIfNeeded {
param(
[string]$Repository,
[string[]]$Pathspec
)
$statusArgs = @("-C", $Repository, "status", "--porcelain=v1", "--untracked-files=all", "--") + $Pathspec
$statusLines = @(& git @statusArgs)
if ($LASTEXITCODE -ne 0) {
Fail "git status failed"
}
if ($statusLines.Count -eq 0) {
return
}
$unmerged = @($statusLines | Where-Object { $_ -match "^(DD|AU|UD|UA|DU|AA|UU)" })
if ($unmerged.Count -gt 0) {
Write-DeployLog "unmerged changes detected under ${SourceDir}:"
$unmerged | Select-Object -First 20 | ForEach-Object { Write-Host " $_" }
Fail "resolve merge conflicts under $SourceDir before deploy"
}
Write-DeployLog "uncommitted changes detected under $SourceDir; auto committing before deploy:"
$maxDisplay = 80
$statusLines | Select-Object -First $maxDisplay | ForEach-Object { Write-Host " $_" }
if ($statusLines.Count -gt $maxDisplay) {
Write-Host " ... and $($statusLines.Count - $maxDisplay) more"
}
Invoke-Native "git" (@("-C", $Repository, "add", "-A", "--") + $Pathspec)
$commitMessage = $AutoCommitMessage
if ([string]::IsNullOrWhiteSpace($commitMessage)) {
$commitMessage = "deploy: auto commit $SourceDir changes $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
}
Invoke-Native "git" (@("-C", $Repository, "commit", "-m", $commitMessage, "--") + $Pathspec)
$newCommit = (Invoke-NativeOutput "git" @("-C", $Repository, "rev-parse", "--short", "HEAD") | Select-Object -First 1)
Write-DeployLog "auto commit created: $newCommit"
}
function Copy-FullPayload { function Copy-FullPayload {
Write-DeployLog "building full payload from current working tree: $SourceDir" Write-DeployLog "building full payload from current working tree: $SourceDir"
Write-DeployLog "full include paths: $($FullIncludePaths -join ' ')" Write-DeployLog "full include paths: $($FullIncludePaths -join ' ')"
@@ -306,6 +358,9 @@ function Write-UpdateLog {
$fromShort = Get-ShortCommit $FromCommit $fromShort = Get-ShortCommit $FromCommit
$toShort = Get-ShortCommit $ToCommit $toShort = Get-ShortCommit $ToCommit
Write-DeployLog "commit range: $fromShort -> $toShort" Write-DeployLog "commit range: $fromShort -> $toShort"
if ($AutoIncremental) {
Write-DeployLog "auto incremental commits: $AutoIncrementalCommits"
}
$logArgs = @("-C", $GitCwd, "-c", "i18n.logOutputEncoding=utf-8", "log", "--encoding=UTF-8", "--no-merges", "--date=format:%Y-%m-%d %H:%M:%S", "--pretty=format: * %h %ad %an %s", "$FromCommit..$ToCommit") $logArgs = @("-C", $GitCwd, "-c", "i18n.logOutputEncoding=utf-8", "log", "--encoding=UTF-8", "--no-merges", "--date=format:%Y-%m-%d %H:%M:%S", "--pretty=format: * %h %ad %an %s", "$FromCommit..$ToCommit")
if ($SourceIsGitLink) { if ($SourceIsGitLink) {
@@ -432,9 +487,13 @@ if ($AutoIncremental -and ($From -or $To)) {
Fail "-AutoIncremental cannot be used with -From/-To" Fail "-AutoIncremental cannot be used with -From/-To"
} }
if (-not $AutoIncremental -and $PSBoundParameters.ContainsKey("AutoIncrementalCommits")) {
Fail "-AutoIncrementalCommits can only be used with -AutoIncremental"
}
if ($AutoIncremental) { if ($AutoIncremental) {
$Mode = "incremental" $Mode = "incremental"
$FromCommit = "HEAD~1" $FromCommit = "HEAD~$AutoIncrementalCommits"
$ToCommit = "HEAD" $ToCommit = "HEAD"
} elseif ($From -or $To) { } elseif ($From -or $To) {
if (-not $From -or -not $To) { if (-not $From -or -not $To) {
@@ -483,6 +542,9 @@ if (-not $SourceIsGitRepo -and ($lsFiles | Where-Object { $_ -match "^160000\s"
$SourceIsGitLink = $true $SourceIsGitLink = $true
} }
$DeployPathspec = @(Get-DeployPathspec)
Commit-SourceChangesIfNeeded -Repository $GitCwd -Pathspec $DeployPathspec
if ($Mode -eq "incremental") { if ($Mode -eq "incremental") {
& git -C $GitCwd rev-parse --verify "$FromCommit^{commit}" *> $null & git -C $GitCwd rev-parse --verify "$FromCommit^{commit}" *> $null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
@@ -532,8 +594,10 @@ try {
"built_at=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" "built_at=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
) )
if ($Mode -eq "incremental") { if ($Mode -eq "incremental") {
$autoIncrementalCommitCount = if ($AutoIncremental) { $AutoIncrementalCommits } else { 0 }
$deployInfo += @( $deployInfo += @(
"auto_incremental=$([int]$AutoIncremental.IsPresent)", "auto_incremental=$([int]$AutoIncremental.IsPresent)",
"auto_incremental_commits=$autoIncrementalCommitCount",
"source_is_gitlink=$([int]$SourceIsGitLink)", "source_is_gitlink=$([int]$SourceIsGitLink)",
"from_commit=$FromCommit", "from_commit=$FromCommit",
"to_commit=$ToCommit", "to_commit=$ToCommit",
+19 -13
View File
@@ -6,9 +6,10 @@
<view v-if="descText" class="feed-card__desc">{{ descText }}</view> <view v-if="descText" class="feed-card__desc">{{ descText }}</view>
<view class="feed-card__footer"> <view class="feed-card__footer">
<text class="feed-card__author">{{ authorText }}</text> <text class="feed-card__author">{{ authorText }}</text>
<text class="feed-card__meta feed-card__meta--center">{{ item.comment_count || item.click || 0 <view class="feed-card__footer-right">
}}评论</text> <text class="feed-card__meta">{{ item.comment_count || item.click || 0 }}阅读</text>
<text class="feed-card__meta feed-card__meta--right">{{ formatTime(item.create_time) }}</text> <text class="feed-card__meta">{{ formatTime(item.create_time) }}</text>
</view>
</view> </view>
</view> </view>
<UImage v-if="item.image" :src="item.image" width="220" height="140" border-radius="8" mode="aspectFill" <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; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between;
margin-top: auto; margin-top: auto;
padding-top: 12rpx; padding-top: 12rpx;
gap: 12rpx; gap: 16rpx;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
&__author { &__author {
@@ -120,19 +123,22 @@ const formatTime = (t: string) => {
text-align: left; 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 { &__meta {
flex: 1;
font-size: 24rpx; font-size: 24rpx;
color: #bbb; color: #bbb;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
&--center {
text-align: center;
}
&--right {
text-align: right;
}
} }
&__img { &__img {
@@ -114,6 +114,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { getLocalMatchDate } from '@/utils/match-time'
const props = defineProps<{ const props = defineProps<{
match: any match: any
@@ -160,7 +161,8 @@ const statusLabel = computed(() => {
const scheduleTime = computed(() => { const scheduleTime = computed(() => {
if (!props.match.match_time) return '' 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 year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0') const month = String(d.getMonth() + 1).padStart(2, '0')
const date = String(d.getDate()).padStart(2, '0') const date = String(d.getDate()).padStart(2, '0')
+4 -3
View File
@@ -2,8 +2,8 @@
"name" : "世博头条", "name" : "世博头条",
"appid" : "__UNI__90671D8", "appid" : "__UNI__90671D8",
"description" : "世博头条AI分析平台", "description" : "世博头条AI分析平台",
"versionName" : "1.0.8", "versionName" : "1.0.9",
"versionCode" : 108, "versionCode" : 109,
"transformPx" : false, "transformPx" : false,
/* 5+App */ /* 5+App */
"app-plus" : { "app-plus" : {
@@ -23,7 +23,8 @@
"Barcode" : {}, "Barcode" : {},
"UIWebview" : {}, "UIWebview" : {},
"Webview-x5" : {}, "Webview-x5" : {},
"VideoPlayer" : {} "VideoPlayer" : {},
"Record" : {}
}, },
/* */ /* */
"distribute" : { "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 TranslatedArticleCard from '@/components/translated-article-card/translated-article-card.vue'
import { getArticleCate, getArticleList } from '@/api/news' import { getArticleCate, getArticleList } from '@/api/news'
import { getWorldCupRankings, getWorldCupSchedule, getWorldCupStandings } from '@/api/match' import { getWorldCupRankings, getWorldCupSchedule, getWorldCupStandings } from '@/api/match'
import { getLocalMatchDate } from '@/utils/match-time'
type MainTab = 'schedule' | 'rank' | 'bracket' | 'news' type MainTab = 'schedule' | 'rank' | 'bracket' | 'news'
type RankingMode = 'standings' | 'goals' | 'assists' type RankingMode = 'standings' | 'goals' | 'assists'
@@ -227,6 +228,8 @@ const tabs: { key: MainTab; label: string }[] = [
{ key: 'news', label: '新闻' } { key: 'news', label: '新闻' }
] ]
const WORLD_CUP_MATCH_CONTEXT = { competition_id: 61, league_name: '世界杯' }
const bracketTabs: { key: BracketTabKey; label: string }[] = [ const bracketTabs: { key: BracketTabKey; label: string }[] = [
{ key: '1/16决赛', label: '32强' }, { key: '1/16决赛', label: '32强' },
{ key: '1/8决赛', label: '十六强' }, { key: '1/8决赛', label: '十六强' },
@@ -531,9 +534,14 @@ const goMatchDetail = (match: any) => {
uni.navigateTo({ url: `/pages/match_detail/match_detail?id=${id}` }) 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) => { const formatDateKey = (timestamp: number) => {
if (!timestamp) return '0' if (!timestamp) return '0'
const d = new Date(timestamp * 1000) const d = getWorldCupDisplayDate(timestamp)
if (!d) return '0'
const y = d.getFullYear() const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0') const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0')
@@ -542,7 +550,8 @@ const formatDateKey = (timestamp: number) => {
const formatDateLabel = (timestamp: number) => { const formatDateLabel = (timestamp: number) => {
if (!timestamp) return '未分组' if (!timestamp) return '未分组'
const d = new Date(timestamp * 1000) const d = getWorldCupDisplayDate(timestamp)
if (!d) return '未分组'
const weeks = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] const weeks = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${weeks[d.getDay()]}` return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${weeks[d.getDay()]}`
} }
@@ -555,13 +564,15 @@ const normalizeGroupName = (value: string) => {
const formatMatchTime = (timestamp: number) => { const formatMatchTime = (timestamp: number) => {
if (!timestamp) return '--' 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')}` return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
} }
const formatBracketDate = (timestamp: number) => { const formatBracketDate = (timestamp: number) => {
if (!timestamp) return '--' if (!timestamp) return '--'
const date = new Date(timestamp * 1000) const date = getWorldCupDisplayDate(timestamp)
if (!date) return '--'
return `${date.getMonth() + 1}${date.getDate()}` return `${date.getMonth() + 1}${date.getDate()}`
} }
+377 -82
View File
@@ -45,7 +45,7 @@
<view class="quick-list"> <view class="quick-list">
<view v-for="item in quickQuestions" :key="item" class="quick-list__item" @tap="sendQuick(item)"> <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>
</view> </view>
@@ -56,37 +56,47 @@
class="message-row" class="message-row"
:class="item.role === 'user' ? 'message-row--user' : 'message-row--assistant'" :class="item.role === 'user' ? 'message-row--user' : 'message-row--assistant'"
> >
<view class="message-bubble"> <view v-if="item.role !== 'user'" class="message-row__avatar">AI</view>
<view v-if="item.pending" class="message-bubble__pending"> <view class="message-row__body">
<text class="message-bubble__loading">正在整理站内资料...</text> <view class="message-bubble">
<text class="message-bubble__timer">分析用时 {{ analysisDurationText }}</text> <view v-if="item.pending" class="message-bubble__pending">
</view> <view class="message-bubble__pulse" />
<text v-else class="message-bubble__text">{{ item.content }}</text> <text class="message-bubble__loading">正在整理站内资料...</text>
<view v-if="item.sources && item.sources.length" class="source-list"> <text class="message-bubble__timer">分析用时 {{ analysisDurationText }}</text>
<view v-for="source in item.sources" :key="source.type + source.source_id + source.title" </view>
class="source-item" @tap="openSource(source)"> <text v-else-if="item.role === 'user'" class="message-bubble__text">{{ item.content }}</text>
<view class="source-item__meta">{{ sourceTypeName(source.type) }}</view> <view v-else class="message-bubble__markdown">
<text class="source-item__title">{{ source.title }}</text> <u-parse :html="renderAssistantMarkdown(item.content)" />
<text class="source-item__summary">{{ source.summary }}</text> </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>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<view class="input-bar"> <view class="input-panel">
<textarea <view class="input-bar">
v-model="inputText" <input
class="input-bar__textarea" v-model="inputText"
auto-height class="input-bar__input"
maxlength="500" type="text"
:disabled="sending" maxlength="500"
placeholder="问问世博头条里的内容" :cursor-spacing="18"
confirm-type="send" :disabled="sending"
@confirm="sendMessage" placeholder="问问世博头条里的内容"
/> confirm-type="send"
<view class="input-bar__send" :class="{ 'input-bar__send--disabled': !canSend }" @tap="sendMessage"> @confirm="sendMessage"
<u-icon name="arrow-upward" size="32" color="#fff" /> />
<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> </view>
</view> </view>
@@ -104,6 +114,7 @@ import {
type AssistantSession, type AssistantSession,
type AssistantSource type AssistantSource
} from '@/api/assistant' } from '@/api/assistant'
import { renderMarkdownToHtml } from '@/utils/assistant-markdown'
const STORAGE_CLIENT_ID = 'sbnews_assistant_client_id' const STORAGE_CLIENT_ID = 'sbnews_assistant_client_id'
@@ -193,6 +204,8 @@ const sendQuick = (text: string) => {
sendMessage() sendMessage()
} }
const renderAssistantMarkdown = (content: string) => renderMarkdownToHtml(content)
const sendMessage = async () => { const sendMessage = async () => {
const text = inputText.value.trim() const text = inputText.value.trim()
if (!text || sending.value) return if (!text || sending.value) return
@@ -232,6 +245,8 @@ const sendMessage = async () => {
await loadSessions() await loadSessions()
} catch (e: any) { } catch (e: any) {
console.error('[ai_assistant] chat failed:', e) console.error('[ai_assistant] chat failed:', e)
const recovered = await recoverLatestAssistantReply(text)
if (recovered) return
messages.value.splice(messages.value.length - 1, 1, { messages.value.splice(messages.value.length - 1, 1, {
id: Date.now() + 2, id: Date.now() + 2,
session_id: sessionId.value, 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) => { const getRawAssistantErrorMessage = (error: any) => {
return typeof error === 'string' return typeof error === 'string'
? error ? error
@@ -262,7 +300,9 @@ const getAssistantErrorMessage = (error: any) => {
return 'AI助手请求已取消,请重新发送问题。' return 'AI助手请求已取消,请重新发送问题。'
} }
if (/request:fail|network|网络/i.test(rawMessage)) { if (/request:fail|network|网络/i.test(rawMessage)) {
return 'AI助手请求失败,请检查网络后重试。' return rawMessage
? `AI助手请求失败,请检查网络后重试。错误详情:${rawMessage}`
: 'AI助手请求失败,请检查网络后重试。'
} }
return rawMessage || 'AI助手请求失败,请稍后重试。' return rawMessage || 'AI助手请求失败,请稍后重试。'
@@ -322,7 +362,8 @@ const sourceTypeName = (type: string) => {
post: '社区', post: '社区',
match: '赛事', match: '赛事',
lottery: '彩票', lottery: '彩票',
crypto: '行情' crypto: '行情',
web_news: '外网资讯'
} }
return map[type] || '来源' return map[type] || '来源'
} }
@@ -344,21 +385,23 @@ onUnmounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.assistant-page { .assistant-page {
height: 100vh; height: 100vh;
min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #eef2f6; background: #f3f6fa;
overflow: hidden; overflow: hidden;
} }
.assistant-nav { .assistant-nav {
flex-shrink: 0; flex-shrink: 0;
height: 96rpx; min-height: 96rpx;
padding-left: 20rpx; padding-left: 20rpx;
padding-right: 20rpx; padding-right: 20rpx;
background: #fff; background: rgba(255, 255, 255, 0.96);
display: flex; display: flex;
align-items: center; 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 { &__title {
flex: 1; flex: 1;
@@ -378,20 +421,27 @@ onUnmounted(() => {
&__btn { &__btn {
width: 64rpx; width: 64rpx;
height: 64rpx; height: 64rpx;
border-radius: 8rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
}
&__btn:active {
background: #eef3fb;
} }
} }
.history-panel { .history-panel {
flex-shrink: 0; flex-shrink: 0;
max-height: 420rpx; max-height: 46vh;
background: #fff; background: #fff;
border-bottom: 1rpx solid #e8edf3; border-bottom: 1rpx solid #dbe6f5;
box-shadow: 0 18rpx 36rpx rgba(38, 55, 77, 0.08);
&__header { &__header {
height: 72rpx; height: 80rpx;
padding: 0 28rpx; padding: 0 28rpx;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -407,7 +457,7 @@ onUnmounted(() => {
} }
&__list { &__list {
max-height: 340rpx; max-height: calc(46vh - 80rpx);
} }
&__empty { &__empty {
@@ -419,8 +469,9 @@ onUnmounted(() => {
} }
.history-item { .history-item {
padding: 22rpx 28rpx; padding: 22rpx 28rpx 24rpx;
border-top: 1rpx solid #f1f5f9; border-top: 1rpx solid #f1f5f9;
background: #fff;
&__title { &__title {
display: block; display: block;
@@ -443,22 +494,23 @@ onUnmounted(() => {
.message-list { .message-list {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
padding: 24rpx 24rpx 32rpx; padding: 24rpx 24rpx 36rpx;
box-sizing: border-box; box-sizing: border-box;
} }
.welcome { .welcome {
display: flex; display: flex;
gap: 18rpx; gap: 18rpx;
padding: 24rpx; padding: 24rpx 24rpx 26rpx;
background: #fff; background: #fff;
border: 1rpx solid #dbe6f5; border: 1rpx solid rgba(214, 224, 237, 0.92);
border-radius: 8rpx; border-radius: 16rpx;
box-shadow: 0 12rpx 28rpx rgba(41, 60, 87, 0.06);
&__badge { &__badge {
width: 56rpx; width: 60rpx;
height: 56rpx; height: 60rpx;
border-radius: 8rpx; border-radius: 14rpx;
background: #185dff; background: #185dff;
color: #fff; color: #fff;
font-size: 24rpx; font-size: 24rpx;
@@ -476,14 +528,15 @@ onUnmounted(() => {
&__title { &__title {
display: block; display: block;
font-size: 30rpx; font-size: 31rpx;
color: #111827; color: #111827;
font-weight: 700; font-weight: 700;
line-height: 1.35;
} }
&__desc { &__desc {
display: block; display: block;
margin-top: 8rpx; margin-top: 10rpx;
font-size: 24rpx; font-size: 24rpx;
color: #6b7280; color: #6b7280;
line-height: 1.5; line-height: 1.5;
@@ -491,51 +544,111 @@ onUnmounted(() => {
} }
.quick-list { .quick-list {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx; gap: 14rpx;
margin: 20rpx 0 28rpx; margin: 22rpx 0 32rpx;
&__item { &__item {
max-width: 100%; min-height: 62rpx;
padding: 14rpx 20rpx; padding: 12rpx 18rpx;
border-radius: 8rpx; border-radius: 14rpx;
background: #fff; background: #fff;
border: 1rpx solid #dbe6f5; border: 1rpx solid rgba(207, 220, 238, 0.95);
color: #185dff; color: #185dff;
font-size: 24rpx; font-size: 24rpx;
line-height: 1.35;
box-sizing: border-box; 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 { .message-row {
display: flex; display: flex;
margin-bottom: 24rpx; align-items: flex-start;
margin-bottom: 26rpx;
&--user { &--user {
justify-content: flex-end; justify-content: flex-end;
.message-row__body {
max-width: 82%;
align-items: flex-end;
}
.message-bubble { .message-bubble {
background: #185dff; background: #185dff;
border-color: #185dff;
color: #fff; color: #fff;
box-shadow: 0 12rpx 28rpx rgba(24, 93, 255, 0.22);
} }
} }
&--assistant { &--assistant {
justify-content: flex-start; justify-content: flex-start;
.message-row__body {
max-width: calc(100% - 78rpx);
align-items: flex-start;
}
.message-bubble { .message-bubble {
background: #fff; background: #fff;
color: #111827; 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 { .message-bubble {
max-width: 82%; max-width: 100%;
padding: 20rpx 22rpx; padding: 20rpx 24rpx;
border-radius: 8rpx; border-radius: 16rpx;
box-sizing: border-box; box-sizing: border-box;
&__pending { &__pending {
@@ -544,14 +657,27 @@ onUnmounted(() => {
gap: 8rpx; gap: 8rpx;
} }
&__pulse {
width: 36rpx;
height: 8rpx;
border-radius: 8rpx;
background: #185dff;
opacity: 0.78;
}
&__text, &__text,
&__loading { &__loading,
&__markdown {
font-size: 28rpx; font-size: 28rpx;
line-height: 1.65; line-height: 1.72;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
&__markdown {
white-space: normal;
}
&__loading { &__loading {
color: #6b7280; color: #6b7280;
} }
@@ -559,11 +685,152 @@ onUnmounted(() => {
&__timer { &__timer {
font-size: 22rpx; font-size: 22rpx;
line-height: 1.4; line-height: 1.4;
color: #185dff; color: #0f7a5f;
font-weight: 600; 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 { .source-list {
margin-top: 18rpx; margin-top: 18rpx;
display: flex; display: flex;
@@ -572,10 +839,23 @@ onUnmounted(() => {
} }
.source-item { .source-item {
padding: 16rpx; position: relative;
border-radius: 8rpx; padding: 18rpx 18rpx 18rpx 22rpx;
border-radius: 14rpx;
background: #f8fafc; background: #f8fafc;
border: 1rpx solid #e2e8f0; 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 { &__meta {
display: inline-flex; display: inline-flex;
@@ -583,9 +863,10 @@ onUnmounted(() => {
height: 34rpx; height: 34rpx;
padding: 0 12rpx; padding: 0 12rpx;
border-radius: 6rpx; border-radius: 6rpx;
background: #e8f0ff; background: #e8f6f1;
color: #185dff; color: #0f7a5f;
font-size: 20rpx; font-size: 20rpx;
font-weight: 600;
} }
&__title { &__title {
@@ -606,39 +887,53 @@ onUnmounted(() => {
} }
} }
.input-bar { .input-panel {
flex-shrink: 0; flex-shrink: 0;
padding: 18rpx 22rpx calc(18rpx + env(safe-area-inset-bottom)); padding-top: 16rpx;
background: #fff; padding-left: 22rpx;
border-top: 1rpx solid #e8edf3; padding-right: 22rpx;
display: flex; padding-bottom: calc(18rpx + constant(safe-area-inset-bottom));
align-items: flex-end; padding-bottom: calc(18rpx + env(safe-area-inset-bottom));
gap: 16rpx; 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; flex: 1;
min-height: 72rpx; height: 64rpx;
max-height: 180rpx; min-height: 64rpx;
padding: 18rpx 22rpx; max-height: 64rpx;
padding: 0 20rpx;
box-sizing: border-box; box-sizing: border-box;
border-radius: 8rpx; border-radius: 14rpx;
background: #f3f6fa; background: #eef3f8;
font-size: 28rpx; font-size: 28rpx;
line-height: 1.45; line-height: 64rpx;
color: #111827;
} }
&__send { &__send {
width: 72rpx; width: 64rpx;
height: 72rpx; height: 64rpx;
border-radius: 8rpx; border-radius: 14rpx;
background: #185dff; background: #185dff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 10rpx 20rpx rgba(24, 93, 255, 0.22);
&--disabled { &--disabled {
background: #b8c5d8; background: #b8c5d8;
box-shadow: none;
} }
} }
} }
@@ -429,6 +429,7 @@ import { onLoad, onUnload } from '@dcloudio/uni-app'
import { getMatchDetail, getMatchLiveText, getMatchLineup } from '@/api/match' import { getMatchDetail, getMatchLiveText, getMatchLineup } from '@/api/match'
import { checkAiUnlock, getMatchPredictSection } from '@/api/ai' import { checkAiUnlock, getMatchPredictSection } from '@/api/ai'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { getLocalMatchDate } from '@/utils/match-time'
const userStore = useUserStore() const userStore = useUserStore()
const vipInfo = computed(() => userStore.userInfo?.vip_info || {}) 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*$/) const dtMatch = s.match(/(\d{2}):(\d{2}):(\d{2})\s*$/)
if (dtMatch) return `${dtMatch[1]}:${dtMatch[2]}:${dtMatch[3]}` if (dtMatch) return `${dtMatch[1]}:${dtMatch[2]}:${dtMatch[3]}`
if (/^\d{9,}$/.test(s)) { if (/^\d{9,}$/.test(s)) {
const bjMs = Number(s) * 1000 + 8 * 3600 * 1000 const d = new Date(Number(s) * 1000)
const d = new Date(bjMs) const hh = String(d.getHours()).padStart(2, '0')
const hh = String(d.getUTCHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0')
const mm = String(d.getUTCMinutes()).padStart(2, '0') const ss = String(d.getSeconds()).padStart(2, '0')
const ss = String(d.getUTCSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}` return `${hh}:${mm}:${ss}`
} }
return s return s
@@ -664,7 +664,8 @@ const formatTs = (ts: number) => {
const formatMatchTime = (ts: number) => { const formatMatchTime = (ts: number) => {
if (!ts) return '' if (!ts) return ''
const d = new Date(ts * 1000) const d = getLocalMatchDate(ts, match.value)
if (!d) return ''
const y = d.getFullYear() const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0') const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).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)
}
+463
View File
@@ -13,6 +13,469 @@
## 一、已完成事项 ## 一、已完成事项
### 65. Docker AI评论调度接入帖子与资讯
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 在 `server/public/dongqiudi-crawler` 新增 `ai_comment_dispatch.py`,由 Docker crawler 直接执行 AI 评论调度,不再复用旧的 PHP `article_ai_comment` 运行链。
- 评论任务新增统一任务表方案 `la_ai_comment_task`,记录 `target_type/target_id/virtual_user_id/persona_key/selection_sources/comment_content/status/error_msg`,并补充幂等 SQL `docs/sql/create_ai_comment_task.sql`
- 新增幂等 SQL `docs/sql/insert_ai_comment_config.sql`,初始化 `auto_comment_enabled``auto_comment_user_pool_size``auto_comment_per_run``auto_comment_daily_cap``auto_comment_concurrency``auto_comment_request_timeout``auto_comment_article_prompt``auto_comment_post_prompt` 等配置;Python 侧直接复用 `la_ai_config` 中的 `openai_* / max_tokens / temperature`
- AI 评论调度会自动把 `ai_comment_` 虚拟账号池补足到 100 个,并按稳定 persona 映射分配昵称与发言角色。
- 资讯候选池按现有前台口径实现:每个可见分类最新 100 条、`is_recommend=1` 视为置顶 10 条、今天创建且当前总浏览最高 20 条;帖子候选池按现有 `community_tag` 口径实现:每个标签最新 100 条、`is_top=1` 置顶 10 条、今天创建且 `view_count` 最高 20 条。
- 各候选池会先按内容 ID 去重并合并来源标签,再过滤已存在机器人成功评论或已有待执行任务的内容;单轮默认按 25 条资讯 + 25 条帖子配额取数,某一侧不足时由另一侧补满。
- 评论生成支持多 persona 的角色、语气、场景和关注点,并在模型失败时回退到兜底短评;成功后直接写入 `la_article_comment` / `la_community_comment` 并同步递增文章或帖子评论数。
- `main.py` 新增 `ai_comment_dispatch` 动作注册与 1800 秒超时,`config/crawler_tasks.yaml` 新增 `*/10 * * * *` 的 Docker 定时任务;另补充 `docs/sql/disable_article_ai_comment_crontab.sql` 用于停用旧 `article_ai_comment` 定时任务。
涉及模块:
- `server/public/dongqiudi-crawler/ai_comment_dispatch.py`
- `server/public/dongqiudi-crawler/main.py`
- `server/public/dongqiudi-crawler/config/crawler_tasks.yaml`
- `server/public/dongqiudi-crawler/tests/test_ai_comment_dispatch.py`
- `qa/backend/test_ai_comment_dispatch_static.py`
- `docs/sql/create_ai_comment_task.sql`
- `docs/sql/insert_ai_comment_config.sql`
- `docs/sql/disable_article_ai_comment_crontab.sql`
验证记录:
- 已先新增并执行 `python -m unittest server/public/dongqiudi-crawler/tests/test_ai_comment_dispatch.py`,在实现前因缺少 `ai_comment_dispatch` 模块失败。
- 修改后已执行 `C:\Python311\python.exe -m unittest server/public/dongqiudi-crawler/tests/test_ai_comment_dispatch.py server/public/dongqiudi-crawler/tests/test_docker_task_runner.py server/public/dongqiudi-crawler/tests/test_runtime_guard.py`15 个测试全部通过。
- 已执行 `C:\Python311\python.exe qa/backend/test_ai_comment_dispatch_static.py`,静态检查通过。
- 已执行 `C:\Python311\python.exe -m py_compile server/public/dongqiudi-crawler/ai_comment_dispatch.py server/public/dongqiudi-crawler/main.py server/public/dongqiudi-crawler/scripts/docker_task_runner.py qa/backend/test_ai_comment_dispatch_static.py`Python 语法检查通过。
- 已在测试库执行 `docs/sql/create_ai_comment_task.sql``docs/sql/insert_ai_comment_config.sql``docs/sql/disable_article_ai_comment_crontab.sql`,确认 `la_ai_comment_task` 建表成功、`auto_comment_*` 配置写入成功,旧 `article_ai_comment` 定时任务已停用。
- 已用远端测试库配置手动执行一轮 `ai_comment_dispatch` 最小联调,临时将单轮条数压到 2,实际成功创建 2 条任务:`article_id=2044144``post_id=5924`,均已写入对应评论表并将文章/帖子 `comment_count` 回写为 `1`
### 64. AI助手资讯类联网检索接入
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 在 AI 助手固定四阶段编排链的 `Retriever` 阶段新增 `AiAssistantWebNewsRetriever`,对资讯/新闻/消息/转会类问题触发 OpenAI `responses``web_search` 工具,补充外网最新资讯,不再只依赖站内向量库和知识库。
- `DeepSeekClient``responses` payload 新增 `tools``tool_choice``include` 透传能力,支持联网检索器强制执行 `web_search` 并请求返回官方 sources 元信息。
- `AiAssistantContextService::composePayload()` 新增 `web_news_context`,回答阶段会同时看到“站内资料、联网资讯与实时上下文”;外网资讯来源统一归一为 `web_news`,并跳转到 `/pages/webview/webview?url=...`
- 修正 `AiAssistantPlanner` fallback 意图判断顺序,像“今天NBA有什么最新消息?”这类问题优先识别为 `news_search`,避免被“今天”误判成赛程类。
- AI 助手前端来源类型映射新增 `web_news => 外网资讯`,方便用户识别联网结果来源。
涉及模块:
- `server/app/common/service/ai/AiAssistantWebNewsRetriever.php`
- `server/app/common/service/ai/AiAssistantRetriever.php`
- `server/app/common/service/ai/AiAssistantContextService.php`
- `server/app/common/service/ai/AiAssistantResponder.php`
- `server/app/common/service/ai/DeepSeekClient.php`
- `server/app/common/service/ai/AiAssistantPlanner.php`
- `uniapp/src/pages/ai_assistant/ai_assistant.vue`
- `qa/backend/test_ai_assistant_web_news_static.php`
- `qa/backend/test_ai_assistant_web_news_source_behavior.php`
验证记录:
- 已先执行 `php qa/backend/test_ai_assistant_web_news_static.php``php qa/backend/test_ai_assistant_web_news_source_behavior.php`,修改前失败。
- 修改后已执行 `php qa/backend/test_ai_assistant_planner_behavior.php``php qa/backend/test_ai_assistant_web_news_static.php``php qa/backend/test_ai_assistant_web_news_source_behavior.php``php qa/backend/test_ai_assistant_orchestrator_static.php`,均通过。
- 已执行 `php -l` 检查 `AiAssistantWebNewsRetriever.php``AiAssistantRetriever.php``AiAssistantContextService.php``AiAssistantResponder.php``DeepSeekClient.php``AiAssistantPlanner.php`,语法检查通过。
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
- 已直接使用测试服 `.env` 中的 `OPENAI_API_KEY``https://lingsuan.top/v1/responses` 发起 `web_search` 请求,确认可返回结构化联网资讯 JSON。
- 截至当前一次 `POST /api/assistant/chat` 复测,“今天NBA有什么最新消息?” 仍主要返回站内文章来源,尚未观察到 `web_news` 来源出现在测试服助手接口响应中;当前未执行测试服后端发布脚本,因为项目内 `scripts/deploy-server.ps1` 会自动提交工作区,不符合当前未请求提交的协作约束。
### 63. AI助手升级为多阶段编排链
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 将 AI 助手主链路从“直接拼上下文后回答”升级为固定四阶段编排:`Planner -> Retriever -> Ranker -> Responder`,由新增 `AiAssistantOrchestrator` 串联,`AiAssistantService` 保留会话、限流、消息落库和返回格式职责。
- 新增 `AiAssistantPlanner`,通过 LLM 先输出结构化查询规划 JSON,包含 `intent``rewritten_query``alternate_queries``domains``preferred_domain``entities``date_range``needs_realtime``answer_format``confidence`;当规划失败时自动回退到规则 fallback,保证助手不中断。
- `AiAssistantContextService` 升级为按规划结果驱动检索:知识库检索优先使用 `rewritten_query + alternate_queries` 多路召回并合并去重;赛事和加密实时上下文根据规划结果决定是否检索、提取哪些实体、是否带日期范围。
- 新增 `AiAssistantRanker` 对知识库命中做领域偏好重排,并统一拼装 `query_plan + knowledge_hits + match_context + crypto_realtime` 的回答上下文;新增 `AiAssistantResponder` 基于规划结果和证据上下文生成最终 markdown 回复。
- 新增 `AiAssistantTrace` 作为本次请求内的轻量 DTO,贯穿四阶段,先不加数据库新表,避免扩大改动面。
- 新增 QA 回归覆盖:AI 助手服务必须委托 `AiAssistantOrchestrator`,编排链必须包含 `Planner/Retriever/Ranker/Responder` 四阶段;`Planner` 的归一化输出必须稳定保留 query、domain、date_range 和 markdown 默认格式。
涉及模块:
- `server/app/common/service/ai/AiAssistantService.php`
- `server/app/common/service/ai/AiAssistantOrchestrator.php`
- `server/app/common/service/ai/AiAssistantTrace.php`
- `server/app/common/service/ai/AiAssistantPlanner.php`
- `server/app/common/service/ai/AiAssistantRetriever.php`
- `server/app/common/service/ai/AiAssistantRanker.php`
- `server/app/common/service/ai/AiAssistantResponder.php`
- `server/app/common/service/ai/AiAssistantContextService.php`
- `qa/backend/test_ai_assistant_orchestrator_static.php`
- `qa/backend/test_ai_assistant_planner_behavior.php`
验证记录:
- 已先执行 `php qa/backend/test_ai_assistant_orchestrator_static.php``php qa/backend/test_ai_assistant_planner_behavior.php`,修改前均失败。
- 修改后已执行 `php qa/backend/test_ai_assistant_orchestrator_static.php``php qa/backend/test_ai_assistant_planner_behavior.php``php qa/backend/test_ai_assistant_frontend_error_static.php``php qa/backend/test_ai_assistant_markdown_static.php``php qa/backend/test_ai_assistant_markdown_table_static.php``php qa/backend/test_ai_assistant_match_context_static.php``php qa/backend/test_ai_assistant_retry_options.php``php qa/backend/test_ai_assistant_worldcup_schedule_static.php`,均通过。
- 已执行 `php -l` 检查新增/修改的 `AiAssistant*` 服务类与相关 QA 脚本,语法检查通过。
- 已复测测试服 `POST /api/assistant/chat`
- 提问“今天的世界杯赛程”可返回 2026-06-12 的两场世界杯并附带表格;
- 提问“BTC 今天行情怎么样?”可返回实时行情摘要并附带 `BTC/USDT` 行情来源。
### 62. AI助手世界杯今日赛程与表格渲染修复
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 复测 `POST /api/assistant/chat` 提问“今天的世界杯赛程”,确认修复前助手会把 2026-07-15/18/19 的未来淘汰赛占位对阵误当成“今天赛程”,根因是赛事检索只按球队/联赛模糊匹配并默认按 `match_time desc` 取最新,不区分“今天/明天/昨天”这类日期问法。
- `AiAssistantContextService` 新增日期范围识别逻辑,对“今天/今日/明天/昨天/昨日”的赛程类问题按本地自然日过滤 `match_time`,并让赛程类问法按时间正序取当天比赛;结果类问法继续优先已完场赛事。
- 复测同一接口发现模型会用 markdown 表格输出赛程,而 `uniapp/src/utils/assistant-markdown.ts` 原先未支持表格,容易导致 AI 助手正文显示异常;现已补齐 markdown table 转 HTML table 逻辑,并在 AI 助手消息样式中补充表格排版。
- 同步更新 QA 静态回归:覆盖“世界杯今日赛程”日期筛选,以及 markdown 表格识别与转换。
涉及模块:
- `server/app/common/service/ai/AiAssistantContextService.php`
- `uniapp/src/utils/assistant-markdown.ts`
- `uniapp/src/pages/ai_assistant/ai_assistant.vue`
- `qa/backend/test_ai_assistant_worldcup_schedule_static.php`
- `qa/backend/test_ai_assistant_markdown_table_static.php`
- `qa/backend/test_ai_assistant_match_context_static.php`
验证记录:
- 已先执行 `php qa/backend/test_ai_assistant_worldcup_schedule_static.php``php qa/backend/test_ai_assistant_markdown_table_static.php`,修改前均失败。
- 修改后已执行 `php qa/backend/test_ai_assistant_markdown_static.php``php qa/backend/test_ai_assistant_frontend_error_static.php``php qa/backend/test_ai_assistant_match_context_static.php``php qa/backend/test_ai_assistant_markdown_table_static.php``php qa/backend/test_ai_assistant_worldcup_schedule_static.php`,均通过。
- 已执行 `php -l server/app/common/service/ai/AiAssistantContextService.php``php -l qa/backend/test_ai_assistant_match_context_static.php``php -l qa/backend/test_ai_assistant_markdown_table_static.php``php -l qa/backend/test_ai_assistant_worldcup_schedule_static.php`,语法检查通过。
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
- 已复测测试服 `POST /api/assistant/chat` 提问“今天的世界杯赛程”,回复改为列出 2026-06-12 的两场世界杯:`韩国 vs 捷克``加拿大 vs 波黑`,并返回 markdown 表格正文与对应赛事来源。
### 61. AI助手回复 markdown 渲染适配
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 排查 `uniapp/src/pages/ai_assistant/ai_assistant.vue`,确认当前助手消息直接通过纯文本节点输出,导致 `##``**`、列表等 markdown 语法原样展示。
- 复用项目已全局注册的 `u-parse` 组件,新增 `uniapp/src/utils/assistant-markdown.ts`,将助手回复中的标题、加粗、列表、引用、分割线、代码块和链接转换为安全 HTML 后渲染。
- AI 助手消息区分用户文本与助手回复:用户消息继续走纯文本展示,助手消息走 markdown 渲染链路,并补充对应的排版样式。
- 新增 QA 静态回归脚本,检查 AI 助手页面存在 markdown 转换逻辑并通过 `u-parse` 渲染助手回复。
涉及模块:
- `uniapp/src/pages/ai_assistant/ai_assistant.vue`
- `uniapp/src/utils/assistant-markdown.ts`
- `qa/backend/test_ai_assistant_markdown_static.php`
验证记录:
- 已先执行 `php qa/backend/test_ai_assistant_markdown_static.php`,修改前失败,提示缺少 markdown 转换逻辑。
- 修改后已执行 `php qa/backend/test_ai_assistant_markdown_static.php`,通过。
- 已执行 `php -l qa/backend/test_ai_assistant_markdown_static.php`,语法检查通过。
- 已执行 `php qa/backend/test_ai_assistant_frontend_error_static.php`,通过。
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
- 已确认本地页面 `http://localhost:8991/pages/ai_assistant/ai_assistant` 可返回 HTTP 200;当前线程未提供可用浏览器/Playwright 依赖,未做自动化截图验收。
### 60. AI助手马刺最新赛果上下文修复
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 核查 `pages/match_detail/match_detail?id=3567442` 对应赛事,确认测试库 `la_match.id=3567442` 为 NBA 纽约尼克斯 vs 圣安东尼奥马刺,比分 107-106,已完场。
- 确认该赛事已进入 AI 知识库:`la_ai_kb_document.id=948``domain=match``subtype=event``source_id=3567442`6 个 chunk 均已有 1024 维 embedding,最近同步任务 `la_ai_kb_sync_job.id=25444` 成功。
- 定位 AI 助手未优先回答该场的原因:`马刺最新比赛结果` 这类中文连写问题没有从 `queryTerms()` 中拆出“马刺”,而知识库向量召回本身没有时间排序语义;同时实时赛事兜底原先按 `match_time desc` 优先拿未来 0-0 赛程。
- `AiAssistantContextService` 已补充赛事查询词停用词剥离,将“马刺最新比赛结果”归一出“马刺”;对“最新/最近/结果/比分/赛果/成绩/战绩”等问法优先查询已完场比赛,再补充其他赛程。
- 新增 QA 静态回归脚本,防止中文连写球队词无法提取、最新赛果不优先完场赛事的问题回退。
涉及模块:
- `server/app/common/service/ai/AiAssistantContextService.php`
- `qa/backend/test_ai_assistant_match_context_static.php`
- `la_match`
- `la_ai_kb_document`
- `la_ai_kb_chunk`
- `la_ai_kb_sync_job`
验证记录:
- 已查询测试库,确认 `source_id=3567442` 的 match/event 知识库文档存在且有 6 个有效向量 chunk。
- 已查询 `la_ai_kb_query_log`,确认修复前 `马刺最新比赛结果` 的 hit docs 不包含 `3567442`
- 已执行 `php qa/backend/test_ai_assistant_match_context_static.php`,修改前失败、修改后通过。
- 已执行 `php -l server/app/common/service/ai/AiAssistantContextService.php``php -l qa/backend/test_ai_assistant_match_context_static.php`,语法检查通过。
- 已请求测试服 `POST /api/assistant/chat` 提问“马刺最新比赛结果”,返回答案包含“圣安东尼奥马刺客场 106-107 不敌纽约尼克斯”,sources 包含 `/pages/match_detail/match_detail?id=3567442`
### 59. 赛事时间按当前时区显示
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 排查赛事时间展示链路,确认普通赛事卡和详情主时间已基本使用本地 `Date`,偏差集中在世界杯专题页固定叠加北京时间、以及比赛详情文字直播时间固定转北京时间。
- 新增 `uniapp/src/utils/match-time.ts`,统一将赛事 Unix 秒转换为设备当前时区的 `Date`;世界杯源字段按 UTC 时间保存但历史入库为服务器本地 Unix 秒,先在工具层归一到真实时间点再本地化展示。
- `match-card``match_detail``WorldCupPanel` 已统一接入 `getLocalMatchDate`,世界杯赛程分组、赛程卡时间、对阵图日期/时间和比赛详情主时间不再固定显示北京时间。
- 比赛详情文字直播时间移除 `+8 * 3600``getUTC*`,数字时间戳直接按设备当前时区显示。
- 新增 QA 静态回归脚本,检查赛事时间展示必须走统一本地时区工具,并阻止世界杯面板/详情页再次写入北京时间硬编码。
涉及模块:
- `uniapp/src/utils/match-time.ts`
- `uniapp/src/components/match-card/match-card.vue`
- `uniapp/src/pages/match_detail/match_detail.vue`
- `uniapp/src/packages_match/components/WorldCupPanel.vue`
- `qa/backend/test_match_timezone_display_static.php`
验证记录:
- 已先执行 `php qa/backend/test_match_timezone_display_static.php`,修改前因缺少统一赛事时间工具失败。
- 修改后已执行 `php qa/backend/test_match_timezone_display_static.php`,通过。
- 已执行 `php -l qa/backend/test_match_timezone_display_static.php`,语法检查通过。
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
### 58. AI助手知识库检索 fallback 修复
- 状态:已完成 - 时间:2026-06-12
完成内容:
- 排查 `/api/assistant/chat` 提问“旧澳六合最近有什么分析线索?”返回 `sources=[]`,确认测试库向量库已有彩票内容:`lottery/draw_result` 4698 条文档、`post/post``旧澳六合` 相关文档 207+ 条,问题不在向量入库。
- 查询 `la_ai_kb_query_log`,确认当前 59+ 条知识库检索日志均为 `status=2``hit_docs_json=[]`,AI 助手在知识库检索异常后静默退化为“站内资料不足”。
- 定位根因:`KbService::loadCandidates()` fallback 使用了 ThinkPHP 不支持的 `whereOrLike()` 魔术方法,会被解析成字段名 `like` 并导致 SQL 异常;同时中文长句只保留整句作为 LIKE 词,无法拆出“旧澳六合”等核心召回词。
- 修复 fallback:改为显式 `whereOr('字段', 'like', $like)`,多关键词按 OR 召回;补充“旧澳六合 -> 老澳门六合彩”“新澳六合 -> 新澳门六合彩”别名,避免依赖 MySQL 中文全文分词。
涉及模块:
- `server/app/common/service/ai/KbService.php`
- `qa/backend/test_kb_retrieve_fallback_static.php`
验证记录:
- 已执行 `php qa/backend/test_kb_retrieve_fallback_static.php`,通过。
- 已执行 `php -l server/app/common/service/ai/KbService.php``php -l qa/backend/test_kb_retrieve_fallback_static.php`,语法检查通过。
- 已执行 `php qa/backend/test_kb_sync_pipeline_static.php``php qa/backend/test_kb_redis_enqueue_static.php`,通过。
- 已用测试库 SQL 复刻修复后的 fallback 条件,确认“旧澳六合最近有什么分析线索?”可召回 `post/post` 211 条和 `lottery/draw_result` 2 条,样本包含“老澳门六合彩 第2026162期开奖”。
- 已通过 `scripts/deploy-server.ps1 -AutoIncremental` 发布 server 修复到测试服,并执行 `php think clear` 清理缓存;复测 `/api/assistant/chat` 提问“旧澳六合最近有什么分析线索?”返回 `sources` 6 条,`la_ai_kb_query_log` session 29 记录 `status=1``hit_len=6`
### 57. AI助手前端网络失败兜底增强
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 排查用户端仍展示“AI助手请求失败,请检查网络后重试。”,确认该文案来自前端 `request:fail/network/网络` 分支;测试库最近 30 分钟无新的助手失败记录,说明该类报错多发生在前端未拿到后端响应时。
- AI 助手发送失败时新增历史恢复逻辑:若请求已到后端并生成会话/回复,但前端网络层未收到响应,则通过 `client_id + last_message` 拉取最新会话消息并回填当前对话。
- 网络失败提示保留原始 `errMsg/message`,展示为“错误详情:xxx”,避免继续只显示泛化网络文案。
- 已重新执行 `npm run build:h5` 并使用 `-IncludePublicBuilds` 发布到测试服,确认远端 `public/mobile` 新产物包含恢复逻辑与“错误详情”文案。
涉及模块:
- `uniapp/src/pages/ai_assistant/ai_assistant.vue`
- `qa/backend/test_ai_assistant_frontend_error_static.php`
- `server/public/mobile`
验证记录:
- 已执行 `php qa/backend/test_ai_assistant_frontend_error_static.php`,通过。
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
- 已请求测试服 `/api/assistant/chat``马刺最近比赛` 返回 `code=1`
- 已发布测试服并执行 `php think clear`;远端构建产物可检索到“错误详情”和恢复逻辑。
### 56. AI助手上游502重试增强
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 排查用户端返回 `OpenAI: OpenAI 返回异常: 响应不是有效JSON(HTTP 502),已重试1次`,确认配置与 API Key 正常,`https://lingsuan.top/v1/responses` 最小请求可返回 200。
- 查询测试库 `la_ai_chat_message`,确认失败集中在 `马刺最近比赛` 等完整助手请求,模型为 `gpt-5.5`,错误为上游中转偶发 `HTTP 502 + 非 JSON`
- AI 助手模型调用单独提高瞬时故障重试能力:`retry_times=3``retry_sleep_ms=1200`,不影响其他 AI 分析链路的默认重试配置。
- 新增 QA 脚本检查 AI 助手默认重试参数,避免后续退回到只重试 1 次。
- 已发布 server 到测试服并执行 `php think clear`;复测 `马刺最近比赛``马刺 最近比赛``马刺最新的比赛` 均返回 `code=1`
涉及模块:
- `server/app/common/service/ai/AiAssistantService.php`
- `qa/backend/test_ai_assistant_retry_options.php`
- `la_ai_chat_message`
验证记录:
- 已执行 `php qa/backend/test_ai_assistant_retry_options.php`,通过。
- 已执行 `php qa/backend/test_deepseek_client_transient_retry.php`,通过。
- 已执行 `php -l server/app/common/service/ai/AiAssistantService.php``php -l server/app/common/service/ai/DeepSeekClient.php``php -l qa/backend/test_ai_assistant_retry_options.php`,语法检查通过。
### 55. 世界杯专题页赛程时间校正
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 修复 `/packages_match/pages/worldcup` 世界杯赛程时间显示偏差:源赛程时间戳比预期北京时间少 8 小时,页面增加世界杯专用时间规范化后再按北京时间展示。
- 统一修正赛程日期分组、赛程卡中间时间、对阵图时间和对阵图日期,避免出现 `19:00` 应为次日 `03:00` 的情况。
验证记录:
- 已用 `match_time=1781175600` 静态验证,页面新逻辑输出 `2026-06-12 03:00`
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
### 54. AI知识库Redis队列测试服启用与消费修复
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 在测试库执行并确认 `kb:enqueue-recent` 每分钟定时任务注册成功,补齐近期扫描索引:`idx_kb_recent_article``idx_kb_recent_match``idx_kb_recent_post``idx_kb_recent_draw``idx_kb_recent_ai_history`
- 发布 server 代码到测试服并执行 `php think clear`,解决测试服 `Command "kb:enqueue-recent" is not defined` 导致定时任务失败的问题。
- 同步远端 `docker/docker-compose.crawler.yml`,补充并启动 `sport-era-kb-worker` 服务;从应用 `.env` 安全同步 embedding key 到 `docker/.env.docker`
- 修复 PHP `KbService``source_updated_at/source_created_at` 直接 `(int)` 转换日期字符串导致写成 `2026` 的问题,统一解析为 Unix 时间戳。
- 修复 Python worker 对 DashScope embedding 单次批量超过 10 条时报错的问题,改为按 `EMBEDDING_BATCH_SIZE` 分批请求;`vector_norm` 写库前保留 8 位小数,减少 MySQL decimal 截断告警。
- 清理 Redis consumer group 中旧 worker 遗留 pending 消息,重新写回 Stream 后由新 worker 消费。
- 追加 worker Redis 读取超时保护,设置连接/读取超时与健康检查;遇到临时 `TimeoutError/ConnectionError` 时记录 warning 并继续轮询,避免容器因 Redis 短暂抖动重启。
验证记录:
- 已执行测试服 `php think kb:enqueue-recent --limit=10 --minutes=30`,成功写入 Redis Stream。
- Redis DB4 `ai:kb:sync:stream` 已存在,consumer group `ai_kb_workers` 已创建,`ai:kb:sync:dead` 死信长度为 0。
- `sport-era-kb-worker` 已启动并持续输出 `kb upsert ok` 日志,`la_ai_kb_document``la_ai_kb_chunk` 持续增量更新。
- 截至 2026-06-11 13:10 左右,Redis lag 已从 648 降至 398,今日赛事过期文档数从 27 降至 20,队列仍在继续追赶。
- 截至 2026-06-11 13:51 左右,Redis `pending=0``lag=0`、死信长度为 0,worker 持续消费并写入知识库。
### 53. AI知识库Redis队列与Python异步向量Worker
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 新增 `kb:enqueue-recent` 后端命令,每分钟扫描最近 30 分钟更新的资讯、加密资讯、彩票开奖结果、彩票 AI 历史、赛事和社区帖子,并写入 Redis Stream `ai:kb:sync:stream`
- 新增 `KbRedisQueueService`,按源表更新时间筛选缺失、失效或过期的知识库文档,入队前使用 `ai:kb:sync:dedupe:{domain}:{subtype}:{source_id}` 做 30 分钟去重。
- 新增 Python 常驻 worker `scripts/kb_worker.py`,通过 Redis consumer group `ai_kb_workers` 消费队列,直接写入 `la_ai_kb_document``la_ai_kb_chunk`;成功 `XACK`,失败重试,超过阈值写入 `ai:kb:sync:dead` 死信队列。
- `docker/docker-compose.crawler.yml` 新增 `kb-worker` 服务,复用 crawler 镜像并支持 `EMBEDDING_API_KEY/BASE_URL/MODEL/DIM` 环境变量;crawler entrypoint 支持传入命令时直接执行,避免 worker 被固定 cron entrypoint 拦截。
- 新增幂等 SQL 注册每分钟 `kb:enqueue-recent` 定时任务,并为近实时扫描补充 `update_time` 相关索引 SQL。
- 新增 QA 静态检查和 Python worker 队列行为单测,覆盖 Redis Stream 名称、去重、五类扫描、consumer group、ack、死信和 Docker worker 配置。
涉及模块:
- `server/app/common/command/KbEnqueueRecent.php`
- `server/app/common/service/ai/KbRedisQueueService.php`
- `server/public/dongqiudi-crawler/scripts/kb_worker.py`
- `server/public/dongqiudi-crawler/docker/entrypoint.sh`
- `docker/docker-compose.crawler.yml`
- `docker/.env.docker.example`
- `docs/sql/insert_ai_kb_enqueue_recent_crontab.sql`
- `docs/sql/add_ai_kb_recent_source_indexes.sql`
- `qa/backend/test_kb_redis_enqueue_static.php`
- `qa/backend/test_kb_worker_static.py`
- `server/public/dongqiudi-crawler/tests/test_kb_worker.py`
- `la_ai_kb_document`
- `la_ai_kb_chunk`
- Redis Stream `ai:kb:sync:stream`
验证记录:
- 已执行 `php qa/backend/test_kb_redis_enqueue_static.php`,静态检查通过。
- 已执行 `C:\Python311\python.exe qa/backend/test_kb_worker_static.py`,静态检查通过。
- 已执行 `C:\Python311\python.exe -m unittest discover -s server/public/dongqiudi-crawler/tests -p test_kb_worker.py`worker 队列行为单测通过。
- 已执行 `C:\Python311\python.exe -m py_compile server/public/dongqiudi-crawler/scripts/kb_worker.py server/public/dongqiudi-crawler/tests/test_kb_worker.py qa/backend/test_kb_worker_static.py`Python 语法检查通过。
- 已执行 `php -l server/app/common/service/ai/KbRedisQueueService.php``php -l server/app/common/command/KbEnqueueRecent.php``php -l qa/backend/test_kb_redis_enqueue_static.php`PHP 语法检查通过。
- 本机未安装 Docker CLI,无法执行 `docker compose config`;本机 PHP CLI 仍因 Redis 驱动缺失报 `not support: redis`,需在测试服执行 SQL、启动 `kb-worker` 并观察 Redis Stream 与知识库表增量。
### 52. AI知识库资讯与赛事向量同步链路修复
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 核查测试库 AI 知识库状态,确认 `la_ai_kb_document``la_ai_kb_chunk` 当前无有效记录;`la_article` 可见资讯 38121 条全部缺少 article 向量文档,最近 24 小时可见资讯 999 条全部缺少向量文档;2026-06-11 当天赛事 36 场全部缺少 match/event 向量文档。
- 确认测试库 `la_dev_crontab` 原本没有 `kb:consume` 定时任务,`la_ai_kb_sync_job` 只有 article/post 待处理任务且长期未消费;爬虫直接写入 `la_article``la_match`,不会主动调用 PHP `KbSyncService::enqueue()`
- `KbSyncService` 新增资讯回填,并将回填逻辑从“只补缺失文档”升级为“补缺失或源数据更新时间晚于向量文档”的未同步数据;赛事回填优先级调高,避免今天赛事被历史资讯 backlog 压住。
- `kb:consume` 每次运行前自动补标赛事、资讯和彩票缺失/过期向量任务;`kb:rebuild` 后按重建域补标残留缺失/过期任务。
- 管理端知识库重建域校验补充 `match`,使后台可触发赛事知识库重建。
- 新增幂等 SQL `docs/sql/insert_ai_kb_consume_crontab.sql`,已在测试库执行并确认只生成 1 条启用的 `kb:consume` 定时任务,表达式为 `*/5 * * * *`,参数为 `--batch=100`
- 新增 QA 静态回归脚本,检查资讯/赛事未同步回填、消费命令补标、重建补标、管理端 match 域校验和定时任务 SQL。
涉及模块:
- `server/app/common/service/ai/KbSyncService.php`
- `server/app/common/command/KbConsume.php`
- `server/app/common/command/KbRebuild.php`
- `server/app/adminapi/validate/ai/KbSyncJobValidate.php`
- `docs/sql/insert_ai_kb_consume_crontab.sql`
- `qa/backend/test_kb_sync_pipeline_static.php`
- `la_ai_kb_document`
- `la_ai_kb_chunk`
- `la_ai_kb_sync_job`
- `la_dev_crontab`
验证记录:
- 已用 MySQL 查询测试库,确认修复前 article/match 向量覆盖率均为 0,且无 `kb:consume` 定时任务。
- 已执行 `docs/sql/insert_ai_kb_consume_crontab.sql`,重复执行后确认 `kb:consume` 仍只有 1 条启用记录,SQL 幂等。
- 已执行 `php qa/backend/test_kb_sync_pipeline_static.php`,回归检查通过。
- 已执行修改过的 PHP 文件语法检查,均通过。
- 本地 Windows CLI 执行 `php think kb:consume --batch=5` 时被当前 PHP 环境 Redis 驱动拦截,报 `not support: redis`,未进入业务消费逻辑;需由测试服 PHP 环境实际定时消费后再观察 `la_ai_kb_document` 增量。
### 51. 资讯卡片底部信息布局优化
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 调整 `feed-card__footer` 布局,将作者信息固定在左侧,阅读量和发布时间组合固定在右侧展示。
- 右侧阅读量与时间改为独立信息组,避免与作者等分宽度导致视觉分散;作者名称超长时在左侧省略,不挤压右侧信息。
涉及模块:
- `uniapp/src/components/feed-card/feed-card.vue`
验证记录:
- 已执行 `npm run build:h5`H5 构建通过。
### 50. AI 助手快捷问题单行省略
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 修复 `/pages/ai_assistant/ai_assistant``.quick-list__item` 快捷问题过长时会分行显示的问题。
- 快捷问题文本增加独立 `.quick-list__text` 包裹,容器与文本均设置单行、省略号和溢出隐藏,移除导致强制换行的 `word-break: break-all`
涉及模块:
- `uniapp/src/pages/ai_assistant/ai_assistant.vue`
验证记录:
- 已先执行静态检查,修改前确认缺少单行省略样式会失败。
- 修改后静态检查通过,确认存在 `quick-list__text``white-space: nowrap``text-overflow: ellipsis`
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
### 49. 部署脚本发布前自动提交 server 变更
- 状态:已完成 - 时间:2026-06-11
完成内容:
- `scripts/deploy-server.ps1` 新增发布前 `server` 工作区检查,在打包和上传前执行 `git status --porcelain -- server`
- 发现 `server` 下存在未提交、删除或未跟踪文件时,脚本会先 `git add -A -- server` 并自动提交,然后再继续计算增量范围。
- 新增 `-AutoCommitMessage` 参数,可自定义自动提交说明;默认提交说明为 `deploy: auto commit server changes <时间>`
- 若 `server` 下存在合并冲突,脚本会提示先手动解决后再发布。
- README 已补充说明:包含 `-DryRun` 在内,发布脚本都会先自动提交 `server` 变更。
涉及模块:
- `scripts/deploy-server.ps1`
- `README.md`
验证记录:
- 已使用临时 Git 仓库验证:`server` 下修改和新增文件会被自动提交,随后继续自动增量 dry-run;仓库中 README/admin 的未提交变更不会被自动提交。
- 已使用临时 Git 仓库验证:`server` 下删除文件会被自动提交,随后进入增量删除清单。
- 已执行 PowerShell Parser 检查,`scripts/deploy-server.ps1` 语法解析通过。
- 已执行 `git diff --check -- scripts/deploy-server.ps1`,未发现空白错误。
### 48. AI 助手对话页跨端样式优化
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 优化 `/pages/ai_assistant/ai_assistant` 移动端聊天视觉:导航、欢迎卡片、快捷问题、AI/用户消息气泡、资料来源卡片和底部输入栏统一为更清爽的对话界面。
- AI 消息补充头像标识与加载状态脉冲条,长文本气泡提升行高和换行稳定性,来源卡片增加左侧强调线与绿色来源标签,便于阅读和点击。
- 底部输入区拆分为 `input-panel + input-bar`,补充 iOS/Android/H5 安全区、`disable-default-padding``cursor-spacing` 和隐藏 iOS confirm bar,避免输入栏贴边、默认内边距和键盘遮挡问题。
- 输入框固定为与发送按钮同高,去除 `auto-height`,输入内容增减时不再撑开底部输入栏,多行内容在输入框内部滚动。
- 修复 H5 下 `constant(safe-area-inset-bottom)` 写在 `padding` 简写中导致整条 padding 失效的问题,改为独立声明 top/left/right/bottom。
涉及模块:
- `uniapp/src/pages/ai_assistant/ai_assistant.vue`
验证记录:
- 已执行 UI 特征检查,确认安全高度、textarea 跨端属性、AI 头像、输入安全区容器和来源强调线均存在。
- 已执行 `npm run build:h5`H5 构建通过并复制到 `server/public/mobile`
- 已通过 Edge Headless DevTools 以 430×932 移动视口检查目标页,确认路由加载为 `pages/ai_assistant/ai_assistant`、页面宽度 430、无横向溢出,输入栏左右 padding 与底部安全区生效。
### 47. AI 助手 OpenAI 502 瞬时错误重试
- 状态:已完成 - 时间:2026-06-11
完成内容:
- 排查 `/api/assistant/chat` 请求 `NBA 最近比赛近况` 返回 `OpenAI: OpenAI 返回异常: 响应不是有效JSON(HTTP 502)`,确认根因是上游 OpenAI 中转偶发返回 502 且响应体不是 JSON,`DeepSeekClient` 原逻辑会立即失败并透传错误。
- `DeepSeekClient` 新增可覆盖的 HTTP 发送方法,并对 cURL 错误、HTTP 429、HTTP 5xx 做有限重试,默认重试 1 次,可通过 `ai_request_retry_times``ai_request_retry_sleep_ms` 配置调整。
- 失败结果补充“已重试N次”提示,便于后续从 AI 助手错误消息和日志判断是否为持续性上游故障。
- 新增 QA 回归脚本,模拟第一次 `HTTP 502 + 非 JSON`、第二次正常 Responses JSON,确认瞬时故障可自动恢复。
涉及模块:
- `server/app/common/service/ai/DeepSeekClient.php`
- `qa/backend/test_deepseek_client_transient_retry.php`
验证记录:
- 已执行 `php qa/backend/test_deepseek_client_transient_retry.php`,回归测试通过。
- 已执行 `php qa/backend/test_deepseek_client_invalid_response.php`,原有非 JSON/null 防护测试通过。
- 已执行 `php -l server/app/common/service/ai/DeepSeekClient.php``php -l qa/backend/test_deepseek_client_transient_retry.php`,语法检查通过。
### 46. 部署脚本自动增量支持自定义提交数
- 状态:已完成 - 时间:2026-06-11
完成内容:
- `scripts/deploy-server.ps1` 新增 `-AutoIncrementalCommits <n>` 参数,配合 `-AutoIncremental` 使用时可自动对比 `HEAD~n``HEAD`,默认仍为最近 1 个提交。
- 自动增量日志补充 `auto incremental commits` 输出,部署元信息补充 `auto_incremental_commits`,便于 dry-run 和真实发布时确认范围。
- README 自动增量发布说明已同步新增最近 N 个提交的发布示例。
涉及模块:
- `scripts/deploy-server.ps1`
- `README.md`
验证记录:
- 已先执行 `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -AutoIncrementalCommits 2 -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun`,修改前因参数不存在失败,确认覆盖新行为。
- 修改后已执行自定义 2 个提交 dry-run,日志显示 `auto incremental commits: 2`,只打包不上传。
- 修改后已执行默认自动增量 dry-run,日志显示 `auto incremental commits: 1`,原命令兼容。
- 已验证单独传 `-AutoIncrementalCommits` 且不带 `-AutoIncremental` 会报错阻止误用。
### 45. AI助手请求失败提示补充真实错误 ### 45. AI助手请求失败提示补充真实错误
- 状态:已完成 - 时间:2026-06-11 - 状态:已完成 - 时间:2026-06-11