dev-manager-tauri/src/components/dashboard/ProjectModal.tsx
lanrtop 289b558278 refactor(github): GitHub 全面退役——免登录 + 发布/导入/同步链路移除 + 凭据去 GitHub 化
D1 免登录改造:App 启动直接进主界面,删 LoginPage/github.rs/OAuth 全套命令、
   Sidebar 账户区与 GitHub OAuth 设置弹窗、健康检查 GitHub Token 项
D2 移除发布/导入/PR/CI 入口:删 PublishModal/ImportRepoModal、ProjectCard 的
   PR compare 链接与 Actions 状态灯、RepoRegistryModal 同步 tab、CicdModal runs tab、
   publish.rs 的 github_create_repo/git_push_to_github
D3 spacesSync 单机化:删 spacesSync/useSpacesSync/SpacesPage/DiscoveryPanel,
   拆除 App→Dashboard→ProjectCard 的 spacesJson 链
D4 git_ops 凭据链重写:ssh agent → git credential helper → gitea_instances token
   前缀匹配 → default,不再依赖 github_token

保留:server_software GitHub 镜像(Releases 下载加速)、gitea_migrate 迁移入口、
cicd.rs(待 Gitea Actions 改造卡);DB 表不删(R05 migration 兼容)

顺带:修复 servers.rs 两个存量测试的 schema 漂移(测试建表缺 deploy_type 列);
      包含会话前未提交的 agent-infra F3(get_agent_health 健康诊断,与 lib.rs/
      commands.ts 物理耦合无法拆分提交)

验证:cargo test 53 passed / typecheck 全绿 / vitest passWithNoTests
2026-07-02 13:15:56 +09:00

408 lines
17 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 { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-dialog";
import { createProject, updateProject, getSpaces, getTags, getProjectTags, setProjectTags, type Project } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog";
const EMPTY: Partial<Project> = {
id: "", name: "", description: "", type: "", status: "active", priority: "mid",
wsl_path: "", win_path: "", tech_stack: "", tags: "", recent_focus: "", startup_commands: "",
platform: "windows",
};
const inputCls = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-400";
const labelCls = "block text-xs font-medium text-gray-600 mb-1";
const sectionCls = "text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3";
export function ProjectModal() {
const editTarget = useUIStore((s) => s.editTarget);
const closeEdit = useUIStore((s) => s.closeEdit);
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 [form, setForm] = useState<Partial<Project>>(EMPTY);
const [saving, setSaving] = useState(false);
const [tab, setTab] = useState<"info" | "deploy">("info");
const isNew = editTarget === null;
// 标签多选
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const { data: allTags = [] } = useQuery({ queryKey: ["tags"], queryFn: getTags });
useEffect(() => {
setForm(editTarget ?? EMPTY);
if (editTarget && editTarget.id) {
getProjectTags(editTarget.id).then((tags) => {
setSelectedTagIds(tags.map((t) => t.id));
}).catch(() => {});
} else {
setSelectedTagIds([]);
}
}, [editTarget]);
if (editTarget === undefined) return null;
const set = (key: keyof Project, value: string) =>
setForm((f) => ({ ...f, [key]: value }));
const pickFolder = async (field: "wsl_path" | "win_path") => {
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
// 直接存原始路径(WSL 路径为 \\wsl.localhost\distro\... 格式,Windows 文件管理器可直接访问)
set(field, selected as string);
};
// 新建项目顶部快速选择:选文件夹后自动填充路径、名称、ID
// WSL 路径直接存 UNC 格式(\\wsl.localhost\Ubuntu-22.04\home\...),不再转换为 Linux 路径
const quickPickFolder = async () => {
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
const raw = selected as string;
const isWsl = form.platform === "wsl";
// UNC 路径兼容提取文件夹名
const folderName = raw.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
setForm((f) => ({
...f,
[isWsl ? "wsl_path" : "win_path"]: raw,
name: folderName,
id: crypto.randomUUID(),
}));
};
const handleSave = async () => {
if (!form.name?.trim()) { showToast("❌ 项目名称不能为空"); return; }
if (isNew && !form.id?.trim()) { showToast("❌ 项目 ID 不能为空"); return; }
setSaving(true);
try {
if (isNew) {
await createProject({ ...form, space_id: currentSpace?.id } as Project);
// 新建时用前端生成的 workspace id(即 form.id)设置标签
if (form.id && selectedTagIds.length > 0) {
await setProjectTags(form.id, selectedTagIds).catch(() => {});
}
showToast("项目已添加 ✓");
} else {
await updateProject(editTarget!.id, form);
await setProjectTags(editTarget!.id, selectedTagIds);
showToast("已保存 ✓");
}
qc.invalidateQueries({ queryKey: ["projects"] });
qc.invalidateQueries({ queryKey: ["tagMaps"] });
closeEdit();
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setSaving(false);
}
};
return (
<Dialog title={isNew ? "添加项目" : "编辑项目"} onClose={closeEdit} size="lg" fixedHeight="82vh">
{/* ── 运行平台 + 项目文件夹(新建/编辑均显示)── */}
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 shrink-0">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
{isNew ? "选择项目文件夹" : "运行平台 / 项目路径"}
</p>
<div className="flex items-center gap-2">
{/* 运行平台 */}
<div className="flex gap-1.5 shrink-0">
{(["windows", "wsl"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setForm((f) => ({ ...f, platform: p }))}
className={`px-3 py-1.5 rounded-md text-xs border transition-colors ${
form.platform === p
? "border-blue-400 bg-blue-50 text-blue-700 font-medium"
: "border-gray-200 bg-white text-gray-500 hover:bg-gray-50"
}`}
>
{p === "windows" ? "Windows" : "WSL / Linux"}
</button>
))}
</div>
{/* 文件夹选择 / 路径输入 */}
<button
type="button"
onClick={isNew ? quickPickFolder : () => pickFolder(form.platform === "wsl" ? "wsl_path" : "win_path")}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-gray-300 text-sm text-gray-400 hover:border-blue-400 hover:text-blue-600 hover:bg-blue-50/40 transition-colors bg-white"
>
<span>📁</span>
<span className="truncate font-mono text-xs">
{(form.platform === "wsl" ? form.wsl_path : form.win_path) || "点击选择项目文件夹…"}
</span>
</button>
</div>
{isNew && (form.platform === "wsl" ? form.wsl_path : form.win_path) && (
<p className="text-xs text-green-600 mt-2">✓ 已自动填充项目名称与 ID,可在下方继续编辑</p>
)}
</div>
{/* ── 标签页导航 ── */}
<div className="border-b border-gray-200 px-6 flex gap-1 shrink-0">
{([
{ id: "info" as const, label: "基本信息" },
{ id: "deploy" as const, label: "部署信息" },
]).map(({ id, label }) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === id
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{label}
</button>
))}
</div>
{/* ── Tab 内容(可滚动区)── */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* ── Tab: 基本信息 ── */}
{tab === "info" && (
<div className="px-6 py-5 space-y-5">
{/* 项目标识 */}
<div>
<p className={sectionCls}>项目标识</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>项目 ID *</label>
<div className="flex gap-2">
<input
className={`${inputCls} font-mono flex-1`}
value={form.id ?? ""}
placeholder="my-project"
onChange={(e) => set("id", e.target.value)}
disabled={!isNew}
/>
{isNew && (
<button
type="button"
onClick={() => set("id", crypto.randomUUID())}
className="px-3 py-2 rounded-lg border border-gray-200 text-xs text-gray-500 hover:bg-gray-50 shrink-0"
title="自动生成 ID"
>
自动生成
</button>
)}
</div>
</div>
<div>
<label className={labelCls}>项目名称 *</label>
<input
className={inputCls}
value={form.name ?? ""}
placeholder="My Project"
onChange={(e) => set("name", e.target.value)}
/>
</div>
<div className="col-span-2">
<label className={labelCls}>描述</label>
<input
className={inputCls}
value={form.description ?? ""}
onChange={(e) => set("description", e.target.value)}
/>
</div>
</div>
</div>
{/* 分类与状态 */}
<div className="border-t border-gray-100 pt-4">
<p className={sectionCls}>分类与状态</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelCls}>类型</label>
<select className={inputCls} value={form.type ?? ""} onChange={(e) => set("type", e.target.value)}>
<option value="">— 不指定 —</option>
{["frontend","backend","fullstack","mobile","tool","library","infra"].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className={labelCls}>状态</label>
<select className={inputCls} value={form.status ?? "active"} onChange={(e) => set("status", e.target.value)}>
{[["active","进行中"],["maintenance","维护中"],["paused","暂停"],["archived","已归档"]].map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div>
<label className={labelCls}>优先级</label>
<select className={inputCls} value={form.priority ?? "mid"} onChange={(e) => set("priority", e.target.value)}>
{[["high","高"],["mid","中"],["low","低"]].map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div className="col-span-3">
<label className={labelCls}>技术栈(逗号分隔)</label>
<input
className={inputCls}
value={form.tech_stack ?? ""}
placeholder="React, TypeScript, Rust"
onChange={(e) => set("tech_stack", e.target.value)}
/>
</div>
<div className="col-span-3">
<label className={labelCls}>分类标签</label>
<div className="flex flex-wrap gap-1.5 min-h-[36px]">
{allTags.length === 0 && (
<p className="text-xs text-gray-300">请先在「管理-标签」里创建标签</p>
)}
{allTags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => setSelectedTagIds((prev) =>
selected ? prev.filter((id) => id !== tag.id) : [...prev, tag.id]
)}
className={`px-2.5 py-1 rounded-full text-xs font-medium border-2 transition-all ${
selected ? "text-white border-transparent" : "bg-white border-gray-200 text-gray-500"
}`}
style={selected ? { backgroundColor: tag.color, borderColor: tag.color } : {}}
>
{tag.name}
</button>
);
})}
</div>
</div>
</div>
</div>
{/* 工作区配置 */}
<div className="border-t border-gray-100 pt-4">
<p className={sectionCls}>工作区配置</p>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelCls}>当前聚焦</label>
<input
className={inputCls}
value={form.recent_focus ?? ""}
placeholder="正在开发登录模块"
onChange={(e) => set("recent_focus", e.target.value)}
/>
</div>
<div className="col-span-2">
<label className={labelCls}>启动命令</label>
<textarea
className={`${inputCls} font-mono resize-none`}
rows={2}
value={form.startup_commands ?? ""}
placeholder="pnpm dev"
onChange={(e) => set("startup_commands", e.target.value)}
/>
</div>
</div>
</div>
</div>
)}
{/* ── Tab: 部署信息 ── */}
{tab === "deploy" && (
<div className="px-6 py-5 space-y-5">
{/* 访问地址 */}
<div>
<p className={sectionCls}>访问地址</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>生产环境 URL</label>
<input
className={inputCls}
value={form.prod_url ?? ""}
placeholder="https://example.com"
onChange={(e) => set("prod_url", e.target.value)}
/>
</div>
<div>
<label className={labelCls}>测试 / 预发环境 URL</label>
<input
className={inputCls}
value={form.staging_url ?? ""}
placeholder="https://staging.example.com"
onChange={(e) => set("staging_url", e.target.value)}
/>
</div>
<div className="col-span-2">
<label className={labelCls}>服务器备注</label>
<input
className={inputCls}
value={form.server_note ?? ""}
placeholder="阿里云 ECS / SSH: root@1.2.3.4"
onChange={(e) => set("server_note", e.target.value)}
/>
</div>
</div>
</div>
{/* Git 仓库 */}
<div className="border-t border-gray-100 pt-4">
<p className={sectionCls}>Git 仓库</p>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelCls}>仓库地址</label>
<input
className={`${inputCls} font-mono`}
value={form.repo_url ?? ""}
placeholder="https://github.com/user/repo.git"
onChange={(e) => set("repo_url", e.target.value)}
/>
</div>
<div className="col-span-2">
<label className={labelCls}>公司上游仓库地址 <span className="text-gray-400 font-normal">(可选)</span></label>
<input
className={`${inputCls} font-mono`}
value={form.upstream_url ?? ""}
placeholder="https://github.com/company/repo.git"
onChange={(e) => set("upstream_url", e.target.value)}
/>
</div>
<div>
<label className={labelCls}>默认分支</label>
<input
className={`${inputCls} font-mono`}
value={form.default_branch ?? ""}
placeholder="main"
onChange={(e) => set("default_branch", e.target.value)}
/>
</div>
</div>
</div>
</div>
)}
</div>{/* end scrollable area */}
{/* ── 底部操作(固定)── */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 shrink-0">
<button onClick={closeEdit} 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}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中…" : "保存"}
</button>
</div>
</Dialog>
);
}