feat(小宝): 优化助手入口与预警已读
关键改动: - 更新 README 项目说明和小宝相关入口命名 - 优化问翻小宝页面与侧边栏测试 - 小宝预警已读状态抑制同签名重复 AI 请求 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
319
README.md
319
README.md
@@ -1,136 +1,241 @@
|
||||
# FTB 项目管理系统
|
||||
# FTB 智能项目管理系统
|
||||
|
||||
基于 Next.js + NestJS 的项目管理系统,包含产品管理、项目管理、版本管理、需求管理、加班记录、成员/角色管理等模块。
|
||||
FTB 是一个面向中小型研发团队的智能项目管理平台,核心目标是帮助产品/项目经理在一个页面判断:**开发完了吗 → 验收过了吗 → 还有多少 Bug → 能不能发布**。
|
||||
|
||||
系统核心层级:
|
||||
|
||||
```text
|
||||
产品 Product → 项目 Project → 版本 Version → 需求 / 开发任务 / 测试用例 / Bug
|
||||
```
|
||||
|
||||
当前项目已经进入 **V2.1 服务端持久化阶段**:前端仍保留现有 Zustand store 数据形状,但业务主数据已切到 NestJS + PostgreSQL `app_data` 文档表,浏览器只保留登录会话。
|
||||
|
||||
## 开始前必读
|
||||
|
||||
新会话或新开发任务开始前,先读这 4 份项目文档,避免重复讨论已确定方案:
|
||||
|
||||
- `docs/architecture.md`:整体架构、心智模型、关键设计原则
|
||||
- `docs/decisions.md`:关键设计决策记录,以及为什么这么做
|
||||
- `docs/workflow.md`:工作流程、协作偏好、命名规范
|
||||
- `docs/roadmap.md`:V1/V2/V3 路线图和已完成清单
|
||||
|
||||
## 核心心智模型
|
||||
|
||||
FTB 把需求、执行和质量三条线分开:
|
||||
|
||||
| 维度 | 核心实体 | 说明 |
|
||||
|------|----------|------|
|
||||
| 语义线 Why | Requirement | 解释为什么做、功能范围是什么,不直接驱动流程 |
|
||||
| 执行线 How | Version / DevTask / VersionPlan | 版本是执行主线,任务和计划归属版本 |
|
||||
| 质量线 Quality | TestCase / Bug | 测试用例和 Bug 挂在版本上形成验收闭环 |
|
||||
|
||||
关键原则:
|
||||
|
||||
- 进度由状态和估时派生,不手填百分比。
|
||||
- 跨模块联动放在纯函数引擎层,例如 `linkage-engine.ts`、`workspace-engine.ts`。
|
||||
- DevTask 的 `submitted` 是开发终态,后续验收失败通过 Bug 流转。
|
||||
- TestCase 和 Bug 主归属版本,需求只是可选语义标签。
|
||||
- AI 只能写入带 `aiDraft: true` 的草案,用户编辑后才转正。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端 | Next.js 14 + React 18 + TypeScript | App Router,管理后台以客户端组件为主 |
|
||||
| UI | Tailwind CSS + Shadcn/ui 风格组件 | 紧凑信息密度,适合项目管理场景 |
|
||||
| 状态 | Zustand | 每个业务域一个 store |
|
||||
| 后端 | NestJS + Prisma | REST API、AppData 文档层、AI 配置和 AI 网关 |
|
||||
| 数据库 | PostgreSQL | 当前主用 `app_data` JSONB,后续逐步关系化 |
|
||||
| 缓存/实时预留 | Redis | 本地通过 Docker Compose 启动 |
|
||||
| AI | Anthropic / OpenAI 格式 Provider | 支持官方和中转站配置 |
|
||||
| 工程化 | pnpm workspace + Turborepo | Monorepo 任务编排 |
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 工具 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Node.js | >= 18.x | 推荐 20.x LTS |
|
||||
| pnpm | >= 9.4.0 | 包管理器 |
|
||||
| Git | >= 2.x | 版本控制 |
|
||||
|
||||
> 后端(可选)还需要 PostgreSQL >= 15 和 Redis >= 7,目前前端独立运行不依赖后端。
|
||||
|
||||
## 安装 Node.js 和 pnpm
|
||||
|
||||
```bash
|
||||
# Windows - 下载安装包
|
||||
# https://nodejs.org/en/download
|
||||
|
||||
# 安装 pnpm
|
||||
npm install -g pnpm@9.4.0
|
||||
```
|
||||
| Node.js | >= 18.x | 推荐 20.x LTS,后端会优先使用 Node 20 的 `.env` 加载能力 |
|
||||
| pnpm | 9.4.0 | 仓库锁定的包管理器版本 |
|
||||
| Docker | 可选 | 用于本地启动 PostgreSQL 和 Redis |
|
||||
| PostgreSQL | >= 15 | Docker Compose 默认使用 PostgreSQL 16 |
|
||||
| Redis | >= 7 | 预留给缓存和实时能力 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/chengnianbai/ftb-project-management.git
|
||||
cd ftb-project-management
|
||||
|
||||
# 2. 安装依赖
|
||||
# 1. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 3. 启动前端开发服务器
|
||||
cd apps/web
|
||||
npx next dev -p 3000
|
||||
# 2. 准备后端环境变量
|
||||
cp apps/server/.env.example apps/server/.env
|
||||
|
||||
# 4. 打开浏览器
|
||||
# http://localhost:3000
|
||||
# 3. 启动 PostgreSQL + Redis
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 创建/同步数据库结构
|
||||
pnpm db:migrate
|
||||
|
||||
# 5. 启动前端 + 后端
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
启动后访问:
|
||||
|
||||
- 前端:`http://localhost:3000`
|
||||
- 后端:`http://localhost:3001/api/v1`
|
||||
- AI 配置探测端点:`http://localhost:3001/api/v1/config/ai`
|
||||
|
||||
如果只想单独启动某一端:
|
||||
|
||||
```bash
|
||||
pnpm dev --filter=web
|
||||
pnpm dev --filter=server
|
||||
```
|
||||
ftb-project-management/
|
||||
├── apps/
|
||||
│ ├── web/ # 前端 (Next.js 14 + React 18)
|
||||
│ │ ├── app/ # 页面路由
|
||||
│ │ │ ├── products/ # 产品管理
|
||||
│ │ │ ├── projects/ # 项目管理
|
||||
│ │ │ ├── versions/ # 版本管理
|
||||
│ │ │ ├── requirements/ # 需求管理
|
||||
│ │ │ ├── overtime/ # 加班记录
|
||||
│ │ │ └── admin/ # 管理(成员/角色)
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── stores/ # Zustand 状态管理
|
||||
│ │ └── lib/ # 类型定义/工具函数
|
||||
│ └── server/ # 后端 (NestJS 10, 暂未启用)
|
||||
│ ├── src/
|
||||
│ └── prisma/ # 数据库 Schema
|
||||
├── packages/
|
||||
│ └── shared/ # 共享类型
|
||||
├── package.json # Monorepo 根配置
|
||||
├── pnpm-workspace.yaml # pnpm workspace
|
||||
└── turbo.json # Turborepo 配置
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
**前端**
|
||||
- Next.js 14 (App Router)
|
||||
- React 18
|
||||
- TypeScript 5.5
|
||||
- Tailwind CSS
|
||||
- Zustand (状态管理)
|
||||
- Lucide React (图标)
|
||||
|
||||
**后端(暂未启用)**
|
||||
- NestJS 10
|
||||
- Prisma (ORM)
|
||||
- PostgreSQL
|
||||
|
||||
**工程化**
|
||||
- pnpm workspace (Monorepo)
|
||||
- Turborepo (任务编排)
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 路径 | 状态 |
|
||||
|------|------|------|
|
||||
| 产品管理 | /products | 已完成 |
|
||||
| 项目管理 | /projects | 已完成 |
|
||||
| 版本管理 | /versions | 已完成 |
|
||||
| 需求管理 | /requirements | 已完成 |
|
||||
| 加班记录 | /overtime | 已完成 |
|
||||
| 成员管理 | /admin/members | 已完成 |
|
||||
| 角色管理 | /admin/roles | 已完成 |
|
||||
|
||||
## 数据存储
|
||||
|
||||
当前阶段所有数据存储在浏览器 localStorage 中(Mock 模式),无需数据库即可体验完整功能。后续接入后端 API 后切换为数据库持久化。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 前端开发
|
||||
cd apps/web && npx next dev -p 3000
|
||||
# 全量开发
|
||||
pnpm dev
|
||||
|
||||
# TypeScript 类型检查
|
||||
cd apps/web && npx tsc --noEmit
|
||||
|
||||
# 全量构建
|
||||
# 构建
|
||||
pnpm build
|
||||
|
||||
# 类型检查
|
||||
pnpm type-check
|
||||
|
||||
# 测试
|
||||
pnpm test
|
||||
|
||||
# ESLint
|
||||
pnpm lint
|
||||
|
||||
# Prisma
|
||||
pnpm db:migrate
|
||||
pnpm db:seed
|
||||
pnpm db:studio
|
||||
|
||||
# 本地基础服务
|
||||
docker-compose up -d
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 后端启动(可选)
|
||||
## 项目结构
|
||||
|
||||
如果需要启动后端 API:
|
||||
```text
|
||||
ftb-project-management/
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js 前端
|
||||
│ │ ├── app/ # 页面路由
|
||||
│ │ │ ├── products/ # 产品列表和详情
|
||||
│ │ │ ├── projects/ # 项目列表和详情
|
||||
│ │ │ ├── versions/ # 版本列表和详情
|
||||
│ │ │ ├── requirements/ # 需求池
|
||||
│ │ │ ├── workspace/ # 与我相关工作台
|
||||
│ │ │ ├── xiaobao-warning/ # 小宝预警
|
||||
│ │ │ └── admin/ # 成员、角色、任务类型、AI 配置
|
||||
│ │ ├── components/ # 业务组件和通用组件
|
||||
│ │ ├── lib/ # 规则引擎、派生计算、API 封装
|
||||
│ │ └── stores/ # Zustand stores
|
||||
│ └── server/ # NestJS 后端
|
||||
│ ├── src/modules/data/ # AppData 文档层:GET/PUT /api/v1/data/:key
|
||||
│ ├── src/modules/ai/ # AI 网关、拆解、风险解读
|
||||
│ ├── src/modules/config/ # AI Provider 配置
|
||||
│ └── prisma/ # Prisma Schema
|
||||
├── packages/
|
||||
│ └── shared/ # 前后端共享类型
|
||||
├── docs/ # 架构、决策、流程、路线图
|
||||
├── docker-compose.yml # PostgreSQL + Redis
|
||||
├── pnpm-workspace.yaml
|
||||
└── turbo.json
|
||||
```
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 路径 | 当前状态 |
|
||||
|------|------|----------|
|
||||
| 产品管理 | `/products` | 已实现 |
|
||||
| 项目管理 | `/projects` | 已实现,项目详情联动版本状态 |
|
||||
| 版本管理 | `/versions` | 已实现,承载计划、开发、测试、Bug 和发布风险 |
|
||||
| 需求池 | `/requirements` | 已实现,需求作为语义层和版本可选分组 |
|
||||
| 与我相关 | `/workspace` | 已实现,聚合个人计划、任务、测试、Bug、日报 |
|
||||
| 小宝预警 | `/xiaobao-warning` | 已实现首版,规则优先、AI 只做解释 |
|
||||
| 加班记录 | `/overtime` | 已实现 |
|
||||
| 成员/角色 | `/admin/members`、`/admin/roles` | 已实现 |
|
||||
| 任务类型 | `/admin/categories` | 已实现,使用稳定语义码对接 AI |
|
||||
| AI 配置 | `/admin/ai-config` | 已实现,支持多 Provider 配置 |
|
||||
|
||||
## 数据持久化
|
||||
|
||||
当前阶段使用服务端通用文档层:
|
||||
|
||||
- 后端接口:`GET/PUT /api/v1/data/:key`
|
||||
- 数据库表:Prisma `AppData`,映射到 PostgreSQL `app_data`
|
||||
- 前端读写:`apps/web/lib/server-data.ts`
|
||||
- 浏览器存储:只保留登录态,不作为业务数据主存储
|
||||
|
||||
已纳入 AppData 的 key:
|
||||
|
||||
```text
|
||||
products-overview
|
||||
requirements
|
||||
version-plans
|
||||
dev-tasks
|
||||
test-cases
|
||||
bugs
|
||||
members
|
||||
task-categories
|
||||
task-worklogs
|
||||
work-activities
|
||||
xiaobao-risk-insights
|
||||
xiaobao-risk-snapshots
|
||||
xiaobao-warning-views
|
||||
overtime
|
||||
```
|
||||
|
||||
后续 V2.2 会把稳定的数据形状逐步拆成关系表和领域 CRUD API。拆表前不要恢复业务 localStorage 缓存,避免多端数据分叉。
|
||||
|
||||
## AI 与小宝预警
|
||||
|
||||
AI 能力当前以 NestJS 后端为入口:
|
||||
|
||||
- AI Provider 配置在 `/admin/ai-config` 管理,服务端落盘到 `apps/server/data/ai-config.json`,环境变量 `ANTHROPIC_API_KEY` 作为兜底。
|
||||
- Prototype Decompose Agent 面向“产品方案原型 → 开发任务 / 测试用例草案”场景,所有写入必须带 `aiDraft: true` 和 `references[]`。
|
||||
- 小宝预警先由规则引擎计算风险分、趋势、静默风险、预测发版日和置信度;AI 只解释规则结果,不改写业务数据。
|
||||
|
||||
相关实现入口:
|
||||
|
||||
- `apps/server/src/modules/ai/`
|
||||
- `apps/web/lib/ai-decompose-*`
|
||||
- `apps/web/lib/xiaobao-risk-*`
|
||||
- `apps/web/components/version/AiDecomposeButton.tsx`
|
||||
- `apps/web/components/xiaobao-warning/`
|
||||
|
||||
## 开发约定
|
||||
|
||||
- 新增跨模块联动时,优先放到 `*-engine.ts` 或纯函数 helper,不在页面组件里散写规则。
|
||||
- 新增状态流转或完成条件时,优先建立 workflow helper。
|
||||
- 新增 AppData key 时,同时更新后端 `data-keys.ts` 和前端 `server-data.ts`。
|
||||
- 时间字段使用 ISO 时间戳;展示时统一格式化。
|
||||
- Commit 使用中文,格式:`类型(模块): 描述`,例如 `feat(版本): 增加小宝预警入口`。
|
||||
- 用户没有明确要求时不要自动 push。
|
||||
|
||||
## 验证建议
|
||||
|
||||
改动后至少运行:
|
||||
|
||||
```bash
|
||||
# 1. 安装 PostgreSQL 并创建数据库
|
||||
createdb ftb_pm
|
||||
|
||||
# 2. 配置环境变量
|
||||
cd apps/server
|
||||
cp .env.example .env
|
||||
# 编辑 .env 设置 DATABASE_URL
|
||||
|
||||
# 3. 初始化数据库
|
||||
npx prisma migrate dev
|
||||
|
||||
# 4. 启动
|
||||
npm run dev
|
||||
# 后端运行在 http://localhost:3001
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
涉及服务端数据持久化时额外运行:
|
||||
|
||||
```bash
|
||||
pnpm --filter server exec prisma validate --schema prisma/schema.prisma
|
||||
```
|
||||
|
||||
涉及 UI 页面时,确保对应页面能返回 200,例如:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/workspace
|
||||
```
|
||||
|
||||
@@ -62,7 +62,7 @@ const INITIAL_MESSAGES: ChatMessage[] = [
|
||||
role: 'assistant',
|
||||
type: 'intro',
|
||||
content:
|
||||
'我是问翻小宝,第一阶段只从内置帮助中心回答系统怎么用,不调用 AI,也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。',
|
||||
'第一阶段只从内置帮助中心回答系统怎么用,不调用 AI,也不消耗模型 token。你可以问产品、项目、版本、需求池、开发任务、测试用例、Bug 和日志记录。',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -337,7 +337,6 @@ function MessageRow({
|
||||
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p className="mb-1 text-[13px] font-semibold text-[#171717]">问翻小宝</p>
|
||||
{message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>}
|
||||
{message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
|
||||
{message.type === 'fallback' && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
filterXiaobaoRiskWarnings,
|
||||
formatRemainingWork,
|
||||
isXiaobaoWarningUpdated,
|
||||
shouldSkipXiaobaoRiskInsightRequestForReadState,
|
||||
sanitizeRiskInsight,
|
||||
} from '@/lib/xiaobao-warning-view';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
@@ -90,6 +91,7 @@ function XiaobaoWarningContent() {
|
||||
|
||||
useEffect(() => {
|
||||
risks.forEach((risk) => {
|
||||
if (shouldSkipXiaobaoRiskInsightRequestForReadState(risk, readStates, user?.id, readStateLoaded)) return;
|
||||
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
||||
const key = buildXiaobaoRiskInsightPendingKey(risk);
|
||||
if (!shouldRequestRiskInsightWithRequestGate({
|
||||
@@ -123,11 +125,14 @@ function XiaobaoWarningContent() {
|
||||
insights,
|
||||
insightRequestAttempts,
|
||||
pendingInsightKeys,
|
||||
readStateLoaded,
|
||||
readStates,
|
||||
riskDataLoaded,
|
||||
risks,
|
||||
saveInsight,
|
||||
snapshots,
|
||||
today,
|
||||
user?.id,
|
||||
]);
|
||||
|
||||
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
|
||||
|
||||
@@ -95,45 +95,45 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
|
||||
<div className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg)]" onClick={(e) => e.stopPropagation()}>
|
||||
{contextLabel && (
|
||||
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{bug.bugNo}</span>
|
||||
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{bug.title}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 pr-3">
|
||||
<span className="shrink-0 text-[12px] font-mono text-[var(--ink-muted)]">{bug.bugNo}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold text-[var(--ink)]">{bug.title}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||
<button onClick={onClose} className="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-5">
|
||||
{/* 关联链路 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 space-y-2">
|
||||
<div className="space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">关联链路</div>
|
||||
{tc && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link2 className="h-3.5 w-3.5 text-[var(--accent)]" />
|
||||
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
|
||||
<span className="text-[13px] text-[var(--ink)]">{tc.title}</span>
|
||||
<span className="min-w-0 truncate text-[13px] text-[var(--ink)]">{tc.title}</span>
|
||||
</div>
|
||||
)}
|
||||
{requirement && (
|
||||
<div className="flex items-center gap-2 pl-5">
|
||||
<div className="flex min-w-0 items-center gap-2 pl-5">
|
||||
<ChevronRight className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">{requirement.title}</span>
|
||||
<span className="min-w-0 truncate text-[12px] text-[var(--ink-soft)]">{requirement.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态 & 操作 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||
<div className="space-y-4 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BugStatusBadge status={bug.status} />
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
|
||||
@@ -189,9 +189,9 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||
<div className="grid grid-cols-1 gap-2 text-[12px] sm:grid-cols-2 sm:gap-x-4 [&>div]:min-w-0 [&>div]:rounded-md [&>div]:bg-[var(--bg-subtle)] [&>div]:px-3 [&>div]:py-2 [&>div]:leading-5">
|
||||
<div><span className="text-[var(--ink-muted)]">计划修复:</span><span className="text-[var(--ink)] font-medium">{bug.plannedFixAt ? formatDateTime(bug.plannedFixAt) : '待排期'}</span></div>
|
||||
<div><span className="text-[var(--ink-muted)]">提交人:</span><span className="text-[var(--ink)]">{reporterName}</span></div>
|
||||
<div><span className="text-[var(--ink-muted)]">修复人:</span><span className="text-[var(--ink)] font-medium">{assigneeName}</span></div>
|
||||
@@ -202,14 +202,14 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">Bug 描述</div>
|
||||
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed whitespace-pre-wrap">{bug.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 截图 */}
|
||||
{bug.images && bug.images.length > 0 && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">截图附件</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bug.images.map((src, i) => (
|
||||
@@ -221,7 +221,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false
|
||||
|
||||
{/* 修复说明 */}
|
||||
{bug.resolution && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div className="text-[10px] text-emerald-700 uppercase tracking-wide mb-2">修复说明</div>
|
||||
<p className="text-[12px] text-emerald-800 leading-relaxed whitespace-pre-wrap">{bug.resolution}</p>
|
||||
</div>
|
||||
|
||||
@@ -196,19 +196,19 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
|
||||
<div className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg)]" onClick={(e) => e.stopPropagation()}>
|
||||
{contextLabel && (
|
||||
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
|
||||
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{task.title}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 pr-3">
|
||||
<span className="shrink-0 text-[12px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold text-[var(--ink)]">{task.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!readOnly && task.status !== 'submitted' && (
|
||||
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
|
||||
)}
|
||||
@@ -233,22 +233,22 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-5">
|
||||
|
||||
{requirement && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5">关联需求</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link2 className="h-3.5 w-3.5 text-[var(--accent)]" />
|
||||
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
|
||||
<span className="text-[13px] text-[var(--ink)]">{requirement.title}</span>
|
||||
<span className="min-w-0 truncate text-[13px] text-[var(--ink)]">{requirement.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||
<div className="space-y-4 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-lg px-3 py-1.5 text-[13px] font-semibold ${DEV_TASK_STATUS_COLOR[task.status]}`}>
|
||||
{DEV_TASK_STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
@@ -295,7 +295,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
<div className="text-[11px] font-medium text-orange-700">
|
||||
{needsClaim ? '领取并填写计划' : '填写计划'}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] text-orange-700">预计开始</label>
|
||||
<WorkDateTimePicker
|
||||
@@ -368,7 +368,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
</div>
|
||||
|
||||
{task.status !== 'todo' && task.status !== 'submitted' && !readOnly && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||
<div className="space-y-4 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">今日进展</div>
|
||||
<textarea
|
||||
value={progressNote}
|
||||
@@ -377,7 +377,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[12px] leading-5 text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
value={progressBlocker}
|
||||
onChange={(e) => setProgressBlocker(e.target.value)}
|
||||
@@ -425,9 +425,9 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3 flex items-center gap-1.5"><CalendarRange className="h-3 w-3" />时间信息</div>
|
||||
<div className="grid grid-cols-2 gap-y-2.5 gap-x-4 text-[12px]">
|
||||
<div className="grid grid-cols-1 gap-2 text-[12px] sm:grid-cols-2 sm:gap-x-4 [&>div]:min-w-0 [&>div]:rounded-md [&>div]:bg-[var(--bg-subtle)] [&>div]:px-3 [&>div]:py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">预计开始</span>
|
||||
<span className="text-[var(--ink)] font-medium tabular-nums">{task.expectedStartAt ? formatShortTime(task.expectedStartAt) : '-'}</span>
|
||||
@@ -475,9 +475,9 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||
<div className="grid grid-cols-1 gap-2 text-[12px] sm:grid-cols-2 sm:gap-x-4 [&>div]:min-w-0 [&>div]:rounded-md [&>div]:bg-[var(--bg-subtle)] [&>div]:px-3 [&>div]:py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
<span className="text-[var(--ink-muted)]">类型</span>
|
||||
@@ -508,7 +508,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
<ActivityLogPanel sourceType="dev_task" sourceId={task.id} />
|
||||
|
||||
{predecessors.length > 0 && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
||||
<div className="space-y-2">
|
||||
{predecessors.map((p: any) => (
|
||||
|
||||
@@ -30,8 +30,8 @@ const NAV_GROUPS = [
|
||||
{
|
||||
label: '工作区',
|
||||
items: [
|
||||
{ label: '问翻小宝', path: '/wenfan-xiaobao', icon: MessageCircleQuestionMark, permission: null as string | null },
|
||||
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
|
||||
{ label: 'AI 助手', path: '/wenfan-xiaobao', icon: MessageCircleQuestionMark, permission: null as string | null },
|
||||
{ label: '智能预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
|
||||
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null, badge: 'workspace-pending' as const },
|
||||
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
||||
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
|
||||
|
||||
@@ -130,19 +130,19 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
|
||||
<div className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg)]" onClick={(e) => e.stopPropagation()}>
|
||||
{contextLabel && (
|
||||
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
|
||||
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{tc.title}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 pr-3">
|
||||
<span className="shrink-0 text-[12px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold text-[var(--ink)]">{tc.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!readOnly && tc.status !== 'passed' && (
|
||||
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
|
||||
)}
|
||||
@@ -168,29 +168,29 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-5">
|
||||
{/* 关联需求 */}
|
||||
{requirement && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5">关联需求</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link2 className="h-3.5 w-3.5 text-[var(--accent)]" />
|
||||
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
|
||||
<span className="text-[13px] text-[var(--ink)]">{requirement.title}</span>
|
||||
<span className="min-w-0 truncate text-[13px] text-[var(--ink)]">{requirement.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态 & 操作 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||
<div className="space-y-4 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TestCaseStatusBadge status={tc.status} />
|
||||
{tc.executedAt && <span className="text-[11px] text-[var(--ink-muted)]">执行于 {tc.executedAt}</span>}
|
||||
</div>
|
||||
|
||||
{visibleNextStatuses.length > 0 && !showFailInput && !showBlockInput && !readOnly && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||
{visibleNextStatuses.map((s) => (
|
||||
<button key={s} onClick={() => handleTransition(s)} className={`h-8 px-4 rounded-lg text-[12px] font-medium transition-colors ${s === 'passed' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : s === 'failed' ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]'}`}>
|
||||
@@ -220,7 +220,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
<div className="text-[11px] font-medium text-orange-700">
|
||||
{needsClaim ? '领取并填写计划' : '填写计划'}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] text-orange-700">计划开始</label>
|
||||
<WorkDateTimePicker
|
||||
@@ -276,9 +276,9 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||
<div className="grid grid-cols-1 gap-2 text-[12px] sm:grid-cols-2 sm:gap-x-4 [&>div]:min-w-0 [&>div]:rounded-md [&>div]:bg-[var(--bg-subtle)] [&>div]:px-3 [&>div]:py-2 [&>div]:leading-5">
|
||||
<div><span className="text-[var(--ink-muted)]">计划测试:</span><span className="text-[var(--ink)] font-medium">{planDisplay}</span></div>
|
||||
<div><span className="text-[var(--ink-muted)]">优先级:</span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
|
||||
<div className="flex items-center gap-1.5"><span className="text-[var(--ink-muted)]">任务类型:</span><CategoryChip category={category} /></div>
|
||||
@@ -295,14 +295,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
|
||||
{/* 用例描述 */}
|
||||
{tc.description && (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">测试步骤 & 预期</div>
|
||||
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed whitespace-pre-wrap">{tc.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关联 Bug + 提BUG按钮 */}
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">关联 Bug ({relatedBugs.length})</div>
|
||||
{tc.status === 'failed' && onCreateBug && !readOnly && (
|
||||
|
||||
@@ -3,15 +3,17 @@ import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
test('sidebar links wenfan xiaobao as an independent entry before xiaobao warning', () => {
|
||||
test('sidebar labels AI assistant before smart warning', () => {
|
||||
const sidebar = readFileSync(join(process.cwd(), 'components/layout/Sidebar.tsx'), 'utf8');
|
||||
|
||||
const askIndex = sidebar.indexOf("label: '问翻小宝'");
|
||||
const warningIndex = sidebar.indexOf("label: '小宝预警'");
|
||||
const askIndex = sidebar.indexOf("label: 'AI 助手'");
|
||||
const warningIndex = sidebar.indexOf("label: '智能预警'");
|
||||
|
||||
assert.notEqual(askIndex, -1);
|
||||
assert.notEqual(warningIndex, -1);
|
||||
assert.ok(askIndex < warningIndex);
|
||||
assert.doesNotMatch(sidebar, /label: '问翻小宝'/);
|
||||
assert.doesNotMatch(sidebar, /label: '小宝预警'/);
|
||||
assert.match(sidebar, /path: '\/wenfan-xiaobao'/);
|
||||
});
|
||||
|
||||
@@ -36,6 +38,7 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', ()
|
||||
assert.match(page, /deleteWenfanConversationRecord/);
|
||||
assert.match(page, /删除历史记录/);
|
||||
assert.doesNotMatch(page, /setHistory\(\(current\) => \[question/);
|
||||
assert.doesNotMatch(page, /<p className="mb-1 text-\[13px\] font-semibold text-\[#171717\]">问翻小宝<\/p>/);
|
||||
assert.match(page, /入口路径/);
|
||||
assert.match(page, /必填字段/);
|
||||
assert.match(page, /状态流转/);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getXiaobaoWarningUpdateSignature,
|
||||
isXiaobaoWarningUpdated,
|
||||
markXiaobaoWarningRead,
|
||||
shouldSkipXiaobaoRiskInsightRequestForReadState,
|
||||
sanitizeRiskInsight,
|
||||
} from './xiaobao-warning-view';
|
||||
|
||||
@@ -192,6 +193,31 @@ test('xiaobao warning update state is unread until the current user reads the sa
|
||||
assert.equal(isXiaobaoWarningUpdated(baseRisk, readStates, 'user-2'), true);
|
||||
});
|
||||
|
||||
test('read current warning state suppresses refresh-triggered AI requests only for the same signature', () => {
|
||||
const baseRisk = risk();
|
||||
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
|
||||
const changedRisk = risk({
|
||||
signals: {
|
||||
...baseRisk.signals,
|
||||
failedTestCount: 1,
|
||||
},
|
||||
riskScore: 72,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
shouldSkipXiaobaoRiskInsightRequestForReadState(baseRisk, readStates, 'user-1', true),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipXiaobaoRiskInsightRequestForReadState(changedRisk, readStates, 'user-1', true),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipXiaobaoRiskInsightRequestForReadState(baseRisk, readStates, 'user-1', false),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('xiaobao warning update state becomes unread again when risk facts change', () => {
|
||||
const baseRisk = risk();
|
||||
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
|
||||
|
||||
@@ -87,6 +87,17 @@ export function isXiaobaoWarningUpdated(
|
||||
return readSignature !== currentSignature;
|
||||
}
|
||||
|
||||
export function shouldSkipXiaobaoRiskInsightRequestForReadState(
|
||||
risk: XiaobaoVersionRisk,
|
||||
readStates: XiaobaoWarningReadState[],
|
||||
userId: string | undefined,
|
||||
readStateLoaded: boolean,
|
||||
): boolean {
|
||||
if (!userId) return false;
|
||||
if (!readStateLoaded) return true;
|
||||
return !isXiaobaoWarningUpdated(risk, readStates, userId);
|
||||
}
|
||||
|
||||
export function getXiaobaoWarningUnreadUpdateCount(
|
||||
risks: XiaobaoVersionRisk[],
|
||||
readStates: XiaobaoWarningReadState[],
|
||||
|
||||
Reference in New Issue
Block a user