refactor: modularize SSTC workspace panels and dialogs

This commit is contained in:
Yuuuu 2026-03-10 16:24:42 +08:00
parent 7f9571c3fa
commit 93185369b9
15 changed files with 3559 additions and 2824 deletions

View File

@ -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
View File

@ -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` 同步更新

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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

View File

@ -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),
];
}

View File

@ -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,
};
}

View File

@ -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"}