dev-manager-tauri/src/components/layout/Sidebar.tsx
lanrtop bc07dd41bf feat(ui): 功能区重构阶段一——六组导航 + 资源/工具一级页 + 卡片操作分层
信息架构按三公理重排(名实一致/层级对齐/频率分层,设计见 ui-restructure 模块):
- Sidebar 六组:总览/项目/资源/洞察/工具/设置
- 新增资源页(服务器/软件仓库/云产品/API密钥/源码库 5 tab),从"项目管理"
  拆出寄生的资源类 tabs;新增工具页(开发环境/WSL代理 合并)
- Dashboard 看板只留产品组/独立项目;洞察页阶段一暂挂健康中心(N3 扩充)
- ProjectCard 底部 10 个平铺操作收敛为:🗺️蓝图/🎓导师/编辑/动态 + ⋯更多下拉
  (带文字标签收纳 Claude配置/备忘/服务器/CICD/环境扫描/工具管理)

零后端变更:纯组件挂载点迁移,面板组件与 queryKey 原样复用。
阶段二(N3 洞察页扩充 / N4 Gitea 实例迁设置 / N5 仓库注册表归位)已拆卡待启动。
2026-07-02 14:33:55 +09:00

262 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSpaces, setCurrentSpace, createSpace, updateSpace, deleteSpace,
type Space,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { RepoRegistryModal } from "../dashboard/RepoRegistryModal";
export type Page = "overview" | "dashboard" | "resources" | "insight" | "tools" | "settings";
const NAV_ITEMS: { id: Page; label: string }[] = [
{ id: "overview", label: "总览" },
{ id: "dashboard", label: "项目" },
{ id: "resources", label: "资源" },
{ id: "insight", label: "洞察" },
{ id: "tools", label: "工具" },
{ id: "settings", label: "设置" },
];
interface Props {
page: Page;
onPageChange: (page: Page) => void;
}
export function Sidebar({ page, onPageChange }: Props) {
const [spaceOpen, setSpaceOpen] = useState(true);
const [showForm, setShowForm] = useState<Space | null | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<Space | null>(null);
const [showRepoRegistry, setShowRepoRegistry] = useState(false);
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const { data: spaces = [], refetch } = useQuery({
queryKey: ["spaces"],
queryFn: getSpaces,
});
const currentSpace = spaces.find((s) => s.is_current);
const handleSwitch = async (id: string) => {
await setCurrentSpace(id);
refetch();
qc.invalidateQueries({ queryKey: ["projects"] });
};
const handleDelete = async (space: Space) => {
await deleteSpace(space.id);
refetch();
showToast("已删除");
setDeleteTarget(null);
};
return (
<>
<aside className="w-48 shrink-0 bg-slate-900 border-r border-slate-700 flex flex-col">
{/* Logo */}
<div className="h-14 flex items-center px-4 border-b border-slate-700 shrink-0">
<span className="font-bold text-white text-sm tracking-tight"></span>
</div>
{/* 导航区(随当前空间) */}
<nav className="px-2 pt-3 pb-2 space-y-0.5 shrink-0">
{NAV_ITEMS.map(({ id, label }) => (
<button
key={id}
onClick={() => onPageChange(id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
page === id
? "bg-slate-700 text-white font-medium"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
}`}
>
{label}
</button>
))}
</nav>
{/* 弹性留白 */}
<div className="flex-1" />
{/* 空间下拉列表 */}
<div className="border-t border-slate-700">
{/* 标题行 */}
<button
onClick={() => setSpaceOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-slate-400 uppercase tracking-wider hover:text-slate-200 transition-colors"
>
<span></span>
<div className="flex items-center gap-1.5">
<span
onClick={(e) => { e.stopPropagation(); setShowForm(null); }}
className="w-4 h-4 flex items-center justify-center rounded text-slate-500 hover:text-white hover:bg-slate-700 transition-colors leading-none cursor-pointer"
title="新建空间"
>
+
</span>
<svg
className={`w-3 h-3 transition-transform ${spaceOpen ? "rotate-180" : ""}`}
viewBox="0 0 16 16" fill="currentColor"
>
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
</svg>
</div>
</button>
{/* 空间列表 */}
{spaceOpen && (
<div className="px-2 pb-2 space-y-0.5">
{spaces.length === 0 && (
<p className="text-xs text-slate-500 px-2 py-2 text-center"> + </p>
)}
{spaces.map((s) => {
const isCurrent = s.id === currentSpace?.id;
return (
<div
key={s.id}
onClick={() => !isCurrent && handleSwitch(s.id)}
className={`group flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors ${
isCurrent
? "bg-slate-700 text-white"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200 cursor-pointer"
}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${isCurrent ? "bg-blue-400" : "bg-transparent"}`} />
<span className="flex-1 text-xs truncate">{s.name}</span>
{/* UD 操作 */}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={(e) => { e.stopPropagation(); setShowForm(s); }}
className="p-0.5 rounded hover:bg-slate-600 text-slate-400 hover:text-white"
title="编辑"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81 3.22 11.34a.25.25 0 00-.064.108l-.65 2.278 2.278-.65a.25.25 0 00.108-.065L11.19 6.25z"/>
</svg>
</button>
{!isCurrent && (
<button
onClick={(e) => { e.stopPropagation(); setDeleteTarget(s); }}
className="p-0.5 rounded hover:bg-red-900 text-slate-400 hover:text-red-400"
title="删除"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 1.75V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 10-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"/>
</svg>
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 仓库管理 */}
<div className="border-t border-slate-700 px-3 py-2 shrink-0">
<button
onClick={() => setShowRepoRegistry(true)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
>
<svg className="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z"/>
</svg>
<span></span>
</button>
</div>
</aside>
{/* 新建/编辑模态框 */}
{showForm !== undefined && (
<SpaceFormModal
space={showForm}
onClose={() => setShowForm(undefined)}
onSaved={() => {
setShowForm(undefined);
refetch();
showToast(showForm ? "已更新" : "已创建");
}}
/>
)}
{/* 仓库管理弹窗 */}
{showRepoRegistry && (
<RepoRegistryModal onClose={() => setShowRepoRegistry(false)} />
)}
{/* 删除确认 */}
{deleteTarget && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-80 p-6 space-y-4">
<h2 className="text-base font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-600">
<span className="font-medium">{deleteTarget.name}</span>
</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteTarget(null)} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"></button>
<button onClick={() => handleDelete(deleteTarget)} className="px-4 py-2 rounded-lg text-sm bg-red-600 text-white hover:bg-red-700"></button>
</div>
</div>
</div>
)}
</>
);
}
function SpaceFormModal({ space, onClose, onSaved }: {
space: Space | null;
onClose: () => void;
onSaved: () => void;
}) {
const [name, setName] = useState(space?.name ?? "");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
if (space) {
await updateSpace(space.id, name.trim());
} else {
await createSpace(name.trim());
}
onSaved();
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-80 p-6 space-y-4">
<h2 className="text-base font-semibold text-gray-800">{space ? "编辑空间" : "新建空间"}</h2>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="笔记本 / 日本服务器 / 公司台式机"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"></button>
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
);
}