refactor: modularize SSTC workspace panels and dialogs
This commit is contained in:
parent
7f9571c3fa
commit
93185369b9
42
AGENTS.md
42
AGENTS.md
|
|
@ -70,3 +70,45 @@ frontend/ # React 前端
|
||||||
## 执行要求
|
## 执行要求
|
||||||
|
|
||||||
根据用户的具体需求,生成高质量、可直接运行的代码。
|
根据用户的具体需求,生成高质量、可直接运行的代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 服务边界约束(禁止越界)
|
||||||
|
|
||||||
|
- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`
|
||||||
|
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||||
|
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||||
|
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||||
|
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||||
|
|
||||||
|
## 9. 新增代码的参考模式
|
||||||
|
|
||||||
|
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
||||||
|
- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts`
|
||||||
|
- 新增数据库表:参考 `alembic/versions/` 最新 migration
|
||||||
|
- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py`
|
||||||
|
|
||||||
|
## 10. 幂等与事务约束
|
||||||
|
|
||||||
|
- 所有入站事件必须经过 `ingest_events` 幂等去重
|
||||||
|
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
|
||||||
|
- 跨表写操作必须在同一个数据库事务内完成
|
||||||
|
|
||||||
|
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||||
|
|
||||||
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||||
|
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||||
|
- World Model 每次全量构建,高负载场景下存在性能风险,暂未缓存,改动前需评估影响
|
||||||
|
|
||||||
|
## 12. 前端组件约束
|
||||||
|
|
||||||
|
- `ui/` 目录下组件禁止修改,仅使用
|
||||||
|
- `sstc/` 目录下组件已模块化拆分,修改前必须执行 `FIND_USAGE`
|
||||||
|
- 新增业务组件放入对应功能目录,禁止在 `pages/` 内写内联组件
|
||||||
|
- 样式只用 Tailwind utility class,禁止内联 `style`
|
||||||
|
|
||||||
|
## 13. 测试与验证
|
||||||
|
|
||||||
|
- 后端改动:`python -m py_compile` 通过 + 相关单测通过
|
||||||
|
- 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误
|
||||||
|
- 新增接口:必须在 `backend/tests/` 补充对应单测
|
||||||
|
|
|
||||||
283
CLAUDE.md
283
CLAUDE.md
|
|
@ -1,226 +1,89 @@
|
||||||
# CLAUDE.md
|
# PMFlow 项目结构说明(CLAUDE.md)
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
## 1. Agent 入口文件(你关心的“进项目先读什么”)
|
||||||
|
- `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束)
|
||||||
|
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
|
||||||
|
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
|
||||||
|
|
||||||
## Project Overview
|
## 2. 项目定位
|
||||||
|
- 项目名:`PMFlow / Neural Sync`
|
||||||
|
- 形态:AI 原生项目管理系统(多租户 + 角色权限 + SSTC 工作流)
|
||||||
|
- 技术栈:
|
||||||
|
- 后端:`FastAPI + SQLAlchemy + Alembic`
|
||||||
|
- 前端:`React + TypeScript + Vite + Tailwind + Radix + React Query`
|
||||||
|
|
||||||
**PMFlow / Neural Sync** is an AI-native project management platform designed for small (10-50 person) AI/ML technical teams. The system has evolved into a multi-tenant, role-based PM operating system with:
|
## 3. 顶层目录(精简版)
|
||||||
|
```text
|
||||||
- **Multi-tenant Architecture**: Complete data isolation with tenant_id filtering
|
AIProjectManger/
|
||||||
- **RBAC**: Role-based access control with JWT authentication
|
├── AGENTS.md # Agent 执行规则(建议优先维护)
|
||||||
- **Event-Driven Architecture**: Event bus with WebSocket real-time updates
|
├── CLAUDE.md # 现有 Claude 说明文档
|
||||||
- **AI-Native Features**: LangChain integration, skill management, intelligent task routing
|
├── start.sh / stop.sh # 一键启动/停止入口
|
||||||
|
├── docker-compose*.yml # 容器编排
|
||||||
### Dual Frontend Architecture
|
├── backend/ # FastAPI 后端
|
||||||
|
├── frontend/ # React 前端
|
||||||
The project maintains two frontends:
|
├── scripts/ # 根目录运维脚本
|
||||||
- **Vue 3** (`frontend/`): Legacy Signal Dashboard with Naive UI
|
├── tests/ # 根级测试(集成/脚本)
|
||||||
- **React 18** (`frontend-react/`): New PMFlow interface with shadcn/ui (recommended for new features)
|
└── docs/ # 额外文档
|
||||||
|
|
||||||
## Common Development Commands
|
|
||||||
|
|
||||||
### Quick Start / Stop
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start all services (backend + React frontend)
|
|
||||||
./start.sh
|
|
||||||
|
|
||||||
# Or use Python launcher (cross-platform)
|
|
||||||
python launcher.py
|
|
||||||
|
|
||||||
# Stop all services
|
|
||||||
./stop.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend Development
|
## 4. 后端结构(`backend/`)
|
||||||
|
```text
|
||||||
```bash
|
backend/
|
||||||
cd backend
|
├── app/
|
||||||
|
│ ├── api/ # 路由层(含 endpoints)
|
||||||
# Install dependencies (use project venv)
|
│ ├── core/ # 配置、日志、核心能力
|
||||||
source ../venv/bin/activate # On Windows: ../venv/Scripts/activate
|
│ ├── dependencies/ # 鉴权/租户等依赖注入
|
||||||
pip install -r requirements.txt
|
│ ├── models/ # SQLAlchemy 模型
|
||||||
|
│ ├── schemas/ # Pydantic 模型
|
||||||
# Run database migrations
|
│ ├── services/ # 业务服务(AI/SSTC/集成)
|
||||||
alembic upgrade head
|
│ ├── config/ # 配置与 prompt
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
# Seed initial data (development)
|
├── alembic/ # 数据库迁移
|
||||||
python -m seed_projects.py
|
├── requirements.txt # Python 依赖
|
||||||
|
├── Dockerfile
|
||||||
# Start backend server (dev mode with hot reload)
|
└── tests/
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
|
|
||||||
# Run specific file
|
|
||||||
python -m app.main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
## 5. 前端结构(`frontend/`)
|
||||||
|
```text
|
||||||
**React (PMFlow - Recommended)**:
|
frontend/
|
||||||
```bash
|
├── src/
|
||||||
cd frontend-react
|
│ ├── api/ # 接口封装
|
||||||
npm install
|
│ ├── pages/ # 页面级组件
|
||||||
npm run dev # Port 5174
|
│ ├── components/
|
||||||
npm run build
|
│ │ ├── ui/ # 基础 UI 组件
|
||||||
npm run preview
|
│ │ └── sstc/ # SSTC 工作流组件(已模块化拆分)
|
||||||
|
│ ├── hooks/ # 自定义 hooks
|
||||||
|
│ ├── lib/ # 通用工具
|
||||||
|
│ ├── stores/ # 状态管理
|
||||||
|
│ ├── router/ # 路由
|
||||||
|
│ └── types/ # TS 类型定义
|
||||||
|
├── package.json
|
||||||
|
└── vite.config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Vue 3 (Legacy)**:
|
## 6. 运行命令(高频)
|
||||||
|
### 根目录
|
||||||
|
```bash
|
||||||
|
./start.sh dev # 推荐:docker-dev 热更新模式
|
||||||
|
./stop.sh dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm run dev
|
||||||
npm run dev -- --host # Port 5174
|
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
### 后端(本地直跑)
|
||||||
|
```bash
|
||||||
### Backend Structure ([`backend/app/`](backend/app/))
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
**Core Architecture**:
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
- [`api/`](backend/app/api/) - API routes organized by module (auth, signals, users, sstc, etc.)
|
|
||||||
- [`core/`](backend/app/core/) - Configuration, logging, response formatting
|
|
||||||
- [`models/`](backend/app/models/) - SQLAlchemy ORM with multi-tenant base classes
|
|
||||||
- [`schemas/`](backend/app/schemas/) - Pydantic request/response models
|
|
||||||
- [`services/`](backend/app/services/) - Business logic layer
|
|
||||||
- [`dependencies/`](backend/app/dependencies/) - FastAPI dependency injection (auth, tenant filtering)
|
|
||||||
- [`config/`](backend/app/config/) - Configuration and AI prompts
|
|
||||||
- [`main.py`](backend/app/main.py) - FastAPI application entry point
|
|
||||||
|
|
||||||
**Key Systems**:
|
|
||||||
|
|
||||||
1. **Multi-Tenancy** ([`models/base.py`](backend/app/models/base.py)):
|
|
||||||
- All models inherit from `Base` (includes `tenant_id`, `created_at`, `updated_at`)
|
|
||||||
- `Tenant` model uses `BaseNoTenant` (no tenant_id)
|
|
||||||
- All queries must filter by tenant_id via dependency injection
|
|
||||||
|
|
||||||
2. **Authentication** ([`services/auth.py`](backend/app/services/auth.py), [`dependencies/auth.py`](backend/app/dependencies/auth.py)):
|
|
||||||
- JWT-based authentication with 24-hour expiration
|
|
||||||
- RBAC with roles: BOSS, ADMIN, AI, FE, BE
|
|
||||||
- Permission matrix for fine-grained access control
|
|
||||||
- Use `require_roles()` and `get_current_user` dependencies
|
|
||||||
|
|
||||||
3. **Event System** ([`events.py`](backend/app/events.py), [`websocket.py`](backend/app/websocket.py)):
|
|
||||||
- Event bus for pub/sub messaging
|
|
||||||
- WebSocket manager with real-time broadcasting
|
|
||||||
- Event types: signals, tasks, code reviews, audits, integrations
|
|
||||||
|
|
||||||
4. **Role Dispatcher** ([`dispatcher.py`](backend/app/dispatcher.py)):
|
|
||||||
- Routes signals to appropriate roles (Boss, AI Engineer, Backend, Frontend)
|
|
||||||
- Keyword and pattern-based routing with confidence scoring
|
|
||||||
- Escalation logic for high-impact decisions
|
|
||||||
|
|
||||||
5. **AI Services**:
|
|
||||||
- [`ai_service.py`](backend/app/services/ai_service.py) - LangChain integration
|
|
||||||
- [`llm.py`](backend/app/services/llm.py) - Ollama/OpenAI client wrapper
|
|
||||||
- [`skill_service.py`](backend/app/services/skill_service.py) - User skill management
|
|
||||||
- [`config/ai_prompts.py`](backend/app/config/ai_prompts.py) - Centralized prompt templates
|
|
||||||
|
|
||||||
6. **Integration Services**:
|
|
||||||
- [`plane.py`](backend/app/services/plane.py) - Plane PM tool integration
|
|
||||||
- [`lark.py`](backend/app/services/lark.py) - Lark/Feishu messaging
|
|
||||||
- [`auditor.py`](backend/app/services/auditor.py) - Code audit workflow
|
|
||||||
- [`auto_merger.py`](backend/app/services/auto_merger.py) - Auto-merge logic
|
|
||||||
|
|
||||||
### Frontend Structure
|
|
||||||
|
|
||||||
**React** ([`frontend-react/src/`](frontend-react/src/)):
|
|
||||||
- [`pages/`](frontend-react/src/pages/) - Page components (Login, Dashboard, Projects, Users, Skills)
|
|
||||||
- [`components/`](frontend-react/src/components/) - Reusable UI components
|
|
||||||
- [`api/`](frontend-react/src/api/) - API client wrappers
|
|
||||||
- [`hooks/`](frontend-react/src/hooks/) - Custom React hooks (useUserRole)
|
|
||||||
- Uses React Router with HashRouter, Tailwind CSS, Zustand for state
|
|
||||||
|
|
||||||
**Vue 3** ([`frontend/src/`](frontend/src/)):
|
|
||||||
- [`pages/`](frontend/src/pages/) - Vue pages
|
|
||||||
- [`components/`](frontend/src/components/) - Vue components with Naive UI
|
|
||||||
- [`api/`](frontend/src/api/) - API client
|
|
||||||
- [`composables/`](frontend/src/composables/) - Vue composables
|
|
||||||
- [`stores/`](frontend/src/stores/) - Pinia stores
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
**Multi-Tenant Models**:
|
|
||||||
- `User` - User accounts with roles (BOSS, ADMIN, AI, FE, BE)
|
|
||||||
- `Tenant` - Tenant isolation
|
|
||||||
- `Project` - Projects with tenant_id
|
|
||||||
- `Signal` - External signals with strategic analysis
|
|
||||||
- `Task` - Tasks linked to signals and projects
|
|
||||||
- `Skill` - User skills with proficiency levels
|
|
||||||
- `UserSkillBinding` - Many-to-many user-skill relationships
|
|
||||||
- `UserWorkload` - Real-time workload tracking
|
|
||||||
|
|
||||||
**Type Compatibility** ([`models/types.py`](backend/app/models/types.py)):
|
|
||||||
- PostgreSQL: JSONB, ARRAY, Vector (pgvector)
|
|
||||||
- SQLite: Fallback to JSON/text storage for development
|
|
||||||
|
|
||||||
## Key Integration Points
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
1. Login via `/auth/login` returns JWT token
|
|
||||||
2. Include token in Authorization header: `Bearer <token>`
|
|
||||||
3. Use `get_current_user` dependency for protected routes
|
|
||||||
4. Use `require_roles("BOSS", "ADMIN")` for role-gated endpoints
|
|
||||||
|
|
||||||
### Multi-Tenant Queries
|
|
||||||
```python
|
|
||||||
from app.dependencies import get_tenant_id
|
|
||||||
|
|
||||||
@router.get("/items")
|
|
||||||
async def list_items(
|
|
||||||
tenant_id: int = Depends(get_tenant_id),
|
|
||||||
db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
result = await db.execute(
|
|
||||||
select(Item).where(Item.tenant_id == tenant_id)
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Event Broadcasting
|
## 7. 团队维护建议(简版)
|
||||||
```python
|
- 规则变更:只改 `AGENTS.md`
|
||||||
from app.events import get_event_bus, EventType
|
- 结构变更:同步改 `CLAUDE.md`
|
||||||
|
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
|
||||||
event_bus = get_event_bus()
|
|
||||||
await event_bus.publish(
|
|
||||||
EventType.TASK_ASSIGNED,
|
|
||||||
{"task_id": 123, "user_id": 456}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### WebSocket Events
|
|
||||||
- Events are automatically bridged to WebSocket clients
|
|
||||||
- Clients subscribe to event types for filtering
|
|
||||||
- Message format: `{"type": "event", "event_type": "...", "data": {...}, "timestamp": ...}`
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
- **Database**: PostgreSQL with pgvector recommended; SQLite fallback for development
|
|
||||||
- **CORS**: Currently allows all origins (`*`) - restrict for production
|
|
||||||
- **Logging**: Structured logging via [`core/logging.py`](backend/app/core/logging.py)
|
|
||||||
- **API Response**: Standardized format via [`core/response.py`](backend/app/core/response.py)
|
|
||||||
- **AI Caching**: Redis-based caching for AI call results
|
|
||||||
- **AI Logging**: All AI calls logged to `ai_call_logs` table
|
|
||||||
|
|
||||||
## Ports and Endpoints
|
|
||||||
|
|
||||||
- **Backend API**: http://127.0.0.1:8000
|
|
||||||
- **API Docs**: http://127.0.0.1:8000/docs (Swagger UI)
|
|
||||||
- **React Frontend**: http://127.0.0.1:5174
|
|
||||||
- **Vue Frontend**: http://127.0.0.1:5173
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Key environment variables (see [`.env.example`](frontend/.env.example)):
|
|
||||||
- `DATABASE_URL` - PostgreSQL/SQLite connection string
|
|
||||||
- `SECRET_KEY` - JWT signing key
|
|
||||||
- `OPENAI_API_KEY` - OpenAI API key for LangChain
|
|
||||||
- `REDIS_URL` - Redis connection for caching
|
|
||||||
- `PLANE_API_*` - Plane PM tool integration
|
|
||||||
|
|
||||||
## File Conventions
|
|
||||||
|
|
||||||
- **Pydantic Schemas**: Request/Response models in [`schemas/`](backend/app/schemas/)
|
|
||||||
- **SQLAlchemy Models**: Database models in [`models/`](backend/app/models/)
|
|
||||||
- **API Routes**: Route handlers in [`api/`](backend/app/api/)
|
|
||||||
- **Business Logic**: Services in [`services/`](backend/app/services/)
|
|
||||||
- **AI Prompts**: Centralized in [`config/ai_prompts.py`](backend/app/config/ai_prompts.py)
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,598 @@
|
||||||
|
import { PlayCircle, Sparkles } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
SignalAttachment,
|
||||||
|
SSTCCharter,
|
||||||
|
SSTCSignal,
|
||||||
|
SSTCTask,
|
||||||
|
} from "@/api/sstc";
|
||||||
|
import { CHARTER_MODULE_SCHEMAS, type CharterModuleKey } from "@/components/sstc/charterModules";
|
||||||
|
import {
|
||||||
|
FIXED_ALIGNMENT_QA_QUESTIONS,
|
||||||
|
statusBadgeClass,
|
||||||
|
} from "@/components/sstc/sstcUtils";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toZhPriority, toZhSignalType, toZhStatus } from "@/lib/display";
|
||||||
|
import { compactTitle } from "@/lib/title";
|
||||||
|
|
||||||
|
interface CharterActionMutationLike {
|
||||||
|
isPending: boolean;
|
||||||
|
mutate: (charterId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateCharterMutationLike {
|
||||||
|
isPending: boolean;
|
||||||
|
mutate: (payload: { charterId: string; title: string; content: Record<string, unknown> }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIPrefillCharterMutationLike {
|
||||||
|
isPending: boolean;
|
||||||
|
mutate: (payload: { charterId: string; force?: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharterDetailShape extends SSTCCharter {
|
||||||
|
signals: SSTCSignal[];
|
||||||
|
source_signal?: SSTCSignal | null;
|
||||||
|
tasks: SSTCTask[];
|
||||||
|
alignment_records: Array<{
|
||||||
|
user_id: string;
|
||||||
|
score: number;
|
||||||
|
confirmed: boolean;
|
||||||
|
comments?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModuleFieldEditState {
|
||||||
|
aiGenerated: boolean;
|
||||||
|
edited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSTCCharterStagePanelProps {
|
||||||
|
charters: SSTCCharter[];
|
||||||
|
selectedCharter: SSTCCharter | null;
|
||||||
|
charterDetail?: CharterDetailShape;
|
||||||
|
charterDraftTitle: string;
|
||||||
|
charterModuleDraft: {
|
||||||
|
modules: Record<CharterModuleKey, Record<string, string>>;
|
||||||
|
};
|
||||||
|
activeModule: {
|
||||||
|
key: CharterModuleKey;
|
||||||
|
title: string;
|
||||||
|
shortTitle: string;
|
||||||
|
fields: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
editable?: boolean;
|
||||||
|
multiline?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
activeModuleIndex: number;
|
||||||
|
charterProgress: {
|
||||||
|
completedModules: number;
|
||||||
|
moduleProgress: Record<
|
||||||
|
CharterModuleKey,
|
||||||
|
{
|
||||||
|
done: boolean;
|
||||||
|
requiredTotal: number;
|
||||||
|
requiredDone: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
serializedCharterContent: Record<string, unknown>;
|
||||||
|
sourceSignalOpen: boolean;
|
||||||
|
showAutoFields: boolean;
|
||||||
|
showAllAlignmentQa: boolean;
|
||||||
|
editingAlignmentQaIndex: number | null;
|
||||||
|
alignmentQaAnswers: string[];
|
||||||
|
downloadingAttachmentKey: string | null;
|
||||||
|
setCharterDraftTitle: (value: string) => void;
|
||||||
|
setSourceSignalOpen: (open: boolean) => void;
|
||||||
|
setShowAutoFields: (show: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
setShowAllAlignmentQa: (show: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
setEditingAlignmentQaIndex: (index: number | null) => void;
|
||||||
|
setActiveModuleKey: (key: CharterModuleKey) => void;
|
||||||
|
onSelectCharter: (charterId: string) => void;
|
||||||
|
updateModuleField: (moduleKey: CharterModuleKey, fieldKey: string, value: string) => void;
|
||||||
|
updateAlignmentQaAnswer: (answerIndex: number, value: string) => void;
|
||||||
|
getFieldEditState: (moduleKey: CharterModuleKey, fieldKey: string) => ModuleFieldEditState;
|
||||||
|
getModuleStepStatus: (moduleKey: CharterModuleKey) => string;
|
||||||
|
updateCharterMutation: UpdateCharterMutationLike;
|
||||||
|
startAlignmentMutation: CharterActionMutationLike;
|
||||||
|
confirmAlignmentMutation: CharterActionMutationLike;
|
||||||
|
aiPrefillCharterMutation: AIPrefillCharterMutationLike;
|
||||||
|
handleDownloadSignalAttachment: (signalId: string, attachment: SignalAttachment) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCCharterStagePanel({
|
||||||
|
charters,
|
||||||
|
selectedCharter,
|
||||||
|
charterDetail,
|
||||||
|
charterDraftTitle,
|
||||||
|
charterModuleDraft,
|
||||||
|
activeModule,
|
||||||
|
activeModuleIndex,
|
||||||
|
charterProgress,
|
||||||
|
serializedCharterContent,
|
||||||
|
sourceSignalOpen,
|
||||||
|
showAutoFields,
|
||||||
|
showAllAlignmentQa,
|
||||||
|
editingAlignmentQaIndex,
|
||||||
|
alignmentQaAnswers,
|
||||||
|
downloadingAttachmentKey,
|
||||||
|
setCharterDraftTitle,
|
||||||
|
setSourceSignalOpen,
|
||||||
|
setShowAutoFields,
|
||||||
|
setShowAllAlignmentQa,
|
||||||
|
setEditingAlignmentQaIndex,
|
||||||
|
setActiveModuleKey,
|
||||||
|
onSelectCharter,
|
||||||
|
updateModuleField,
|
||||||
|
updateAlignmentQaAnswer,
|
||||||
|
getFieldEditState,
|
||||||
|
getModuleStepStatus,
|
||||||
|
updateCharterMutation,
|
||||||
|
startAlignmentMutation,
|
||||||
|
confirmAlignmentMutation,
|
||||||
|
aiPrefillCharterMutation,
|
||||||
|
handleDownloadSignalAttachment,
|
||||||
|
}: SSTCCharterStagePanelProps) {
|
||||||
|
const sourceSignals = charterDetail?.signals || [];
|
||||||
|
const sourceNames = Array.from(
|
||||||
|
new Set(
|
||||||
|
sourceSignals
|
||||||
|
.map((signal) => toZhSignalType(signal.source || signal.signal_type || "other"))
|
||||||
|
.filter((name) => !!name && name !== "-")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const sourceSummary = sourceNames.length > 0 ? sourceNames.join("/") : "未绑定";
|
||||||
|
const autoFields = activeModule.fields.filter((field) => field.editable === false);
|
||||||
|
const prevModule = activeModuleIndex > 0 ? CHARTER_MODULE_SCHEMAS[activeModuleIndex - 1] : null;
|
||||||
|
const nextModule =
|
||||||
|
activeModuleIndex < CHARTER_MODULE_SCHEMAS.length - 1
|
||||||
|
? CHARTER_MODULE_SCHEMAS[activeModuleIndex + 1]
|
||||||
|
: null;
|
||||||
|
const hasMultipleCharters = charters.length > 1;
|
||||||
|
const filledModulePercent = Math.round(
|
||||||
|
(charterProgress.completedModules / Math.max(CHARTER_MODULE_SCHEMAS.length, 1)) * 100
|
||||||
|
);
|
||||||
|
const aiReferenceText =
|
||||||
|
sourceSignals.length > 0
|
||||||
|
? `参考了 ${sourceSignals
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((signal, signalIndex) => `信号 #${signal.id.match(/\d+/)?.[0] || signalIndex + 1}`)
|
||||||
|
.join(" 和 ")} 与项目上下文资料`
|
||||||
|
: "参考了项目上下文资料";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid items-start gap-4 ${hasMultipleCharters ? "lg:grid-cols-12" : ""}`}>
|
||||||
|
{hasMultipleCharters && (
|
||||||
|
<section className="lg:col-span-4 rounded-2xl bg-[#F8F9FA] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold tracking-tight text-slate-700">宪章列表</p>
|
||||||
|
<Badge className="border border-slate-200 bg-white text-xs text-slate-600">
|
||||||
|
{charters.length} 份
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{charters.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectCharter(c.id)}
|
||||||
|
className={`w-full rounded-xl px-4 py-3 text-left shadow-[0_4px_12px_rgba(15,23,42,0.06)] transition ${
|
||||||
|
c.id === selectedCharter?.id
|
||||||
|
? "bg-white ring-2 ring-blue-500/70"
|
||||||
|
: "bg-white/90 hover:bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-slate-900" title={c.title}>
|
||||||
|
{compactTitle(c.title, 24)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
v{c.version} · {toZhStatus(c.status)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{charters.length === 0 && <p className="text-sm text-slate-500">暂无宪章</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className={`${hasMultipleCharters ? "lg:col-span-8" : ""} rounded-2xl bg-[#F8F9FA] p-4`}>
|
||||||
|
<div className="rounded-2xl bg-white px-6 py-5 shadow-[0_8px_20px_rgba(15,23,42,0.08)]">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-semibold tracking-tight text-slate-900"
|
||||||
|
title={selectedCharter?.title || "未选择宪章"}
|
||||||
|
>
|
||||||
|
{compactTitle(selectedCharter?.title || "未选择宪章", 24)}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
v{selectedCharter?.version || 0} · {toZhStatus(selectedCharter?.status)}
|
||||||
|
</p>
|
||||||
|
{!hasMultipleCharters && (
|
||||||
|
<p className="mt-1 text-xs text-slate-400">单宪章模式:左侧列表已自动折叠</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 h-0.5 w-48 rounded-full bg-slate-200">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-blue-600"
|
||||||
|
style={{ width: `${filledModulePercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={!selectedCharter || updateCharterMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedCharter) return;
|
||||||
|
updateCharterMutation.mutate({
|
||||||
|
charterId: selectedCharter.id,
|
||||||
|
title: compactTitle(charterDraftTitle || selectedCharter.title, 24),
|
||||||
|
content: serializedCharterContent,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateCharterMutation.isPending ? "保存中…" : "更新版本"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
disabled={!selectedCharter}
|
||||||
|
onClick={() => selectedCharter && startAlignmentMutation.mutate(selectedCharter.id)}
|
||||||
|
>
|
||||||
|
<PlayCircle className="mr-2 h-4 w-4" />
|
||||||
|
发起对齐
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-[#F4F7FF] px-3 py-2 text-xs text-slate-600">
|
||||||
|
<span>↑ 信号源: {sourceSummary}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||||
|
disabled={sourceSignals.length === 0}
|
||||||
|
onClick={() => setSourceSignalOpen(true)}
|
||||||
|
>
|
||||||
|
查看溯源
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||||
|
disabled={!selectedCharter}
|
||||||
|
onClick={() => selectedCharter && confirmAlignmentMutation.mutate(selectedCharter.id)}
|
||||||
|
>
|
||||||
|
确认对齐
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiPrefillCharterMutation.isPending && (
|
||||||
|
<div className="rounded-xl bg-[#F4F7FF] p-2 text-xs text-blue-700">
|
||||||
|
AI 正在根据信号源自动生成 6 模块草稿,请稍候…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_20px_rgba(15,23,42,0.08)]">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
||||||
|
<aside className="rounded-xl bg-[#F3F5F7] p-3">
|
||||||
|
<div className="relative pl-5">
|
||||||
|
<span className="absolute bottom-2 left-[9px] top-2 w-px bg-slate-300/80" />
|
||||||
|
{CHARTER_MODULE_SCHEMAS.map((schema) => {
|
||||||
|
const active = schema.key === activeModule.key;
|
||||||
|
const status = getModuleStepStatus(schema.key);
|
||||||
|
const progress = charterProgress.moduleProgress[schema.key];
|
||||||
|
const completion =
|
||||||
|
progress.requiredTotal === 0
|
||||||
|
? 100
|
||||||
|
: Math.round((progress.requiredDone / progress.requiredTotal) * 100);
|
||||||
|
const isDone = status === "已完成";
|
||||||
|
const markerClass = isDone
|
||||||
|
? "bg-emerald-500 text-white"
|
||||||
|
: active
|
||||||
|
? "bg-blue-600 text-white ring-4 ring-blue-100"
|
||||||
|
: "bg-slate-300 text-slate-500";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={schema.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveModuleKey(schema.key)}
|
||||||
|
className={`relative mb-2 w-full rounded-lg px-3 py-2 text-left transition ${
|
||||||
|
active ? "bg-white shadow-sm" : "bg-transparent hover:bg-white/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute left-[-18px] top-1/2 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded-full text-[10px] font-semibold ${markerClass}`}
|
||||||
|
>
|
||||||
|
{isDone ? "✓" : ""}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-semibold text-slate-800">
|
||||||
|
{schema.shortTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">{status}</p>
|
||||||
|
<div className="mt-2 h-0.5 overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div className="h-full rounded-full bg-blue-600" style={{ width: `${completion}%` }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<Input
|
||||||
|
value={charterDraftTitle || selectedCharter?.title || ""}
|
||||||
|
onChange={(e) => setCharterDraftTitle(e.target.value)}
|
||||||
|
placeholder="宪章标题(自动精简)"
|
||||||
|
className="border-0 bg-[#F8F9FA] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-[#F8F9FA] p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-base font-semibold text-slate-800">{activeModule.title}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{activeModuleIndex + 1}/{CHARTER_MODULE_SCHEMAS.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{activeModule.fields
|
||||||
|
.filter(
|
||||||
|
(field) =>
|
||||||
|
field.editable !== false &&
|
||||||
|
!(activeModule.key === "module_2" && field.key === "alignment_qa")
|
||||||
|
)
|
||||||
|
.map((field) => {
|
||||||
|
const state = getFieldEditState(activeModule.key, field.key);
|
||||||
|
const fieldTone =
|
||||||
|
state.aiGenerated && !state.edited ? "bg-[#F4F7FF]" : "bg-white";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${activeModule.key}-${field.key}`}
|
||||||
|
className={`rounded-xl p-3 shadow-[0_1px_2px_rgba(15,23,42,0.08)] ${fieldTone}`}
|
||||||
|
>
|
||||||
|
<p className="mb-1 text-xs text-slate-500">{field.label}</p>
|
||||||
|
{field.multiline ? (
|
||||||
|
<Textarea
|
||||||
|
rows={field.rows || 3}
|
||||||
|
value={charterModuleDraft.modules[activeModule.key][field.key] || ""}
|
||||||
|
onChange={(e) => updateModuleField(activeModule.key, field.key, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="border-slate-200 bg-white text-xs shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={charterModuleDraft.modules[activeModule.key][field.key] || ""}
|
||||||
|
onChange={(e) => updateModuleField(activeModule.key, field.key, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-slate-500">
|
||||||
|
{state.aiGenerated ? (state.edited ? "AI 草稿后已编辑" : "AI 已生成") : "人工输入"}
|
||||||
|
</p>
|
||||||
|
{state.aiGenerated && !state.edited && (
|
||||||
|
<p className="text-slate-400">{aiReferenceText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 text-blue-700 hover:bg-blue-50"
|
||||||
|
disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
selectedCharter &&
|
||||||
|
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
|
||||||
|
disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
selectedCharter &&
|
||||||
|
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
再次优化
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{activeModule.key === "module_2" && (
|
||||||
|
<div className="rounded-xl bg-[#F4F7FF] p-3">
|
||||||
|
<p className="mb-2 text-xs text-slate-500">5个固定对齐问答</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{FIXED_ALIGNMENT_QA_QUESTIONS.slice(0, showAllAlignmentQa ? 5 : 2).map(
|
||||||
|
(question, idx) => {
|
||||||
|
const editing = editingAlignmentQaIndex === idx;
|
||||||
|
return (
|
||||||
|
<div key={`qa-${idx}`} className="rounded-lg bg-white p-2 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">
|
||||||
|
<p className="text-xs font-medium text-slate-700">Q{idx + 1} {question}</p>
|
||||||
|
{editing ? (
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
value={alignmentQaAnswers[idx] || ""}
|
||||||
|
onChange={(e) => updateAlignmentQaAnswer(idx, e.target.value)}
|
||||||
|
className="mt-2 border-slate-200 bg-white text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm text-slate-700">
|
||||||
|
{alignmentQaAnswers[idx] || "AI 待补全"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
|
||||||
|
onClick={() => setEditingAlignmentQaIndex(editing ? null : idx)}
|
||||||
|
>
|
||||||
|
{editing ? "完成" : "编辑"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-2 text-xs text-slate-500 hover:text-slate-800"
|
||||||
|
onClick={() => setShowAllAlignmentQa((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showAllAlignmentQa ? "▾ 收起剩余 3 个问答" : "▸ 展开剩余 3 个问答"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{autoFields.length > 0 && (
|
||||||
|
<div className="mt-4 rounded-xl bg-white p-2 text-xs shadow-[0_1px_2px_rgba(15,23,42,0.08)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left text-slate-500"
|
||||||
|
onClick={() => setShowAutoFields((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showAutoFields ? "▾ AI 自动处理项(校验规则等)" : "▸ AI 自动处理项(校验规则等)"}
|
||||||
|
</button>
|
||||||
|
{showAutoFields && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{autoFields.map((field) => (
|
||||||
|
<div key={`auto-${field.key}`} className="rounded-lg bg-[#F8F9FA] p-2">
|
||||||
|
<p className="text-[11px] text-slate-500">{field.label}</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-xs text-slate-700">
|
||||||
|
{charterModuleDraft.modules[activeModule.key][field.key] || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(charterDetail?.alignment_records || []).length > 0 && (
|
||||||
|
<div className="rounded-xl bg-white p-3 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">
|
||||||
|
<p className="mb-2 text-sm font-semibold text-slate-700">对齐状态</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{charterDetail?.alignment_records.map((r) => (
|
||||||
|
<div key={r.user_id} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-600">用户 {r.user_id}</span>
|
||||||
|
<span className="text-slate-600">{r.score} 分</span>
|
||||||
|
<Badge className={`border ${statusBadgeClass(r.confirmed ? "confirmed" : "pending")}`}>
|
||||||
|
{r.confirmed ? "已确认" : "待确认"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 text-sm">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!prevModule}
|
||||||
|
onClick={() => prevModule && setActiveModuleKey(prevModule.key)}
|
||||||
|
>
|
||||||
|
← {prevModule ? prevModule.shortTitle : "M1"}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{activeModuleIndex + 1}/{CHARTER_MODULE_SCHEMAS.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!nextModule}
|
||||||
|
onClick={() => nextModule && setActiveModuleKey(nextModule.key)}
|
||||||
|
>
|
||||||
|
{nextModule ? `${nextModule.shortTitle} →` : "M6 →"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={sourceSignalOpen} onOpenChange={setSourceSignalOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>来源信号(只读)</DialogTitle>
|
||||||
|
<DialogDescription>用于回溯该宪章的来源上下文</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[360px] space-y-2 overflow-auto rounded-xl bg-[#F8F9FA] p-3">
|
||||||
|
{sourceSignals.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无来源信号</p>
|
||||||
|
) : (
|
||||||
|
sourceSignals.map((signal) => (
|
||||||
|
<div key={signal.id} className="rounded-lg bg-white p-2 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
#{signal.id} · {toZhSignalType(signal.signal_type || signal.source)} · {toZhPriority(signal.priority)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-slate-800">{signal.title || "-"}</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-xs text-slate-500">{signal.content || "-"}</p>
|
||||||
|
{(signal.attachments || []).length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1 rounded border border-slate-200 bg-slate-50 p-2">
|
||||||
|
<p className="text-[11px] font-medium text-slate-600">附件 {(signal.attachments || []).length} 个</p>
|
||||||
|
{(signal.attachments || []).map((attachment) => (
|
||||||
|
<div key={`${signal.id}-${attachment.attachment_id}`} className="flex items-center justify-between gap-2 text-[11px] text-slate-600">
|
||||||
|
<span className="truncate">{attachment.file_name}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 border-slate-300 bg-white px-2 text-[11px]"
|
||||||
|
disabled={downloadingAttachmentKey !== null}
|
||||||
|
onClick={() => handleDownloadSignalAttachment(signal.id, attachment)}
|
||||||
|
>
|
||||||
|
{downloadingAttachmentKey === `${signal.id}:${String(attachment.attachment_id || attachment.id || "").trim()}`
|
||||||
|
? "下载中..."
|
||||||
|
: "下载"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setSourceSignalOpen(false)}>关闭</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { RefreshCcw } from "lucide-react";
|
||||||
|
import type { DragEvent } from "react";
|
||||||
|
import type { SSTCTask } from "@/api/sstc";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { EXECUTION_LANES, priorityBadgeClass } from "@/components/sstc/sstcUtils";
|
||||||
|
import { toZhPriority } from "@/lib/display";
|
||||||
|
|
||||||
|
interface OptimizeWorkloadMutationLike {
|
||||||
|
mutate: (mode: "unassigned" | "rebalance") => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatchTaskStatusMutationLike {
|
||||||
|
mutate: (payload: { taskId: string; status: SSTCTask["status"] }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSTCExecutionBoardPanelProps {
|
||||||
|
stageLocked: boolean;
|
||||||
|
optimizeFeedback: string;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
optimizeWorkloadMutation: OptimizeWorkloadMutationLike;
|
||||||
|
patchTaskStatusMutation: PatchTaskStatusMutationLike;
|
||||||
|
executionLanes: Record<string, SSTCTask[]>;
|
||||||
|
handleDropTask: (targetStatus: SSTCTask["status"], ev: DragEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCExecutionBoardPanel({
|
||||||
|
stageLocked,
|
||||||
|
optimizeFeedback,
|
||||||
|
onRefresh,
|
||||||
|
optimizeWorkloadMutation,
|
||||||
|
patchTaskStatusMutation,
|
||||||
|
executionLanes,
|
||||||
|
handleDropTask,
|
||||||
|
}: SSTCExecutionBoardPanelProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">执行看板</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">支持拖拽任务卡片变更状态,实时同步执行状态</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
disabled={stageLocked || optimizeWorkloadMutation.isPending}
|
||||||
|
onClick={() => optimizeWorkloadMutation.mutate("rebalance")}
|
||||||
|
>
|
||||||
|
AI 优化负载分配
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={stageLocked || optimizeWorkloadMutation.isPending}
|
||||||
|
onClick={() => optimizeWorkloadMutation.mutate("unassigned")}
|
||||||
|
>
|
||||||
|
AI 仅分配未指派
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={onRefresh}>
|
||||||
|
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stageLocked && (
|
||||||
|
<p className="mb-3 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-700">
|
||||||
|
当前阶段未解锁:请先完成上游宪章对齐。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!!optimizeFeedback && (
|
||||||
|
<p className="mb-3 rounded-md border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700">
|
||||||
|
{optimizeFeedback}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-5">
|
||||||
|
{EXECUTION_LANES.map((lane) => (
|
||||||
|
<div
|
||||||
|
key={lane.key}
|
||||||
|
className="min-h-[260px] rounded-md border bg-muted/20 p-2"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => handleDropTask(lane.key, e)}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold">{lane.label}</p>
|
||||||
|
<Badge variant="outline">{executionLanes[lane.key].length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{executionLanes[lane.key].map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
draggable={!stageLocked}
|
||||||
|
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)}
|
||||||
|
className="cursor-grab rounded-md border bg-card p-2 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{t.title}</p>
|
||||||
|
<div className="mt-1 flex items-center justify-between">
|
||||||
|
<Badge className={`border text-[10px] ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t.due_date ? t.due_date.slice(0, 10) : "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-[10px]"
|
||||||
|
disabled={stageLocked}
|
||||||
|
onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "blocked" })}
|
||||||
|
>
|
||||||
|
阻塞
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-[10px]"
|
||||||
|
disabled={stageLocked}
|
||||||
|
onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "done" })}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import type { SSTCCharter, SSTCTask } from "@/api/sstc";
|
||||||
|
import { sstcApi } from "@/api/sstc";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
interface SSTCReleaseCreateDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
stageLocked: boolean;
|
||||||
|
charters: SSTCCharter[];
|
||||||
|
tasks: SSTCTask[];
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCReleaseCreateDialog({
|
||||||
|
projectId,
|
||||||
|
stageLocked,
|
||||||
|
charters,
|
||||||
|
tasks,
|
||||||
|
onRefresh,
|
||||||
|
}: SSTCReleaseCreateDialogProps) {
|
||||||
|
const [newReleaseOpen, setNewReleaseOpen] = useState(false);
|
||||||
|
const [newReleaseTitle, setNewReleaseTitle] = useState("");
|
||||||
|
const [releaseCharterId, setReleaseCharterId] = useState("");
|
||||||
|
const [releaseTaskIds, setReleaseTaskIds] = useState<string[]>([]);
|
||||||
|
const [releasePlannedAt, setReleasePlannedAt] = useState("");
|
||||||
|
|
||||||
|
const doneTasks = useMemo(
|
||||||
|
() => tasks.filter((t) => ["done", "completed"].includes((t.status || "").toLowerCase())),
|
||||||
|
[tasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createReleaseMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
sstcApi.createRelease(projectId, {
|
||||||
|
title: newReleaseTitle,
|
||||||
|
charter_id: Number(releaseCharterId),
|
||||||
|
task_ids: releaseTaskIds.map((id) => Number(id)),
|
||||||
|
planned_at: releasePlannedAt || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewReleaseOpen(false);
|
||||||
|
setNewReleaseTitle("");
|
||||||
|
setReleaseCharterId("");
|
||||||
|
setReleaseTaskIds([]);
|
||||||
|
setReleasePlannedAt("");
|
||||||
|
onRefresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" disabled={stageLocked} onClick={() => setNewReleaseOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建发布计划
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={newReleaseOpen} onOpenChange={setNewReleaseOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建发布计划</DialogTitle>
|
||||||
|
<DialogDescription>仅可绑定已完成任务</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input value={newReleaseTitle} onChange={(e) => setNewReleaseTitle(e.target.value)} placeholder="发布版本号" />
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border bg-background px-2 py-2 text-sm"
|
||||||
|
value={releaseCharterId}
|
||||||
|
onChange={(e) => setReleaseCharterId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">选择宪章</option>
|
||||||
|
{charters.map((c) => (
|
||||||
|
<option value={c.id} key={c.id}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input value={releasePlannedAt} onChange={(e) => setReleasePlannedAt(e.target.value)} placeholder="计划发布时间(标准格式)" />
|
||||||
|
<div className="max-h-40 space-y-2 overflow-auto rounded-md border p-2">
|
||||||
|
{doneTasks.map((t) => (
|
||||||
|
<label key={t.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={releaseTaskIds.includes(t.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) setReleaseTaskIds((prev) => [...prev, t.id]);
|
||||||
|
else setReleaseTaskIds((prev) => prev.filter((id) => id !== t.id));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{t.title}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setNewReleaseOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createReleaseMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
!newReleaseTitle.trim() ||
|
||||||
|
!releaseCharterId ||
|
||||||
|
releaseTaskIds.length === 0 ||
|
||||||
|
createReleaseMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
创建发布计划
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
SSTCCharter,
|
||||||
|
SSTCGovernanceSnapshot,
|
||||||
|
SSTCRelease,
|
||||||
|
SSTCTask,
|
||||||
|
} from "@/api/sstc";
|
||||||
|
import { SSTCReleaseCreateDialog } from "@/components/sstc/SSTCReleaseCreateDialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { statusBadgeClass } from "@/components/sstc/sstcUtils";
|
||||||
|
import { toZhStatus } from "@/lib/display";
|
||||||
|
|
||||||
|
interface StatusMutationLike {
|
||||||
|
mutate: (payload: { releaseNodeId: string; status: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IdMutationLike {
|
||||||
|
mutate: (releaseNodeId: string) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerificationItemMutationLike {
|
||||||
|
mutate: (payload: {
|
||||||
|
releaseNodeId: string;
|
||||||
|
itemId: string;
|
||||||
|
status: "pending" | "done";
|
||||||
|
}) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSTCReleaseStagePanelProps {
|
||||||
|
projectId: string;
|
||||||
|
stageLocked: boolean;
|
||||||
|
charters: SSTCCharter[];
|
||||||
|
tasks: SSTCTask[];
|
||||||
|
releases: SSTCRelease[];
|
||||||
|
governance?: SSTCGovernanceSnapshot;
|
||||||
|
releaseStatusFilter: "all" | "pending" | "releasing" | "online" | "verified" | "rolled_back";
|
||||||
|
setReleaseStatusFilter: (value: "all" | "pending" | "releasing" | "online" | "verified" | "rolled_back") => void;
|
||||||
|
releaseStatusSummary: {
|
||||||
|
all: number;
|
||||||
|
pending: number;
|
||||||
|
releasing: number;
|
||||||
|
online: number;
|
||||||
|
verified: number;
|
||||||
|
rolled_back: number;
|
||||||
|
};
|
||||||
|
filteredReleases: SSTCRelease[];
|
||||||
|
patchReleaseStatusMutation: StatusMutationLike;
|
||||||
|
genVerificationMutation: IdMutationLike;
|
||||||
|
completeVerificationMutation: IdMutationLike;
|
||||||
|
patchVerificationItemMutation: VerificationItemMutationLike;
|
||||||
|
getReleaseVerificationItems: (release: SSTCRelease) => Array<Record<string, unknown>>;
|
||||||
|
getReleaseVerificationProgress: (release: SSTCRelease) => { done: number; total: number };
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCReleaseStagePanel({
|
||||||
|
projectId,
|
||||||
|
stageLocked,
|
||||||
|
charters,
|
||||||
|
tasks,
|
||||||
|
releases,
|
||||||
|
governance,
|
||||||
|
releaseStatusFilter,
|
||||||
|
setReleaseStatusFilter,
|
||||||
|
releaseStatusSummary,
|
||||||
|
filteredReleases,
|
||||||
|
patchReleaseStatusMutation,
|
||||||
|
genVerificationMutation,
|
||||||
|
completeVerificationMutation,
|
||||||
|
patchVerificationItemMutation,
|
||||||
|
getReleaseVerificationItems,
|
||||||
|
getReleaseVerificationProgress,
|
||||||
|
onRefresh,
|
||||||
|
}: SSTCReleaseStagePanelProps) {
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border border-slate-200/80 bg-white shadow-[0_8px_20px_rgba(15,23,42,0.08)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-slate-900">发布验证</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500">预发布阶段可先准备验证清单;上线前必须通过治理与验证约束</p>
|
||||||
|
</div>
|
||||||
|
<SSTCReleaseCreateDialog
|
||||||
|
projectId={projectId}
|
||||||
|
stageLocked={stageLocked}
|
||||||
|
charters={charters}
|
||||||
|
tasks={tasks}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{stageLocked && (
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-700">
|
||||||
|
当前阶段未解锁:至少完成一个任务后可进入预发布准备。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!governance && (governance.blockers.length > 0 || governance.warnings.length > 0) && (
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border px-3 py-2 text-xs ${
|
||||||
|
governance.blockers.length > 0
|
||||||
|
? "border-rose-200 bg-rose-50 text-rose-700"
|
||||||
|
: "border-amber-200 bg-amber-50 text-amber-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
AI 治理提示
|
||||||
|
</div>
|
||||||
|
{governance.blockers.map((item, idx) => (
|
||||||
|
<p key={`release-blocker-${idx}`}>- {item}</p>
|
||||||
|
))}
|
||||||
|
{governance.warnings.map((item, idx) => (
|
||||||
|
<p key={`release-warning-${idx}`}>- {item}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
|
||||||
|
<span>发布状态筛选</span>
|
||||||
|
<Badge className="border border-slate-200 bg-white text-slate-600">
|
||||||
|
总计 {releaseStatusSummary.all}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="border border-slate-200 bg-white text-slate-600">待发布 {releaseStatusSummary.pending}</Badge>
|
||||||
|
<Badge className="border border-slate-200 bg-white text-slate-600">发布中 {releaseStatusSummary.releasing}</Badge>
|
||||||
|
<Badge className="border border-slate-200 bg-white text-slate-600">已上线 {releaseStatusSummary.online}</Badge>
|
||||||
|
<Badge className="border border-slate-200 bg-white text-slate-600">已验证 {releaseStatusSummary.verified}</Badge>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-slate-300 bg-white px-2 text-xs text-slate-700"
|
||||||
|
value={releaseStatusFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setReleaseStatusFilter(
|
||||||
|
e.target.value as "all" | "pending" | "releasing" | "online" | "verified" | "rolled_back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="pending">待发布/可预发布</option>
|
||||||
|
<option value="releasing">发布中</option>
|
||||||
|
<option value="online">已上线</option>
|
||||||
|
<option value="verified">已验证</option>
|
||||||
|
<option value="rolled_back">已回滚</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredReleases.map((r) => {
|
||||||
|
const content = (r.content || {}) as Record<string, unknown>;
|
||||||
|
const taskIds = Array.isArray(content.task_ids) ? content.task_ids : [];
|
||||||
|
const verificationItems = getReleaseVerificationItems(r);
|
||||||
|
const progress = getReleaseVerificationProgress(r);
|
||||||
|
const progressPercent = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
|
||||||
|
const status = String(r.status || "").toLowerCase();
|
||||||
|
const canStartRelease = !stageLocked && (status === "pending" || status === "rolled_back");
|
||||||
|
const canMarkOnline = !stageLocked && status === "releasing";
|
||||||
|
const canCompleteVerify =
|
||||||
|
!stageLocked &&
|
||||||
|
(status === "online" || status === "releasing") &&
|
||||||
|
progress.total > 0 &&
|
||||||
|
progress.done === progress.total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="rounded-xl border border-slate-200 p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{r.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
关联任务 {taskIds.length} · 计划时间 {String(content.planned_at || "-")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Badge className={`border ${statusBadgeClass(r.status)}`}>{toZhStatus(r.status)}</Badge>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
验证进度 {progress.done}/{progress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={stageLocked}
|
||||||
|
onClick={() => genVerificationMutation.mutate(r.id)}
|
||||||
|
>
|
||||||
|
生成验证清单
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canStartRelease}
|
||||||
|
onClick={() =>
|
||||||
|
patchReleaseStatusMutation.mutate({
|
||||||
|
releaseNodeId: r.id,
|
||||||
|
status: "releasing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
开始发布
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canMarkOnline}
|
||||||
|
onClick={() =>
|
||||||
|
patchReleaseStatusMutation.mutate({
|
||||||
|
releaseNodeId: r.id,
|
||||||
|
status: "online",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
发布上线
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={!canCompleteVerify} onClick={() => completeVerificationMutation.mutate(r.id)}>
|
||||||
|
完成验证
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div className="h-full rounded-full bg-blue-600" style={{ width: `${progressPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verificationItems.length > 0 && (
|
||||||
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
|
{verificationItems.slice(0, 8).map((item) => {
|
||||||
|
const itemId = String(item.id || "");
|
||||||
|
const checked = String(item.status || "").toLowerCase() === "done";
|
||||||
|
return (
|
||||||
|
<label key={`${r.id}-${itemId}`} className="flex items-start gap-2 rounded-lg bg-slate-50 p-2 text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={stageLocked || patchVerificationItemMutation.isPending}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchVerificationItemMutation.mutate({
|
||||||
|
releaseNodeId: r.id,
|
||||||
|
itemId,
|
||||||
|
status: e.target.checked ? "done" : "pending",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-0.5 h-3.5 w-3.5 rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-700">{String(item.title || "-")}</p>
|
||||||
|
<p className="text-slate-500">{String(item.expected || "达到预期")}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredReleases.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-200 py-10 text-center text-sm text-slate-500">
|
||||||
|
{releases.length === 0 ? "暂无发布计划" : "当前筛选条件下无发布计划"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import type { SSTCRelease } from "@/api/sstc";
|
||||||
|
import { sstcApi } from "@/api/sstc";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { splitNonEmptyLines } from "@/components/sstc/sstcUtils";
|
||||||
|
|
||||||
|
interface SSTCReviewCreateDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
stageLocked: boolean;
|
||||||
|
releases: SSTCRelease[];
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCReviewCreateDialog({
|
||||||
|
projectId,
|
||||||
|
stageLocked,
|
||||||
|
releases,
|
||||||
|
onRefresh,
|
||||||
|
}: SSTCReviewCreateDialogProps) {
|
||||||
|
const [newReviewOpen, setNewReviewOpen] = useState(false);
|
||||||
|
const [newReviewTitle, setNewReviewTitle] = useState("");
|
||||||
|
const [reviewReleaseId, setReviewReleaseId] = useState("");
|
||||||
|
const [reviewContent, setReviewContent] = useState("");
|
||||||
|
|
||||||
|
const createReviewMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const lines = splitNonEmptyLines(reviewContent);
|
||||||
|
const summary = lines[0] || reviewContent.trim();
|
||||||
|
const improvements = lines.slice(1, 7).map((title, idx) => ({
|
||||||
|
id: `imp-${idx + 1}`,
|
||||||
|
title,
|
||||||
|
status: "pending",
|
||||||
|
}));
|
||||||
|
return sstcApi.createReview(projectId, {
|
||||||
|
title: newReviewTitle,
|
||||||
|
release_node_id: Number(reviewReleaseId),
|
||||||
|
content: {
|
||||||
|
summary,
|
||||||
|
improvements,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewReviewOpen(false);
|
||||||
|
setNewReviewTitle("");
|
||||||
|
setReviewReleaseId("");
|
||||||
|
setReviewContent("");
|
||||||
|
onRefresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" disabled={stageLocked} onClick={() => setNewReviewOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建复盘
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={newReviewOpen} onOpenChange={setNewReviewOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建复盘</DialogTitle>
|
||||||
|
<DialogDescription>发布验证完成后可归档闭环</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input value={newReviewTitle} onChange={(e) => setNewReviewTitle(e.target.value)} placeholder="复盘标题" />
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border bg-background px-2 py-2 text-sm"
|
||||||
|
value={reviewReleaseId}
|
||||||
|
onChange={(e) => setReviewReleaseId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">选择已验证发布</option>
|
||||||
|
{releases
|
||||||
|
.filter((r) => (r.status || "").toLowerCase().includes("verified"))
|
||||||
|
.map((r) => (
|
||||||
|
<option value={r.id} key={r.id}>
|
||||||
|
{r.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Textarea
|
||||||
|
value={reviewContent}
|
||||||
|
onChange={(e) => setReviewContent(e.target.value)}
|
||||||
|
placeholder={"第一行写复盘摘要\n后续每行写一个改进项(将自动生成可转任务列表)"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setNewReviewOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createReviewMutation.mutate()}
|
||||||
|
disabled={!newReviewTitle.trim() || !reviewReleaseId || createReviewMutation.isPending}
|
||||||
|
>
|
||||||
|
创建复盘
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AlertTriangle, CheckCircle2, Workflow } from "lucide-react";
|
||||||
|
import type { SSTCGovernanceSnapshot, SSTCRelease, SSTCReview } from "@/api/sstc";
|
||||||
|
import type { User } from "@/api/users";
|
||||||
|
import { SSTCReviewCreateDialog } from "@/components/sstc/SSTCReviewCreateDialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toZhStatus } from "@/lib/display";
|
||||||
|
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||||
|
import { statusBadgeClass, toDateInputValue, type ReviewImprovementDraft } from "@/components/sstc/sstcUtils";
|
||||||
|
|
||||||
|
interface SaveReviewImprovementsMutationLike {
|
||||||
|
mutate: (payload: { reviewId: string; improvements: ReviewImprovementDraft[] }) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatchReviewContentMutationLike {
|
||||||
|
mutate: (payload: { reviewId: string; content: Record<string, unknown> }) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchiveReviewMutationLike {
|
||||||
|
mutate: (reviewId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConvertReviewImprovementMutationLike {
|
||||||
|
mutate: (payload: { reviewId: string; improvementIndex: number; title?: string }) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSTCReviewStagePanelProps {
|
||||||
|
projectId: string;
|
||||||
|
stageLocked: boolean;
|
||||||
|
governance?: SSTCGovernanceSnapshot;
|
||||||
|
reviews: SSTCReview[];
|
||||||
|
releases: SSTCRelease[];
|
||||||
|
projectUsers: User[];
|
||||||
|
saveReviewImprovementsMutation: SaveReviewImprovementsMutationLike;
|
||||||
|
patchReviewContentMutation: PatchReviewContentMutationLike;
|
||||||
|
archiveReviewMutation: ArchiveReviewMutationLike;
|
||||||
|
convertReviewImprovementMutation: ConvertReviewImprovementMutationLike;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCReviewStagePanel({
|
||||||
|
projectId,
|
||||||
|
stageLocked,
|
||||||
|
governance,
|
||||||
|
reviews,
|
||||||
|
releases,
|
||||||
|
projectUsers,
|
||||||
|
saveReviewImprovementsMutation,
|
||||||
|
patchReviewContentMutation,
|
||||||
|
archiveReviewMutation,
|
||||||
|
convertReviewImprovementMutation,
|
||||||
|
onRefresh,
|
||||||
|
}: SSTCReviewStagePanelProps) {
|
||||||
|
const [reviewImprovementDrafts, setReviewImprovementDrafts] = useState<Record<string, ReviewImprovementDraft[]>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next: Record<string, ReviewImprovementDraft[]> = {};
|
||||||
|
for (const review of reviews) {
|
||||||
|
const content = (review.content || {}) as Record<string, unknown>;
|
||||||
|
const source = Array.isArray(content.improvements)
|
||||||
|
? (content.improvements as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
next[review.id] = source.map((item, idx) => ({
|
||||||
|
id: String(item.id || `imp-${idx + 1}`),
|
||||||
|
title: String(item.title || "").trim(),
|
||||||
|
status: String(item.status || "pending"),
|
||||||
|
owner_id: item.owner_id ? Number(item.owner_id) : null,
|
||||||
|
due_date: toDateInputValue(item.due_date),
|
||||||
|
task_id: item.task_id ? Number(item.task_id) : null,
|
||||||
|
description: String(item.description || "").trim(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setReviewImprovementDrafts(next);
|
||||||
|
}, [reviews]);
|
||||||
|
|
||||||
|
const updateReviewImprovementDraft = (
|
||||||
|
reviewId: string,
|
||||||
|
index: number,
|
||||||
|
patch: Partial<ReviewImprovementDraft>
|
||||||
|
) => {
|
||||||
|
setReviewImprovementDrafts((prev) => {
|
||||||
|
const current = prev[reviewId] || [];
|
||||||
|
const next = current.map((item, idx) =>
|
||||||
|
idx === index
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[reviewId]: next,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border border-slate-200/80 bg-white shadow-[0_8px_20px_rgba(15,23,42,0.08)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-slate-900">复盘沉淀</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500">阶段复盘与终局复盘统一管理,改进项可一键转任务闭环</p>
|
||||||
|
</div>
|
||||||
|
<SSTCReviewCreateDialog
|
||||||
|
projectId={projectId}
|
||||||
|
stageLocked={stageLocked}
|
||||||
|
releases={releases}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{stageLocked && (
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-700">
|
||||||
|
当前阶段未解锁:至少完成一次发布上线后可进入阶段复盘。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!governance && governance.warnings.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
复盘前建议
|
||||||
|
</div>
|
||||||
|
{governance.warnings.map((item, idx) => (
|
||||||
|
<p key={`review-warning-${idx}`}>- {item}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{reviews.map((r) => {
|
||||||
|
const content = (r.content || {}) as Record<string, unknown>;
|
||||||
|
const summary = String(content.summary || "暂无摘要");
|
||||||
|
const sourceImprovements = Array.isArray(content.improvements)
|
||||||
|
? (content.improvements as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
const fallbackDrafts: ReviewImprovementDraft[] = sourceImprovements.map((item, idx) => ({
|
||||||
|
id: String(item.id || `imp-${idx + 1}`),
|
||||||
|
title: String(item.title || "").trim(),
|
||||||
|
status: String(item.status || "pending"),
|
||||||
|
owner_id: item.owner_id ? Number(item.owner_id) : null,
|
||||||
|
due_date: toDateInputValue(item.due_date),
|
||||||
|
task_id: item.task_id ? Number(item.task_id) : null,
|
||||||
|
description: String(item.description || "").trim(),
|
||||||
|
}));
|
||||||
|
const improvementDrafts = reviewImprovementDrafts[r.id] || fallbackDrafts;
|
||||||
|
const sourceFingerprint = JSON.stringify(fallbackDrafts);
|
||||||
|
const draftFingerprint = JSON.stringify(improvementDrafts);
|
||||||
|
const hasDraftChange = sourceFingerprint !== draftFingerprint;
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="rounded-xl border border-slate-200 p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{r.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{formatBeijingDateTime(r.created_at)} · 改进项 {sourceImprovements.length}
|
||||||
|
</p>
|
||||||
|
<Badge className={`mt-2 border ${statusBadgeClass(r.status)}`}>{toZhStatus(r.status)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={stageLocked || !hasDraftChange || saveReviewImprovementsMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
saveReviewImprovementsMutation.mutate({
|
||||||
|
reviewId: r.id,
|
||||||
|
improvements: improvementDrafts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
保存改进项
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={patchReviewContentMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
patchReviewContentMutation.mutate({
|
||||||
|
reviewId: r.id,
|
||||||
|
content: { summary },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Workflow className="mr-2 h-4 w-4" />
|
||||||
|
同步知识图谱
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => archiveReviewMutation.mutate(r.id)}>
|
||||||
|
归档
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-lg bg-slate-50 p-2 text-xs text-slate-600">{summary}</div>
|
||||||
|
|
||||||
|
{improvementDrafts.length > 0 && (
|
||||||
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
|
{improvementDrafts.map((item, idx) => {
|
||||||
|
const title = String(item.title || "-");
|
||||||
|
const hasTask = !!item.task_id;
|
||||||
|
const status = String(item.status || "pending");
|
||||||
|
return (
|
||||||
|
<div key={`${r.id}-imp-${idx}`} className="rounded-lg border border-slate-200 p-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-xs font-medium text-slate-700">{title}</p>
|
||||||
|
{hasTask ? (
|
||||||
|
<Badge className="border border-emerald-200 bg-emerald-50 text-emerald-700">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
已转任务
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="border border-slate-200 bg-slate-100 text-slate-600">
|
||||||
|
待转任务
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid gap-2">
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-slate-300 bg-white px-2 text-xs text-slate-700"
|
||||||
|
value={status}
|
||||||
|
disabled={stageLocked || saveReviewImprovementsMutation.isPending}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateReviewImprovementDraft(r.id, idx, {
|
||||||
|
status: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="pending">待处理</option>
|
||||||
|
<option value="in_progress">进行中</option>
|
||||||
|
<option value="done">已完成</option>
|
||||||
|
{hasTask && <option value="converted">已转任务</option>}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-slate-300 bg-white px-2 text-xs text-slate-700"
|
||||||
|
value={item.owner_id ? String(item.owner_id) : ""}
|
||||||
|
disabled={stageLocked || saveReviewImprovementsMutation.isPending}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateReviewImprovementDraft(r.id, idx, {
|
||||||
|
owner_id: e.target.value ? Number(e.target.value) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">未指定负责人</option>
|
||||||
|
{projectUsers.map((u) => (
|
||||||
|
<option key={`${r.id}-imp-owner-${u.id}`} value={u.id}>
|
||||||
|
{u.name || u.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={toDateInputValue(item.due_date)}
|
||||||
|
disabled={stageLocked || saveReviewImprovementsMutation.isPending}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateReviewImprovementDraft(r.id, idx, {
|
||||||
|
due_date: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={stageLocked || hasTask || convertReviewImprovementMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
convertReviewImprovementMutation.mutate({
|
||||||
|
reviewId: r.id,
|
||||||
|
improvementIndex: idx,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
一键转任务
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{reviews.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-200 py-10 text-center text-sm text-slate-500">
|
||||||
|
暂无复盘
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
import { RefreshCcw, Sparkles } from "lucide-react";
|
||||||
|
import { type SignalAttachment, type SSTCSignal } from "@/api/sstc";
|
||||||
|
import { AIVisibilityPanel } from "@/components/sstc/AIVisibilityPanel";
|
||||||
|
import { SignalContentHoverCard } from "@/components/sstc/SignalContentHoverCard";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { toZhPriority, toZhSignalType, toZhStatus } from "@/lib/display";
|
||||||
|
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||||
|
import {
|
||||||
|
priorityBadgeClass,
|
||||||
|
signalStatusBadgeClass,
|
||||||
|
type SignalFilterState,
|
||||||
|
} from "@/components/sstc/sstcUtils";
|
||||||
|
|
||||||
|
interface SignalMutationLike {
|
||||||
|
isPending: boolean;
|
||||||
|
mutate: (signalId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastInvoker {
|
||||||
|
(payload: { title: string; variant?: "default" | "success" | "error" }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSTCSignalStagePanelProps {
|
||||||
|
projectId: string;
|
||||||
|
signals: SSTCSignal[];
|
||||||
|
signalFilters: SignalFilterState;
|
||||||
|
signalListMode: "default" | "high_value";
|
||||||
|
signalPage: number;
|
||||||
|
signalTotal: number;
|
||||||
|
signalTotalPages: number;
|
||||||
|
canPrevSignalPage: boolean;
|
||||||
|
canNextSignalPage: boolean;
|
||||||
|
refreshingSignals: boolean;
|
||||||
|
identifyingHighSignals: boolean;
|
||||||
|
highValueSignalCount: number;
|
||||||
|
displaySignals: SSTCSignal[];
|
||||||
|
archiveSignalMutation: SignalMutationLike;
|
||||||
|
deleteSignalMutation: SignalMutationLike;
|
||||||
|
onSignalPageChange: (nextPage: number) => void;
|
||||||
|
onSignalFiltersChange: (patch: Partial<SignalFilterState>) => void;
|
||||||
|
onResetSignalFilters: () => void;
|
||||||
|
onCreateCharterFromSignal: (signal: SSTCSignal) => void;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
setSignalListMode: (mode: "default" | "high_value") => void;
|
||||||
|
setRefreshingSignals: (value: boolean) => void;
|
||||||
|
setIdentifyingHighSignals: (value: boolean) => void;
|
||||||
|
handleDownloadSignalAttachment: (signalId: string, attachment: SignalAttachment) => Promise<void>;
|
||||||
|
handleUploadSignalAttachment: (
|
||||||
|
signalId: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
) => Promise<void>;
|
||||||
|
toast: ToastInvoker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCSignalStagePanel({
|
||||||
|
projectId,
|
||||||
|
signals,
|
||||||
|
signalFilters,
|
||||||
|
signalListMode,
|
||||||
|
signalPage,
|
||||||
|
signalTotal,
|
||||||
|
signalTotalPages,
|
||||||
|
canPrevSignalPage,
|
||||||
|
canNextSignalPage,
|
||||||
|
refreshingSignals,
|
||||||
|
identifyingHighSignals,
|
||||||
|
highValueSignalCount,
|
||||||
|
displaySignals,
|
||||||
|
archiveSignalMutation,
|
||||||
|
deleteSignalMutation,
|
||||||
|
onSignalPageChange,
|
||||||
|
onSignalFiltersChange,
|
||||||
|
onResetSignalFilters,
|
||||||
|
onCreateCharterFromSignal,
|
||||||
|
onRefresh,
|
||||||
|
setSignalListMode,
|
||||||
|
setRefreshingSignals,
|
||||||
|
setIdentifyingHighSignals,
|
||||||
|
handleDownloadSignalAttachment,
|
||||||
|
handleUploadSignalAttachment,
|
||||||
|
toast,
|
||||||
|
}: SSTCSignalStagePanelProps) {
|
||||||
|
const pendingCount = signals.filter(
|
||||||
|
(signal) =>
|
||||||
|
String(signal.status_code || "").toLowerCase() === "pending" ||
|
||||||
|
String(signal.status || "").toLowerCase().includes("pending") ||
|
||||||
|
String(signal.status || "").includes("待")
|
||||||
|
).length;
|
||||||
|
const archivedCount = signals.filter(
|
||||||
|
(signal) =>
|
||||||
|
String(signal.status_code || "").toLowerCase() === "closed" ||
|
||||||
|
String(signal.status || "").toLowerCase().includes("archive") ||
|
||||||
|
String(signal.status || "").includes("归档") ||
|
||||||
|
String(signal.status || "").includes("关闭")
|
||||||
|
).length;
|
||||||
|
const highPriorityCount = highValueSignalCount;
|
||||||
|
|
||||||
|
const handleRefreshSignals = async () => {
|
||||||
|
if (refreshingSignals) return;
|
||||||
|
setRefreshingSignals(true);
|
||||||
|
try {
|
||||||
|
setSignalListMode("default");
|
||||||
|
await Promise.resolve(onRefresh());
|
||||||
|
toast({ title: "信号源已刷新", variant: "success" });
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { detail?: string } }; message?: string })?.response?.data?.detail ||
|
||||||
|
(err as { message?: string })?.message ||
|
||||||
|
"刷新失败,请稍后重试";
|
||||||
|
toast({ title: message, variant: "error" });
|
||||||
|
} finally {
|
||||||
|
setRefreshingSignals(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIdentifyHighSignals = async () => {
|
||||||
|
if (identifyingHighSignals) return;
|
||||||
|
setIdentifyingHighSignals(true);
|
||||||
|
try {
|
||||||
|
await Promise.resolve(onRefresh());
|
||||||
|
setSignalListMode("high_value");
|
||||||
|
toast({
|
||||||
|
title:
|
||||||
|
highPriorityCount > 0
|
||||||
|
? `已识别 ${highPriorityCount} 条高价值信号,并置顶展示`
|
||||||
|
: "当前暂无高价值信号",
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { detail?: string } }; message?: string })?.response?.data?.detail ||
|
||||||
|
(err as { message?: string })?.message ||
|
||||||
|
"识别失败,请稍后重试";
|
||||||
|
toast({ title: message, variant: "error" });
|
||||||
|
} finally {
|
||||||
|
setIdentifyingHighSignals(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border border-slate-200/80 bg-white shadow-[0_8px_20px_rgba(15,23,42,0.08)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-slate-900">项目信号源</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500">市场/竞品/用户反馈统一入口,支持 AI 预处理</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
onClick={handleRefreshSignals}
|
||||||
|
disabled={refreshingSignals || identifyingHighSignals}
|
||||||
|
>
|
||||||
|
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshingSignals ? "animate-spin" : ""}`} />
|
||||||
|
{refreshingSignals ? "刷新中…" : "刷新信号源"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
onClick={handleIdentifyHighSignals}
|
||||||
|
disabled={refreshingSignals || identifyingHighSignals}
|
||||||
|
>
|
||||||
|
<Sparkles className={`mr-2 h-4 w-4 ${identifyingHighSignals ? "animate-pulse" : ""}`} />
|
||||||
|
{identifyingHighSignals ? "识别中…" : "AI 识别高价值信号"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{signalListMode === "high_value" && (
|
||||||
|
<div className="mb-3 flex items-center justify-between rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700">
|
||||||
|
<span>当前按高价值优先展示(规则:优先级 + 业务关键词 + 来源)</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 border-blue-200 bg-white text-blue-700 hover:bg-blue-100"
|
||||||
|
onClick={() => setSignalListMode("default")}
|
||||||
|
>
|
||||||
|
恢复默认排序
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-4 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.12em] text-slate-600">高级筛选(默认待处理)</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
|
||||||
|
onClick={onResetSignalFilters}
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-6">
|
||||||
|
<Input
|
||||||
|
value={signalFilters.keyword}
|
||||||
|
onChange={(e) => onSignalFiltersChange({ keyword: e.target.value })}
|
||||||
|
placeholder="关键词:标题/内容/ID"
|
||||||
|
className="h-8 bg-white text-xs"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-slate-300 bg-white px-2 text-xs text-slate-700"
|
||||||
|
value={signalFilters.status}
|
||||||
|
onChange={(e) => onSignalFiltersChange({ status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="pending">待处理</option>
|
||||||
|
<option value="linked">已关联宪章</option>
|
||||||
|
<option value="closed">已关闭</option>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-slate-300 bg-white px-2 text-xs text-slate-700"
|
||||||
|
value={signalFilters.source}
|
||||||
|
onChange={(e) => onSignalFiltersChange({ source: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">全部来源</option>
|
||||||
|
<option value="Feishu">Feishu</option>
|
||||||
|
<option value="PMFlow">PMFlow</option>
|
||||||
|
<option value="WeCom">WeCom</option>
|
||||||
|
<option value="GitHub">GitHub</option>
|
||||||
|
<option value="HackerNews">HackerNews</option>
|
||||||
|
<option value="Browser">Browser</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-slate-300 bg-white px-2 text-xs text-slate-700"
|
||||||
|
value={signalFilters.priority}
|
||||||
|
onChange={(e) => onSignalFiltersChange({ priority: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">全部优先级</option>
|
||||||
|
<option value="p0">P0 / 高</option>
|
||||||
|
<option value="p1">P1 / 中</option>
|
||||||
|
<option value="p2">P2 / 低</option>
|
||||||
|
<option value="p3">P3</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="low">low</option>
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={signalFilters.created_from}
|
||||||
|
onChange={(e) => onSignalFiltersChange({ created_from: e.target.value })}
|
||||||
|
className="h-8 bg-white text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={signalFilters.created_to}
|
||||||
|
onChange={(e) => onSignalFiltersChange({ created_to: e.target.value })}
|
||||||
|
className="h-8 bg-white text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 grid gap-2 md:grid-cols-4">
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p className="text-[11px] text-slate-500">当前页信号</p>
|
||||||
|
<p className="text-lg font-semibold text-slate-900">{displaySignals.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-amber-50 px-3 py-2">
|
||||||
|
<p className="text-[11px] text-amber-700">待处理</p>
|
||||||
|
<p className="text-lg font-semibold text-amber-700">{pendingCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-rose-50 px-3 py-2">
|
||||||
|
<p className="text-[11px] text-rose-700">高优先级</p>
|
||||||
|
<p className="text-lg font-semibold text-rose-700">{highPriorityCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-100 px-3 py-2">
|
||||||
|
<p className="text-[11px] text-slate-600">已归档</p>
|
||||||
|
<p className="text-lg font-semibold text-slate-700">{archivedCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<AIVisibilityPanel projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-slate-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>信号标题</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>来源</TableHead>
|
||||||
|
<TableHead>项目创建人</TableHead>
|
||||||
|
<TableHead>处理状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{displaySignals.map((s) => (
|
||||||
|
<TableRow key={s.id} className="hover:bg-slate-50/70">
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={`border ${priorityBadgeClass(s.priority)}`}>{toZhPriority(s.priority)}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-slate-600">#{s.id}</TableCell>
|
||||||
|
<TableCell className="max-w-[420px] align-top">
|
||||||
|
<SignalContentHoverCard
|
||||||
|
signalId={s.id}
|
||||||
|
title={s.title}
|
||||||
|
content={s.content}
|
||||||
|
proposerName={s.proposer_name}
|
||||||
|
attachments={s.attachments || []}
|
||||||
|
onDownloadAttachment={(attachment) => handleDownloadSignalAttachment(s.id, attachment)}
|
||||||
|
onUploadAttachment={(file, onProgress) =>
|
||||||
|
handleUploadSignalAttachment(s.id, file, onProgress)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{toZhSignalType(s.signal_type || "pending_classification")}</TableCell>
|
||||||
|
<TableCell>{toZhSignalType(s.source)}</TableCell>
|
||||||
|
<TableCell>{s.project_creator_name || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={`border whitespace-nowrap ${signalStatusBadgeClass(s.status)}`}>
|
||||||
|
{toZhStatus(s.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatBeijingDateTime(s.created_at)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
disabled={s.can_create_charter === false}
|
||||||
|
title={
|
||||||
|
s.can_create_charter === false ? (s.charter_lock_reason || "该信号已关联宪章,禁止重复新建") : ""
|
||||||
|
}
|
||||||
|
onClick={() => onCreateCharterFromSignal(s)}
|
||||||
|
>
|
||||||
|
新建宪章
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={archiveSignalMutation.isPending}
|
||||||
|
onClick={() => archiveSignalMutation.mutate(s.id)}
|
||||||
|
>
|
||||||
|
归档
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-rose-200 bg-white text-rose-700 hover:bg-rose-50"
|
||||||
|
disabled={deleteSignalMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm("确认删除该信号?删除后将从当前项目列表移除。")) return;
|
||||||
|
deleteSignalMutation.mutate(s.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{displaySignals.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="py-10 text-center text-slate-500">
|
||||||
|
暂无信号数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
第 {signalPage}/{signalTotalPages} 页,共 {signalTotal} 条信号
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={!canPrevSignalPage}
|
||||||
|
onClick={() => onSignalPageChange(Math.max(1, signalPage - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={!canNextSignalPage}
|
||||||
|
onClick={() => onSignalPageChange(Math.min(signalTotalPages, signalPage + 1))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { sstcApi } from "@/api/sstc";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface SelectedCharterLike {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSTCTaskCreateDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
stageLocked: boolean;
|
||||||
|
selectedCharter: SelectedCharterLike | null;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCTaskCreateDialog({
|
||||||
|
projectId,
|
||||||
|
stageLocked,
|
||||||
|
selectedCharter,
|
||||||
|
onRefresh,
|
||||||
|
}: SSTCTaskCreateDialogProps) {
|
||||||
|
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||||
|
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||||
|
const [newTaskDesc, setNewTaskDesc] = useState("");
|
||||||
|
const [newTaskPriority, setNewTaskPriority] = useState("medium");
|
||||||
|
const [newTaskHours, setNewTaskHours] = useState("");
|
||||||
|
const [newTaskDueDate, setNewTaskDueDate] = useState("");
|
||||||
|
|
||||||
|
const createTaskMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
sstcApi.createTask(projectId, selectedCharter!.id, {
|
||||||
|
title: newTaskTitle,
|
||||||
|
description: newTaskDesc || undefined,
|
||||||
|
priority: newTaskPriority,
|
||||||
|
estimated_hours: newTaskHours ? Number(newTaskHours) : undefined,
|
||||||
|
due_date: newTaskDueDate || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewTaskOpen(false);
|
||||||
|
setNewTaskTitle("");
|
||||||
|
setNewTaskDesc("");
|
||||||
|
setNewTaskHours("");
|
||||||
|
setNewTaskDueDate("");
|
||||||
|
onRefresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={!selectedCharter || stageLocked}
|
||||||
|
onClick={() => setNewTaskOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建任务</DialogTitle>
|
||||||
|
<DialogDescription>任务将绑定当前宪章(SSTC 强制)</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input value={newTaskTitle} onChange={(e) => setNewTaskTitle(e.target.value)} placeholder="任务标题" />
|
||||||
|
<Textarea value={newTaskDesc} onChange={(e) => setNewTaskDesc(e.target.value)} placeholder="任务描述" />
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-background px-2 py-2 text-sm"
|
||||||
|
value={newTaskPriority}
|
||||||
|
onChange={(e) => setNewTaskPriority(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="high">P0 / 高</option>
|
||||||
|
<option value="medium">P1 / 中</option>
|
||||||
|
<option value="low">P2 / 低</option>
|
||||||
|
</select>
|
||||||
|
<Input value={newTaskHours} onChange={(e) => setNewTaskHours(e.target.value)} placeholder="工时(小时)" />
|
||||||
|
<Input value={newTaskDueDate} onChange={(e) => setNewTaskDueDate(e.target.value)} placeholder="截止时间(标准格式)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setNewTaskOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createTaskMutation.mutate()}
|
||||||
|
disabled={!newTaskTitle.trim() || !selectedCharter || createTaskMutation.isPending}
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MoreHorizontal, Sparkles } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
SSTCCharter,
|
||||||
|
SSTCTask,
|
||||||
|
SSTCTaskAssigneeRecommendationResponse,
|
||||||
|
SSTCTaskRecommendation,
|
||||||
|
} from "@/api/sstc";
|
||||||
|
import type { User } from "@/api/users";
|
||||||
|
import { SSTCTaskCreateDialog } from "@/components/sstc/SSTCTaskCreateDialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { toZhPriority, toZhStatus } from "@/lib/display";
|
||||||
|
import { compactTitle } from "@/lib/title";
|
||||||
|
import { priorityBadgeClass, statusBadgeClass } from "@/components/sstc/sstcUtils";
|
||||||
|
|
||||||
|
interface AIBreakdownMutationLike {
|
||||||
|
mutate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptimizeWorkloadMutationLike {
|
||||||
|
mutate: (mode: "unassigned" | "rebalance") => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatchTaskStatusMutationLike {
|
||||||
|
mutate: (payload: { taskId: string; status: SSTCTask["status"] }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkTaskStatusMutationLike {
|
||||||
|
mutateAsync: (payload: { taskIds: string[]; status: SSTCTask["status"] }) => Promise<void>;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatchTaskMutationLike {
|
||||||
|
mutate: (payload: {
|
||||||
|
taskId: string;
|
||||||
|
payload: {
|
||||||
|
assignee_id?: number | null;
|
||||||
|
estimated_hours?: number;
|
||||||
|
priority?: string;
|
||||||
|
};
|
||||||
|
}) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecommendAssigneeMutationLike {
|
||||||
|
mutateAsync: (taskId: string) => Promise<SSTCTaskAssigneeRecommendationResponse>;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSTCTaskStagePanelProps {
|
||||||
|
projectId: string;
|
||||||
|
stageLocked: boolean;
|
||||||
|
selectedCharter: SSTCCharter | null;
|
||||||
|
tasks: SSTCTask[];
|
||||||
|
projectUsers: User[];
|
||||||
|
optimizeFeedback: string;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
aiBreakdownMutation: AIBreakdownMutationLike;
|
||||||
|
optimizeWorkloadMutation: OptimizeWorkloadMutationLike;
|
||||||
|
patchTaskStatusMutation: PatchTaskStatusMutationLike;
|
||||||
|
bulkTaskStatusMutation: BulkTaskStatusMutationLike;
|
||||||
|
patchTaskMutation: PatchTaskMutationLike;
|
||||||
|
recommendAssigneeMutation: RecommendAssigneeMutationLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSTCTaskStagePanel({
|
||||||
|
projectId,
|
||||||
|
stageLocked,
|
||||||
|
selectedCharter,
|
||||||
|
tasks,
|
||||||
|
projectUsers,
|
||||||
|
optimizeFeedback,
|
||||||
|
onRefresh,
|
||||||
|
aiBreakdownMutation,
|
||||||
|
optimizeWorkloadMutation,
|
||||||
|
patchTaskStatusMutation,
|
||||||
|
bulkTaskStatusMutation,
|
||||||
|
patchTaskMutation,
|
||||||
|
recommendAssigneeMutation,
|
||||||
|
}: SSTCTaskStagePanelProps) {
|
||||||
|
const [taskRecommendations, setTaskRecommendations] = useState<Record<string, SSTCTaskRecommendation[]>>({});
|
||||||
|
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedTaskIds([]);
|
||||||
|
}, [selectedCharter?.id]);
|
||||||
|
|
||||||
|
const charterTasks = selectedCharter
|
||||||
|
? tasks.filter((t) => String(t.truth_node_id || t.charter_id) === selectedCharter.id)
|
||||||
|
: tasks;
|
||||||
|
const allSelected = charterTasks.length > 0 && selectedTaskIds.length === charterTasks.length;
|
||||||
|
const selectedCount = selectedTaskIds.length;
|
||||||
|
|
||||||
|
const toggleTaskSelection = (taskId: string) => {
|
||||||
|
setSelectedTaskIds((prev) =>
|
||||||
|
prev.includes(taskId) ? prev.filter((id) => id !== taskId) : [...prev, taskId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedTaskIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedTaskIds(charterTasks.map((task) => task.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPrimaryAction = (task: SSTCTask) => {
|
||||||
|
const status = String(task.status || "").toLowerCase();
|
||||||
|
if (status === "done" || status === "completed") return;
|
||||||
|
if (status === "in_progress" || status === "review") {
|
||||||
|
patchTaskStatusMutation.mutate({ taskId: task.id, status: "done" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchTaskStatusMutation.mutate({ taskId: task.id, status: "in_progress" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryActionLabel = (task: SSTCTask): string => {
|
||||||
|
const status = String(task.status || "").toLowerCase();
|
||||||
|
if (status === "done" || status === "completed") return "已完成";
|
||||||
|
if (status === "in_progress" || status === "review") return "标记完成";
|
||||||
|
return "开始执行";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecommendAssignee = async (taskId: string) => {
|
||||||
|
const data = await recommendAssigneeMutation.mutateAsync(taskId);
|
||||||
|
setTaskRecommendations((prev) => ({ ...prev, [data.task_id]: data.recommendations || [] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkUpdate = async (status: SSTCTask["status"]) => {
|
||||||
|
await bulkTaskStatusMutation.mutateAsync({ taskIds: selectedTaskIds, status });
|
||||||
|
setSelectedTaskIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border border-slate-200/80 bg-white shadow-[0_8px_20px_rgba(15,23,42,0.08)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-slate-900">任务拆解树</CardTitle>
|
||||||
|
<p className="text-sm text-slate-500">任务必须绑定已对齐宪章,支持 AI 自动拆解与负载优化</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<SSTCTaskCreateDialog
|
||||||
|
projectId={projectId}
|
||||||
|
selectedCharter={selectedCharter}
|
||||||
|
stageLocked={stageLocked}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={!selectedCharter || stageLocked}
|
||||||
|
onClick={() => aiBreakdownMutation.mutate()}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
AI 重新拆解任务
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
disabled={stageLocked}
|
||||||
|
onClick={() => optimizeWorkloadMutation.mutate("rebalance")}
|
||||||
|
>
|
||||||
|
AI 优化负载分配
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={stageLocked}
|
||||||
|
onClick={() => optimizeWorkloadMutation.mutate("unassigned")}
|
||||||
|
>
|
||||||
|
AI 仅分配未指派
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stageLocked && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
|
<p className="text-xs text-amber-700">当前阶段未解锁:请先完成上游宪章对齐。</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs text-amber-700 hover:bg-amber-100"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
>
|
||||||
|
去完成上游对齐
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!optimizeFeedback && (
|
||||||
|
<div className="mb-3 rounded-xl border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700">
|
||||||
|
{optimizeFeedback}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||||
|
<span>已选择 {selectedCount} 项任务</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 border-slate-200 bg-white px-2 text-xs text-slate-700 hover:bg-slate-50"
|
||||||
|
disabled={stageLocked || bulkTaskStatusMutation.isPending}
|
||||||
|
onClick={() => void handleBulkUpdate("in_progress")}
|
||||||
|
>
|
||||||
|
批量开始
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 bg-blue-600 px-2 text-xs text-white hover:bg-blue-700"
|
||||||
|
disabled={stageLocked || bulkTaskStatusMutation.isPending}
|
||||||
|
onClick={() => void handleBulkUpdate("done")}
|
||||||
|
>
|
||||||
|
批量完成
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs text-slate-600 hover:bg-slate-100"
|
||||||
|
onClick={() => setSelectedTaskIds([])}
|
||||||
|
>
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 align-middle"
|
||||||
|
aria-label="选择全部任务"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>任务标题</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>负责人</TableHead>
|
||||||
|
<TableHead className="text-right">预估工时</TableHead>
|
||||||
|
<TableHead className="text-right">截止日期</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{charterTasks.map((t) => {
|
||||||
|
const recs = taskRecommendations[t.id] || [];
|
||||||
|
const status = String(t.status || "").toLowerCase();
|
||||||
|
const done = status === "done" || status === "completed";
|
||||||
|
const topRec = recs[0];
|
||||||
|
const sourceCharterTitle = selectedCharter ? compactTitle(selectedCharter.title, 16) : "-";
|
||||||
|
return (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="text-center align-top">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTaskIds.includes(t.id)}
|
||||||
|
onChange={() => toggleTaskSelection(t.id)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300"
|
||||||
|
aria-label={`选择任务 ${t.title}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="font-medium">{t.title}</p>
|
||||||
|
{t.description && <p className="text-xs text-muted-foreground">{t.description}</p>}
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-500">
|
||||||
|
<span>来源:{sourceCharterTitle}</span>
|
||||||
|
<span>风险:{t.risk_count ?? 0}</span>
|
||||||
|
<span>任务ID:{String(t.id).slice(-6)}</span>
|
||||||
|
</div>
|
||||||
|
{recs.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{recs.slice(0, 4).map((r) => (
|
||||||
|
<span
|
||||||
|
key={`${t.id}-${r.user_id}`}
|
||||||
|
className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{r.user_name} · {r.recommended_role || "-"} · {Number(r.workload_rate || 0).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={`border whitespace-nowrap ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={`border whitespace-nowrap ${statusBadgeClass(t.status)}`}>
|
||||||
|
{done ? "✓ " : ""}
|
||||||
|
{toZhStatus(t.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="min-w-[170px]">
|
||||||
|
<select
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||||
|
value={t.assignee_id || ""}
|
||||||
|
disabled={stageLocked || patchTaskMutation.isPending}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchTaskMutation.mutate({
|
||||||
|
taskId: t.id,
|
||||||
|
payload: { assignee_id: e.target.value ? Number(e.target.value) : null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">未分配</option>
|
||||||
|
{projectUsers.map((u) => {
|
||||||
|
const active = (u.is_active ?? true) && u.status === "active";
|
||||||
|
return (
|
||||||
|
<option key={u.id} value={u.id} disabled={!active}>
|
||||||
|
{u.name || u.username} {active ? "" : "(已禁用)"}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
{topRec && (
|
||||||
|
<p className="mt-1 text-[10px] text-blue-700">
|
||||||
|
推荐:{topRec.user_name}(匹配度 {Math.round(Number(topRec.match_score || 0))}%)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{t.estimated_hours ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">{t.due_date ? t.due_date.slice(0, 10) : "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
variant={done ? "outline" : "default"}
|
||||||
|
disabled={stageLocked || done}
|
||||||
|
onClick={() => runPrimaryAction(t)}
|
||||||
|
>
|
||||||
|
{primaryActionLabel(t)}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||||
|
disabled={stageLocked || recommendAssigneeMutation.isPending}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => void handleRecommendAssignee(t.id)}>
|
||||||
|
推荐负责人
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "in_progress" })}>
|
||||||
|
开始执行
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "done" })}>
|
||||||
|
标记完成
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "blocked" })}>
|
||||||
|
标记阻塞
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{charterTasks.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
|
暂无任务
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { type SSTCSignal } from "@/api/sstc";
|
||||||
|
import { type CharterModuleDraft } from "@/components/sstc/charterModules";
|
||||||
|
import { apiBaseURL } from "@/lib/api";
|
||||||
|
|
||||||
|
export interface SignalFilterState {
|
||||||
|
status: string;
|
||||||
|
keyword: string;
|
||||||
|
source: string;
|
||||||
|
priority: string;
|
||||||
|
created_from: string;
|
||||||
|
created_to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewImprovementDraft {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
owner_id: number | null;
|
||||||
|
due_date: string | null;
|
||||||
|
task_id: number | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecutionLaneKey = "todo" | "in_progress" | "blocked" | "review" | "done";
|
||||||
|
|
||||||
|
export function priorityBadgeClass(priority?: string): string {
|
||||||
|
const p = (priority || "").toLowerCase();
|
||||||
|
if (p === "p0" || p === "high") return "bg-red-100 text-red-700 border-red-200";
|
||||||
|
if (p === "p1" || p === "medium") return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
|
return "bg-slate-100 text-slate-700 border-slate-200";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusBadgeClass(status?: string): string {
|
||||||
|
const s = (status || "").toLowerCase();
|
||||||
|
if (s.includes("done") || s.includes("completed") || s.includes("已") || s.includes("verified")) {
|
||||||
|
return "bg-emerald-100 text-emerald-800 border-emerald-300 shadow-[inset_0_-1px_0_rgba(5,150,105,0.25)]";
|
||||||
|
}
|
||||||
|
if (s.includes("progress") || s.includes("review") || s.includes("对齐")) {
|
||||||
|
return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
|
}
|
||||||
|
if (s.includes("pending") || s.includes("待")) {
|
||||||
|
return "bg-slate-100 text-slate-700 border-slate-200";
|
||||||
|
}
|
||||||
|
if (s.includes("blocked") || s.includes("未解锁")) {
|
||||||
|
return "bg-red-100 text-red-700 border-red-200";
|
||||||
|
}
|
||||||
|
return "bg-muted text-muted-foreground border-border";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signalStatusBadgeClass(status?: string): string {
|
||||||
|
const s = (status || "").toLowerCase();
|
||||||
|
if (s.includes("archive") || s.includes("已归档")) {
|
||||||
|
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||||
|
}
|
||||||
|
if (s.includes("success") || s.includes("done") || s.includes("completed") || s.includes("已处理")) {
|
||||||
|
return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
|
}
|
||||||
|
if (s.includes("pending") || s.includes("待")) {
|
||||||
|
return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
|
}
|
||||||
|
if (s.includes("processing") || s.includes("进行")) {
|
||||||
|
return "bg-blue-100 text-blue-700 border-blue-200";
|
||||||
|
}
|
||||||
|
return "bg-slate-100 text-slate-700 border-slate-200";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLane(status: string): ExecutionLaneKey {
|
||||||
|
const s = (status || "").toLowerCase();
|
||||||
|
if (s === "completed") return "done";
|
||||||
|
if (s === "in_progress") return "in_progress";
|
||||||
|
if (s === "blocked") return "blocked";
|
||||||
|
if (s === "review") return "review";
|
||||||
|
if (s === "done") return "done";
|
||||||
|
return "todo";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signalValueScore(signal: SSTCSignal): number {
|
||||||
|
const priority = String(signal.priority || "").toLowerCase();
|
||||||
|
const source = String(signal.source || "").toLowerCase();
|
||||||
|
const status = String(signal.status || "").toLowerCase();
|
||||||
|
const content = `${signal.title || ""} ${signal.content || ""}`.toLowerCase();
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (priority === "p0" || priority === "high" || priority === "critical") score += 80;
|
||||||
|
else if (priority === "p1" || priority === "medium") score += 45;
|
||||||
|
else if (priority === "p2" || priority === "low") score += 20;
|
||||||
|
else score += 15;
|
||||||
|
|
||||||
|
const keywords = [
|
||||||
|
"p0",
|
||||||
|
"紧急",
|
||||||
|
"故障",
|
||||||
|
"阻塞",
|
||||||
|
"增长",
|
||||||
|
"转化",
|
||||||
|
"收入",
|
||||||
|
"成本",
|
||||||
|
"性能",
|
||||||
|
"发布",
|
||||||
|
"上线",
|
||||||
|
"风险",
|
||||||
|
"流失",
|
||||||
|
"api",
|
||||||
|
"模型",
|
||||||
|
];
|
||||||
|
const keywordHits = keywords.filter((word) => content.includes(word)).length;
|
||||||
|
score += Math.min(keywordHits, 3) * 10;
|
||||||
|
|
||||||
|
if (source.includes("feishu")) score += 8;
|
||||||
|
if (status.includes("pending") || status.includes("待")) score += 6;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReleaseStatus(
|
||||||
|
status?: string
|
||||||
|
): "pending" | "releasing" | "online" | "verified" | "rolled_back" | "unknown" {
|
||||||
|
const s = String(status || "").toLowerCase();
|
||||||
|
if (s.includes("verified") || s.includes("已验证")) return "verified";
|
||||||
|
if (s.includes("releasing") || s.includes("发布中")) return "releasing";
|
||||||
|
if (s.includes("online") || s.includes("deployed") || s.includes("已上线")) return "online";
|
||||||
|
if (s.includes("rolled_back") || s.includes("回滚")) return "rolled_back";
|
||||||
|
if (s.includes("pending") || s.includes("待发布") || s.includes("可预发布")) return "pending";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateInputValue(raw: unknown): string {
|
||||||
|
const text = String(raw || "").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
if (text.includes("T")) return text.split("T")[0];
|
||||||
|
return text.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAttachmentDownloadUrl(rawUrl?: string | null): string | null {
|
||||||
|
const value = String(rawUrl || "").trim();
|
||||||
|
if (!value) return null;
|
||||||
|
if (/^https?:\/\//i.test(value)) return value;
|
||||||
|
const normalizedPath = value.startsWith("/") ? value : `/${value}`;
|
||||||
|
const normalizedBase = String(apiBaseURL || "").trim().replace(/\/+$/, "");
|
||||||
|
if (!normalizedBase) return normalizedPath;
|
||||||
|
return `${normalizedBase}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXECUTION_LANES: Array<{ key: ExecutionLaneKey; label: string }> = [
|
||||||
|
{ key: "todo", label: "待办" },
|
||||||
|
{ key: "in_progress", label: "进行中" },
|
||||||
|
{ key: "blocked", label: "已阻塞" },
|
||||||
|
{ key: "review", label: "评审中" },
|
||||||
|
{ key: "done", label: "已完成" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FIXED_ALIGNMENT_QA_QUESTIONS = [
|
||||||
|
"核心用户是谁?",
|
||||||
|
"上线时间要求?",
|
||||||
|
"明确不做什么?",
|
||||||
|
"成功如何衡量?",
|
||||||
|
"关键风险与依赖?",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function splitNonEmptyLines(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstLine(value: string): string {
|
||||||
|
return splitNonEmptyLines(value)[0] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAlignmentQaAnswers(raw: string): string[] {
|
||||||
|
const answers = Array.from({ length: FIXED_ALIGNMENT_QA_QUESTIONS.length }, () => "");
|
||||||
|
const text = String(raw || "").trim();
|
||||||
|
if (!text) return answers;
|
||||||
|
|
||||||
|
const qRegex = /Q([1-5])[\s::].*?(?:\nA\1[\s::]?([^\n]*))?/gis;
|
||||||
|
const matchedIndexes = new Set<number>();
|
||||||
|
for (const matched of text.matchAll(qRegex)) {
|
||||||
|
const idx = Number(matched[1]) - 1;
|
||||||
|
if (idx >= 0 && idx < answers.length) {
|
||||||
|
answers[idx] = String(matched[2] || "").trim();
|
||||||
|
matchedIndexes.add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchedIndexes.size > 0) return answers;
|
||||||
|
|
||||||
|
const lines = splitNonEmptyLines(text).map((line) =>
|
||||||
|
line
|
||||||
|
.replace(/^[\-*•\d\.\)\s]+/, "")
|
||||||
|
.replace(/^Q[1-5][\s::]*/i, "")
|
||||||
|
.replace(/^A[1-5][\s::]*/i, "")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
for (let i = 0; i < answers.length; i += 1) {
|
||||||
|
answers[i] = lines[i] || "";
|
||||||
|
}
|
||||||
|
return answers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAlignmentQaText(answers: string[]): string {
|
||||||
|
return FIXED_ALIGNMENT_QA_QUESTIONS.map((question, idx) => {
|
||||||
|
const answer = String(answers[idx] || "").trim();
|
||||||
|
return `Q${idx + 1} ${question}\nA${idx + 1} ${answer}`;
|
||||||
|
}).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFallbackAlignmentAnswers(draft: CharterModuleDraft): string[] {
|
||||||
|
return [
|
||||||
|
firstLine(draft.modules.module_1.problem_statement),
|
||||||
|
firstLine(draft.modules.module_4.milestones),
|
||||||
|
firstLine(draft.modules.module_2.out_of_scope),
|
||||||
|
firstLine(draft.modules.module_1.kpis),
|
||||||
|
firstLine(draft.modules.module_5.risk_register),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,639 @@
|
||||||
|
import { type DragEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { getUsers, type User } from "@/api/users";
|
||||||
|
import type {
|
||||||
|
SignalAttachment,
|
||||||
|
StageKey,
|
||||||
|
SSTCCharter,
|
||||||
|
SSTCGovernanceSnapshot,
|
||||||
|
SSTCRelease,
|
||||||
|
SSTCReview,
|
||||||
|
SSTCSignal,
|
||||||
|
SSTCStageNode,
|
||||||
|
SSTCTask,
|
||||||
|
} from "@/api/sstc";
|
||||||
|
import { sstcApi } from "@/api/sstc";
|
||||||
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
import { getApiErrorMessage } from "@/lib/apiError";
|
||||||
|
import {
|
||||||
|
CHARTER_MODULE_SCHEMAS,
|
||||||
|
buildCharterContentPayload,
|
||||||
|
calculateCharterProgress,
|
||||||
|
createEmptyCharterModuleDraft,
|
||||||
|
parseCharterModuleDraft,
|
||||||
|
type CharterModuleKey,
|
||||||
|
} from "@/components/sstc/charterModules";
|
||||||
|
import {
|
||||||
|
buildAlignmentQaText,
|
||||||
|
buildFallbackAlignmentAnswers,
|
||||||
|
normalizeLane,
|
||||||
|
normalizeReleaseStatus,
|
||||||
|
parseAlignmentQaAnswers,
|
||||||
|
resolveAttachmentDownloadUrl,
|
||||||
|
signalValueScore,
|
||||||
|
type ReviewImprovementDraft,
|
||||||
|
type SignalFilterState,
|
||||||
|
} from "@/components/sstc/sstcUtils";
|
||||||
|
|
||||||
|
export interface SSTCWorkspaceProps {
|
||||||
|
projectId: string;
|
||||||
|
activeStage: StageKey;
|
||||||
|
stages: SSTCStageNode[];
|
||||||
|
signals: SSTCSignal[];
|
||||||
|
signalFilters: SignalFilterState;
|
||||||
|
charters: SSTCCharter[];
|
||||||
|
tasks: SSTCTask[];
|
||||||
|
releases: SSTCRelease[];
|
||||||
|
reviews: SSTCReview[];
|
||||||
|
selectedCharterId: string | null;
|
||||||
|
signalPage: number;
|
||||||
|
signalTotal: number;
|
||||||
|
signalPageSize: number;
|
||||||
|
onSignalPageChange: (nextPage: number) => void;
|
||||||
|
onSelectCharter: (charterId: string) => void;
|
||||||
|
onCreateCharterFromSignal: (signal: SSTCSignal) => void;
|
||||||
|
onSignalFiltersChange: (patch: Partial<SignalFilterState>) => void;
|
||||||
|
onResetSignalFilters: () => void;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSSTCWorkspaceOrchestration({
|
||||||
|
projectId,
|
||||||
|
activeStage,
|
||||||
|
stages,
|
||||||
|
signals,
|
||||||
|
charters,
|
||||||
|
tasks,
|
||||||
|
releases,
|
||||||
|
selectedCharterId,
|
||||||
|
signalPage,
|
||||||
|
signalTotal,
|
||||||
|
signalPageSize,
|
||||||
|
onRefresh,
|
||||||
|
}: SSTCWorkspaceProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const activeMeta = stages.find((s) => s.key === activeStage);
|
||||||
|
const stageLocked = !!activeMeta?.locked;
|
||||||
|
|
||||||
|
const [releaseStatusFilter, setReleaseStatusFilter] = useState<
|
||||||
|
"all" | "pending" | "releasing" | "online" | "verified" | "rolled_back"
|
||||||
|
>("all");
|
||||||
|
|
||||||
|
const [optimizeFeedback, setOptimizeFeedback] = useState("");
|
||||||
|
|
||||||
|
const [charterDraftTitle, setCharterDraftTitle] = useState("");
|
||||||
|
const [charterModuleDraft, setCharterModuleDraft] = useState(createEmptyCharterModuleDraft());
|
||||||
|
const [charterBaselineDraft, setCharterBaselineDraft] = useState(createEmptyCharterModuleDraft());
|
||||||
|
const [activeModuleKey, setActiveModuleKey] = useState<CharterModuleKey>("module_1");
|
||||||
|
const [sourceSignalOpen, setSourceSignalOpen] = useState(false);
|
||||||
|
const [showAutoFields, setShowAutoFields] = useState(false);
|
||||||
|
const [showAllAlignmentQa, setShowAllAlignmentQa] = useState(false);
|
||||||
|
const [editingAlignmentQaIndex, setEditingAlignmentQaIndex] = useState<number | null>(null);
|
||||||
|
const [aiPrefillAttempted, setAiPrefillAttempted] = useState<Record<string, boolean>>({});
|
||||||
|
const [signalListMode, setSignalListMode] = useState<"default" | "high_value">("default");
|
||||||
|
const [refreshingSignals, setRefreshingSignals] = useState(false);
|
||||||
|
const [identifyingHighSignals, setIdentifyingHighSignals] = useState(false);
|
||||||
|
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDownloadSignalAttachment = async (signalId: string, attachment: SignalAttachment) => {
|
||||||
|
const attachmentId = String(attachment.attachment_id || attachment.id || "").trim();
|
||||||
|
if (!attachmentId) {
|
||||||
|
toast({ title: "附件标识缺失,无法下载", variant: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${signalId}:${attachmentId}`;
|
||||||
|
setDownloadingAttachmentKey(key);
|
||||||
|
try {
|
||||||
|
const blob = await sstcApi.downloadSignalAttachment(projectId, signalId, attachmentId);
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = attachment.file_name || `attachment-${attachmentId}`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackUrl = resolveAttachmentDownloadUrl(attachment.download_url);
|
||||||
|
if (fallbackUrl) {
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = fallbackUrl;
|
||||||
|
anchor.target = "_blank";
|
||||||
|
anchor.rel = "noopener noreferrer";
|
||||||
|
anchor.download = attachment.file_name || `attachment-${attachmentId}`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: getApiErrorMessage(error, "附件下载失败"),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDownloadingAttachmentKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadSignalAttachment = async (
|
||||||
|
signalId: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
) => {
|
||||||
|
const ext = `.${String(file.name || "").split(".").pop() || ""}`.toLowerCase();
|
||||||
|
const allowed = [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".doc", ".docx", ".zip"];
|
||||||
|
if (!allowed.includes(ext)) {
|
||||||
|
toast({ title: "仅支持 jpg/png/gif/pdf/doc/docx/zip 文件", variant: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
|
toast({ title: "单文件大小不能超过 20MB", variant: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sstcApi.uploadSignalAttachment(projectId, signalId, file, onProgress);
|
||||||
|
await Promise.resolve(onRefresh());
|
||||||
|
toast({ title: "附件上传成功", variant: "success" });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: getApiErrorMessage(error, "上传附件失败"),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCharter = useMemo(
|
||||||
|
() => charters.find((c) => c.id === selectedCharterId) || charters[0] || null,
|
||||||
|
[charters, selectedCharterId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const charterProgress = useMemo(() => calculateCharterProgress(charterModuleDraft), [charterModuleDraft]);
|
||||||
|
|
||||||
|
const serializedCharterContent = useMemo(
|
||||||
|
() =>
|
||||||
|
buildCharterContentPayload(charterModuleDraft, (selectedCharter?.content as Record<string, unknown>) || {}),
|
||||||
|
[charterModuleDraft, selectedCharter?.content]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCharterDraftTitle(selectedCharter?.title || "");
|
||||||
|
const parsed = parseCharterModuleDraft((selectedCharter?.content as Record<string, unknown>) || {});
|
||||||
|
if (!parsed.modules.module_2.alignment_qa.trim()) {
|
||||||
|
const fallback = buildFallbackAlignmentAnswers(parsed);
|
||||||
|
parsed.modules.module_2.alignment_qa = buildAlignmentQaText(fallback);
|
||||||
|
}
|
||||||
|
setCharterModuleDraft(parsed);
|
||||||
|
setCharterBaselineDraft(parsed);
|
||||||
|
setActiveModuleKey("module_1");
|
||||||
|
setSourceSignalOpen(false);
|
||||||
|
setShowAutoFields(false);
|
||||||
|
setShowAllAlignmentQa(false);
|
||||||
|
setEditingAlignmentQaIndex(null);
|
||||||
|
}, [selectedCharter?.id, selectedCharter?.title, selectedCharter?.content]);
|
||||||
|
|
||||||
|
const rankedSignals = useMemo(() => {
|
||||||
|
return signals
|
||||||
|
.map((signal) => ({ signal, score: signalValueScore(signal) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
const aTime = new Date(a.signal.created_at || 0).getTime();
|
||||||
|
const bTime = new Date(b.signal.created_at || 0).getTime();
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
}, [signals]);
|
||||||
|
|
||||||
|
const highValueThreshold = 70;
|
||||||
|
const highValueSignalCount = useMemo(
|
||||||
|
() => rankedSignals.filter((item) => item.score >= highValueThreshold).length,
|
||||||
|
[rankedSignals]
|
||||||
|
);
|
||||||
|
const displaySignals = useMemo(
|
||||||
|
() => (signalListMode === "high_value" ? rankedSignals.map((item) => item.signal) : signals),
|
||||||
|
[signalListMode, rankedSignals, signals]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: charterDetail } = useQuery({
|
||||||
|
queryKey: ["sstc-charter-detail", projectId, selectedCharter?.id],
|
||||||
|
queryFn: () => sstcApi.getCharterDetail(projectId, selectedCharter!.id),
|
||||||
|
enabled: !!selectedCharter?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: governance } = useQuery<SSTCGovernanceSnapshot>({
|
||||||
|
queryKey: ["sstc-governance", projectId, selectedCharter?.id],
|
||||||
|
queryFn: () =>
|
||||||
|
sstcApi.getGovernance(
|
||||||
|
projectId,
|
||||||
|
selectedCharter?.id ? { charter_id: Number(selectedCharter.id) } : undefined
|
||||||
|
),
|
||||||
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projectUsersResponse } = useQuery({
|
||||||
|
queryKey: ["project-users-for-task", projectId],
|
||||||
|
queryFn: () => getUsers({ page: 1, page_size: 100, project_id: Number(projectId) }),
|
||||||
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
const projectUsers: User[] = projectUsersResponse?.items ?? [];
|
||||||
|
|
||||||
|
const archiveSignalMutation = useMutation({
|
||||||
|
mutationFn: (signalId: string) => sstcApi.archiveSignal(projectId, signalId),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSignalMutation = useMutation({
|
||||||
|
mutationFn: (signalId: string) => sstcApi.deleteSignal(projectId, signalId),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startAlignmentMutation = useMutation({
|
||||||
|
mutationFn: (charterId: string) => sstcApi.startAlignment(projectId, charterId),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmAlignmentMutation = useMutation({
|
||||||
|
mutationFn: (charterId: string) =>
|
||||||
|
sstcApi.confirmAlignment(projectId, charterId, 90, true, "已确认对齐"),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCharterMutation = useMutation({
|
||||||
|
mutationFn: (payload: { charterId: string; title: string; content: Record<string, unknown> }) =>
|
||||||
|
sstcApi.updateCharter(projectId, payload.charterId, {
|
||||||
|
title: payload.title,
|
||||||
|
content: payload.content,
|
||||||
|
force_new_version: false,
|
||||||
|
}),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiPrefillCharterMutation = useMutation({
|
||||||
|
mutationFn: (payload: { charterId: string; force?: boolean }) =>
|
||||||
|
sstcApi.aiPrefillCharter(projectId, payload.charterId, { force: !!payload.force }),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
setAiPrefillAttempted((prev) => ({ ...prev, [variables.charterId]: true }));
|
||||||
|
onRefresh();
|
||||||
|
},
|
||||||
|
onError: (_error, variables) => {
|
||||||
|
setAiPrefillAttempted((prev) => ({ ...prev, [variables.charterId]: true }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const charterId = selectedCharter?.id;
|
||||||
|
if (!charterId) return;
|
||||||
|
if (aiPrefillAttempted[charterId]) return;
|
||||||
|
if (aiPrefillCharterMutation.isPending) return;
|
||||||
|
if (charterProgress.completedModules >= CHARTER_MODULE_SCHEMAS.length) return;
|
||||||
|
const hasSignal =
|
||||||
|
(selectedCharter?.signal_ids?.length || 0) > 0 ||
|
||||||
|
(charterDetail?.signals?.length || 0) > 0 ||
|
||||||
|
!!charterDetail?.source_signal;
|
||||||
|
if (!hasSignal) return;
|
||||||
|
setAiPrefillAttempted((prev) => ({ ...prev, [charterId]: true }));
|
||||||
|
aiPrefillCharterMutation.mutate({ charterId });
|
||||||
|
}, [
|
||||||
|
selectedCharter?.id,
|
||||||
|
selectedCharter?.signal_ids,
|
||||||
|
charterDetail?.signals?.length,
|
||||||
|
charterDetail?.source_signal,
|
||||||
|
charterProgress.completedModules,
|
||||||
|
aiPrefillAttempted,
|
||||||
|
aiPrefillCharterMutation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const aiBreakdownMutation = useMutation({
|
||||||
|
mutationFn: () => sstcApi.aiBreakdownTasks(projectId, selectedCharter!.id),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchTaskStatusMutation = useMutation({
|
||||||
|
mutationFn: ({ taskId, status }: { taskId: string; status: SSTCTask["status"] }) =>
|
||||||
|
sstcApi.patchTaskStatus(projectId, taskId, status),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkTaskStatusMutation = useMutation({
|
||||||
|
mutationFn: async (payload: { taskIds: string[]; status: SSTCTask["status"] }) => {
|
||||||
|
await Promise.all(payload.taskIds.map((taskId) => sstcApi.patchTaskStatus(projectId, taskId, payload.status)));
|
||||||
|
},
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchTaskMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
taskId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
payload: {
|
||||||
|
assignee_id?: number | null;
|
||||||
|
estimated_hours?: number;
|
||||||
|
priority?: string;
|
||||||
|
};
|
||||||
|
}) => sstcApi.patchTask(projectId, taskId, payload),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recommendAssigneeMutation = useMutation({
|
||||||
|
mutationFn: (taskId: string) => sstcApi.recommendTaskAssignee(projectId, taskId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const optimizeWorkloadMutation = useMutation({
|
||||||
|
mutationFn: (mode: "unassigned" | "rebalance") =>
|
||||||
|
sstcApi.optimizeWorkload(projectId, { mode, max_moves: 200 }),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const updated = Number(data?.updated || 0);
|
||||||
|
const mode = data?.mode === "unassigned" ? "未指派任务分配" : "全量负载重平衡";
|
||||||
|
setOptimizeFeedback(updated > 0 ? `${mode}已完成:更新 ${updated} 个任务` : `${mode}无可优化任务`);
|
||||||
|
onRefresh();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setOptimizeFeedback("AI 负载优化执行失败,请稍后重试");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchReleaseStatusMutation = useMutation({
|
||||||
|
mutationFn: ({ releaseNodeId, status }: { releaseNodeId: string; status: string }) =>
|
||||||
|
sstcApi.patchReleaseStatus(projectId, releaseNodeId, status),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const genVerificationMutation = useMutation({
|
||||||
|
mutationFn: (releaseNodeId: string) => sstcApi.generateReleaseVerification(projectId, releaseNodeId),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeVerificationMutation = useMutation({
|
||||||
|
mutationFn: (releaseNodeId: string) => sstcApi.completeReleaseVerification(projectId, releaseNodeId),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchVerificationItemMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
releaseNodeId,
|
||||||
|
itemId,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
releaseNodeId: string;
|
||||||
|
itemId: string;
|
||||||
|
status: "pending" | "done";
|
||||||
|
}) => sstcApi.patchReleaseVerificationItem(projectId, releaseNodeId, itemId, { status }),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveReviewMutation = useMutation({
|
||||||
|
mutationFn: (reviewId: string) => sstcApi.archiveReview(projectId, reviewId, true),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchReviewContentMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
reviewId,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
reviewId: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
}) => sstcApi.patchReviewContent(projectId, reviewId, content),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveReviewImprovementsMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
reviewId,
|
||||||
|
improvements,
|
||||||
|
}: {
|
||||||
|
reviewId: string;
|
||||||
|
improvements: ReviewImprovementDraft[];
|
||||||
|
}) =>
|
||||||
|
sstcApi.patchReviewContent(projectId, reviewId, {
|
||||||
|
improvements,
|
||||||
|
}),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const convertReviewImprovementMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
reviewId,
|
||||||
|
improvementIndex,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
reviewId: string;
|
||||||
|
improvementIndex: number;
|
||||||
|
title?: string;
|
||||||
|
}) =>
|
||||||
|
sstcApi.convertReviewImprovementToTask(projectId, reviewId, {
|
||||||
|
improvement_index: improvementIndex,
|
||||||
|
title,
|
||||||
|
}),
|
||||||
|
onSuccess: onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const releaseStatusSummary = useMemo(() => {
|
||||||
|
const summary = {
|
||||||
|
all: releases.length,
|
||||||
|
pending: 0,
|
||||||
|
releasing: 0,
|
||||||
|
online: 0,
|
||||||
|
verified: 0,
|
||||||
|
rolled_back: 0,
|
||||||
|
};
|
||||||
|
for (const release of releases) {
|
||||||
|
const key = normalizeReleaseStatus(release.status);
|
||||||
|
if (key in summary) {
|
||||||
|
summary[key as keyof typeof summary] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}, [releases]);
|
||||||
|
|
||||||
|
const filteredReleases = useMemo(() => {
|
||||||
|
if (releaseStatusFilter === "all") return releases;
|
||||||
|
return releases.filter((release) => normalizeReleaseStatus(release.status) === releaseStatusFilter);
|
||||||
|
}, [releases, releaseStatusFilter]);
|
||||||
|
|
||||||
|
const signalTotalPages = Math.max(1, Math.ceil(signalTotal / signalPageSize));
|
||||||
|
const canPrevSignalPage = signalPage > 1;
|
||||||
|
const canNextSignalPage = signalPage < signalTotalPages;
|
||||||
|
|
||||||
|
const executionLanes = useMemo(() => {
|
||||||
|
const grouped: Record<string, SSTCTask[]> = {
|
||||||
|
todo: [],
|
||||||
|
in_progress: [],
|
||||||
|
blocked: [],
|
||||||
|
review: [],
|
||||||
|
done: [],
|
||||||
|
};
|
||||||
|
for (const t of tasks) {
|
||||||
|
grouped[normalizeLane(t.status || "todo")].push(t);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const handleDropTask = (targetStatus: SSTCTask["status"], ev: DragEvent<HTMLDivElement>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const taskId = ev.dataTransfer.getData("taskId");
|
||||||
|
if (!taskId) return;
|
||||||
|
patchTaskStatusMutation.mutate({ taskId, status: targetStatus });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReleaseVerificationItems = (release: SSTCRelease): Array<Record<string, unknown>> => {
|
||||||
|
const content = (release.content || {}) as Record<string, unknown>;
|
||||||
|
const items = content.verification_items;
|
||||||
|
if (!Array.isArray(items)) return [];
|
||||||
|
return items.filter((item) => typeof item === "object" && item !== null) as Array<
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReleaseVerificationProgress = (release: SSTCRelease): { done: number; total: number } => {
|
||||||
|
const content = (release.content || {}) as Record<string, unknown>;
|
||||||
|
const progress = content.verification_progress as { done?: number; total?: number } | undefined;
|
||||||
|
if (progress && typeof progress.done === "number" && typeof progress.total === "number") {
|
||||||
|
return { done: progress.done, total: progress.total };
|
||||||
|
}
|
||||||
|
const items = getReleaseVerificationItems(release);
|
||||||
|
const done = items.filter((item) => String(item.status || "").toLowerCase() === "done").length;
|
||||||
|
return { done, total: items.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeModule = CHARTER_MODULE_SCHEMAS.find((item) => item.key === activeModuleKey) || CHARTER_MODULE_SCHEMAS[0];
|
||||||
|
|
||||||
|
const updateModuleField = (moduleKey: CharterModuleKey, fieldKey: string, value: string) => {
|
||||||
|
setCharterModuleDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modules: {
|
||||||
|
...prev.modules,
|
||||||
|
[moduleKey]: {
|
||||||
|
...prev.modules[moduleKey],
|
||||||
|
[fieldKey]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleOrder = CHARTER_MODULE_SCHEMAS.map((schema) => schema.key);
|
||||||
|
const activeModuleIndex = Math.max(0, moduleOrder.indexOf(activeModule.key));
|
||||||
|
|
||||||
|
const alignmentQaAnswers = useMemo(
|
||||||
|
() => parseAlignmentQaAnswers(charterModuleDraft.modules.module_2.alignment_qa || ""),
|
||||||
|
[charterModuleDraft.modules.module_2.alignment_qa]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAlignmentQaAnswer = (answerIndex: number, value: string) => {
|
||||||
|
const nextAnswers = [...alignmentQaAnswers];
|
||||||
|
nextAnswers[answerIndex] = value;
|
||||||
|
updateModuleField("module_2", "alignment_qa", buildAlignmentQaText(nextAnswers));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldEditState = (moduleKey: CharterModuleKey, fieldKey: string) => {
|
||||||
|
const baseline = (charterBaselineDraft.modules[moduleKey][fieldKey] || "").trim();
|
||||||
|
const current = (charterModuleDraft.modules[moduleKey][fieldKey] || "").trim();
|
||||||
|
const aiGenerated = baseline.length > 0;
|
||||||
|
const edited = aiGenerated && baseline !== current;
|
||||||
|
return { aiGenerated, edited };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModuleStepStatus = (moduleKey: CharterModuleKey): string => {
|
||||||
|
if (moduleKey === activeModule.key) return "进行中";
|
||||||
|
const progress = charterProgress.moduleProgress[moduleKey];
|
||||||
|
if (progress.done) return "已完成";
|
||||||
|
const moduleContent = charterModuleDraft.modules[moduleKey];
|
||||||
|
const hasContent = Object.values(moduleContent).some((value) => String(value || "").trim().length > 0);
|
||||||
|
if (!hasContent) return moduleKey === "module_6" ? "选填" : "未开始";
|
||||||
|
const changed = Object.keys(moduleContent).some(
|
||||||
|
(fieldKey) =>
|
||||||
|
String(charterModuleDraft.modules[moduleKey][fieldKey] || "").trim() !==
|
||||||
|
String(charterBaselineDraft.modules[moduleKey][fieldKey] || "").trim()
|
||||||
|
);
|
||||||
|
return changed ? "已编辑" : "AI已预填";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast,
|
||||||
|
stageLocked,
|
||||||
|
releaseStatusFilter,
|
||||||
|
setReleaseStatusFilter,
|
||||||
|
optimizeFeedback,
|
||||||
|
setOptimizeFeedback,
|
||||||
|
|
||||||
|
charterDraftTitle,
|
||||||
|
setCharterDraftTitle,
|
||||||
|
charterModuleDraft,
|
||||||
|
setCharterModuleDraft,
|
||||||
|
charterBaselineDraft,
|
||||||
|
setCharterBaselineDraft,
|
||||||
|
activeModuleKey,
|
||||||
|
setActiveModuleKey,
|
||||||
|
sourceSignalOpen,
|
||||||
|
setSourceSignalOpen,
|
||||||
|
showAutoFields,
|
||||||
|
setShowAutoFields,
|
||||||
|
showAllAlignmentQa,
|
||||||
|
setShowAllAlignmentQa,
|
||||||
|
editingAlignmentQaIndex,
|
||||||
|
setEditingAlignmentQaIndex,
|
||||||
|
aiPrefillAttempted,
|
||||||
|
setAiPrefillAttempted,
|
||||||
|
signalListMode,
|
||||||
|
setSignalListMode,
|
||||||
|
refreshingSignals,
|
||||||
|
setRefreshingSignals,
|
||||||
|
identifyingHighSignals,
|
||||||
|
setIdentifyingHighSignals,
|
||||||
|
downloadingAttachmentKey,
|
||||||
|
setDownloadingAttachmentKey,
|
||||||
|
|
||||||
|
selectedCharter,
|
||||||
|
charterProgress,
|
||||||
|
serializedCharterContent,
|
||||||
|
rankedSignals,
|
||||||
|
highValueSignalCount,
|
||||||
|
displaySignals,
|
||||||
|
|
||||||
|
charterDetail,
|
||||||
|
governance,
|
||||||
|
projectUsers,
|
||||||
|
|
||||||
|
archiveSignalMutation,
|
||||||
|
deleteSignalMutation,
|
||||||
|
startAlignmentMutation,
|
||||||
|
confirmAlignmentMutation,
|
||||||
|
updateCharterMutation,
|
||||||
|
aiPrefillCharterMutation,
|
||||||
|
aiBreakdownMutation,
|
||||||
|
patchTaskStatusMutation,
|
||||||
|
bulkTaskStatusMutation,
|
||||||
|
patchTaskMutation,
|
||||||
|
recommendAssigneeMutation,
|
||||||
|
optimizeWorkloadMutation,
|
||||||
|
patchReleaseStatusMutation,
|
||||||
|
genVerificationMutation,
|
||||||
|
completeVerificationMutation,
|
||||||
|
patchVerificationItemMutation,
|
||||||
|
archiveReviewMutation,
|
||||||
|
patchReviewContentMutation,
|
||||||
|
saveReviewImprovementsMutation,
|
||||||
|
convertReviewImprovementMutation,
|
||||||
|
releaseStatusSummary,
|
||||||
|
filteredReleases,
|
||||||
|
signalTotalPages,
|
||||||
|
canPrevSignalPage,
|
||||||
|
canNextSignalPage,
|
||||||
|
executionLanes,
|
||||||
|
|
||||||
|
handleDownloadSignalAttachment,
|
||||||
|
handleUploadSignalAttachment,
|
||||||
|
handleDropTask,
|
||||||
|
getReleaseVerificationItems,
|
||||||
|
getReleaseVerificationProgress,
|
||||||
|
|
||||||
|
activeModule,
|
||||||
|
updateModuleField,
|
||||||
|
activeModuleIndex,
|
||||||
|
alignmentQaAnswers,
|
||||||
|
updateAlignmentQaAnswer,
|
||||||
|
getFieldEditState,
|
||||||
|
getModuleStepStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/inbox.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}
|
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/inbox.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}
|
||||||
Loading…
Reference in New Issue