dev-manager-tauri/src/components/dashboard/BatchAddModal.tsx
lanrtop 0851aa9783 feat(projects/parity): register_project 控制面投影,登记本体拒绝同目录重复登记(P8f)
- projects:从 create_project 抽出登记本体 register_project_records(连接注入),
  UI create_project 与 MCP register_project 共用;只写数据库,不建目录、不执行 git init、不接入。
  拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID;
  重复检查与插入放进同一 IMMEDIATE 事务;workspace 插入去掉 OR IGNORE,ID 冲突不再静默成功
- 同目录判定 registration_path_key:去长路径前缀、斜杠统一、合并重复分隔符、去尾部分隔符、
  不区分大小写;win_path 与 wsl_path 两列都比较,WSL Linux 路径按 wsl.localhost 换算;
  错误信息给出已有 project_id(根治 PR #34 审查 L1/L2:同目录两条登记互相抢占 Codex 托管块)
- mcp_tools:新增 register_project(path 必填、name 可选,project_id 由炼境生成 UUID,归入当前空间,
  structuredContent 返回);只开放在全局路由,is_project_scoped_tool 显式排除;描述写明须用户确认目标
- 批量添加对已登记目录计入 skipped,模板新建与挂载仓库复用同一重复目录检查
- project-context.yaml「MCP 工具接口」段同步(T1)

cargo test 335 passed / 0 failed / 5 ignored(新增 10 条),0 warning;pnpm typecheck 通过;
9 处变异全部被对应用例捕获。

Feature-Confirmed: true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:29:26 +09:00

293 lines
12 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 { open } from "@tauri-apps/plugin-dialog";
import { batchCreateProjects, getSpaces, scanSubfolders, type SubFolder } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog";
// Windows 路径转 WSL 路径(仅 /mnt/x/... 形式,不处理 UNC)
function winToWsl(p: string): string {
const norm = p.replace(/\\/g, "/");
const m = norm.match(/^([a-zA-Z]):\/(.*)$/);
return m ? `/mnt/${m[1].toLowerCase()}/${m[2]}` : norm;
}
type PathMode = "win" | "wsl_convert" | "wsl_manual";
interface FolderItem extends SubFolder {
checked: boolean;
customId: string; // 可编辑的 id
customName: string; // 可编辑的显示名
}
export function BatchAddModal({ onClose }: { onClose: () => void }) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
const currentSpace = spaces.find((s) => s.is_current);
const [parentPath, setParentPath] = useState("");
const [pathMode, setPathMode] = useState<PathMode>("win");
const [items, setItems] = useState<FolderItem[]>([]);
const [scanning, setScanning] = useState(false);
const [saving, setSaving] = useState(false);
// 公共字段
const [commonStatus, setCommonStatus] = useState("active");
const [commonPriority, setCommonPriority] = useState("mid");
const pickFolder = async () => {
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
setParentPath(selected as string);
setItems([]);
};
const scan = async () => {
if (!parentPath.trim()) { showToast("❌ 请先选择父目录"); return; }
setScanning(true);
try {
const folders = await scanSubfolders(parentPath.trim());
setItems(folders.map((f) => ({
...f,
checked: true,
customId: f.name.toLowerCase().replace(/[^a-z0-9_-]/g, "-"),
customName: f.name,
})));
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setScanning(false);
}
};
const toggleAll = (val: boolean) =>
setItems((prev) => prev.map((i) => ({ ...i, checked: val })));
const update = (idx: number, patch: Partial<FolderItem>) =>
setItems((prev) => prev.map((item, i) => i === idx ? { ...item, ...patch } : item));
const handleSave = async () => {
const selected = items.filter((i) => i.checked);
if (selected.length === 0) { showToast("❌ 请至少勾选一个文件夹"); return; }
// 检查 id 是否有重复
const ids = selected.map((i) => i.customId.trim());
const dupId = ids.find((id, idx) => ids.indexOf(id) !== idx);
if (dupId) { showToast(`❌ ID 重复:${dupId}`); return; }
if (ids.some((id) => !id)) { showToast("❌ 存在空 ID,请检查"); return; }
setSaving(true);
try {
const payload = selected.map((item) => {
const winPath = item.path;
const wslPath = pathMode === "wsl_convert" ? winToWsl(item.path) : undefined;
return {
id: item.customId.trim(),
name: item.customName.trim() || item.name,
win_path: pathMode === "win" ? winPath : undefined,
wsl_path: pathMode === "wsl_convert" ? wslPath : undefined,
status: commonStatus,
priority: commonPriority,
space_id: currentSpace?.id,
platform: pathMode === "wsl_convert" ? "wsl" : "windows",
};
});
const skipped = await batchCreateProjects(payload);
qc.invalidateQueries({ queryKey: ["projects"] });
if (skipped.length > 0) {
showToast(`已添加 ${selected.length - skipped.length} 个,跳过 ${skipped.length} 个(ID 已存在或目录已登记)`);
} else {
showToast(`成功添加 ${selected.length} 个项目 ✓`);
}
onClose();
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setSaving(false);
}
};
const checkedCount = items.filter((i) => i.checked).length;
return (
<Dialog title="批量添加项目" onClose={onClose} size="lg">
<div className="px-6 py-4 space-y-4">
{/* Step 1: 选目录 */}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">① 选择父目录</p>
<div className="flex gap-2">
<input
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200"
value={parentPath}
onChange={(e) => { setParentPath(e.target.value); setItems([]); }}
placeholder="C:\projects 或手动输入"
/>
<button
onClick={pickFolder}
className="px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 shrink-0"
>
📁 浏览
</button>
<button
onClick={scan}
disabled={scanning || !parentPath.trim()}
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50 shrink-0"
>
{scanning ? "扫描中…" : "扫描"}
</button>
</div>
</div>
{/* Step 2: 路径模式 */}
{items.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">② 路径存储方式</p>
<div className="flex gap-2 flex-wrap">
{([
["win", "仅 Windows 路径"],
["wsl_convert", "仅 WSL 路径(自动转换)"],
] as [PathMode, string][]).map(([val, label]) => (
<button
key={val}
onClick={() => setPathMode(val)}
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
pathMode === val
? "bg-blue-600 text-white border-blue-600"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{label}
</button>
))}
</div>
{pathMode === "wsl_convert" && (
<p className="text-xs text-gray-400 mt-1">
示例:<code className="bg-gray-100 px-1 rounded">C:\projects\app</code> → <code className="bg-gray-100 px-1 rounded">/mnt/c/projects/app</code>
</p>
)}
</div>
)}
{/* Step 3: 公共字段 */}
{items.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">③ 公共属性</p>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">状态</label>
<select
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
value={commonStatus}
onChange={(e) => setCommonStatus(e.target.value)}
>
{[["active","进行中"],["maintenance","维护中"],["paused","暂停"],["archived","已归档"]].map(([v,l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">优先级</label>
<select
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
value={commonPriority}
onChange={(e) => setCommonPriority(e.target.value)}
>
{[["high","高"],["mid","中"],["low","低"]].map(([v,l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
</div>
</div>
)}
{/* Step 4: 文件夹列表 */}
{items.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
④ 选择文件夹
<span className="ml-2 font-normal text-gray-400 normal-case">
已选 {checkedCount} / {items.length}
</span>
</p>
<div className="flex gap-2">
<button onClick={() => toggleAll(true)} className="text-xs text-blue-600 hover:underline">全选</button>
<button onClick={() => toggleAll(false)} className="text-xs text-gray-400 hover:underline">全不选</button>
</div>
</div>
<div className="border border-gray-100 rounded-lg overflow-hidden max-h-64 overflow-y-auto">
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-500 sticky top-0">
<tr>
<th className="w-8 px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-left font-medium">项目 ID</th>
<th className="px-3 py-2 text-left font-medium">显示名称</th>
<th className="px-3 py-2 text-left font-medium text-gray-400">文件夹名</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{items.map((item, idx) => (
<tr key={item.path} className={item.checked ? "bg-white" : "bg-gray-50 opacity-50"}>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={item.checked}
onChange={(e) => update(idx, { checked: e.target.checked })}
className="accent-blue-600 cursor-pointer"
/>
</td>
<td className="px-2 py-1.5">
<input
className="w-full border border-gray-200 rounded px-2 py-1 font-mono text-xs outline-none focus:ring-1 focus:ring-blue-200 disabled:bg-gray-50 disabled:text-gray-400"
value={item.customId}
disabled={!item.checked}
onChange={(e) => update(idx, { customId: e.target.value })}
/>
</td>
<td className="px-2 py-1.5">
<input
className="w-full border border-gray-200 rounded px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-blue-200 disabled:bg-gray-50 disabled:text-gray-400"
value={item.customName}
disabled={!item.checked}
onChange={(e) => update(idx, { customName: e.target.value })}
/>
</td>
<td className="px-3 py-1.5 text-gray-400 font-mono">{item.name}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{items.length === 0 && !scanning && parentPath && (
<div className="text-center py-6 text-gray-400 text-sm">
点击「扫描」加载文件夹列表
</div>
)}
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100">
<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={saving || checkedCount === 0}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "添加中…" : `添加 ${checkedCount} 个项目`}
</button>
</div>
</Dialog>
);
}