no message
This commit is contained in:
@@ -27,3 +27,4 @@ bin-release/
|
|||||||
/.code-graph/
|
/.code-graph/
|
||||||
**/.hbuilderx/
|
**/.hbuilderx/
|
||||||
**/unpackage/
|
**/unpackage/
|
||||||
|
qa/
|
||||||
|
|||||||
@@ -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 目录,保证可以继续发布。
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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()}日`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +56,18 @@
|
|||||||
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 v-if="item.role !== 'user'" class="message-row__avatar">AI</view>
|
||||||
|
<view class="message-row__body">
|
||||||
<view class="message-bubble">
|
<view class="message-bubble">
|
||||||
<view v-if="item.pending" class="message-bubble__pending">
|
<view v-if="item.pending" class="message-bubble__pending">
|
||||||
|
<view class="message-bubble__pulse" />
|
||||||
<text class="message-bubble__loading">正在整理站内资料...</text>
|
<text class="message-bubble__loading">正在整理站内资料...</text>
|
||||||
<text class="message-bubble__timer">分析用时 {{ analysisDurationText }}</text>
|
<text class="message-bubble__timer">分析用时 {{ analysisDurationText }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text v-else class="message-bubble__text">{{ item.content }}</text>
|
<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-if="item.sources && item.sources.length" class="source-list">
|
||||||
<view v-for="source in item.sources" :key="source.type + source.source_id + source.title"
|
<view v-for="source in item.sources" :key="source.type + source.source_id + source.title"
|
||||||
class="source-item" @tap="openSource(source)">
|
class="source-item" @tap="openSource(source)">
|
||||||
@@ -72,14 +78,17 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="input-panel">
|
||||||
<view class="input-bar">
|
<view class="input-bar">
|
||||||
<textarea
|
<input
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
class="input-bar__textarea"
|
class="input-bar__input"
|
||||||
auto-height
|
type="text"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
|
:cursor-spacing="18"
|
||||||
:disabled="sending"
|
:disabled="sending"
|
||||||
placeholder="问问世博头条里的内容"
|
placeholder="问问世博头条里的内容"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
@@ -90,6 +99,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const escapeAttribute = (value: string) => escapeHtml(value)
|
||||||
|
|
||||||
|
const parseLink = (value: string) => {
|
||||||
|
return value.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, text: string, url: string) => {
|
||||||
|
const safeUrl = escapeAttribute(url)
|
||||||
|
return `<a href="${safeUrl}">${text}</a>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseInlineMarkdown = (value: string) => {
|
||||||
|
let html = escapeHtml(value)
|
||||||
|
html = parseLink(html)
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
html = html.replace(/(^|[^\*])\*([^\*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderMarkdownToHtml = (value: string) => {
|
||||||
|
const source = String(value || '').replace(/\r\n?/g, '\n').trim()
|
||||||
|
if (!source) return ''
|
||||||
|
|
||||||
|
const lines = source.split('\n')
|
||||||
|
const htmlParts: string[] = []
|
||||||
|
const paragraphLines: string[] = []
|
||||||
|
const codeLines: string[] = []
|
||||||
|
const quoteLines: string[] = []
|
||||||
|
const tableLines: string[] = []
|
||||||
|
let listType: 'ul' | 'ol' | '' = ''
|
||||||
|
let inCodeBlock = false
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (!paragraphLines.length) return
|
||||||
|
htmlParts.push(`<p>${paragraphLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</p>`)
|
||||||
|
paragraphLines.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (!listType) return
|
||||||
|
htmlParts.push(`</${listType}>`)
|
||||||
|
listType = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushQuote = () => {
|
||||||
|
if (!quoteLines.length) return
|
||||||
|
htmlParts.push(
|
||||||
|
`<blockquote>${quoteLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</blockquote>`
|
||||||
|
)
|
||||||
|
quoteLines.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushCodeBlock = () => {
|
||||||
|
if (!codeLines.length) return
|
||||||
|
htmlParts.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||||
|
codeLines.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseTableCells = (line: string) => {
|
||||||
|
const cells = line
|
||||||
|
.trim()
|
||||||
|
.replace(/^\|/, '')
|
||||||
|
.replace(/\|$/, '')
|
||||||
|
.split('|')
|
||||||
|
.map((cell) => cell.trim())
|
||||||
|
return cells.filter((_cell, index) => index < cells.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTableRow = (line: string) => /^\|.*\|$/.test(line) && parseTableCells(line).length >= 2
|
||||||
|
|
||||||
|
const isTableSeparatorLine = (line: string) => {
|
||||||
|
if (!isTableRow(line)) return false
|
||||||
|
return parseTableCells(line).every((cell) => /^:?-{3,}:?$/.test(cell))
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushTable = () => {
|
||||||
|
if (!tableLines.length) return
|
||||||
|
|
||||||
|
if (tableLines.length >= 2 && isTableSeparatorLine(tableLines[1])) {
|
||||||
|
const headers = parseTableCells(tableLines[0])
|
||||||
|
const bodyRows = tableLines.slice(2).map(parseTableCells).filter((cells) => cells.length > 0)
|
||||||
|
const thead = `<thead><tr>${headers.map((cell) => `<th>${parseInlineMarkdown(cell)}</th>`).join('')}</tr></thead>`
|
||||||
|
const tbody = bodyRows.length
|
||||||
|
? `<tbody>${bodyRows
|
||||||
|
.map((cells) => `<tr>${cells.map((cell) => `<td>${parseInlineMarkdown(cell)}</td>`).join('')}</tr>`)
|
||||||
|
.join('')}</tbody>`
|
||||||
|
: ''
|
||||||
|
htmlParts.push(`<table>${thead}${tbody}</table>`)
|
||||||
|
} else {
|
||||||
|
htmlParts.push(`<p>${tableLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</p>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
tableLines.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
|
||||||
|
if (/^```/.test(line)) {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
flushQuote()
|
||||||
|
flushTable()
|
||||||
|
if (inCodeBlock) {
|
||||||
|
flushCodeBlock()
|
||||||
|
}
|
||||||
|
inCodeBlock = !inCodeBlock
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeLines.push(rawLine)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
flushQuote()
|
||||||
|
flushTable()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/)
|
||||||
|
if (headingMatch) {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
flushQuote()
|
||||||
|
flushTable()
|
||||||
|
const level = Math.min(6, headingMatch[1].length)
|
||||||
|
htmlParts.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteMatch = line.match(/^>\s?(.*)$/)
|
||||||
|
if (quoteMatch) {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
flushTable()
|
||||||
|
quoteLines.push(quoteMatch[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flushQuote()
|
||||||
|
|
||||||
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
flushTable()
|
||||||
|
htmlParts.push('<hr />')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const unorderedMatch = line.match(/^[-*+]\s+(.*)$/)
|
||||||
|
if (unorderedMatch) {
|
||||||
|
flushParagraph()
|
||||||
|
flushTable()
|
||||||
|
if (listType !== 'ul') {
|
||||||
|
closeList()
|
||||||
|
listType = 'ul'
|
||||||
|
htmlParts.push('<ul>')
|
||||||
|
}
|
||||||
|
htmlParts.push(`<li>${parseInlineMarkdown(unorderedMatch[1])}</li>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedMatch = line.match(/^\d+\.\s+(.*)$/)
|
||||||
|
if (orderedMatch) {
|
||||||
|
flushParagraph()
|
||||||
|
flushTable()
|
||||||
|
if (listType !== 'ol') {
|
||||||
|
closeList()
|
||||||
|
listType = 'ol'
|
||||||
|
htmlParts.push('<ol>')
|
||||||
|
}
|
||||||
|
htmlParts.push(`<li>${parseInlineMarkdown(orderedMatch[1])}</li>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTableRow(line)) {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
flushQuote()
|
||||||
|
tableLines.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flushTable()
|
||||||
|
closeList()
|
||||||
|
paragraphLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
flushQuote()
|
||||||
|
flushTable()
|
||||||
|
if (inCodeBlock) {
|
||||||
|
flushCodeBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlParts.join('')
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
type MatchLike = {
|
||||||
|
competition_id?: number | string
|
||||||
|
league_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORLD_CUP_COMPETITION_ID = 61
|
||||||
|
const WORLD_CUP_SOURCE_UTC_OFFSET_SECONDS = 8 * 60 * 60
|
||||||
|
|
||||||
|
export const isWorldCupMatch = (match?: MatchLike | null): boolean => {
|
||||||
|
if (!match) return false
|
||||||
|
return Number(match.competition_id || 0) === WORLD_CUP_COMPETITION_ID
|
||||||
|
|| String(match.league_name || '') === '世界杯'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveMatchTimestamp = (timestamp: number | string | null | undefined, match?: MatchLike | null): number => {
|
||||||
|
const seconds = Number(timestamp || 0)
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return 0
|
||||||
|
|
||||||
|
// World Cup source fields are UTC clock values saved as server-local Unix seconds.
|
||||||
|
if (isWorldCupMatch(match)) return seconds + WORLD_CUP_SOURCE_UTC_OFFSET_SECONDS
|
||||||
|
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLocalMatchDate = (timestamp: number | string | null | undefined, match?: MatchLike | null): Date | null => {
|
||||||
|
const seconds = resolveMatchTimestamp(timestamp, match)
|
||||||
|
if (!seconds) return null
|
||||||
|
return new Date(seconds * 1000)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user