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