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:
|
||||
|
||||
- **Multi-tenant Architecture**: Complete data isolation with tenant_id filtering
|
||||
- **RBAC**: Role-based access control with JWT authentication
|
||||
- **Event-Driven Architecture**: Event bus with WebSocket real-time updates
|
||||
- **AI-Native Features**: LangChain integration, skill management, intelligent task routing
|
||||
|
||||
### Dual Frontend Architecture
|
||||
|
||||
The project maintains two frontends:
|
||||
- **Vue 3** (`frontend/`): Legacy Signal Dashboard with Naive UI
|
||||
- **React 18** (`frontend-react/`): New PMFlow interface with shadcn/ui (recommended for new features)
|
||||
|
||||
## 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
|
||||
## 3. 顶层目录(精简版)
|
||||
```text
|
||||
AIProjectManger/
|
||||
├── AGENTS.md # Agent 执行规则(建议优先维护)
|
||||
├── CLAUDE.md # 现有 Claude 说明文档
|
||||
├── start.sh / stop.sh # 一键启动/停止入口
|
||||
├── docker-compose*.yml # 容器编排
|
||||
├── backend/ # FastAPI 后端
|
||||
├── frontend/ # React 前端
|
||||
├── scripts/ # 根目录运维脚本
|
||||
├── tests/ # 根级测试(集成/脚本)
|
||||
└── docs/ # 额外文档
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Install dependencies (use project venv)
|
||||
source ../venv/bin/activate # On Windows: ../venv/Scripts/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run database migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Seed initial data (development)
|
||||
python -m seed_projects.py
|
||||
|
||||
# Start backend server (dev mode with hot reload)
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Run specific file
|
||||
python -m app.main
|
||||
## 4. 后端结构(`backend/`)
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # 路由层(含 endpoints)
|
||||
│ ├── core/ # 配置、日志、核心能力
|
||||
│ ├── dependencies/ # 鉴权/租户等依赖注入
|
||||
│ ├── models/ # SQLAlchemy 模型
|
||||
│ ├── schemas/ # Pydantic 模型
|
||||
│ ├── services/ # 业务服务(AI/SSTC/集成)
|
||||
│ ├── config/ # 配置与 prompt
|
||||
│ └── utils/ # 工具函数
|
||||
├── alembic/ # 数据库迁移
|
||||
├── requirements.txt # Python 依赖
|
||||
├── Dockerfile
|
||||
└── tests/
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
**React (PMFlow - Recommended)**:
|
||||
```bash
|
||||
cd frontend-react
|
||||
npm install
|
||||
npm run dev # Port 5174
|
||||
npm run build
|
||||
npm run preview
|
||||
## 5. 前端结构(`frontend/`)
|
||||
```text
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ # 接口封装
|
||||
│ ├── pages/ # 页面级组件
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # 基础 UI 组件
|
||||
│ │ └── 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
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --host # Port 5174
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Structure ([`backend/app/`](backend/app/))
|
||||
|
||||
**Core Architecture**:
|
||||
- [`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)
|
||||
)
|
||||
### 后端(本地直跑)
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Event Broadcasting
|
||||
```python
|
||||
from app.events import get_event_bus, EventType
|
||||
|
||||
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)
|
||||
## 7. 团队维护建议(简版)
|
||||
- 规则变更:只改 `AGENTS.md`
|
||||
- 结构变更:同步改 `CLAUDE.md`
|
||||
- API/契约变更:后端 `schemas` + 前端 `types` 同步更新
|
||||
|
|
|
|||
|
|
@ -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