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/
|
├── src/
|
||||||
│ ├── api/ # 接口封装
|
│ ├── api/ # 接口封装
|
||||||
│ ├── pages/ # 页面级组件
|
│ ├── 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/
|
│ ├── components/
|
||||||
│ │ ├── ui/ # 基础 UI 组件
|
│ │ ├── ui/ # 基础 UI 组件
|
||||||
│ │ ├── inbox/ # 消息台 Feed 列表组件
|
│ │ ├── inbox/ # 消息台 Feed 列表组件
|
||||||
|
|
@ -222,12 +246,44 @@ frontend/
|
||||||
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
|
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
|
||||||
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与双模型消费验证 API)
|
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与双模型消费验证 API)
|
||||||
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
|
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
|
||||||
- 新增:`frontend/src/pages/TaskBriefCenterPage.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/TaskBriefTemplatePage.tsx`(任务包模板管理页面)
|
- 新增:`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/TaskBriefRunsPage.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/ContractSyncBoardPage.tsx`(契约同步看板页面)
|
- 新增:`frontend/src/pages/user-profile/UserProfilePage.tsx`、`user-skills/UserSkillsPage.tsx`、`workload-detail/WorkloadDetailPage.tsx`、`workloads/WorkloadsPage.tsx`(用户与负载页面目录化收敛)
|
||||||
- 新增:`frontend/src/pages/ModelRoutingConfigPage.tsx`(模型路由配置页面)
|
- 更新:`frontend/src/pages/InboxPage.tsx`(降级为轻量入口,转发到 `pages/inbox/InboxPage`)
|
||||||
- 新增:`frontend/src/pages/KnowledgeGraphContextBoardPage.tsx`(知识图谱与双模型上下文看板)
|
- 新增:`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/pages/AIStewardPage.tsx`(移除 OpenClaw 指挥调度入口,新增本地 LLM 开关与严格失败边界说明)
|
||||||
- 移除:`frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx`(OpenClaw 指挥调度面板)
|
- 移除:`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";
|
export { default } from "./charter-detail/CharterDetailPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
export { default } from "./contract-sync-board/ContractSyncBoardPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
export { default } from "./dashboard/DashboardPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,471 +1 @@
|
||||||
import { useEffect, useState } from "react";
|
export { default } from "./feishu-config/FeishuConfigPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,450 +1 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
export { default } from "./inbox/InboxPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,303 +1 @@
|
||||||
import { useMemo } from "react";
|
export { default } from "./knowledge-graph-context-board/KnowledgeGraphContextBoardPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1 @@
|
||||||
import { useEffect, useState } from "react";
|
export { default } from "./login/LoginPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,237 +1 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
export { default } from "./model-routing-config/ModelRoutingConfigPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,498 +1 @@
|
||||||
import { useMemo, useState } from "react";
|
export { default } from "./projects/ProjectsPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,442 +1 @@
|
||||||
/**
|
export { default } from "./recipes/RecipesPage";
|
||||||
* 食谱页面 - 支持拍照识别热量
|
|
||||||
*/
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./scheduler-center/SchedulerCenterPage";
|
||||||
|
|
@ -1,137 +1 @@
|
||||||
import { Link, useParams } from "react-router-dom";
|
export { default } from "./skill-tasks/SkillTasksPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,425 +1 @@
|
||||||
import { useMemo, useState } from "react";
|
export { default } from "./skills/SkillsPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,192 +1 @@
|
||||||
import { useMemo, useState } from "react";
|
export { default } from "./task-brief-center/TaskBriefCenterPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
export { default } from "./task-brief-runs/TaskBriefRunsPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
export { default } from "./task-brief-template/TaskBriefTemplatePage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1 @@
|
||||||
import { useParams, Link } from "react-router-dom";
|
export { default } from "./user-profile/UserProfilePage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,448 +1 @@
|
||||||
import { useMemo, useRef, useState } from "react";
|
export { default } from "./user-skills/UserSkillsPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,792 +1 @@
|
||||||
import { useMemo, useState } from "react";
|
export { default } from "./users/UsersPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,289 +1 @@
|
||||||
import { useMemo, useState } from "react";
|
export { default } from "./workload-detail/WorkloadDetailPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,414 +1 @@
|
||||||
import { useMemo, useState } from "react";
|
export { default } from "./workloads/WorkloadsPage";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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