第 22 章 搭建项目骨架(Vite + React + Tauri 2 + Tailwind)
本章目标
- 创建
cloudtone项目目录。 - 集成 Tailwind、shadcn/ui、
tauri-specta类型自动生成。 - 配置路径别名、ESLint、Prettier。
- 跑起 Hello CloudTone。
一、创建项目
pnpm create tauri-app@latest cloudtone
# 选 pnpm, React, TypeScript
cd cloudtone
pnpm install
按第 10 章的目录规范整理:
mkdir -p src/{app,components/ui,components/player,features,lib,types}
mkdir -p src-tauri/{capabilities,migrations}
mkdir -p src-tauri/src/{cmds,core,core/audio,core/library,core/lyrics,core/providers,core/db}
二、依赖
pnpm add react-router-dom zustand @tanstack/react-query lucide-react
pnpm add clsx tailwind-merge
pnpm add -D tailwindcss@3 postcss autoprefixer
pnpm add -D @types/node vite-plugin-svgr
pnpm exec tailwindcss init -p
shadcn/ui(可选但推荐):
pnpm dlx shadcn@latest init
# 选 "default","slate"
pnpm dlx shadcn@latest add button dialog dropdown-menu slider tooltip input
三、Tailwind 配置
tailwind.config.js:
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
brand: { 500: "#ec4899", 600: "#db2777" },
surface: {
bg: "#0f0f10",
card: "#17171a",
elevated: "#202024",
border: "rgba(255,255,255,0.06)",
},
},
fontFamily: {
sans: ["Inter", "PingFang SC", "Microsoft YaHei", "sans-serif"],
},
},
},
plugins: [require("tailwindcss-animate")],
};
src/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root { height: 100%; margin: 0; }
body { background: #0a0a0a; color: #e5e7eb; -webkit-font-smoothing: antialiased; }
.scrollbar-thin::-webkit-scrollbar { width: 8px; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
四、Vite 配置
vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: { port: 1420, strictPort: true },
envPrefix: ["VITE_", "TAURI_"],
build: {
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
五、基础 Rust 后端
src-tauri/Cargo.toml 加依赖:
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-log = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-notification = "2"
tauri-plugin-http = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
thiserror = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter","fmt","json"] }
tracing-appender = "0.2"
sqlx = { version = "0.8", features = ["runtime-tokio","sqlite","macros","migrate","chrono"] }
reqwest = { version = "0.12", features = ["json","stream","rustls-tls"] }
url = "2"
urlencoding = "2"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4","serde"] }
dirs = "5"
walkdir = "2"
notify = "6"
# 音频
symphonia = { version = "0.5", features = ["all"] }
cpal = "0.15"
rubato = "0.15" # 采样率转换
lofty = "0.21" # 元数据
specta = { version = "2.0.0-rc", features = ["serde","derive"] }
tauri-specta = { version = "=2.0.0-rc", features = ["derive","typescript"] }
specta-typescript = "0.0.7"
src-tauri/src/lib.rs 初版
mod cmds;
mod core;
mod error;
mod events;
mod state;
use tauri::Manager;
use tauri_specta::{collect_commands, Builder};
use crate::state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let specta = Builder::<tauri::Wry>::new().commands(collect_commands![
cmds::misc::ping,
]);
#[cfg(debug_assertions)]
specta.export(
specta_typescript::Typescript::default(),
"../src/lib/ipc.ts",
).expect("failed to export TS bindings");
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); }
}))
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_sql::Builder::default().build())
.setup(|app| {
let db_path = app.path().app_data_dir()?.join("cloudtone.sqlite");
let pool = tauri::async_runtime::block_on(core::db::open_db(&db_path))?;
app.manage(AppState::new(pool));
Ok(())
})
.invoke_handler(specta.invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
cmds/misc.rs:
#[tauri::command]
#[specta::specta]
pub async fn ping() -> &'static str { "pong" }
cmds/mod.rs:
pub mod misc;
六、tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CloudTone",
"version": "0.1.0",
"identifier": "dev.codecow.cloudtone",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
{
"label": "main",
"title": "CloudTone",
"width": 1200,
"height": 780,
"minWidth": 960,
"minHeight": 600,
"center": true,
"decorations": false,
"transparent": false,
"resizable": true,
"titleBarStyle": "Overlay"
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: https: http: asset: custom:; media-src 'self' asset: custom: https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http: https:"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/32x32.png","icons/128x128.png","icons/icon.icns","icons/icon.ico"],
"category": "Music",
"shortDescription": "CloudTone 音乐播放器",
"longDescription": "跨平台本地音乐播放器",
"fileAssociations": [
{ "ext": ["mp3","flac","m4a","wav","ogg","aac","aiff","ape"], "name": "CloudTone Audio", "role": "Viewer" }
]
}
}
七、Capability
src-tauri/capabilities/default.json:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:default",
"core:path:default",
"core:webview:default",
"log:default",
"dialog:default",
"notification:default",
"opener:default",
"global-shortcut:default",
"sql:default",
"http:default",
"fs:default",
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$APPDATA/cloudtone/**" }]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$APPDATA/cloudtone/**" }]
},
{
"identifier": "fs:allow-exists",
"allow": [{ "path": "$MUSIC/**" }, { "path": "$HOME/**" }]
}
]
}
八、最小化 React App
src/main.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "@/router";
import "./index.css";
const qc = new QueryClient({ defaultOptions: { queries: { staleTime: 5000 } } });
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
);
src/router.tsx(骨架):
import { createBrowserRouter } from "react-router-dom";
import AppShell from "@/app/AppShell";
import HomePage from "@/app/home/HomePage";
export const router = createBrowserRouter([
{
path: "/",
element: <AppShell />,
children: [
{ index: true, element: <HomePage /> },
],
},
]);
src/app/AppShell.tsx:
import { Outlet } from "react-router-dom";
export default function AppShell() {
return (
<div className="h-screen flex flex-col bg-surface-bg text-gray-100">
<div data-tauri-drag-region className="h-10 flex items-center px-3 border-b border-white/5">
CloudTone
</div>
<div className="flex-1 overflow-auto">
<Outlet />
</div>
<div className="h-20 border-t border-white/5 flex items-center px-4">
(播放控制条 - 下一章实现)
</div>
</div>
);
}
src/app/home/HomePage.tsx:
import { useEffect, useState } from "react";
import { commands } from "@/lib/ipc";
export default function HomePage() {
const [pong, setPong] = useState("");
useEffect(() => { commands.ping().then(setPong); }, []);
return (
<div className="p-6">
<h1 className="text-2xl font-semibold mb-4">欢迎使用 CloudTone</h1>
<p className="text-gray-400">后端应答:{pong || "..."}</p>
</div>
);
}
九、跑起来
pnpm tauri dev
等几分钟,一个窗口亮起:上面显示 "欢迎使用 CloudTone",下面显示 "后端应答:pong"。
常见陷阱
1.
ipc.ts生成失败检查
tauri-specta版本和specta-typescript匹配。Builder 的export调用必须在tauri::Builder之前。
2.
AppShell的 Drag Region 整个屏幕都能拖给其他组件
data-tauri-drag-region={false}或class="!region-no-drag"。
本章小结
骨架已立。后面每一章都在此之上增量。本章交付的目录就是 CloudTone 的"钢筋"。
动手时刻
-
跑通
pnpm tauri dev。 - 看到欢迎界面 + pong。
-
src/lib/ipc.ts被自动生成。 -
打一个 dev build:
pnpm tauri build --debug。
下一章,设计系统与三栏布局。