Document front-end page updates
This commit is contained in:
parent
78f938791a
commit
90b7e4301f
68
CLAUDE.md
68
CLAUDE.md
|
|
@ -199,6 +199,30 @@ frontend/
|
|||
├── src/
|
||||
│ ├── api/ # 接口封装
|
||||
│ ├── pages/ # 页面级组件
|
||||
│ │ ├── ai-config/ # AI Config 页面模块目录(types/constants/components/sections)
|
||||
│ │ ├── ai-steward/ # AI Steward 页面模块目录(types/constants/components/sections)
|
||||
│ │ ├── charter-detail/ # Charter Detail 页面模块目录
|
||||
│ │ ├── contract-sync-board/ # Contract Sync Board 页面模块目录
|
||||
│ │ ├── dashboard/ # Dashboard 页面模块目录
|
||||
│ │ ├── feishu-config/ # Feishu Config 页面模块目录(helpers/components)
|
||||
│ │ ├── inbox/ # Inbox 页面模块目录(helpers/components)
|
||||
│ │ ├── knowledge-graph-context-board/ # KG Context Board 页面模块目录
|
||||
│ │ ├── login/ # Login 页面模块目录
|
||||
│ │ ├── model-routing-config/ # Model Routing Config 页面模块目录
|
||||
│ │ ├── project-board/ # Project Board 页面模块目录(helpers/components)
|
||||
│ │ ├── projects/ # Projects 页面模块目录(helpers/components)
|
||||
│ │ ├── recipes/ # Recipes 页面模块目录(helpers/components)
|
||||
│ │ ├── scheduler-center/ # Scheduler Center 页面模块目录
|
||||
│ │ ├── skill-tasks/ # Skill Tasks 页面模块目录
|
||||
│ │ ├── skills/ # Skills 页面模块目录
|
||||
│ │ ├── task-brief-center/ # Task Brief Center 页面模块目录
|
||||
│ │ ├── task-brief-runs/ # Task Brief Runs 页面模块目录
|
||||
│ │ ├── task-brief-template/ # Task Brief Template 页面模块目录
|
||||
│ │ ├── user-profile/ # User Profile 页面模块目录
|
||||
│ │ ├── user-skills/ # User Skills 页面模块目录
|
||||
│ │ ├── users/ # Users 页面模块目录(helpers/components)
|
||||
│ │ ├── workload-detail/ # Workload Detail 页面模块目录
|
||||
│ │ └── workloads/ # Workloads 页面模块目录
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # 基础 UI 组件
|
||||
│ │ ├── inbox/ # 消息台 Feed 列表组件
|
||||
|
|
@ -222,12 +246,44 @@ frontend/
|
|||
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
|
||||
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与双模型消费验证 API)
|
||||
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
|
||||
- 新增:`frontend/src/pages/TaskBriefCenterPage.tsx`(增量任务包中心页面)
|
||||
- 新增:`frontend/src/pages/TaskBriefTemplatePage.tsx`(任务包模板管理页面)
|
||||
- 新增:`frontend/src/pages/TaskBriefRunsPage.tsx`(任务包生成记录页面)
|
||||
- 新增:`frontend/src/pages/ContractSyncBoardPage.tsx`(契约同步看板页面)
|
||||
- 新增:`frontend/src/pages/ModelRoutingConfigPage.tsx`(模型路由配置页面)
|
||||
- 新增:`frontend/src/pages/KnowledgeGraphContextBoardPage.tsx`(知识图谱与双模型上下文看板)
|
||||
- 更新:`frontend/src/pages/CharterDetailPage.tsx`、`ContractSyncBoardPage.tsx`、`DashboardPage.tsx`、`KnowledgeGraphContextBoardPage.tsx`、`LoginPage.tsx`、`ModelRoutingConfigPage.tsx`、`SchedulerCenterPage.tsx`、`SkillTasksPage.tsx`、`SkillsPage.tsx`、`TaskBriefCenterPage.tsx`、`TaskBriefRunsPage.tsx`、`TaskBriefTemplatePage.tsx`、`UserProfilePage.tsx`、`UserSkillsPage.tsx`、`WorkloadDetailPage.tsx`、`WorkloadsPage.tsx`(统一降级为轻量入口,转发到 `pages/*/` 子目录)
|
||||
- 新增:`frontend/src/pages/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛)
|
||||
- 新增:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`、`skill-tasks/SkillTasksPage.tsx`、`skills/SkillsPage.tsx`、`task-brief-center/TaskBriefCenterPage.tsx`、`task-brief-runs/TaskBriefRunsPage.tsx`、`task-brief-template/TaskBriefTemplatePage.tsx`(任务/调度/技能页面目录化收敛)
|
||||
- 新增:`frontend/src/pages/user-profile/UserProfilePage.tsx`、`user-skills/UserSkillsPage.tsx`、`workload-detail/WorkloadDetailPage.tsx`、`workloads/WorkloadsPage.tsx`(用户与负载页面目录化收敛)
|
||||
- 更新:`frontend/src/pages/InboxPage.tsx`(降级为轻量入口,转发到 `pages/inbox/InboxPage`)
|
||||
- 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper)
|
||||
- 新增:`frontend/src/pages/inbox/components/InboxFiltersPanel.tsx`(Inbox 页面顶部筛选/搜索/tab 面板拆分)
|
||||
- 新增:`frontend/src/pages/inbox/InboxPage.tsx`(Inbox 页面编排层,主文件聚焦状态与动作流转)
|
||||
- 更新:`frontend/src/pages/RecipesPage.tsx`(降级为轻量入口,转发到 `pages/recipes/RecipesPage`)
|
||||
- 新增:`frontend/src/pages/recipes/helpers.ts`(Recipes 页面默认表单与分类常量)
|
||||
- 新增:`frontend/src/pages/recipes/components/CreateRecipeDialog.tsx`、`AnalyzeRecipeDialog.tsx`(Recipes 页面手动创建/AI 识别弹窗拆分)
|
||||
- 新增:`frontend/src/pages/recipes/RecipesPage.tsx`(Recipes 页面编排层,主文件聚焦列表筛选与状态)
|
||||
- 更新:`frontend/src/pages/FeishuConfigPage.tsx`(降级为轻量入口,转发到 `pages/feishu-config/FeishuConfigPage`)
|
||||
- 新增:`frontend/src/pages/feishu-config/helpers.ts`(Feishu Config 表单与状态类型、默认配置常量)
|
||||
- 新增:`frontend/src/pages/feishu-config/components/SetupGuideCard.tsx`(Feishu Config 配置指南卡片拆分)
|
||||
- 新增:`frontend/src/pages/feishu-config/FeishuConfigPage.tsx`(Feishu Config 页面编排层,主文件聚焦状态与交互)
|
||||
- 更新:`frontend/src/pages/AIConfigPage.tsx`(降级为轻量入口,转发到 `pages/ai-config/AIConfigPage`)
|
||||
- 新增:`frontend/src/pages/ai-config/types.ts`、`frontend/src/pages/ai-config/constants.ts`(AI Config 类型与常量集中管理)
|
||||
- 新增:`frontend/src/pages/ai-config/components/GovernanceToolsCard.tsx`、`StatusBadge.tsx`、`PriorityBadge.tsx`(AI Config 复用展示组件)
|
||||
- 新增:`frontend/src/pages/ai-config/sections/OverviewTab.tsx`、`ConfigureTab.tsx`、`KnowledgeTab.tsx`、`AdvancedTab.tsx`(AI Config 分阶段 UI 拆分)
|
||||
- 新增:`frontend/src/pages/ai-config/AIConfigPage.tsx`(AI Config 页面编排层,主文件仅保留状态与事件编排)
|
||||
- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`)
|
||||
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
|
||||
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
||||
- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`(AI Steward 分阶段 UI 拆分)
|
||||
- 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件)
|
||||
- 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
|
||||
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
|
||||
- 新增:`frontend/src/pages/project-board/components/CreateCharterDialog.tsx`、`ProjectSettingsDialog.tsx`(Project Board 大体量弹窗组件拆分)
|
||||
- 新增:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(Project Board 页面编排层)
|
||||
- 更新:`frontend/src/pages/ProjectsPage.tsx`(降级为轻量入口,转发到 `pages/projects/ProjectsPage`)
|
||||
- 新增:`frontend/src/pages/projects/helpers.ts`(Projects 页面链路阶段、状态样式、权限与错误解析 helper)
|
||||
- 新增:`frontend/src/pages/projects/components/ProjectQueryDialog.tsx`、`DeleteProjectDialog.tsx`(Projects 页面查询/删除弹窗拆分)
|
||||
- 新增:`frontend/src/pages/projects/ProjectsPage.tsx`(Projects 页面编排层,主文件聚焦状态与事件)
|
||||
- 更新:`frontend/src/pages/UsersPage.tsx`(降级为轻量入口,转发到 `pages/users/UsersPage`)
|
||||
- 新增:`frontend/src/pages/users/helpers.ts`(Users 页面角色/密码/CSV 预览 helper 聚合)
|
||||
- 新增:`frontend/src/pages/users/components/DeleteUserDialog.tsx`、`ResetPasswordDialog.tsx`、`ImportUsersDialog.tsx`、`BulkAssignDialog.tsx`(Users 页面大体量弹窗组件拆分)
|
||||
- 新增:`frontend/src/pages/users/UsersPage.tsx`(Users 页面编排层,主文件聚焦状态与事件)
|
||||
- 更新:`frontend/src/pages/AIStewardPage.tsx`(移除 OpenClaw 指挥调度入口,新增本地 LLM 开关与严格失败边界说明)
|
||||
- 移除:`frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx`(OpenClaw 指挥调度面板)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,431 +1 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Radio, ListTodo, Plus } from "lucide-react";
|
||||
import { sstcApi } from "@/api/sstc";
|
||||
import type { SSTCSignal, SSTCTask } from "@/api/sstc";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toZhPriority, toZhStatus } from "@/lib/display";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import {
|
||||
CHARTER_MODULE_SCHEMAS,
|
||||
calculateCharterProgress,
|
||||
parseCharterModuleDraft,
|
||||
} from "@/components/sstc/charterModules";
|
||||
|
||||
export default function CharterDetailPage() {
|
||||
const { id, charterId } = useParams<{ id: string; charterId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [createTaskOpen, setCreateTaskOpen] = useState(false);
|
||||
const [sourceSignalOpen, setSourceSignalOpen] = useState(false);
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [newTaskDesc, setNewTaskDesc] = useState("");
|
||||
const [autoPrefillTried, setAutoPrefillTried] = useState(false);
|
||||
|
||||
const { data: charter, isLoading, error } = useQuery({
|
||||
queryKey: ["charter", id, charterId],
|
||||
queryFn: () => sstcApi.getCharterDetail(id!, charterId!),
|
||||
enabled: !!id && !!charterId,
|
||||
});
|
||||
|
||||
const charterModuleDraft = useMemo(
|
||||
() =>
|
||||
parseCharterModuleDraft(
|
||||
(charter?.content as Record<string, unknown>) || {}
|
||||
),
|
||||
[charter?.content]
|
||||
);
|
||||
const charterProgress = useMemo(
|
||||
() => calculateCharterProgress(charterModuleDraft),
|
||||
[charterModuleDraft]
|
||||
);
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
sstcApi.createTask(id!, charterId!, {
|
||||
title: newTaskTitle,
|
||||
description: newTaskDesc || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["charter", id, charterId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-chain", id] });
|
||||
setCreateTaskOpen(false);
|
||||
setNewTaskTitle("");
|
||||
setNewTaskDesc("");
|
||||
},
|
||||
});
|
||||
|
||||
const aiPrefillMutation = useMutation({
|
||||
mutationFn: () => sstcApi.aiPrefillCharter(id!, charterId!, {}),
|
||||
onSuccess: () => {
|
||||
setAutoPrefillTried(true);
|
||||
queryClient.invalidateQueries({ queryKey: ["charter", id, charterId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-charter-detail", id, charterId] });
|
||||
},
|
||||
onError: () => {
|
||||
setAutoPrefillTried(true);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!charter || !id || !charterId) return;
|
||||
if (autoPrefillTried || aiPrefillMutation.isPending) return;
|
||||
const hasSignal = (charter.signal_ids?.length || 0) > 0 || !!charter.source_signal;
|
||||
if (!hasSignal) return;
|
||||
if (charterProgress.completedModules >= CHARTER_MODULE_SCHEMAS.length) return;
|
||||
setAutoPrefillTried(true);
|
||||
aiPrefillMutation.mutate();
|
||||
}, [
|
||||
charter,
|
||||
id,
|
||||
charterId,
|
||||
autoPrefillTried,
|
||||
aiPrefillMutation,
|
||||
charterProgress.completedModules,
|
||||
]);
|
||||
|
||||
const handleDownloadSignalAttachment = async (signalId: string, attachmentId: string, fileName?: string) => {
|
||||
try {
|
||||
const blob = await sstcApi.downloadSignalAttachment(id!, signalId, attachmentId);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName || `attachment-${attachmentId}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: getApiErrorMessage(error, "附件下载失败"),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/projects/${id}/board`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{charter?.title || "需求宪章"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
项目 {id} · 宪章 {charterId} · SSTC 链路
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="sm:ml-auto"
|
||||
onClick={() => setCreateTaskOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-destructive">
|
||||
加载失败
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : charter ? (
|
||||
<>
|
||||
{charter.source_signal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceSignalOpen(true)}
|
||||
className="flex w-full items-center justify-between rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-left text-sm transition hover:bg-amber-100"
|
||||
>
|
||||
<span>
|
||||
来源信号 #{charter.source_signal.id}: {charter.source_signal.title || "未命名信号"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">查看详情 ↗</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>宪章内容</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
版本 v{charter.version} · {toZhStatus(charter.status)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{aiPrefillMutation.isPending && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700">
|
||||
AI 正在根据信号源生成 6 模块草稿,请稍候…
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">模块回填进度</p>
|
||||
<Badge className="border">
|
||||
{charterProgress.requiredPercent}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
完成模块 {charterProgress.completedModules}/
|
||||
{CHARTER_MODULE_SCHEMAS.length} · 必填字段{" "}
|
||||
{charterProgress.requiredDone}/{charterProgress.requiredTotal}
|
||||
</p>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary"
|
||||
style={{ width: `${charterProgress.requiredPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
草稿状态:{toZhStatus(charterModuleDraft.metadata.status || "draft")} ·
|
||||
置信度:{" "}
|
||||
{charterModuleDraft.metadata.confidence_score || "-"} ·
|
||||
最近校验:{" "}
|
||||
{formatBeijingDateTime(charterModuleDraft.metadata.last_validated_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{CHARTER_MODULE_SCHEMAS.map((module) => {
|
||||
const moduleState = charterProgress.moduleProgress[module.key];
|
||||
return (
|
||||
<div key={module.key} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{module.shortTitle}</p>
|
||||
<Badge className="border">
|
||||
{moduleState.requiredDone}/{moduleState.requiredTotal}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{module.fields
|
||||
.filter((field) => field.editable !== false)
|
||||
.map((field) => {
|
||||
const value =
|
||||
charterModuleDraft.modules[module.key][field.key] || "";
|
||||
if (!value.trim()) return null;
|
||||
return (
|
||||
<div key={`${module.key}-${field.key}`}>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{field.label}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{moduleState.missing.length > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
缺少必填:{moduleState.missing.join("、")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5" />
|
||||
关联信号源 ({charter.signals?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{charter.signals?.length ? (
|
||||
<div className="space-y-2">
|
||||
{charter.signals.map((s: SSTCSignal) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="rounded-lg border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
#{s.id} · {s.source} · {toZhPriority(s.priority)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{s.content}</p>
|
||||
{(s.attachments || []).length > 0 && (
|
||||
<div className="mt-2 space-y-1 rounded border border-slate-200 bg-white p-2">
|
||||
<p className="text-[11px] text-slate-500">附件 {(s.attachments || []).length} 个</p>
|
||||
{(s.attachments || []).map((attachment) => (
|
||||
<div key={`${s.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]"
|
||||
onClick={() => handleDownloadSignalAttachment(s.id, attachment.attachment_id, attachment.file_name)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">无关联信号</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListTodo className="h-5 w-5" />
|
||||
任务列表 ({charter.tasks?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{charter.tasks?.length ? (
|
||||
<div className="space-y-2">
|
||||
{charter.tasks.map((t: SSTCTask) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center justify-between rounded border px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{t.title}</p>
|
||||
{t.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
t.status === "done" || t.status === "completed"
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{toZhStatus(t.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无任务</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Dialog open={createTaskOpen} onOpenChange={setCreateTaskOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
任务将绑定到当前宪章(SSTC 强制约束)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">任务标题</label>
|
||||
<Input
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
placeholder="例:接入视频生成接口"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">描述(可选)</label>
|
||||
<Input
|
||||
value={newTaskDesc}
|
||||
onChange={(e) => setNewTaskDesc(e.target.value)}
|
||||
placeholder="任务详细说明"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateTaskOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createTaskMutation.mutate()}
|
||||
disabled={
|
||||
!newTaskTitle.trim() || createTaskMutation.isPending
|
||||
}
|
||||
>
|
||||
{createTaskMutation.isPending ? "创建中…" : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={sourceSignalOpen} onOpenChange={setSourceSignalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>来源信号(只读)</DialogTitle>
|
||||
<DialogDescription>用于回溯该宪章的来源上下文</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ID:#{charter?.source_signal?.id || "-"} · 类型:{charter?.source_signal?.signal_type || "-"} · 优先级:{toZhPriority(charter?.source_signal?.priority)}
|
||||
</p>
|
||||
<p className="text-sm font-medium">{charter?.source_signal?.title || "-"}</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{charter?.source_signal?.content || "-"}
|
||||
</p>
|
||||
{(charter?.source_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] text-slate-500">附件 {(charter?.source_signal?.attachments || []).length} 个</p>
|
||||
{(charter?.source_signal?.attachments || []).map((attachment) => (
|
||||
<div key={`source-${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]"
|
||||
onClick={() =>
|
||||
charter?.source_signal &&
|
||||
handleDownloadSignalAttachment(
|
||||
charter.source_signal.id,
|
||||
attachment.attachment_id,
|
||||
attachment.file_name
|
||||
)
|
||||
}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setSourceSignalOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./charter-detail/CharterDetailPage";
|
||||
|
|
|
|||
|
|
@ -1,110 +1 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { taskBriefApi } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function ContractSyncBoardPage() {
|
||||
const syncQuery = useQuery({
|
||||
queryKey: ["task-brief-contract-sync"],
|
||||
queryFn: () => taskBriefApi.getContractSyncStatus(),
|
||||
});
|
||||
const data = syncQuery.data;
|
||||
const files = data?.files || [];
|
||||
const errorText = syncQuery.error instanceof Error ? syncQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="契约同步看板"
|
||||
description="持续检查关键契约文档是否与当前功能实现同步。"
|
||||
icon={<ShieldCheck className="h-5 w-5" />}
|
||||
tone="amber"
|
||||
meta={
|
||||
<span>
|
||||
状态:
|
||||
{syncQuery.isLoading ? "检查中" : syncQuery.isError ? "检查失败" : data?.all_synced ? "已同步" : "存在缺失"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="contract-sync-board"
|
||||
title="契约同步看板"
|
||||
purpose="检查关键架构契约文档是否存在,避免跨端协作时口径不一致。"
|
||||
whenToUse="做接口改动、跨端联调、发版前回归时都应该看一眼。"
|
||||
steps={[
|
||||
"先看顶部状态是“已同步”还是“存在缺失”。",
|
||||
"若缺失,按文件清单补齐对应文档。",
|
||||
"补齐后点击重试,确认状态恢复为已同步。",
|
||||
]}
|
||||
outputs={[
|
||||
"契约文件缺失清单",
|
||||
"发版前的文档一致性检查结果",
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">契约文件明细</CardTitle>
|
||||
<CardDescription>缺失项会直接影响跨端协作与回归可靠性。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{syncQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : syncQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">同步状态读取失败:{errorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void syncQuery.refetch()}
|
||||
disabled={syncQuery.isFetching}
|
||||
>
|
||||
{syncQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无契约文件记录。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文件</TableHead>
|
||||
<TableHead>存在性</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((item) => (
|
||||
<TableRow key={item.file} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{item.file}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={
|
||||
item.exists
|
||||
? "inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-200"
|
||||
: "inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-900/35 dark:text-rose-200"
|
||||
}
|
||||
>
|
||||
{item.exists ? "已存在" : "缺失"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./contract-sync-board/ContractSyncBoardPage";
|
||||
|
|
|
|||
|
|
@ -1,120 +1 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface WorkloadItem {
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
username: string;
|
||||
estimated_hours: number;
|
||||
total_tasks: number;
|
||||
workload_rate?: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", "workload", "team"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ items?: WorkloadItem[] } | WorkloadItem[]>("/skills/workload/team");
|
||||
const raw = res.data;
|
||||
return Array.isArray(raw) ? raw : (raw?.items ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
const chartData = items.map((d) => ({
|
||||
name: d.user_name || d.username,
|
||||
工时: d.estimated_hours ?? 0,
|
||||
任务数: d.total_tasks ?? 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">团队负载看板</h1>
|
||||
<p className="text-muted-foreground">查看团队成员工作负载分布</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>工时分布</CardTitle>
|
||||
<CardDescription>各成员预估工时(小时)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-destructive">
|
||||
加载失败
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<XAxis type="number" />
|
||||
<YAxis type="category" dataKey="name" width={80} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="工时" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>负载明细</CardTitle>
|
||||
<CardDescription>按成员统计任务数与工时</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="text-destructive">加载失败</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground">暂无数据</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[520px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead className="text-right">任务数</TableHead>
|
||||
<TableHead className="text-right">工时 (h)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.user_id}>
|
||||
<TableCell>{row.user_name || row.username}</TableCell>
|
||||
<TableCell className="text-right">{row.total_tasks}</TableCell>
|
||||
<TableCell className="text-right">{(row.estimated_hours ?? 0).toFixed(1)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./dashboard/DashboardPage";
|
||||
|
|
|
|||
|
|
@ -1,471 +1 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Settings, CheckCircle2, AlertCircle, Loader2, Key } from "lucide-react";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
FEISHU_EVENT_MODE_OPTIONS,
|
||||
type FeishuEventMode,
|
||||
getFeishuEventModeLabel,
|
||||
} from "@/lib/feishuEventMode";
|
||||
|
||||
export default function FeishuConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const [config, setConfig] = useState({
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
event_mode: "long_connection" as FeishuEventMode
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
configured: boolean;
|
||||
app_id?: string;
|
||||
event_mode?: FeishuEventMode;
|
||||
} | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
// 加载当前配置状态
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const mode = "long_connection" as FeishuEventMode;
|
||||
// API returns is_configured, frontend expects configured
|
||||
setStatus({
|
||||
configured: data.is_configured || false,
|
||||
app_id: data.app_id,
|
||||
event_mode: mode,
|
||||
});
|
||||
setConfig({
|
||||
app_id: data.app_id || "",
|
||||
app_secret: data.app_secret || "",
|
||||
event_mode: mode,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load status:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
if (!config.app_id || !config.app_secret) {
|
||||
setMessage({ type: "error", text: "请填写应用编号和应用密钥" });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await response.json();
|
||||
setMessage({ type: "success", text: "飞书配置已保存" });
|
||||
await loadStatus();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: "error", text: error.detail || "保存失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "网络错误,请重试" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 测试连接
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/test-connection`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === "success") {
|
||||
setMessage({ type: "success", text: "飞书连接测试成功!" });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.message || "连接测试失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "测试失败,请检查配置" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除配置
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("确定要删除飞书配置吗?")) return;
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: "success", text: "配置已删除" });
|
||||
setStatus(null);
|
||||
setConfig({ app_id: "", app_secret: "", event_mode: "long_connection" });
|
||||
await loadStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "删除失败" });
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载状态
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Settings className="w-10 h-10 text-violet-500" />
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
飞书集成配置
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
配置飞书应用后,AI 可以自动从群聊中捕获需求信号
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>配置状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{status.configured ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{status.configured ? "已配置" : "未配置"}
|
||||
</p>
|
||||
{status.app_id && (
|
||||
<p className="text-sm text-slate-500">应用编号: {status.app_id}</p>
|
||||
)}
|
||||
{status.event_mode && (
|
||||
<p className="text-sm text-slate-500">
|
||||
接收模式: {getFeishuEventModeLabel(status.event_mode)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">加载中…</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configuration Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>飞书应用配置</CardTitle>
|
||||
<CardDescription>
|
||||
从飞书开放平台获取应用凭证
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">事件接收方式</Label>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
飞书接入已统一为 <code>long_connection</code>
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{FEISHU_EVENT_MODE_OPTIONS.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 rounded border px-3 py-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feishu_event_mode"
|
||||
value={option.value}
|
||||
checked={config.event_mode === option.value}
|
||||
onChange={() => setConfig({ ...config, event_mode: option.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
<code className="text-[11px] text-slate-500">({option.value})</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="app_id">应用编号</Label>
|
||||
<Input
|
||||
id="app_id"
|
||||
placeholder="cli_xxxxxxxxxxxxx"
|
||||
value={config.app_id}
|
||||
onChange={(e) => setConfig({ ...config, app_id: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="app_secret">应用密钥</Label>
|
||||
<Input
|
||||
id="app_secret"
|
||||
type="password"
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
value={config.app_secret}
|
||||
onChange={(e) => setConfig({ ...config, app_secret: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>运行说明</Label>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
已切换到飞书长连接模式。无需配置 Webhook、Encrypt Key 或 Verification Token。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`p-3 rounded-lg flex items-center gap-2 ${
|
||||
message.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
|
||||
}`}>
|
||||
{message.type === "success" ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-violet-600 hover:bg-violet-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
{status?.configured ? "更新配置" : "保存配置"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !status?.configured}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
测试中…
|
||||
</>
|
||||
) : (
|
||||
"测试连接"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{status?.configured && (
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="destructive"
|
||||
disabled={loading}
|
||||
>
|
||||
删除配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Setup Guide */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>配置指南</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="step1">
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="step1" className="shrink-0 whitespace-nowrap">1. 创建应用</TabsTrigger>
|
||||
<TabsTrigger value="step2" className="shrink-0 whitespace-nowrap">2. 获取凭证</TabsTrigger>
|
||||
<TabsTrigger value="step3" className="shrink-0 whitespace-nowrap">3. 配置事件</TabsTrigger>
|
||||
<TabsTrigger value="step4" className="shrink-0 whitespace-nowrap">4. 回调地址</TabsTrigger>
|
||||
<TabsTrigger value="step5" className="shrink-0 whitespace-nowrap">5. 完成</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="step1">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
访问飞书开放平台创建企业自建应用:
|
||||
</p>
|
||||
<a
|
||||
href="https://open.feishu.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-600 hover:underline text-sm"
|
||||
>
|
||||
https://open.feishu.cn/
|
||||
</a>
|
||||
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-400 space-y-2">
|
||||
<li>进入「开放平台」→「创建自建应用」</li>
|
||||
<li>选择「企业自建应用」类型</li>
|
||||
<li>填写应用名称(如:AI 项目助手)</li>
|
||||
<li>选择应用图标</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step2">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
在应用的「凭证与基础信息」页面获取:
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge>应用编号</Badge>
|
||||
<span className="text-sm">复制应用编号到上方配置表单</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge>应用密钥</Badge>
|
||||
<span className="text-sm">点击「查看」按钮,复制到上方配置表单</span>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step3">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
飞书已统一切换到长连接模式,这一步只需要确认权限即可:
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge>长连接</Badge>
|
||||
<span className="text-sm">无需再配置回调地址、Encrypt Key、Verification Token</span>
|
||||
</div>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
⚠️ 必须先保存配置,才能进行下一步
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
当前为「长连接」模式,无需配置任何回调地址。
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm font-medium">添加权限:</p>
|
||||
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-400 space-y-1">
|
||||
<li>im:message - 发送消息</li>
|
||||
<li>im:message:group_at_msg - 群@消息</li>
|
||||
<li>im:chat - 聊天信息</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step5">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
完成配置后:
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">AI 会自动监听飞书群聊消息</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">识别有价值的讨论并创建信号</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">自动推送到相关项目</span>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 p-3 bg-violet-50 dark:bg-violet-900/20 rounded-lg">
|
||||
<p className="text-sm text-violet-800 dark:text-violet-200">
|
||||
💡 提示:配置完成后,后端会自动建立长连接,在飞书群中@机器人即可测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./feishu-config/FeishuConfigPage";
|
||||
|
|
|
|||
|
|
@ -1,450 +1 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { notificationsApi } from "@/api/notifications";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import InboxActionPanel, { type InboxProjectOption } from "@/components/inbox/InboxActionPanel";
|
||||
import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { parseBackendDate } from "@/lib/datetime";
|
||||
import { buildInboxView } from "@/lib/inbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
|
||||
|
||||
const POLL_INTERVAL_MS = 15000;
|
||||
|
||||
type InboxTabKey = "all" | "needs_action" | "in_progress" | "done";
|
||||
|
||||
const TAB_LABELS: Record<InboxTabKey, string> = {
|
||||
all: "全部",
|
||||
needs_action: "待处理",
|
||||
in_progress: "进行中",
|
||||
done: "已完成",
|
||||
};
|
||||
|
||||
const TAB_TO_DISPLAY_KIND: Partial<Record<InboxTabKey, InboxDisplayKind>> = {
|
||||
needs_action: "action_required",
|
||||
in_progress: "auto_running",
|
||||
done: "auto_done",
|
||||
};
|
||||
|
||||
function normalizeTabKey(raw?: string | null): InboxTabKey {
|
||||
const value = String(raw || "").trim();
|
||||
if (value === "action_required" || value === "needs_action") return "needs_action";
|
||||
if (value === "auto_running" || value === "in_progress") return "in_progress";
|
||||
if (value === "auto_done" || value === "done") return "done";
|
||||
return "all";
|
||||
}
|
||||
|
||||
function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] {
|
||||
const toTimestamp = (item: InboxItemViewModel): number => {
|
||||
const date = parseBackendDate(item.updated_at || item.created_at);
|
||||
return date ? date.getTime() : -1;
|
||||
};
|
||||
|
||||
return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a));
|
||||
}
|
||||
|
||||
function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean {
|
||||
if (tab === "all") return true;
|
||||
return item.display_kind === TAB_TO_DISPLAY_KIND[tab];
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
const integer = Math.trunc(parsed);
|
||||
return integer > 0 ? integer : null;
|
||||
}
|
||||
|
||||
function isMissingProjectScopeItem(item: InboxItemViewModel): boolean {
|
||||
if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return false;
|
||||
const state = String(item.source_work_item.state || "").toLowerCase();
|
||||
const gateCode = String(item.latest_policy_gate_code || item.source_work_item.latest_policy_gate_code || "").toLowerCase();
|
||||
return state === "waiting_manual" && gateCode === "missing_project_scope";
|
||||
}
|
||||
|
||||
export default function InboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||
const [selectedProjectByItem, setSelectedProjectByItem] = useState<Record<string, string>>({});
|
||||
const [autoExpandedByQuery, setAutoExpandedByQuery] = useState(false);
|
||||
|
||||
const activeTab = normalizeTabKey(searchParams.get("section"));
|
||||
const projectFilter = searchParams.get("project_id") || "all";
|
||||
const searchValue = searchParams.get("q") || "";
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ["notifications", "inbox"],
|
||||
queryFn: () => notificationsApi.list({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const workItemsQuery = useQuery({
|
||||
queryKey: ["ai-pmo-work-items", "inbox"],
|
||||
queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const activeProjectsQuery = useQuery({
|
||||
queryKey: ["projects", "active", "inbox-inline-actions"],
|
||||
queryFn: () => projectsApi.list({ status: "active" }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const inboxData = useMemo(() => {
|
||||
return buildInboxView({
|
||||
notifications: notificationsQuery.data || { items: [], unread_count: 0 },
|
||||
workItems: workItemsQuery.data || { items: [] },
|
||||
});
|
||||
}, [notificationsQuery.data, workItemsQuery.data]);
|
||||
|
||||
const projectOptions = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of inboxData.items) {
|
||||
if (!item.project_id) continue;
|
||||
map.set(String(item.project_id), item.project_name || `项目 #${item.project_id}`);
|
||||
}
|
||||
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
|
||||
}, [inboxData.items]);
|
||||
|
||||
const activeProjectOptions = useMemo<InboxProjectOption[]>(() => {
|
||||
const projects = activeProjectsQuery.data || [];
|
||||
return projects.map((project) => ({
|
||||
value: String(project.id),
|
||||
label: String(project.title || `项目 #${project.id}`),
|
||||
}));
|
||||
}, [activeProjectsQuery.data]);
|
||||
|
||||
const projectScopedItems = useMemo(() => {
|
||||
return inboxData.items.filter((item) => {
|
||||
if (projectFilter === "all") return true;
|
||||
return String(item.project_id || "") === projectFilter;
|
||||
});
|
||||
}, [inboxData.items, projectFilter]);
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
all: projectScopedItems.length,
|
||||
needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
|
||||
in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
|
||||
done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length,
|
||||
} satisfies Record<InboxTabKey, number>;
|
||||
}, [projectScopedItems]);
|
||||
|
||||
const searchedItems = useMemo(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
if (!query) return projectScopedItems;
|
||||
|
||||
return projectScopedItems.filter((item) => {
|
||||
return `${item.title} ${item.summary} ${item.project_name || ""} ${item.stage_text || ""}`
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
}, [projectScopedItems, searchValue]);
|
||||
|
||||
const feedItems = useMemo(() => {
|
||||
const tabItems = searchedItems.filter((item) => matchesTab(item, activeTab));
|
||||
return sortByLatest(tabItems);
|
||||
}, [searchedItems, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoExpandedByQuery) return;
|
||||
const targetId = parsePositiveInt(searchParams.get("workItemId"));
|
||||
if (!targetId) return;
|
||||
const targetKey = `work-item-${targetId}`;
|
||||
const matched = feedItems.find((item) => item.id === targetKey);
|
||||
if (matched) {
|
||||
setExpandedItemId(targetKey);
|
||||
setAutoExpandedByQuery(true);
|
||||
}
|
||||
}, [autoExpandedByQuery, feedItems, searchParams]);
|
||||
|
||||
const setTab = (tab: string) => {
|
||||
const normalized = normalizeTabKey(tab);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", normalized);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const setProject = (projectId: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (projectId === "all") next.delete("project_id");
|
||||
else next.set("project_id", projectId);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (!value.trim()) next.delete("q");
|
||||
else next.set("q", value.trim());
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const toggleExpand = (itemId: string) => {
|
||||
setExpandedItemId((current) => (current === itemId ? null : itemId));
|
||||
};
|
||||
|
||||
const getSelectedProjectId = (item: InboxItemViewModel): number | null => {
|
||||
const selected = selectedProjectByItem[item.id];
|
||||
if (selected) return parsePositiveInt(selected);
|
||||
if (item.project_id) return parsePositiveInt(item.project_id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSelectedProjectValue = (item: InboxItemViewModel): string => {
|
||||
const selected = selectedProjectByItem[item.id];
|
||||
if (selected) return selected;
|
||||
return item.project_id ? String(item.project_id) : "";
|
||||
};
|
||||
|
||||
const setSelectedProjectValue = (item: InboxItemViewModel, value: string) => {
|
||||
setSelectedProjectByItem((current) => ({ ...current, [item.id]: value }));
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-pmo-work-items", "inbox"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const markNotificationRead = async (item: InboxItemViewModel) => {
|
||||
if (item.source_kind === "notification" && item.source_notification && !item.source_notification.is_read) {
|
||||
try {
|
||||
await notificationsApi.markRead(Number(item.source_notification.id));
|
||||
} catch {
|
||||
// 不阻断后续操作。
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openTarget = async (item: InboxItemViewModel) => {
|
||||
await markNotificationRead(item);
|
||||
|
||||
if (item.target_url) {
|
||||
if (item.target_url.startsWith("/")) navigate(item.target_url);
|
||||
else window.location.assign(item.target_url);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ title: "当前消息暂无可打开的目标入口", variant: "default" });
|
||||
};
|
||||
|
||||
const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => {
|
||||
const actionKey = buildInboxActionKey(item, action);
|
||||
setPendingActionKey(actionKey);
|
||||
|
||||
try {
|
||||
if (action.kind === "open_target") {
|
||||
await openTarget(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "open_detail") {
|
||||
await markNotificationRead(item);
|
||||
toggleExpand(item.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const missingProjectScope = isMissingProjectScopeItem(item);
|
||||
const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null);
|
||||
if (missingProjectScope && !selectedProjectId) {
|
||||
setExpandedItemId(item.id);
|
||||
toast({ title: "请先选择关联项目后再确认", variant: "default" });
|
||||
return;
|
||||
}
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "approve",
|
||||
project_id: selectedProjectId ?? undefined,
|
||||
});
|
||||
await refreshAll();
|
||||
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
if (result.status === "succeeded") {
|
||||
toast({ title: followupReply || "已继续执行并完成", variant: "success" });
|
||||
} else if (result.status === "pending_manual") {
|
||||
toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" });
|
||||
} else {
|
||||
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
|
||||
}
|
||||
setSelectedProjectByItem((current) => {
|
||||
if (!(item.id in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[item.id];
|
||||
return next;
|
||||
});
|
||||
setExpandedItemId(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "reject",
|
||||
reason: "人工审批拒绝,已标记失效",
|
||||
});
|
||||
await refreshAll();
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
|
||||
await notificationsApi.markRead(item.source_notification.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "none") {
|
||||
toggleExpand(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error), variant: "error" });
|
||||
} finally {
|
||||
setPendingActionKey((current) => (current === actionKey ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await notificationsApi.markAllRead();
|
||||
await refreshAll();
|
||||
toast({ title: "站内通知已全部设为已读", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
|
||||
const activeProjectErrorText = activeProjectsQuery.isError
|
||||
? getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-12">
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-3 md:p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 md:text-2xl">消息台</h1>
|
||||
<p className="mt-1 text-xs text-slate-500 md:text-sm">列表 Feed · 最新在上 · 支持移动端</p>
|
||||
<p className="mt-1 text-xs text-slate-500">当前结果:{feedItems.length} 条</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={markAllRead}
|
||||
disabled={notificationsQuery.data?.unread_count === 0}
|
||||
>
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Input
|
||||
placeholder="搜索标题、副标题、项目"
|
||||
defaultValue={searchValue}
|
||||
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
|
||||
onBlur={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(event) => setProject(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
{projectOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
type="button"
|
||||
onClick={() => setTab(tabKey)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
|
||||
activeTab === tabKey
|
||||
? "border-slate-300 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white"
|
||||
)}
|
||||
>
|
||||
<span>{TAB_LABELS[tabKey]}</span>
|
||||
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
加载消息台中…
|
||||
</div>
|
||||
</section>
|
||||
) : feedItems.length === 0 ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="rounded-xl bg-white px-4 py-10 text-center text-slate-500">
|
||||
<BellRing className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="font-medium text-slate-700">当前没有匹配收件项</p>
|
||||
<p className="mt-1 text-sm">请调整筛选条件后重试</p>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="space-y-2 rounded-2xl bg-[#F8F9FA] p-2 md:p-3">
|
||||
{feedItems.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedItemId === item.id}
|
||||
pendingActionKey={pendingActionKey}
|
||||
inlinePanel={
|
||||
expandedItemId === item.id && isMissingProjectScopeItem(item) ? (
|
||||
<InboxActionPanel
|
||||
selectedProjectId={getSelectedProjectValue(item)}
|
||||
projectOptions={activeProjectOptions}
|
||||
loadingProjects={activeProjectsQuery.isLoading}
|
||||
projectsErrorText={activeProjectErrorText}
|
||||
pending={pendingActionKey === buildInboxActionKey(item, item.primary_action)}
|
||||
onSelectProject={(value) => setSelectedProjectValue(item, value)}
|
||||
onCancel={() => setExpandedItemId(null)}
|
||||
onConfirm={() => void handleAction(item, item.primary_action)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onToggleExpand={toggleExpand}
|
||||
onAction={(target, action) => void handleAction(target, action)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./inbox/InboxPage";
|
||||
|
|
|
|||
|
|
@ -1,303 +1 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Boxes, Database, GitBranch, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
knowledgeGraphApi,
|
||||
type ContextConsumptionVerification,
|
||||
type KnowledgeGraphCapabilitySummary,
|
||||
} from "@/api/knowledgeGraph";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
function statusBadge(pass: boolean, okText: string, failText: string) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
pass
|
||||
? "inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-200"
|
||||
: "inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-900/35 dark:text-rose-200"
|
||||
}
|
||||
>
|
||||
{pass ? okText : failText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KnowledgeGraphContextBoardPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canOperate = isManagerRole(user?.role);
|
||||
|
||||
const summaryQuery = useQuery<KnowledgeGraphCapabilitySummary>({
|
||||
queryKey: ["knowledge-graph-capability-summary"],
|
||||
queryFn: () => knowledgeGraphApi.getCapabilitySummary(),
|
||||
});
|
||||
|
||||
const verifyMutation = useMutation<ContextConsumptionVerification>({
|
||||
mutationFn: () => knowledgeGraphApi.verifyContextConsumption({ run_canary: true }),
|
||||
});
|
||||
|
||||
const summary = summaryQuery.data;
|
||||
const verification = verifyMutation.data;
|
||||
const loading = summaryQuery.isLoading;
|
||||
const errorText = summaryQuery.error instanceof Error ? summaryQuery.error.message : "请检查后端服务。";
|
||||
|
||||
const matrixRows = useMemo(
|
||||
() => summary?.function_model_template_matrix || [],
|
||||
[summary]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="知识图谱与模型上下文看板"
|
||||
description="统一展示 KG/任务模板/契约同步与双模型上下文消费验证状态。"
|
||||
icon={<Boxes className="h-5 w-5" />}
|
||||
tone="blue"
|
||||
meta={
|
||||
<span>
|
||||
状态:
|
||||
{loading
|
||||
? "加载中"
|
||||
: summaryQuery.isError
|
||||
? "读取失败"
|
||||
: summary?.coverage.template_ready && summary?.coverage.contract_ready
|
||||
? "基础就绪"
|
||||
: "待修复"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="knowledge-graph-board"
|
||||
title="知识图谱与模型上下文看板"
|
||||
purpose="确认 low/high 两个模型是否真正吃到 KG、模板和契约三类上下文。"
|
||||
whenToUse="你要判断“模型为什么表现异常、上下文是否生效”时优先看这里。"
|
||||
steps={[
|
||||
"先看 KG 状态、模板覆盖、契约同步三个基础区块是否就绪。",
|
||||
"点击“运行双模型验证”,触发 canary 与日志证据回查。",
|
||||
"查看函数矩阵和验证结果,定位是路由问题还是上下文注入问题。",
|
||||
]}
|
||||
outputs={[
|
||||
"双模型上下文消费通过/失败结论",
|
||||
"按函数可追溯的证据与路由快照",
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void summaryQuery.refetch()}
|
||||
disabled={summaryQuery.isFetching}
|
||||
>
|
||||
<RefreshCw className={`mr-1 h-4 w-4 ${summaryQuery.isFetching ? "animate-spin" : ""}`} />
|
||||
{summaryQuery.isFetching ? "刷新中..." : "刷新汇总"}
|
||||
</Button>
|
||||
{canOperate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => verifyMutation.mutate()}
|
||||
disabled={verifyMutation.isPending}
|
||||
>
|
||||
<ShieldCheck className={`mr-1 h-4 w-4 ${verifyMutation.isPending ? "animate-pulse" : ""}`} />
|
||||
{verifyMutation.isPending ? "验证中..." : "运行双模型验证"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">仅管理员可执行验证操作。</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summaryQuery.isError ? (
|
||||
<Card className="border-rose-200/70 bg-rose-50/80 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<CardContent className="py-4 text-sm text-rose-700 dark:text-rose-200">
|
||||
能力汇总读取失败:{errorText}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Database className="h-4 w-4" />
|
||||
KG 状态
|
||||
</CardTitle>
|
||||
<CardDescription>实体、关系、证据规模与最新同步信息。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<p>实体总数:{summary?.knowledge_graph.entity_total ?? "-"}</p>
|
||||
<p>关系总数:{summary?.knowledge_graph.relation_total ?? "-"}</p>
|
||||
<p>证据总数:{summary?.knowledge_graph.evidence_total ?? "-"}</p>
|
||||
<p>待处理事件:{summary?.knowledge_graph.pending_events ?? "-"}</p>
|
||||
<p>
|
||||
最新同步:
|
||||
{summary?.knowledge_graph.latest_sync_run
|
||||
? `${summary.knowledge_graph.latest_sync_run.run_type || "-"} / ${summary.knowledge_graph.latest_sync_run.status || "-"}`
|
||||
: "-"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">任务模板覆盖</CardTitle>
|
||||
<CardDescription>双模型链路要求模板的覆盖与缺口。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge(
|
||||
!!summary?.coverage.template_ready,
|
||||
"模板齐备",
|
||||
"存在缺失"
|
||||
)}
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
总模板 {summary?.task_templates.total ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<p>必需模板:{(summary?.task_templates.required_template_ids || []).join(", ") || "-"}</p>
|
||||
<p>缺失模板:{(summary?.task_templates.missing_template_ids || []).join(", ") || "无"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">契约同步状态</CardTitle>
|
||||
<CardDescription>只注入状态与缺失清单,不注入全文。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>{statusBadge(!!summary?.contract_sync.all_synced, "契约已同步", "契约未同步")}</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文件</TableHead>
|
||||
<TableHead>存在性</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(summary?.contract_sync.files || []).map((item) => (
|
||||
<TableRow key={item.file} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{item.file}</TableCell>
|
||||
<TableCell>{item.exists ? "已存在" : "缺失"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
函数-模型-模板矩阵
|
||||
</CardTitle>
|
||||
<CardDescription>固定四条函数链路的路由与模板绑定关系。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>函数</TableHead>
|
||||
<TableHead>层级</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{matrixRows.map((row) => (
|
||||
<TableRow key={row.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{row.function_name}</TableCell>
|
||||
<TableCell>{row.tier}</TableCell>
|
||||
<TableCell>{`${row.model_routing.provider}/${row.model_routing.model}`}</TableCell>
|
||||
<TableCell>{row.template_ids.join(", ") || "-"}</TableCell>
|
||||
<TableCell>{row.model_routing.source || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{matrixRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-slate-500 dark:text-slate-400">
|
||||
暂无矩阵数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">双模型上下文消费验证</CardTitle>
|
||||
<CardDescription>通过 canary + ai_call_logs 标记校验模型是否吃到三类上下文。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{statusBadge(!!verification?.overall_pass, "验证通过", "未通过/未执行")}
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
probe_id: {verification?.probe_id || "-"}
|
||||
</span>
|
||||
</div>
|
||||
{verifyMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
验证失败:
|
||||
{verifyMutation.error instanceof Error ? verifyMutation.error.message : "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>函数</TableHead>
|
||||
<TableHead>层级</TableHead>
|
||||
<TableHead>检查结果</TableHead>
|
||||
<TableHead>canary 状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(verification?.functions || []).map((item) => (
|
||||
<TableRow key={item.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{item.function_name}</TableCell>
|
||||
<TableCell>{item.tier}</TableCell>
|
||||
<TableCell>
|
||||
{item.pass
|
||||
? "marker/kg/template/contract 全部命中"
|
||||
: `缺失: ${Object.entries(item.checks)
|
||||
.filter(([, ok]) => !ok)
|
||||
.map(([key]) => key)
|
||||
.join(", ")}`}
|
||||
</TableCell>
|
||||
<TableCell>{item.canary.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(verification?.functions || []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-slate-500 dark:text-slate-400">
|
||||
还未执行验证
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./knowledge-graph-context-board/KnowledgeGraphContextBoardPage";
|
||||
|
|
|
|||
|
|
@ -1,117 +1 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import { clearSessionExpired } from "@/lib/sessionGuard";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirect = searchParams.get("redirect") || "/dashboard";
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const isAuthenticated = useAuthStore((s) => s.token != null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate(redirect, { replace: true });
|
||||
}, [isAuthenticated, navigate, redirect]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
// 允许在登录页重新发起鉴权,避免被过期标记拦截
|
||||
clearSessionExpired();
|
||||
const body = new URLSearchParams({ username, password }).toString();
|
||||
const { data } = await api.post<{ access_token: string; token_type: string; user?: Record<string, unknown> }>(
|
||||
"/auth/login",
|
||||
body,
|
||||
{ headers: { "Content-Type": "application/x-www-form-urlencoded" } }
|
||||
);
|
||||
const token = data.access_token;
|
||||
const user = data.user as { id: string; username: string; email?: string; name?: string; role?: string } | undefined;
|
||||
setAuth(token, user || { id: "", username });
|
||||
navigate(redirect, { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const ax = err as {
|
||||
code?: string;
|
||||
message?: string;
|
||||
response?: { status?: number; data?: { detail?: string } };
|
||||
};
|
||||
// 网络/隧道不可达(ERR_CONNECTION_CLOSED、ERR_NETWORK 等)
|
||||
const isNetworkError =
|
||||
!ax.response &&
|
||||
(ax.code === "ERR_CONNECTION_CLOSED" ||
|
||||
ax.code === "ERR_NETWORK" ||
|
||||
/network|failed to fetch|connection/i.test(ax.message ?? ""));
|
||||
if (isNetworkError) {
|
||||
setError(
|
||||
"无法连接服务器。若使用公网穿透,请确认隧道已启动且后端运行中;或改用本地:在 frontend/.env.development 中设置 VITE_API_URL=http://localhost:8000 后重启前端。"
|
||||
);
|
||||
} else {
|
||||
setError(ax.response?.data?.detail ?? "登录失败,请检查用户名和密码(默认:boss / boss123)");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4 sm:p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<CardTitle className="text-2xl font-bold sm:text-3xl">PMFlow</CardTitle>
|
||||
<CardDescription>登录以继续</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">
|
||||
用户名
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
密码
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||
演示账号:boss / boss123
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./login/LoginPage";
|
||||
|
|
|
|||
|
|
@ -1,237 +1 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings2 } from "lucide-react";
|
||||
|
||||
import { modelRoutingApi, type ModelRoutingItem } from "@/api/modelRouting";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
const PROVIDER_OPTIONS = ["openai", "glm", "deepseek", "ollama"] as const;
|
||||
const TIER_OPTIONS = ["high", "low", "safety"] as const;
|
||||
const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
||||
adaptive_rag_answer_deep: "复杂问题的深度回答阶段:会做更细的推理和更完整的结论组织。",
|
||||
adaptive_rag_answer_normal: "常规问答阶段:基于已检索到的信息快速生成可读回复。",
|
||||
feishu_humanized_reply: "对外回复润色阶段:把结果改写成飞书里更自然、易读的表达。",
|
||||
rag_db_project_semantic_retrieval: "知识检索阶段:从项目库里找最相关的历史上下文与证据。",
|
||||
rag_intent_classification: "意图识别阶段:先判断这是哪类问题,再决定后续处理路径。",
|
||||
signal_planner_analyze: "任务规划阶段:把需求拆成可执行步骤、产物和责任边界。",
|
||||
classify_intent_ai_pmo_kernel: "轻量意图分流:快速判断是否进入 PMO 内核处理链路。",
|
||||
classify_intent_complex: "复杂意图判别:识别高复杂度请求,决定是否升级到 high 模型。",
|
||||
};
|
||||
|
||||
function getFunctionUsageHint(functionName: string): string {
|
||||
return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的模型选择。";
|
||||
}
|
||||
|
||||
export default function ModelRoutingConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedFunction, setSelectedFunction] = useState("signal_planner_analyze");
|
||||
const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>("openai");
|
||||
const [model, setModel] = useState("GLM-5");
|
||||
const [apiEnv, setApiEnv] = useState("OPENAI_API_KEY");
|
||||
const [tier, setTier] = useState<(typeof TIER_OPTIONS)[number]>("high");
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
|
||||
const routingQuery = useQuery({
|
||||
queryKey: ["model-routing-functions"],
|
||||
queryFn: () => modelRoutingApi.listFunctions(),
|
||||
});
|
||||
const items = routingQuery.data || [];
|
||||
|
||||
const current = useMemo(
|
||||
() => items.find((item) => item.function_name === selectedFunction) || null,
|
||||
[items, selectedFunction]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fallback: ModelRoutingItem | null = current || items[0] || null;
|
||||
if (!fallback) return;
|
||||
if (!current && fallback.function_name) {
|
||||
setSelectedFunction(fallback.function_name);
|
||||
}
|
||||
const nextProvider = PROVIDER_OPTIONS.includes(fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
||||
? (fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
||||
: "openai";
|
||||
const nextTier = TIER_OPTIONS.includes(fallback.tier as (typeof TIER_OPTIONS)[number])
|
||||
? (fallback.tier as (typeof TIER_OPTIONS)[number])
|
||||
: "high";
|
||||
setProvider(nextProvider);
|
||||
setTier(nextTier);
|
||||
setModel(fallback.model || "");
|
||||
setApiEnv(fallback.api_env || "");
|
||||
setBaseURL(fallback.base_url || "");
|
||||
}, [current, items]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
modelRoutingApi.updateFunction(selectedFunction, {
|
||||
provider,
|
||||
model,
|
||||
api_env: apiEnv,
|
||||
tier,
|
||||
base_url: baseURL || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["model-routing-functions"] });
|
||||
},
|
||||
});
|
||||
|
||||
const routingErrorText = routingQuery.error instanceof Error ? routingQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="模型路由配置"
|
||||
description="按 function_name 动态调整 provider / model / api_env / tier,立即生效。"
|
||||
icon={<Settings2 className="h-5 w-5" />}
|
||||
tone="blue"
|
||||
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="model-routing-config"
|
||||
title="模型路由配置"
|
||||
purpose="给不同函数指定不同模型,控制成本、速度和效果。"
|
||||
whenToUse="某个功能效果差、成本高、超时多时,用它定向切换函数路由。"
|
||||
steps={[
|
||||
"在右侧选中目标函数,左侧会自动回填当前配置。",
|
||||
"调整 provider / tier / model / api_env,点“保存路由”。",
|
||||
"触发一次该功能调用,观察日志是否按新路由生效。",
|
||||
]}
|
||||
outputs={[
|
||||
"函数级模型路由覆盖项(override)",
|
||||
"可快速回滚到更稳定的模型组合",
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">编辑器</CardTitle>
|
||||
<CardDescription>选择函数并更新路由参数。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
value={selectedFunction}
|
||||
onChange={(e) => setSelectedFunction(e.target.value.trim())}
|
||||
placeholder="function_name"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{getFunctionUsageHint(selectedFunction)}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-1">
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as (typeof PROVIDER_OPTIONS)[number])}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
{PROVIDER_OPTIONS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as (typeof TIER_OPTIONS)[number])}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
{TIER_OPTIONS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="模型名" />
|
||||
<Input value={apiEnv} onChange={(e) => setApiEnv(e.target.value)} placeholder="API Key 环境变量名" />
|
||||
<Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="Base URL(可选)" />
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
disabled={updateMutation.isPending || !selectedFunction || !model || !apiEnv}
|
||||
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
{updateMutation.isPending ? "保存中..." : "保存路由"}
|
||||
</Button>
|
||||
{updateMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
保存失败:{updateMutation.error instanceof Error ? updateMutation.error.message : "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">生效路由</CardTitle>
|
||||
<CardDescription>选中行即当前编辑目标。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{routingQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : routingQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">路由读取失败:{routingErrorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void routingQuery.refetch()}
|
||||
disabled={routingQuery.isFetching}
|
||||
>
|
||||
{routingQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无路由配置。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Function</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Tier</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.function_name}
|
||||
className={
|
||||
item.function_name === selectedFunction
|
||||
? "bg-blue-50/80 dark:bg-blue-950/35"
|
||||
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/60"
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium">{item.function_name}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{getFunctionUsageHint(item.function_name)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.provider}</TableCell>
|
||||
<TableCell>{item.model}</TableCell>
|
||||
<TableCell>{item.tier}</TableCell>
|
||||
<TableCell>{item.source}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelectedFunction(item.function_name)}>
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./model-routing-config/ModelRoutingConfigPage";
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,498 +1 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
FolderKanban,
|
||||
Radio,
|
||||
FileText,
|
||||
ListTodo,
|
||||
Code,
|
||||
Package,
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, apiBaseURL } from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { formatProjectNameWithId, toZhRole, toZhStatus } from "@/lib/display";
|
||||
import { compactDescription, compactTitle } from "@/lib/title";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
/** 项目列表项:支持普通列表与 summary 含进度 */
|
||||
interface ProjectItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
assigned_role?: string | null;
|
||||
signal_count: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
progress?: number;
|
||||
total_tasks?: number;
|
||||
completed_tasks?: number;
|
||||
in_progress_tasks?: number;
|
||||
pending_tasks?: number;
|
||||
tasks?: Array<{ task_name: string; type: string; status: string; signal_id: string }>;
|
||||
}
|
||||
|
||||
/** 后端返回:直接数组 或 { projects: ProjectItem[] } */
|
||||
function normalizeProjects(
|
||||
raw: ProjectItem[] | { projects?: ProjectItem[] }
|
||||
): ProjectItem[] {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
return (raw as { projects?: ProjectItem[] }).projects ?? [];
|
||||
}
|
||||
|
||||
/** SSTC 链路六阶段(与文档一致) */
|
||||
const CHAIN_STAGES = [
|
||||
{ key: "signal", label: "信号源", icon: Radio },
|
||||
{ key: "charter", label: "需求宪章", icon: FileText },
|
||||
{ key: "task", label: "任务拆解", icon: ListTodo },
|
||||
{ key: "execute", label: "执行落地", icon: Code },
|
||||
{ key: "release", label: "发布验证", icon: Package },
|
||||
{ key: "retro", label: "复盘沉淀", icon: MessageSquare },
|
||||
] as const;
|
||||
|
||||
const LIVE_REFRESH_MS = 10_000;
|
||||
|
||||
function projectStatusClass(status: string): string {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (value.includes("done") || value.includes("completed") || value.includes("已")) {
|
||||
return "bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
if (value.includes("progress") || value.includes("active") || value.includes("进行")) {
|
||||
return "bg-blue-50 text-blue-700";
|
||||
}
|
||||
if (value.includes("blocked") || value.includes("error") || value.includes("暂停")) {
|
||||
return "bg-rose-50 text-rose-700";
|
||||
}
|
||||
return "bg-slate-100 text-slate-600";
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const currentRole = useAuthStore((s) => s.user?.role);
|
||||
const [nameKeyword, setNameKeyword] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [appliedNameKeyword, setAppliedNameKeyword] = useState("");
|
||||
const [appliedStartDate, setAppliedStartDate] = useState("");
|
||||
const [appliedEndDate, setAppliedEndDate] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [queryOpen, setQueryOpen] = useState(false);
|
||||
const [deleteProjectState, setDeleteProjectState] = useState<ProjectItem | null>(null);
|
||||
const pageSize = 9;
|
||||
const canDeleteProject = useMemo(() => {
|
||||
const roles = String(currentRole || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
return roles.includes("BOSS") || roles.includes("ADMIN");
|
||||
}, [currentRole]);
|
||||
|
||||
const {
|
||||
data: projects = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["projects", "summary"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<ProjectItem[] | { projects: ProjectItem[] }>(
|
||||
"/projects/?summary=1",
|
||||
{ timeout: 12000 }
|
||||
);
|
||||
const raw = (res.data as { data?: unknown })?.data ?? res.data;
|
||||
return normalizeProjects(raw as ProjectItem[] | { projects?: ProjectItem[] });
|
||||
},
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
retryDelay: 1500,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (projectId: string) => {
|
||||
await api.delete(`/projects/${projectId}`);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["projects", "summary"] });
|
||||
setDeleteProjectState(null);
|
||||
toast({ title: "项目删除成功", variant: "success" });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message =
|
||||
(err as { response?: { data?: { detail?: string; message?: string } }; message?: string })
|
||||
?.response?.data?.detail ||
|
||||
(err as { response?: { data?: { detail?: string; message?: string } }; message?: string })
|
||||
?.response?.data?.message ||
|
||||
(err as { message?: string })?.message ||
|
||||
"删除失败,请稍后重试";
|
||||
toast({ title: message, variant: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const keyword = appliedNameKeyword.trim().toLowerCase();
|
||||
const fromTs = appliedStartDate ? new Date(`${appliedStartDate}T00:00:00`).getTime() : null;
|
||||
const toTs = appliedEndDate ? new Date(`${appliedEndDate}T23:59:59`).getTime() : null;
|
||||
|
||||
return projects.filter((project) => {
|
||||
const projectLabel = formatProjectNameWithId({ id: project.id, title: project.title }).toLowerCase();
|
||||
if (keyword && !projectLabel.includes(keyword)) return false;
|
||||
|
||||
if (fromTs || toTs) {
|
||||
const created = project.created_at ? new Date(project.created_at).getTime() : NaN;
|
||||
if (!Number.isFinite(created)) return false;
|
||||
if (fromTs && created < fromTs) return false;
|
||||
if (toTs && created > toTs) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [projects, appliedNameKeyword, appliedStartDate, appliedEndDate]);
|
||||
|
||||
const totalFiltered = filteredProjects.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const hasActiveFilters = !!(appliedNameKeyword.trim() || appliedStartDate || appliedEndDate);
|
||||
const appliedFilterSummary = [
|
||||
appliedNameKeyword.trim() ? `项目名:${appliedNameKeyword.trim()}` : "",
|
||||
appliedStartDate ? `开始:${appliedStartDate}` : "",
|
||||
appliedEndDate ? `结束:${appliedEndDate}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
const pagedProjects = useMemo(
|
||||
() => filteredProjects.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[filteredProjects, safePage]
|
||||
);
|
||||
|
||||
const runQuery = () => {
|
||||
setAppliedNameKeyword(nameKeyword);
|
||||
setAppliedStartDate(startDate);
|
||||
setAppliedEndDate(endDate);
|
||||
setPage(1);
|
||||
setQueryOpen(false);
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
setNameKeyword("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setAppliedNameKeyword("");
|
||||
setAppliedStartDate("");
|
||||
setAppliedEndDate("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">项目列表</h1>
|
||||
<p className="mt-1 text-slate-500">管理所有项目 · SSTC 唯一事实链路</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
当前结果:{totalFiltered} 个项目
|
||||
{hasActiveFilters ? `(${appliedFilterSummary})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => setQueryOpen(true)}
|
||||
>
|
||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||
条件查询
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||
SSTC 唯一事实链路
|
||||
</span>
|
||||
<p className="text-sm font-medium text-slate-700">全链路强绑定,无断点闭环</p>
|
||||
</div>
|
||||
<CardDescription className="mt-2 text-slate-500">
|
||||
任何项目相关操作必须纳入完整链路;游离在链路外的信息不具备执行效力。
|
||||
</CardDescription>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{CHAIN_STAGES.map((stage) => {
|
||||
const Icon = stage.icon;
|
||||
return (
|
||||
<div key={stage.key} className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white">
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="text-xs font-semibold">{stage.label}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-0.5 rounded-full bg-slate-200">
|
||||
<div className="h-full w-2/3 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 列表内容 */}
|
||||
{isLoading ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500 shadow-[0_4px_14px_rgba(15,23,42,0.06)]">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
加载中…
|
||||
</div>
|
||||
</section>
|
||||
) : error ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-10">
|
||||
<AlertCircle className="h-10 w-10 text-rose-600" />
|
||||
<p className="font-medium text-rose-600">加载失败</p>
|
||||
<p className="max-w-md text-center text-sm text-slate-500">
|
||||
{(error as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||
(error as Error).message ??
|
||||
"请检查后端服务与登录状态"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
当前请求:GET <code className="rounded bg-slate-100 px-1">{apiBaseURL}/projects/?summary=1</code>
|
||||
{apiBaseURL === "/api" && "(由 Vite 代理到后端,请确认后端在 8000 端口已启动)"}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : totalFiltered === 0 ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<CardContent className="py-12 text-center text-slate-500">
|
||||
<FolderKanban className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="font-medium text-slate-700">无匹配项目</p>
|
||||
<p className="mt-1 text-sm">请调整名称或时间筛选条件后重试</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{pagedProjects.map((p) => {
|
||||
const progressPercent = Math.min(100, typeof p.progress === "number" ? p.progress : 0);
|
||||
const projectLabel = formatProjectNameWithId({ id: p.id, title: p.title });
|
||||
return (
|
||||
<Card
|
||||
key={p.id}
|
||||
className="flex min-h-[244px] flex-col rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
<CardHeader className="space-y-3 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base text-slate-900" title={projectLabel}>
|
||||
{compactTitle(projectLabel, 22)}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-2 text-slate-500" title={p.description || p.title || ""}>
|
||||
{compactDescription(p.description || p.title || "", 44) || "无描述"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FolderKanban className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{p.signal_count} 信号</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{toZhRole(p.assigned_role)}</span>
|
||||
{p.status && (
|
||||
<span className={`rounded-full px-2 py-1 font-medium ${projectStatusClass(p.status)}`}>
|
||||
{toZhStatus(p.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto space-y-3">
|
||||
{typeof p.total_tasks === "number" && p.total_tasks > 0 && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>任务进度</span>
|
||||
<span>
|
||||
{p.completed_tasks ?? 0}/{p.total_tasks}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="h-full rounded-full bg-blue-600 transition-all" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Button asChild size="sm" className="w-full bg-blue-600 text-white hover:bg-blue-700">
|
||||
<Link to={`/projects/${p.id}/board`}>
|
||||
进入 SSTC 看板
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteProject && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full border-rose-200 bg-white text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
onClick={() => setDeleteProjectState(p)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除项目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 rounded-xl bg-white px-3 py-2 shadow-[0_4px_12px_rgba(15,23,42,0.06)]">
|
||||
<p className="text-xs text-slate-500">
|
||||
第 {safePage}/{totalPages} 页,共 {totalFiltered} 个项目
|
||||
</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={safePage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
disabled={safePage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Dialog open={queryOpen} onOpenChange={setQueryOpen}>
|
||||
<DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>条件查询</DialogTitle>
|
||||
<DialogDescription>按项目名称和创建时间过滤项目列表。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-slate-600">项目名称</p>
|
||||
<Input
|
||||
value={nameKeyword}
|
||||
onChange={(e) => setNameKeyword(e.target.value)}
|
||||
placeholder="输入项目名称或ID关键字"
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-slate-600">开始时间</p>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-slate-600">结束时间</p>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={resetQuery}
|
||||
>
|
||||
清空条件
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => setQueryOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={runQuery}>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deleteProjectState} onOpenChange={(open) => !open && setDeleteProjectState(null)}>
|
||||
<DialogContent className="w-[min(420px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除项目</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除项目「
|
||||
{formatProjectNameWithId({
|
||||
id: deleteProjectState?.id,
|
||||
title: deleteProjectState?.title,
|
||||
})}
|
||||
」后不可恢复,关联的链路数据将一并移除。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => setDeleteProjectState(null)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending || !deleteProjectState}
|
||||
onClick={() => deleteProjectState && deleteMutation.mutate(deleteProjectState.id)}
|
||||
>
|
||||
{deleteMutation.isPending ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./projects/ProjectsPage";
|
||||
|
|
|
|||
|
|
@ -1,442 +1 @@
|
|||
/**
|
||||
* 食谱页面 - 支持拍照识别热量
|
||||
*/
|
||||
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getRecipes,
|
||||
createRecipe,
|
||||
analyzeCalories,
|
||||
toggleFavorite,
|
||||
type RecipeCreate,
|
||||
} from '@/api/recipes'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Camera, Heart, Search, Plus, Flame, Utensils, Droplets } from 'lucide-react'
|
||||
|
||||
export default function RecipesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false)
|
||||
const [newRecipe, setNewRecipe] = useState<RecipeCreate>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
cuisine: '',
|
||||
})
|
||||
const [foodDescription, setFoodDescription] = useState('')
|
||||
const [imageBase64, setImageBase64] = useState('')
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
|
||||
// 获取食谱列表
|
||||
const { data: recipes = [], isLoading } = useQuery({
|
||||
queryKey: ['recipes', categoryFilter],
|
||||
queryFn: () =>
|
||||
getRecipes({
|
||||
limit: 50,
|
||||
category: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
}),
|
||||
})
|
||||
|
||||
// 创建食谱
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createRecipe,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['recipes'] })
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewRecipe({ name: '', description: '', category: '', cuisine: '' })
|
||||
},
|
||||
})
|
||||
|
||||
// AI 热量分析
|
||||
const analyzeMutation = useMutation({
|
||||
mutationFn: (data: { recipe: RecipeCreate; foodDescription: string }) =>
|
||||
analyzeCalories(data.recipe, data.foodDescription),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['recipes'] })
|
||||
setIsAnalyzeDialogOpen(false)
|
||||
setNewRecipe({ name: '', description: '', category: '', cuisine: '' })
|
||||
setFoodDescription('')
|
||||
setImageBase64('')
|
||||
setSelectedFile(null)
|
||||
},
|
||||
})
|
||||
|
||||
// 收藏/取消收藏
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: toggleFavorite,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['recipes'] })
|
||||
},
|
||||
})
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setSelectedFile(file)
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
setImageBase64(reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤食谱
|
||||
const filteredRecipes = recipes.filter(
|
||||
(recipe) =>
|
||||
recipe.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
recipe.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const handleCreate = () => {
|
||||
createMutation.mutate(newRecipe)
|
||||
}
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!newRecipe.name) {
|
||||
alert('请输入菜名')
|
||||
return
|
||||
}
|
||||
if (!foodDescription && !selectedFile) {
|
||||
alert('请输入食物描述或上传图片')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果上传了图片,使用文件名作为描述
|
||||
const description = foodDescription || selectedFile?.name || newRecipe.name
|
||||
analyzeMutation.mutate({
|
||||
recipe: newRecipe,
|
||||
foodDescription: description,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">食谱管理</h1>
|
||||
<p className="text-muted-foreground">管理您的食谱,AI 智能识别热量</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
手动添加
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建食谱</DialogTitle>
|
||||
<DialogDescription>手动添加食谱信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="name">菜名</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newRecipe.name}
|
||||
onChange={(e) => setNewRecipe({ ...newRecipe, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newRecipe.description}
|
||||
onChange={(e) =>
|
||||
setNewRecipe({ ...newRecipe, description: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">分类</Label>
|
||||
<Input
|
||||
id="category"
|
||||
value={newRecipe.category}
|
||||
onChange={(e) =>
|
||||
setNewRecipe({ ...newRecipe, category: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cuisine">菜系</Label>
|
||||
<Input
|
||||
id="cuisine"
|
||||
value={newRecipe.cuisine}
|
||||
onChange={(e) => setNewRecipe({ ...newRecipe, cuisine: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAnalyzeDialogOpen} onOpenChange={setIsAnalyzeDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
拍照识别
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[720px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 热量识别</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传食物图片或描述,AI 自动分析热量和营养成分
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Tabs defaultValue="upload">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="upload">上传图片</TabsTrigger>
|
||||
<TabsTrigger value="text">文字描述</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="image">食物图片</Label>
|
||||
<Input
|
||||
id="image"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{imageBase64 && (
|
||||
<div className="mt-4">
|
||||
<img
|
||||
src={imageBase64}
|
||||
alt="预览"
|
||||
className="max-w-full max-h-64 rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="text" className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="text-description">食物描述</Label>
|
||||
<Textarea
|
||||
id="text-description"
|
||||
placeholder="描述食物的外观、份量、食材等,例如:一碗红烧肉,约200克..."
|
||||
value={foodDescription}
|
||||
onChange={(e) => setFoodDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="name">菜名</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="输入菜名"
|
||||
value={newRecipe.name}
|
||||
onChange={(e) => setNewRecipe({ ...newRecipe, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="category">分类</Label>
|
||||
<Input
|
||||
id="category"
|
||||
placeholder="如:主食、蔬菜、肉类"
|
||||
value={newRecipe.category}
|
||||
onChange={(e) =>
|
||||
setNewRecipe({ ...newRecipe, category: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cuisine">菜系</Label>
|
||||
<Input
|
||||
id="cuisine"
|
||||
placeholder="如:中式、西式、日式"
|
||||
value={newRecipe.cuisine}
|
||||
onChange={(e) => setNewRecipe({ ...newRecipe, cuisine: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="user_notes">备注</Label>
|
||||
<Textarea
|
||||
id="user_notes"
|
||||
placeholder="添加备注信息..."
|
||||
value={newRecipe.user_notes}
|
||||
onChange={(e) =>
|
||||
setNewRecipe({ ...newRecipe, user_notes: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAnalyzeDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAnalyze} disabled={analyzeMutation.isPending}>
|
||||
{analyzeMutation.isPending ? '分析中...' : '开始识别'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-full sm:max-w-sm sm:flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索食谱..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm sm:w-44"
|
||||
>
|
||||
<option value="all">全部分类</option>
|
||||
<option value="主食">主食</option>
|
||||
<option value="蔬菜">蔬菜</option>
|
||||
<option value="肉类">肉类</option>
|
||||
<option value="汤品">汤品</option>
|
||||
<option value="甜品">甜品</option>
|
||||
<option value="饮品">饮品</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
) : filteredRecipes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<Utensils className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">暂无食谱,点击右上角添加</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<Card key={recipe.id} className="overflow-hidden">
|
||||
{recipe.image_url && (
|
||||
<img
|
||||
src={recipe.image_url}
|
||||
alt={recipe.name}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{recipe.name}</CardTitle>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{recipe.category && (
|
||||
<Badge variant="secondary">{recipe.category}</Badge>
|
||||
)}
|
||||
{recipe.cuisine && <Badge variant="outline">{recipe.cuisine}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => favoriteMutation.mutate(recipe.id)}
|
||||
className={recipe.is_favorite ? 'text-red-500' : ''}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${recipe.is_favorite ? 'fill-current' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{recipe.description && (
|
||||
<CardDescription className="mt-2">{recipe.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recipe.calories !== undefined && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
<span className="font-medium">{recipe.calories.toFixed(0)} kcal</span>
|
||||
{recipe.ai_recognition_confidence && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
AI {Math.round(recipe.ai_recognition_confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground sm:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>蛋白质</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.protein?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>脂肪</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.fat?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>碳水</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.carbs?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>纤维</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.fiber?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { default } from "./recipes/RecipesPage";
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./scheduler-center/SchedulerCenterPage";
|
||||
|
|
@ -1,137 +1 @@
|
|||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ListTodo } from "lucide-react";
|
||||
import { getUserSkillTasks } from "@/api/skill";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toZhPriority, toZhStatus } from "@/lib/display";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function SkillTasksPage() {
|
||||
const { id: userId, skillId } = useParams();
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["skill-tasks", userId, skillId],
|
||||
queryFn: () => getUserSkillTasks(userId!, skillId!),
|
||||
enabled: !!userId && !!skillId,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? items.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={userId ? `/users/${userId}/profile` : "/users"}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">技能历史任务</h1>
|
||||
<p className="text-muted-foreground">
|
||||
用户 {userId} / 技能 {skillId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>任务列表 ({total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-destructive">
|
||||
加载失败:{(error as Error).message || "请求异常"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-10 text-center text-muted-foreground">
|
||||
<ListTodo className="mb-3 h-10 w-10 opacity-60" />
|
||||
<p>暂无历史任务</p>
|
||||
<p className="mt-1 text-xs">
|
||||
当前后端可能尚未提供技能历史任务接口,页面已兼容空数据。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[240px] whitespace-nowrap">任务</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap">状态</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap">优先级</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap text-right">工时 (h)</TableHead>
|
||||
<TableHead className="min-w-[170px] whitespace-nowrap">更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="min-w-[240px]">
|
||||
<div>
|
||||
<p className="font-medium">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[90px] whitespace-nowrap">{toZhStatus(task.status)}</TableCell>
|
||||
<TableCell className="min-w-[90px] whitespace-nowrap">{toZhPriority(task.priority)}</TableCell>
|
||||
<TableCell className="min-w-[90px] whitespace-nowrap text-right">
|
||||
{typeof task.estimated_hours === "number"
|
||||
? task.estimated_hours.toFixed(1)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[170px] whitespace-nowrap">
|
||||
{task.updated_at
|
||||
? formatBeijingDateTime(task.updated_at)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((task) => (
|
||||
<article key={task.id} className="rounded-lg border bg-white p-4">
|
||||
<p className="text-base font-semibold">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span>状态:{toZhStatus(task.status)}</span>
|
||||
<span>优先级:{toZhPriority(task.priority)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>工时:{typeof task.estimated_hours === "number" ? task.estimated_hours.toFixed(1) : "-"}h</span>
|
||||
<span>{task.updated_at ? formatBeijingDateTime(task.updated_at) : "-"}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./skill-tasks/SkillTasksPage";
|
||||
|
|
|
|||
|
|
@ -1,425 +1 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
createSkillTag,
|
||||
deleteSkillTag,
|
||||
getSkillTags,
|
||||
updateSkillTag,
|
||||
type SkillTag,
|
||||
} from "@/api/skill";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { toZhSkillCategory } from "@/lib/display";
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
dev: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
algorithm: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
|
||||
product: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300",
|
||||
pm: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
||||
qa: "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-300",
|
||||
language: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
framework: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
|
||||
skill: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
tool: "bg-stone-100 text-stone-800 dark:bg-stone-800 dark:text-stone-300",
|
||||
};
|
||||
|
||||
const CATEGORY_OPTIONS = ["dev", "algorithm", "product", "pm", "qa", "language", "framework", "skill", "tool"];
|
||||
|
||||
function categoryClass(category?: string | null) {
|
||||
return CATEGORY_COLORS[(category || "").toLowerCase()] ?? "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canManage = isManagerRole(user?.role);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<SkillTag | null>(null);
|
||||
const [deleteRow, setDeleteRow] = useState<SkillTag | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skill-tags", categoryFilter],
|
||||
queryFn: () => getSkillTags({ category: categoryFilter || undefined }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: { name: string; slug: string; category?: string; description?: string }) =>
|
||||
createSkillTag(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||||
setCreateOpen(false);
|
||||
toast({ title: "技能标签创建成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "技能标签创建失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ tagId, payload }: { tagId: string; payload: { name?: string; category?: string; description?: string } }) =>
|
||||
updateSkillTag(tagId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||||
setEditRow(null);
|
||||
toast({ title: "技能标签更新成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "技能标签更新失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tagId: string) => deleteSkillTag(tagId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||||
setDeleteRow(null);
|
||||
toast({ title: "技能标签删除成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "技能标签删除失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return (data ?? []).filter((row) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
row.name.toLowerCase().includes(q) ||
|
||||
(row.slug || "").toLowerCase().includes(q) ||
|
||||
(row.category || "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [data, search]);
|
||||
const renderTagActions = (tag: SkillTag) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditRow(tag)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!!tag.is_system}
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteRow(tag)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">技能标签管理</h1>
|
||||
<p className="text-muted-foreground">维护技能标签分类并查看关联人数</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建标签
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<CardTitle>标签列表</CardTitle>
|
||||
<CardDescription>共 {rows.length} 个</CardDescription>
|
||||
</div>
|
||||
<div className="ml-auto w-full sm:w-auto">
|
||||
<Input
|
||||
placeholder="搜索名称/标识/分类"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full sm:w-72"
|
||||
/>
|
||||
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<select
|
||||
className="h-10 min-w-[132px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
{CATEGORY_OPTIONS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{toZhSkillCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">加载失败</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无技能标签</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[180px] whitespace-nowrap">名称</TableHead>
|
||||
<TableHead className="min-w-[120px] whitespace-nowrap">分类</TableHead>
|
||||
<TableHead className="min-w-[96px] whitespace-nowrap">关联人数</TableHead>
|
||||
<TableHead className="min-w-[220px] whitespace-nowrap">描述</TableHead>
|
||||
{canManage && <TableHead className="min-w-[40px] whitespace-nowrap" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{tag.name}</span>
|
||||
{tag.is_system && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
系统
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[120px]">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
|
||||
{toZhSkillCategory(tag.category)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[96px] whitespace-nowrap">{tag.linked_user_count ?? 0}</TableCell>
|
||||
<TableCell className="min-w-[220px] text-muted-foreground">{tag.description || "-"}</TableCell>
|
||||
{canManage && <TableCell className="min-w-[40px]">{renderTagActions(tag)}</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{rows.map((tag) => (
|
||||
<article key={tag.id} className="rounded-lg border bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-semibold">{tag.name}</span>
|
||||
{tag.is_system && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
系统
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
|
||||
{toZhSkillCategory(tag.category)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && renderTagActions(tag)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">{tag.description || "暂无描述"}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">关联人数:{tag.linked_user_count ?? 0}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SkillTagDialog
|
||||
open={createOpen}
|
||||
title="新建技能标签"
|
||||
description="创建自定义技能标签"
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={(payload) => {
|
||||
if (!payload.name || !payload.slug) return;
|
||||
createMutation.mutate({
|
||||
name: payload.name,
|
||||
slug: payload.slug,
|
||||
category: payload.category,
|
||||
description: payload.description,
|
||||
});
|
||||
}}
|
||||
isPending={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<SkillTagDialog
|
||||
open={!!editRow}
|
||||
title="编辑技能标签"
|
||||
description={editRow?.is_system ? "系统标签仅允许修改分类" : "可修改名称、分类与描述"}
|
||||
onOpenChange={(open) => !open && setEditRow(null)}
|
||||
defaultValue={editRow || undefined}
|
||||
disableName={!!editRow?.is_system}
|
||||
onSubmit={(payload) => {
|
||||
if (!editRow) return;
|
||||
updateMutation.mutate({ tagId: editRow.id, payload });
|
||||
}}
|
||||
isPending={updateMutation.isPending}
|
||||
/>
|
||||
|
||||
<Dialog open={!!deleteRow} onOpenChange={(open) => !open && setDeleteRow(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>删除后将移除该标签及关联关系,无法恢复。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">标签:{deleteRow?.name}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteRow(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteRow && deleteMutation.mutate(deleteRow.id)}
|
||||
>
|
||||
{deleteMutation.isPending ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillTagDialog(props: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultValue?: SkillTag;
|
||||
disableName?: boolean;
|
||||
isPending?: boolean;
|
||||
onSubmit: (payload: { name?: string; slug?: string; category?: string; description?: string }) => void;
|
||||
}) {
|
||||
const { open, onOpenChange, title, description, defaultValue, disableName, isPending, onSubmit } = props;
|
||||
const [name, setName] = useState(defaultValue?.name || "");
|
||||
const [slug, setSlug] = useState(defaultValue?.slug || "");
|
||||
const [category, setCategory] = useState(defaultValue?.category || "dev");
|
||||
const [descriptionValue, setDescriptionValue] = useState(defaultValue?.description || "");
|
||||
|
||||
const isCreate = !defaultValue;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) {
|
||||
setName(defaultValue?.name || "");
|
||||
setSlug(defaultValue?.slug || "");
|
||||
setCategory(defaultValue?.category || "dev");
|
||||
setDescriptionValue(defaultValue?.description || "");
|
||||
}
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={disableName}
|
||||
placeholder="例如:前端框架"
|
||||
/>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">标识</label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} placeholder="例如:qian-duan-kuang-jia" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">分类</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{CATEGORY_OPTIONS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{toZhSkillCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Input
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!!isPending ||
|
||||
!category ||
|
||||
(!defaultValue && (!name.trim() || !slug.trim())) ||
|
||||
(!!defaultValue && !disableName && !name.trim())
|
||||
}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
name: disableName ? undefined : name.trim(),
|
||||
slug: isCreate ? slug.trim() : undefined,
|
||||
category: category.trim(),
|
||||
description: descriptionValue.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export { default } from "./skills/SkillsPage";
|
||||
|
|
|
|||
|
|
@ -1,192 +1 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Files } from "lucide-react";
|
||||
|
||||
import { taskBriefApi } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function TaskBriefCenterPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("Signal Planner 增量任务包");
|
||||
const [templateId, setTemplateId] = useState<string>("");
|
||||
const [scopeSummary, setScopeSummary] = useState("");
|
||||
const [changedFilesText, setChangedFilesText] = useState("");
|
||||
|
||||
const centerQuery = useQuery({
|
||||
queryKey: ["task-brief-center"],
|
||||
queryFn: () => taskBriefApi.listCenter(),
|
||||
});
|
||||
const cards = centerQuery.data || [];
|
||||
|
||||
const templateQuery = useQuery({
|
||||
queryKey: ["task-brief-templates"],
|
||||
queryFn: () => taskBriefApi.listTemplates(),
|
||||
});
|
||||
const templates = templateQuery.data || [];
|
||||
|
||||
const changedFiles = useMemo(
|
||||
() =>
|
||||
changedFilesText
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
[changedFilesText]
|
||||
);
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
taskBriefApi.generate({
|
||||
title,
|
||||
template_id: templateId || undefined,
|
||||
changed_files: changedFiles,
|
||||
scope_summary: scopeSummary || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-center"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-runs"] });
|
||||
},
|
||||
});
|
||||
|
||||
const centerErrorText = centerQuery.error instanceof Error ? centerQuery.error.message : "请检查登录状态或后端服务。";
|
||||
const templateErrorText = templateQuery.error instanceof Error ? templateQuery.error.message : "模板读取失败";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="增量任务包中心"
|
||||
description="按模板生成任务包并追踪输出卡片。"
|
||||
icon={<Files className="h-5 w-5" />}
|
||||
tone="emerald"
|
||||
meta={<span>{centerQuery.isError ? "读取失败" : `当前卡片 ${cards.length}`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="task-brief-center"
|
||||
title="增量任务包中心"
|
||||
purpose="把一次需求改动整理成可执行任务包,减少团队沟通成本。"
|
||||
whenToUse="你准备开始一个新改动,或者要把需求交给 AI/同事继续执行时使用。"
|
||||
steps={[
|
||||
"先填写标题、选择模板,并输入本次范围摘要。",
|
||||
"把变更文件一行一个填入,点击“生成任务包”。",
|
||||
"在下方卡片区确认模板、状态和摘要是否符合预期。",
|
||||
]}
|
||||
outputs={[
|
||||
"新增一张任务包卡片",
|
||||
"对应一次可追溯的生成记录",
|
||||
"后续可在“任务包生成记录”里回看",
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">任务包生成器</CardTitle>
|
||||
<CardDescription>填写变更范围并一键生成任务包。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="任务包标题" />
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">默认模板</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{templateQuery.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">模板加载失败:{templateErrorText}</p>
|
||||
) : null}
|
||||
<Input
|
||||
value={scopeSummary}
|
||||
onChange={(e) => setScopeSummary(e.target.value)}
|
||||
placeholder="范围摘要(可选)"
|
||||
/>
|
||||
<Textarea
|
||||
rows={6}
|
||||
value={changedFilesText}
|
||||
onChange={(e) => setChangedFilesText(e.target.value)}
|
||||
placeholder={"每行一个变更文件,例如:\nbackend/app/services/signal_planner_service.py\nfrontend/src/pages/ModelRoutingConfigPage.tsx"}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">已解析 {changedFiles.length} 个文件路径</p>
|
||||
<Button
|
||||
onClick={() => generateMutation.mutate()}
|
||||
disabled={generateMutation.isPending || !title.trim()}
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
{generateMutation.isPending ? "生成中..." : "生成任务包"}
|
||||
</Button>
|
||||
</div>
|
||||
{generateMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
生成失败:{generateMutation.error instanceof Error ? generateMutation.error.message : "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">任务包卡片</CardTitle>
|
||||
<CardDescription>按更新时间倒序展示。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{centerQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : centerQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">任务包读取失败:{centerErrorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void centerQuery.refetch()}
|
||||
disabled={centerQuery.isFetching}
|
||||
>
|
||||
{centerQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无任务包,请先生成。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cards.map((card) => (
|
||||
<TableRow key={card.id} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{card.title}</TableCell>
|
||||
<TableCell>{card.status}</TableCell>
|
||||
<TableCell>{card.template_name || "-"}</TableCell>
|
||||
<TableCell className="max-w-[460px] truncate">{card.summary || "-"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(card.updated_at || card.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./task-brief-center/TaskBriefCenterPage";
|
||||
|
|
|
|||
|
|
@ -1,99 +1 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History } from "lucide-react";
|
||||
|
||||
import { taskBriefApi } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function TaskBriefRunsPage() {
|
||||
const runsQuery = useQuery({
|
||||
queryKey: ["task-brief-runs"],
|
||||
queryFn: () => taskBriefApi.listRuns(),
|
||||
});
|
||||
const runs = runsQuery.data || [];
|
||||
const errorText = runsQuery.error instanceof Error ? runsQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="任务包生成记录"
|
||||
description="跟踪任务包生成流水,快速回溯模板与范围。"
|
||||
icon={<History className="h-5 w-5" />}
|
||||
tone="slate"
|
||||
meta={<span>{runsQuery.isError ? "读取失败" : `记录总数 ${runs.length}`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="task-brief-runs"
|
||||
title="任务包生成记录"
|
||||
purpose="按时间回看每次任务包生成结果,做审计和复盘。"
|
||||
whenToUse="你要查“某次改动是按哪个模板生成的、范围是什么”时使用。"
|
||||
steps={[
|
||||
"先定位目标标题或生成时间。",
|
||||
"查看模板、状态、变更文件数和范围摘要。",
|
||||
"对异常记录回到模板页或任务包中心做修正。",
|
||||
]}
|
||||
outputs={[
|
||||
"可追溯的生成流水",
|
||||
"帮助定位错误模板或错误范围输入",
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">运行列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runsQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : runsQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">记录读取失败:{errorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void runsQuery.refetch()}
|
||||
disabled={runsQuery.isFetching}
|
||||
>
|
||||
{runsQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无生成记录。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>变更文件数</TableHead>
|
||||
<TableHead>范围摘要</TableHead>
|
||||
<TableHead>生成时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow key={run.id} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{run.title}</TableCell>
|
||||
<TableCell>{run.template_name || "-"}</TableCell>
|
||||
<TableCell>{run.status}</TableCell>
|
||||
<TableCell>{run.changed_files?.length || 0}</TableCell>
|
||||
<TableCell className="max-w-[420px] truncate">{run.scope_summary || "-"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(run.generated_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./task-brief-runs/TaskBriefRunsPage";
|
||||
|
|
|
|||
|
|
@ -1,205 +1 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { LayoutTemplate } from "lucide-react";
|
||||
|
||||
import { taskBriefApi, type TaskBriefTemplate } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function TaskBriefTemplatePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["task-brief-templates"],
|
||||
queryFn: () => taskBriefApi.listTemplates(),
|
||||
});
|
||||
const templates = templatesQuery.data || [];
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() => templates.find((item) => item.id === selectedId) || null,
|
||||
[templates, selectedId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return;
|
||||
setName(selectedTemplate.name || "");
|
||||
setDescription(selectedTemplate.description || "");
|
||||
setContent(selectedTemplate.content || "");
|
||||
}, [selectedTemplate]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: { name: string; description: string; content: string }) =>
|
||||
taskBriefApi.createTemplate({
|
||||
name: payload.name,
|
||||
description: payload.description || undefined,
|
||||
content: payload.content,
|
||||
}),
|
||||
onSuccess: (created: TaskBriefTemplate) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-templates"] });
|
||||
setSelectedId(created.id);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: { id: string; name: string; description: string; content: string }) =>
|
||||
taskBriefApi.updateTemplate(payload.id, {
|
||||
name: payload.name,
|
||||
description: payload.description || undefined,
|
||||
content: payload.content,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-templates"] });
|
||||
},
|
||||
});
|
||||
|
||||
const templatesErrorText = templatesQuery.error instanceof Error ? templatesQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="任务包模板管理"
|
||||
description="统一维护增量任务包模板,保障跨任务输出结构一致。"
|
||||
icon={<LayoutTemplate className="h-5 w-5" />}
|
||||
tone="violet"
|
||||
meta={<span>{templatesQuery.isError ? "模板读取失败" : `模板总数 ${templates.length}`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="task-brief-templates"
|
||||
title="任务包模板管理"
|
||||
purpose="维护 low/high 模型可复用的任务包模板,让输出结构稳定。"
|
||||
whenToUse="你想新增模板,或者发现当前模板不适合团队执行时使用。"
|
||||
steps={[
|
||||
"在左侧编辑器填写名称、描述和模板正文。",
|
||||
"新模板点“新建模板”;已有模板点“编辑”后修改并保存。",
|
||||
"在右侧列表确认更新时间与内容是否已生效。",
|
||||
]}
|
||||
outputs={[
|
||||
"模板库新增或更新",
|
||||
"后续生成任务包会直接使用最新模板",
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">模板编辑器</CardTitle>
|
||||
<CardDescription>支持新建与覆盖更新。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="模板名称" />
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="模板描述(可选)"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
rows={12}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="模板正文"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
disabled={createMutation.isPending || !name.trim() || !content.trim()}
|
||||
onClick={() => createMutation.mutate({ name, description, content })}
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建模板"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={updateMutation.isPending || !selectedId || !name.trim() || !content.trim()}
|
||||
onClick={() => updateMutation.mutate({ id: selectedId, name, description, content })}
|
||||
>
|
||||
{updateMutation.isPending ? "保存中..." : "保存当前模板"}
|
||||
</Button>
|
||||
</div>
|
||||
{createMutation.isError || updateMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
保存失败:
|
||||
{createMutation.error instanceof Error
|
||||
? createMutation.error.message
|
||||
: updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">模板列表</CardTitle>
|
||||
<CardDescription>点击“编辑”可回填到左侧编辑器。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{templatesQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : templatesQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">模板读取失败:{templatesErrorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void templatesQuery.refetch()}
|
||||
disabled={templatesQuery.isFetching}
|
||||
>
|
||||
{templatesQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无模板。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templates.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={
|
||||
item.id === selectedId
|
||||
? "bg-blue-50/80 dark:bg-blue-950/35"
|
||||
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/60"
|
||||
}
|
||||
>
|
||||
<TableCell className="font-medium">{item.name}</TableCell>
|
||||
<TableCell className="max-w-[320px] truncate">{item.description || "-"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelectedId(item.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./task-brief-template/TaskBriefTemplatePage";
|
||||
|
|
|
|||
|
|
@ -1,152 +1 @@
|
|||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { getUser, getRoleLabel, getStatusLabel, type User } from "@/api/users";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import SkillRadarChart from "@/components/SkillRadarChart";
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { id } = useParams();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const userId = id ?? currentUser?.id ?? "";
|
||||
|
||||
const { data: profile, isLoading, error } = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: () => getUser(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const user: User | undefined = profile ?? (currentUser?.id === userId ? (currentUser as unknown as User) : undefined);
|
||||
const displayName = user?.name || user?.username || (currentUser?.name || currentUser?.username) || `用户 ${id}`;
|
||||
const isManager = isManagerRole(currentUser?.role);
|
||||
const initials = (displayName || "PM").slice(0, 2).toUpperCase();
|
||||
const topSkills = user?.top_skills?.filter(Boolean) ?? [];
|
||||
const projectCount = user?.project_ids?.length ?? 0;
|
||||
const domainCount = user?.assigned_domains?.length ?? 0;
|
||||
const workload = typeof user?.workload === "number" ? user.workload : 0;
|
||||
const workloadWidth = Math.min(Math.max(workload, 0), 100);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={isManager ? "/users" : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">个人中心</h1>
|
||||
<p className="text-muted-foreground">{displayName}</p>
|
||||
</div>
|
||||
<div className="sm:ml-auto">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/users/${userId}/skills`}>技能详情</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 2xl:grid-cols-[minmax(0,1.7fr)_360px]">
|
||||
{userId && (
|
||||
<SkillRadarChart
|
||||
userId={userId}
|
||||
userName={user?.name || user?.username}
|
||||
className="min-w-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="border-slate-200/80 shadow-sm">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-blue-600/10 text-lg font-semibold text-blue-600">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-xl">{displayName}</CardTitle>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">@{user?.username ?? "-"}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="rounded-full px-3 py-1 text-xs">
|
||||
{user?.status ? getStatusLabel(user.status) : "未同步"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">角色画像</p>
|
||||
<p className="mt-2 text-sm font-medium text-slate-900">{user?.role ? getRoleLabel(user.role) : "-"}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && !user ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="text-destructive">加载失败</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">用户名</dt>
|
||||
<dd className="mt-2 font-medium text-slate-900">{user?.username ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">邮箱</dt>
|
||||
<dd className="mt-2 break-all font-medium text-slate-900">{user?.email ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">参与项目</dt>
|
||||
<dd className="mt-2 font-medium text-slate-900">{projectCount} 个</dd>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">负责域</dt>
|
||||
<dd className="mt-2 font-medium text-slate-900">{domainCount} 个</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">当前负载</p>
|
||||
<p className="text-xs text-muted-foreground">按用户工作量占比展示</p>
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-slate-900">{workload}%</span>
|
||||
</div>
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-600 transition-all"
|
||||
style={{ width: `${workloadWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-medium text-slate-900">核心技能标签</h3>
|
||||
<span className="text-xs text-muted-foreground">{topSkills.length} 项</span>
|
||||
</div>
|
||||
{topSkills.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topSkills.map((skill) => (
|
||||
<Badge
|
||||
key={skill}
|
||||
variant="outline"
|
||||
className="rounded-full border-slate-200 bg-slate-50 px-3 py-1 text-slate-700"
|
||||
>
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 px-4 py-3 text-sm text-muted-foreground">
|
||||
暂无技能标签
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./user-profile/UserProfilePage";
|
||||
|
|
|
|||
|
|
@ -1,448 +1 @@
|
|||
import { useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { ArrowLeft, Download, Plus, RefreshCw } from "lucide-react";
|
||||
import { getUser, type UserRole } from "@/api/users";
|
||||
import {
|
||||
addUserSkill,
|
||||
getSkillTags,
|
||||
getUserSkillBindings,
|
||||
removeUserSkill,
|
||||
updateUserSkill,
|
||||
type UserSkillBinding,
|
||||
} from "@/api/skill";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { toZhSkillCategory, toZhSkillLevel } from "@/lib/display";
|
||||
|
||||
const LEVEL_OPTIONS = ["beginner", "intermediate", "advanced", "expert"] as const;
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
const detail = (error as AxiosError<{ detail?: string }>)?.response?.data?.detail;
|
||||
return detail || "请求失败";
|
||||
}
|
||||
|
||||
function roleLabel(role?: UserRole | string) {
|
||||
if (!role) return "-";
|
||||
const m: Record<string, string> = {
|
||||
BOSS: "负责人",
|
||||
ADMIN: "管理员",
|
||||
AI: "AI工程师",
|
||||
FE: "前端开发",
|
||||
BE: "后端开发",
|
||||
};
|
||||
return m[role] || role;
|
||||
}
|
||||
|
||||
function groupedByCategory(bindings: UserSkillBinding[]) {
|
||||
const map = new Map<string, UserSkillBinding[]>();
|
||||
for (const row of bindings) {
|
||||
const key = (row as UserSkillBinding & { category?: string }).category || "uncategorized";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function UserSkillsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const userId = id || "";
|
||||
const queryClient = useQueryClient();
|
||||
const chartWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [warningOpen, setWarningOpen] = useState(false);
|
||||
const [addSkillOpen, setAddSkillOpen] = useState(false);
|
||||
const [selectedSkillId, setSelectedSkillId] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
|
||||
const userQuery = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: () => getUser(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const bindingsQuery = useQuery({
|
||||
queryKey: ["user-skills", userId],
|
||||
queryFn: () => getUserSkillBindings(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: ["skill-tags-all"],
|
||||
queryFn: () => getSkillTags(),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: ({ skillId }: { skillId: string }) => removeUserSkill(userId, skillId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
toast({ title: "技能移除成功", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: `技能移除失败:${getErrorMessage(error)}`, variant: "error" }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
skillId,
|
||||
payload,
|
||||
}: {
|
||||
skillId: string;
|
||||
payload: {
|
||||
level?: string;
|
||||
proficiency?: number;
|
||||
quality_score?: number;
|
||||
efficiency_score?: number;
|
||||
};
|
||||
}) => updateUserSkill(userId, skillId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
toast({ title: "技能已更新", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: `技能更新失败:${getErrorMessage(error)}`, variant: "error" }),
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (payload: { skill_tag_id: string }) => addUserSkill(userId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
setAddSkillOpen(false);
|
||||
setSelectedSkillId("");
|
||||
toast({ title: "技能添加成功", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: `技能添加失败:${getErrorMessage(error)}`, variant: "error" }),
|
||||
});
|
||||
|
||||
const user = userQuery.data;
|
||||
const bindings = bindingsQuery.data ?? [];
|
||||
const grouped = useMemo(() => groupedByCategory(bindings), [bindings]);
|
||||
const isSelf = String(currentUser?.id || "") === String(userId);
|
||||
const canManageAll = isManagerRole(currentUser?.role);
|
||||
const canEditProficiency = canManageAll || isSelf;
|
||||
const canEditScores = canManageAll;
|
||||
const canAddRemove = canManageAll;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const mapped = bindings.map((row) => {
|
||||
const score = Math.round((Number(row.quality_score ?? 0) * 0.6 + Number(row.efficiency_score ?? 0) * 0.4) || row.proficiency);
|
||||
return {
|
||||
subject: row.skill_name,
|
||||
score: Math.max(0, Math.min(100, score)),
|
||||
category: (row as UserSkillBinding & { category?: string }).category || "uncategorized",
|
||||
};
|
||||
});
|
||||
if (selectedCategory === "all") return mapped;
|
||||
return mapped.filter((row) => row.category === selectedCategory);
|
||||
}, [bindings, selectedCategory]);
|
||||
|
||||
const availableSkillTags = useMemo(() => {
|
||||
const owned = new Set(bindings.map((b) => b.skill_tag_id));
|
||||
return (tagsQuery.data ?? []).filter((tag) => !owned.has(tag.id));
|
||||
}, [tagsQuery.data, bindings]);
|
||||
|
||||
function exportChart() {
|
||||
const root = chartWrapperRef.current;
|
||||
const svg = root?.querySelector("svg");
|
||||
if (!svg) return;
|
||||
const serializer = new XMLSerializer();
|
||||
const source = serializer.serializeToString(svg);
|
||||
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width || 1200;
|
||||
canvas.height = img.height || 800;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const pngUrl = canvas.toDataURL("image/png");
|
||||
const a = document.createElement("a");
|
||||
a.href = pngUrl;
|
||||
a.download = `user-${userId}-skills-radar.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
};
|
||||
img.src = svgUrl;
|
||||
}
|
||||
|
||||
if (userQuery.isLoading || bindingsQuery.isLoading) {
|
||||
return <p className="text-muted-foreground">加载中…</p>;
|
||||
}
|
||||
|
||||
if (userQuery.error || !user) {
|
||||
return <p className="text-destructive">用户不存在或无权限查看</p>;
|
||||
}
|
||||
if (bindingsQuery.error) {
|
||||
return <p className="text-destructive">技能数据加载失败</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/users/${userId}/profile`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.username} 技能详情</h1>
|
||||
<p className="text-muted-foreground">
|
||||
角色: {roleLabel(user.role)} · 项目数: {(user.project_ids ?? []).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canManageAll}
|
||||
onClick={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
setWarningOpen(true);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新评分
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setBatchMode((v) => !v)}>
|
||||
{batchMode ? "退出批量编辑" : "批量编辑"}
|
||||
</Button>
|
||||
<Button disabled={!canAddRemove} onClick={() => setAddSkillOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加技能
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-5">
|
||||
<div className="space-y-4 xl:col-span-3">
|
||||
{bindings.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
当前暂无技能绑定
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{[...grouped.entries()].map(([category, items]) => (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{toZhSkillCategory(category)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{items.map((row) => (
|
||||
<div key={row.skill_tag_id} className="rounded-md border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{row.skill_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
最近使用: {row.last_used_at ? row.last_used_at.slice(0, 10) : "暂无"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canAddRemove || !!row.is_system || removeMutation.isPending}
|
||||
onClick={() => removeMutation.mutate({ skillId: row.skill_tag_id })}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={row.level}
|
||||
disabled={!canEditProficiency}
|
||||
onChange={(e) =>
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { level: e.target.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
{LEVEL_OPTIONS.map((lv) => (
|
||||
<option key={lv} value={lv}>
|
||||
{toZhSkillLevel(lv)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={row.proficiency}
|
||||
disabled={!batchMode || !canEditProficiency}
|
||||
onBlur={(e) =>
|
||||
batchMode &&
|
||||
canEditProficiency &&
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { proficiency: Number(e.target.value || 0) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
defaultValue={row.quality_score ?? 0}
|
||||
disabled={!batchMode || !canEditScores}
|
||||
onBlur={(e) =>
|
||||
batchMode &&
|
||||
canEditScores &&
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { quality_score: Number(e.target.value || 0) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
defaultValue={row.efficiency_score ?? 0}
|
||||
disabled={!batchMode || !canEditScores}
|
||||
onBlur={(e) =>
|
||||
batchMode &&
|
||||
canEditScores &&
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { efficiency_score: Number(e.target.value || 0) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <p className="text-sm text-muted-foreground">暂无技能绑定</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-base">技能雷达图</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">全部分类</option>
|
||||
{[...new Set(bindings.map((row) => (row as UserSkillBinding & { category?: string }).category || "uncategorized"))].map(
|
||||
(category) => (
|
||||
<option key={category} value={category}>
|
||||
{toZhSkillCategory(category)}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={exportChart}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出图表
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent ref={chartWrapperRef}>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex h-[420px] items-center justify-center text-sm text-muted-foreground">
|
||||
当前分类暂无可展示技能数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[420px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chartData}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="subject" />
|
||||
<PolarRadiusAxis domain={[0, 100]} />
|
||||
<Radar dataKey="score" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.28} />
|
||||
<Tooltip />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={warningOpen} onOpenChange={setWarningOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>提示</DialogTitle>
|
||||
<DialogDescription>手动分数编辑会在下次 AI 更新时被覆盖。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setWarningOpen(false)}>我知道了</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={addSkillOpen} onOpenChange={setAddSkillOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加技能</DialogTitle>
|
||||
<DialogDescription>选择一个未绑定技能并添加到当前用户。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={selectedSkillId}
|
||||
onChange={(e) => setSelectedSkillId(e.target.value)}
|
||||
>
|
||||
<option value="">请选择技能</option>
|
||||
{tagsQuery.error && <option value="">技能加载失败</option>}
|
||||
{availableSkillTags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name} ({toZhSkillCategory(tag.category || "uncategorized")})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddSkillOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedSkillId || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate({ skill_tag_id: selectedSkillId })}
|
||||
>
|
||||
{addMutation.isPending ? "添加中…" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./user-skills/UserSkillsPage";
|
||||
|
|
|
|||
|
|
@ -1,792 +1 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Import,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
User as UserIcon,
|
||||
UserX,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
bulkAssignUsers,
|
||||
bulkDeleteUsers,
|
||||
deleteUser,
|
||||
getRoleLabel,
|
||||
getStatusLabel,
|
||||
getUsers,
|
||||
importUsers,
|
||||
parseUserRoles,
|
||||
patchUserStatus,
|
||||
resetUserPassword,
|
||||
type User,
|
||||
type UserRole,
|
||||
type UserStatus,
|
||||
} from "@/api/users";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RoleBadge } from "@/components/ui/RoleBadge";
|
||||
import { SkillBadge } from "@/components/ui/SkillBadge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { UserFormDialog } from "@/components/users/UserFormDialog";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
|
||||
const ROLES: UserRole[] = ["BOSS", "ADMIN", "AI", "FE", "BE"];
|
||||
const MAX_PASSWORD_BYTES = 72;
|
||||
|
||||
function isManager(role?: string): boolean {
|
||||
const roles = parseUserRoles(role);
|
||||
return roles.includes("BOSS") || roles.includes("ADMIN");
|
||||
}
|
||||
|
||||
function isBoss(role?: string): boolean {
|
||||
return parseUserRoles(role).includes("BOSS");
|
||||
}
|
||||
|
||||
function workloadClass(ratio: number): string {
|
||||
if (ratio > 100) return "text-destructive";
|
||||
if (ratio >= 70) return "text-amber-600 dark:text-amber-400";
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
}
|
||||
|
||||
function parseCsvPreview(content: string) {
|
||||
const lines = content.split(/\r?\n/).filter((line) => line.trim());
|
||||
if (lines.length === 0) return { headers: [] as string[], rows: [] as string[][] };
|
||||
const headers = lines[0].split(",").map((h) => h.trim());
|
||||
const rows = lines.slice(1, 11).map((line) => line.split(",").map((x) => x.trim()));
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
function passwordUtf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
|
||||
function generateSafePassword(length = 14): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
|
||||
const randomValues = new Uint32Array(length);
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(randomValues);
|
||||
} else {
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
randomValues[i] = Math.floor(Math.random() * chars.length);
|
||||
}
|
||||
}
|
||||
return Array.from(randomValues, (value) => chars[value % chars.length]).join("");
|
||||
}
|
||||
|
||||
function resolvePasswordValidationMessage(passwordTooShort: boolean, passwordTooLong: boolean): string | null {
|
||||
if (passwordTooShort) return "密码至少需要 6 个字符";
|
||||
if (passwordTooLong) return "密码最长支持 72 个 UTF-8 字节,请缩短后重试";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStalePasswordServerError(
|
||||
message: string,
|
||||
options: {
|
||||
passwordTooShort: boolean;
|
||||
passwordTooLong: boolean;
|
||||
},
|
||||
): boolean {
|
||||
const { passwordTooShort, passwordTooLong } = options;
|
||||
const normalized = String(message || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if ((normalized.includes("72 个 utf-8 字节") || normalized.includes("72 bytes")) && !passwordTooLong) {
|
||||
return true;
|
||||
}
|
||||
if ((normalized.includes("至少需要 6 个字符") || normalized.includes("至少 6 个字符")) && !passwordTooShort) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const canManage = isManager(user?.role);
|
||||
const canDelete = isBoss(user?.role);
|
||||
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | "">("");
|
||||
const [statusFilter, setStatusFilter] = useState<"" | UserStatus>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [workloadStateFilter, setWorkloadStateFilter] = useState<"" | "idle" | "normal" | "overloaded">("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
const [deleteUserState, setDeleteUserState] = useState<User | null>(null);
|
||||
const [resetPwdUser, setResetPwdUser] = useState<User | null>(null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [showResetPassword, setShowResetPassword] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importSummary, setImportSummary] = useState<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const [importPreview, setImportPreview] = useState<{ headers: string[]; rows: string[][] } | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [bulkAssignOpen, setBulkAssignOpen] = useState(false);
|
||||
const [bulkRole, setBulkRole] = useState<UserRole | "">("");
|
||||
const [bulkProjectIds, setBulkProjectIds] = useState("");
|
||||
const resetPasswordBytes = passwordUtf8Bytes(newPassword);
|
||||
const resetPasswordTooLong = resetPasswordBytes > MAX_PASSWORD_BYTES;
|
||||
const resetPasswordTooShort = newPassword.length > 0 && newPassword.length < 6;
|
||||
const resetPasswordValidationMessage = resolvePasswordValidationMessage(
|
||||
resetPasswordTooShort,
|
||||
resetPasswordTooLong,
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: [
|
||||
"users",
|
||||
search,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
workloadStateFilter,
|
||||
],
|
||||
queryFn: () =>
|
||||
getUsers({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
search: search || undefined,
|
||||
role: roleFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
workload_state: workloadStateFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const users = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setDeleteUserState(null);
|
||||
toast({ title: "用户删除成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "删除用户失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => bulkDeleteUsers(ids),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setSelectedIds([]);
|
||||
toast({
|
||||
title: `批量删除完成:成功 ${res.deleted_count},跳过 ${res.skipped.length}`,
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: () => toast({ title: "批量删除失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const bulkAssignMutation = useMutation({
|
||||
mutationFn: (payload: { user_ids: string[]; role?: UserRole; project_ids?: number[] }) =>
|
||||
bulkAssignUsers(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setBulkAssignOpen(false);
|
||||
toast({ title: "批量分配成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "批量分配失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
||||
patchUserStatus(userId, isActive),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast({ title: "用户状态已更新", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "更新用户状态失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const resetPwdMutation = useMutation({
|
||||
mutationFn: ({ userId, password }: { userId: string; password: string }) =>
|
||||
resetUserPassword(userId, password),
|
||||
onSuccess: () => {
|
||||
setResetPwdUser(null);
|
||||
setNewPassword("");
|
||||
toast({ title: "密码重置成功", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: getApiErrorMessage(error, "密码重置失败"), variant: "error" }),
|
||||
});
|
||||
const resetPasswordServerMessage = resetPwdMutation.error
|
||||
? getApiErrorMessage(resetPwdMutation.error, "密码重置失败")
|
||||
: "";
|
||||
const visibleResetPasswordError = isStalePasswordServerError(resetPasswordServerMessage, {
|
||||
passwordTooShort: resetPasswordTooShort,
|
||||
passwordTooLong: resetPasswordTooLong,
|
||||
})
|
||||
? ""
|
||||
: resetPasswordServerMessage;
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => importUsers(file),
|
||||
onSuccess: (res) => {
|
||||
setImportSummary({ total: res.total, success: res.success, failed: res.failed });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast({
|
||||
title: `导入完成:成功 ${res.success},失败 ${res.failed}`,
|
||||
variant: res.failed > 0 ? "default" : "success",
|
||||
});
|
||||
},
|
||||
onError: () => toast({ title: "导入失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const allSelected = users.length > 0 && users.every((u) => selectedIds.includes(u.id));
|
||||
|
||||
const renderedUsers = useMemo(() => users, [users]);
|
||||
const renderUserActions = (u: User) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditUser(u)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setResetPwdUser(u)}>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
重置密码
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
statusMutation.mutate({
|
||||
userId: u.id,
|
||||
isActive: !(u.is_active ?? true),
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
{(u.is_active ?? true) ? "禁用用户" : "启用用户"}
|
||||
</DropdownMenuItem>
|
||||
{canDelete && user?.id !== u.id && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteUserState(u)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => setCreateOpen(true)} className="hidden md:inline-flex">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
+ 新建用户
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Import className="mr-2 h-4 w-4" />
|
||||
导入
|
||||
</Button>
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
disabled={bulkDeleteMutation.isPending}
|
||||
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
批量删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-300 text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => setBulkAssignOpen(true)}
|
||||
>
|
||||
批量分配
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
<CardDescription>共 {total} 人</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
|
||||
<Input
|
||||
placeholder="搜索用户名/姓名/邮箱"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full xl:max-w-[320px]"
|
||||
/>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter((e.target.value || "") as UserRole | "")}
|
||||
className="h-10 min-w-[132px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">全部角色</option>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{getRoleLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter((e.target.value || "") as "" | UserStatus)}
|
||||
className="h-10 min-w-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">在线</option>
|
||||
<option value="away">离开</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={workloadStateFilter}
|
||||
onChange={(e) =>
|
||||
setWorkloadStateFilter((e.target.value || "") as "" | "idle" | "normal" | "overloaded")
|
||||
}
|
||||
className="h-10 min-w-[144px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">全部负载</option>
|
||||
<option value="idle">闲置 (<70%)</option>
|
||||
<option value="normal">正常 (70-100%)</option>
|
||||
<option value="overloaded">过载 (>100%)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">{String(error)}</p>
|
||||
) : renderedUsers.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无用户</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[980px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) => {
|
||||
setSelectedIds(e.target.checked ? renderedUsers.map((u) => u.id) : []);
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">用户</TableHead>
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">角色</TableHead>
|
||||
<TableHead className="min-w-[60px] whitespace-nowrap">项目</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">核心技能(前3)</TableHead>
|
||||
<TableHead className="min-w-[80px] whitespace-nowrap">负载</TableHead>
|
||||
<TableHead className="min-w-[60px] whitespace-nowrap">状态</TableHead>
|
||||
{canManage && <TableHead className="min-w-[40px] whitespace-nowrap" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{renderedUsers.map((u) => {
|
||||
const ratio = u.workload_ratio ?? u.workload ?? 0;
|
||||
return (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(u.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedIds((prev) =>
|
||||
e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[160px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
|
||||
<UserIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link to={`/users/${u.id}/profile`} className="font-medium hover:underline">
|
||||
{u.name || u.username}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">{u.email || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[200px]">
|
||||
<RoleBadge roles={[String(u.role || "")]} className="max-w-[240px]" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[60px] whitespace-nowrap">{(u.project_ids ?? []).length}</TableCell>
|
||||
<TableCell className="min-w-[160px]">
|
||||
<SkillBadge skills={u.top_skills ?? []} max={3} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[80px] whitespace-nowrap">
|
||||
<span className={workloadClass(ratio)}>{ratio.toFixed(1)}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[60px] whitespace-nowrap">
|
||||
{getStatusLabel((u.status || "offline") as UserStatus)}
|
||||
</TableCell>
|
||||
{canManage && <TableCell className="min-w-[40px]">{renderUserActions(u)}</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{renderedUsers.map((u) => {
|
||||
const ratio = u.workload_ratio ?? u.workload ?? 0;
|
||||
return (
|
||||
<article key={u.id} className="rounded-lg border bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(u.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedIds((prev) =>
|
||||
e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id)
|
||||
);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Link to={`/users/${u.id}/profile`} className="block truncate text-base font-semibold">
|
||||
{u.name || u.username}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">{u.email || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-semibold ${workloadClass(ratio)}`}>{ratio.toFixed(1)}%</span>
|
||||
{canManage && renderUserActions(u)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<RoleBadge roles={[String(u.role || "")]} className="max-w-full" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<SkillBadge skills={u.top_skills ?? []} max={3} className="max-w-full" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">项目 {(u.project_ids ?? []).length}个</span>
|
||||
<span className="whitespace-nowrap">{getStatusLabel((u.status || "offline") as UserStatus)}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="fixed bottom-[136px] right-4 z-[119] h-11 rounded-full px-4 shadow-lg md:hidden"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
新建用户
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<UserFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
mode="create"
|
||||
onSuccess={() => {
|
||||
setCreateOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
}}
|
||||
/>
|
||||
|
||||
{editUser && (
|
||||
<UserFormDialog
|
||||
open={!!editUser}
|
||||
onOpenChange={(open) => !open && setEditUser(null)}
|
||||
mode="edit"
|
||||
user={editUser}
|
||||
onSuccess={() => {
|
||||
setEditUser(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!deleteUserState} onOpenChange={(o) => !o && setDeleteUserState(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除用户「{deleteUserState?.name || deleteUserState?.username}」后不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteUserState(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteUserState && deleteMutation.mutate(deleteUserState.id)}
|
||||
>
|
||||
{deleteMutation.isPending ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!resetPwdUser}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setResetPwdUser(null);
|
||||
setNewPassword("");
|
||||
setShowResetPassword(false);
|
||||
resetPwdMutation.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>重置密码</DialogTitle>
|
||||
<DialogDescription>为 {resetPwdUser?.name || resetPwdUser?.username} 设置新密码。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">推荐使用系统生成的 14 位 ASCII 密码,避免长度边界问题。</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => {
|
||||
setNewPassword(generateSafePassword());
|
||||
setShowResetPassword(true);
|
||||
resetPwdMutation.reset();
|
||||
}}
|
||||
>
|
||||
生成安全密码
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showResetPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
resetPwdMutation.reset();
|
||||
}}
|
||||
className="pr-10"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
onClick={() => setShowResetPassword((v) => !v)}
|
||||
aria-label={showResetPassword ? "隐藏密码" : "显示密码"}
|
||||
>
|
||||
{showResetPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-xs ${resetPasswordValidationMessage ? "text-rose-600" : "text-muted-foreground"}`}>
|
||||
{resetPasswordValidationMessage || `至少 6 个字符,最长 72 个 UTF-8 字节。当前 ${resetPasswordBytes}/72 bytes`}
|
||||
</p>
|
||||
{visibleResetPasswordError && (
|
||||
<p className="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{visibleResetPasswordError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setResetPwdUser(null);
|
||||
setNewPassword("");
|
||||
setShowResetPassword(false);
|
||||
resetPwdMutation.reset();
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={resetPwdMutation.isPending || resetPasswordTooShort || resetPasswordTooLong}
|
||||
onClick={() => {
|
||||
if (resetPasswordTooShort) {
|
||||
toast({ title: "密码至少需要 6 个字符", variant: "error" });
|
||||
return;
|
||||
}
|
||||
if (resetPasswordTooLong) {
|
||||
toast({ title: "密码最长支持 72 个 UTF-8 字节,请缩短后重试", variant: "error" });
|
||||
return;
|
||||
}
|
||||
if (resetPwdUser) {
|
||||
resetPwdMutation.mutate({ userId: resetPwdUser.id, password: newPassword });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{resetPwdMutation.isPending ? "提交中…" : "确认"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={importOpen} onOpenChange={setImportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入用户</DialogTitle>
|
||||
<DialogDescription>支持 CSV/XLSX,最多 50 行。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setImportFile(file);
|
||||
setImportSummary(null);
|
||||
if (!file) {
|
||||
setImportPreview(null);
|
||||
return;
|
||||
}
|
||||
if (file.name.toLowerCase().endsWith(".csv")) {
|
||||
const text = await file.text();
|
||||
setImportPreview(parseCsvPreview(text));
|
||||
} else {
|
||||
setImportPreview({ headers: ["提示"], rows: [["XLSX 预览需服务端解析,当前仅展示导入结果"]] });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{importPreview && (
|
||||
<div className="max-h-40 overflow-auto rounded-md border p-2 text-xs">
|
||||
{importPreview.headers.length > 0 && (
|
||||
<div className="mb-1 font-medium">{importPreview.headers.join(" | ")}</div>
|
||||
)}
|
||||
{importPreview.rows.map((row, idx) => (
|
||||
<div key={idx} className="text-muted-foreground">
|
||||
{row.join(" | ")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{importSummary && (
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<p>总数: {importSummary.total}</p>
|
||||
<p>成功: {importSummary.success}</p>
|
||||
<p>失败: {importSummary.failed}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setImportOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!importFile || importMutation.isPending}
|
||||
onClick={() => importFile && importMutation.mutate(importFile)}
|
||||
>
|
||||
{importMutation.isPending ? "导入中…" : "开始导入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={bulkAssignOpen} onOpenChange={setBulkAssignOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量分配项目/角色</DialogTitle>
|
||||
<DialogDescription>将操作应用到已选择用户。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={bulkRole}
|
||||
onChange={(e) => setBulkRole((e.target.value || "") as UserRole | "")}
|
||||
>
|
||||
<option value="">不调整角色</option>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{getRoleLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
placeholder="项目编号(逗号分隔,不填则不调整)"
|
||||
value={bulkProjectIds}
|
||||
onChange={(e) => setBulkProjectIds(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBulkAssignOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={bulkAssignMutation.isPending || (!bulkRole && !bulkProjectIds.trim())}
|
||||
onClick={() =>
|
||||
bulkAssignMutation.mutate({
|
||||
user_ids: selectedIds,
|
||||
role: bulkRole || undefined,
|
||||
project_ids: bulkProjectIds
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n > 0),
|
||||
})
|
||||
}
|
||||
>
|
||||
{bulkAssignMutation.isPending ? "提交中…" : "提交"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./users/UsersPage";
|
||||
|
|
|
|||
|
|
@ -1,289 +1 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
getUserWorkload,
|
||||
getUserWorkloadTasks,
|
||||
getUserWorkloadTrend,
|
||||
updateUserWeeklyHours,
|
||||
} from "@/api/workloads";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { toZhPriority, toZhStatus } from "@/lib/display";
|
||||
|
||||
function ratioClass(value: number): string {
|
||||
if (value > 100) return "text-destructive";
|
||||
if (value >= 70) return "text-amber-600 dark:text-amber-400";
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
}
|
||||
|
||||
export default function WorkloadDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [days, setDays] = useState<7 | 30>(7);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [priorityFilter, setPriorityFilter] = useState("");
|
||||
const [adjustOpen, setAdjustOpen] = useState(false);
|
||||
const [maxWeeklyHours, setMaxWeeklyHours] = useState("40");
|
||||
const { toast } = useToast();
|
||||
|
||||
const userId = id ?? "";
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workloads", "detail", userId],
|
||||
queryFn: () => getUserWorkload(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: ["workloads", "trend", userId, days],
|
||||
queryFn: () => getUserWorkloadTrend(userId, days),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["workloads", "tasks", userId, statusFilter, priorityFilter],
|
||||
queryFn: () =>
|
||||
getUserWorkloadTasks(userId, {
|
||||
status: statusFilter || undefined,
|
||||
priority: priorityFilter || undefined,
|
||||
}),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const updateHoursMutation = useMutation({
|
||||
mutationFn: (hours: number) => updateUserWeeklyHours(userId, hours),
|
||||
onSuccess: () => {
|
||||
setAdjustOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads", "detail", userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads", "team"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads", "trend", userId] });
|
||||
toast({ title: "每周工时已更新", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "更新每周工时失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const trend = trendQuery.data?.items ?? [];
|
||||
const tasks = tasksQuery.data?.items ?? [];
|
||||
|
||||
const headerRatio = useMemo(() => detail?.workload_ratio ?? 0, [detail]);
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return <p className="text-muted-foreground">加载中…</p>;
|
||||
}
|
||||
if (detailQuery.error || !detail) {
|
||||
return <p className="text-destructive">加载失败或无权限访问该用户负载</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{detail.user_name} 的工作负载</h1>
|
||||
<p className="text-muted-foreground">{detail.username}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMaxWeeklyHours(String(detail.max_weekly_hours));
|
||||
setAdjustOpen(true);
|
||||
}}
|
||||
>
|
||||
调整每周工时
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">负载比例</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className={`text-3xl font-semibold ${ratioClass(headerRatio)}`}>
|
||||
{headerRatio.toFixed(1)}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">已分配工时</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{detail.current_assigned_hours.toFixed(1)}h</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">剩余工时</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{detail.remaining_hours.toFixed(1)}h</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>负载趋势</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant={days === 7 ? "default" : "outline"} size="sm" onClick={() => setDays(7)}>
|
||||
7天
|
||||
</Button>
|
||||
<Button variant={days === 30 ? "default" : "outline"} size="sm" onClick={() => setDays(30)}>
|
||||
30天
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendQuery.isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">趋势加载中…</p>
|
||||
) : trendQuery.error ? (
|
||||
<p className="py-8 text-center text-destructive">趋势加载失败</p>
|
||||
) : trend.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无趋势数据</p>
|
||||
) : (
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<ReferenceLine y={70} stroke="#f59e0b" strokeDasharray="4 4" />
|
||||
<ReferenceLine y={100} stroke="#ef4444" strokeDasharray="4 4" />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="workload_ratio"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>任务列表</CardTitle>
|
||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-auto"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="todo">待办</option>
|
||||
<option value="in_progress">进行中</option>
|
||||
<option value="done">已完成</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-auto"
|
||||
value={priorityFilter}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部优先级</option>
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="critical">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasksQuery.isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : tasksQuery.error ? (
|
||||
<p className="py-8 text-center text-destructive">任务加载失败</p>
|
||||
) : tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无任务</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>项目</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>工时</TableHead>
|
||||
<TableHead>到期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>{task.title}</TableCell>
|
||||
<TableCell>{task.project_id}</TableCell>
|
||||
<TableCell>{toZhPriority(task.priority)}</TableCell>
|
||||
<TableCell>{task.estimated_hours?.toFixed(1)}h</TableCell>
|
||||
<TableCell>{task.due_date ? task.due_date.slice(0, 10) : "-"}</TableCell>
|
||||
<TableCell>{toZhStatus(task.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={adjustOpen} onOpenChange={setAdjustOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>调整每周工时上限</DialogTitle>
|
||||
<DialogDescription>修改后会立即重算该成员负载比例。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={maxWeeklyHours}
|
||||
onChange={(e) => setMaxWeeklyHours(e.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAdjustOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={updateHoursMutation.isPending}
|
||||
onClick={() => updateHoursMutation.mutate(Number(maxWeeklyHours || 40))}
|
||||
>
|
||||
{updateHoursMutation.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./workload-detail/WorkloadDetailPage";
|
||||
|
|
|
|||
|
|
@ -1,414 +1 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import {
|
||||
applyAIOptimizePlan,
|
||||
getAIOptimizePreview,
|
||||
getTeamWorkloads,
|
||||
type AIOptimizeProposal,
|
||||
} from "@/api/workloads";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RoleBadge } from "@/components/ui/RoleBadge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { canManageTeamWorkload } from "@/lib/rbac";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
function ratioClass(value: number): string {
|
||||
if (value > 100) return "text-red-500";
|
||||
if (value >= 60) return "text-yellow-500";
|
||||
return "text-green-500";
|
||||
}
|
||||
|
||||
function progressClass(value: number): string {
|
||||
if (value > 100) return "bg-red-500";
|
||||
if (value >= 60) return "bg-yellow-400";
|
||||
return "bg-green-400";
|
||||
}
|
||||
|
||||
export default function WorkloadsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canOptimize = canManageTeamWorkload(user?.role);
|
||||
const { toast } = useToast();
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [range, setRange] = useState<"week" | "month" | "custom">("week");
|
||||
const [customFrom, setCustomFrom] = useState("");
|
||||
const [customTo, setCustomTo] = useState("");
|
||||
const [optimizeOpen, setOptimizeOpen] = useState(false);
|
||||
const [optimizeProposals, setOptimizeProposals] = useState<AIOptimizeProposal[]>([]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["workloads", "team", projectId],
|
||||
queryFn: () => getTeamWorkloads({ project_id: projectId ? Number(projectId) : undefined }),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const summary = data?.summary;
|
||||
const hasOverloaded = (summary?.overloaded_count ?? 0) > 0;
|
||||
const hasQueryError = !!error;
|
||||
|
||||
const exportRows = useMemo(
|
||||
() =>
|
||||
items.map((r) => ({
|
||||
member: r.user_name || r.username,
|
||||
role: String(r.role || ""),
|
||||
assigned_hours: r.current_assigned_hours,
|
||||
max_weekly_hours: r.max_weekly_hours,
|
||||
workload_ratio: r.workload_ratio,
|
||||
remaining_hours: r.remaining_hours,
|
||||
})),
|
||||
[items]
|
||||
);
|
||||
|
||||
function exportExcelLike() {
|
||||
const headers = ["成员", "角色", "已分配工时", "每周上限", "负载比例%", "剩余工时"];
|
||||
const lines = [
|
||||
headers.join("\t"),
|
||||
...exportRows.map((row) =>
|
||||
[
|
||||
row.member,
|
||||
row.role,
|
||||
row.assigned_hours.toFixed(1),
|
||||
row.max_weekly_hours.toFixed(1),
|
||||
row.workload_ratio.toFixed(1),
|
||||
row.remaining_hours.toFixed(1),
|
||||
].join("\t")
|
||||
),
|
||||
];
|
||||
const blob = new Blob([`\ufeff${lines.join("\n")}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `团队负载报告-${new Date().toISOString().slice(0, 10)}.xls`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportPdfLike() {
|
||||
const htmlRows = exportRows
|
||||
.map(
|
||||
(r) => `<tr><td>${r.member}</td><td>${r.role}</td><td>${r.assigned_hours.toFixed(1)}</td><td>${r.max_weekly_hours.toFixed(1)}</td><td>${r.workload_ratio.toFixed(1)}%</td><td>${r.remaining_hours.toFixed(1)}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
const win = window.open("", "_blank");
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head><title>团队负载报告</title></head>
|
||||
<body>
|
||||
<h2>团队负载报告</h2>
|
||||
<table border="1" cellspacing="0" cellpadding="6">
|
||||
<thead><tr><th>成员</th><th>角色</th><th>已分配工时</th><th>每周上限</th><th>负载比例%</th><th>剩余工时</th></tr></thead>
|
||||
<tbody>${htmlRows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
win.print();
|
||||
}
|
||||
|
||||
const optimizePreviewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
getAIOptimizePreview({
|
||||
project_id: projectId ? Number(projectId) : undefined,
|
||||
max_moves: 30,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setOptimizeProposals(res.proposals ?? []);
|
||||
setOptimizeOpen(true);
|
||||
toast({ title: `已生成 ${res.total ?? 0} 条优化建议`, variant: "success" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "生成优化预览失败", variant: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const optimizeApplyMutation = useMutation({
|
||||
mutationFn: (proposals: AIOptimizeProposal[]) => applyAIOptimizePlan(proposals),
|
||||
onSuccess: (res) => {
|
||||
setOptimizeOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads"] });
|
||||
toast({ title: `已应用 ${res.applied_count} 条优化`, variant: "success" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "应用优化失败", variant: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">团队工作负载</h1>
|
||||
<p className="text-muted-foreground">查看成员负载比例、剩余工时与过载告警</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!canOptimize || optimizePreviewMutation.isPending}
|
||||
onClick={() => optimizePreviewMutation.mutate()}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
AI 优化任务分配
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl bg-gray-50 p-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">团队均值</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading || hasQueryError ? "--" : `${(summary?.avg_ratio ?? 0).toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">闲置</p>
|
||||
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.idle_count ?? 0}人`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">正常</p>
|
||||
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.normal_count ?? 0}人`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">过载</p>
|
||||
<p className="text-2xl font-bold text-red-500">
|
||||
{isLoading || hasQueryError ? "--" : `${summary?.overloaded_count ?? 0}人`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasOverloaded && (
|
||||
<div className="mb-4 flex h-9 items-center rounded-md border-l-4 border-orange-400 bg-orange-50 px-3 text-sm text-orange-700">
|
||||
<AlertTriangle className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span>{summary?.overloaded_count ?? 0}名成员负载超过100%,建议立即执行任务迁移</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQueryError && (
|
||||
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
负载数据请求失败,请稍后重试。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="w-full sm:w-auto">
|
||||
<Input
|
||||
className="w-full sm:w-40"
|
||||
placeholder="项目编号搜索"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
/>
|
||||
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<select
|
||||
className="h-10 min-w-[96px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={range}
|
||||
onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")}
|
||||
>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
{range === "custom" && (
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[168px]"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[168px]"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<Button variant="outline" onClick={exportExcelLike}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 Excel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={exportPdfLike}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>团队负载明细</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">加载失败:{String(error)}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无数据</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px] whitespace-nowrap">成员</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">主要角色</TableHead>
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">负载进度条</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap text-right">比例</TableHead>
|
||||
<TableHead className="min-w-[80px] whitespace-nowrap">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.user_id} className={row.workload_ratio > 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}>
|
||||
<TableCell className="min-w-[140px]">{row.user_name || row.username}</TableCell>
|
||||
<TableCell className="min-w-[160px]">
|
||||
<RoleBadge roles={[row.role]} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[200px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[6px] w-[120px] overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className={`h-[6px] rounded-full ${progressClass(row.workload_ratio)}`}
|
||||
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600">{row.workload_ratio.toFixed(1)}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`min-w-[90px] whitespace-nowrap text-right font-semibold ${ratioClass(row.workload_ratio)}`}>
|
||||
{row.workload_ratio.toFixed(1)}%
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[80px] whitespace-nowrap">
|
||||
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
|
||||
查看详情
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((row) => (
|
||||
<article
|
||||
key={row.user_id}
|
||||
className={`rounded-lg border bg-white p-4 ${row.workload_ratio > 100 ? "border-red-200 bg-red-50/30" : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-semibold">{row.user_name || row.username}</p>
|
||||
<div className="mt-2">
|
||||
<RoleBadge roles={[row.role]} className="max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${ratioClass(row.workload_ratio)}`}>
|
||||
{row.workload_ratio.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="h-[8px] w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className={`h-[8px] rounded-full ${progressClass(row.workload_ratio)}`}
|
||||
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">剩余工时 {(row.remaining_hours ?? 0).toFixed(1)}h</span>
|
||||
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
|
||||
查看详情
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={optimizeOpen} onOpenChange={setOptimizeOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>任务分配优化预览</DialogTitle>
|
||||
<DialogDescription>预览 旧负责人 → 新负责人,确认后批量应用。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[420px] overflow-auto rounded-md border">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>旧负责人</TableHead>
|
||||
<TableHead>新负责人</TableHead>
|
||||
<TableHead>旧负载%</TableHead>
|
||||
<TableHead>新负载%</TableHead>
|
||||
<TableHead>原因</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{optimizeProposals.map((row) => (
|
||||
<TableRow key={`${row.task_id}-${row.new_assignee_id}`}>
|
||||
<TableCell>{row.task_title}</TableCell>
|
||||
<TableCell>{row.old_assignee_id}</TableCell>
|
||||
<TableCell>{row.new_assignee_id}</TableCell>
|
||||
<TableCell>{row.old_ratio.toFixed(1)}</TableCell>
|
||||
<TableCell>{row.new_ratio.toFixed(1)}</TableCell>
|
||||
<TableCell>{row.reason}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{optimizeProposals.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
当前无可优化分配项
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOptimizeOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!optimizeProposals.length || optimizeApplyMutation.isPending}
|
||||
onClick={() => optimizeApplyMutation.mutate(optimizeProposals)}
|
||||
>
|
||||
{optimizeApplyMutation.isPending ? "应用中…" : "确认并应用"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "./workloads/WorkloadsPage";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,699 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Settings as SettingsIcon, CheckCircle2, AlertCircle, Sparkles
|
||||
} from "lucide-react";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import {
|
||||
Card, CardContent
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import PromptRoleSettings from "@/components/ai-steward/PromptRoleSettings";
|
||||
import { type FeishuEventMode } from "@/lib/feishuEventMode";
|
||||
import { buildIntegrationSources } from "./constants";
|
||||
import GovernanceToolsCard from "./components/GovernanceToolsCard";
|
||||
import AdvancedTab from "./sections/AdvancedTab";
|
||||
import ConfigureTab from "./sections/ConfigureTab";
|
||||
import KnowledgeTab from "./sections/KnowledgeTab";
|
||||
import OverviewTab from "./sections/OverviewTab";
|
||||
import type {
|
||||
AIConfigSuggestion,
|
||||
ConfigStatus,
|
||||
FeishuConfigForm,
|
||||
GithubConfigForm,
|
||||
KnowledgeDoc,
|
||||
MonitorSettings,
|
||||
PageMessage,
|
||||
WecomConfigForm,
|
||||
} from "./types";
|
||||
|
||||
export default function AIConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<ConfigStatus | null>(null);
|
||||
const [suggestion, setSuggestion] = useState<AIConfigSuggestion | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [scrollToSection, setScrollToSection] = useState<string | null>(null);
|
||||
|
||||
// Feishu config form
|
||||
const [feishuConfig, setFeishuConfig] = useState<FeishuConfigForm>({
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
event_mode: "long_connection" as FeishuEventMode
|
||||
});
|
||||
|
||||
// WeChat Work config form
|
||||
const [wecomConfig, setWecomConfig] = useState<WecomConfigForm>({
|
||||
corp_id: "",
|
||||
agent_id: "",
|
||||
app_secret: "",
|
||||
token: "",
|
||||
encoding_aes_key: ""
|
||||
});
|
||||
|
||||
// GitHub config form
|
||||
const [githubConfig, setGithubConfig] = useState<GithubConfigForm>({
|
||||
repo_url: "",
|
||||
webhook_url: "",
|
||||
access_token: ""
|
||||
});
|
||||
|
||||
// Monitor settings
|
||||
const [monitorSettings, setMonitorSettings] = useState<MonitorSettings>({
|
||||
hn_enabled: false,
|
||||
hn_interval: 30,
|
||||
browser_enabled: false
|
||||
});
|
||||
|
||||
const [message, setMessage] = useState<PageMessage | null>(null);
|
||||
const [wecomCopyTip, setWecomCopyTip] = useState("");
|
||||
const [knowledgeDocs, setKnowledgeDocs] = useState<KnowledgeDoc[]>([]);
|
||||
const [knowledgeFile, setKnowledgeFile] = useState<File | null>(null);
|
||||
const [knowledgeURL, setKnowledgeURL] = useState("");
|
||||
const [knowledgeURLTitle, setKnowledgeURLTitle] = useState("");
|
||||
|
||||
// Guide expansion state
|
||||
const [showFeishuGuide, setShowFeishuGuide] = useState(false);
|
||||
const [showWecomGuide, setShowWecomGuide] = useState(false);
|
||||
const [editingFeishu, setEditingFeishu] = useState(false);
|
||||
const [editingWecom, setEditingWecom] = useState(false);
|
||||
const [editingGithub, setEditingGithub] = useState(false);
|
||||
|
||||
const integrationSources = buildIntegrationSources(status);
|
||||
|
||||
// ==================== API Calls ====================
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Feishu config
|
||||
const feishuRes = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (feishuRes.ok) {
|
||||
const feishuData = await feishuRes.json();
|
||||
const mode = "long_connection" as FeishuEventMode;
|
||||
setStatus(prev => ({
|
||||
...prev!,
|
||||
feishu: {
|
||||
configured: feishuData.is_configured,
|
||||
app_id: feishuData.app_id,
|
||||
event_mode: mode,
|
||||
},
|
||||
}));
|
||||
setFeishuConfig({
|
||||
app_id: feishuData.app_id || "",
|
||||
app_secret: feishuData.app_secret || "",
|
||||
event_mode: mode,
|
||||
});
|
||||
}
|
||||
|
||||
// Check WeChat Work config
|
||||
const wecomRes = await fetch(`${apiBaseURL}/wecom/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (wecomRes.ok) {
|
||||
const wecomData = await wecomRes.json();
|
||||
setStatus(prev => ({
|
||||
...prev!,
|
||||
wecom: {
|
||||
configured: wecomData.is_configured,
|
||||
corp_id: wecomData.corp_id,
|
||||
agent_id: wecomData.agent_id,
|
||||
token_preview: wecomData.token_preview || "",
|
||||
encoding_aes_key_preview: wecomData.encoding_aes_key_preview || "",
|
||||
},
|
||||
}));
|
||||
setWecomConfig({
|
||||
corp_id: wecomData.corp_id || "",
|
||||
agent_id: wecomData.agent_id || "",
|
||||
app_secret: wecomData.app_secret || "",
|
||||
token: wecomData.token || "",
|
||||
encoding_aes_key: wecomData.encoding_aes_key || "",
|
||||
});
|
||||
}
|
||||
|
||||
// Get AI suggestions
|
||||
const suggestRes = await fetch(`${apiBaseURL}/ai-steward/config/suggest`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (suggestRes.ok) {
|
||||
const suggestData = await suggestRes.json();
|
||||
setSuggestion(suggestData);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to load status:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFeishuConfig = async () => {
|
||||
setChecking("feishu");
|
||||
setMessage(null);
|
||||
if (!feishuConfig.app_id || !feishuConfig.app_secret) {
|
||||
setMessage({ type: "error", text: "请填写应用编号和应用密钥" });
|
||||
setChecking(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(feishuConfig)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: "success", text: "飞书配置已保存" });
|
||||
setEditingFeishu(false);
|
||||
await loadStatus();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: "error", text: error.detail || "保存失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "网络错误,请重试" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const testFeishuConnection = async () => {
|
||||
setChecking("feishu-test");
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/feishu/test-connection`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === "success") {
|
||||
setMessage({ type: "success", text: "飞书连接测试成功!" });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.message || "连接测试失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "测试失败" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFeishuConfig = async () => {
|
||||
if (!confirm("确定要删除飞书配置吗?删除后将无法从飞书群聊捕获信号。")) return;
|
||||
|
||||
setChecking("feishu-delete");
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: "success", text: "配置已删除" });
|
||||
setFeishuConfig({ app_id: "", app_secret: "", event_mode: "long_connection" });
|
||||
setEditingFeishu(false);
|
||||
await loadStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: "删除失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "网络错误" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveWecomConfig = async () => {
|
||||
setChecking("wecom");
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/wecom/config`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(wecomConfig)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: "success", text: "企业微信配置已保存" });
|
||||
setEditingWecom(false);
|
||||
await loadStatus();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: "error", text: error.detail || "保存失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "网络错误,请重试" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const testWecomConnection = async () => {
|
||||
setChecking("wecom-test");
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/wecom/test-connection`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === "success") {
|
||||
setMessage({ type: "success", text: "企业微信连接测试成功!" });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.message || "连接测试失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "测试失败" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWecomConfig = async () => {
|
||||
if (!confirm("确定要删除企业微信配置吗?删除后将无法从企业微信群聊捕获信号。")) return;
|
||||
|
||||
setChecking("wecom-delete");
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/wecom/config`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: "success", text: "配置已删除" });
|
||||
setWecomConfig({ corp_id: "", agent_id: "", app_secret: "", token: "", encoding_aes_key: "" });
|
||||
setEditingWecom(false);
|
||||
await loadStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: "删除失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "网络错误" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getPublicBaseUrl = () => {
|
||||
const normalized = apiBaseURL.replace(/\/api\/?$/, "");
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return normalized.replace(/\/$/, "");
|
||||
}
|
||||
return window.location.origin.replace(/\/$/, "");
|
||||
};
|
||||
|
||||
const getWecomWebhookUrl = () => `${getPublicBaseUrl()}/wecom/webhook`;
|
||||
|
||||
const startHNMonitor = async () => {
|
||||
setChecking("hn");
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/signal/start-hn-monitor?interval_minutes=${monitorSettings.hn_interval}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: "success", text: "技术资讯监控已启动" });
|
||||
setMonitorSettings(prev => ({ ...prev!, hn_enabled: true }));
|
||||
} else {
|
||||
setMessage({ type: "error", text: "启动失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "网络错误" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
try {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = text;
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
input.style.left = "-9999px";
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(input);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyWebhook = async (text: string) => {
|
||||
const ok = await copyText(text);
|
||||
if (ok) {
|
||||
setMessage({ type: "success", text: "已复制到剪切板" });
|
||||
setWecomCopyTip("已复制到剪切板");
|
||||
setTimeout(() => setWecomCopyTip(""), 1500);
|
||||
return;
|
||||
}
|
||||
setMessage({ type: "error", text: "复制失败,请手动复制地址" });
|
||||
setWecomCopyTip("复制失败");
|
||||
setTimeout(() => setWecomCopyTip(""), 1500);
|
||||
};
|
||||
|
||||
const loadKnowledgeDocs = async () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/docs`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const docs = (payload?.data ?? payload) || [];
|
||||
setKnowledgeDocs(Array.isArray(docs) ? docs : []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load knowledge docs:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadKnowledgeFile = async () => {
|
||||
if (!knowledgeFile) {
|
||||
setMessage({ type: "error", text: "请先选择文件" });
|
||||
return;
|
||||
}
|
||||
setChecking("knowledge-upload");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const formData = new FormData();
|
||||
formData.append("file", knowledgeFile);
|
||||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/upload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setMessage({ type: "error", text: payload?.detail || "上传失败" });
|
||||
return;
|
||||
}
|
||||
setKnowledgeFile(null);
|
||||
setMessage({ type: "success", text: "文件已提交,正在向量化处理" });
|
||||
await loadKnowledgeDocs();
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "上传失败,请检查网络" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addKnowledgeURL = async () => {
|
||||
if (!knowledgeURL.trim()) {
|
||||
setMessage({ type: "error", text: "请输入 URL" });
|
||||
return;
|
||||
}
|
||||
setChecking("knowledge-url");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/urls`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: knowledgeURL.trim(),
|
||||
title: knowledgeURLTitle.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setMessage({ type: "error", text: payload?.detail || "URL 添加失败" });
|
||||
return;
|
||||
}
|
||||
setKnowledgeURL("");
|
||||
setKnowledgeURLTitle("");
|
||||
setMessage({ type: "success", text: "URL 已加入知识库,正在抓取与向量化" });
|
||||
await loadKnowledgeDocs();
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "URL 添加失败,请稍后重试" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshKnowledgeDoc = async (docId: number) => {
|
||||
setChecking(`knowledge-refresh-${docId}`);
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/docs/${docId}/refresh`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
setMessage({ type: "error", text: payload?.detail || "刷新失败" });
|
||||
return;
|
||||
}
|
||||
setMessage({ type: "success", text: "已触发刷新,正在处理" });
|
||||
await loadKnowledgeDocs();
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "刷新失败,请稍后重试" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKnowledgeDoc = async (docId: number) => {
|
||||
if (!confirm("确认删除该知识库条目及其向量数据?")) return;
|
||||
setChecking(`knowledge-delete-${docId}`);
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/docs/${docId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
setMessage({ type: "error", text: payload?.detail || "删除失败" });
|
||||
return;
|
||||
}
|
||||
setMessage({ type: "success", text: "已删除知识库条目" });
|
||||
await loadKnowledgeDocs();
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "删除失败,请稍后重试" });
|
||||
} finally {
|
||||
setChecking(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Initial Load ====================
|
||||
|
||||
// Scroll to section when tab changes to configure
|
||||
useEffect(() => {
|
||||
if (activeTab === "configure" && scrollToSection) {
|
||||
// Small delay to ensure the tab content is rendered
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(`config-${scrollToSection}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
setScrollToSection(null);
|
||||
}, 100);
|
||||
}
|
||||
}, [activeTab, scrollToSection]);
|
||||
|
||||
const handleConfigureClick = (sourceId: string) => {
|
||||
setScrollToSection(sourceId);
|
||||
setActiveTab("configure");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setStatus({
|
||||
feishu: { configured: false },
|
||||
wecom: { configured: false },
|
||||
github: { configured: false },
|
||||
browser: { installed: false },
|
||||
hn_monitor: { active: false }
|
||||
});
|
||||
loadStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "knowledge") return;
|
||||
loadKnowledgeDocs();
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "knowledge") return;
|
||||
if (!knowledgeDocs.some((item) => item.status === "processing")) return;
|
||||
const timer = setInterval(() => {
|
||||
loadKnowledgeDocs();
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [activeTab, knowledgeDocs]);
|
||||
|
||||
// ==================== Render ====================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<SettingsIcon className="w-10 h-10 text-violet-500" />
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
AI 智能配置中心
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
AI 自动识别可配置的集成源,一键启用主动捕获能力
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Suggestion Card */}
|
||||
{suggestion && (
|
||||
<Card className="mb-6 border-violet-200 dark:border-violet-800 bg-violet-50 dark:bg-violet-900/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-violet-500 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-violet-900 dark:text-violet-100">AI 建议配置方案</h3>
|
||||
<p className="text-sm text-violet-700 dark:text-violet-300 mt-1">{suggestion.reasoning}</p>
|
||||
<p className="text-xs text-violet-600 dark:text-violet-400 mt-2">预期影响: {suggestion.estimated_impact}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`mb-4 p-3 rounded-lg flex items-center gap-2 ${
|
||||
message.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
|
||||
}`}>
|
||||
{message.type === "success" ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GovernanceToolsCard />
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-2 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">集成源概览</TabsTrigger>
|
||||
<TabsTrigger value="configure" className="shrink-0 whitespace-nowrap">快速配置</TabsTrigger>
|
||||
<TabsTrigger value="knowledge" className="shrink-0 whitespace-nowrap">知识库</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="shrink-0 whitespace-nowrap">高级设置</TabsTrigger>
|
||||
<TabsTrigger value="prompts" className="shrink-0 whitespace-nowrap">Prompt 设置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<OverviewTab integrationSources={integrationSources} onConfigureClick={handleConfigureClick} />
|
||||
|
||||
<ConfigureTab
|
||||
status={status}
|
||||
checking={checking}
|
||||
integrationSources={integrationSources}
|
||||
feishuConfig={feishuConfig}
|
||||
setFeishuConfig={setFeishuConfig}
|
||||
wecomConfig={wecomConfig}
|
||||
setWecomConfig={setWecomConfig}
|
||||
githubConfig={githubConfig}
|
||||
setGithubConfig={setGithubConfig}
|
||||
monitorSettings={monitorSettings}
|
||||
setMonitorSettings={setMonitorSettings}
|
||||
showFeishuGuide={showFeishuGuide}
|
||||
setShowFeishuGuide={setShowFeishuGuide}
|
||||
showWecomGuide={showWecomGuide}
|
||||
setShowWecomGuide={setShowWecomGuide}
|
||||
editingFeishu={editingFeishu}
|
||||
setEditingFeishu={setEditingFeishu}
|
||||
editingWecom={editingWecom}
|
||||
setEditingWecom={setEditingWecom}
|
||||
editingGithub={editingGithub}
|
||||
setEditingGithub={setEditingGithub}
|
||||
wecomCopyTip={wecomCopyTip}
|
||||
setStatus={setStatus}
|
||||
setMessage={setMessage}
|
||||
loadStatus={loadStatus}
|
||||
saveFeishuConfig={saveFeishuConfig}
|
||||
testFeishuConnection={testFeishuConnection}
|
||||
deleteFeishuConfig={deleteFeishuConfig}
|
||||
saveWecomConfig={saveWecomConfig}
|
||||
testWecomConnection={testWecomConnection}
|
||||
deleteWecomConfig={deleteWecomConfig}
|
||||
startHNMonitor={startHNMonitor}
|
||||
handleCopyWebhook={handleCopyWebhook}
|
||||
getWecomWebhookUrl={getWecomWebhookUrl}
|
||||
/>
|
||||
|
||||
<KnowledgeTab
|
||||
checking={checking}
|
||||
knowledgeDocs={knowledgeDocs}
|
||||
knowledgeFile={knowledgeFile}
|
||||
knowledgeURL={knowledgeURL}
|
||||
knowledgeURLTitle={knowledgeURLTitle}
|
||||
onKnowledgeFileChange={setKnowledgeFile}
|
||||
onKnowledgeURLChange={setKnowledgeURL}
|
||||
onKnowledgeURLTitleChange={setKnowledgeURLTitle}
|
||||
onUploadKnowledgeFile={uploadKnowledgeFile}
|
||||
onAddKnowledgeURL={addKnowledgeURL}
|
||||
onRefreshKnowledgeDoc={refreshKnowledgeDoc}
|
||||
onDeleteKnowledgeDoc={deleteKnowledgeDoc}
|
||||
/>
|
||||
|
||||
<AdvancedTab />
|
||||
|
||||
<TabsContent value="prompts" className="mt-6">
|
||||
<PromptRoleSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { GOVERNANCE_TOOLS } from "../constants";
|
||||
|
||||
export default function GovernanceToolsCard() {
|
||||
return (
|
||||
<Card className="mb-6 border-slate-200/80 bg-white/80 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">配置治理工具</CardTitle>
|
||||
<CardDescription>新增治理页面统一收敛到智能配置中心入口,避免侧栏信息过载。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
|
||||
{GOVERNANCE_TOOLS.map(({ id, name, description, to, icon: Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={to}
|
||||
className="group rounded-2xl border border-slate-200 bg-white p-3 transition-all hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800"
|
||||
>
|
||||
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{name}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">{description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import type { IntegrationSource } from "../types";
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
priority: IntegrationSource["priority"];
|
||||
}
|
||||
|
||||
export default function PriorityBadge({ priority }: PriorityBadgeProps) {
|
||||
const config = {
|
||||
high: { label: "高优先级", className: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" },
|
||||
medium: { label: "中优先级", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" },
|
||||
low: { label: "低优先级", className: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300" },
|
||||
};
|
||||
const { label, className } = config[priority];
|
||||
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${className}`}>{label}</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { AlertCircle, AlertTriangle, CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { IntegrationSource } from "../types";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: IntegrationSource["status"];
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||
const config = {
|
||||
configured: { label: "已配置", variant: "default" as const, icon: CheckCircle2, color: "text-green-600" },
|
||||
available: { label: "可用", variant: "secondary" as const, icon: Sparkles, color: "text-blue-600" },
|
||||
needs_setup: { label: "待配置", variant: "outline" as const, icon: AlertTriangle, color: "text-orange-600" },
|
||||
unavailable: { label: "不可用", variant: "secondary" as const, icon: AlertCircle, color: "text-slate-400" },
|
||||
};
|
||||
const { label, variant, icon: Icon, color } = config[status];
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="gap-1">
|
||||
<Icon className={`w-3 h-3 ${color}`} />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Building2,
|
||||
Files,
|
||||
Github,
|
||||
Globe,
|
||||
History,
|
||||
LayoutTemplate,
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { ConfigStatus, IntegrationSource } from "./types";
|
||||
|
||||
export const GOVERNANCE_TOOLS = [
|
||||
{
|
||||
id: "brief-center",
|
||||
name: "增量任务包中心",
|
||||
description: "生成与查看任务包卡片",
|
||||
to: "/ai-config/task-brief-center",
|
||||
icon: Files,
|
||||
},
|
||||
{
|
||||
id: "brief-template",
|
||||
name: "任务包模板管理",
|
||||
description: "统一维护模板与规范",
|
||||
to: "/ai-config/task-brief-templates",
|
||||
icon: LayoutTemplate,
|
||||
},
|
||||
{
|
||||
id: "brief-runs",
|
||||
name: "任务包生成记录",
|
||||
description: "查看生成历史与状态",
|
||||
to: "/ai-config/task-brief-runs",
|
||||
icon: History,
|
||||
},
|
||||
{
|
||||
id: "contract-board",
|
||||
name: "契约同步看板",
|
||||
description: "检查契约文档同步状态",
|
||||
to: "/ai-config/contract-sync-board",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
id: "model-routing",
|
||||
name: "模型路由配置",
|
||||
description: "动态切换函数模型路由",
|
||||
to: "/ai-config/model-routing-config",
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
id: "knowledge-graph-board",
|
||||
name: "知识图谱上下文看板",
|
||||
description: "查看 KG 汇总与双模型上下文消费验证",
|
||||
to: "/ai-config/knowledge-graph-board",
|
||||
icon: Boxes,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const buildIntegrationSources = (status: ConfigStatus | null): IntegrationSource[] => [
|
||||
{
|
||||
id: "feishu",
|
||||
name: "飞书群聊",
|
||||
description: "自动捕获群聊中的需求讨论,AI 分析价值",
|
||||
icon: MessageSquare,
|
||||
status: status?.feishu?.configured ? "configured" : "needs_setup",
|
||||
priority: "high",
|
||||
estimated_benefit: "每天自动捕获 5-10 个需求信号",
|
||||
setup_time: "5 分钟",
|
||||
auto_detected: true,
|
||||
config_fields: [
|
||||
{ key: "app_id", label: "应用编号", type: "text", placeholder: "cli_xxxxxxxxxxxxx", required: true },
|
||||
{ key: "app_secret", label: "应用密钥", type: "password", placeholder: "应用密钥", required: true },
|
||||
],
|
||||
setup_guide: [
|
||||
"访问 https://open.feishu.cn/ 创建自建应用",
|
||||
"在「凭证与基础信息」获取应用编号和应用密钥",
|
||||
"飞书接入已统一为长连接模式,无需再配置回调地址",
|
||||
"添加权限:im:message, im:message:group_at_msg, im:chat",
|
||||
"订阅事件:接收消息 v1 (message.receive_v1)",
|
||||
"保存后后端会自动重启飞书长连接",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "wecom",
|
||||
name: "企业微信",
|
||||
description: "自动捕获企业微信群消息,AI 分析需求价值",
|
||||
icon: Building2,
|
||||
status: status?.wecom?.configured ? "configured" : "needs_setup",
|
||||
priority: "high",
|
||||
estimated_benefit: "每天自动捕获 5-10 个需求信号",
|
||||
setup_time: "5 分钟",
|
||||
auto_detected: true,
|
||||
config_fields: [
|
||||
{ key: "corp_id", label: "企业编号", type: "text", placeholder: "wwxxxxxxxxxxxxxx", required: true },
|
||||
{ key: "agent_id", label: "应用编号", type: "text", placeholder: "1000002", required: true },
|
||||
{ key: "app_secret", label: "应用密钥", type: "password", placeholder: "应用密钥", required: true },
|
||||
{ key: "token", label: "验证令牌", type: "text", placeholder: "回调验证令牌", required: true },
|
||||
{ key: "encoding_aes_key", label: "消息加密密钥", type: "text", placeholder: "消息加密密钥(43位)", required: true },
|
||||
],
|
||||
setup_guide: [
|
||||
"访问 https://work.weixin.qq.com/ 创建应用",
|
||||
"在「应用管理」获取应用编号和应用密钥",
|
||||
"在「我的企业」获取企业编号",
|
||||
"在「应用管理」-「接收消息」设置验证令牌和消息加密密钥",
|
||||
"添加权限:通讯录权限、消息权限",
|
||||
"点击保存后,复制回调地址到企业微信配置",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "hn",
|
||||
name: "技术资讯监控(HN)",
|
||||
description: "定时抓取技术前沿,分析行业趋势",
|
||||
icon: Activity,
|
||||
status: status?.hn_monitor?.active ? "configured" : "available",
|
||||
priority: "medium",
|
||||
estimated_benefit: "每周捕获 2-3 个高价值技术趋势",
|
||||
setup_time: "1 分钟",
|
||||
auto_detected: true,
|
||||
config_fields: [
|
||||
{ key: "hn_interval", label: "抓取间隔(分钟)", type: "text", placeholder: "30", required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "代码仓监控",
|
||||
description: "跟踪竞品仓库、上游依赖更新",
|
||||
icon: Github,
|
||||
status: status?.github?.configured ? "configured" : "available",
|
||||
priority: "medium",
|
||||
estimated_benefit: "及时获知竞品动态和依赖更新",
|
||||
setup_time: "10 分钟",
|
||||
auto_detected: true,
|
||||
config_fields: [
|
||||
{ key: "repo_url", label: "仓库地址", type: "url", placeholder: "https://github.com/user/repo", required: true },
|
||||
{ key: "access_token", label: "访问令牌", type: "password", placeholder: "ghp_xxxxxxxxxxxx", required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
name: "浏览器扩展",
|
||||
description: "监控访问的技术文档、竞品页面",
|
||||
icon: Globe,
|
||||
status: status?.browser?.installed ? "configured" : "needs_setup",
|
||||
priority: "low",
|
||||
estimated_benefit: "自动捕获浏览时的有价值内容",
|
||||
setup_time: "2 分钟",
|
||||
auto_detected: false,
|
||||
config_fields: [],
|
||||
setup_guide: [
|
||||
"从 Chrome 应用商店安装 PMFlow 扩展",
|
||||
"登录您的 PMFlow 账号",
|
||||
"扩展会自动监控技术文档和竞品页面",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
export default function AdvancedTab() {
|
||||
return (
|
||||
<TabsContent value="advanced" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>自动检测规则</CardTitle>
|
||||
<CardDescription>AI 自动判断信号价值的阈值设置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>信号捕获阈值</Label>
|
||||
<span className="text-sm text-slate-500">影响分 ≥ 0.7</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">低于此阈值的信号将被忽略,减少噪音</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>冲突检测灵敏度</Label>
|
||||
<span className="text-sm text-slate-500">中等</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">控制认知冲突检测的严格程度</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>简报推送时间</Label>
|
||||
<span className="text-sm text-slate-500">每天 9:00</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">个性化每日简报的推送时间</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>数据同步设置</CardTitle>
|
||||
<CardDescription>控制数据同步频率和范围</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>实时同步</Label>
|
||||
<p className="text-xs text-slate-500">重要事件立即推送</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>历史数据导入</Label>
|
||||
<p className="text-xs text-slate-500">导入过去 30 天的聊天记录</p>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>自动关联项目</Label>
|
||||
<p className="text-xs text-slate-500">新信号自动关联到活跃项目</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,656 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Github,
|
||||
Globe,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { FEISHU_EVENT_MODE_OPTIONS, getFeishuEventModeLabel } from "@/lib/feishuEventMode";
|
||||
import type {
|
||||
ConfigStatus,
|
||||
FeishuConfigForm,
|
||||
GithubConfigForm,
|
||||
IntegrationSource,
|
||||
MonitorSettings,
|
||||
PageMessage,
|
||||
WecomConfigForm,
|
||||
} from "../types";
|
||||
|
||||
interface ConfigureTabProps {
|
||||
status: ConfigStatus | null;
|
||||
checking: string | null;
|
||||
integrationSources: IntegrationSource[];
|
||||
feishuConfig: FeishuConfigForm;
|
||||
setFeishuConfig: Dispatch<SetStateAction<FeishuConfigForm>>;
|
||||
wecomConfig: WecomConfigForm;
|
||||
setWecomConfig: Dispatch<SetStateAction<WecomConfigForm>>;
|
||||
githubConfig: GithubConfigForm;
|
||||
setGithubConfig: Dispatch<SetStateAction<GithubConfigForm>>;
|
||||
monitorSettings: MonitorSettings;
|
||||
setMonitorSettings: Dispatch<SetStateAction<MonitorSettings>>;
|
||||
showFeishuGuide: boolean;
|
||||
setShowFeishuGuide: Dispatch<SetStateAction<boolean>>;
|
||||
showWecomGuide: boolean;
|
||||
setShowWecomGuide: Dispatch<SetStateAction<boolean>>;
|
||||
editingFeishu: boolean;
|
||||
setEditingFeishu: Dispatch<SetStateAction<boolean>>;
|
||||
editingWecom: boolean;
|
||||
setEditingWecom: Dispatch<SetStateAction<boolean>>;
|
||||
editingGithub: boolean;
|
||||
setEditingGithub: Dispatch<SetStateAction<boolean>>;
|
||||
wecomCopyTip: string;
|
||||
setStatus: Dispatch<SetStateAction<ConfigStatus | null>>;
|
||||
setMessage: Dispatch<SetStateAction<PageMessage | null>>;
|
||||
loadStatus: () => Promise<void>;
|
||||
saveFeishuConfig: () => Promise<void>;
|
||||
testFeishuConnection: () => Promise<void>;
|
||||
deleteFeishuConfig: () => Promise<void>;
|
||||
saveWecomConfig: () => Promise<void>;
|
||||
testWecomConnection: () => Promise<void>;
|
||||
deleteWecomConfig: () => Promise<void>;
|
||||
startHNMonitor: () => Promise<void>;
|
||||
handleCopyWebhook: (text: string) => Promise<void>;
|
||||
getWecomWebhookUrl: () => string;
|
||||
}
|
||||
|
||||
export default function ConfigureTab({
|
||||
status,
|
||||
checking,
|
||||
integrationSources,
|
||||
feishuConfig,
|
||||
setFeishuConfig,
|
||||
wecomConfig,
|
||||
setWecomConfig,
|
||||
githubConfig,
|
||||
setGithubConfig,
|
||||
monitorSettings,
|
||||
setMonitorSettings,
|
||||
showFeishuGuide,
|
||||
setShowFeishuGuide,
|
||||
showWecomGuide,
|
||||
setShowWecomGuide,
|
||||
editingFeishu,
|
||||
setEditingFeishu,
|
||||
editingWecom,
|
||||
setEditingWecom,
|
||||
editingGithub,
|
||||
setEditingGithub,
|
||||
wecomCopyTip,
|
||||
setStatus,
|
||||
setMessage,
|
||||
loadStatus,
|
||||
saveFeishuConfig,
|
||||
testFeishuConnection,
|
||||
deleteFeishuConfig,
|
||||
saveWecomConfig,
|
||||
testWecomConnection,
|
||||
deleteWecomConfig,
|
||||
startHNMonitor,
|
||||
handleCopyWebhook,
|
||||
getWecomWebhookUrl,
|
||||
}: ConfigureTabProps) {
|
||||
return (
|
||||
<TabsContent value="configure" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
{/* Feishu Config */}
|
||||
<Card id="config-feishu" className={status?.feishu?.configured ? "border-green-200 dark:border-green-800" : ""}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||
<CardTitle>飞书群聊集成</CardTitle>
|
||||
{status?.feishu?.configured && (
|
||||
<Badge className="ml-2" variant="default">已配置</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowFeishuGuide(!showFeishuGuide)}
|
||||
>
|
||||
{showFeishuGuide ? "收起指南" : "配置指南"}
|
||||
<ChevronRight className={`w-4 h-4 ml-1 transition-transform ${showFeishuGuide ? "rotate-90" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
AI 自动从飞书群聊捕获需求信号,无需手动录入
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Setup Guide */}
|
||||
{showFeishuGuide && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-3 flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
飞书配置指南(6 步完成)
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{integrationSources.find(s => s.id === "feishu")?.setup_guide?.map((step, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm text-blue-800 dark:text-blue-200">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-800 dark:text-amber-200">
|
||||
⚠️ 配置完成后,请点击下方"测试连接"按钮验证
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.feishu?.configured && !editingFeishu ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="font-medium">飞书已配置</span>
|
||||
{status.feishu.app_id && (
|
||||
<span className="text-xs ml-2 text-green-600 dark:text-green-400">
|
||||
(应用编号: {status.feishu.app_id})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-green-700/80 dark:text-green-300/80 mt-2">
|
||||
当前接收模式:{getFeishuEventModeLabel(status.feishu.event_mode || "long_connection")}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button onClick={testFeishuConnection} disabled={checking === "feishu-test"} size="sm">
|
||||
{checking === "feishu-test" ? <Loader2 className="w-4 h-4 animate-spin" /> : "测试连接"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingFeishu(true);
|
||||
void loadStatus();
|
||||
}}
|
||||
>
|
||||
修改配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deleteFeishuConfig}
|
||||
disabled={checking === "feishu-delete"}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
{checking === "feishu-delete" ? <Loader2 className="w-4 h-4 animate-spin" /> : "删除配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{status?.feishu?.configured && (
|
||||
<div className="p-2 rounded bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-800 dark:text-amber-200">
|
||||
正在修改已有配置:已自动回填当前配置,可直接核对并修改后保存。
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="mb-2 block">事件接收方式 *</Label>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
飞书接入已统一为 <code>long_connection</code>
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{FEISHU_EVENT_MODE_OPTIONS.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 rounded border px-3 py-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feishu_event_mode"
|
||||
value={option.value}
|
||||
checked={feishuConfig.event_mode === option.value}
|
||||
onChange={() => setFeishuConfig({ ...feishuConfig, event_mode: option.value })}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
<code className="text-[11px] text-slate-500">({option.value})</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="feishu_app_id">应用编号 *</Label>
|
||||
<Input
|
||||
id="feishu_app_id"
|
||||
placeholder="cli_xxxxxxxxxxxxx"
|
||||
value={feishuConfig.app_id}
|
||||
onChange={(e) => setFeishuConfig({ ...feishuConfig, app_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="feishu_app_secret">应用密钥 *</Label>
|
||||
<Input
|
||||
id="feishu_app_secret"
|
||||
type="password"
|
||||
placeholder="应用密钥"
|
||||
value={feishuConfig.app_secret}
|
||||
onChange={(e) => setFeishuConfig({ ...feishuConfig, app_secret: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
已切换到飞书长连接模式。无需配置 Webhook、Encrypt Key 或 Verification Token。
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveFeishuConfig}
|
||||
disabled={checking === "feishu" || !feishuConfig.app_id || !feishuConfig.app_secret}
|
||||
className="w-full"
|
||||
>
|
||||
{checking === "feishu" ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
status?.feishu?.configured ? "更新配置" : "保存配置"
|
||||
)}
|
||||
</Button>
|
||||
{status?.feishu?.configured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditingFeishu(false)}
|
||||
disabled={checking === "feishu"}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs text-slate-600 dark:text-slate-300">
|
||||
当前选择的是「长连接」模式,无需配置 Webhook 请求地址。
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* WeChat Work Config */}
|
||||
<Card id="config-wecom" className={status?.wecom?.configured ? "border-green-200 dark:border-green-800" : ""}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-emerald-500" />
|
||||
<CardTitle>企业微信集成</CardTitle>
|
||||
{status?.wecom?.configured && (
|
||||
<Badge className="ml-2" variant="default">已配置</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowWecomGuide(!showWecomGuide)}
|
||||
>
|
||||
{showWecomGuide ? "收起指南" : "配置指南"}
|
||||
<ChevronRight className={`w-4 h-4 ml-1 transition-transform ${showWecomGuide ? "rotate-90" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
AI 自动从企业微信群聊捕获需求信号,无需手动录入
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Setup Guide */}
|
||||
{showWecomGuide && (
|
||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg border border-emerald-200 dark:border-emerald-800">
|
||||
<h4 className="font-semibold text-emerald-900 dark:text-emerald-100 mb-3 flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
企业微信配置指南(6 步完成)
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{integrationSources.find(s => s.id === "wecom")?.setup_guide?.map((step, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-emerald-500 text-white text-xs flex items-center justify-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm text-emerald-800 dark:text-emerald-200">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-800 dark:text-amber-200">
|
||||
⚠️ 配置完成后,请点击下方"测试连接"按钮验证
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.wecom?.configured && !editingWecom ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="font-medium">企业微信已配置</span>
|
||||
{status.wecom.corp_id && (
|
||||
<span className="text-xs ml-2 text-green-600 dark:text-green-400">
|
||||
(企业编号: {status.wecom.corp_id})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{status.wecom.agent_id && (
|
||||
<p className="text-xs text-green-700/80 dark:text-green-300/80 mt-2">
|
||||
应用编号:{status.wecom.agent_id}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-green-700/80 dark:text-green-300/80 mt-1">
|
||||
Token: {status.wecom.token_preview || "未设置"},EncodingAESKey: {status.wecom.encoding_aes_key_preview || "未设置"}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button onClick={testWecomConnection} disabled={checking === "wecom-test"} size="sm">
|
||||
{checking === "wecom-test" ? <Loader2 className="w-4 h-4 animate-spin" /> : "测试连接"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingWecom(true);
|
||||
void loadStatus();
|
||||
}}
|
||||
>
|
||||
修改配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deleteWecomConfig}
|
||||
disabled={checking === "wecom-delete"}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
{checking === "wecom-delete" ? <Loader2 className="w-4 h-4 animate-spin" /> : "删除配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{status?.wecom?.configured && (
|
||||
<div className="p-2 rounded bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-800 dark:text-amber-200">
|
||||
正在修改已有配置:已自动回填当前配置,可直接核对并修改后保存。
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="wecom_corp_id">企业编号 *</Label>
|
||||
<Input
|
||||
id="wecom_corp_id"
|
||||
placeholder="wwxxxxxxxxxxxxxx"
|
||||
value={wecomConfig.corp_id}
|
||||
onChange={(e) => setWecomConfig({ ...wecomConfig, corp_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="wecom_agent_id">应用编号 *</Label>
|
||||
<Input
|
||||
id="wecom_agent_id"
|
||||
placeholder="1000002"
|
||||
value={wecomConfig.agent_id}
|
||||
onChange={(e) => setWecomConfig({ ...wecomConfig, agent_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="wecom_app_secret">应用密钥 *</Label>
|
||||
<Input
|
||||
id="wecom_app_secret"
|
||||
type="password"
|
||||
placeholder="应用密钥"
|
||||
value={wecomConfig.app_secret}
|
||||
onChange={(e) => setWecomConfig({ ...wecomConfig, app_secret: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="wecom_token">验证令牌 *</Label>
|
||||
<Input
|
||||
id="wecom_token"
|
||||
placeholder="回调验证令牌"
|
||||
value={wecomConfig.token}
|
||||
onChange={(e) => setWecomConfig({ ...wecomConfig, token: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="wecom_encoding_aes_key">消息加密密钥 *</Label>
|
||||
<Input
|
||||
id="wecom_encoding_aes_key"
|
||||
placeholder="消息加密密钥(43位)"
|
||||
value={wecomConfig.encoding_aes_key}
|
||||
onChange={(e) => setWecomConfig({ ...wecomConfig, encoding_aes_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveWecomConfig}
|
||||
disabled={checking === "wecom" || !wecomConfig.corp_id || !wecomConfig.agent_id || !wecomConfig.app_secret}
|
||||
className="w-full"
|
||||
>
|
||||
{checking === "wecom" ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
status?.wecom?.configured ? "更新配置" : "保存配置"
|
||||
)}
|
||||
</Button>
|
||||
{status?.wecom?.configured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingWecom(false);
|
||||
void loadStatus();
|
||||
}}
|
||||
disabled={checking === "wecom"}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回调地址 */}
|
||||
<div className="p-3 bg-slate-100 dark:bg-slate-800 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm">回调地址</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{wecomCopyTip && <span className="text-xs text-green-600">{wecomCopyTip}</span>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyWebhook(getWecomWebhookUrl())}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-xs break-all block">{getWecomWebhookUrl()}</code>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
复制此回调地址到企业微信管理后台 - 应用管理 - 接收消息 - 请求地址
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* HN Monitor */}
|
||||
<Card id="config-hn">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-purple-500" />
|
||||
<CardTitle>技术资讯监控(HN)</CardTitle>
|
||||
{status?.hn_monitor?.active && (
|
||||
<Badge className="ml-auto" variant="default">运行中</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
定时抓取 HN 首页,AI 分析技术趋势
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>启用监控</Label>
|
||||
<p className="text-xs text-slate-500">每 {monitorSettings.hn_interval} 分钟抓取一次</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={monitorSettings.hn_enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) startHNMonitor();
|
||||
setMonitorSettings({ ...monitorSettings, hn_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!monitorSettings.hn_enabled && (
|
||||
<Button
|
||||
onClick={startHNMonitor}
|
||||
disabled={checking === "hn"}
|
||||
className="w-full"
|
||||
>
|
||||
{checking === "hn" ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : "启动监控"}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GitHub */}
|
||||
<Card id="config-github" className={status?.github?.configured ? "border-green-200 dark:border-green-800" : ""}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="w-5 h-5 text-slate-700 dark:text-slate-300" />
|
||||
<CardTitle>代码仓监控</CardTitle>
|
||||
{status?.github?.configured && (
|
||||
<Badge className="ml-2" variant="default">已配置</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
跟踪竞品仓库更新、上游依赖变更
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status?.github?.configured && !editingGithub ? (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="font-medium">代码仓监控已配置</span>
|
||||
{status.github.repos && (
|
||||
<span className="text-xs ml-2 text-green-600 dark:text-green-400">
|
||||
({status.github.repos.length} 个仓库)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGithubConfig((prev) => ({
|
||||
...prev,
|
||||
repo_url: status?.github?.repos?.join(",") || prev.repo_url,
|
||||
}));
|
||||
setEditingGithub(true);
|
||||
}}
|
||||
>
|
||||
修改配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGithubConfig({ repo_url: "", webhook_url: "", access_token: "" });
|
||||
setStatus(prev => ({ ...prev!, github: { configured: false } }));
|
||||
setEditingGithub(false);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
删除配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="github_repo_url">仓库地址 *</Label>
|
||||
<Input
|
||||
id="github_repo_url"
|
||||
placeholder="https://github.com/user/repo"
|
||||
value={githubConfig.repo_url}
|
||||
onChange={(e) => setGithubConfig({ ...githubConfig, repo_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">支持输入多个仓库,用逗号分隔</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="github_webhook_url">回调地址</Label>
|
||||
<Input
|
||||
id="github_webhook_url"
|
||||
placeholder="https://your-domain.com/api/github/webhook"
|
||||
value={githubConfig.webhook_url}
|
||||
onChange={(e) => setGithubConfig({ ...githubConfig, webhook_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">仓库侧配置的回调地址</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="github_token">访问令牌</Label>
|
||||
<Input
|
||||
id="github_token"
|
||||
type="password"
|
||||
placeholder="ghp_xxxxxxxxxxxx"
|
||||
value={githubConfig.access_token}
|
||||
onChange={(e) => setGithubConfig({ ...githubConfig, access_token: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">用于获取仓库信息的个人访问令牌</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMessage({ type: "success", text: "代码仓监控配置已保存" });
|
||||
setStatus(prev => ({ ...prev!, github: { configured: true, repos: githubConfig.repo_url.split(",") } }));
|
||||
setEditingGithub(false);
|
||||
}}
|
||||
disabled={!githubConfig.repo_url}
|
||||
className="w-full"
|
||||
>
|
||||
{status?.github?.configured ? "更新配置" : "保存配置"}
|
||||
</Button>
|
||||
{status?.github?.configured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditingGithub(false)}
|
||||
className="w-full"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Browser Extension */}
|
||||
<Card id="config-browser">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-green-500" />
|
||||
<CardTitle>浏览器扩展</CardTitle>
|
||||
<Badge className="ml-auto" variant="secondary">敬请期待</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
自动监控访问的技术文档、竞品页面
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-4 bg-slate-100 dark:bg-slate-800 rounded-lg text-center">
|
||||
<Globe className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
浏览器扩展正在开发中
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
支持功能:自动捕获页面内容、智能分析价值
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { Link2, Loader2, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
import type { KnowledgeDoc } from "../types";
|
||||
|
||||
interface KnowledgeTabProps {
|
||||
checking: string | null;
|
||||
knowledgeDocs: KnowledgeDoc[];
|
||||
knowledgeFile: File | null;
|
||||
knowledgeURL: string;
|
||||
knowledgeURLTitle: string;
|
||||
onKnowledgeFileChange: (file: File | null) => void;
|
||||
onKnowledgeURLChange: (value: string) => void;
|
||||
onKnowledgeURLTitleChange: (value: string) => void;
|
||||
onUploadKnowledgeFile: () => void;
|
||||
onAddKnowledgeURL: () => void;
|
||||
onRefreshKnowledgeDoc: (docId: number) => void;
|
||||
onDeleteKnowledgeDoc: (docId: number) => void;
|
||||
}
|
||||
|
||||
export default function KnowledgeTab({
|
||||
checking,
|
||||
knowledgeDocs,
|
||||
knowledgeFile,
|
||||
knowledgeURL,
|
||||
knowledgeURLTitle,
|
||||
onKnowledgeFileChange,
|
||||
onKnowledgeURLChange,
|
||||
onKnowledgeURLTitleChange,
|
||||
onUploadKnowledgeFile,
|
||||
onAddKnowledgeURL,
|
||||
onRefreshKnowledgeDoc,
|
||||
onDeleteKnowledgeDoc,
|
||||
}: KnowledgeTabProps) {
|
||||
return (
|
||||
<TabsContent value="knowledge" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-violet-500" />
|
||||
文件上传
|
||||
</CardTitle>
|
||||
<CardDescription>支持 PDF / Word / Markdown / TXT,上传后自动分块与向量化。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.md,.markdown,.txt"
|
||||
onChange={(e) => onKnowledgeFileChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-slate-500">{knowledgeFile ? `已选择: ${knowledgeFile.name}` : "未选择文件"}</div>
|
||||
<Button onClick={onUploadKnowledgeFile} disabled={!knowledgeFile || checking === "knowledge-upload"}>
|
||||
{checking === "knowledge-upload" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
上传中…
|
||||
</>
|
||||
) : (
|
||||
"上传并处理"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-blue-500" />
|
||||
URL 知识源
|
||||
</CardTitle>
|
||||
<CardDescription>添加公开文档 URL,系统自动抓取并向量化,可手动刷新。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="URL 标题(可选)"
|
||||
value={knowledgeURLTitle}
|
||||
onChange={(e) => onKnowledgeURLTitleChange(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="https://example.com/docs"
|
||||
value={knowledgeURL}
|
||||
onChange={(e) => onKnowledgeURLChange(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onAddKnowledgeURL} disabled={!knowledgeURL.trim() || checking === "knowledge-url"}>
|
||||
{checking === "knowledge-url" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
添加中…
|
||||
</>
|
||||
) : (
|
||||
"Add URL"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>知识库列表</CardTitle>
|
||||
<CardDescription>处理中状态会每 3 秒自动刷新。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{knowledgeDocs.length === 0 ? (
|
||||
<div className="text-sm text-slate-500">暂无知识库条目</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{knowledgeDocs.map((doc) => (
|
||||
<div key={doc.id} className="p-3 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="font-medium truncate">{doc.title}</div>
|
||||
<div className="text-xs text-slate-500">类型: {doc.file_type} · 来源: {doc.source_type}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
上传时间: {formatBeijingDateTime(doc.created_at)}
|
||||
{doc.last_fetched_at ? ` · 最近抓取: ${formatBeijingDateTime(doc.last_fetched_at)}` : ""}
|
||||
</div>
|
||||
{doc.file_url && <div className="text-xs text-blue-600 truncate">{doc.file_url}</div>}
|
||||
{doc.error_message && <div className="text-xs text-red-600">{doc.error_message}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant={doc.status === "complete" ? "default" : doc.status === "failed" ? "destructive" : "secondary"}>
|
||||
{doc.status === "processing" ? "processing" : doc.status === "complete" ? "complete" : "failed"}
|
||||
</Badge>
|
||||
{doc.source_type === "url" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onRefreshKnowledgeDoc(doc.id)}
|
||||
disabled={checking === `knowledge-refresh-${doc.id}`}
|
||||
>
|
||||
{checking === `knowledge-refresh-${doc.id}` ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteKnowledgeDoc(doc.id)}
|
||||
disabled={checking === `knowledge-delete-${doc.id}`}
|
||||
>
|
||||
{checking === `knowledge-delete-${doc.id}` ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import PriorityBadge from "../components/PriorityBadge";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
import type { IntegrationSource } from "../types";
|
||||
|
||||
interface OverviewTabProps {
|
||||
integrationSources: IntegrationSource[];
|
||||
onConfigureClick: (sourceId: string) => void;
|
||||
}
|
||||
|
||||
export default function OverviewTab({ integrationSources, onConfigureClick }: OverviewTabProps) {
|
||||
return (
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{integrationSources.map((source) => {
|
||||
const Icon = source.icon;
|
||||
return (
|
||||
<Card key={source.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
source.status === "configured"
|
||||
? "bg-green-100 dark:bg-green-900/30"
|
||||
: source.status === "needs_setup"
|
||||
? "bg-orange-100 dark:bg-orange-900/30"
|
||||
: "bg-slate-100 dark:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-5 h-5 ${
|
||||
source.status === "configured"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: source.status === "needs_setup"
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-slate-600 dark:text-slate-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{source.name}</CardTitle>
|
||||
<CardDescription className="text-sm">{source.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={source.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">优先级</span>
|
||||
<PriorityBadge priority={source.priority} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">预计收益</span>
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300">{source.estimated_benefit}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">配置耗时</span>
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300">{source.setup_time}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-2"
|
||||
onClick={() => onConfigureClick(source.id)}
|
||||
>
|
||||
{source.status === "configured" ? "查看配置" : "开始配置"}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import type { FeishuEventMode } from "@/lib/feishuEventMode";
|
||||
|
||||
export type IntegrationStatus = "available" | "configured" | "needs_setup" | "unavailable";
|
||||
export type IntegrationPriority = "high" | "medium" | "low";
|
||||
|
||||
export interface ConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "password" | "url" | "toggle";
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IntegrationSource {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
status: IntegrationStatus;
|
||||
priority: IntegrationPriority;
|
||||
estimated_benefit: string;
|
||||
setup_time: string;
|
||||
auto_detected: boolean;
|
||||
config_fields?: ConfigField[];
|
||||
setup_guide?: string[];
|
||||
}
|
||||
|
||||
export interface AIConfigSuggestion {
|
||||
recommended_sources: string[];
|
||||
reasoning: string;
|
||||
estimated_impact: string;
|
||||
}
|
||||
|
||||
export interface ConfigStatus {
|
||||
feishu: {
|
||||
configured: boolean;
|
||||
app_id?: string;
|
||||
event_mode?: FeishuEventMode;
|
||||
};
|
||||
wecom: {
|
||||
configured: boolean;
|
||||
corp_id?: string;
|
||||
agent_id?: string;
|
||||
token_preview?: string;
|
||||
encoding_aes_key_preview?: string;
|
||||
};
|
||||
github: { configured: boolean; repos?: string[] };
|
||||
browser: { installed: boolean };
|
||||
hn_monitor: { active: boolean; last_run?: string };
|
||||
}
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
file_type: string;
|
||||
source_type: string;
|
||||
file_url?: string | null;
|
||||
status: "processing" | "complete" | "failed";
|
||||
created_at?: string | null;
|
||||
last_fetched_at?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface PageMessage {
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FeishuConfigForm {
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
event_mode: FeishuEventMode;
|
||||
}
|
||||
|
||||
export interface WecomConfigForm {
|
||||
corp_id: string;
|
||||
agent_id: string;
|
||||
app_secret: string;
|
||||
token: string;
|
||||
encoding_aes_key: string;
|
||||
}
|
||||
|
||||
export interface GithubConfigForm {
|
||||
repo_url: string;
|
||||
webhook_url: string;
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface MonitorSettings {
|
||||
hn_enabled: boolean;
|
||||
hn_interval: number;
|
||||
browser_enabled: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,538 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import {
|
||||
Card, CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
|
||||
import {
|
||||
aiStewardApi,
|
||||
type LLMEngine,
|
||||
type LLMEngineUpsertRequest,
|
||||
} from "@/api/aiSteward";
|
||||
import {
|
||||
DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
ENGINE_PROVIDER_FALLBACK_PRESETS,
|
||||
} from "./constants";
|
||||
import type {
|
||||
Blocker,
|
||||
CognitiveConflict,
|
||||
DailyBriefing,
|
||||
EngineDataState,
|
||||
EngineForm,
|
||||
EngineMessage,
|
||||
EngineProviderPresets,
|
||||
ProjectFromTextResponse,
|
||||
} from "./types";
|
||||
import FeatureOverviewCards from "./components/FeatureOverviewCards";
|
||||
import BlockersTab from "./sections/BlockersTab";
|
||||
import BriefingTab from "./sections/BriefingTab";
|
||||
import ConflictsTab from "./sections/ConflictsTab";
|
||||
import CreateProjectTab from "./sections/CreateProjectTab";
|
||||
import EngineTab from "./sections/EngineTab";
|
||||
import OverviewTab from "./sections/OverviewTab";
|
||||
import SignalsTab from "./sections/SignalsTab";
|
||||
|
||||
export default function AIStewardPage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
// Project creation state
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projectResult, setProjectResult] = useState<ProjectFromTextResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// AI Steward features state
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
||||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
||||
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
|
||||
const [engineData, setEngineData] = useState<EngineDataState>({
|
||||
active_engine_id: null,
|
||||
engines: [],
|
||||
settings: DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
const [engineProviderPresets, setEngineProviderPresets] = useState<EngineProviderPresets>(
|
||||
ENGINE_PROVIDER_FALLBACK_PRESETS
|
||||
);
|
||||
const [engineForm, setEngineForm] = useState<EngineForm>({
|
||||
name: "",
|
||||
provider: "glm",
|
||||
api_key: "",
|
||||
model: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
|
||||
base_url: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
|
||||
});
|
||||
const [engineEditingId, setEngineEditingId] = useState<string | null>(null);
|
||||
const [engineMessage, setEngineMessage] = useState<EngineMessage | null>(null);
|
||||
|
||||
// ==================== API Calls ====================
|
||||
|
||||
const loadConflicts = async () => {
|
||||
setFeatureLoading("conflicts");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/cognitive-conflicts`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConflicts(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load conflicts:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBlockers = async () => {
|
||||
setFeatureLoading("blockers");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/active`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBlockers(data.blockers || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load blockers:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBriefing = async () => {
|
||||
setFeatureLoading("briefing");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBriefing(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load briefing:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const detectConflicts = async (projectId: number = 1) => {
|
||||
setFeatureLoading("detect-conflicts");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/conflicts/detect`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ project_id: projectId, detect_type: "all" }),
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadConflicts();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to detect conflicts:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const detectTaskBlockers = async () => {
|
||||
setFeatureLoading("detect-blockers");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/detect-tasks`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadBlockers();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to detect blockers:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const startHNMonitor = async () => {
|
||||
setFeatureLoading("hn-monitor");
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/signal/start-hn-monitor?interval_minutes=30`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to start HN monitor:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const loadEngineConfigs = async () => {
|
||||
setFeatureLoading("engine-list");
|
||||
try {
|
||||
const data = await aiStewardApi.listLlmEngines();
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load engine configs:", err);
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEngineProviderPresets = async () => {
|
||||
try {
|
||||
const data = await aiStewardApi.getLlmProviderPresets();
|
||||
const providers = data?.providers ?? {};
|
||||
const nextPresets: EngineProviderPresets = {
|
||||
deepseek: {
|
||||
model: providers.deepseek?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.model,
|
||||
base_url: providers.deepseek?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.base_url,
|
||||
},
|
||||
glm: {
|
||||
model: providers.glm?.model || data?.low || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
|
||||
base_url: providers.glm?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
|
||||
},
|
||||
openai: {
|
||||
model: providers.openai?.model || data?.high || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.model,
|
||||
base_url: providers.openai?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.base_url,
|
||||
},
|
||||
ollama: {
|
||||
model: providers.ollama?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.model,
|
||||
base_url: providers.ollama?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.base_url,
|
||||
},
|
||||
};
|
||||
setEngineProviderPresets(nextPresets);
|
||||
setEngineForm((prev) => {
|
||||
if (engineEditingId) return prev;
|
||||
const preset = nextPresets[prev.provider];
|
||||
return {
|
||||
...prev,
|
||||
model: preset.model,
|
||||
base_url: preset.base_url,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load provider presets:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const resetEngineForm = () => {
|
||||
setEngineEditingId(null);
|
||||
setEngineForm({
|
||||
name: "",
|
||||
provider: "glm",
|
||||
api_key: "",
|
||||
model: engineProviderPresets.glm.model,
|
||||
base_url: engineProviderPresets.glm.base_url,
|
||||
});
|
||||
};
|
||||
|
||||
const saveEngineConfig = async () => {
|
||||
setFeatureLoading("engine-save");
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const isEdit = Boolean(engineEditingId);
|
||||
const body: LLMEngineUpsertRequest = {
|
||||
name: engineForm.name || `${engineForm.provider.toUpperCase()} 引擎`,
|
||||
provider: engineForm.provider,
|
||||
model: engineForm.model || undefined,
|
||||
base_url: engineForm.base_url || undefined,
|
||||
};
|
||||
if (engineForm.api_key.trim()) {
|
||||
body.api_key = engineForm.api_key.trim();
|
||||
}
|
||||
if (!isEdit) {
|
||||
body.set_active = true;
|
||||
}
|
||||
const data = isEdit
|
||||
? await aiStewardApi.updateLlmEngine(String(engineEditingId), body)
|
||||
: await aiStewardApi.createLlmEngine(body);
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
setEngineMessage({ type: "success", text: isEdit ? "引擎配置已更新" : "引擎配置已新增" });
|
||||
resetEngineForm();
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,保存失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activateEngine = async (engineId: string) => {
|
||||
setFeatureLoading(`engine-activate-${engineId}`);
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = await aiStewardApi.activateLlmEngine(engineId);
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
setEngineMessage({ type: "success", text: "已切换激活引擎" });
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,切换失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEngine = async (engineId: string) => {
|
||||
if (!confirm("确定删除该引擎配置吗?")) return;
|
||||
setFeatureLoading(`engine-delete-${engineId}`);
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = await aiStewardApi.deleteLlmEngine(engineId);
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
if (engineEditingId === engineId) {
|
||||
resetEngineForm();
|
||||
}
|
||||
setEngineMessage({ type: "success", text: "引擎配置已删除" });
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,删除失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const testEngineConnection = async (engineId?: string) => {
|
||||
const key = engineId ? `engine-test-${engineId}` : "engine-test-active";
|
||||
setFeatureLoading(key);
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = engineId ? await aiStewardApi.testLlmEngine(engineId) : await aiStewardApi.testActiveLlmEngine();
|
||||
const name = data?.name || "未知引擎";
|
||||
const latency = typeof data?.latency_ms === "number" ? `${data.latency_ms}ms` : "-";
|
||||
const preview = String(data?.response_preview || "").slice(0, 60);
|
||||
setEngineMessage({
|
||||
type: "success",
|
||||
text: `连通成功:${name}(延迟 ${latency})${preview ? `,响应:${preview}` : ""}`,
|
||||
});
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,连通测试失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateLocalKbQaSetting = async (checked: boolean) => {
|
||||
setFeatureLoading("engine-settings");
|
||||
setEngineMessage(null);
|
||||
try {
|
||||
const data = await aiStewardApi.updateLlmEngineSettings({ enable_local_kb_qa: checked });
|
||||
setEngineData({
|
||||
active_engine_id: data?.active_engine_id ?? null,
|
||||
engines: data?.engines ?? [],
|
||||
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
|
||||
});
|
||||
setEngineMessage({
|
||||
type: "success",
|
||||
text: checked ? "已启用本地 LLM,仅知识库问答可使用" : "已禁用本地 LLM",
|
||||
});
|
||||
} catch (err) {
|
||||
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "更新开关失败" });
|
||||
} finally {
|
||||
setFeatureLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditEngine = (engine: LLMEngine) => {
|
||||
setEngineEditingId(engine.id);
|
||||
setEngineForm({
|
||||
name: engine.name || "",
|
||||
provider: engine.provider,
|
||||
api_key: "",
|
||||
model: engine.model || "",
|
||||
base_url: engine.base_url || "",
|
||||
});
|
||||
setEngineMessage(null);
|
||||
};
|
||||
|
||||
// ==================== Project Creation ====================
|
||||
|
||||
const handleProjectSubmit = async () => {
|
||||
if (!input.trim()) {
|
||||
setError("请输入项目描述");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProjectResult(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/ai-steward/project-from-text`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ input_text: input }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "项目创建失败");
|
||||
}
|
||||
|
||||
const data: ProjectFromTextResponse = await response.json();
|
||||
setProjectResult(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "未知错误");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Initial Load ====================
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "conflicts") loadConflicts();
|
||||
if (activeTab === "blockers") loadBlockers();
|
||||
if (activeTab === "briefing") loadBriefing();
|
||||
if (activeTab === "engine") {
|
||||
loadEngineProviderPresets();
|
||||
loadEngineConfigs();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// ==================== Render ====================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Sparkles className="w-10 h-10 text-violet-500" />
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
|
||||
AI 管家
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
||||
主动式 AI 管家 - 7x24 小时自动守护项目
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FeatureOverviewCards onSelectTab={(tab) => setActiveTab(tab)} />
|
||||
|
||||
{/* Main Content */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
|
||||
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
||||
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
|
||||
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
|
||||
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
|
||||
<TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
|
||||
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
|
||||
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<OverviewTab />
|
||||
|
||||
<CreateProjectTab
|
||||
input={input}
|
||||
loading={loading}
|
||||
error={error}
|
||||
projectResult={projectResult}
|
||||
onInputChange={setInput}
|
||||
onSubmit={handleProjectSubmit}
|
||||
onResetProject={() => setProjectResult(null)}
|
||||
/>
|
||||
|
||||
<SignalsTab
|
||||
featureLoading={featureLoading}
|
||||
onStartHNMonitor={() => void startHNMonitor()}
|
||||
onOpenFeishuConfig={() => navigate("/feishu-config")}
|
||||
/>
|
||||
|
||||
<ConflictsTab
|
||||
featureLoading={featureLoading}
|
||||
conflicts={conflicts}
|
||||
onDetectConflicts={() => void detectConflicts()}
|
||||
/>
|
||||
|
||||
<BlockersTab
|
||||
featureLoading={featureLoading}
|
||||
blockers={blockers}
|
||||
onDetectTaskBlockers={() => void detectTaskBlockers()}
|
||||
/>
|
||||
|
||||
<BriefingTab
|
||||
featureLoading={featureLoading}
|
||||
briefing={briefing}
|
||||
username={user?.username}
|
||||
onRefreshBriefing={() => void loadBriefing()}
|
||||
/>
|
||||
|
||||
{/* QA Records Tab */}
|
||||
<TabsContent value="qa" className="mt-6">
|
||||
<QAHistoryPanel />
|
||||
</TabsContent>
|
||||
|
||||
<EngineTab
|
||||
featureLoading={featureLoading}
|
||||
engineData={engineData}
|
||||
engineMessage={engineMessage}
|
||||
engineForm={engineForm}
|
||||
engineEditingId={engineEditingId}
|
||||
engineProviderPresets={engineProviderPresets}
|
||||
setEngineForm={setEngineForm}
|
||||
onTestActiveEngine={() => void testEngineConnection()}
|
||||
onRefreshEngines={() => void loadEngineConfigs()}
|
||||
onUpdateLocalKbQaSetting={(checked) => void updateLocalKbQaSetting(checked)}
|
||||
onActivateEngine={(engineId) => void activateEngine(engineId)}
|
||||
onTestEngine={(engineId) => void testEngineConnection(engineId)}
|
||||
onStartEditEngine={startEditEngine}
|
||||
onDeleteEngine={(engineId) => void deleteEngine(engineId)}
|
||||
onSaveEngineConfig={() => void saveEngineConfig()}
|
||||
onResetEngineForm={resetEngineForm}
|
||||
/>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { AlertTriangle, Bell, Radar, Shield } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface FeatureOverviewCardsProps {
|
||||
onSelectTab: (tab: "signals" | "conflicts" | "blockers" | "briefing") => void;
|
||||
}
|
||||
|
||||
export default function FeatureOverviewCards({ onSelectTab }: FeatureOverviewCardsProps) {
|
||||
return (
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-blue-200 dark:border-blue-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("signals")}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Radar className="w-8 h-8 text-blue-500" />
|
||||
<Badge variant="secondary">主动抓取</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-lg">信号捕获</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">飞书群聊、技术资讯、代码仓、浏览器 - 自动捕获需求信号</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-orange-200 dark:border-orange-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("conflicts")}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<AlertTriangle className="w-8 h-8 text-orange-500" />
|
||||
<Badge variant="secondary">认知对齐</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-lg">冲突消解</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">自动检测隐性认知差异,推送 1v1 异步对齐</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-red-200 dark:border-red-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("blockers")}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Shield className="w-8 h-8 text-red-500" />
|
||||
<Badge variant="secondary">自动解决</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-lg">阻塞检测</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">代码注释扫描,自动生成解决方案,创建追踪任务</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-green-200 dark:border-green-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => onSelectTab("briefing")}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Bell className="w-8 h-8 text-green-500" />
|
||||
<Badge variant="secondary">智能简报</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-lg">进度同步</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">个性化每日简报,只显示相关变更,替代日报</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { LLMEngine } from "@/api/aiSteward";
|
||||
import type { EngineProviderPresets } from "./types";
|
||||
|
||||
export const PROVIDER_ZH: Record<LLMEngine["provider"], string> = {
|
||||
deepseek: "DeepSeek",
|
||||
glm: "GLM",
|
||||
openai: "OpenAI",
|
||||
ollama: "Ollama(本地)",
|
||||
};
|
||||
|
||||
export const DEFAULT_LLM_ENGINE_SETTINGS = {
|
||||
enable_local_kb_qa: false,
|
||||
local_kb_qa_scope: "kb_qa_only" as const,
|
||||
};
|
||||
|
||||
export const ENGINE_PROVIDER_FALLBACK_PRESETS: EngineProviderPresets = {
|
||||
deepseek: {
|
||||
model: "deepseek-chat",
|
||||
base_url: "https://api.deepseek.com/v1",
|
||||
},
|
||||
glm: {
|
||||
model: "GLM-4.7",
|
||||
base_url: "https://api.edgefn.net/v1",
|
||||
},
|
||||
openai: {
|
||||
model: "MiniMax-M2.5",
|
||||
base_url: "https://api.edgefn.net/v1",
|
||||
},
|
||||
ollama: {
|
||||
model: "llama3:latest",
|
||||
base_url: "http://host.docker.internal:11434",
|
||||
},
|
||||
};
|
||||
|
||||
export const EXAMPLE_INPUTS = [
|
||||
"我们要做一个智能客服工具,基于大语言模型,对标同类竞品,3 周上线",
|
||||
"开发一个智能推荐系统,使用协同过滤算法,针对电商用户,2个月内完成",
|
||||
"构建实时数据大屏,采用实时推送与可视化图表,支持百万级数据点,1个月交付",
|
||||
];
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { CheckCircle2, Loader2, Shield, Target } from "lucide-react";
|
||||
import { formatBeijingDate } from "@/lib/datetime";
|
||||
import { toZhSeverity } from "@/lib/display";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import type { Blocker } from "../types";
|
||||
|
||||
interface BlockersTabProps {
|
||||
featureLoading: string | null;
|
||||
blockers: Blocker[];
|
||||
onDetectTaskBlockers: () => void;
|
||||
}
|
||||
|
||||
export default function BlockersTab({ featureLoading, blockers, onDetectTaskBlockers }: BlockersTabProps) {
|
||||
return (
|
||||
<TabsContent value="blockers" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-red-500" />
|
||||
阻塞检测
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">AI 自动扫描代码注释,识别阻塞并生成解决方案</p>
|
||||
</div>
|
||||
<Button onClick={onDetectTaskBlockers} disabled={featureLoading === "detect-blockers"}>
|
||||
{featureLoading === "detect-blockers" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
检测中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Target className="w-4 h-4 mr-2" />
|
||||
扫描任务
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">支持的阻塞模式</h4>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<Badge variant="outline">等待 X 接口</Badge>
|
||||
<Badge variant="outline">接口效果不达标</Badge>
|
||||
<Badge variant="outline">暂时跳过</Badge>
|
||||
<Badge variant="outline">待办等待</Badge>
|
||||
<Badge variant="outline">被阻塞</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{featureLoading === "blockers" ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-violet-500" />
|
||||
</div>
|
||||
) : blockers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||
<h4 className="font-semibold text-green-700 dark:text-green-300">未检测到阻塞</h4>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mt-2">所有任务进展正常,点击"扫描任务"进行检查</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{blockers.map((blocker) => (
|
||||
<Card key={blocker.id} className="border-red-200 dark:border-red-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-red-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{blocker.type}</Badge>
|
||||
<Badge variant={String(blocker.severity || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
||||
{toZhSeverity(blocker.severity)}
|
||||
</Badge>
|
||||
{blocker.detected_at && <span className="text-xs text-slate-500">{formatBeijingDate(blocker.detected_at)}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{blocker.description}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline">
|
||||
查看方案
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckSquare,
|
||||
FileText,
|
||||
ListTodo,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toZhStatus } from "@/lib/display";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import type { DailyBriefing } from "../types";
|
||||
|
||||
interface BriefingTabProps {
|
||||
featureLoading: string | null;
|
||||
briefing: DailyBriefing | null;
|
||||
username?: string;
|
||||
onRefreshBriefing: () => void;
|
||||
}
|
||||
|
||||
export default function BriefingTab({ featureLoading, briefing, username, onRefreshBriefing }: BriefingTabProps) {
|
||||
return (
|
||||
<TabsContent value="briefing" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-green-500" />
|
||||
每日进度简报
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{username} 的个性化简报 - 只显示相关内容</p>
|
||||
</div>
|
||||
<Button onClick={onRefreshBriefing} disabled={featureLoading === "briefing"}>
|
||||
{featureLoading === "briefing" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
刷新中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
刷新简报
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{featureLoading === "briefing" ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-violet-500" />
|
||||
</div>
|
||||
) : !briefing ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<Bell className="w-12 h-12 text-slate-400 mx-auto mb-3" />
|
||||
<h4 className="font-semibold text-slate-700 dark:text-slate-300">点击"刷新简报"加载今日内容</h4>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mt-2">AI 会根据您的角色筛选相关信息</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 border-violet-200 dark:border-violet-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-6 h-6 text-violet-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">AI 摘要</h4>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{briefing.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ListTodo className="w-4 h-4 text-blue-500" />
|
||||
你的任务 ({briefing.your_tasks?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{briefing.your_tasks && briefing.your_tasks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{briefing.your_tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-800 rounded">
|
||||
<span className="text-sm">{task.title}</span>
|
||||
<Badge variant="outline" className="text-xs">{toZhStatus(task.status)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">暂无任务</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-orange-500" />
|
||||
行动项 ({briefing.action_items?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{briefing.action_items && briefing.action_items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{briefing.action_items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2 bg-orange-50 dark:bg-orange-900/20 rounded">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
<span className="text-sm">{item.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">暂无行动项</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
风险 ({briefing.risks?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{briefing.risks && briefing.risks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{briefing.risks.map((risk, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2 bg-red-50 dark:bg-red-900/20 rounded">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-sm">{risk.task}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">暂无风险</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500" />
|
||||
需要对齐 ({briefing.conflicts?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{briefing.conflicts && briefing.conflicts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{briefing.conflicts.map((conflict) => (
|
||||
<div key={conflict.id} className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Badge variant="outline" className="text-xs">{conflict.type}</Badge>
|
||||
</div>
|
||||
<p className="text-xs">{conflict.description.substring(0, 60)}...</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">暂无冲突</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{briefing.signals && briefing.signals.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-purple-500" />
|
||||
高影响信号 ({briefing.signals.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{briefing.signals.map((signal) => (
|
||||
<div key={signal.id} className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge variant="outline" className="text-xs">{signal.source}</Badge>
|
||||
<span className="text-xs text-purple-600 dark:text-purple-400">影响分: {signal.impact_score.toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="text-sm">{signal.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{briefing.team_updates && briefing.team_updates.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-slate-500" />
|
||||
团队更新 ({briefing.team_updates.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{briefing.team_updates.map((update, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-800 rounded">
|
||||
<span className="text-sm">{update.task}</span>
|
||||
<Badge variant="outline" className="text-xs">{toZhStatus(update.status)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { AlertTriangle, CheckCircle2, Loader2, Users, Zap } from "lucide-react";
|
||||
import { toZhSeverity } from "@/lib/display";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import type { CognitiveConflict } from "../types";
|
||||
|
||||
interface ConflictsTabProps {
|
||||
featureLoading: string | null;
|
||||
conflicts: CognitiveConflict[];
|
||||
onDetectConflicts: () => void;
|
||||
}
|
||||
|
||||
export default function ConflictsTab({ featureLoading, conflicts, onDetectConflicts }: ConflictsTabProps) {
|
||||
return (
|
||||
<TabsContent value="conflicts" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
认知冲突检测
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">AI 自动识别团队隐性认知差异</p>
|
||||
</div>
|
||||
<Button onClick={onDetectConflicts} disabled={featureLoading === "detect-conflicts"}>
|
||||
{featureLoading === "detect-conflicts" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
检测中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
开始检测
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold mb-1">什么是认知冲突?</h4>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
产品在宪章写"用户留存提升10%",开发实现"DAU提升10%",双方没有争论,但目标不一致。AI 会自动识别这种隐性差异,推送 1v1 异步对齐。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{featureLoading === "conflicts" ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-violet-500" />
|
||||
</div>
|
||||
) : conflicts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||
<h4 className="font-semibold text-green-700 dark:text-green-300">未检测到认知冲突</h4>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mt-2">团队认知状态良好,点击"开始检测"进行扫描</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{conflicts.map((conflict) => (
|
||||
<Card key={conflict.id} className="border-orange-200 dark:border-orange-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className={`w-5 h-5 mt-0.5 ${String(conflict.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{conflict.type}</Badge>
|
||||
<Badge variant={String(conflict.severity || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
||||
{toZhSeverity(conflict.severity)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{conflict.description}</p>
|
||||
{conflict.related_users.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<Users className="w-3 h-3 text-slate-500" />
|
||||
<span className="text-xs text-slate-500">涉及 {conflict.related_users.length} 人</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline">
|
||||
查看方案
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import { AlertCircle, AlertTriangle, CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||
import { formatProjectNameWithId, toZhPriority, toZhSeverity } from "@/lib/display";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EXAMPLE_INPUTS } from "../constants";
|
||||
import type { ProjectFromTextResponse } from "../types";
|
||||
|
||||
interface CreateProjectTabProps {
|
||||
input: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
projectResult: ProjectFromTextResponse | null;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onResetProject: () => void;
|
||||
}
|
||||
|
||||
export default function CreateProjectTab({
|
||||
input,
|
||||
loading,
|
||||
error,
|
||||
projectResult,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onResetProject,
|
||||
}: CreateProjectTabProps) {
|
||||
return (
|
||||
<TabsContent value="create" className="mt-6">
|
||||
{!projectResult ? (
|
||||
<div className="max-w-2xl mx-auto space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">描述你的项目</label>
|
||||
<Textarea
|
||||
placeholder="例如:我们要做一个智能客服工具,基于大语言模型,对标同类竞品,3 周上线"
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">示例输入(点击填充)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXAMPLE_INPUTS.map((example, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onInputChange(example)}
|
||||
className="px-3 py-1.5 text-sm bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
{example.length > 30 ? `${example.substring(0, 30)}...` : example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={onSubmit} disabled={loading || !input.trim()} className="w-full bg-violet-600 hover:bg-violet-700" size="lg">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
AI 正在生成项目…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
一键生成项目
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900 dark:text-green-100">项目创建成功!</h3>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">AI 已为您生成了完整的项目配置,包含信号分析、宪章、任务链和风险预警</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="project-overview">
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="project-overview" className="shrink-0 whitespace-nowrap">项目概览</TabsTrigger>
|
||||
<TabsTrigger value="project-charter" className="shrink-0 whitespace-nowrap">项目宪章</TabsTrigger>
|
||||
<TabsTrigger value="project-tasks" className="shrink-0 whitespace-nowrap">任务列表</TabsTrigger>
|
||||
<TabsTrigger value="project-risks" className="shrink-0 whitespace-nowrap">风险预警</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="project-overview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{formatProjectNameWithId({
|
||||
id: projectResult.project.id,
|
||||
title: projectResult.project.title || projectResult.project.name,
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>{projectResult.project.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-slate-500">技术栈</label>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{projectResult.analysis.tech_stack.map((tech, i) => (
|
||||
<Badge key={i} variant="secondary">{tech}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-500">时间周期</label>
|
||||
<p className="mt-1">{projectResult.analysis.timeline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onResetProject} variant="outline" className="w-full">
|
||||
创建另一个项目
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="project-charter">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>项目宪章</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-semibold mb-2">{projectResult.charter.title}</h3>
|
||||
<p className="whitespace-pre-wrap text-slate-700 dark:text-slate-300">{projectResult.charter.content}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="project-tasks">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 生成的任务链</CardTitle>
|
||||
<CardDescription>共 {projectResult.tasks.length} 个任务</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{projectResult.tasks.map((task, index) => (
|
||||
<div key={task.id} className="flex items-start gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-violet-100 dark:bg-violet-900 flex items-center justify-center text-violet-600 dark:text-violet-400 text-xs font-semibold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold">{task.title}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline">{task.type}</Badge>
|
||||
<Badge variant={String(task.priority || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
||||
{toZhPriority(task.priority)}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-500">{task.estimated_hours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="project-risks">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 风险预警规则</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{projectResult.risks.map((risk, index) => (
|
||||
<div key={index} className="flex items-start gap-3 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<AlertTriangle className={`w-5 h-5 ${String(risk.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
|
||||
<div>
|
||||
<h4 className="font-semibold">{risk.description}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline">{risk.type}</Badge>
|
||||
<Badge variant={String(risk.severity || "").toLowerCase() === "high" ? "destructive" : "default"}>
|
||||
{toZhSeverity(risk.severity)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { AlertCircle, Loader2, Sparkles } from "lucide-react";
|
||||
import { type LLMEngine } from "@/api/aiSteward";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { PROVIDER_ZH } from "../constants";
|
||||
import type { EngineDataState, EngineForm, EngineMessage, EngineProviderPresets } from "../types";
|
||||
|
||||
interface EngineTabProps {
|
||||
featureLoading: string | null;
|
||||
engineData: EngineDataState;
|
||||
engineMessage: EngineMessage | null;
|
||||
engineForm: EngineForm;
|
||||
engineEditingId: string | null;
|
||||
engineProviderPresets: EngineProviderPresets;
|
||||
setEngineForm: Dispatch<SetStateAction<EngineForm>>;
|
||||
onTestActiveEngine: () => void;
|
||||
onRefreshEngines: () => void;
|
||||
onUpdateLocalKbQaSetting: (checked: boolean) => void;
|
||||
onActivateEngine: (engineId: string) => void;
|
||||
onTestEngine: (engineId: string) => void;
|
||||
onStartEditEngine: (engine: LLMEngine) => void;
|
||||
onDeleteEngine: (engineId: string) => void;
|
||||
onSaveEngineConfig: () => void;
|
||||
onResetEngineForm: () => void;
|
||||
}
|
||||
|
||||
export default function EngineTab({
|
||||
featureLoading,
|
||||
engineData,
|
||||
engineMessage,
|
||||
engineForm,
|
||||
engineEditingId,
|
||||
engineProviderPresets,
|
||||
setEngineForm,
|
||||
onTestActiveEngine,
|
||||
onRefreshEngines,
|
||||
onUpdateLocalKbQaSetting,
|
||||
onActivateEngine,
|
||||
onTestEngine,
|
||||
onStartEditEngine,
|
||||
onDeleteEngine,
|
||||
onSaveEngineConfig,
|
||||
onResetEngineForm,
|
||||
}: EngineTabProps) {
|
||||
return (
|
||||
<TabsContent value="engine" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-violet-500" />
|
||||
LLM 引擎配置
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">可切换 DeepSeek / GLM / OpenAI,并维护 API 配置</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onTestActiveEngine} disabled={!engineData.active_engine_id || featureLoading === "engine-test-active"}>
|
||||
{featureLoading === "engine-test-active" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
测试中…
|
||||
</>
|
||||
) : (
|
||||
"测试激活引擎"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onRefreshEngines} disabled={featureLoading === "engine-list"}>
|
||||
{featureLoading === "engine-list" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
刷新中…
|
||||
</>
|
||||
) : (
|
||||
"刷新列表"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{engineMessage && (
|
||||
<div className={`p-3 rounded-lg text-sm ${
|
||||
engineMessage.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
|
||||
}`}>
|
||||
{engineMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600" />
|
||||
运行边界
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
当前已关闭所有 LLM 兜底。模型异常、超时、结构化输出不合法时,系统会直接报错,不再自动拼结果。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border border-slate-200 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">本地 LLM(仅知识库问答)</div>
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||
关闭时绝对不可用。开启后也只允许知识库问答链路使用本地 Ollama,其它函数会直接报错。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(engineData.settings?.enable_local_kb_qa)}
|
||||
onCheckedChange={onUpdateLocalKbQaSetting}
|
||||
disabled={featureLoading === "engine-settings"}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">已配置引擎</CardTitle>
|
||||
<CardDescription>当前激活:{engineData.active_engine_id ? "已设置" : "未设置"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{engineData.engines.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无引擎配置</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{engineData.engines.map((engine) => {
|
||||
const active = engine.id === engineData.active_engine_id;
|
||||
return (
|
||||
<div key={engine.id} className={`rounded-lg border p-3 ${active ? "border-violet-300 bg-violet-50/70 dark:border-violet-700 dark:bg-violet-900/20" : "border-slate-200 dark:border-slate-800"}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium">{engine.name}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{active && <Badge>激活中</Badge>}
|
||||
<Badge variant="outline">{PROVIDER_ZH[engine.provider]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 dark:text-slate-400 space-y-1">
|
||||
<div>模型:{engine.model || "-"}</div>
|
||||
<div>基础地址:{engine.base_url || "-"}</div>
|
||||
<div>API 密钥:{engine.api_key_masked || "-"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{!active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onActivateEngine(engine.id)}
|
||||
disabled={featureLoading === `engine-activate-${engine.id}`}
|
||||
>
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTestEngine(engine.id)}
|
||||
disabled={featureLoading === `engine-test-${engine.id}`}
|
||||
>
|
||||
{featureLoading === `engine-test-${engine.id}` ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onStartEditEngine(engine)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteEngine(engine.id)}
|
||||
disabled={featureLoading === `engine-delete-${engine.id}`}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{engineEditingId ? "编辑引擎" : "新增引擎"}</CardTitle>
|
||||
<CardDescription>支持 DeepSeek、GLM、OpenAI、Ollama。编辑时 API Key 留空表示不变。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
value={engineForm.name}
|
||||
onChange={(e) => setEngineForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如:DeepSeek-生产"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>服务商</Label>
|
||||
<select
|
||||
value={engineForm.provider}
|
||||
onChange={(e) =>
|
||||
setEngineForm((prev) => {
|
||||
const provider = e.target.value as LLMEngine["provider"];
|
||||
const preset = engineProviderPresets[provider];
|
||||
return {
|
||||
...prev,
|
||||
provider,
|
||||
model: preset.model,
|
||||
base_url: preset.base_url,
|
||||
};
|
||||
})
|
||||
}
|
||||
className="w-full border rounded-md h-10 px-3 bg-background"
|
||||
>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="glm">GLM</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="ollama">Ollama(本地)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>模型</Label>
|
||||
<Input
|
||||
value={engineForm.model}
|
||||
onChange={(e) => setEngineForm((prev) => ({ ...prev, model: e.target.value }))}
|
||||
placeholder="如:deepseek-chat / GLM-4.7 / MiniMax-M2.5 / llama3:latest"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>基础地址</Label>
|
||||
<Input
|
||||
value={engineForm.base_url}
|
||||
onChange={(e) => setEngineForm((prev) => ({ ...prev, base_url: e.target.value }))}
|
||||
placeholder="如:https://api.edgefn.net/v1"
|
||||
/>
|
||||
<div className="text-xs text-slate-500">当前推荐:GLM 与 MiniMax 都走 `https://api.edgefn.net/v1`</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>API 密钥</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={engineForm.api_key}
|
||||
onChange={(e) => setEngineForm((prev) => ({ ...prev, api_key: e.target.value }))}
|
||||
placeholder={engineEditingId ? "留空则不修改" : "必填"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button onClick={onSaveEngineConfig} disabled={featureLoading === "engine-save"}>
|
||||
{featureLoading === "engine-save" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
保存中…
|
||||
</>
|
||||
) : engineEditingId ? "保存修改" : "新增并启用"}
|
||||
</Button>
|
||||
{engineEditingId && (
|
||||
<Button variant="outline" onClick={onResetEngineForm}>
|
||||
取消编辑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Radar,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
export default function OverviewTab() {
|
||||
return (
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold mb-2">AI 管家正在运行</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">4 大主动服务守护你的项目,无需手动触发</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="p-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<Radar className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">1. 主动抓取信号</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">无需手动录入,AI 自动捕获所有需求来源</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<MessageSquare className="w-4 h-4 text-blue-500" />
|
||||
飞书群聊监控 - 配置完成,正在运行
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<Activity className="w-4 h-4 text-purple-500" />
|
||||
技术资讯定时抓取 - 可启用
|
||||
</li>
|
||||
<li className="flex items-center gap-2 opacity-50">
|
||||
<Clock className="w-4 h-4" />
|
||||
浏览器标签监控 - 需安装扩展
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">2. 主动消解认知差</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">提前发现隐性分歧,避免返工</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
自动检测定义不一致(如"留存" vs "DAU")
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
任务与宪章一致性检查
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
AI 生成对齐方案
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-red-500 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">3. 主动解决阻塞</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">代码注释扫描,自动推荐替代方案</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
识别阻塞模式("等待X接口"、"达不到效果")
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
AI 分析严重程度并生成解决方案
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
自动创建追踪任务
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<Bell className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">4. 主动同步进度</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">个性化简报,彻底取消日报周报</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
按角色智能过滤信息
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
只显示相关任务变更和风险
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
推送行动项和对齐提醒
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { Activity, Eye, Lightbulb, Loader2, MessageSquare, Radar } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
interface SignalsTabProps {
|
||||
featureLoading: string | null;
|
||||
onStartHNMonitor: () => void;
|
||||
onOpenFeishuConfig: () => void;
|
||||
}
|
||||
|
||||
export default function SignalsTab({ featureLoading, onStartHNMonitor, onOpenFeishuConfig }: SignalsTabProps) {
|
||||
return (
|
||||
<TabsContent value="signals" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Radar className="w-5 h-5 text-blue-500" />
|
||||
信号捕获状态
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">AI 自动从多个来源捕获需求信号</p>
|
||||
</div>
|
||||
<Button onClick={onStartHNMonitor} disabled={featureLoading === "hn-monitor"}>
|
||||
{featureLoading === "hn-monitor" ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
启动中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
启动 HN 监控
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="border-blue-200 dark:border-blue-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||
<CardTitle className="text-lg">飞书群聊</CardTitle>
|
||||
<Badge className="ml-auto" variant="default">运行中</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">自动监听飞书群聊,识别有价值的需求讨论</p>
|
||||
<div className="text-xs text-slate-500">
|
||||
配置状态:<span className="text-green-600">已配置</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={onOpenFeishuConfig}>
|
||||
查看配置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-purple-200 dark:border-purple-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-purple-500" />
|
||||
<CardTitle className="text-lg">技术资讯(HN)</CardTitle>
|
||||
<Badge className="ml-auto" variant="secondary">可启用</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">定时抓取 HN 首页,分析技术趋势</p>
|
||||
<div className="text-xs text-slate-500">监控间隔:30 分钟</div>
|
||||
<Button onClick={onStartHNMonitor} variant="outline" size="sm" className="mt-3" disabled={featureLoading === "hn-monitor"}>
|
||||
{featureLoading === "hn-monitor" ? "启动中…" : "启动监控"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 dark:border-slate-800 opacity-60">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-5 h-5 text-slate-500" />
|
||||
<CardTitle className="text-lg">浏览器标签</CardTitle>
|
||||
<Badge className="ml-auto" variant="outline">需扩展</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">监控访问的技术文档、竞品页面</p>
|
||||
<div className="text-xs text-slate-500">需要安装浏览器扩展</div>
|
||||
<Button variant="outline" size="sm" className="mt-3" disabled>
|
||||
敬请期待
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 dark:border-slate-800 opacity-60">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-slate-500" />
|
||||
<CardTitle className="text-lg">代码仓</CardTitle>
|
||||
<Badge className="ml-auto" variant="outline">需配置</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3">监控竞品仓库更新、上游依赖变更</p>
|
||||
<div className="text-xs text-slate-500">需要配置回调地址</div>
|
||||
<Button variant="outline" size="sm" className="mt-3" disabled>
|
||||
敬请期待
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
||||
|
||||
export interface ProjectFromTextResponse {
|
||||
project: { id: string; name: string; title: string; description: string; status: string };
|
||||
signal: { id: string; content: string; impact_score: number };
|
||||
charter: { id: string; title: string; content: string };
|
||||
tasks: Array<{ id: string; title: string; type: string; estimated_hours: number; priority: string }>;
|
||||
risks: Array<{ type: string; description: string; severity: string }>;
|
||||
analysis: { project_name: string; tech_stack: string[]; timeline: string; primary_role: string };
|
||||
}
|
||||
|
||||
export interface CognitiveConflict {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
severity: string;
|
||||
related_users: string[];
|
||||
}
|
||||
|
||||
export interface Blocker {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
severity: string;
|
||||
detected_at: string;
|
||||
}
|
||||
|
||||
export interface DailyBriefing {
|
||||
date: string;
|
||||
summary: string;
|
||||
highlights: Array<{ type: string; priority: string; content: string }>;
|
||||
your_tasks: Array<{ id: number; title: string; status: string; priority: string }>;
|
||||
risks: Array<{ type: string; task: string; severity: string }>;
|
||||
signals: Array<{ id: number; source: string; content: string; impact_score: number }>;
|
||||
conflicts: Array<{ id: string; type: string; description: string }>;
|
||||
action_items: Array<{ type: string; content: string; priority: string }>;
|
||||
team_updates: Array<{ assignee_id: number; task: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface EngineProviderPreset {
|
||||
model: string;
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
export type EngineProviderPresets = Record<LLMEngine["provider"], EngineProviderPreset>;
|
||||
|
||||
export type EngineForm = {
|
||||
name: string;
|
||||
provider: LLMEngine["provider"];
|
||||
api_key: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
};
|
||||
|
||||
export interface EngineMessage {
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type EngineDataState = LLMEngineListResponse;
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Radio, ListTodo, Plus } from "lucide-react";
|
||||
import { sstcApi } from "@/api/sstc";
|
||||
import type { SSTCSignal, SSTCTask } from "@/api/sstc";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toZhPriority, toZhStatus } from "@/lib/display";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import {
|
||||
CHARTER_MODULE_SCHEMAS,
|
||||
calculateCharterProgress,
|
||||
parseCharterModuleDraft,
|
||||
} from "@/components/sstc/charterModules";
|
||||
|
||||
export default function CharterDetailPage() {
|
||||
const { id, charterId } = useParams<{ id: string; charterId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [createTaskOpen, setCreateTaskOpen] = useState(false);
|
||||
const [sourceSignalOpen, setSourceSignalOpen] = useState(false);
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [newTaskDesc, setNewTaskDesc] = useState("");
|
||||
const [autoPrefillTried, setAutoPrefillTried] = useState(false);
|
||||
|
||||
const { data: charter, isLoading, error } = useQuery({
|
||||
queryKey: ["charter", id, charterId],
|
||||
queryFn: () => sstcApi.getCharterDetail(id!, charterId!),
|
||||
enabled: !!id && !!charterId,
|
||||
});
|
||||
|
||||
const charterModuleDraft = useMemo(
|
||||
() =>
|
||||
parseCharterModuleDraft(
|
||||
(charter?.content as Record<string, unknown>) || {}
|
||||
),
|
||||
[charter?.content]
|
||||
);
|
||||
const charterProgress = useMemo(
|
||||
() => calculateCharterProgress(charterModuleDraft),
|
||||
[charterModuleDraft]
|
||||
);
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
sstcApi.createTask(id!, charterId!, {
|
||||
title: newTaskTitle,
|
||||
description: newTaskDesc || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["charter", id, charterId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-chain", id] });
|
||||
setCreateTaskOpen(false);
|
||||
setNewTaskTitle("");
|
||||
setNewTaskDesc("");
|
||||
},
|
||||
});
|
||||
|
||||
const aiPrefillMutation = useMutation({
|
||||
mutationFn: () => sstcApi.aiPrefillCharter(id!, charterId!, {}),
|
||||
onSuccess: () => {
|
||||
setAutoPrefillTried(true);
|
||||
queryClient.invalidateQueries({ queryKey: ["charter", id, charterId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-charter-detail", id, charterId] });
|
||||
},
|
||||
onError: () => {
|
||||
setAutoPrefillTried(true);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!charter || !id || !charterId) return;
|
||||
if (autoPrefillTried || aiPrefillMutation.isPending) return;
|
||||
const hasSignal = (charter.signal_ids?.length || 0) > 0 || !!charter.source_signal;
|
||||
if (!hasSignal) return;
|
||||
if (charterProgress.completedModules >= CHARTER_MODULE_SCHEMAS.length) return;
|
||||
setAutoPrefillTried(true);
|
||||
aiPrefillMutation.mutate();
|
||||
}, [
|
||||
charter,
|
||||
id,
|
||||
charterId,
|
||||
autoPrefillTried,
|
||||
aiPrefillMutation,
|
||||
charterProgress.completedModules,
|
||||
]);
|
||||
|
||||
const handleDownloadSignalAttachment = async (signalId: string, attachmentId: string, fileName?: string) => {
|
||||
try {
|
||||
const blob = await sstcApi.downloadSignalAttachment(id!, signalId, attachmentId);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName || `attachment-${attachmentId}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: getApiErrorMessage(error, "附件下载失败"),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/projects/${id}/board`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{charter?.title || "需求宪章"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
项目 {id} · 宪章 {charterId} · SSTC 链路
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="sm:ml-auto"
|
||||
onClick={() => setCreateTaskOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-destructive">
|
||||
加载失败
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : charter ? (
|
||||
<>
|
||||
{charter.source_signal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceSignalOpen(true)}
|
||||
className="flex w-full items-center justify-between rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-left text-sm transition hover:bg-amber-100"
|
||||
>
|
||||
<span>
|
||||
来源信号 #{charter.source_signal.id}: {charter.source_signal.title || "未命名信号"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">查看详情 ↗</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>宪章内容</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
版本 v{charter.version} · {toZhStatus(charter.status)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{aiPrefillMutation.isPending && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700">
|
||||
AI 正在根据信号源生成 6 模块草稿,请稍候…
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">模块回填进度</p>
|
||||
<Badge className="border">
|
||||
{charterProgress.requiredPercent}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
完成模块 {charterProgress.completedModules}/
|
||||
{CHARTER_MODULE_SCHEMAS.length} · 必填字段{" "}
|
||||
{charterProgress.requiredDone}/{charterProgress.requiredTotal}
|
||||
</p>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary"
|
||||
style={{ width: `${charterProgress.requiredPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
草稿状态:{toZhStatus(charterModuleDraft.metadata.status || "draft")} ·
|
||||
置信度:{" "}
|
||||
{charterModuleDraft.metadata.confidence_score || "-"} ·
|
||||
最近校验:{" "}
|
||||
{formatBeijingDateTime(charterModuleDraft.metadata.last_validated_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{CHARTER_MODULE_SCHEMAS.map((module) => {
|
||||
const moduleState = charterProgress.moduleProgress[module.key];
|
||||
return (
|
||||
<div key={module.key} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{module.shortTitle}</p>
|
||||
<Badge className="border">
|
||||
{moduleState.requiredDone}/{moduleState.requiredTotal}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{module.fields
|
||||
.filter((field) => field.editable !== false)
|
||||
.map((field) => {
|
||||
const value =
|
||||
charterModuleDraft.modules[module.key][field.key] || "";
|
||||
if (!value.trim()) return null;
|
||||
return (
|
||||
<div key={`${module.key}-${field.key}`}>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{field.label}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{moduleState.missing.length > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
缺少必填:{moduleState.missing.join("、")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5" />
|
||||
关联信号源 ({charter.signals?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{charter.signals?.length ? (
|
||||
<div className="space-y-2">
|
||||
{charter.signals.map((s: SSTCSignal) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="rounded-lg border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
#{s.id} · {s.source} · {toZhPriority(s.priority)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{s.content}</p>
|
||||
{(s.attachments || []).length > 0 && (
|
||||
<div className="mt-2 space-y-1 rounded border border-slate-200 bg-white p-2">
|
||||
<p className="text-[11px] text-slate-500">附件 {(s.attachments || []).length} 个</p>
|
||||
{(s.attachments || []).map((attachment) => (
|
||||
<div key={`${s.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]"
|
||||
onClick={() => handleDownloadSignalAttachment(s.id, attachment.attachment_id, attachment.file_name)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">无关联信号</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListTodo className="h-5 w-5" />
|
||||
任务列表 ({charter.tasks?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{charter.tasks?.length ? (
|
||||
<div className="space-y-2">
|
||||
{charter.tasks.map((t: SSTCTask) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center justify-between rounded border px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{t.title}</p>
|
||||
{t.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
t.status === "done" || t.status === "completed"
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{toZhStatus(t.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无任务</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Dialog open={createTaskOpen} onOpenChange={setCreateTaskOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
任务将绑定到当前宪章(SSTC 强制约束)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">任务标题</label>
|
||||
<Input
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
placeholder="例:接入视频生成接口"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">描述(可选)</label>
|
||||
<Input
|
||||
value={newTaskDesc}
|
||||
onChange={(e) => setNewTaskDesc(e.target.value)}
|
||||
placeholder="任务详细说明"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateTaskOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createTaskMutation.mutate()}
|
||||
disabled={
|
||||
!newTaskTitle.trim() || createTaskMutation.isPending
|
||||
}
|
||||
>
|
||||
{createTaskMutation.isPending ? "创建中…" : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={sourceSignalOpen} onOpenChange={setSourceSignalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>来源信号(只读)</DialogTitle>
|
||||
<DialogDescription>用于回溯该宪章的来源上下文</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ID:#{charter?.source_signal?.id || "-"} · 类型:{charter?.source_signal?.signal_type || "-"} · 优先级:{toZhPriority(charter?.source_signal?.priority)}
|
||||
</p>
|
||||
<p className="text-sm font-medium">{charter?.source_signal?.title || "-"}</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{charter?.source_signal?.content || "-"}
|
||||
</p>
|
||||
{(charter?.source_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] text-slate-500">附件 {(charter?.source_signal?.attachments || []).length} 个</p>
|
||||
{(charter?.source_signal?.attachments || []).map((attachment) => (
|
||||
<div key={`source-${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]"
|
||||
onClick={() =>
|
||||
charter?.source_signal &&
|
||||
handleDownloadSignalAttachment(
|
||||
charter.source_signal.id,
|
||||
attachment.attachment_id,
|
||||
attachment.file_name
|
||||
)
|
||||
}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setSourceSignalOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { taskBriefApi } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function ContractSyncBoardPage() {
|
||||
const syncQuery = useQuery({
|
||||
queryKey: ["task-brief-contract-sync"],
|
||||
queryFn: () => taskBriefApi.getContractSyncStatus(),
|
||||
});
|
||||
const data = syncQuery.data;
|
||||
const files = data?.files || [];
|
||||
const errorText = syncQuery.error instanceof Error ? syncQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="契约同步看板"
|
||||
description="持续检查关键契约文档是否与当前功能实现同步。"
|
||||
icon={<ShieldCheck className="h-5 w-5" />}
|
||||
tone="amber"
|
||||
meta={
|
||||
<span>
|
||||
状态:
|
||||
{syncQuery.isLoading ? "检查中" : syncQuery.isError ? "检查失败" : data?.all_synced ? "已同步" : "存在缺失"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="contract-sync-board"
|
||||
title="契约同步看板"
|
||||
purpose="检查关键架构契约文档是否存在,避免跨端协作时口径不一致。"
|
||||
whenToUse="做接口改动、跨端联调、发版前回归时都应该看一眼。"
|
||||
steps={[
|
||||
"先看顶部状态是“已同步”还是“存在缺失”。",
|
||||
"若缺失,按文件清单补齐对应文档。",
|
||||
"补齐后点击重试,确认状态恢复为已同步。",
|
||||
]}
|
||||
outputs={[
|
||||
"契约文件缺失清单",
|
||||
"发版前的文档一致性检查结果",
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">契约文件明细</CardTitle>
|
||||
<CardDescription>缺失项会直接影响跨端协作与回归可靠性。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{syncQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : syncQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">同步状态读取失败:{errorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void syncQuery.refetch()}
|
||||
disabled={syncQuery.isFetching}
|
||||
>
|
||||
{syncQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无契约文件记录。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文件</TableHead>
|
||||
<TableHead>存在性</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((item) => (
|
||||
<TableRow key={item.file} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{item.file}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={
|
||||
item.exists
|
||||
? "inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-200"
|
||||
: "inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-900/35 dark:text-rose-200"
|
||||
}
|
||||
>
|
||||
{item.exists ? "已存在" : "缺失"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface WorkloadItem {
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
username: string;
|
||||
estimated_hours: number;
|
||||
total_tasks: number;
|
||||
workload_rate?: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", "workload", "team"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ items?: WorkloadItem[] } | WorkloadItem[]>("/skills/workload/team");
|
||||
const raw = res.data;
|
||||
return Array.isArray(raw) ? raw : (raw?.items ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
const chartData = items.map((d) => ({
|
||||
name: d.user_name || d.username,
|
||||
工时: d.estimated_hours ?? 0,
|
||||
任务数: d.total_tasks ?? 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">团队负载看板</h1>
|
||||
<p className="text-muted-foreground">查看团队成员工作负载分布</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>工时分布</CardTitle>
|
||||
<CardDescription>各成员预估工时(小时)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center text-destructive">
|
||||
加载失败
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<XAxis type="number" />
|
||||
<YAxis type="category" dataKey="name" width={80} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="工时" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>负载明细</CardTitle>
|
||||
<CardDescription>按成员统计任务数与工时</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="text-destructive">加载失败</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground">暂无数据</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[520px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead className="text-right">任务数</TableHead>
|
||||
<TableHead className="text-right">工时 (h)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.user_id}>
|
||||
<TableCell>{row.user_name || row.username}</TableCell>
|
||||
<TableCell className="text-right">{row.total_tasks}</TableCell>
|
||||
<TableCell className="text-right">{(row.estimated_hours ?? 0).toFixed(1)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Settings, CheckCircle2, AlertCircle, Loader2, Key } from "lucide-react";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
FEISHU_EVENT_MODE_OPTIONS,
|
||||
type FeishuEventMode,
|
||||
getFeishuEventModeLabel,
|
||||
} from "@/lib/feishuEventMode";
|
||||
import SetupGuideCard from "./components/SetupGuideCard";
|
||||
import {
|
||||
DEFAULT_EVENT_MODE,
|
||||
EMPTY_FEISHU_CONFIG,
|
||||
type FeishuConfigMessage,
|
||||
type FeishuConfigStatus,
|
||||
} from "./helpers";
|
||||
|
||||
export default function FeishuConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const [config, setConfig] = useState({ ...EMPTY_FEISHU_CONFIG });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [status, setStatus] = useState<FeishuConfigStatus | null>(null);
|
||||
const [message, setMessage] = useState<FeishuConfigMessage | null>(null);
|
||||
// 加载当前配置状态
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const mode: FeishuEventMode = DEFAULT_EVENT_MODE;
|
||||
// API returns is_configured, frontend expects configured
|
||||
setStatus({
|
||||
configured: data.is_configured || false,
|
||||
app_id: data.app_id,
|
||||
event_mode: mode,
|
||||
});
|
||||
setConfig({
|
||||
app_id: data.app_id || "",
|
||||
app_secret: data.app_secret || "",
|
||||
event_mode: mode,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load status:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
if (!config.app_id || !config.app_secret) {
|
||||
setMessage({ type: "error", text: "请填写应用编号和应用密钥" });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await response.json();
|
||||
setMessage({ type: "success", text: "飞书配置已保存" });
|
||||
await loadStatus();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: "error", text: error.detail || "保存失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "网络错误,请重试" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 测试连接
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/test-connection`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === "success") {
|
||||
setMessage({ type: "success", text: "飞书连接测试成功!" });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.message || "连接测试失败" });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "测试失败,请检查配置" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除配置
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("确定要删除飞书配置吗?")) return;
|
||||
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: "success", text: "配置已删除" });
|
||||
setStatus(null);
|
||||
setConfig({ ...EMPTY_FEISHU_CONFIG });
|
||||
await loadStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: "删除失败" });
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载状态
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Settings className="w-10 h-10 text-violet-500" />
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
飞书集成配置
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
配置飞书应用后,AI 可以自动从群聊中捕获需求信号
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>配置状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{status.configured ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{status.configured ? "已配置" : "未配置"}
|
||||
</p>
|
||||
{status.app_id && (
|
||||
<p className="text-sm text-slate-500">应用编号: {status.app_id}</p>
|
||||
)}
|
||||
{status.event_mode && (
|
||||
<p className="text-sm text-slate-500">
|
||||
接收模式: {getFeishuEventModeLabel(status.event_mode)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">加载中…</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configuration Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>飞书应用配置</CardTitle>
|
||||
<CardDescription>
|
||||
从飞书开放平台获取应用凭证
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">事件接收方式</Label>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
飞书接入已统一为 <code>long_connection</code>
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{FEISHU_EVENT_MODE_OPTIONS.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 rounded border px-3 py-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="feishu_event_mode"
|
||||
value={option.value}
|
||||
checked={config.event_mode === option.value}
|
||||
onChange={() => setConfig({ ...config, event_mode: option.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
<code className="text-[11px] text-slate-500">({option.value})</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="app_id">应用编号</Label>
|
||||
<Input
|
||||
id="app_id"
|
||||
placeholder="cli_xxxxxxxxxxxxx"
|
||||
value={config.app_id}
|
||||
onChange={(e) => setConfig({ ...config, app_id: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="app_secret">应用密钥</Label>
|
||||
<Input
|
||||
id="app_secret"
|
||||
type="password"
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
value={config.app_secret}
|
||||
onChange={(e) => setConfig({ ...config, app_secret: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>运行说明</Label>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
已切换到飞书长连接模式。无需配置 Webhook、Encrypt Key 或 Verification Token。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`p-3 rounded-lg flex items-center gap-2 ${
|
||||
message.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
|
||||
}`}>
|
||||
{message.type === "success" ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
)}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-violet-600 hover:bg-violet-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
{status?.configured ? "更新配置" : "保存配置"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !status?.configured}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
测试中…
|
||||
</>
|
||||
) : (
|
||||
"测试连接"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{status?.configured && (
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="destructive"
|
||||
disabled={loading}
|
||||
>
|
||||
删除配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SetupGuideCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export default function SetupGuideCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>配置指南</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="step1">
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="step1" className="shrink-0 whitespace-nowrap">
|
||||
1. 创建应用
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="step2" className="shrink-0 whitespace-nowrap">
|
||||
2. 获取凭证
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="step3" className="shrink-0 whitespace-nowrap">
|
||||
3. 配置事件
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="step4" className="shrink-0 whitespace-nowrap">
|
||||
4. 回调地址
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="step5" className="shrink-0 whitespace-nowrap">
|
||||
5. 完成
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="step1">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">访问飞书开放平台创建企业自建应用:</p>
|
||||
<a
|
||||
href="https://open.feishu.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-600 hover:underline text-sm"
|
||||
>
|
||||
https://open.feishu.cn/
|
||||
</a>
|
||||
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-400 space-y-2">
|
||||
<li>进入「开放平台」→「创建自建应用」</li>
|
||||
<li>选择「企业自建应用」类型</li>
|
||||
<li>填写应用名称(如:AI 项目助手)</li>
|
||||
<li>选择应用图标</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step2">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">在应用的「凭证与基础信息」页面获取:</p>
|
||||
<ul className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge>应用编号</Badge>
|
||||
<span className="text-sm">复制应用编号到上方配置表单</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge>应用密钥</Badge>
|
||||
<span className="text-sm">点击「查看」按钮,复制到上方配置表单</span>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step3">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
飞书已统一切换到长连接模式,这一步只需要确认权限即可:
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge>长连接</Badge>
|
||||
<span className="text-sm">无需再配置回调地址、Encrypt Key、Verification Token</span>
|
||||
</div>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">⚠️ 必须先保存配置,才能进行下一步</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">当前为「长连接」模式,无需配置任何回调地址。</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm font-medium">添加权限:</p>
|
||||
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-400 space-y-1">
|
||||
<li>im:message - 发送消息</li>
|
||||
<li>im:message:group_at_msg - 群@消息</li>
|
||||
<li>im:chat - 聊天信息</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="step5">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">完成配置后:</p>
|
||||
<ul className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">AI 会自动监听飞书群聊消息</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">识别有价值的讨论并创建信号</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">自动推送到相关项目</span>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 p-3 bg-violet-50 dark:bg-violet-900/20 rounded-lg">
|
||||
<p className="text-sm text-violet-800 dark:text-violet-200">
|
||||
💡 提示:配置完成后,后端会自动建立长连接,在飞书群中@机器人即可测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { FeishuEventMode } from "@/lib/feishuEventMode";
|
||||
|
||||
export interface FeishuConfigForm {
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
event_mode: FeishuEventMode;
|
||||
}
|
||||
|
||||
export interface FeishuConfigStatus {
|
||||
configured: boolean;
|
||||
app_id?: string;
|
||||
event_mode?: FeishuEventMode;
|
||||
}
|
||||
|
||||
export interface FeishuConfigMessage {
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_EVENT_MODE: FeishuEventMode = "long_connection";
|
||||
|
||||
export const EMPTY_FEISHU_CONFIG: FeishuConfigForm = {
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
event_mode: DEFAULT_EVENT_MODE,
|
||||
};
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { notificationsApi } from "@/api/notifications";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import InboxActionPanel, { type InboxProjectOption } from "@/components/inbox/InboxActionPanel";
|
||||
import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { buildInboxView } from "@/lib/inbox";
|
||||
import type { InboxActionDescriptor, InboxItemViewModel } from "@/types/inbox";
|
||||
import InboxFiltersPanel from "./components/InboxFiltersPanel";
|
||||
import {
|
||||
POLL_INTERVAL_MS,
|
||||
matchesTab,
|
||||
normalizeTabKey,
|
||||
parsePositiveInt,
|
||||
sortByLatest,
|
||||
isMissingProjectScopeItem,
|
||||
type InboxTabKey,
|
||||
} from "./helpers";
|
||||
|
||||
export default function InboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||
const [selectedProjectByItem, setSelectedProjectByItem] = useState<Record<string, string>>({});
|
||||
const [autoExpandedByQuery, setAutoExpandedByQuery] = useState(false);
|
||||
|
||||
const activeTab = normalizeTabKey(searchParams.get("section"));
|
||||
const projectFilter = searchParams.get("project_id") || "all";
|
||||
const searchValue = searchParams.get("q") || "";
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ["notifications", "inbox"],
|
||||
queryFn: () => notificationsApi.list({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const workItemsQuery = useQuery({
|
||||
queryKey: ["ai-pmo-work-items", "inbox"],
|
||||
queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const activeProjectsQuery = useQuery({
|
||||
queryKey: ["projects", "active", "inbox-inline-actions"],
|
||||
queryFn: () => projectsApi.list({ status: "active" }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const inboxData = useMemo(() => {
|
||||
return buildInboxView({
|
||||
notifications: notificationsQuery.data || { items: [], unread_count: 0 },
|
||||
workItems: workItemsQuery.data || { items: [] },
|
||||
});
|
||||
}, [notificationsQuery.data, workItemsQuery.data]);
|
||||
|
||||
const projectOptions = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of inboxData.items) {
|
||||
if (!item.project_id) continue;
|
||||
map.set(String(item.project_id), item.project_name || `项目 #${item.project_id}`);
|
||||
}
|
||||
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
|
||||
}, [inboxData.items]);
|
||||
|
||||
const activeProjectOptions = useMemo<InboxProjectOption[]>(() => {
|
||||
const projects = activeProjectsQuery.data || [];
|
||||
return projects.map((project) => ({
|
||||
value: String(project.id),
|
||||
label: String(project.title || `项目 #${project.id}`),
|
||||
}));
|
||||
}, [activeProjectsQuery.data]);
|
||||
|
||||
const projectScopedItems = useMemo(() => {
|
||||
return inboxData.items.filter((item) => {
|
||||
if (projectFilter === "all") return true;
|
||||
return String(item.project_id || "") === projectFilter;
|
||||
});
|
||||
}, [inboxData.items, projectFilter]);
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
all: projectScopedItems.length,
|
||||
needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
|
||||
in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
|
||||
done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length,
|
||||
} satisfies Record<InboxTabKey, number>;
|
||||
}, [projectScopedItems]);
|
||||
|
||||
const searchedItems = useMemo(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
if (!query) return projectScopedItems;
|
||||
|
||||
return projectScopedItems.filter((item) => {
|
||||
return `${item.title} ${item.summary} ${item.project_name || ""} ${item.stage_text || ""}`
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
}, [projectScopedItems, searchValue]);
|
||||
|
||||
const feedItems = useMemo(() => {
|
||||
const tabItems = searchedItems.filter((item) => matchesTab(item, activeTab));
|
||||
return sortByLatest(tabItems);
|
||||
}, [searchedItems, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoExpandedByQuery) return;
|
||||
const targetId = parsePositiveInt(searchParams.get("workItemId"));
|
||||
if (!targetId) return;
|
||||
const targetKey = `work-item-${targetId}`;
|
||||
const matched = feedItems.find((item) => item.id === targetKey);
|
||||
if (matched) {
|
||||
setExpandedItemId(targetKey);
|
||||
setAutoExpandedByQuery(true);
|
||||
}
|
||||
}, [autoExpandedByQuery, feedItems, searchParams]);
|
||||
|
||||
const setTab = (tab: InboxTabKey) => {
|
||||
const normalized = normalizeTabKey(tab);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", normalized);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const setProject = (projectId: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (projectId === "all") next.delete("project_id");
|
||||
else next.set("project_id", projectId);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (!value.trim()) next.delete("q");
|
||||
else next.set("q", value.trim());
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const toggleExpand = (itemId: string) => {
|
||||
setExpandedItemId((current) => (current === itemId ? null : itemId));
|
||||
};
|
||||
|
||||
const getSelectedProjectId = (item: InboxItemViewModel): number | null => {
|
||||
const selected = selectedProjectByItem[item.id];
|
||||
if (selected) return parsePositiveInt(selected);
|
||||
if (item.project_id) return parsePositiveInt(item.project_id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSelectedProjectValue = (item: InboxItemViewModel): string => {
|
||||
const selected = selectedProjectByItem[item.id];
|
||||
if (selected) return selected;
|
||||
return item.project_id ? String(item.project_id) : "";
|
||||
};
|
||||
|
||||
const setSelectedProjectValue = (item: InboxItemViewModel, value: string) => {
|
||||
setSelectedProjectByItem((current) => ({ ...current, [item.id]: value }));
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-pmo-work-items", "inbox"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const markNotificationRead = async (item: InboxItemViewModel) => {
|
||||
if (item.source_kind === "notification" && item.source_notification && !item.source_notification.is_read) {
|
||||
try {
|
||||
await notificationsApi.markRead(Number(item.source_notification.id));
|
||||
} catch {
|
||||
// 不阻断后续操作。
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openTarget = async (item: InboxItemViewModel) => {
|
||||
await markNotificationRead(item);
|
||||
|
||||
if (item.target_url) {
|
||||
if (item.target_url.startsWith("/")) navigate(item.target_url);
|
||||
else window.location.assign(item.target_url);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ title: "当前消息暂无可打开的目标入口", variant: "default" });
|
||||
};
|
||||
|
||||
const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => {
|
||||
const actionKey = buildInboxActionKey(item, action);
|
||||
setPendingActionKey(actionKey);
|
||||
|
||||
try {
|
||||
if (action.kind === "open_target") {
|
||||
await openTarget(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "open_detail") {
|
||||
await markNotificationRead(item);
|
||||
toggleExpand(item.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const missingProjectScope = isMissingProjectScopeItem(item);
|
||||
const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null);
|
||||
if (missingProjectScope && !selectedProjectId) {
|
||||
setExpandedItemId(item.id);
|
||||
toast({ title: "请先选择关联项目后再确认", variant: "default" });
|
||||
return;
|
||||
}
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "approve",
|
||||
project_id: selectedProjectId ?? undefined,
|
||||
});
|
||||
await refreshAll();
|
||||
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
if (result.status === "succeeded") {
|
||||
toast({ title: followupReply || "已继续执行并完成", variant: "success" });
|
||||
} else if (result.status === "pending_manual") {
|
||||
toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" });
|
||||
} else {
|
||||
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
|
||||
}
|
||||
setSelectedProjectByItem((current) => {
|
||||
if (!(item.id in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[item.id];
|
||||
return next;
|
||||
});
|
||||
setExpandedItemId(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "reject",
|
||||
reason: "人工审批拒绝,已标记失效",
|
||||
});
|
||||
await refreshAll();
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
|
||||
await notificationsApi.markRead(item.source_notification.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "none") {
|
||||
toggleExpand(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error), variant: "error" });
|
||||
} finally {
|
||||
setPendingActionKey((current) => (current === actionKey ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await notificationsApi.markAllRead();
|
||||
await refreshAll();
|
||||
toast({ title: "站内通知已全部设为已读", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
|
||||
const activeProjectErrorText = activeProjectsQuery.isError
|
||||
? getApiErrorMessage(activeProjectsQuery.error, "项目列表加载失败")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-12">
|
||||
<InboxFiltersPanel
|
||||
feedCount={feedItems.length}
|
||||
activeTab={activeTab}
|
||||
projectFilter={projectFilter}
|
||||
searchValue={searchValue}
|
||||
projectOptions={projectOptions}
|
||||
tabCounts={tabCounts}
|
||||
disableMarkAllRead={notificationsQuery.data?.unread_count === 0}
|
||||
onMarkAllRead={() => void markAllRead()}
|
||||
onSetSearch={setSearch}
|
||||
onSetProject={setProject}
|
||||
onSetTab={setTab}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
加载消息台中…
|
||||
</div>
|
||||
</section>
|
||||
) : feedItems.length === 0 ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="rounded-xl bg-white px-4 py-10 text-center text-slate-500">
|
||||
<BellRing className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="font-medium text-slate-700">当前没有匹配收件项</p>
|
||||
<p className="mt-1 text-sm">请调整筛选条件后重试</p>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="space-y-2 rounded-2xl bg-[#F8F9FA] p-2 md:p-3">
|
||||
{feedItems.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedItemId === item.id}
|
||||
pendingActionKey={pendingActionKey}
|
||||
inlinePanel={
|
||||
expandedItemId === item.id && isMissingProjectScopeItem(item) ? (
|
||||
<InboxActionPanel
|
||||
selectedProjectId={getSelectedProjectValue(item)}
|
||||
projectOptions={activeProjectOptions}
|
||||
loadingProjects={activeProjectsQuery.isLoading}
|
||||
projectsErrorText={activeProjectErrorText}
|
||||
pending={pendingActionKey === buildInboxActionKey(item, item.primary_action)}
|
||||
onSelectProject={(value) => setSelectedProjectValue(item, value)}
|
||||
onCancel={() => setExpandedItemId(null)}
|
||||
onConfirm={() => void handleAction(item, item.primary_action)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onToggleExpand={toggleExpand}
|
||||
onAction={(target, action) => void handleAction(target, action)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import type { InboxTabKey } from "../helpers";
|
||||
import { TAB_LABELS } from "../helpers";
|
||||
import type { InboxProjectOption } from "@/components/inbox/InboxActionPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InboxFiltersPanelProps {
|
||||
feedCount: number;
|
||||
activeTab: InboxTabKey;
|
||||
projectFilter: string;
|
||||
searchValue: string;
|
||||
projectOptions: InboxProjectOption[];
|
||||
tabCounts: Record<InboxTabKey, number>;
|
||||
disableMarkAllRead: boolean;
|
||||
onMarkAllRead: () => void;
|
||||
onSetSearch: (value: string) => void;
|
||||
onSetProject: (projectId: string) => void;
|
||||
onSetTab: (tab: InboxTabKey) => void;
|
||||
}
|
||||
|
||||
export default function InboxFiltersPanel({
|
||||
feedCount,
|
||||
activeTab,
|
||||
projectFilter,
|
||||
searchValue,
|
||||
projectOptions,
|
||||
tabCounts,
|
||||
disableMarkAllRead,
|
||||
onMarkAllRead,
|
||||
onSetSearch,
|
||||
onSetProject,
|
||||
onSetTab,
|
||||
}: InboxFiltersPanelProps) {
|
||||
return (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-3 md:p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 md:text-2xl">消息台</h1>
|
||||
<p className="mt-1 text-xs text-slate-500 md:text-sm">列表 Feed · 最新在上 · 支持移动端</p>
|
||||
<p className="mt-1 text-xs text-slate-500">当前结果:{feedCount} 条</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={disableMarkAllRead}
|
||||
>
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Input
|
||||
placeholder="搜索标题、副标题、项目"
|
||||
defaultValue={searchValue}
|
||||
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
|
||||
onBlur={(event) => onSetSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onSetSearch((event.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(event) => onSetProject(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
{projectOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
type="button"
|
||||
onClick={() => onSetTab(tabKey)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
|
||||
activeTab === tabKey
|
||||
? "border-slate-300 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white",
|
||||
)}
|
||||
>
|
||||
<span>{TAB_LABELS[tabKey]}</span>
|
||||
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { parseBackendDate } from "@/lib/datetime";
|
||||
import type { InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
|
||||
|
||||
export const POLL_INTERVAL_MS = 15000;
|
||||
|
||||
export type InboxTabKey = "all" | "needs_action" | "in_progress" | "done";
|
||||
|
||||
export const TAB_LABELS: Record<InboxTabKey, string> = {
|
||||
all: "全部",
|
||||
needs_action: "待处理",
|
||||
in_progress: "进行中",
|
||||
done: "已完成",
|
||||
};
|
||||
|
||||
export const TAB_TO_DISPLAY_KIND: Partial<Record<InboxTabKey, InboxDisplayKind>> = {
|
||||
needs_action: "action_required",
|
||||
in_progress: "auto_running",
|
||||
done: "auto_done",
|
||||
};
|
||||
|
||||
export function normalizeTabKey(raw?: string | null): InboxTabKey {
|
||||
const value = String(raw || "").trim();
|
||||
if (value === "action_required" || value === "needs_action") return "needs_action";
|
||||
if (value === "auto_running" || value === "in_progress") return "in_progress";
|
||||
if (value === "auto_done" || value === "done") return "done";
|
||||
return "all";
|
||||
}
|
||||
|
||||
export function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] {
|
||||
const toTimestamp = (item: InboxItemViewModel): number => {
|
||||
const date = parseBackendDate(item.updated_at || item.created_at);
|
||||
return date ? date.getTime() : -1;
|
||||
};
|
||||
|
||||
return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a));
|
||||
}
|
||||
|
||||
export function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean {
|
||||
if (tab === "all") return true;
|
||||
return item.display_kind === TAB_TO_DISPLAY_KIND[tab];
|
||||
}
|
||||
|
||||
export function parsePositiveInt(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
const integer = Math.trunc(parsed);
|
||||
return integer > 0 ? integer : null;
|
||||
}
|
||||
|
||||
export function isMissingProjectScopeItem(item: InboxItemViewModel): boolean {
|
||||
if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return false;
|
||||
const state = String(item.source_work_item.state || "").toLowerCase();
|
||||
const gateCode = String(item.latest_policy_gate_code || item.source_work_item.latest_policy_gate_code || "").toLowerCase();
|
||||
return state === "waiting_manual" && gateCode === "missing_project_scope";
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Boxes, Database, GitBranch, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
knowledgeGraphApi,
|
||||
type ContextConsumptionVerification,
|
||||
type KnowledgeGraphCapabilitySummary,
|
||||
} from "@/api/knowledgeGraph";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
function statusBadge(pass: boolean, okText: string, failText: string) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
pass
|
||||
? "inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/35 dark:text-emerald-200"
|
||||
: "inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-900/35 dark:text-rose-200"
|
||||
}
|
||||
>
|
||||
{pass ? okText : failText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KnowledgeGraphContextBoardPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canOperate = isManagerRole(user?.role);
|
||||
|
||||
const summaryQuery = useQuery<KnowledgeGraphCapabilitySummary>({
|
||||
queryKey: ["knowledge-graph-capability-summary"],
|
||||
queryFn: () => knowledgeGraphApi.getCapabilitySummary(),
|
||||
});
|
||||
|
||||
const verifyMutation = useMutation<ContextConsumptionVerification>({
|
||||
mutationFn: () => knowledgeGraphApi.verifyContextConsumption({ run_canary: true }),
|
||||
});
|
||||
|
||||
const summary = summaryQuery.data;
|
||||
const verification = verifyMutation.data;
|
||||
const loading = summaryQuery.isLoading;
|
||||
const errorText = summaryQuery.error instanceof Error ? summaryQuery.error.message : "请检查后端服务。";
|
||||
|
||||
const matrixRows = useMemo(
|
||||
() => summary?.function_model_template_matrix || [],
|
||||
[summary]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="知识图谱与模型上下文看板"
|
||||
description="统一展示 KG/任务模板/契约同步与双模型上下文消费验证状态。"
|
||||
icon={<Boxes className="h-5 w-5" />}
|
||||
tone="blue"
|
||||
meta={
|
||||
<span>
|
||||
状态:
|
||||
{loading
|
||||
? "加载中"
|
||||
: summaryQuery.isError
|
||||
? "读取失败"
|
||||
: summary?.coverage.template_ready && summary?.coverage.contract_ready
|
||||
? "基础就绪"
|
||||
: "待修复"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="knowledge-graph-board"
|
||||
title="知识图谱与模型上下文看板"
|
||||
purpose="确认 low/high 两个模型是否真正吃到 KG、模板和契约三类上下文。"
|
||||
whenToUse="你要判断“模型为什么表现异常、上下文是否生效”时优先看这里。"
|
||||
steps={[
|
||||
"先看 KG 状态、模板覆盖、契约同步三个基础区块是否就绪。",
|
||||
"点击“运行双模型验证”,触发 canary 与日志证据回查。",
|
||||
"查看函数矩阵和验证结果,定位是路由问题还是上下文注入问题。",
|
||||
]}
|
||||
outputs={[
|
||||
"双模型上下文消费通过/失败结论",
|
||||
"按函数可追溯的证据与路由快照",
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void summaryQuery.refetch()}
|
||||
disabled={summaryQuery.isFetching}
|
||||
>
|
||||
<RefreshCw className={`mr-1 h-4 w-4 ${summaryQuery.isFetching ? "animate-spin" : ""}`} />
|
||||
{summaryQuery.isFetching ? "刷新中..." : "刷新汇总"}
|
||||
</Button>
|
||||
{canOperate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => verifyMutation.mutate()}
|
||||
disabled={verifyMutation.isPending}
|
||||
>
|
||||
<ShieldCheck className={`mr-1 h-4 w-4 ${verifyMutation.isPending ? "animate-pulse" : ""}`} />
|
||||
{verifyMutation.isPending ? "验证中..." : "运行双模型验证"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">仅管理员可执行验证操作。</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summaryQuery.isError ? (
|
||||
<Card className="border-rose-200/70 bg-rose-50/80 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<CardContent className="py-4 text-sm text-rose-700 dark:text-rose-200">
|
||||
能力汇总读取失败:{errorText}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Database className="h-4 w-4" />
|
||||
KG 状态
|
||||
</CardTitle>
|
||||
<CardDescription>实体、关系、证据规模与最新同步信息。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<p>实体总数:{summary?.knowledge_graph.entity_total ?? "-"}</p>
|
||||
<p>关系总数:{summary?.knowledge_graph.relation_total ?? "-"}</p>
|
||||
<p>证据总数:{summary?.knowledge_graph.evidence_total ?? "-"}</p>
|
||||
<p>待处理事件:{summary?.knowledge_graph.pending_events ?? "-"}</p>
|
||||
<p>
|
||||
最新同步:
|
||||
{summary?.knowledge_graph.latest_sync_run
|
||||
? `${summary.knowledge_graph.latest_sync_run.run_type || "-"} / ${summary.knowledge_graph.latest_sync_run.status || "-"}`
|
||||
: "-"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">任务模板覆盖</CardTitle>
|
||||
<CardDescription>双模型链路要求模板的覆盖与缺口。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge(
|
||||
!!summary?.coverage.template_ready,
|
||||
"模板齐备",
|
||||
"存在缺失"
|
||||
)}
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
总模板 {summary?.task_templates.total ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<p>必需模板:{(summary?.task_templates.required_template_ids || []).join(", ") || "-"}</p>
|
||||
<p>缺失模板:{(summary?.task_templates.missing_template_ids || []).join(", ") || "无"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">契约同步状态</CardTitle>
|
||||
<CardDescription>只注入状态与缺失清单,不注入全文。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>{statusBadge(!!summary?.contract_sync.all_synced, "契约已同步", "契约未同步")}</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文件</TableHead>
|
||||
<TableHead>存在性</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(summary?.contract_sync.files || []).map((item) => (
|
||||
<TableRow key={item.file} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{item.file}</TableCell>
|
||||
<TableCell>{item.exists ? "已存在" : "缺失"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
函数-模型-模板矩阵
|
||||
</CardTitle>
|
||||
<CardDescription>固定四条函数链路的路由与模板绑定关系。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>函数</TableHead>
|
||||
<TableHead>层级</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{matrixRows.map((row) => (
|
||||
<TableRow key={row.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{row.function_name}</TableCell>
|
||||
<TableCell>{row.tier}</TableCell>
|
||||
<TableCell>{`${row.model_routing.provider}/${row.model_routing.model}`}</TableCell>
|
||||
<TableCell>{row.template_ids.join(", ") || "-"}</TableCell>
|
||||
<TableCell>{row.model_routing.source || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{matrixRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-slate-500 dark:text-slate-400">
|
||||
暂无矩阵数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">双模型上下文消费验证</CardTitle>
|
||||
<CardDescription>通过 canary + ai_call_logs 标记校验模型是否吃到三类上下文。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{statusBadge(!!verification?.overall_pass, "验证通过", "未通过/未执行")}
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
probe_id: {verification?.probe_id || "-"}
|
||||
</span>
|
||||
</div>
|
||||
{verifyMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
验证失败:
|
||||
{verifyMutation.error instanceof Error ? verifyMutation.error.message : "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>函数</TableHead>
|
||||
<TableHead>层级</TableHead>
|
||||
<TableHead>检查结果</TableHead>
|
||||
<TableHead>canary 状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(verification?.functions || []).map((item) => (
|
||||
<TableRow key={item.function_name} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{item.function_name}</TableCell>
|
||||
<TableCell>{item.tier}</TableCell>
|
||||
<TableCell>
|
||||
{item.pass
|
||||
? "marker/kg/template/contract 全部命中"
|
||||
: `缺失: ${Object.entries(item.checks)
|
||||
.filter(([, ok]) => !ok)
|
||||
.map(([key]) => key)
|
||||
.join(", ")}`}
|
||||
</TableCell>
|
||||
<TableCell>{item.canary.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(verification?.functions || []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-slate-500 dark:text-slate-400">
|
||||
还未执行验证
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import { clearSessionExpired } from "@/lib/sessionGuard";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirect = searchParams.get("redirect") || "/dashboard";
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const isAuthenticated = useAuthStore((s) => s.token != null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate(redirect, { replace: true });
|
||||
}, [isAuthenticated, navigate, redirect]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
// 允许在登录页重新发起鉴权,避免被过期标记拦截
|
||||
clearSessionExpired();
|
||||
const body = new URLSearchParams({ username, password }).toString();
|
||||
const { data } = await api.post<{ access_token: string; token_type: string; user?: Record<string, unknown> }>(
|
||||
"/auth/login",
|
||||
body,
|
||||
{ headers: { "Content-Type": "application/x-www-form-urlencoded" } }
|
||||
);
|
||||
const token = data.access_token;
|
||||
const user = data.user as { id: string; username: string; email?: string; name?: string; role?: string } | undefined;
|
||||
setAuth(token, user || { id: "", username });
|
||||
navigate(redirect, { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const ax = err as {
|
||||
code?: string;
|
||||
message?: string;
|
||||
response?: { status?: number; data?: { detail?: string } };
|
||||
};
|
||||
// 网络/隧道不可达(ERR_CONNECTION_CLOSED、ERR_NETWORK 等)
|
||||
const isNetworkError =
|
||||
!ax.response &&
|
||||
(ax.code === "ERR_CONNECTION_CLOSED" ||
|
||||
ax.code === "ERR_NETWORK" ||
|
||||
/network|failed to fetch|connection/i.test(ax.message ?? ""));
|
||||
if (isNetworkError) {
|
||||
setError(
|
||||
"无法连接服务器。若使用公网穿透,请确认隧道已启动且后端运行中;或改用本地:在 frontend/.env.development 中设置 VITE_API_URL=http://localhost:8000 后重启前端。"
|
||||
);
|
||||
} else {
|
||||
setError(ax.response?.data?.detail ?? "登录失败,请检查用户名和密码(默认:boss / boss123)");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4 sm:p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<CardTitle className="text-2xl font-bold sm:text-3xl">PMFlow</CardTitle>
|
||||
<CardDescription>登录以继续</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">
|
||||
用户名
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
密码
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||
演示账号:boss / boss123
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings2 } from "lucide-react";
|
||||
|
||||
import { modelRoutingApi, type ModelRoutingItem } from "@/api/modelRouting";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
const PROVIDER_OPTIONS = ["openai", "glm", "deepseek", "ollama"] as const;
|
||||
const TIER_OPTIONS = ["high", "low", "safety"] as const;
|
||||
const FUNCTION_USAGE_HINTS: Record<string, string> = {
|
||||
adaptive_rag_answer_deep: "复杂问题的深度回答阶段:会做更细的推理和更完整的结论组织。",
|
||||
adaptive_rag_answer_normal: "常规问答阶段:基于已检索到的信息快速生成可读回复。",
|
||||
feishu_humanized_reply: "对外回复润色阶段:把结果改写成飞书里更自然、易读的表达。",
|
||||
rag_db_project_semantic_retrieval: "知识检索阶段:从项目库里找最相关的历史上下文与证据。",
|
||||
rag_intent_classification: "意图识别阶段:先判断这是哪类问题,再决定后续处理路径。",
|
||||
signal_planner_analyze: "任务规划阶段:把需求拆成可执行步骤、产物和责任边界。",
|
||||
classify_intent_ai_pmo_kernel: "轻量意图分流:快速判断是否进入 PMO 内核处理链路。",
|
||||
classify_intent_complex: "复杂意图判别:识别高复杂度请求,决定是否升级到 high 模型。",
|
||||
};
|
||||
|
||||
function getFunctionUsageHint(functionName: string): string {
|
||||
return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的模型选择。";
|
||||
}
|
||||
|
||||
export default function ModelRoutingConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedFunction, setSelectedFunction] = useState("signal_planner_analyze");
|
||||
const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>("openai");
|
||||
const [model, setModel] = useState("GLM-5");
|
||||
const [apiEnv, setApiEnv] = useState("OPENAI_API_KEY");
|
||||
const [tier, setTier] = useState<(typeof TIER_OPTIONS)[number]>("high");
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
|
||||
const routingQuery = useQuery({
|
||||
queryKey: ["model-routing-functions"],
|
||||
queryFn: () => modelRoutingApi.listFunctions(),
|
||||
});
|
||||
const items = routingQuery.data || [];
|
||||
|
||||
const current = useMemo(
|
||||
() => items.find((item) => item.function_name === selectedFunction) || null,
|
||||
[items, selectedFunction]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fallback: ModelRoutingItem | null = current || items[0] || null;
|
||||
if (!fallback) return;
|
||||
if (!current && fallback.function_name) {
|
||||
setSelectedFunction(fallback.function_name);
|
||||
}
|
||||
const nextProvider = PROVIDER_OPTIONS.includes(fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
||||
? (fallback.provider as (typeof PROVIDER_OPTIONS)[number])
|
||||
: "openai";
|
||||
const nextTier = TIER_OPTIONS.includes(fallback.tier as (typeof TIER_OPTIONS)[number])
|
||||
? (fallback.tier as (typeof TIER_OPTIONS)[number])
|
||||
: "high";
|
||||
setProvider(nextProvider);
|
||||
setTier(nextTier);
|
||||
setModel(fallback.model || "");
|
||||
setApiEnv(fallback.api_env || "");
|
||||
setBaseURL(fallback.base_url || "");
|
||||
}, [current, items]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
modelRoutingApi.updateFunction(selectedFunction, {
|
||||
provider,
|
||||
model,
|
||||
api_env: apiEnv,
|
||||
tier,
|
||||
base_url: baseURL || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["model-routing-functions"] });
|
||||
},
|
||||
});
|
||||
|
||||
const routingErrorText = routingQuery.error instanceof Error ? routingQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="模型路由配置"
|
||||
description="按 function_name 动态调整 provider / model / api_env / tier,立即生效。"
|
||||
icon={<Settings2 className="h-5 w-5" />}
|
||||
tone="blue"
|
||||
meta={<span>{routingQuery.isError ? "读取失败" : `已加载 ${items.length} 条路由`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="model-routing-config"
|
||||
title="模型路由配置"
|
||||
purpose="给不同函数指定不同模型,控制成本、速度和效果。"
|
||||
whenToUse="某个功能效果差、成本高、超时多时,用它定向切换函数路由。"
|
||||
steps={[
|
||||
"在右侧选中目标函数,左侧会自动回填当前配置。",
|
||||
"调整 provider / tier / model / api_env,点“保存路由”。",
|
||||
"触发一次该功能调用,观察日志是否按新路由生效。",
|
||||
]}
|
||||
outputs={[
|
||||
"函数级模型路由覆盖项(override)",
|
||||
"可快速回滚到更稳定的模型组合",
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">编辑器</CardTitle>
|
||||
<CardDescription>选择函数并更新路由参数。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
value={selectedFunction}
|
||||
onChange={(e) => setSelectedFunction(e.target.value.trim())}
|
||||
placeholder="function_name"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{getFunctionUsageHint(selectedFunction)}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-1">
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as (typeof PROVIDER_OPTIONS)[number])}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
{PROVIDER_OPTIONS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as (typeof TIER_OPTIONS)[number])}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
{TIER_OPTIONS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="模型名" />
|
||||
<Input value={apiEnv} onChange={(e) => setApiEnv(e.target.value)} placeholder="API Key 环境变量名" />
|
||||
<Input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="Base URL(可选)" />
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
disabled={updateMutation.isPending || !selectedFunction || !model || !apiEnv}
|
||||
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
{updateMutation.isPending ? "保存中..." : "保存路由"}
|
||||
</Button>
|
||||
{updateMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
保存失败:{updateMutation.error instanceof Error ? updateMutation.error.message : "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">生效路由</CardTitle>
|
||||
<CardDescription>选中行即当前编辑目标。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{routingQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : routingQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">路由读取失败:{routingErrorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void routingQuery.refetch()}
|
||||
disabled={routingQuery.isFetching}
|
||||
>
|
||||
{routingQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无路由配置。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Function</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Tier</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.function_name}
|
||||
className={
|
||||
item.function_name === selectedFunction
|
||||
? "bg-blue-50/80 dark:bg-blue-950/35"
|
||||
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/60"
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium">{item.function_name}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{getFunctionUsageHint(item.function_name)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.provider}</TableCell>
|
||||
<TableCell>{item.model}</TableCell>
|
||||
<TableCell>{item.tier}</TableCell>
|
||||
<TableCell>{item.source}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelectedFunction(item.function_name)}>
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,683 @@
|
|||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { sstcApi, type AIRoutingConfig, type SSTCSignal, type StageKey } from "@/api/sstc";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ModelCapabilityMatrix } from "@/components/model-capability/ModelCapabilityMatrix";
|
||||
import { BudgetControlPanel } from "@/components/budget/BudgetControlPanel";
|
||||
import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader";
|
||||
import { SSTCStageNav } from "@/components/sstc/SSTCStageNav";
|
||||
import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { formatProjectNameWithId } from "@/lib/display";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { compactTitle } from "@/lib/title";
|
||||
import { cn } from "@/lib/utils";
|
||||
import CreateCharterDialog from "./components/CreateCharterDialog";
|
||||
import ProjectSettingsDialog from "./components/ProjectSettingsDialog";
|
||||
import {
|
||||
DEFAULT_SIGNAL_FILTERS,
|
||||
type SignalFilterState,
|
||||
LIVE_REFRESH_MS,
|
||||
dedupeWorkItemsBySemanticKey,
|
||||
toReadableWorkItemSummary,
|
||||
toReadableWorkItemTitle,
|
||||
} from "./helpers";
|
||||
|
||||
export default function ProjectBoardPage() {
|
||||
const SIGNAL_PAGE_SIZE = 10;
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth < 768 : false
|
||||
);
|
||||
|
||||
const [activeStage, setActiveStage] = useState<StageKey>("signal");
|
||||
const [selectedCharterId, setSelectedCharterId] = useState<string | null>(null);
|
||||
const [createCharterOpen, setCreateCharterOpen] = useState(false);
|
||||
const [newCharterTitle, setNewCharterTitle] = useState("");
|
||||
const [newCharterRawText, setNewCharterRawText] = useState("");
|
||||
const [selectedSignalIds, setSelectedSignalIds] = useState<string[]>([]);
|
||||
const [charterSeedSignalId, setCharterSeedSignalId] = useState<string | null>(null);
|
||||
const [signalSearch, setSignalSearch] = useState("");
|
||||
const [showAllSignalOptions, setShowAllSignalOptions] = useState(false);
|
||||
const [signalPage, setSignalPage] = useState(1);
|
||||
const [signalFilters, setSignalFilters] = useState<SignalFilterState>(DEFAULT_SIGNAL_FILTERS);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [routingDraft, setRoutingDraft] = useState<AIRoutingConfig | null>(null);
|
||||
const [overviewExpanded, setOverviewExpanded] = useState(true);
|
||||
const deferredSignalSearch = useDeferredValue(signalSearch.trim());
|
||||
|
||||
const { data: boardData, isLoading, error } = useQuery({
|
||||
queryKey: ["sstc-board-overview", id],
|
||||
queryFn: () => sstcApi.getBoardOverview(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: chain } = useQuery({
|
||||
queryKey: ["sstc-chain", id],
|
||||
queryFn: () => sstcApi.getChain(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: signalsPayload } = useQuery({
|
||||
queryKey: ["sstc-signals", id, signalPage, signalFilters],
|
||||
queryFn: () =>
|
||||
sstcApi.listSignals(id!, {
|
||||
status: signalFilters.status || undefined,
|
||||
keyword: signalFilters.keyword.trim() || undefined,
|
||||
source: signalFilters.source || undefined,
|
||||
priority: signalFilters.priority || undefined,
|
||||
created_from: signalFilters.created_from || undefined,
|
||||
created_to: signalFilters.created_to || undefined,
|
||||
limit: SIGNAL_PAGE_SIZE,
|
||||
offset: (signalPage - 1) * SIGNAL_PAGE_SIZE,
|
||||
}),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const signals = signalsPayload?.items || [];
|
||||
const signalTotal = Number(signalsPayload?.total || 0);
|
||||
const signalTotalPages = Math.max(1, Math.ceil(signalTotal / SIGNAL_PAGE_SIZE));
|
||||
|
||||
const { data: signalPickerPayload } = useQuery({
|
||||
queryKey: ["sstc-signals-picker", id],
|
||||
queryFn: () =>
|
||||
sstcApi.listSignals(id!, {
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}),
|
||||
enabled: !!id && createCharterOpen && showAllSignalOptions,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: charters = [] } = useQuery({
|
||||
queryKey: ["sstc-charters", id],
|
||||
queryFn: () => sstcApi.listCharters(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ["sstc-task-board", id],
|
||||
queryFn: () => sstcApi.getTaskBoard(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: releases = [] } = useQuery({
|
||||
queryKey: ["sstc-releases", id],
|
||||
queryFn: () => sstcApi.listReleases(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: reviews = [] } = useQuery({
|
||||
queryKey: ["sstc-reviews", id],
|
||||
queryFn: () => sstcApi.listReviews(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: routingConfig, isLoading: routingConfigLoading } = useQuery({
|
||||
queryKey: ["sstc-ai-routing-config", id],
|
||||
queryFn: () => sstcApi.getAIRoutingConfig(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const numericProjectId = Number(id || 0);
|
||||
const { data: pmoWorkItemsPayload } = useQuery({
|
||||
queryKey: ["ai-pmo-work-items", id],
|
||||
queryFn: () => aiPmoApi.listWorkItems({ projectId: numericProjectId, limit: 6 }),
|
||||
enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: pmoRiskSnapshot } = useQuery({
|
||||
queryKey: ["ai-pmo-cadence", "risk_review", id],
|
||||
queryFn: () => aiPmoApi.getLatestCadence({ cadenceType: "risk_review", projectId: numericProjectId }),
|
||||
enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { data: pmoDailySnapshot } = useQuery({
|
||||
queryKey: ["ai-pmo-cadence", "daily_standup", id],
|
||||
queryFn: () => aiPmoApi.getLatestCadence({ cadenceType: "daily_standup", projectId: numericProjectId }),
|
||||
enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0,
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (routingConfig) {
|
||||
setRoutingDraft(routingConfig);
|
||||
}
|
||||
}, [routingConfig]);
|
||||
|
||||
const updateRoutingConfigMutation = useMutation({
|
||||
mutationFn: (payload: {
|
||||
cold_start_enabled: boolean;
|
||||
signal_auto_enter_threshold: number;
|
||||
project_auto_create_threshold: number;
|
||||
project_uncertain_threshold: number;
|
||||
}) => sstcApi.updateAIRoutingConfig(id!, payload),
|
||||
onSuccess: (data) => {
|
||||
setRoutingDraft(data);
|
||||
setSettingsOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-ai-routing-config", id] });
|
||||
refreshAll();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCharterId && charters.length > 0) {
|
||||
setSelectedCharterId(charters[0].id);
|
||||
}
|
||||
}, [charters, selectedCharterId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const syncViewport = () => setIsMobileViewport(window.innerWidth < 768);
|
||||
syncViewport();
|
||||
window.addEventListener("resize", syncViewport);
|
||||
return () => window.removeEventListener("resize", syncViewport);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSignalPage(1);
|
||||
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
|
||||
}, [id]);
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-board-overview", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-chain", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-signals", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-signals-picker", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-charters", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-task-board", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-releases", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-reviews", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["sstc-governance", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const resetCreateCharterDraft = () => {
|
||||
setNewCharterTitle("");
|
||||
setNewCharterRawText("");
|
||||
setSelectedSignalIds([]);
|
||||
setCharterSeedSignalId(null);
|
||||
setSignalSearch("");
|
||||
setShowAllSignalOptions(false);
|
||||
};
|
||||
|
||||
const handleCreateCharterDialogChange = (open: boolean) => {
|
||||
setCreateCharterOpen(open);
|
||||
if (!open && !createCharterMutation.isPending) {
|
||||
resetCreateCharterDraft();
|
||||
}
|
||||
};
|
||||
|
||||
async function runCharterPrefillInBackground(charterId: string, rawText: string) {
|
||||
try {
|
||||
await sstcApi.aiPrefillCharter(
|
||||
id!,
|
||||
charterId,
|
||||
rawText ? { raw_text: rawText } : {}
|
||||
);
|
||||
await refreshAll();
|
||||
toast({ title: "宪章草稿已自动补全 6 模块", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: getApiErrorMessage(error, "AI 补全失败,请在宪章页手动重试"),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createCharterMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const rawText = newCharterRawText.trim();
|
||||
const charter = await sstcApi.createCharter(id!, {
|
||||
title: compactTitle(newCharterTitle, 24),
|
||||
content: rawText ? { raw_source_text: rawText } : undefined,
|
||||
signal_ids: selectedSignalIds.map((v) => Number(v)),
|
||||
skip_ai_prefill: true,
|
||||
});
|
||||
return { charter, rawText };
|
||||
},
|
||||
onSuccess: ({ charter, rawText }) => {
|
||||
setActiveStage("charter");
|
||||
setSelectedCharterId(charter.id);
|
||||
setCreateCharterOpen(false);
|
||||
resetCreateCharterDraft();
|
||||
void refreshAll();
|
||||
toast({
|
||||
title: "宪章已创建,AI 正在后台补全 6 模块",
|
||||
variant: "success",
|
||||
});
|
||||
void runCharterPrefillInBackground(charter.id, rawText);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: getApiErrorMessage(error, "创建宪章失败"),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const stageNodes = boardData?.stages || [];
|
||||
const overview = boardData?.overview;
|
||||
const risks = boardData?.risks;
|
||||
|
||||
const selectedStageLocked = useMemo(
|
||||
() => stageNodes.find((s) => s.key === activeStage)?.locked ?? false,
|
||||
[stageNodes, activeStage]
|
||||
);
|
||||
const compactNewCharterTitle = compactTitle(newCharterTitle, 24);
|
||||
const boardTitle = compactTitle(
|
||||
formatProjectNameWithId({
|
||||
id: overview?.project?.id || chain?.project?.id || id,
|
||||
title: overview?.project?.title || chain?.project?.title || "项目看板",
|
||||
}),
|
||||
24
|
||||
);
|
||||
const pmoWorkItems = useMemo(
|
||||
() => dedupeWorkItemsBySemanticKey(pmoWorkItemsPayload?.items || []),
|
||||
[pmoWorkItemsPayload?.items]
|
||||
);
|
||||
const pendingManualWorkItemCount = useMemo(
|
||||
() => pmoWorkItems.filter((item) => item.state === "waiting_manual").length,
|
||||
[pmoWorkItems]
|
||||
);
|
||||
const riskSummary = (pmoRiskSnapshot?.summary || {}) as Record<string, number>;
|
||||
const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record<string, unknown>;
|
||||
const riskActions = Array.isArray(dailyPayload.focus_actions)
|
||||
? (dailyPayload.focus_actions as string[]).slice(0, 2)
|
||||
: [];
|
||||
const currentSignalIds = useMemo(() => new Set(signals.map((signal) => signal.id)), [signals]);
|
||||
const signalOptionPool = useMemo(() => {
|
||||
const merged = new Map<string, SSTCSignal>();
|
||||
signals.forEach((signal) => merged.set(signal.id, signal));
|
||||
(signalPickerPayload?.items || []).forEach((signal) => merged.set(signal.id, signal));
|
||||
return Array.from(merged.values());
|
||||
}, [signals, signalPickerPayload?.items]);
|
||||
const signalOptions = useMemo(() => {
|
||||
const keyword = deferredSignalSearch.toLowerCase();
|
||||
const scoped = signalOptionPool.filter((signal) => {
|
||||
if (showAllSignalOptions) return true;
|
||||
return (
|
||||
currentSignalIds.has(signal.id) ||
|
||||
signal.id === charterSeedSignalId ||
|
||||
selectedSignalIds.includes(signal.id)
|
||||
);
|
||||
});
|
||||
const filtered = keyword
|
||||
? scoped.filter((signal) => {
|
||||
const haystack = [
|
||||
signal.title,
|
||||
signal.content,
|
||||
signal.proposer_name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
})
|
||||
: scoped;
|
||||
return filtered.sort((a, b) => {
|
||||
const aPinned = Number(a.id === charterSeedSignalId || selectedSignalIds.includes(a.id));
|
||||
const bPinned = Number(b.id === charterSeedSignalId || selectedSignalIds.includes(b.id));
|
||||
return bPinned - aPinned;
|
||||
});
|
||||
}, [
|
||||
charterSeedSignalId,
|
||||
currentSignalIds,
|
||||
deferredSignalSearch,
|
||||
selectedSignalIds,
|
||||
showAllSignalOptions,
|
||||
signalOptionPool,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createCharterOpen) return;
|
||||
setSelectedSignalIds((prev) =>
|
||||
prev.filter((sid) => {
|
||||
const signal = signalOptionPool.find((item) => item.id === sid);
|
||||
if (!signal) return false;
|
||||
return signal.can_create_charter !== false;
|
||||
})
|
||||
);
|
||||
}, [createCharterOpen, signalOptionPool]);
|
||||
|
||||
const toggleSignal = (sid: string) => {
|
||||
const signal = signalOptionPool.find((item) => item.id === sid);
|
||||
if (signal && signal.can_create_charter === false) return;
|
||||
setSelectedSignalIds((prev) =>
|
||||
prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSignalFiltersChange = (patch: Partial<SignalFilterState>) => {
|
||||
setSignalPage(1);
|
||||
setSignalFilters((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const resetSignalFilters = () => {
|
||||
setSignalPage(1);
|
||||
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
|
||||
};
|
||||
|
||||
const openCreateCharterDialog = () => {
|
||||
resetCreateCharterDraft();
|
||||
setCreateCharterOpen(true);
|
||||
};
|
||||
|
||||
const openCreateCharterFromSignal = (signal: SSTCSignal) => {
|
||||
if (signal.can_create_charter === false) {
|
||||
toast({
|
||||
title: signal.charter_lock_reason || "该信号已关联宪章,禁止重复新建",
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const titleSource = signal.title || signal.content || "新需求";
|
||||
setNewCharterTitle(compactTitle(titleSource, 24));
|
||||
setNewCharterRawText("");
|
||||
setSelectedSignalIds([signal.id]);
|
||||
setCharterSeedSignalId(signal.id);
|
||||
setSignalSearch("");
|
||||
setShowAllSignalOptions(false);
|
||||
setCreateCharterOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-24">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to="/projects">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{boardTitle}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">SSTC 唯一事实链路 · 可视化操作中枢</p>
|
||||
</div>
|
||||
<Button className="ml-auto" onClick={openCreateCharterDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建宪章
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="sstc" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="sstc">SSTC 看板</TabsTrigger>
|
||||
<TabsTrigger value="capability">模型能力矩阵</TabsTrigger>
|
||||
<TabsTrigger value="budget">预算管控</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="sstc" className="space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error || !overview ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-destructive">
|
||||
加载失败:{String(error)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white/70 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left transition hover:bg-slate-50/80"
|
||||
onClick={() => setOverviewExpanded((prev) => !prev)}
|
||||
aria-expanded={overviewExpanded}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">项目概览</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
汇总项目状态、团队负载、PMO 风险节奏与待处理工作项。
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-slate-600">
|
||||
{overviewExpanded ? "收起" : "展开"}
|
||||
{overviewExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-300 ease-out",
|
||||
overviewExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-4 border-t border-slate-200/80 px-5 py-5">
|
||||
<SSTCGlobalHeader
|
||||
overview={overview}
|
||||
onOpenAi={() => navigate("/ai-steward")}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenWorkloads={() => navigate("/workloads")}
|
||||
onExport={() => {
|
||||
const payload = JSON.stringify({ overview, chain, stageNodes, risks }, null, 2);
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `链路导出-${id}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">AI PMO 风险节奏</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
最新 cadence 快照已接入项目看板,风险、阻塞和待人工确认不再只存在旧队列里。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`/inbox?project_id=${id}§ion=action_required`)}>
|
||||
查看收件箱
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{[
|
||||
{ label: "Blocked Tasks", value: Number(riskSummary.blocked_task_count || 0) },
|
||||
{ label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) },
|
||||
{ label: "风险通知", value: Number(riskSummary.unread_risk_notification_count || 0) },
|
||||
{ label: "待确认工作项", value: pendingManualWorkItemCount },
|
||||
].map((metric) => (
|
||||
<div key={metric.label} className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.12em] text-slate-500">{metric.label}</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900">{metric.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前焦点动作</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{riskActions.length > 0 ? (
|
||||
riskActions.map((action) => (
|
||||
<div key={action} className="text-sm text-slate-700">
|
||||
{action}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-slate-500">当前暂无新增高风险节奏动作。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-slate-900">AI PMO 工作项</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/inbox?project_id=${id}`)}>
|
||||
全部查看
|
||||
</Button>
|
||||
</div>
|
||||
{pmoWorkItems.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6">
|
||||
<div className="text-sm text-slate-600">当前项目暂无 PMO 工作项。</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setActiveStage("task")}>
|
||||
去任务阶段
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/inbox?project_id=${id}`)}>
|
||||
打开消息台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
pmoWorkItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition hover:bg-white"
|
||||
onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="line-clamp-2 text-sm font-medium text-slate-900">{toReadableWorkItemTitle(item)}</div>
|
||||
{item.state === "waiting_manual" && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500">{item.stage_text || item.state}</div>
|
||||
<div className="mt-2 line-clamp-2 text-xs text-slate-600">{toReadableWorkItemSummary(item)}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SSTCStageNav
|
||||
stages={stageNodes}
|
||||
activeStage={activeStage}
|
||||
signalPreview={chain?.signals?.slice(0, 6) || signals.slice(0, 6)}
|
||||
onChange={setActiveStage}
|
||||
/>
|
||||
|
||||
<SSTCWorkspace
|
||||
projectId={id!}
|
||||
activeStage={activeStage}
|
||||
isMobileViewport={isMobileViewport}
|
||||
charterReadOnly={isMobileViewport}
|
||||
stages={stageNodes}
|
||||
signals={signals}
|
||||
signalFilters={signalFilters}
|
||||
charters={charters}
|
||||
tasks={tasks}
|
||||
releases={releases}
|
||||
reviews={reviews}
|
||||
selectedCharterId={selectedCharterId}
|
||||
signalPage={signalPage}
|
||||
signalTotal={signalTotal}
|
||||
signalPageSize={SIGNAL_PAGE_SIZE}
|
||||
onSignalPageChange={(nextPage) =>
|
||||
setSignalPage(Math.min(Math.max(1, nextPage), signalTotalPages))
|
||||
}
|
||||
onSelectCharter={setSelectedCharterId}
|
||||
onCreateCharterFromSignal={openCreateCharterFromSignal}
|
||||
onSignalFiltersChange={handleSignalFiltersChange}
|
||||
onResetSignalFilters={resetSignalFilters}
|
||||
onRefresh={refreshAll}
|
||||
/>
|
||||
|
||||
{selectedStageLocked && (
|
||||
<p className="text-xs text-amber-700">
|
||||
当前阶段处于锁定态,仅可查看;请先完成上游阶段。
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="capability">
|
||||
<ModelCapabilityMatrix projectId={id!} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="budget">
|
||||
<BudgetControlPanel projectId={id!} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CreateCharterDialog
|
||||
open={createCharterOpen}
|
||||
onOpenChange={handleCreateCharterDialogChange}
|
||||
newCharterTitle={newCharterTitle}
|
||||
setNewCharterTitle={setNewCharterTitle}
|
||||
compactNewCharterTitle={compactNewCharterTitle}
|
||||
newCharterRawText={newCharterRawText}
|
||||
setNewCharterRawText={setNewCharterRawText}
|
||||
selectedSignalIds={selectedSignalIds}
|
||||
charterSeedSignalId={charterSeedSignalId}
|
||||
showAllSignalOptions={showAllSignalOptions}
|
||||
setShowAllSignalOptions={setShowAllSignalOptions}
|
||||
signalSearch={signalSearch}
|
||||
setSignalSearch={setSignalSearch}
|
||||
signalPickerLoaded={Boolean(signalPickerPayload)}
|
||||
signalOptions={signalOptions}
|
||||
onToggleSignal={toggleSignal}
|
||||
onCancel={() => setCreateCharterOpen(false)}
|
||||
onCreate={() => createCharterMutation.mutate()}
|
||||
isCreating={createCharterMutation.isPending}
|
||||
canCreate={Boolean(compactNewCharterTitle) && selectedSignalIds.length > 0}
|
||||
/>
|
||||
|
||||
<ProjectSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
routingDraft={routingDraft}
|
||||
setRoutingDraft={setRoutingDraft}
|
||||
routingConfigLoading={routingConfigLoading}
|
||||
isSaving={updateRoutingConfigMutation.isPending}
|
||||
onCancel={() => {
|
||||
setSettingsOpen(false);
|
||||
setRoutingDraft(routingConfig || null);
|
||||
}}
|
||||
onSave={(payload) => {
|
||||
updateRoutingConfigMutation.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { SSTCSignal } from "@/api/sstc";
|
||||
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 } from "@/lib/display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CreateCharterDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
newCharterTitle: string;
|
||||
setNewCharterTitle: Dispatch<SetStateAction<string>>;
|
||||
compactNewCharterTitle: string;
|
||||
newCharterRawText: string;
|
||||
setNewCharterRawText: Dispatch<SetStateAction<string>>;
|
||||
selectedSignalIds: string[];
|
||||
charterSeedSignalId: string | null;
|
||||
showAllSignalOptions: boolean;
|
||||
setShowAllSignalOptions: Dispatch<SetStateAction<boolean>>;
|
||||
signalSearch: string;
|
||||
setSignalSearch: Dispatch<SetStateAction<string>>;
|
||||
signalPickerLoaded: boolean;
|
||||
signalOptions: SSTCSignal[];
|
||||
onToggleSignal: (signalId: string) => void;
|
||||
onCancel: () => void;
|
||||
onCreate: () => void;
|
||||
isCreating: boolean;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
export default function CreateCharterDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
newCharterTitle,
|
||||
setNewCharterTitle,
|
||||
compactNewCharterTitle,
|
||||
newCharterRawText,
|
||||
setNewCharterRawText,
|
||||
selectedSignalIds,
|
||||
charterSeedSignalId,
|
||||
showAllSignalOptions,
|
||||
setShowAllSignalOptions,
|
||||
signalSearch,
|
||||
setSignalSearch,
|
||||
signalPickerLoaded,
|
||||
signalOptions,
|
||||
onToggleSignal,
|
||||
onCancel,
|
||||
onCreate,
|
||||
isCreating,
|
||||
canCreate,
|
||||
}: CreateCharterDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] flex-col gap-0 overflow-hidden p-0 sm:w-[560px] sm:max-w-[560px]">
|
||||
<DialogHeader className="min-w-0 shrink-0 border-b border-slate-200 px-6 py-5">
|
||||
<DialogTitle className="overflow-hidden truncate">新建需求宪章</DialogTitle>
|
||||
<DialogDescription
|
||||
className="overflow-hidden truncate"
|
||||
title="SSTC 强制:宪章必须至少关联一个信号。现在先快速创建,AI 6 模块草稿会在后台补全。"
|
||||
>
|
||||
SSTC 强制:宪章必须至少关联一个信号。现在先快速创建,AI 6 模块草稿会在后台补全。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<Input
|
||||
className="min-w-0"
|
||||
value={newCharterTitle}
|
||||
onChange={(e) => setNewCharterTitle(e.target.value)}
|
||||
placeholder="输入需求(系统会自动精简标题)"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">精简标题</p>
|
||||
<p
|
||||
className="mt-1 overflow-hidden truncate text-xs text-muted-foreground"
|
||||
title={compactNewCharterTitle || "请输入需求"}
|
||||
>
|
||||
{compactNewCharterTitle || "请输入需求"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="mb-1 overflow-hidden truncate text-xs text-muted-foreground"
|
||||
title="补充上下文(可选,支持粘贴聊天/邮件/会议纪要,AI 会一起预填 6 模块)"
|
||||
>
|
||||
补充上下文(可选,支持粘贴聊天/邮件/会议纪要,AI 会一起预填 6 模块)
|
||||
</p>
|
||||
<Textarea
|
||||
className="min-w-0 resize-none"
|
||||
rows={3}
|
||||
value={newCharterRawText}
|
||||
onChange={(e) => setNewCharterRawText(e.target.value)}
|
||||
placeholder="粘贴额外上下文,留空则仅使用已绑定信号源"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border p-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="overflow-hidden truncate text-sm font-medium text-slate-900">关联信号</p>
|
||||
<p
|
||||
className="overflow-hidden truncate text-xs text-muted-foreground"
|
||||
title={`已选 ${selectedSignalIds.length} 个${
|
||||
charterSeedSignalId ? ",已从当前信号快速发起" : ",默认先展示当前页信号"
|
||||
}`}
|
||||
>
|
||||
已选 {selectedSignalIds.length} 个
|
||||
{charterSeedSignalId ? ",已从当前信号快速发起" : ",默认先展示当前页信号"}
|
||||
</p>
|
||||
</div>
|
||||
{!showAllSignalOptions && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setShowAllSignalOptions(true)}
|
||||
>
|
||||
加载更多信号
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showAllSignalOptions && (
|
||||
<Input
|
||||
className="mt-3 min-w-0"
|
||||
value={signalSearch}
|
||||
onChange={(e) => setSignalSearch(e.target.value)}
|
||||
placeholder="搜索标题 / 内容 / 提报人"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto overflow-x-hidden rounded-md border p-2">
|
||||
{showAllSignalOptions && !signalPickerLoaded && (
|
||||
<p className="overflow-hidden truncate text-center text-sm text-muted-foreground">正在加载更多信号…</p>
|
||||
)}
|
||||
{signalOptions.map((signal) => {
|
||||
const isSelected = selectedSignalIds.includes(signal.id);
|
||||
const isLocked = signal.can_create_charter === false;
|
||||
return (
|
||||
<label
|
||||
key={signal.id}
|
||||
className={cn(
|
||||
"flex min-w-0 cursor-pointer items-start gap-3 rounded border p-3 text-sm transition",
|
||||
isLocked && "cursor-not-allowed opacity-60",
|
||||
isSelected ? "border-blue-200 bg-blue-50/50" : "border-slate-200 hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
disabled={isLocked}
|
||||
onChange={() => onToggleSignal(signal.id)}
|
||||
className="mt-1 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p
|
||||
className="min-w-0 flex-1 overflow-hidden truncate font-medium text-slate-900"
|
||||
title={signal.title || `信号 #${signal.id}`}
|
||||
>
|
||||
{signal.title || `信号 #${signal.id}`}
|
||||
</p>
|
||||
<Badge variant="outline" className="shrink-0 overflow-hidden truncate">
|
||||
{toZhPriority(signal.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
className="mt-1 overflow-hidden truncate text-xs text-slate-500"
|
||||
title={signal.content || "暂无信号内容"}
|
||||
>
|
||||
{signal.content || "暂无信号内容"}
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 overflow-hidden truncate text-[11px] text-slate-400"
|
||||
title={signal.proposer_name || "未知提报人"}
|
||||
>
|
||||
{signal.proposer_name || "未知提报人"}
|
||||
</p>
|
||||
{isLocked && (
|
||||
<p
|
||||
className="mt-1 overflow-hidden truncate text-[11px] text-rose-600"
|
||||
title={signal.charter_lock_reason || "该信号已关联宪章,禁止重复新建"}
|
||||
>
|
||||
{signal.charter_lock_reason || "该信号已关联宪章,禁止重复新建"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{signalOptions.length === 0 && (
|
||||
<p className="overflow-hidden truncate text-center text-sm text-muted-foreground">
|
||||
{showAllSignalOptions ? "未找到匹配信号" : "当前页暂无可关联信号"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 border-t border-slate-200 bg-background px-6 py-4">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onCreate} disabled={!canCreate || isCreating}>
|
||||
{isCreating ? "创建中…" : "创建宪章"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { AIRoutingConfig } 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 ProjectSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
routingDraft: AIRoutingConfig | null;
|
||||
setRoutingDraft: Dispatch<SetStateAction<AIRoutingConfig | null>>;
|
||||
routingConfigLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (payload: {
|
||||
cold_start_enabled: boolean;
|
||||
signal_auto_enter_threshold: number;
|
||||
project_auto_create_threshold: number;
|
||||
project_uncertain_threshold: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export default function ProjectSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
routingDraft,
|
||||
setRoutingDraft,
|
||||
routingConfigLoading,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: ProjectSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(640px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>项目设置</DialogTitle>
|
||||
<DialogDescription>
|
||||
管理冷启动与自动路由阈值。示例:`0.80` 表示模型置信度达到 80% 即自动入库。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!routingDraft ? (
|
||||
<p className="text-sm text-muted-foreground">{routingConfigLoading ? "正在加载项目设置…" : "暂未加载到路由配置"}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">冷启动开关</p>
|
||||
<p className="text-xs text-muted-foreground">{routingDraft.tips.cold_start_enabled}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={routingDraft.cold_start_enabled ? "default" : "outline"}
|
||||
disabled={isSaving}
|
||||
onClick={() =>
|
||||
setRoutingDraft((prev) => (prev ? { ...prev, cold_start_enabled: !prev.cold_start_enabled } : prev))
|
||||
}
|
||||
>
|
||||
冷启动:{routingDraft.cold_start_enabled ? "开启" : "关闭"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1 text-xs">
|
||||
<span className="font-medium">自动入库阈值(confidence)</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={routingDraft.signal_auto_enter_threshold}
|
||||
disabled={isSaving}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setRoutingDraft((prev) =>
|
||||
prev && Number.isFinite(val) ? { ...prev, signal_auto_enter_threshold: val } : prev
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="text-muted-foreground">{routingDraft.tips.signal_auto_enter_threshold}</p>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs">
|
||||
<span className="font-medium">自动建项阈值(project_score)</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={routingDraft.project_auto_create_threshold}
|
||||
disabled={isSaving}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setRoutingDraft((prev) =>
|
||||
prev && Number.isFinite(val) ? { ...prev, project_auto_create_threshold: val } : prev
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="text-muted-foreground">{routingDraft.tips.project_auto_create_threshold}</p>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-xs">
|
||||
<span className="font-medium">项目待确认阈值(project_uncertain)</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={routingDraft.project_uncertain_threshold}
|
||||
disabled={isSaving}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setRoutingDraft((prev) =>
|
||||
prev && Number.isFinite(val) ? { ...prev, project_uncertain_threshold: val } : prev
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="text-muted-foreground">{routingDraft.tips.project_uncertain_threshold}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!routingDraft || isSaving}
|
||||
onClick={() => {
|
||||
if (!routingDraft) return;
|
||||
onSave({
|
||||
cold_start_enabled: routingDraft.cold_start_enabled,
|
||||
signal_auto_enter_threshold: Number(routingDraft.signal_auto_enter_threshold.toFixed(2)),
|
||||
project_auto_create_threshold: Number(routingDraft.project_auto_create_threshold.toFixed(2)),
|
||||
project_uncertain_threshold: Number(routingDraft.project_uncertain_threshold.toFixed(2)),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isSaving ? "保存中…" : "保存设置"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { AIPMOWorkItem } from "@/api/aiPmo";
|
||||
|
||||
export const LIVE_REFRESH_MS = 10_000;
|
||||
|
||||
export type SignalFilterState = {
|
||||
status: string;
|
||||
keyword: string;
|
||||
source: string;
|
||||
priority: string;
|
||||
created_from: string;
|
||||
created_to: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
|
||||
status: "pending",
|
||||
keyword: "",
|
||||
source: "",
|
||||
priority: "",
|
||||
created_from: "",
|
||||
created_to: "",
|
||||
};
|
||||
|
||||
const REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE = /^om_registry_repair_(\d+)_(\d+)_(base|explicit)$/i;
|
||||
const REGISTRY_REPAIR_TEXT_RE = /补录历史接入记录\s*registry#(\d+)(?:(([^)]+)))?/i;
|
||||
|
||||
function normalizeTechText(value?: string | null): string {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
return raw
|
||||
.replace(/补录历史接入记录/g, "补全历史接入信息")
|
||||
.replace(/registry#(\d+)/gi, "记录#$1")
|
||||
.replace(/请仅执行入库,不需要二次处理。?/g, "请确认后继续,系统会自动完成补录。");
|
||||
}
|
||||
|
||||
export function dedupeWorkItemsBySemanticKey(items: AIPMOWorkItem[]): AIPMOWorkItem[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AIPMOWorkItem[] = [];
|
||||
for (const item of items) {
|
||||
const sourceMessageId = String(item.source_message_id || "").trim();
|
||||
const sourceMatch = sourceMessageId.match(REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE);
|
||||
let key = `work_item:${item.id}`;
|
||||
if (sourceMatch) {
|
||||
key = `registry_repair:${sourceMatch[1]}:${sourceMatch[2]}`;
|
||||
} else {
|
||||
const merged = [item.title, item.summary, item.next_step_text].filter(Boolean).join(" ");
|
||||
const textMatch = merged.match(REGISTRY_REPAIR_TEXT_RE);
|
||||
if (textMatch) {
|
||||
key = `registry_repair:${item.project_id || "na"}:${textMatch[1]}`;
|
||||
}
|
||||
}
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function toReadableWorkItemTitle(item: AIPMOWorkItem): string {
|
||||
const sourceMessageId = String(item.source_message_id || "").trim();
|
||||
const sourceMatch = sourceMessageId.match(REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE);
|
||||
if (sourceMatch) {
|
||||
return `补全历史接入记录(#${sourceMatch[2]})`;
|
||||
}
|
||||
const titleMatch = String(item.title || "").match(REGISTRY_REPAIR_TEXT_RE);
|
||||
if (titleMatch) {
|
||||
return `补全历史接入记录(#${titleMatch[1]})`;
|
||||
}
|
||||
return normalizeTechText(item.title) || `工作项#${item.id}`;
|
||||
}
|
||||
|
||||
export function toReadableWorkItemSummary(item: AIPMOWorkItem): string {
|
||||
const merged = [item.title, item.summary, item.next_step_text].filter(Boolean).join(" ");
|
||||
const textMatch = merged.match(REGISTRY_REPAIR_TEXT_RE);
|
||||
if (textMatch) {
|
||||
const sourceName = String(textMatch[2] || "").trim();
|
||||
if (sourceName) {
|
||||
return `检测到「${sourceName}」存在历史接入信息缺失,请确认后继续,系统将自动入库并回填关联。`;
|
||||
}
|
||||
return "检测到历史接入信息缺失,请确认后继续,系统将自动入库并回填关联。";
|
||||
}
|
||||
return (
|
||||
normalizeTechText(item.next_step_text)
|
||||
|| normalizeTechText(item.summary)
|
||||
|| "请进入收件箱查看详情并选择下一步处理。"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
FolderKanban,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api, apiBaseURL } from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { formatProjectNameWithId, toZhRole, toZhStatus } from "@/lib/display";
|
||||
import { compactDescription, compactTitle } from "@/lib/title";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import ProjectQueryDialog from "./components/ProjectQueryDialog";
|
||||
import DeleteProjectDialog from "./components/DeleteProjectDialog";
|
||||
import {
|
||||
CHAIN_STAGES,
|
||||
LIVE_REFRESH_MS,
|
||||
canDeleteProjectByRole,
|
||||
normalizeProjects,
|
||||
projectStatusClass,
|
||||
resolveDeleteProjectError,
|
||||
type ProjectItem,
|
||||
} from "./helpers";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const currentRole = useAuthStore((s) => s.user?.role);
|
||||
const [nameKeyword, setNameKeyword] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [appliedNameKeyword, setAppliedNameKeyword] = useState("");
|
||||
const [appliedStartDate, setAppliedStartDate] = useState("");
|
||||
const [appliedEndDate, setAppliedEndDate] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [queryOpen, setQueryOpen] = useState(false);
|
||||
const [deleteProjectState, setDeleteProjectState] = useState<ProjectItem | null>(null);
|
||||
const pageSize = 9;
|
||||
const canDeleteProject = useMemo(() => canDeleteProjectByRole(currentRole), [currentRole]);
|
||||
|
||||
const {
|
||||
data: projects = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["projects", "summary"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<ProjectItem[] | { projects: ProjectItem[] }>(
|
||||
"/projects/?summary=1",
|
||||
{ timeout: 12000 }
|
||||
);
|
||||
const raw = (res.data as { data?: unknown })?.data ?? res.data;
|
||||
return normalizeProjects(raw as ProjectItem[] | { projects?: ProjectItem[] });
|
||||
},
|
||||
refetchInterval: LIVE_REFRESH_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
retryDelay: 1500,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (projectId: string) => {
|
||||
await api.delete(`/projects/${projectId}`);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["projects", "summary"] });
|
||||
setDeleteProjectState(null);
|
||||
toast({ title: "项目删除成功", variant: "success" });
|
||||
},
|
||||
onError: (error: unknown) => toast({ title: resolveDeleteProjectError(error), variant: "error" }),
|
||||
});
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const keyword = appliedNameKeyword.trim().toLowerCase();
|
||||
const fromTs = appliedStartDate ? new Date(`${appliedStartDate}T00:00:00`).getTime() : null;
|
||||
const toTs = appliedEndDate ? new Date(`${appliedEndDate}T23:59:59`).getTime() : null;
|
||||
|
||||
return projects.filter((project) => {
|
||||
const projectLabel = formatProjectNameWithId({ id: project.id, title: project.title }).toLowerCase();
|
||||
if (keyword && !projectLabel.includes(keyword)) return false;
|
||||
|
||||
if (fromTs || toTs) {
|
||||
const created = project.created_at ? new Date(project.created_at).getTime() : NaN;
|
||||
if (!Number.isFinite(created)) return false;
|
||||
if (fromTs && created < fromTs) return false;
|
||||
if (toTs && created > toTs) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [projects, appliedNameKeyword, appliedStartDate, appliedEndDate]);
|
||||
|
||||
const totalFiltered = filteredProjects.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const hasActiveFilters = !!(appliedNameKeyword.trim() || appliedStartDate || appliedEndDate);
|
||||
const appliedFilterSummary = [
|
||||
appliedNameKeyword.trim() ? `项目名:${appliedNameKeyword.trim()}` : "",
|
||||
appliedStartDate ? `开始:${appliedStartDate}` : "",
|
||||
appliedEndDate ? `结束:${appliedEndDate}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
const pagedProjects = useMemo(
|
||||
() => filteredProjects.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[filteredProjects, safePage]
|
||||
);
|
||||
|
||||
const runQuery = () => {
|
||||
setAppliedNameKeyword(nameKeyword);
|
||||
setAppliedStartDate(startDate);
|
||||
setAppliedEndDate(endDate);
|
||||
setPage(1);
|
||||
setQueryOpen(false);
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
setNameKeyword("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setAppliedNameKeyword("");
|
||||
setAppliedStartDate("");
|
||||
setAppliedEndDate("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">项目列表</h1>
|
||||
<p className="mt-1 text-slate-500">管理所有项目 · SSTC 唯一事实链路</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
当前结果:{totalFiltered} 个项目
|
||||
{hasActiveFilters ? `(${appliedFilterSummary})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => setQueryOpen(true)}
|
||||
>
|
||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||
条件查询
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||
SSTC 唯一事实链路
|
||||
</span>
|
||||
<p className="text-sm font-medium text-slate-700">全链路强绑定,无断点闭环</p>
|
||||
</div>
|
||||
<CardDescription className="mt-2 text-slate-500">
|
||||
任何项目相关操作必须纳入完整链路;游离在链路外的信息不具备执行效力。
|
||||
</CardDescription>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{CHAIN_STAGES.map((stage) => {
|
||||
const Icon = stage.icon;
|
||||
return (
|
||||
<div key={stage.key} className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white">
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="text-xs font-semibold">{stage.label}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-0.5 rounded-full bg-slate-200">
|
||||
<div className="h-full w-2/3 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 列表内容 */}
|
||||
{isLoading ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500 shadow-[0_4px_14px_rgba(15,23,42,0.06)]">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
加载中…
|
||||
</div>
|
||||
</section>
|
||||
) : error ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-10">
|
||||
<AlertCircle className="h-10 w-10 text-rose-600" />
|
||||
<p className="font-medium text-rose-600">加载失败</p>
|
||||
<p className="max-w-md text-center text-sm text-slate-500">
|
||||
{(error as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||
(error as Error).message ??
|
||||
"请检查后端服务与登录状态"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
当前请求:GET <code className="rounded bg-slate-100 px-1">{apiBaseURL}/projects/?summary=1</code>
|
||||
{apiBaseURL === "/api" && "(由 Vite 代理到后端,请确认后端在 8000 端口已启动)"}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : totalFiltered === 0 ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<CardContent className="py-12 text-center text-slate-500">
|
||||
<FolderKanban className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="font-medium text-slate-700">无匹配项目</p>
|
||||
<p className="mt-1 text-sm">请调整名称或时间筛选条件后重试</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{pagedProjects.map((p) => {
|
||||
const progressPercent = Math.min(100, typeof p.progress === "number" ? p.progress : 0);
|
||||
const projectLabel = formatProjectNameWithId({ id: p.id, title: p.title });
|
||||
return (
|
||||
<Card
|
||||
key={p.id}
|
||||
className="flex min-h-[244px] flex-col rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
<CardHeader className="space-y-3 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base text-slate-900" title={projectLabel}>
|
||||
{compactTitle(projectLabel, 22)}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-2 text-slate-500" title={p.description || p.title || ""}>
|
||||
{compactDescription(p.description || p.title || "", 44) || "无描述"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FolderKanban className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{p.signal_count} 信号</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{toZhRole(p.assigned_role)}</span>
|
||||
{p.status && (
|
||||
<span className={`rounded-full px-2 py-1 font-medium ${projectStatusClass(p.status)}`}>
|
||||
{toZhStatus(p.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto space-y-3">
|
||||
{typeof p.total_tasks === "number" && p.total_tasks > 0 && (
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>任务进度</span>
|
||||
<span>
|
||||
{p.completed_tasks ?? 0}/{p.total_tasks}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="h-full rounded-full bg-blue-600 transition-all" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Button asChild size="sm" className="w-full bg-blue-600 text-white hover:bg-blue-700">
|
||||
<Link to={`/projects/${p.id}/board`}>
|
||||
进入 SSTC 看板
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteProject && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full border-rose-200 bg-white text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
onClick={() => setDeleteProjectState(p)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除项目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 rounded-xl bg-white px-3 py-2 shadow-[0_4px_12px_rgba(15,23,42,0.06)]">
|
||||
<p className="text-xs text-slate-500">
|
||||
第 {safePage}/{totalPages} 页,共 {totalFiltered} 个项目
|
||||
</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={safePage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
disabled={safePage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ProjectQueryDialog
|
||||
open={queryOpen}
|
||||
onOpenChange={setQueryOpen}
|
||||
nameKeyword={nameKeyword}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onNameKeywordChange={setNameKeyword}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onReset={resetQuery}
|
||||
onRunQuery={runQuery}
|
||||
/>
|
||||
|
||||
<DeleteProjectDialog
|
||||
open={!!deleteProjectState}
|
||||
project={deleteProjectState}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onCancel={() => setDeleteProjectState(null)}
|
||||
onConfirm={() => deleteProjectState && deleteMutation.mutate(deleteProjectState.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { formatProjectNameWithId } from "@/lib/display";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { ProjectItem } from "../helpers";
|
||||
|
||||
interface DeleteProjectDialogProps {
|
||||
open: boolean;
|
||||
project: ProjectItem | null;
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteProjectDialog({
|
||||
open,
|
||||
project,
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteProjectDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
|
||||
<DialogContent className="w-[min(420px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除项目</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除项目「
|
||||
{formatProjectNameWithId({
|
||||
id: project?.id,
|
||||
title: project?.title,
|
||||
})}
|
||||
」后不可恢复,关联的链路数据将一并移除。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={onCancel}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={isDeleting || !project} onClick={onConfirm}>
|
||||
{isDeleting ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ProjectQueryDialogProps {
|
||||
open: boolean;
|
||||
nameKeyword: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onNameKeywordChange: (value: string) => void;
|
||||
onStartDateChange: (value: string) => void;
|
||||
onEndDateChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
onRunQuery: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectQueryDialog({
|
||||
open,
|
||||
nameKeyword,
|
||||
startDate,
|
||||
endDate,
|
||||
onOpenChange,
|
||||
onNameKeywordChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onReset,
|
||||
onRunQuery,
|
||||
}: ProjectQueryDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>条件查询</DialogTitle>
|
||||
<DialogDescription>按项目名称和创建时间过滤项目列表。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-slate-600">项目名称</p>
|
||||
<Input
|
||||
value={nameKeyword}
|
||||
onChange={(event) => onNameKeywordChange(event.target.value)}
|
||||
placeholder="输入项目名称或ID关键字"
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-slate-600">开始时间</p>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => onStartDateChange(event.target.value)}
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-slate-600">结束时间</p>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => onEndDateChange(event.target.value)}
|
||||
className="border-slate-200 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={onReset}
|
||||
>
|
||||
清空条件
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={onRunQuery}>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { Code, FileText, ListTodo, MessageSquare, Package, Radio } from "lucide-react";
|
||||
|
||||
/** 项目列表项:支持普通列表与 summary 含进度 */
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
assigned_role?: string | null;
|
||||
signal_count: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
progress?: number;
|
||||
total_tasks?: number;
|
||||
completed_tasks?: number;
|
||||
in_progress_tasks?: number;
|
||||
pending_tasks?: number;
|
||||
tasks?: Array<{ task_name: string; type: string; status: string; signal_id: string }>;
|
||||
}
|
||||
|
||||
/** 后端返回:直接数组 或 { projects: ProjectItem[] } */
|
||||
export function normalizeProjects(raw: ProjectItem[] | { projects?: ProjectItem[] }): ProjectItem[] {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
return (raw as { projects?: ProjectItem[] }).projects ?? [];
|
||||
}
|
||||
|
||||
/** SSTC 链路六阶段(与文档一致) */
|
||||
export const CHAIN_STAGES = [
|
||||
{ key: "signal", label: "信号源", icon: Radio },
|
||||
{ key: "charter", label: "需求宪章", icon: FileText },
|
||||
{ key: "task", label: "任务拆解", icon: ListTodo },
|
||||
{ key: "execute", label: "执行落地", icon: Code },
|
||||
{ key: "release", label: "发布验证", icon: Package },
|
||||
{ key: "retro", label: "复盘沉淀", icon: MessageSquare },
|
||||
] as const;
|
||||
|
||||
export const LIVE_REFRESH_MS = 10_000;
|
||||
|
||||
export function projectStatusClass(status: string): string {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (value.includes("done") || value.includes("completed") || value.includes("已")) {
|
||||
return "bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
if (value.includes("progress") || value.includes("active") || value.includes("进行")) {
|
||||
return "bg-blue-50 text-blue-700";
|
||||
}
|
||||
if (value.includes("blocked") || value.includes("error") || value.includes("暂停")) {
|
||||
return "bg-rose-50 text-rose-700";
|
||||
}
|
||||
return "bg-slate-100 text-slate-600";
|
||||
}
|
||||
|
||||
export function canDeleteProjectByRole(role?: string): boolean {
|
||||
const roles = String(role || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
return roles.includes("BOSS") || roles.includes("ADMIN");
|
||||
}
|
||||
|
||||
export function resolveDeleteProjectError(error: unknown): string {
|
||||
return (
|
||||
(error as { response?: { data?: { detail?: string; message?: string } }; message?: string })?.response?.data
|
||||
?.detail ||
|
||||
(error as { response?: { data?: { detail?: string; message?: string } }; message?: string })?.response?.data
|
||||
?.message ||
|
||||
(error as { message?: string })?.message ||
|
||||
"删除失败,请稍后重试"
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* 食谱页面 - 支持拍照识别热量
|
||||
*/
|
||||
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getRecipes,
|
||||
createRecipe,
|
||||
analyzeCalories,
|
||||
toggleFavorite,
|
||||
type RecipeCreate,
|
||||
} from '@/api/recipes'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Heart, Search, Flame, Utensils, Droplets } from 'lucide-react'
|
||||
import AnalyzeRecipeDialog from './components/AnalyzeRecipeDialog'
|
||||
import CreateRecipeDialog from './components/CreateRecipeDialog'
|
||||
import { EMPTY_RECIPE, RECIPE_CATEGORY_OPTIONS } from './helpers'
|
||||
|
||||
export default function RecipesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false)
|
||||
const [newRecipe, setNewRecipe] = useState({ ...EMPTY_RECIPE })
|
||||
const [foodDescription, setFoodDescription] = useState('')
|
||||
const [imageBase64, setImageBase64] = useState('')
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
|
||||
// 获取食谱列表
|
||||
const { data: recipes = [], isLoading } = useQuery({
|
||||
queryKey: ['recipes', categoryFilter],
|
||||
queryFn: () =>
|
||||
getRecipes({
|
||||
limit: 50,
|
||||
category: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
}),
|
||||
})
|
||||
|
||||
// 创建食谱
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createRecipe,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['recipes'] })
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewRecipe({ ...EMPTY_RECIPE })
|
||||
},
|
||||
})
|
||||
|
||||
// AI 热量分析
|
||||
const analyzeMutation = useMutation({
|
||||
mutationFn: (data: { recipe: RecipeCreate; foodDescription: string }) =>
|
||||
analyzeCalories(data.recipe, data.foodDescription),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['recipes'] })
|
||||
setIsAnalyzeDialogOpen(false)
|
||||
setNewRecipe({ ...EMPTY_RECIPE })
|
||||
setFoodDescription('')
|
||||
setImageBase64('')
|
||||
setSelectedFile(null)
|
||||
},
|
||||
})
|
||||
|
||||
// 收藏/取消收藏
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: toggleFavorite,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['recipes'] })
|
||||
},
|
||||
})
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setSelectedFile(file)
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
setImageBase64(reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤食谱
|
||||
const filteredRecipes = recipes.filter(
|
||||
(recipe) =>
|
||||
recipe.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
recipe.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const handleCreate = () => {
|
||||
createMutation.mutate(newRecipe)
|
||||
}
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!newRecipe.name) {
|
||||
alert('请输入菜名')
|
||||
return
|
||||
}
|
||||
if (!foodDescription && !selectedFile) {
|
||||
alert('请输入食物描述或上传图片')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果上传了图片,使用文件名作为描述
|
||||
const description = foodDescription || selectedFile?.name || newRecipe.name
|
||||
analyzeMutation.mutate({
|
||||
recipe: newRecipe,
|
||||
foodDescription: description,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">食谱管理</h1>
|
||||
<p className="text-muted-foreground">管理您的食谱,AI 智能识别热量</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CreateRecipeDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
recipe={newRecipe}
|
||||
onRecipeChange={setNewRecipe}
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<AnalyzeRecipeDialog
|
||||
open={isAnalyzeDialogOpen}
|
||||
onOpenChange={setIsAnalyzeDialogOpen}
|
||||
recipe={newRecipe}
|
||||
onRecipeChange={setNewRecipe}
|
||||
foodDescription={foodDescription}
|
||||
onFoodDescriptionChange={setFoodDescription}
|
||||
imageBase64={imageBase64}
|
||||
onFileSelect={handleFileSelect}
|
||||
onSubmit={handleAnalyze}
|
||||
isAnalyzing={analyzeMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-full sm:max-w-sm sm:flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索食谱..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm sm:w-44"
|
||||
>
|
||||
<option value="all">全部分类</option>
|
||||
{RECIPE_CATEGORY_OPTIONS.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
) : filteredRecipes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<Utensils className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">暂无食谱,点击右上角添加</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<Card key={recipe.id} className="overflow-hidden">
|
||||
{recipe.image_url && (
|
||||
<img
|
||||
src={recipe.image_url}
|
||||
alt={recipe.name}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{recipe.name}</CardTitle>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{recipe.category && (
|
||||
<Badge variant="secondary">{recipe.category}</Badge>
|
||||
)}
|
||||
{recipe.cuisine && <Badge variant="outline">{recipe.cuisine}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => favoriteMutation.mutate(recipe.id)}
|
||||
className={recipe.is_favorite ? 'text-red-500' : ''}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${recipe.is_favorite ? 'fill-current' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{recipe.description && (
|
||||
<CardDescription className="mt-2">{recipe.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recipe.calories !== undefined && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
<span className="font-medium">{recipe.calories.toFixed(0)} kcal</span>
|
||||
{recipe.ai_recognition_confidence && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
AI {Math.round(recipe.ai_recognition_confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground sm:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>蛋白质</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.protein?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>脂肪</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.fat?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>碳水</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.carbs?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>纤维</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{recipe.fiber?.toFixed(1) || '-'}g
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import type { ChangeEvent } from "react";
|
||||
import type { RecipeCreate } from "@/api/recipes";
|
||||
import { Camera } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface AnalyzeRecipeDialogProps {
|
||||
open: boolean;
|
||||
recipe: RecipeCreate;
|
||||
foodDescription: string;
|
||||
imageBase64: string;
|
||||
isAnalyzing: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRecipeChange: (recipe: RecipeCreate) => void;
|
||||
onFoodDescriptionChange: (value: string) => void;
|
||||
onFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function AnalyzeRecipeDialog({
|
||||
open,
|
||||
recipe,
|
||||
foodDescription,
|
||||
imageBase64,
|
||||
isAnalyzing,
|
||||
onOpenChange,
|
||||
onRecipeChange,
|
||||
onFoodDescriptionChange,
|
||||
onFileSelect,
|
||||
onSubmit,
|
||||
}: AnalyzeRecipeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
拍照识别
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[720px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 热量识别</DialogTitle>
|
||||
<DialogDescription>上传食物图片或描述,AI 自动分析热量和营养成分</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Tabs defaultValue="upload">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="upload">上传图片</TabsTrigger>
|
||||
<TabsTrigger value="text">文字描述</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="image">食物图片</Label>
|
||||
<Input id="image" type="file" accept="image/*" onChange={onFileSelect} className="cursor-pointer" />
|
||||
</div>
|
||||
{imageBase64 && (
|
||||
<div className="mt-4">
|
||||
<img src={imageBase64} alt="预览" className="max-w-full max-h-64 rounded-lg border" />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="text" className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="text-description">食物描述</Label>
|
||||
<Textarea
|
||||
id="text-description"
|
||||
placeholder="描述食物的外观、份量、食材等,例如:一碗红烧肉,约200克..."
|
||||
value={foodDescription}
|
||||
onChange={(event) => onFoodDescriptionChange(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="name">菜名</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="输入菜名"
|
||||
value={recipe.name}
|
||||
onChange={(event) => onRecipeChange({ ...recipe, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="category">分类</Label>
|
||||
<Input
|
||||
id="category"
|
||||
placeholder="如:主食、蔬菜、肉类"
|
||||
value={recipe.category}
|
||||
onChange={(event) => onRecipeChange({ ...recipe, category: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cuisine">菜系</Label>
|
||||
<Input
|
||||
id="cuisine"
|
||||
placeholder="如:中式、西式、日式"
|
||||
value={recipe.cuisine}
|
||||
onChange={(event) => onRecipeChange({ ...recipe, cuisine: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="user_notes">备注</Label>
|
||||
<Textarea
|
||||
id="user_notes"
|
||||
placeholder="添加备注信息..."
|
||||
value={recipe.user_notes}
|
||||
onChange={(event) => onRecipeChange({ ...recipe, user_notes: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onSubmit} disabled={isAnalyzing}>
|
||||
{isAnalyzing ? "分析中..." : "开始识别"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { RecipeCreate } from "@/api/recipes";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface CreateRecipeDialogProps {
|
||||
open: boolean;
|
||||
recipe: RecipeCreate;
|
||||
isSubmitting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRecipeChange: (recipe: RecipeCreate) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function CreateRecipeDialog({
|
||||
open,
|
||||
recipe,
|
||||
isSubmitting,
|
||||
onOpenChange,
|
||||
onRecipeChange,
|
||||
onSubmit,
|
||||
}: CreateRecipeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
手动添加
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建食谱</DialogTitle>
|
||||
<DialogDescription>手动添加食谱信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="name">菜名</Label>
|
||||
<Input id="name" value={recipe.name} onChange={(event) => onRecipeChange({ ...recipe, name: event.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={recipe.description}
|
||||
onChange={(event) => onRecipeChange({ ...recipe, description: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">分类</Label>
|
||||
<Input
|
||||
id="category"
|
||||
value={recipe.category}
|
||||
onChange={(event) => onRecipeChange({ ...recipe, category: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cuisine">菜系</Label>
|
||||
<Input id="cuisine" value={recipe.cuisine} onChange={(event) => onRecipeChange({ ...recipe, cuisine: event.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { RecipeCreate } from "@/api/recipes";
|
||||
|
||||
export const RECIPE_CATEGORY_OPTIONS = ["主食", "蔬菜", "肉类", "汤品", "甜品", "饮品"] as const;
|
||||
|
||||
export const EMPTY_RECIPE: RecipeCreate = {
|
||||
name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
cuisine: "",
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default function SchedulerCenterPage() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
调度中心功能建设中
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ListTodo } from "lucide-react";
|
||||
import { getUserSkillTasks } from "@/api/skill";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toZhPriority, toZhStatus } from "@/lib/display";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function SkillTasksPage() {
|
||||
const { id: userId, skillId } = useParams();
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["skill-tasks", userId, skillId],
|
||||
queryFn: () => getUserSkillTasks(userId!, skillId!),
|
||||
enabled: !!userId && !!skillId,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? items.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={userId ? `/users/${userId}/profile` : "/users"}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">技能历史任务</h1>
|
||||
<p className="text-muted-foreground">
|
||||
用户 {userId} / 技能 {skillId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>任务列表 ({total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-destructive">
|
||||
加载失败:{(error as Error).message || "请求异常"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-10 text-center text-muted-foreground">
|
||||
<ListTodo className="mb-3 h-10 w-10 opacity-60" />
|
||||
<p>暂无历史任务</p>
|
||||
<p className="mt-1 text-xs">
|
||||
当前后端可能尚未提供技能历史任务接口,页面已兼容空数据。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[240px] whitespace-nowrap">任务</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap">状态</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap">优先级</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap text-right">工时 (h)</TableHead>
|
||||
<TableHead className="min-w-[170px] whitespace-nowrap">更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="min-w-[240px]">
|
||||
<div>
|
||||
<p className="font-medium">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[90px] whitespace-nowrap">{toZhStatus(task.status)}</TableCell>
|
||||
<TableCell className="min-w-[90px] whitespace-nowrap">{toZhPriority(task.priority)}</TableCell>
|
||||
<TableCell className="min-w-[90px] whitespace-nowrap text-right">
|
||||
{typeof task.estimated_hours === "number"
|
||||
? task.estimated_hours.toFixed(1)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[170px] whitespace-nowrap">
|
||||
{task.updated_at
|
||||
? formatBeijingDateTime(task.updated_at)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((task) => (
|
||||
<article key={task.id} className="rounded-lg border bg-white p-4">
|
||||
<p className="text-base font-semibold">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span>状态:{toZhStatus(task.status)}</span>
|
||||
<span>优先级:{toZhPriority(task.priority)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>工时:{typeof task.estimated_hours === "number" ? task.estimated_hours.toFixed(1) : "-"}h</span>
|
||||
<span>{task.updated_at ? formatBeijingDateTime(task.updated_at) : "-"}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
createSkillTag,
|
||||
deleteSkillTag,
|
||||
getSkillTags,
|
||||
updateSkillTag,
|
||||
type SkillTag,
|
||||
} from "@/api/skill";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { toZhSkillCategory } from "@/lib/display";
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
dev: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
algorithm: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
|
||||
product: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300",
|
||||
pm: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
||||
qa: "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-300",
|
||||
language: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
framework: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
|
||||
skill: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
tool: "bg-stone-100 text-stone-800 dark:bg-stone-800 dark:text-stone-300",
|
||||
};
|
||||
|
||||
const CATEGORY_OPTIONS = ["dev", "algorithm", "product", "pm", "qa", "language", "framework", "skill", "tool"];
|
||||
|
||||
function categoryClass(category?: string | null) {
|
||||
return CATEGORY_COLORS[(category || "").toLowerCase()] ?? "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canManage = isManagerRole(user?.role);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<SkillTag | null>(null);
|
||||
const [deleteRow, setDeleteRow] = useState<SkillTag | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skill-tags", categoryFilter],
|
||||
queryFn: () => getSkillTags({ category: categoryFilter || undefined }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: { name: string; slug: string; category?: string; description?: string }) =>
|
||||
createSkillTag(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||||
setCreateOpen(false);
|
||||
toast({ title: "技能标签创建成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "技能标签创建失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ tagId, payload }: { tagId: string; payload: { name?: string; category?: string; description?: string } }) =>
|
||||
updateSkillTag(tagId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||||
setEditRow(null);
|
||||
toast({ title: "技能标签更新成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "技能标签更新失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tagId: string) => deleteSkillTag(tagId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||||
setDeleteRow(null);
|
||||
toast({ title: "技能标签删除成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "技能标签删除失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return (data ?? []).filter((row) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
row.name.toLowerCase().includes(q) ||
|
||||
(row.slug || "").toLowerCase().includes(q) ||
|
||||
(row.category || "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [data, search]);
|
||||
const renderTagActions = (tag: SkillTag) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditRow(tag)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!!tag.is_system}
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteRow(tag)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">技能标签管理</h1>
|
||||
<p className="text-muted-foreground">维护技能标签分类并查看关联人数</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建标签
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<CardTitle>标签列表</CardTitle>
|
||||
<CardDescription>共 {rows.length} 个</CardDescription>
|
||||
</div>
|
||||
<div className="ml-auto w-full sm:w-auto">
|
||||
<Input
|
||||
placeholder="搜索名称/标识/分类"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full sm:w-72"
|
||||
/>
|
||||
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<select
|
||||
className="h-10 min-w-[132px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
{CATEGORY_OPTIONS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{toZhSkillCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">加载失败</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无技能标签</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[180px] whitespace-nowrap">名称</TableHead>
|
||||
<TableHead className="min-w-[120px] whitespace-nowrap">分类</TableHead>
|
||||
<TableHead className="min-w-[96px] whitespace-nowrap">关联人数</TableHead>
|
||||
<TableHead className="min-w-[220px] whitespace-nowrap">描述</TableHead>
|
||||
{canManage && <TableHead className="min-w-[40px] whitespace-nowrap" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{tag.name}</span>
|
||||
{tag.is_system && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
系统
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[120px]">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
|
||||
{toZhSkillCategory(tag.category)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[96px] whitespace-nowrap">{tag.linked_user_count ?? 0}</TableCell>
|
||||
<TableCell className="min-w-[220px] text-muted-foreground">{tag.description || "-"}</TableCell>
|
||||
{canManage && <TableCell className="min-w-[40px]">{renderTagActions(tag)}</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{rows.map((tag) => (
|
||||
<article key={tag.id} className="rounded-lg border bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-semibold">{tag.name}</span>
|
||||
{tag.is_system && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
系统
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
|
||||
{toZhSkillCategory(tag.category)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && renderTagActions(tag)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">{tag.description || "暂无描述"}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">关联人数:{tag.linked_user_count ?? 0}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SkillTagDialog
|
||||
open={createOpen}
|
||||
title="新建技能标签"
|
||||
description="创建自定义技能标签"
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={(payload) => {
|
||||
if (!payload.name || !payload.slug) return;
|
||||
createMutation.mutate({
|
||||
name: payload.name,
|
||||
slug: payload.slug,
|
||||
category: payload.category,
|
||||
description: payload.description,
|
||||
});
|
||||
}}
|
||||
isPending={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<SkillTagDialog
|
||||
open={!!editRow}
|
||||
title="编辑技能标签"
|
||||
description={editRow?.is_system ? "系统标签仅允许修改分类" : "可修改名称、分类与描述"}
|
||||
onOpenChange={(open) => !open && setEditRow(null)}
|
||||
defaultValue={editRow || undefined}
|
||||
disableName={!!editRow?.is_system}
|
||||
onSubmit={(payload) => {
|
||||
if (!editRow) return;
|
||||
updateMutation.mutate({ tagId: editRow.id, payload });
|
||||
}}
|
||||
isPending={updateMutation.isPending}
|
||||
/>
|
||||
|
||||
<Dialog open={!!deleteRow} onOpenChange={(open) => !open && setDeleteRow(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>删除后将移除该标签及关联关系,无法恢复。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">标签:{deleteRow?.name}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteRow(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteRow && deleteMutation.mutate(deleteRow.id)}
|
||||
>
|
||||
{deleteMutation.isPending ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillTagDialog(props: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultValue?: SkillTag;
|
||||
disableName?: boolean;
|
||||
isPending?: boolean;
|
||||
onSubmit: (payload: { name?: string; slug?: string; category?: string; description?: string }) => void;
|
||||
}) {
|
||||
const { open, onOpenChange, title, description, defaultValue, disableName, isPending, onSubmit } = props;
|
||||
const [name, setName] = useState(defaultValue?.name || "");
|
||||
const [slug, setSlug] = useState(defaultValue?.slug || "");
|
||||
const [category, setCategory] = useState(defaultValue?.category || "dev");
|
||||
const [descriptionValue, setDescriptionValue] = useState(defaultValue?.description || "");
|
||||
|
||||
const isCreate = !defaultValue;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) {
|
||||
setName(defaultValue?.name || "");
|
||||
setSlug(defaultValue?.slug || "");
|
||||
setCategory(defaultValue?.category || "dev");
|
||||
setDescriptionValue(defaultValue?.description || "");
|
||||
}
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={disableName}
|
||||
placeholder="例如:前端框架"
|
||||
/>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">标识</label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} placeholder="例如:qian-duan-kuang-jia" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">分类</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{CATEGORY_OPTIONS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{toZhSkillCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Input
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!!isPending ||
|
||||
!category ||
|
||||
(!defaultValue && (!name.trim() || !slug.trim())) ||
|
||||
(!!defaultValue && !disableName && !name.trim())
|
||||
}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
name: disableName ? undefined : name.trim(),
|
||||
slug: isCreate ? slug.trim() : undefined,
|
||||
category: category.trim(),
|
||||
description: descriptionValue.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Files } from "lucide-react";
|
||||
|
||||
import { taskBriefApi } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function TaskBriefCenterPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("Signal Planner 增量任务包");
|
||||
const [templateId, setTemplateId] = useState<string>("");
|
||||
const [scopeSummary, setScopeSummary] = useState("");
|
||||
const [changedFilesText, setChangedFilesText] = useState("");
|
||||
|
||||
const centerQuery = useQuery({
|
||||
queryKey: ["task-brief-center"],
|
||||
queryFn: () => taskBriefApi.listCenter(),
|
||||
});
|
||||
const cards = centerQuery.data || [];
|
||||
|
||||
const templateQuery = useQuery({
|
||||
queryKey: ["task-brief-templates"],
|
||||
queryFn: () => taskBriefApi.listTemplates(),
|
||||
});
|
||||
const templates = templateQuery.data || [];
|
||||
|
||||
const changedFiles = useMemo(
|
||||
() =>
|
||||
changedFilesText
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
[changedFilesText]
|
||||
);
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
taskBriefApi.generate({
|
||||
title,
|
||||
template_id: templateId || undefined,
|
||||
changed_files: changedFiles,
|
||||
scope_summary: scopeSummary || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-center"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-runs"] });
|
||||
},
|
||||
});
|
||||
|
||||
const centerErrorText = centerQuery.error instanceof Error ? centerQuery.error.message : "请检查登录状态或后端服务。";
|
||||
const templateErrorText = templateQuery.error instanceof Error ? templateQuery.error.message : "模板读取失败";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="增量任务包中心"
|
||||
description="按模板生成任务包并追踪输出卡片。"
|
||||
icon={<Files className="h-5 w-5" />}
|
||||
tone="emerald"
|
||||
meta={<span>{centerQuery.isError ? "读取失败" : `当前卡片 ${cards.length}`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="task-brief-center"
|
||||
title="增量任务包中心"
|
||||
purpose="把一次需求改动整理成可执行任务包,减少团队沟通成本。"
|
||||
whenToUse="你准备开始一个新改动,或者要把需求交给 AI/同事继续执行时使用。"
|
||||
steps={[
|
||||
"先填写标题、选择模板,并输入本次范围摘要。",
|
||||
"把变更文件一行一个填入,点击“生成任务包”。",
|
||||
"在下方卡片区确认模板、状态和摘要是否符合预期。",
|
||||
]}
|
||||
outputs={[
|
||||
"新增一张任务包卡片",
|
||||
"对应一次可追溯的生成记录",
|
||||
"后续可在“任务包生成记录”里回看",
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">任务包生成器</CardTitle>
|
||||
<CardDescription>填写变更范围并一键生成任务包。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="任务包标题" />
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">默认模板</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{templateQuery.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">模板加载失败:{templateErrorText}</p>
|
||||
) : null}
|
||||
<Input
|
||||
value={scopeSummary}
|
||||
onChange={(e) => setScopeSummary(e.target.value)}
|
||||
placeholder="范围摘要(可选)"
|
||||
/>
|
||||
<Textarea
|
||||
rows={6}
|
||||
value={changedFilesText}
|
||||
onChange={(e) => setChangedFilesText(e.target.value)}
|
||||
placeholder={"每行一个变更文件,例如:\nbackend/app/services/signal_planner_service.py\nfrontend/src/pages/ModelRoutingConfigPage.tsx"}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">已解析 {changedFiles.length} 个文件路径</p>
|
||||
<Button
|
||||
onClick={() => generateMutation.mutate()}
|
||||
disabled={generateMutation.isPending || !title.trim()}
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
{generateMutation.isPending ? "生成中..." : "生成任务包"}
|
||||
</Button>
|
||||
</div>
|
||||
{generateMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
生成失败:{generateMutation.error instanceof Error ? generateMutation.error.message : "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">任务包卡片</CardTitle>
|
||||
<CardDescription>按更新时间倒序展示。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{centerQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : centerQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">任务包读取失败:{centerErrorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void centerQuery.refetch()}
|
||||
disabled={centerQuery.isFetching}
|
||||
>
|
||||
{centerQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无任务包,请先生成。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cards.map((card) => (
|
||||
<TableRow key={card.id} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{card.title}</TableCell>
|
||||
<TableCell>{card.status}</TableCell>
|
||||
<TableCell>{card.template_name || "-"}</TableCell>
|
||||
<TableCell className="max-w-[460px] truncate">{card.summary || "-"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(card.updated_at || card.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History } from "lucide-react";
|
||||
|
||||
import { taskBriefApi } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function TaskBriefRunsPage() {
|
||||
const runsQuery = useQuery({
|
||||
queryKey: ["task-brief-runs"],
|
||||
queryFn: () => taskBriefApi.listRuns(),
|
||||
});
|
||||
const runs = runsQuery.data || [];
|
||||
const errorText = runsQuery.error instanceof Error ? runsQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="任务包生成记录"
|
||||
description="跟踪任务包生成流水,快速回溯模板与范围。"
|
||||
icon={<History className="h-5 w-5" />}
|
||||
tone="slate"
|
||||
meta={<span>{runsQuery.isError ? "读取失败" : `记录总数 ${runs.length}`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="task-brief-runs"
|
||||
title="任务包生成记录"
|
||||
purpose="按时间回看每次任务包生成结果,做审计和复盘。"
|
||||
whenToUse="你要查“某次改动是按哪个模板生成的、范围是什么”时使用。"
|
||||
steps={[
|
||||
"先定位目标标题或生成时间。",
|
||||
"查看模板、状态、变更文件数和范围摘要。",
|
||||
"对异常记录回到模板页或任务包中心做修正。",
|
||||
]}
|
||||
outputs={[
|
||||
"可追溯的生成流水",
|
||||
"帮助定位错误模板或错误范围输入",
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">运行列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runsQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : runsQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">记录读取失败:{errorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void runsQuery.refetch()}
|
||||
disabled={runsQuery.isFetching}
|
||||
>
|
||||
{runsQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无生成记录。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>变更文件数</TableHead>
|
||||
<TableHead>范围摘要</TableHead>
|
||||
<TableHead>生成时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow key={run.id} className="hover:bg-slate-50/70 dark:hover:bg-slate-800/60">
|
||||
<TableCell className="font-medium">{run.title}</TableCell>
|
||||
<TableCell>{run.template_name || "-"}</TableCell>
|
||||
<TableCell>{run.status}</TableCell>
|
||||
<TableCell>{run.changed_files?.length || 0}</TableCell>
|
||||
<TableCell className="max-w-[420px] truncate">{run.scope_summary || "-"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(run.generated_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { LayoutTemplate } from "lucide-react";
|
||||
|
||||
import { taskBriefApi, type TaskBriefTemplate } from "@/api/taskBriefs";
|
||||
import { ConfigSubpageHeader } from "@/components/ai-config/ConfigSubpageHeader";
|
||||
import { FeatureIntroGuide } from "@/components/ai-config/FeatureIntroGuide";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { formatBeijingDateTime } from "@/lib/datetime";
|
||||
|
||||
export default function TaskBriefTemplatePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["task-brief-templates"],
|
||||
queryFn: () => taskBriefApi.listTemplates(),
|
||||
});
|
||||
const templates = templatesQuery.data || [];
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() => templates.find((item) => item.id === selectedId) || null,
|
||||
[templates, selectedId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return;
|
||||
setName(selectedTemplate.name || "");
|
||||
setDescription(selectedTemplate.description || "");
|
||||
setContent(selectedTemplate.content || "");
|
||||
}, [selectedTemplate]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: { name: string; description: string; content: string }) =>
|
||||
taskBriefApi.createTemplate({
|
||||
name: payload.name,
|
||||
description: payload.description || undefined,
|
||||
content: payload.content,
|
||||
}),
|
||||
onSuccess: (created: TaskBriefTemplate) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-templates"] });
|
||||
setSelectedId(created.id);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: { id: string; name: string; description: string; content: string }) =>
|
||||
taskBriefApi.updateTemplate(payload.id, {
|
||||
name: payload.name,
|
||||
description: payload.description || undefined,
|
||||
content: payload.content,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["task-brief-templates"] });
|
||||
},
|
||||
});
|
||||
|
||||
const templatesErrorText = templatesQuery.error instanceof Error ? templatesQuery.error.message : "请检查后端服务。";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfigSubpageHeader
|
||||
title="任务包模板管理"
|
||||
description="统一维护增量任务包模板,保障跨任务输出结构一致。"
|
||||
icon={<LayoutTemplate className="h-5 w-5" />}
|
||||
tone="violet"
|
||||
meta={<span>{templatesQuery.isError ? "模板读取失败" : `模板总数 ${templates.length}`}</span>}
|
||||
/>
|
||||
<FeatureIntroGuide
|
||||
featureId="task-brief-templates"
|
||||
title="任务包模板管理"
|
||||
purpose="维护 low/high 模型可复用的任务包模板,让输出结构稳定。"
|
||||
whenToUse="你想新增模板,或者发现当前模板不适合团队执行时使用。"
|
||||
steps={[
|
||||
"在左侧编辑器填写名称、描述和模板正文。",
|
||||
"新模板点“新建模板”;已有模板点“编辑”后修改并保存。",
|
||||
"在右侧列表确认更新时间与内容是否已生效。",
|
||||
]}
|
||||
outputs={[
|
||||
"模板库新增或更新",
|
||||
"后续生成任务包会直接使用最新模板",
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">模板编辑器</CardTitle>
|
||||
<CardDescription>支持新建与覆盖更新。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="模板名称" />
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="模板描述(可选)"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
rows={12}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="模板正文"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
disabled={createMutation.isPending || !name.trim() || !content.trim()}
|
||||
onClick={() => createMutation.mutate({ name, description, content })}
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建模板"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={updateMutation.isPending || !selectedId || !name.trim() || !content.trim()}
|
||||
onClick={() => updateMutation.mutate({ id: selectedId, name, description, content })}
|
||||
>
|
||||
{updateMutation.isPending ? "保存中..." : "保存当前模板"}
|
||||
</Button>
|
||||
</div>
|
||||
{createMutation.isError || updateMutation.isError ? (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">
|
||||
保存失败:
|
||||
{createMutation.error instanceof Error
|
||||
? createMutation.error.message
|
||||
: updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: "请重试"}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">模板列表</CardTitle>
|
||||
<CardDescription>点击“编辑”可回填到左侧编辑器。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{templatesQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">加载中...</p>
|
||||
) : templatesQuery.isError ? (
|
||||
<div className="rounded-xl border border-rose-200/70 bg-rose-50/80 p-4 dark:border-rose-900/40 dark:bg-rose-950/30">
|
||||
<p className="text-sm font-medium text-rose-700 dark:text-rose-200">模板读取失败:{templatesErrorText}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void templatesQuery.refetch()}
|
||||
disabled={templatesQuery.isFetching}
|
||||
>
|
||||
{templatesQuery.isFetching ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">暂无模板。</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200/90 dark:border-slate-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templates.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={
|
||||
item.id === selectedId
|
||||
? "bg-blue-50/80 dark:bg-blue-950/35"
|
||||
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/60"
|
||||
}
|
||||
>
|
||||
<TableCell className="font-medium">{item.name}</TableCell>
|
||||
<TableCell className="max-w-[320px] truncate">{item.description || "-"}</TableCell>
|
||||
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelectedId(item.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { getUser, getRoleLabel, getStatusLabel, type User } from "@/api/users";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import SkillRadarChart from "@/components/SkillRadarChart";
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { id } = useParams();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const userId = id ?? currentUser?.id ?? "";
|
||||
|
||||
const { data: profile, isLoading, error } = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: () => getUser(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const user: User | undefined = profile ?? (currentUser?.id === userId ? (currentUser as unknown as User) : undefined);
|
||||
const displayName = user?.name || user?.username || (currentUser?.name || currentUser?.username) || `用户 ${id}`;
|
||||
const isManager = isManagerRole(currentUser?.role);
|
||||
const initials = (displayName || "PM").slice(0, 2).toUpperCase();
|
||||
const topSkills = user?.top_skills?.filter(Boolean) ?? [];
|
||||
const projectCount = user?.project_ids?.length ?? 0;
|
||||
const domainCount = user?.assigned_domains?.length ?? 0;
|
||||
const workload = typeof user?.workload === "number" ? user.workload : 0;
|
||||
const workloadWidth = Math.min(Math.max(workload, 0), 100);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={isManager ? "/users" : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">个人中心</h1>
|
||||
<p className="text-muted-foreground">{displayName}</p>
|
||||
</div>
|
||||
<div className="sm:ml-auto">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/users/${userId}/skills`}>技能详情</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 2xl:grid-cols-[minmax(0,1.7fr)_360px]">
|
||||
{userId && (
|
||||
<SkillRadarChart
|
||||
userId={userId}
|
||||
userName={user?.name || user?.username}
|
||||
className="min-w-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="border-slate-200/80 shadow-sm">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-blue-600/10 text-lg font-semibold text-blue-600">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-xl">{displayName}</CardTitle>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">@{user?.username ?? "-"}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="rounded-full px-3 py-1 text-xs">
|
||||
{user?.status ? getStatusLabel(user.status) : "未同步"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">角色画像</p>
|
||||
<p className="mt-2 text-sm font-medium text-slate-900">{user?.role ? getRoleLabel(user.role) : "-"}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && !user ? (
|
||||
<p className="text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="text-destructive">加载失败</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">用户名</dt>
|
||||
<dd className="mt-2 font-medium text-slate-900">{user?.username ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">邮箱</dt>
|
||||
<dd className="mt-2 break-all font-medium text-slate-900">{user?.email ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">参与项目</dt>
|
||||
<dd className="mt-2 font-medium text-slate-900">{projectCount} 个</dd>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">负责域</dt>
|
||||
<dd className="mt-2 font-medium text-slate-900">{domainCount} 个</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">当前负载</p>
|
||||
<p className="text-xs text-muted-foreground">按用户工作量占比展示</p>
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-slate-900">{workload}%</span>
|
||||
</div>
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-600 transition-all"
|
||||
style={{ width: `${workloadWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-medium text-slate-900">核心技能标签</h3>
|
||||
<span className="text-xs text-muted-foreground">{topSkills.length} 项</span>
|
||||
</div>
|
||||
{topSkills.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topSkills.map((skill) => (
|
||||
<Badge
|
||||
key={skill}
|
||||
variant="outline"
|
||||
className="rounded-full border-slate-200 bg-slate-50 px-3 py-1 text-slate-700"
|
||||
>
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed border-slate-200 px-4 py-3 text-sm text-muted-foreground">
|
||||
暂无技能标签
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
import { useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { ArrowLeft, Download, Plus, RefreshCw } from "lucide-react";
|
||||
import { getUser, type UserRole } from "@/api/users";
|
||||
import {
|
||||
addUserSkill,
|
||||
getSkillTags,
|
||||
getUserSkillBindings,
|
||||
removeUserSkill,
|
||||
updateUserSkill,
|
||||
type UserSkillBinding,
|
||||
} from "@/api/skill";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { toZhSkillCategory, toZhSkillLevel } from "@/lib/display";
|
||||
|
||||
const LEVEL_OPTIONS = ["beginner", "intermediate", "advanced", "expert"] as const;
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
const detail = (error as AxiosError<{ detail?: string }>)?.response?.data?.detail;
|
||||
return detail || "请求失败";
|
||||
}
|
||||
|
||||
function roleLabel(role?: UserRole | string) {
|
||||
if (!role) return "-";
|
||||
const m: Record<string, string> = {
|
||||
BOSS: "负责人",
|
||||
ADMIN: "管理员",
|
||||
AI: "AI工程师",
|
||||
FE: "前端开发",
|
||||
BE: "后端开发",
|
||||
};
|
||||
return m[role] || role;
|
||||
}
|
||||
|
||||
function groupedByCategory(bindings: UserSkillBinding[]) {
|
||||
const map = new Map<string, UserSkillBinding[]>();
|
||||
for (const row of bindings) {
|
||||
const key = (row as UserSkillBinding & { category?: string }).category || "uncategorized";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function UserSkillsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const userId = id || "";
|
||||
const queryClient = useQueryClient();
|
||||
const chartWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [warningOpen, setWarningOpen] = useState(false);
|
||||
const [addSkillOpen, setAddSkillOpen] = useState(false);
|
||||
const [selectedSkillId, setSelectedSkillId] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
|
||||
const userQuery = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: () => getUser(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const bindingsQuery = useQuery({
|
||||
queryKey: ["user-skills", userId],
|
||||
queryFn: () => getUserSkillBindings(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: ["skill-tags-all"],
|
||||
queryFn: () => getSkillTags(),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: ({ skillId }: { skillId: string }) => removeUserSkill(userId, skillId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
toast({ title: "技能移除成功", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: `技能移除失败:${getErrorMessage(error)}`, variant: "error" }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
skillId,
|
||||
payload,
|
||||
}: {
|
||||
skillId: string;
|
||||
payload: {
|
||||
level?: string;
|
||||
proficiency?: number;
|
||||
quality_score?: number;
|
||||
efficiency_score?: number;
|
||||
};
|
||||
}) => updateUserSkill(userId, skillId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
toast({ title: "技能已更新", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: `技能更新失败:${getErrorMessage(error)}`, variant: "error" }),
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (payload: { skill_tag_id: string }) => addUserSkill(userId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
setAddSkillOpen(false);
|
||||
setSelectedSkillId("");
|
||||
toast({ title: "技能添加成功", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: `技能添加失败:${getErrorMessage(error)}`, variant: "error" }),
|
||||
});
|
||||
|
||||
const user = userQuery.data;
|
||||
const bindings = bindingsQuery.data ?? [];
|
||||
const grouped = useMemo(() => groupedByCategory(bindings), [bindings]);
|
||||
const isSelf = String(currentUser?.id || "") === String(userId);
|
||||
const canManageAll = isManagerRole(currentUser?.role);
|
||||
const canEditProficiency = canManageAll || isSelf;
|
||||
const canEditScores = canManageAll;
|
||||
const canAddRemove = canManageAll;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const mapped = bindings.map((row) => {
|
||||
const score = Math.round((Number(row.quality_score ?? 0) * 0.6 + Number(row.efficiency_score ?? 0) * 0.4) || row.proficiency);
|
||||
return {
|
||||
subject: row.skill_name,
|
||||
score: Math.max(0, Math.min(100, score)),
|
||||
category: (row as UserSkillBinding & { category?: string }).category || "uncategorized",
|
||||
};
|
||||
});
|
||||
if (selectedCategory === "all") return mapped;
|
||||
return mapped.filter((row) => row.category === selectedCategory);
|
||||
}, [bindings, selectedCategory]);
|
||||
|
||||
const availableSkillTags = useMemo(() => {
|
||||
const owned = new Set(bindings.map((b) => b.skill_tag_id));
|
||||
return (tagsQuery.data ?? []).filter((tag) => !owned.has(tag.id));
|
||||
}, [tagsQuery.data, bindings]);
|
||||
|
||||
function exportChart() {
|
||||
const root = chartWrapperRef.current;
|
||||
const svg = root?.querySelector("svg");
|
||||
if (!svg) return;
|
||||
const serializer = new XMLSerializer();
|
||||
const source = serializer.serializeToString(svg);
|
||||
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width || 1200;
|
||||
canvas.height = img.height || 800;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const pngUrl = canvas.toDataURL("image/png");
|
||||
const a = document.createElement("a");
|
||||
a.href = pngUrl;
|
||||
a.download = `user-${userId}-skills-radar.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
};
|
||||
img.src = svgUrl;
|
||||
}
|
||||
|
||||
if (userQuery.isLoading || bindingsQuery.isLoading) {
|
||||
return <p className="text-muted-foreground">加载中…</p>;
|
||||
}
|
||||
|
||||
if (userQuery.error || !user) {
|
||||
return <p className="text-destructive">用户不存在或无权限查看</p>;
|
||||
}
|
||||
if (bindingsQuery.error) {
|
||||
return <p className="text-destructive">技能数据加载失败</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/users/${userId}/profile`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.username} 技能详情</h1>
|
||||
<p className="text-muted-foreground">
|
||||
角色: {roleLabel(user.role)} · 项目数: {(user.project_ids ?? []).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canManageAll}
|
||||
onClick={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
|
||||
setWarningOpen(true);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新评分
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setBatchMode((v) => !v)}>
|
||||
{batchMode ? "退出批量编辑" : "批量编辑"}
|
||||
</Button>
|
||||
<Button disabled={!canAddRemove} onClick={() => setAddSkillOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加技能
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-5">
|
||||
<div className="space-y-4 xl:col-span-3">
|
||||
{bindings.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
当前暂无技能绑定
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{[...grouped.entries()].map(([category, items]) => (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{toZhSkillCategory(category)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{items.map((row) => (
|
||||
<div key={row.skill_tag_id} className="rounded-md border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{row.skill_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
最近使用: {row.last_used_at ? row.last_used_at.slice(0, 10) : "暂无"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canAddRemove || !!row.is_system || removeMutation.isPending}
|
||||
onClick={() => removeMutation.mutate({ skillId: row.skill_tag_id })}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={row.level}
|
||||
disabled={!canEditProficiency}
|
||||
onChange={(e) =>
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { level: e.target.value },
|
||||
})
|
||||
}
|
||||
>
|
||||
{LEVEL_OPTIONS.map((lv) => (
|
||||
<option key={lv} value={lv}>
|
||||
{toZhSkillLevel(lv)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={row.proficiency}
|
||||
disabled={!batchMode || !canEditProficiency}
|
||||
onBlur={(e) =>
|
||||
batchMode &&
|
||||
canEditProficiency &&
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { proficiency: Number(e.target.value || 0) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
defaultValue={row.quality_score ?? 0}
|
||||
disabled={!batchMode || !canEditScores}
|
||||
onBlur={(e) =>
|
||||
batchMode &&
|
||||
canEditScores &&
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { quality_score: Number(e.target.value || 0) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
defaultValue={row.efficiency_score ?? 0}
|
||||
disabled={!batchMode || !canEditScores}
|
||||
onBlur={(e) =>
|
||||
batchMode &&
|
||||
canEditScores &&
|
||||
updateMutation.mutate({
|
||||
skillId: row.skill_tag_id,
|
||||
payload: { efficiency_score: Number(e.target.value || 0) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <p className="text-sm text-muted-foreground">暂无技能绑定</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-base">技能雷达图</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">全部分类</option>
|
||||
{[...new Set(bindings.map((row) => (row as UserSkillBinding & { category?: string }).category || "uncategorized"))].map(
|
||||
(category) => (
|
||||
<option key={category} value={category}>
|
||||
{toZhSkillCategory(category)}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={exportChart}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出图表
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent ref={chartWrapperRef}>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex h-[420px] items-center justify-center text-sm text-muted-foreground">
|
||||
当前分类暂无可展示技能数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[420px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chartData}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="subject" />
|
||||
<PolarRadiusAxis domain={[0, 100]} />
|
||||
<Radar dataKey="score" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.28} />
|
||||
<Tooltip />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={warningOpen} onOpenChange={setWarningOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>提示</DialogTitle>
|
||||
<DialogDescription>手动分数编辑会在下次 AI 更新时被覆盖。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setWarningOpen(false)}>我知道了</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={addSkillOpen} onOpenChange={setAddSkillOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加技能</DialogTitle>
|
||||
<DialogDescription>选择一个未绑定技能并添加到当前用户。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={selectedSkillId}
|
||||
onChange={(e) => setSelectedSkillId(e.target.value)}
|
||||
>
|
||||
<option value="">请选择技能</option>
|
||||
{tagsQuery.error && <option value="">技能加载失败</option>}
|
||||
{availableSkillTags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name} ({toZhSkillCategory(tag.category || "uncategorized")})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddSkillOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedSkillId || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate({ skill_tag_id: selectedSkillId })}
|
||||
>
|
||||
{addMutation.isPending ? "添加中…" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,606 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Import,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
User as UserIcon,
|
||||
UserX,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
bulkAssignUsers,
|
||||
bulkDeleteUsers,
|
||||
deleteUser,
|
||||
getRoleLabel,
|
||||
getStatusLabel,
|
||||
getUsers,
|
||||
importUsers,
|
||||
patchUserStatus,
|
||||
resetUserPassword,
|
||||
type User,
|
||||
type UserRole,
|
||||
type UserStatus,
|
||||
} from "@/api/users";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RoleBadge } from "@/components/ui/RoleBadge";
|
||||
import { SkillBadge } from "@/components/ui/SkillBadge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { UserFormDialog } from "@/components/users/UserFormDialog";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import DeleteUserDialog from "./components/DeleteUserDialog";
|
||||
import ResetPasswordDialog from "./components/ResetPasswordDialog";
|
||||
import ImportUsersDialog from "./components/ImportUsersDialog";
|
||||
import BulkAssignDialog from "./components/BulkAssignDialog";
|
||||
import {
|
||||
MAX_PASSWORD_BYTES,
|
||||
ROLES,
|
||||
generateSafePassword,
|
||||
isBoss,
|
||||
isManager,
|
||||
isStalePasswordServerError,
|
||||
parseCsvPreview,
|
||||
passwordUtf8Bytes,
|
||||
resolvePasswordValidationMessage,
|
||||
workloadClass,
|
||||
type CsvPreview,
|
||||
} from "./helpers";
|
||||
|
||||
export default function UsersPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const canManage = isManager(user?.role);
|
||||
const canDelete = isBoss(user?.role);
|
||||
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | "">("");
|
||||
const [statusFilter, setStatusFilter] = useState<"" | UserStatus>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [workloadStateFilter, setWorkloadStateFilter] = useState<"" | "idle" | "normal" | "overloaded">("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
const [deleteUserState, setDeleteUserState] = useState<User | null>(null);
|
||||
const [resetPwdUser, setResetPwdUser] = useState<User | null>(null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [showResetPassword, setShowResetPassword] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importSummary, setImportSummary] = useState<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const [importPreview, setImportPreview] = useState<CsvPreview | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [bulkAssignOpen, setBulkAssignOpen] = useState(false);
|
||||
const [bulkRole, setBulkRole] = useState<UserRole | "">("");
|
||||
const [bulkProjectIds, setBulkProjectIds] = useState("");
|
||||
const resetPasswordBytes = passwordUtf8Bytes(newPassword);
|
||||
const resetPasswordTooLong = resetPasswordBytes > MAX_PASSWORD_BYTES;
|
||||
const resetPasswordTooShort = newPassword.length > 0 && newPassword.length < 6;
|
||||
const resetPasswordValidationMessage = resolvePasswordValidationMessage(
|
||||
resetPasswordTooShort,
|
||||
resetPasswordTooLong,
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: [
|
||||
"users",
|
||||
search,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
workloadStateFilter,
|
||||
],
|
||||
queryFn: () =>
|
||||
getUsers({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
search: search || undefined,
|
||||
role: roleFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
workload_state: workloadStateFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const users = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setDeleteUserState(null);
|
||||
toast({ title: "用户删除成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "删除用户失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => bulkDeleteUsers(ids),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setSelectedIds([]);
|
||||
toast({
|
||||
title: `批量删除完成:成功 ${res.deleted_count},跳过 ${res.skipped.length}`,
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: () => toast({ title: "批量删除失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const bulkAssignMutation = useMutation({
|
||||
mutationFn: (payload: { user_ids: string[]; role?: UserRole; project_ids?: number[] }) =>
|
||||
bulkAssignUsers(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setBulkAssignOpen(false);
|
||||
toast({ title: "批量分配成功", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "批量分配失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
||||
patchUserStatus(userId, isActive),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast({ title: "用户状态已更新", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "更新用户状态失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const resetPwdMutation = useMutation({
|
||||
mutationFn: ({ userId, password }: { userId: string; password: string }) =>
|
||||
resetUserPassword(userId, password),
|
||||
onSuccess: () => {
|
||||
setResetPwdUser(null);
|
||||
setNewPassword("");
|
||||
toast({ title: "密码重置成功", variant: "success" });
|
||||
},
|
||||
onError: (error) => toast({ title: getApiErrorMessage(error, "密码重置失败"), variant: "error" }),
|
||||
});
|
||||
const resetPasswordServerMessage = resetPwdMutation.error
|
||||
? getApiErrorMessage(resetPwdMutation.error, "密码重置失败")
|
||||
: "";
|
||||
const visibleResetPasswordError = isStalePasswordServerError(resetPasswordServerMessage, {
|
||||
passwordTooShort: resetPasswordTooShort,
|
||||
passwordTooLong: resetPasswordTooLong,
|
||||
})
|
||||
? ""
|
||||
: resetPasswordServerMessage;
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => importUsers(file),
|
||||
onSuccess: (res) => {
|
||||
setImportSummary({ total: res.total, success: res.success, failed: res.failed });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast({
|
||||
title: `导入完成:成功 ${res.success},失败 ${res.failed}`,
|
||||
variant: res.failed > 0 ? "default" : "success",
|
||||
});
|
||||
},
|
||||
onError: () => toast({ title: "导入失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const allSelected = users.length > 0 && users.every((u) => selectedIds.includes(u.id));
|
||||
|
||||
const closeResetPasswordDialog = () => {
|
||||
setResetPwdUser(null);
|
||||
setNewPassword("");
|
||||
setShowResetPassword(false);
|
||||
resetPwdMutation.reset();
|
||||
};
|
||||
|
||||
const handleImportFileChange = async (file: File | null) => {
|
||||
setImportFile(file);
|
||||
setImportSummary(null);
|
||||
if (!file) {
|
||||
setImportPreview(null);
|
||||
return;
|
||||
}
|
||||
if (file.name.toLowerCase().endsWith(".csv")) {
|
||||
const text = await file.text();
|
||||
setImportPreview(parseCsvPreview(text));
|
||||
return;
|
||||
}
|
||||
setImportPreview({ headers: ["提示"], rows: [["XLSX 预览需服务端解析,当前仅展示导入结果"]] });
|
||||
};
|
||||
|
||||
const renderedUsers = useMemo(() => users, [users]);
|
||||
const renderUserActions = (u: User) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditUser(u)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setResetPwdUser(u)}>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
重置密码
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
statusMutation.mutate({
|
||||
userId: u.id,
|
||||
isActive: !(u.is_active ?? true),
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
{(u.is_active ?? true) ? "禁用用户" : "启用用户"}
|
||||
</DropdownMenuItem>
|
||||
{canDelete && user?.id !== u.id && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteUserState(u)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">用户管理</h1>
|
||||
<p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => setCreateOpen(true)} className="hidden md:inline-flex">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
+ 新建用户
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Import className="mr-2 h-4 w-4" />
|
||||
导入
|
||||
</Button>
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
disabled={bulkDeleteMutation.isPending}
|
||||
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
批量删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-300 text-slate-700 hover:bg-slate-50"
|
||||
onClick={() => setBulkAssignOpen(true)}
|
||||
>
|
||||
批量分配
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
<CardDescription>共 {total} 人</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
|
||||
<Input
|
||||
placeholder="搜索用户名/姓名/邮箱"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full xl:max-w-[320px]"
|
||||
/>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter((e.target.value || "") as UserRole | "")}
|
||||
className="h-10 min-w-[132px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">全部角色</option>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{getRoleLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter((e.target.value || "") as "" | UserStatus)}
|
||||
className="h-10 min-w-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">在线</option>
|
||||
<option value="away">离开</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={workloadStateFilter}
|
||||
onChange={(e) =>
|
||||
setWorkloadStateFilter((e.target.value || "") as "" | "idle" | "normal" | "overloaded")
|
||||
}
|
||||
className="h-10 min-w-[144px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">全部负载</option>
|
||||
<option value="idle">闲置 (<70%)</option>
|
||||
<option value="normal">正常 (70-100%)</option>
|
||||
<option value="overloaded">过载 (>100%)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">{String(error)}</p>
|
||||
) : renderedUsers.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无用户</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[980px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) => {
|
||||
setSelectedIds(e.target.checked ? renderedUsers.map((u) => u.id) : []);
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">用户</TableHead>
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">角色</TableHead>
|
||||
<TableHead className="min-w-[60px] whitespace-nowrap">项目</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">核心技能(前3)</TableHead>
|
||||
<TableHead className="min-w-[80px] whitespace-nowrap">负载</TableHead>
|
||||
<TableHead className="min-w-[60px] whitespace-nowrap">状态</TableHead>
|
||||
{canManage && <TableHead className="min-w-[40px] whitespace-nowrap" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{renderedUsers.map((u) => {
|
||||
const ratio = u.workload_ratio ?? u.workload ?? 0;
|
||||
return (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(u.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedIds((prev) =>
|
||||
e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[160px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
|
||||
<UserIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link to={`/users/${u.id}/profile`} className="font-medium hover:underline">
|
||||
{u.name || u.username}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">{u.email || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[200px]">
|
||||
<RoleBadge roles={[String(u.role || "")]} className="max-w-[240px]" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[60px] whitespace-nowrap">{(u.project_ids ?? []).length}</TableCell>
|
||||
<TableCell className="min-w-[160px]">
|
||||
<SkillBadge skills={u.top_skills ?? []} max={3} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[80px] whitespace-nowrap">
|
||||
<span className={workloadClass(ratio)}>{ratio.toFixed(1)}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[60px] whitespace-nowrap">
|
||||
{getStatusLabel((u.status || "offline") as UserStatus)}
|
||||
</TableCell>
|
||||
{canManage && <TableCell className="min-w-[40px]">{renderUserActions(u)}</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{renderedUsers.map((u) => {
|
||||
const ratio = u.workload_ratio ?? u.workload ?? 0;
|
||||
return (
|
||||
<article key={u.id} className="rounded-lg border bg-white p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(u.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedIds((prev) =>
|
||||
e.target.checked ? [...prev, u.id] : prev.filter((id) => id !== u.id)
|
||||
);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Link to={`/users/${u.id}/profile`} className="block truncate text-base font-semibold">
|
||||
{u.name || u.username}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">{u.email || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-semibold ${workloadClass(ratio)}`}>{ratio.toFixed(1)}%</span>
|
||||
{canManage && renderUserActions(u)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<RoleBadge roles={[String(u.role || "")]} className="max-w-full" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<SkillBadge skills={u.top_skills ?? []} max={3} className="max-w-full" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">项目 {(u.project_ids ?? []).length}个</span>
|
||||
<span className="whitespace-nowrap">{getStatusLabel((u.status || "offline") as UserStatus)}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="fixed bottom-[136px] right-4 z-[119] h-11 rounded-full px-4 shadow-lg md:hidden"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
新建用户
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<UserFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
mode="create"
|
||||
onSuccess={() => {
|
||||
setCreateOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
}}
|
||||
/>
|
||||
|
||||
{editUser && (
|
||||
<UserFormDialog
|
||||
open={!!editUser}
|
||||
onOpenChange={(open) => !open && setEditUser(null)}
|
||||
mode="edit"
|
||||
user={editUser}
|
||||
onSuccess={() => {
|
||||
setEditUser(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteUserDialog
|
||||
open={!!deleteUserState}
|
||||
user={deleteUserState}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onCancel={() => setDeleteUserState(null)}
|
||||
onConfirm={() => deleteUserState && deleteMutation.mutate(deleteUserState.id)}
|
||||
/>
|
||||
|
||||
<ResetPasswordDialog
|
||||
open={!!resetPwdUser}
|
||||
user={resetPwdUser}
|
||||
newPassword={newPassword}
|
||||
onPasswordChange={(value) => {
|
||||
setNewPassword(value);
|
||||
resetPwdMutation.reset();
|
||||
}}
|
||||
showResetPassword={showResetPassword}
|
||||
setShowResetPassword={setShowResetPassword}
|
||||
resetPasswordBytes={resetPasswordBytes}
|
||||
resetPasswordValidationMessage={resetPasswordValidationMessage}
|
||||
visibleResetPasswordError={visibleResetPasswordError}
|
||||
isSubmitting={resetPwdMutation.isPending}
|
||||
isPasswordTooShort={resetPasswordTooShort}
|
||||
isPasswordTooLong={resetPasswordTooLong}
|
||||
onGenerateSafePassword={() => {
|
||||
setNewPassword(generateSafePassword());
|
||||
setShowResetPassword(true);
|
||||
resetPwdMutation.reset();
|
||||
}}
|
||||
onClose={closeResetPasswordDialog}
|
||||
onConfirm={() => {
|
||||
if (resetPasswordTooShort) {
|
||||
toast({ title: "密码至少需要 6 个字符", variant: "error" });
|
||||
return;
|
||||
}
|
||||
if (resetPasswordTooLong) {
|
||||
toast({ title: "密码最长支持 72 个 UTF-8 字节,请缩短后重试", variant: "error" });
|
||||
return;
|
||||
}
|
||||
if (resetPwdUser) {
|
||||
resetPwdMutation.mutate({ userId: resetPwdUser.id, password: newPassword });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImportUsersDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
importFile={importFile}
|
||||
importPreview={importPreview}
|
||||
importSummary={importSummary}
|
||||
isImporting={importMutation.isPending}
|
||||
onSelectFile={handleImportFileChange}
|
||||
onSubmit={() => importFile && importMutation.mutate(importFile)}
|
||||
/>
|
||||
|
||||
<BulkAssignDialog
|
||||
open={bulkAssignOpen}
|
||||
onOpenChange={setBulkAssignOpen}
|
||||
bulkRole={bulkRole}
|
||||
bulkProjectIds={bulkProjectIds}
|
||||
isSubmitting={bulkAssignMutation.isPending}
|
||||
onBulkRoleChange={setBulkRole}
|
||||
onBulkProjectIdsChange={setBulkProjectIds}
|
||||
onSubmit={() =>
|
||||
bulkAssignMutation.mutate({
|
||||
user_ids: selectedIds,
|
||||
role: bulkRole || undefined,
|
||||
project_ids: bulkProjectIds
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n > 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { getRoleLabel, type UserRole } from "@/api/users";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ROLES } from "../helpers";
|
||||
|
||||
interface BulkAssignDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bulkRole: UserRole | "";
|
||||
bulkProjectIds: string;
|
||||
isSubmitting: boolean;
|
||||
onBulkRoleChange: (role: UserRole | "") => void;
|
||||
onBulkProjectIdsChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function BulkAssignDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
bulkRole,
|
||||
bulkProjectIds,
|
||||
isSubmitting,
|
||||
onBulkRoleChange,
|
||||
onBulkProjectIdsChange,
|
||||
onSubmit,
|
||||
}: BulkAssignDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量分配项目/角色</DialogTitle>
|
||||
<DialogDescription>将操作应用到已选择用户。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={bulkRole}
|
||||
onChange={(e) => onBulkRoleChange((e.target.value || "") as UserRole | "")}
|
||||
>
|
||||
<option value="">不调整角色</option>
|
||||
{ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{getRoleLabel(role)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
placeholder="项目编号(逗号分隔,不填则不调整)"
|
||||
value={bulkProjectIds}
|
||||
onChange={(e) => onBulkProjectIdsChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isSubmitting || (!bulkRole && !bulkProjectIds.trim())} onClick={onSubmit}>
|
||||
{isSubmitting ? "提交中…" : "提交"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { User } from "@/api/users";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface DeleteUserDialogProps {
|
||||
open: boolean;
|
||||
user: User | null;
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteUserDialog({ open, user, isDeleting, onCancel, onConfirm }: DeleteUserDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>删除用户「{user?.name || user?.username}」后不可恢复。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={isDeleting} onClick={onConfirm}>
|
||||
{isDeleting ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { ChangeEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { CsvPreview } from "../helpers";
|
||||
|
||||
interface ImportSummary {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface ImportUsersDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
importFile: File | null;
|
||||
importPreview: CsvPreview | null;
|
||||
importSummary: ImportSummary | null;
|
||||
isImporting: boolean;
|
||||
onSelectFile: (file: File | null) => Promise<void>;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function ImportUsersDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
importFile,
|
||||
importPreview,
|
||||
importSummary,
|
||||
isImporting,
|
||||
onSelectFile,
|
||||
onSubmit,
|
||||
}: ImportUsersDialogProps) {
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
await onSelectFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入用户</DialogTitle>
|
||||
<DialogDescription>支持 CSV/XLSX,最多 50 行。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input type="file" accept=".csv,.xlsx,.xls" onChange={handleFileChange} />
|
||||
{importPreview && (
|
||||
<div className="max-h-40 overflow-auto rounded-md border p-2 text-xs">
|
||||
{importPreview.headers.length > 0 && <div className="mb-1 font-medium">{importPreview.headers.join(" | ")}</div>}
|
||||
{importPreview.rows.map((row, idx) => (
|
||||
<div key={idx} className="text-muted-foreground">
|
||||
{row.join(" | ")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{importSummary && (
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<p>总数: {importSummary.total}</p>
|
||||
<p>成功: {importSummary.success}</p>
|
||||
<p>失败: {importSummary.failed}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button disabled={!importFile || isImporting} onClick={onSubmit}>
|
||||
{isImporting ? "导入中…" : "开始导入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { User } from "@/api/users";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ResetPasswordDialogProps {
|
||||
open: boolean;
|
||||
user: User | null;
|
||||
newPassword: string;
|
||||
onPasswordChange: (value: string) => void;
|
||||
showResetPassword: boolean;
|
||||
setShowResetPassword: Dispatch<SetStateAction<boolean>>;
|
||||
resetPasswordBytes: number;
|
||||
resetPasswordValidationMessage: string | null;
|
||||
visibleResetPasswordError: string;
|
||||
isSubmitting: boolean;
|
||||
isPasswordTooShort: boolean;
|
||||
isPasswordTooLong: boolean;
|
||||
onGenerateSafePassword: () => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function ResetPasswordDialog({
|
||||
open,
|
||||
user,
|
||||
newPassword,
|
||||
onPasswordChange,
|
||||
showResetPassword,
|
||||
setShowResetPassword,
|
||||
resetPasswordBytes,
|
||||
resetPasswordValidationMessage,
|
||||
visibleResetPasswordError,
|
||||
isSubmitting,
|
||||
isPasswordTooShort,
|
||||
isPasswordTooLong,
|
||||
onGenerateSafePassword,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ResetPasswordDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>重置密码</DialogTitle>
|
||||
<DialogDescription>为 {user?.name || user?.username} 设置新密码。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">推荐使用系统生成的 14 位 ASCII 密码,避免长度边界问题。</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={onGenerateSafePassword}
|
||||
>
|
||||
生成安全密码
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showResetPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
className="pr-10"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
onClick={() => setShowResetPassword((v) => !v)}
|
||||
aria-label={showResetPassword ? "隐藏密码" : "显示密码"}
|
||||
>
|
||||
{showResetPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-xs ${resetPasswordValidationMessage ? "text-rose-600" : "text-muted-foreground"}`}>
|
||||
{resetPasswordValidationMessage || `至少 6 个字符,最长 72 个 UTF-8 字节。当前 ${resetPasswordBytes}/72 bytes`}
|
||||
</p>
|
||||
{visibleResetPasswordError && (
|
||||
<p className="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{visibleResetPasswordError}</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isSubmitting || isPasswordTooShort || isPasswordTooLong} onClick={onConfirm}>
|
||||
{isSubmitting ? "提交中…" : "确认"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { parseUserRoles, type UserRole } from "@/api/users";
|
||||
|
||||
export const ROLES: UserRole[] = ["BOSS", "ADMIN", "AI", "FE", "BE"];
|
||||
export const MAX_PASSWORD_BYTES = 72;
|
||||
|
||||
export interface CsvPreview {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export function isManager(role?: string): boolean {
|
||||
const roles = parseUserRoles(role);
|
||||
return roles.includes("BOSS") || roles.includes("ADMIN");
|
||||
}
|
||||
|
||||
export function isBoss(role?: string): boolean {
|
||||
return parseUserRoles(role).includes("BOSS");
|
||||
}
|
||||
|
||||
export function workloadClass(ratio: number): string {
|
||||
if (ratio > 100) return "text-destructive";
|
||||
if (ratio >= 70) return "text-amber-600 dark:text-amber-400";
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
}
|
||||
|
||||
export function parseCsvPreview(content: string): CsvPreview {
|
||||
const lines = content.split(/\r?\n/).filter((line) => line.trim());
|
||||
if (lines.length === 0) return { headers: [], rows: [] };
|
||||
const headers = lines[0].split(",").map((h) => h.trim());
|
||||
const rows = lines.slice(1, 11).map((line) => line.split(",").map((x) => x.trim()));
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
export function passwordUtf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
|
||||
export function generateSafePassword(length = 14): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
|
||||
const randomValues = new Uint32Array(length);
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(randomValues);
|
||||
} else {
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
randomValues[i] = Math.floor(Math.random() * chars.length);
|
||||
}
|
||||
}
|
||||
return Array.from(randomValues, (value) => chars[value % chars.length]).join("");
|
||||
}
|
||||
|
||||
export function resolvePasswordValidationMessage(passwordTooShort: boolean, passwordTooLong: boolean): string | null {
|
||||
if (passwordTooShort) return "密码至少需要 6 个字符";
|
||||
if (passwordTooLong) return "密码最长支持 72 个 UTF-8 字节,请缩短后重试";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isStalePasswordServerError(
|
||||
message: string,
|
||||
options: {
|
||||
passwordTooShort: boolean;
|
||||
passwordTooLong: boolean;
|
||||
},
|
||||
): boolean {
|
||||
const { passwordTooShort, passwordTooLong } = options;
|
||||
const normalized = String(message || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if ((normalized.includes("72 个 utf-8 字节") || normalized.includes("72 bytes")) && !passwordTooLong) {
|
||||
return true;
|
||||
}
|
||||
if ((normalized.includes("至少需要 6 个字符") || normalized.includes("至少 6 个字符")) && !passwordTooShort) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
getUserWorkload,
|
||||
getUserWorkloadTasks,
|
||||
getUserWorkloadTrend,
|
||||
updateUserWeeklyHours,
|
||||
} from "@/api/workloads";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { toZhPriority, toZhStatus } from "@/lib/display";
|
||||
|
||||
function ratioClass(value: number): string {
|
||||
if (value > 100) return "text-destructive";
|
||||
if (value >= 70) return "text-amber-600 dark:text-amber-400";
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
}
|
||||
|
||||
export default function WorkloadDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [days, setDays] = useState<7 | 30>(7);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [priorityFilter, setPriorityFilter] = useState("");
|
||||
const [adjustOpen, setAdjustOpen] = useState(false);
|
||||
const [maxWeeklyHours, setMaxWeeklyHours] = useState("40");
|
||||
const { toast } = useToast();
|
||||
|
||||
const userId = id ?? "";
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workloads", "detail", userId],
|
||||
queryFn: () => getUserWorkload(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: ["workloads", "trend", userId, days],
|
||||
queryFn: () => getUserWorkloadTrend(userId, days),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["workloads", "tasks", userId, statusFilter, priorityFilter],
|
||||
queryFn: () =>
|
||||
getUserWorkloadTasks(userId, {
|
||||
status: statusFilter || undefined,
|
||||
priority: priorityFilter || undefined,
|
||||
}),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const updateHoursMutation = useMutation({
|
||||
mutationFn: (hours: number) => updateUserWeeklyHours(userId, hours),
|
||||
onSuccess: () => {
|
||||
setAdjustOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads", "detail", userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads", "team"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads", "trend", userId] });
|
||||
toast({ title: "每周工时已更新", variant: "success" });
|
||||
},
|
||||
onError: () => toast({ title: "更新每周工时失败", variant: "error" }),
|
||||
});
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const trend = trendQuery.data?.items ?? [];
|
||||
const tasks = tasksQuery.data?.items ?? [];
|
||||
|
||||
const headerRatio = useMemo(() => detail?.workload_ratio ?? 0, [detail]);
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return <p className="text-muted-foreground">加载中…</p>;
|
||||
}
|
||||
if (detailQuery.error || !detail) {
|
||||
return <p className="text-destructive">加载失败或无权限访问该用户负载</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{detail.user_name} 的工作负载</h1>
|
||||
<p className="text-muted-foreground">{detail.username}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMaxWeeklyHours(String(detail.max_weekly_hours));
|
||||
setAdjustOpen(true);
|
||||
}}
|
||||
>
|
||||
调整每周工时
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">负载比例</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className={`text-3xl font-semibold ${ratioClass(headerRatio)}`}>
|
||||
{headerRatio.toFixed(1)}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">已分配工时</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{detail.current_assigned_hours.toFixed(1)}h</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">剩余工时</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{detail.remaining_hours.toFixed(1)}h</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>负载趋势</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant={days === 7 ? "default" : "outline"} size="sm" onClick={() => setDays(7)}>
|
||||
7天
|
||||
</Button>
|
||||
<Button variant={days === 30 ? "default" : "outline"} size="sm" onClick={() => setDays(30)}>
|
||||
30天
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendQuery.isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">趋势加载中…</p>
|
||||
) : trendQuery.error ? (
|
||||
<p className="py-8 text-center text-destructive">趋势加载失败</p>
|
||||
) : trend.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无趋势数据</p>
|
||||
) : (
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<ReferenceLine y={70} stroke="#f59e0b" strokeDasharray="4 4" />
|
||||
<ReferenceLine y={100} stroke="#ef4444" strokeDasharray="4 4" />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="workload_ratio"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>任务列表</CardTitle>
|
||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-auto"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="todo">待办</option>
|
||||
<option value="in_progress">进行中</option>
|
||||
<option value="done">已完成</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-auto"
|
||||
value={priorityFilter}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部优先级</option>
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="critical">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasksQuery.isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : tasksQuery.error ? (
|
||||
<p className="py-8 text-center text-destructive">任务加载失败</p>
|
||||
) : tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无任务</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>项目</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>工时</TableHead>
|
||||
<TableHead>到期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>{task.title}</TableCell>
|
||||
<TableCell>{task.project_id}</TableCell>
|
||||
<TableCell>{toZhPriority(task.priority)}</TableCell>
|
||||
<TableCell>{task.estimated_hours?.toFixed(1)}h</TableCell>
|
||||
<TableCell>{task.due_date ? task.due_date.slice(0, 10) : "-"}</TableCell>
|
||||
<TableCell>{toZhStatus(task.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={adjustOpen} onOpenChange={setAdjustOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>调整每周工时上限</DialogTitle>
|
||||
<DialogDescription>修改后会立即重算该成员负载比例。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={maxWeeklyHours}
|
||||
onChange={(e) => setMaxWeeklyHours(e.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAdjustOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={updateHoursMutation.isPending}
|
||||
onClick={() => updateHoursMutation.mutate(Number(maxWeeklyHours || 40))}
|
||||
>
|
||||
{updateHoursMutation.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import {
|
||||
applyAIOptimizePlan,
|
||||
getAIOptimizePreview,
|
||||
getTeamWorkloads,
|
||||
type AIOptimizeProposal,
|
||||
} from "@/api/workloads";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RoleBadge } from "@/components/ui/RoleBadge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { canManageTeamWorkload } from "@/lib/rbac";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
function ratioClass(value: number): string {
|
||||
if (value > 100) return "text-red-500";
|
||||
if (value >= 60) return "text-yellow-500";
|
||||
return "text-green-500";
|
||||
}
|
||||
|
||||
function progressClass(value: number): string {
|
||||
if (value > 100) return "bg-red-500";
|
||||
if (value >= 60) return "bg-yellow-400";
|
||||
return "bg-green-400";
|
||||
}
|
||||
|
||||
export default function WorkloadsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canOptimize = canManageTeamWorkload(user?.role);
|
||||
const { toast } = useToast();
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [range, setRange] = useState<"week" | "month" | "custom">("week");
|
||||
const [customFrom, setCustomFrom] = useState("");
|
||||
const [customTo, setCustomTo] = useState("");
|
||||
const [optimizeOpen, setOptimizeOpen] = useState(false);
|
||||
const [optimizeProposals, setOptimizeProposals] = useState<AIOptimizeProposal[]>([]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["workloads", "team", projectId],
|
||||
queryFn: () => getTeamWorkloads({ project_id: projectId ? Number(projectId) : undefined }),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const summary = data?.summary;
|
||||
const hasOverloaded = (summary?.overloaded_count ?? 0) > 0;
|
||||
const hasQueryError = !!error;
|
||||
|
||||
const exportRows = useMemo(
|
||||
() =>
|
||||
items.map((r) => ({
|
||||
member: r.user_name || r.username,
|
||||
role: String(r.role || ""),
|
||||
assigned_hours: r.current_assigned_hours,
|
||||
max_weekly_hours: r.max_weekly_hours,
|
||||
workload_ratio: r.workload_ratio,
|
||||
remaining_hours: r.remaining_hours,
|
||||
})),
|
||||
[items]
|
||||
);
|
||||
|
||||
function exportExcelLike() {
|
||||
const headers = ["成员", "角色", "已分配工时", "每周上限", "负载比例%", "剩余工时"];
|
||||
const lines = [
|
||||
headers.join("\t"),
|
||||
...exportRows.map((row) =>
|
||||
[
|
||||
row.member,
|
||||
row.role,
|
||||
row.assigned_hours.toFixed(1),
|
||||
row.max_weekly_hours.toFixed(1),
|
||||
row.workload_ratio.toFixed(1),
|
||||
row.remaining_hours.toFixed(1),
|
||||
].join("\t")
|
||||
),
|
||||
];
|
||||
const blob = new Blob([`\ufeff${lines.join("\n")}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `团队负载报告-${new Date().toISOString().slice(0, 10)}.xls`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportPdfLike() {
|
||||
const htmlRows = exportRows
|
||||
.map(
|
||||
(r) => `<tr><td>${r.member}</td><td>${r.role}</td><td>${r.assigned_hours.toFixed(1)}</td><td>${r.max_weekly_hours.toFixed(1)}</td><td>${r.workload_ratio.toFixed(1)}%</td><td>${r.remaining_hours.toFixed(1)}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
const win = window.open("", "_blank");
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head><title>团队负载报告</title></head>
|
||||
<body>
|
||||
<h2>团队负载报告</h2>
|
||||
<table border="1" cellspacing="0" cellpadding="6">
|
||||
<thead><tr><th>成员</th><th>角色</th><th>已分配工时</th><th>每周上限</th><th>负载比例%</th><th>剩余工时</th></tr></thead>
|
||||
<tbody>${htmlRows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
win.print();
|
||||
}
|
||||
|
||||
const optimizePreviewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
getAIOptimizePreview({
|
||||
project_id: projectId ? Number(projectId) : undefined,
|
||||
max_moves: 30,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setOptimizeProposals(res.proposals ?? []);
|
||||
setOptimizeOpen(true);
|
||||
toast({ title: `已生成 ${res.total ?? 0} 条优化建议`, variant: "success" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "生成优化预览失败", variant: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const optimizeApplyMutation = useMutation({
|
||||
mutationFn: (proposals: AIOptimizeProposal[]) => applyAIOptimizePlan(proposals),
|
||||
onSuccess: (res) => {
|
||||
setOptimizeOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["workloads"] });
|
||||
toast({ title: `已应用 ${res.applied_count} 条优化`, variant: "success" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "应用优化失败", variant: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">团队工作负载</h1>
|
||||
<p className="text-muted-foreground">查看成员负载比例、剩余工时与过载告警</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!canOptimize || optimizePreviewMutation.isPending}
|
||||
onClick={() => optimizePreviewMutation.mutate()}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
AI 优化任务分配
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl bg-gray-50 p-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">团队均值</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading || hasQueryError ? "--" : `${(summary?.avg_ratio ?? 0).toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">闲置</p>
|
||||
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.idle_count ?? 0}人`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">正常</p>
|
||||
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.normal_count ?? 0}人`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">过载</p>
|
||||
<p className="text-2xl font-bold text-red-500">
|
||||
{isLoading || hasQueryError ? "--" : `${summary?.overloaded_count ?? 0}人`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasOverloaded && (
|
||||
<div className="mb-4 flex h-9 items-center rounded-md border-l-4 border-orange-400 bg-orange-50 px-3 text-sm text-orange-700">
|
||||
<AlertTriangle className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span>{summary?.overloaded_count ?? 0}名成员负载超过100%,建议立即执行任务迁移</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQueryError && (
|
||||
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
负载数据请求失败,请稍后重试。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="w-full sm:w-auto">
|
||||
<Input
|
||||
className="w-full sm:w-40"
|
||||
placeholder="项目编号搜索"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
/>
|
||||
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<select
|
||||
className="h-10 min-w-[96px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={range}
|
||||
onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")}
|
||||
>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
{range === "custom" && (
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[168px]"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
className="w-[168px]"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||||
<Button variant="outline" onClick={exportExcelLike}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 Excel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={exportPdfLike}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>团队负载明细</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-destructive">加载失败:{String(error)}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无数据</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||||
<Table className="min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px] whitespace-nowrap">成员</TableHead>
|
||||
<TableHead className="min-w-[160px] whitespace-nowrap">主要角色</TableHead>
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">负载进度条</TableHead>
|
||||
<TableHead className="min-w-[90px] whitespace-nowrap text-right">比例</TableHead>
|
||||
<TableHead className="min-w-[80px] whitespace-nowrap">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.user_id} className={row.workload_ratio > 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}>
|
||||
<TableCell className="min-w-[140px]">{row.user_name || row.username}</TableCell>
|
||||
<TableCell className="min-w-[160px]">
|
||||
<RoleBadge roles={[row.role]} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[200px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[6px] w-[120px] overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className={`h-[6px] rounded-full ${progressClass(row.workload_ratio)}`}
|
||||
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600">{row.workload_ratio.toFixed(1)}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`min-w-[90px] whitespace-nowrap text-right font-semibold ${ratioClass(row.workload_ratio)}`}>
|
||||
{row.workload_ratio.toFixed(1)}%
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[80px] whitespace-nowrap">
|
||||
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
|
||||
查看详情
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="space-y-3 md:hidden">
|
||||
{items.map((row) => (
|
||||
<article
|
||||
key={row.user_id}
|
||||
className={`rounded-lg border bg-white p-4 ${row.workload_ratio > 100 ? "border-red-200 bg-red-50/30" : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-semibold">{row.user_name || row.username}</p>
|
||||
<div className="mt-2">
|
||||
<RoleBadge roles={[row.role]} className="max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${ratioClass(row.workload_ratio)}`}>
|
||||
{row.workload_ratio.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="h-[8px] w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className={`h-[8px] rounded-full ${progressClass(row.workload_ratio)}`}
|
||||
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">剩余工时 {(row.remaining_hours ?? 0).toFixed(1)}h</span>
|
||||
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
|
||||
查看详情
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={optimizeOpen} onOpenChange={setOptimizeOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>任务分配优化预览</DialogTitle>
|
||||
<DialogDescription>预览 旧负责人 → 新负责人,确认后批量应用。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[420px] overflow-auto rounded-md border">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>旧负责人</TableHead>
|
||||
<TableHead>新负责人</TableHead>
|
||||
<TableHead>旧负载%</TableHead>
|
||||
<TableHead>新负载%</TableHead>
|
||||
<TableHead>原因</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{optimizeProposals.map((row) => (
|
||||
<TableRow key={`${row.task_id}-${row.new_assignee_id}`}>
|
||||
<TableCell>{row.task_title}</TableCell>
|
||||
<TableCell>{row.old_assignee_id}</TableCell>
|
||||
<TableCell>{row.new_assignee_id}</TableCell>
|
||||
<TableCell>{row.old_ratio.toFixed(1)}</TableCell>
|
||||
<TableCell>{row.new_ratio.toFixed(1)}</TableCell>
|
||||
<TableCell>{row.reason}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{optimizeProposals.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
当前无可优化分配项
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOptimizeOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!optimizeProposals.length || optimizeApplyMutation.isPending}
|
||||
onClick={() => optimizeApplyMutation.mutate(optimizeProposals)}
|
||||
>
|
||||
{optimizeApplyMutation.isPending ? "应用中…" : "确认并应用"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue