feat: 仪表盘全局快启栏

新增 global_tools 表及 CRUD 命令,Dashboard 顶部增加独立的「快启」工具栏,
可配置与项目无关的日常应用。ProjectToolsModal 重构为通用组件,支持注入
自定义 commands / queryKey;ProjectCard 快启行始终显示并内联添加按钮。
This commit is contained in:
lanrtop 2026-04-07 00:46:13 +09:00
parent 6c24d34bb4
commit fea43b0b8a
8 changed files with 282 additions and 24 deletions

View File

@ -44,6 +44,29 @@
"todo": 1,
"total": 116
}
},
{
"at": "2026-04-06T12:10:41Z",
"blocked_reasons": [],
"conventions_version": "1.4.0",
"iteration": 1,
"modules": {
"blocked": 0,
"concept": 0,
"done": 33,
"in_progress": 0,
"planned": 0,
"stalled": [],
"total": 33
},
"tasks": {
"blocked": 0,
"dispatched": 0,
"done": 130,
"in_progress": 0,
"todo": 1,
"total": 135
}
}
]
}

View File

@ -109,6 +109,84 @@ pub fn remove_project_tool(id: String) -> Result<(), String> {
Ok(())
}
// ── 全局快启命令(写入 global_tools 表,无项目 FK 约束)────────────────────────
#[tauri::command]
pub fn get_global_tools() -> Result<Vec<ProjectTool>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, icon, exe_path, args, sort_order
FROM global_tools
ORDER BY sort_order ASC, rowid ASC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(ProjectTool {
id: row.get(0)?,
project_id: "__global__".to_string(),
name: row.get(1)?,
icon: row.get(2)?,
exe_path: row.get(3)?,
args: row.get(4)?,
sort_order: row.get(5)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string());
rows
}
#[tauri::command]
pub fn add_global_tool(input: ProjectToolInput) -> Result<String, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO global_tools (id, name, icon, exe_path, args, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
id,
input.name,
input.icon,
input.exe_path,
input.args,
input.sort_order.unwrap_or(0),
],
)
.map_err(|e| e.to_string())?;
Ok(id)
}
#[tauri::command]
pub fn update_global_tool(id: String, input: ProjectToolInput) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE global_tools
SET name = ?1, icon = ?2, exe_path = ?3, args = ?4, sort_order = ?5
WHERE id = ?6",
params![
input.name,
input.icon,
input.exe_path,
input.args,
input.sort_order.unwrap_or(0),
id,
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn remove_global_tool(id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM global_tools WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// 启动工具通用exe 路径 + 可选参数)
#[tauri::command]
pub fn launch_tool(exe_path: String, args: Option<String>) -> Result<(), String> {

View File

@ -424,6 +424,18 @@ fn migrate(conn: &rusqlite::Connection) {
status TEXT NOT NULL DEFAULT 'active'
);
").expect("create blueprint_buffs table");
// 仪表盘全局快启应用(无项目 FK 约束)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS global_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔧',
exe_path TEXT NOT NULL,
args TEXT,
sort_order INTEGER DEFAULT 0
);
").expect("create global_tools table");
}
#[cfg(test)]

View File

@ -138,6 +138,11 @@ pub fn run() {
update_project_tool,
remove_project_tool,
launch_tool,
// global quick launch
get_global_tools,
add_global_tool,
update_global_tool,
remove_global_tool,
// devtools
scan_dev_tools,
scan_wsl_dev_tools,

View File

@ -1,7 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { getGroups, getGroupMaps, getProjects, getSpaces, getTags, getAllProjectTagMaps, type ProductGroup, type Project } from "../../lib/commands";
import { getGroups, getGroupMaps, getProjects, getSpaces, getTags, getAllProjectTagMaps, getGlobalTools, addGlobalTool, updateGlobalTool, removeGlobalTool, launchTool, type ProductGroup, type Project, type ProjectToolInput } from "../../lib/commands";
import { ProjectCard } from "./ProjectCard";
import { DeployModal } from "./DeployModal";
import { GroupResourceCanvas } from "./GroupResourceCanvas";
@ -9,9 +9,11 @@ import { ManagePage } from "../manage/ManagePage";
import { BatchAddModal } from "./BatchAddModal";
import { MountRepoModal } from "./MountRepoModal";
import { DiscoveryPanel } from "./DiscoveryPanel";
import { ProjectToolsModal } from "./ProjectToolsModal";
import { StatusBadge } from "../ui/Badge";
import { getDiscoverableProjects, type SpacesJson } from "../../lib/spacesSync";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useUIStore } from "../../store/ui";
type DashView = "board" | "manage";
type BoardTab = "groups" | "standalone";
@ -141,6 +143,8 @@ export function Dashboard({ spacesJson }: Props) {
</button>
</div>
<GlobalQuickLaunchBar />
<div className="flex-1 overflow-y-auto min-h-0">
<div className="w-full px-8 py-8 space-y-8 pb-16">
@ -412,6 +416,93 @@ function ProjectRow({
);
}
/* ── GlobalQuickLaunchBar ──────────────────────────────────────────────────── */
const GLOBAL_QUERY_KEY = ["globalTools"];
const globalToolCmds = {
getTools: () => getGlobalTools(),
addTool: (input: ProjectToolInput) => addGlobalTool(input),
updateTool: (id: string, input: ProjectToolInput) => updateGlobalTool(id, input),
removeTool: (id: string) => removeGlobalTool(id),
};
function GlobalQuickLaunchBar() {
const showToast = useUIStore((s) => s.showToast);
const [showMgr, setShowMgr] = useState(false);
const [mgrDefaultAdd, setMgrDefaultAdd] = useState(false);
const { data: tools = [] } = useQuery({
queryKey: GLOBAL_QUERY_KEY,
queryFn: () => getGlobalTools(),
staleTime: 30000,
});
const handleLaunch = async (exePath: string, args: string | null | undefined, name: string) => {
try {
await launchTool(exePath, args ?? undefined);
showToast(`已启动 ${name}`);
} catch (e) {
showToast(`${e}`);
}
};
return (
<div className="shrink-0 border-b border-gray-100 bg-white px-8 py-2 flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-semibold text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5 shrink-0">
</span>
{tools.length > 0 ? (
<>
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => handleLaunch(tool.exe_path, tool.args, tool.name)}
title={tool.exe_path}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-orange-50 hover:border-orange-300 hover:text-orange-800 transition-colors"
>
{tool.icon} {tool.name}
</button>
))}
<button
onClick={() => { setMgrDefaultAdd(true); setShowMgr(true); }}
className="px-2 py-1 rounded-md text-xs border border-dashed border-orange-200 text-orange-400 hover:border-orange-400 hover:text-orange-600 hover:bg-orange-50 transition-colors"
title="添加快启应用"
>
+
</button>
</>
) : (
<button
onClick={() => { setMgrDefaultAdd(true); setShowMgr(true); }}
className="px-2.5 py-1 rounded-md text-xs border border-dashed border-orange-200 text-orange-400 hover:border-orange-400 hover:text-orange-600 hover:bg-orange-50 transition-colors"
>
+
</button>
)}
<button
onClick={() => { setMgrDefaultAdd(false); setShowMgr(true); }}
className="ml-auto text-gray-300 hover:text-gray-500 transition-colors"
title="管理快启应用"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
</button>
{showMgr && (
<ProjectToolsModal
projectId="__global__"
projectName="日常应用"
defaultOpenAdd={mgrDefaultAdd}
commands={globalToolCmds}
queryKey={GLOBAL_QUERY_KEY}
onClose={() => { setShowMgr(false); setMgrDefaultAdd(false); }}
/>
)}
</div>
);
}
/* ── StandaloneBoardSection ────────────────────────────────────────────────── */
function StandaloneBoardSection({

View File

@ -60,6 +60,7 @@ interface Props {
export function ProjectCard({ project: p, role, spacesJson }: Props) {
const [expanded, setExpanded] = useState(false);
const [showToolsMgr, setShowToolsMgr] = useState(false);
const [toolsMgrDefaultAdd, setToolsMgrDefaultAdd] = useState(false);
const [showEnvScan, setShowEnvScan] = useState(false);
const [showCicd, setShowCicd] = useState(false);
const [showPublish, setShowPublish] = useState(false);
@ -655,7 +656,7 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
)}
{/* ── 快捷操作区 ── */}
{(p.wsl_path || p.win_path || projectTools.length > 0) && (
{(p.wsl_path || p.win_path) && (
<div className="border-t border-gray-100 px-5 py-3 flex flex-col gap-2">
{p.wsl_path && (
<div className="flex items-center gap-1.5 flex-wrap">
@ -687,9 +688,10 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
</button>
</div>
)}
{projectTools.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5 shrink-0"></span>
<span className="text-[11px] font-semibold text-purple-700 bg-purple-50 border border-purple-200 rounded px-1.5 py-0.5 shrink-0"></span>
{projectTools.length > 0 ? (
<>
{projectTools.map((tool) => (
<button key={tool.id} onClick={() => handleLaunchTool(tool.exe_path, tool.args, tool.name)}
className="px-2.5 py-1 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-purple-50 hover:border-purple-300 hover:text-purple-800 transition-colors"
@ -697,9 +699,22 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
{tool.icon} {tool.name}
</button>
))}
</div>
<button
onClick={() => { setToolsMgrDefaultAdd(true); setShowToolsMgr(true); }}
className="px-2 py-1 rounded-md text-xs border border-dashed border-purple-200 text-purple-400 hover:border-purple-400 hover:text-purple-600 hover:bg-purple-50 transition-colors"
title="添加快启应用">
+
</button>
</>
) : (
<button
onClick={() => { setToolsMgrDefaultAdd(true); setShowToolsMgr(true); }}
className="px-2.5 py-1 rounded-md text-xs border border-dashed border-purple-200 text-purple-400 hover:border-purple-400 hover:text-purple-600 hover:bg-purple-50 transition-colors">
+
</button>
)}
</div>
</div>
)}
{/* ── 底部操作栏 ── */}
@ -752,7 +767,8 @@ export function ProjectCard({ project: p, role, spacesJson }: Props) {
<ProjectToolsModal
projectId={p.id}
projectName={p.name}
onClose={() => setShowToolsMgr(false)}
defaultOpenAdd={toolsMgrDefaultAdd}
onClose={() => { setShowToolsMgr(false); setToolsMgrDefaultAdd(false); }}
/>
)}

View File

@ -9,6 +9,13 @@ import {
type ProjectTool,
type ProjectToolInput,
} from "../../lib/commands";
export interface ToolCommands {
getTools: () => Promise<ProjectTool[]>;
addTool: (input: ProjectToolInput) => Promise<string>;
updateTool: (id: string, input: ProjectToolInput) => Promise<void>;
removeTool: (id: string) => Promise<void>;
}
import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog";
@ -20,20 +27,32 @@ interface Props {
projectId: string;
projectName: string;
onClose: () => void;
defaultOpenAdd?: boolean;
commands?: ToolCommands;
queryKey?: unknown[];
}
export function ProjectToolsModal({ projectId, projectName, onClose }: Props) {
export function ProjectToolsModal({ projectId, projectName, onClose, defaultOpenAdd, commands, queryKey }: Props) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const cmds: ToolCommands = commands ?? {
getTools: () => getProjectTools(projectId),
addTool: (input) => addProjectTool(projectId, input),
updateTool: (id, input) => updateProjectTool(id, input),
removeTool: (id) => removeProjectTool(id),
};
const resolvedKey = queryKey ?? ["projectTools", projectId];
const { data: tools = [], refetch } = useQuery({
queryKey: ["projectTools", projectId],
queryFn: () => getProjectTools(projectId),
queryKey: resolvedKey,
queryFn: () => cmds.getTools(),
});
const [editing, setEditing] = useState<ProjectTool | null>(null); // null = 新增模式
const [form, setForm] = useState<ProjectToolInput>(EMPTY);
const [showForm, setShowForm] = useState(false);
const [showForm, setShowForm] = useState(defaultOpenAdd ?? false);
const [saving, setSaving] = useState(false);
const openAdd = () => {
@ -80,12 +99,12 @@ export function ProjectToolsModal({ projectId, projectName, onClose }: Props) {
sort_order: editing?.sort_order ?? tools.length,
};
if (editing) {
await updateProjectTool(editing.id, input);
await cmds.updateTool(editing.id, input);
} else {
await addProjectTool(projectId, input);
await cmds.addTool(input);
}
await refetch();
qc.invalidateQueries({ queryKey: ["projectTools", projectId] });
qc.invalidateQueries({ queryKey: resolvedKey });
setShowForm(false);
showToast(editing ? "已更新 ✓" : "已添加 ✓");
} catch (e) {
@ -97,9 +116,9 @@ export function ProjectToolsModal({ projectId, projectName, onClose }: Props) {
const handleDelete = async (id: string) => {
try {
await removeProjectTool(id);
await cmds.removeTool(id);
await refetch();
qc.invalidateQueries({ queryKey: ["projectTools", projectId] });
qc.invalidateQueries({ queryKey: resolvedKey });
} catch (e) {
showToast(`${e}`);
}

View File

@ -222,6 +222,20 @@ export const removeProjectTool = (id: string) =>
export const launchTool = (exePath: string, args?: string) =>
invoke<void>("launch_tool", { exePath, args });
// ── Global Quick Launch ───────────────────────────────────────────────────────
export const getGlobalTools = () =>
invoke<ProjectTool[]>("get_global_tools");
export const addGlobalTool = (input: ProjectToolInput) =>
invoke<string>("add_global_tool", { input });
export const updateGlobalTool = (id: string, input: ProjectToolInput) =>
invoke<void>("update_global_tool", { id, input });
export const removeGlobalTool = (id: string) =>
invoke<void>("remove_global_tool", { id });
// ── Dev Tools ─────────────────────────────────────────────────────────────────
export interface ScannedTool {