Feat: 增加 Tauri 相关框架,页面增加 Tab 效果

This commit is contained in:
奇趣保罗 2026-01-16 17:35:27 +08:00
parent dcedc4b99c
commit 510821020c
31 changed files with 5669 additions and 103 deletions

View File

@ -29,10 +29,12 @@
"react-virtuoso": "^4.7.11", "react-virtuoso": "^4.7.11",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.16" "tailwindcss": "^4.1.16",
"zustand": "^5.0.10"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@tauri-apps/cli": "^2.9.6",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",

View File

@ -65,10 +65,16 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.1.16 version: 4.1.16
zustand:
specifier: ^5.0.10
version: 5.0.10(@types/react@19.2.2)(react@19.2.0)
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.36.0 specifier: ^9.36.0
version: 9.39.0 version: 9.39.0
'@tauri-apps/cli':
specifier: ^2.9.6
version: 2.9.6
'@types/node': '@types/node':
specifier: ^24.6.0 specifier: ^24.6.0
version: 24.9.2 version: 24.9.2
@ -1009,6 +1015,77 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 vite: ^5.2.0 || ^6 || ^7
'@tauri-apps/cli-darwin-arm64@2.9.6':
resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.9.6':
resolution: {integrity: sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
resolution: {integrity: sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.9.6':
resolution: {integrity: sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-linux-x64-musl@2.9.6':
resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.9.6':
resolution: {integrity: sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.9.6':
resolution: {integrity: sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.9.6':
resolution: {integrity: sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==}
engines: {node: '>= 10'}
hasBin: true
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -1869,6 +1946,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zustand@5.0.10:
resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots: snapshots:
'@babel/code-frame@7.27.1': '@babel/code-frame@7.27.1':
@ -2640,6 +2735,53 @@ snapshots:
tailwindcss: 4.1.16 tailwindcss: 4.1.16
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2) vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
'@tauri-apps/cli-darwin-arm64@2.9.6':
optional: true
'@tauri-apps/cli-darwin-x64@2.9.6':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.9.6':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.9.6':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.9.6':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.9.6':
optional: true
'@tauri-apps/cli@2.9.6':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.9.6
'@tauri-apps/cli-darwin-x64': 2.9.6
'@tauri-apps/cli-linux-arm-gnueabihf': 2.9.6
'@tauri-apps/cli-linux-arm64-gnu': 2.9.6
'@tauri-apps/cli-linux-arm64-musl': 2.9.6
'@tauri-apps/cli-linux-riscv64-gnu': 2.9.6
'@tauri-apps/cli-linux-x64-gnu': 2.9.6
'@tauri-apps/cli-linux-x64-musl': 2.9.6
'@tauri-apps/cli-win32-arm64-msvc': 2.9.6
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.28.5 '@babel/parser': 7.28.5
@ -3448,3 +3590,8 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zustand@5.0.10(@types/react@19.2.2)(react@19.2.0):
optionalDependencies:
'@types/react': 19.2.2
react: 19.2.0

4
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

4952
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

16
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

37
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,37 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "translate-it",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5007",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
{
"title": "Translate It",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@ -0,0 +1,73 @@
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useProjectTabsStore } from "@/store/project-tabs-store";
export function ProjectTabsBar() {
const tabs = useProjectTabsStore((state) => state.tabs);
const activeTabId = useProjectTabsStore((state) => state.activeTabId);
const activateTab = useProjectTabsStore((state) => state.activateTab);
const activateHome = useProjectTabsStore((state) => state.activateHome);
const closeTab = useProjectTabsStore((state) => state.closeTab);
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
const active = tabs.find((tab) => tab.id === activeTabId);
if (!active) return;
const targetPath = active.kind === "home" ? "/" : `/editor/${active.projectId}`;
if (location.pathname === targetPath) return;
navigate(targetPath);
}, [activeTabId, tabs, location.pathname, navigate]);
function handleSelect(tabId: string, kind: "home" | "project") {
if (kind === "home") activateHome();
else activateTab(tabId);
}
function handleClose(tabId: string) {
closeTab(tabId);
}
return (
<div className="relative bg-muted px-2 pt-2">
<div className="flex h-8 items-stretch gap-1 overflow-x-auto">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<button
key={tab.id}
type="button"
className={cn(
"group relative flex min-w-[140px] max-w-[200px] shrink-0 items-center gap-2 rounded-t-md border px-3 text-sm transition border-b-0",
isActive
? "border-border bg-background text-foreground z-1"
: "border-transparent bg-transparent text-muted-foreground hover:border-border/60 hover:bg-background/80 hover:text-foreground",
)}
title={tab.label}
onClick={() => handleSelect(tab.id, tab.kind)}
>
<span className="truncate">{tab.label}</span>
{tab.closable && (
<button
type="button"
className="ml-auto rounded-sm text-muted-foreground/80 transition hover:bg-muted hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
handleClose(tab.id);
}}
aria-label="关闭标签"
>
<X className="size-4" />
</button>
)}
</button>
);
})}
</div>
<span className="block absolute bottom-0 left-0 right-0 h-2 border-b border-border/70"></span>
</div>
);
}

View File

@ -42,6 +42,8 @@ import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-c
import { generateLanguageJson } from "@/lib/utils"; import { generateLanguageJson } from "@/lib/utils";
import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator"; import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button"; import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button";
import { useProjectTabsStore } from "@/store/project-tabs-store";
import { ProjectTabsBar } from "@/components/biz/project-tabs-bar";
export default function Editor() { export default function Editor() {
const { id: projectId } = useParams(); const { id: projectId } = useParams();
@ -71,6 +73,10 @@ export default function Editor() {
const { copy } = useClipboard(); const { copy } = useClipboard();
const connSnap = useFileConnections(projectId ?? ""); const connSnap = useFileConnections(projectId ?? "");
const openProjectTab = useProjectTabsStore((state) => state.openProjectTab);
const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta);
const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject);
const activateHomeTab = useProjectTabsStore((state) => state.activateHome);
function highlightRow(index: number) { function highlightRow(index: number) {
const tryFindAndAnimate = (attempt = 0) => { const tryFindAndAnimate = (attempt = 0) => {
@ -136,6 +142,7 @@ export default function Editor() {
const p = await getProject(projectId); const p = await getProject(projectId);
if (!p) throw new Error("项目不存在"); if (!p) throw new Error("项目不存在");
setProject(p); setProject(p);
upsertProjectMeta(p);
const s = await getStructure(projectId); const s = await getStructure(projectId);
if (s) setStructure(s); if (s) setStructure(s);
const langs = await listLanguages(projectId); const langs = await listLanguages(projectId);
@ -147,6 +154,11 @@ export default function Editor() {
} }
} }
useEffect(() => {
if (!projectId) return;
openProjectTab({ id: projectId });
}, [projectId, openProjectTab]);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -458,16 +470,11 @@ export default function Editor() {
}, [projectId, languages]); }, [projectId, languages]);
return ( return (
<div className="h-screen flex flex-col"> <div className="flex h-screen flex-col bg-background">
<header className="h-14 px-2 md:px-4 from-blue-400 to-cyan-400 bg-linear-to-r text-white"> <ProjectTabsBar />
<div className="flex flex-1 flex-col">
<header className="h-14 bg-linear-to-r from-blue-400 to-cyan-400 px-2 text-white md:px-4">
<div className="grid grid-cols-3 h-full items-center"> <div className="grid grid-cols-3 h-full items-center">
<div className="flex items-center gap-2">
<Button asChild size="icon" variant="ghost" aria-label="返回">
<Link to="/">
<ArrowLeft className="size-5" />
</Link>
</Button>
</div>
<div className="text-center font-medium truncate"> <div className="text-center font-medium truncate">
{project?.name ?? "编辑器"} {project?.name ?? "编辑器"}
</div> </div>
@ -512,9 +519,9 @@ export default function Editor() {
)} )}
</div> </div>
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-4 md:p-6"> <main className="flex-1 overflow-auto p-4 md:p-6">
{pageError && ( {pageError && (
<div className="mb-4 text-sm text-red-600" role="alert">{pageError}</div> <div className="mb-4 text-sm text-red-600" role="alert">{pageError}</div>
)} )}
@ -652,7 +659,8 @@ export default function Editor() {
</div> </div>
</div> </div>
)} )}
</main> </main>
</div>
<ImportLanguageModal <ImportLanguageModal
open={importOpen} open={importOpen}
@ -679,10 +687,13 @@ export default function Editor() {
if (!projectId) return; if (!projectId) return;
const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences }); const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences });
setProject(next); setProject(next);
upsertProjectMeta(next);
}} }}
onDelete={async () => { onDelete={async () => {
if (!projectId) return; if (!projectId) return;
await deleteProjectDeep(projectId); await deleteProjectDeep(projectId);
forgetProjectInTabs(projectId);
activateHomeTab();
navigate("/"); navigate("/");
}} }}
/> />

View File

@ -7,6 +7,8 @@ import { ProjectSettingsModal } from "@/components/biz/project-settings-modal";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import { useConnectionFlags } from "@/store/file-connection"; import { useConnectionFlags } from "@/store/file-connection";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useProjectTabsStore } from "@/store/project-tabs-store";
import { ProjectTabsBar } from "@/components/biz/project-tabs-bar";
function formatTime(ts: number) { function formatTime(ts: number) {
try { try {
@ -26,6 +28,11 @@ function App() {
const [currentProject, setCurrentProject] = useState<Project | null>(null); const [currentProject, setCurrentProject] = useState<Project | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const flags = useConnectionFlags(); const flags = useConnectionFlags();
const activateHomeTab = useProjectTabsStore((state) => state.activateHome);
const registerProjectsInTabs = useProjectTabsStore((state) => state.registerProjects);
const openProjectTab = useProjectTabsStore((state) => state.openProjectTab);
const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta);
const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject);
async function refresh() { async function refresh() {
setLoading(true); setLoading(true);
@ -43,6 +50,15 @@ function App() {
void refresh(); void refresh();
}, []); }, []);
useEffect(() => {
activateHomeTab();
}, [activateHomeTab]);
useEffect(() => {
if (projects.length === 0) return;
registerProjectsInTabs(projects);
}, [projects, registerProjectsInTabs]);
const canSubmit = useMemo(() => projectName.trim().length > 0 && !submitting, [projectName, submitting]); const canSubmit = useMemo(() => projectName.trim().length > 0 && !submitting, [projectName, submitting]);
async function onCreate(e: React.FormEvent) { async function onCreate(e: React.FormEvent) {
@ -62,103 +78,116 @@ function App() {
} }
return ( return (
<div className="mx-auto max-w-5xl px-4 py-8"> <div className="flex min-h-screen flex-col bg-background">
<h1 className="text-2xl font-semibold"></h1> <ProjectTabsBar />
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-5xl px-4 py-8">
<h1 className="text-2xl font-semibold"></h1>
<form onSubmit={onCreate} className="mt-6 flex items-center gap-3"> <form onSubmit={onCreate} className="mt-6 flex items-center gap-3">
<Input <Input
placeholder="输入项目名称" placeholder="输入项目名称"
value={projectName} value={projectName}
onChange={(e) => setProjectName(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
aria-label="项目名称" aria-label="项目名称"
/> />
<Button type="submit" disabled={!canSubmit}> <Button type="submit" disabled={!canSubmit}>
{submitting ? "创建中..." : "新建项目"} {submitting ? "创建中..." : "新建项目"}
</Button> </Button>
</form> </form>
{error && ( {error && (
<div className="mt-4 text-sm text-red-600" role="alert"> <div className="mt-4 text-sm text-red-600" role="alert">
{error} {error}
</div> </div>
)} )}
<div className="mt-8"> <div className="mt-8">
<h2 className="mb-3 text-lg font-medium"></h2> <h2 className="mb-3 text-lg font-medium"></h2>
{loading ? ( {loading ? (
<div className="text-sm text-muted-foreground">...</div> <div className="text-sm text-muted-foreground">...</div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground"></div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => { {projects.map((p) => {
const hasConn = !!flags[p.id]; const hasConn = !!flags[p.id];
return ( return (
<div <div
key={p.id} key={p.id}
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative" className="relative cursor-pointer rounded-md border p-4 transition hover:shadow-sm"
onClick={() => navigate(`/editor/${p.id}`)} onClick={() => {
role="button" openProjectTab(p);
tabIndex={0} navigate(`/editor/${p.id}`);
onKeyDown={(e) => { }}
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`); role="button"
}} tabIndex={0}
> onKeyDown={(e) => {
<button if (e.key === "Enter" || e.key === " ") {
type="button" openProjectTab(p);
aria-label="项目设置" navigate(`/editor/${p.id}`);
className="absolute top-2 right-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent" }
onClick={(e) => { }}
e.stopPropagation(); >
setCurrentProject(p); <button
setSettingsOpen(true); type="button"
}} aria-label="项目设置"
> className="absolute right-2 top-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
<Settings className="size-4" /> onClick={(e) => {
</button> e.stopPropagation();
<div className="truncate font-medium pr-8 flex items-center gap-2"> setCurrentProject(p);
<span setSettingsOpen(true);
className={cn( }}
"inline-block size-2 rounded-full", >
hasConn ? "bg-green-500" : "bg-zinc-300" <Settings className="size-4" />
)} </button>
title={hasConn ? "已连线" : "未连线"} <div className="flex items-center gap-2 truncate pr-8 font-medium">
/> <span
{p.name} className={cn(
</div> "inline-block size-2 rounded-full",
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</div> hasConn ? "bg-green-500" : "bg-zinc-300"
<div className="mt-1 text-xs text-muted-foreground">: {formatTime(p.createdAt)}</div> )}
{p.preferences?.aiPrompt ? ( title={hasConn ? "已连线" : "未连线"}
<div className="mt-3 text-xs line-clamp-3 text-muted-foreground"> />
{p.preferences.aiPrompt} {p.name}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">ID: {p.id}</div>
<div className="mt-1 text-xs text-muted-foreground">: {formatTime(p.createdAt)}</div>
{p.preferences?.aiPrompt ? (
<div className="mt-3 line-clamp-3 text-xs text-muted-foreground">
{p.preferences.aiPrompt}
</div>
) : null}
</div> </div>
) : null} );
</div> })}
); </div>
})} )}
</div> </div>
)}
</div>
<ProjectSettingsModal <ProjectSettingsModal
open={settingsOpen} open={settingsOpen}
onOpenChange={setSettingsOpen} onOpenChange={setSettingsOpen}
project={currentProject} project={currentProject}
onSave={async (update) => { onSave={async (update) => {
if (!currentProject) return; if (!currentProject) return;
const next = await updateProject({ id: currentProject.id, name: update.name, preferences: update.preferences }); const next = await updateProject({ id: currentProject.id, name: update.name, preferences: update.preferences });
setProjects((arr) => arr.map((it) => (it.id === next.id ? next : it))); setProjects((arr) => arr.map((it) => (it.id === next.id ? next : it)));
setCurrentProject(next); setCurrentProject(next);
}} upsertProjectMeta(next);
onDelete={async () => { }}
if (!currentProject) return; onDelete={async () => {
await deleteProjectDeep(currentProject.id); if (!currentProject) return;
setSettingsOpen(false); await deleteProjectDeep(currentProject.id);
setCurrentProject(null); forgetProjectInTabs(currentProject.id);
await refresh(); setSettingsOpen(false);
}} setCurrentProject(null);
/> await refresh();
}}
/>
</div>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,247 @@
import { create } from "zustand";
import type { Project } from "@/lib/db";
export type ProjectMeta = { id: string } & Partial<Omit<Project, "id">>;
type BaseTab = {
id: string;
label: string;
closable: boolean;
};
type HomeTab = BaseTab & { kind: "home" };
type ProjectTab = BaseTab & { kind: "project"; projectId: string };
export type ProjectTabDescriptor = HomeTab | ProjectTab;
type State = {
tabs: ProjectTabDescriptor[];
activeTabId: string;
projectIndex: Record<string, ProjectMeta>;
lastActiveProjectId: string | null;
};
type Actions = {
activateHome: () => void;
activateTab: (tabId: string) => void;
openProjectTab: (meta: ProjectMeta | string, opts?: { activate?: boolean }) => string;
closeTab: (tabId: string) => void;
closeProjectTab: (projectId: string) => void;
registerProjects: (projects: ProjectMeta[]) => void;
upsertProjectMeta: (project: ProjectMeta) => void;
forgetProject: (projectId: string) => void;
getActiveProjectId: () => string | null;
isProjectOpen: (projectId: string) => boolean;
};
export type ProjectTabsStore = State & Actions;
export const HOME_TAB_ID = "tab-home";
const HOME_TAB: HomeTab = { id: HOME_TAB_ID, kind: "home", label: "首页", closable: false };
function buildProjectTabId(projectId: string): string {
return `tab-${projectId}`;
}
function normalizeProjectMeta(meta: ProjectMeta | string): ProjectMeta {
if (typeof meta === "string") return { id: meta };
return meta;
}
function deriveProjectLabel(projectId: string, meta?: ProjectMeta): string {
if (meta?.name?.trim()) return meta.name.trim();
if (projectId.length <= 8) return projectId;
return `项目 ${projectId.slice(-4)}`;
}
function isPreferencesEqual(a?: Project["preferences"], b?: Project["preferences"]): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
return a.aiModel === b.aiModel && a.aiPrompt === b.aiPrompt;
}
function mergePreferences(prev?: Project["preferences"], incoming?: Project["preferences"]): Project["preferences"] | undefined {
if (!prev && !incoming) return undefined;
if (!prev) return incoming ? { ...incoming } : undefined;
if (!incoming) return prev;
const next = { ...prev, ...incoming };
return isPreferencesEqual(prev, next) ? prev : next;
}
function isMetaEqual(a: ProjectMeta, b: ProjectMeta): boolean {
return (
a.name === b.name &&
a.createdAt === b.createdAt &&
a.updatedAt === b.updatedAt &&
isPreferencesEqual(a.preferences, b.preferences)
);
}
function mergeProjectMeta(prev: ProjectMeta | undefined, incoming: ProjectMeta): { meta: ProjectMeta; changed: boolean } {
if (!prev) {
return { meta: { ...incoming }, changed: true };
}
const mergedPrefs = mergePreferences(prev.preferences, incoming.preferences);
const next: ProjectMeta = {
...prev,
...incoming,
...(mergedPrefs ? { preferences: mergedPrefs } : {}),
};
if (isMetaEqual(prev, next)) {
return { meta: prev, changed: false };
}
return { meta: next, changed: true };
}
function ensureProjectMeta(index: Record<string, ProjectMeta>, incoming: ProjectMeta): { index: Record<string, ProjectMeta>; meta: ProjectMeta } {
const current = index[incoming.id];
const { meta, changed } = mergeProjectMeta(current, incoming);
if (!changed) return { index, meta };
return { index: { ...index, [incoming.id]: meta }, meta };
}
export const useProjectTabsStore = create<ProjectTabsStore>((set, get) => ({
tabs: [HOME_TAB],
activeTabId: HOME_TAB_ID,
projectIndex: {},
lastActiveProjectId: null,
activateHome: () => {
set(() => ({
activeTabId: HOME_TAB_ID,
lastActiveProjectId: null,
}));
},
activateTab: (tabId) => {
set((state) => {
const target = state.tabs.find((tab) => tab.id === tabId);
if (!target) return state;
return {
activeTabId: tabId,
lastActiveProjectId: target.kind === "project" ? target.projectId : state.lastActiveProjectId,
};
});
},
openProjectTab: (input, opts) => {
const normalized = normalizeProjectMeta(input);
if (!normalized.id) return HOME_TAB_ID;
const tabId = buildProjectTabId(normalized.id);
set((state) => {
const { index: projectIndex, meta } = ensureProjectMeta(state.projectIndex, normalized);
const label = deriveProjectLabel(meta.id, projectIndex[meta.id]);
const existing = state.tabs.find((tab) => tab.id === tabId);
const shouldActivate = opts?.activate !== false;
if (existing) {
const updated = existing.label === label ? existing : { ...existing, label };
return {
projectIndex,
tabs: state.tabs.map((tab) => (tab.id === tabId ? updated : tab)),
activeTabId: shouldActivate ? tabId : state.activeTabId,
lastActiveProjectId: shouldActivate ? meta.id : state.lastActiveProjectId,
};
}
const nextTab: ProjectTab = { id: tabId, kind: "project", projectId: meta.id, label, closable: true };
return {
projectIndex,
tabs: [...state.tabs, nextTab],
activeTabId: shouldActivate ? tabId : state.activeTabId,
lastActiveProjectId: shouldActivate ? meta.id : state.lastActiveProjectId,
};
});
return tabId;
},
closeTab: (tabId) => {
if (tabId === HOME_TAB_ID) return;
set((state) => {
const targetIndex = state.tabs.findIndex((tab) => tab.id === tabId);
if (targetIndex === -1) return state;
const target = state.tabs[targetIndex];
if (target.kind === "home") return state;
const nextTabs = state.tabs.filter((tab) => tab.id !== tabId);
const hasHome = nextTabs.some((tab) => tab.id === HOME_TAB_ID);
const tabs = hasHome ? nextTabs : [HOME_TAB, ...nextTabs];
const nextLastActive = state.lastActiveProjectId === target.projectId ? null : state.lastActiveProjectId;
if (state.activeTabId !== tabId) {
return {
tabs,
lastActiveProjectId: nextLastActive,
};
}
const fallback = state.tabs[targetIndex - 1] ?? state.tabs[targetIndex + 1] ?? HOME_TAB;
return {
tabs,
activeTabId: fallback.id,
lastActiveProjectId: fallback.kind === "project" ? fallback.projectId : null,
};
});
},
closeProjectTab: (projectId) => {
if (!projectId) return;
get().closeTab(buildProjectTabId(projectId));
},
registerProjects: (projects) => {
if (!projects || projects.length === 0) return;
set((state) => {
let nextIndex = state.projectIndex;
let indexChanged = false;
for (const project of projects) {
if (!project?.id) continue;
const result = ensureProjectMeta(nextIndex, project);
if (result.index !== nextIndex) {
nextIndex = result.index;
indexChanged = true;
}
}
if (!indexChanged) return state;
let tabsChanged = false;
const tabs = state.tabs.map((tab) => {
if (tab.kind !== "project") return tab;
const label = deriveProjectLabel(tab.projectId, nextIndex[tab.projectId]);
if (label === tab.label) return tab;
tabsChanged = true;
return { ...tab, label };
});
return {
projectIndex: nextIndex,
tabs: tabsChanged ? tabs : state.tabs,
};
});
},
upsertProjectMeta: (project) => {
if (!project?.id) return;
set((state) => {
const result = ensureProjectMeta(state.projectIndex, project);
if (result.index === state.projectIndex) return state;
const tabs = state.tabs.map((tab) => {
if (tab.kind !== "project" || tab.projectId !== project.id) return tab;
const label = deriveProjectLabel(project.id, result.meta);
if (label === tab.label) return tab;
return { ...tab, label };
});
return {
projectIndex: result.index,
tabs,
};
});
},
forgetProject: (projectId) => {
if (!projectId) return;
get().closeProjectTab(projectId);
set((state) => {
if (!state.projectIndex[projectId]) return state;
const { [projectId]: _removed, ...rest } = state.projectIndex;
return {
projectIndex: rest,
lastActiveProjectId: state.lastActiveProjectId === projectId ? null : state.lastActiveProjectId,
};
});
},
getActiveProjectId: () => {
const { tabs, activeTabId } = get();
const active = tabs.find((tab) => tab.id === activeTabId);
return active && active.kind === "project" ? active.projectId : null;
},
isProjectOpen: (projectId) => {
if (!projectId) return false;
const tabId = buildProjectTabId(projectId);
return get().tabs.some((tab) => tab.id === tabId);
},
}));

View File

@ -6,6 +6,9 @@ import react from "@vitejs/plugin-react";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
port: 5007,
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),