dev-manager-tauri/src/components/dashboard/BatchAddModal.tsx
lanrtop 0baa304fbe feat: WSL路径支持、项目发布到GitHub、三灯状态指示
- 新增发布到GitHub功能(scan/init/commit/push/register全流程)
- 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式
- 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用)
- ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示
- ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换
- 添加项目支持顶部快速选择文件夹并自动填充ID和名称
- 设置页WSL区块改为下拉检测选择发行版
- Dialog组件支持fixedHeight固定高度模式
2026-04-01 01:36:21 +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>
);
}