diff --git a/.env.beta b/.env.beta index eb23526..79d1153 100644 --- a/.env.beta +++ b/.env.beta @@ -1,2 +1,7 @@ VITE_BUILD_MODE=beta VITE_APP_ICON=/beta.png + +VITE_START_POP=true +VITE_START_POP_TITLE="你正在使用 UI 预览版本" +VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失" +VITE_START_POP_BOUTTON="我已了解" \ No newline at end of file diff --git a/.env.development b/.env.development index 468d727..aed8c10 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,7 @@ VITE_BUILD_MODE=dev VITE_APP_ICON=/dev.png + +VITE_START_POP=true +VITE_START_POP_TITLE="你正在使用 UI 预览版本" +VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失" +VITE_START_POP_BOUTTON="我已了解" \ No newline at end of file diff --git a/.env.production b/.env.production index ce78263..1be13d2 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,7 @@ VITE_BUILD_MODE=run VITE_APP_ICON=/run.png + +VITE_START_POP=false +VITE_START_POP_TITLE="你正在使用 UI 预览版本" +VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失" +VITE_START_POP_BOUTTON="我已了解" \ No newline at end of file diff --git a/DEV.md b/DEV.md new file mode 100644 index 0000000..4bd9579 --- /dev/null +++ b/DEV.md @@ -0,0 +1,453 @@ +# Koring Launcher — 开发文档 + +## 技术栈 + +| 层 | 技术 | +|---|---| +| Frontend | React 19 + Vite 7 + TypeScript | +| UI | Tailwind CSS v4 + shadcn/ui (base-ui) | +| 状态管理 | Zustand | +| Backend | Rust / Tauri 2 | +| Sidecar | Node.js / TypeScript / @xmcl/* | +| 包管理 | pnpm | +| 目标平台 | ARM64 Windows (`aarch64-pc-windows-msvc`) | + +## 快速命令 + +```bash +pnpm dev # 前端 dev server (port 1420) +pnpm dev:t # 前端 + Rust dev (需 VS 环境) +./dev-vs.cmd # VS ARM64 环境启动 dev +pnpm build # 生产构建 (switch-icon run + tsc + vite build) +pnpm build:beta # Beta 构建 (switch-icon beta + tsc + vite build --mode beta) +./build-vs.cmd # Rust 编译 (production, NSIS 打包) +./build-vs.cmd --mode beta # Rust 编译 (beta, 跳过打包) +``` + +## 构建模式 + +| 模式 | `VITE_BUILD_MODE` | 图标 | Badge | 说明 | +|---|---|---|---|---| +| dev | `"dev"` | `dev.png` | 🟢 DEV | 开发预览版 | +| beta | `"beta"` | `beta.png` | 🟡 BETA | 测试版 | +| run | `"run"` | `run.png` | 无 | 正式版 | + +模式由 `src/lib/mode.ts` 导出 `BUILD_MODE`, `isDev`, `isBeta`, `isRun`。 + +## 环境变量 (.env.*) + +```env +VITE_BUILD_MODE=dev|beta|run +VITE_APP_ICON=/dev.png|/beta.png|/run.png +VITE_START_POP=true|false # 启动弹窗开关 +VITE_START_POP_TITLE="..." # 弹窗标题 +VITE_START_POP_INFO="..." # 弹窗内容 +VITE_START_POP_BOUTTON="..." # 弹窗按钮文字 +``` + +--- + +## 项目结构 + +``` +koring-launcher/ +├── src/ # 前端源码 +│ ├── App.tsx # 路由入口 (Zustand 路由) +│ ├── index.css # 全局样式 + CSS 变量 + 动画 +│ ├── layouts/ +│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer +│ ├── components/ +│ │ ├── background/ +│ │ │ └── BackgroundLayer.tsx # 全屏背景层 + 视差 + 强内容遮罩 +│ │ ├── system/ +│ │ │ ├── SystemLayer.tsx # 系统层容器 (z-[100]) +│ │ │ ├── TitleBar.tsx # 自定义标题栏 (40px, 胶囊菜单/返回按钮) +│ │ │ └── WindowControls.tsx # 窗口按钮 + DEV/BETA badge + TaskButton +│ │ ├── splash/ +│ │ │ └── Splash.tsx # 启动动画 (独立窗口, HTML+React) +│ │ ├── silk/ +│ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js) +│ │ ├── task/ +│ │ │ ├── TaskButton.tsx # 标题栏任务指示器 (SVG 圆弧动画) +│ │ │ ├── TaskSheet.tsx # 任务队列侧面板 (z-[110]) +│ │ │ └── TaskCard.tsx # 单个任务卡片 (进度条+日志) +│ │ ├── ui/ # shadcn/ui 组件 +│ │ │ ├── sheet.tsx, button.tsx, switch.tsx, slider.tsx +│ │ │ ├── progress.tsx, badge.tsx, separator.tsx +│ │ │ ├── label.tsx, radio-group.tsx, alert-dialog.tsx +│ │ ├── VersionCard.tsx # 版本/更新卡片 (Silk 背景+毛玻璃) +│ │ ├── UnderConstruction.tsx # "装修中" 占位组件 +│ │ └── StartupPopup.tsx # 启动弹窗 (环境变量控制) +│ ├── stores/ # Zustand 状态管理 +│ ├── hooks/ +│ │ └── useTheme.ts # 同步 darkMode → .dark class +│ ├── lib/ +│ │ ├── mode.ts # BUILD_MODE 常量 +│ │ └── utils.ts # cn() 工具函数 +│ ├── api/ # Sidecar IPC 封装 +│ ├── types/ +│ │ └── task.ts # Task 类型定义 +│ └── pages/ # 页面组件 +├── src-tauri/ # Rust 后端 +│ ├── tauri.conf.json # 窗口配置 + Bundle + Updater +│ ├── capabilities/default.json # 权限声明 +│ ├── src/ +│ │ ├── lib.rs # 插件注册 + splash/main 窗口逻辑 +│ │ ├── commands/mod.rs # Tauri 命令 (→ sidecar) +│ │ └── sidecar.rs # Sidecar 进程管理 +│ └── binaries/ # Sidecar 二进制文件 +├── splash.html # 启动动画 HTML 入口 (加载 Splash.tsx) +├── build-vs.cmd # VS 环境编译脚本 +└── dev-vs.cmd # VS 环境开发脚本 +``` + +--- + +## 页面路由 + +### 顶层路由 (标题栏可见) + +| Key | Label | 组件 | 说明 | +|---|---|---|---| +| `home` | 首页 | `pages/home/index.tsx` | 欢迎页 | +| `store` | 资源 | `pages/store/index.tsx` | 🚧 装修中 | +| `today` | 资讯 | `pages/today/index.tsx` | 🚧 装修中 | +| `play-link` | 联机 | `pages/play-link/index.tsx` | 🚧 装修中 | +| `setting` | 设置 | `pages/setting/index.tsx` | 侧边栏 + 内容区 | + +### 隐藏路由 (debug) + +| Key | Label | 组件 | +|---|---|---| +| `debug` | 调试 | `pages/debug/index.tsx` | +| `debug-splash` | 启动动画调试 | `pages/debug/splash-debug.tsx` | +| `debug-display` | 显示效果调试 | `pages/debug/display-debug.tsx` | +| `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` | +| `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` | + +### 路由层级 (返回导航) + +``` +home / store / today / play-link / setting + └─ debug + ├─ debug-splash + ├─ debug-display + ├─ debug-version-card + └─ debug-task +``` + +### 设置子页面 (setting 内部侧边栏) + +| 分组 | Key | 组件 | 状态 | +|---|---|---|---| +| **通用** | `home` | `general/home.tsx` | ✅ 设置首页 (搜索+快捷入口) | +| | `account` | `general/account.tsx` | ✅ Koring 账户 (微软登录) | +| | `game-account` | `game/game-account.tsx` | 🚧 占位 | +| | `about` | `general/about.tsx` | ✅ 关于 (版本卡+链接) | +| | `copyright` | `general/copyright.tsx` | ✅ 版权声明 | +| **游戏** | `java-mem` | `game/java-mem.tsx` | ✅ Java/内存/JVM 参数 | +| | `game-dir` | `game/game-dir.tsx` | ✅ 游戏目录路径 | +| | `advanced` | `game/advanced.tsx` | ✅ 高级设置 | +| **个性化** | `theme-bg` | `personalization/theme-bg.tsx` | ✅ 主题+背景 (文件选择器) | +| | `ui` | `personalization/ui.tsx` | ✅ UI 设置 | +| | `lang` | `personalization/lang.tsx` | ✅ 语言设置 | +| | `a11y` | `personalization/a11y.tsx` | ✅ 无障碍 | +| **网络** | `download` | `network/download.tsx` | ✅ 下载设置 | +| | `security-id` | `network/security-id.tsx` | ✅ 第三方认证 | +| | `ether-online` | `network/ether-online.tsx` | 🚧 占位 | +| | `tawa-online` | `network/tawa-online.tsx` | 🚧 占位 | +| **其他** | `feedback` | `other/feedback.tsx` | 🚧 占位 | +| | `sponsor` | `other/sponsor.tsx` | 🚧 占位 | +| | `developer` | _(→ debug route)_ | 跳转调试页 | + +--- + +## Zustand Stores + +### routeStore + +管理页面路由和标题栏模式。 + +```ts +// State +current: RouteKey // 当前路由 key +titleBarMode: TitleBarMode // "default" | "sub" | "window" +direction: TransitionDirection // "forward" | "backward" + +// Actions +navigate(key) // 跳转路由 (View Transitions API 动画) +goBack() // 返回父路由 (通过 parentMap) +setTitleBarMode(m) // 手动设置标题栏模式 +``` + +### themeStore + +深色模式和视差设置。 + +```ts +// State +darkMode: "auto" | "light" | "dark" // 默认 "auto" +parallax: boolean // 默认 true + +// Actions +setDarkMode(mode) // 应用深色模式到 DOM (.dark class) +setParallax(v) // 设置视差开关 +``` + +### backgroundStore + +背景图片/颜色/模糊/透明度 (localStorage 持久化)。 + +```ts +// State (localStorage: "koring-background") +type: "image" | "color" // 默认 "image" +image: string // 默认 "/background.png" +blur: number // 默认 0 (0-20) +opacity: number // 默认 1 (0-1) + +// Actions +setImage(url) // 设置背景图 (通过 convertFileSrc 转换本地路径) +setColor(color) // 设置纯色背景 +setBlur(blur) // 设置模糊 +setOpacity(op) // 设置透明度 +reset() // 恢复默认 +``` + +### a11yStore + +无障碍设置。 + +```ts +// State +reduceMotion: boolean // 默认 false +reduceTransparency: boolean // 默认 false +highContrast: boolean // 默认 false +contentBlurOpacity: number // 默认 50 (0-100) + +// Actions +setReduceMotion(v) +setReduceTransparency(v) +setHighContrast(v) +setContentBlurOpacity(v) +``` + +### devStore + +开发者调试控制。 + +```ts +// State +forceDisableContentBlur: boolean // 默认 false +previewMode: string | null // 默认 null +previewUpdateState: PreviewUpdateState | null // 默认 null +overlayOpacity: number // 默认 30 +blurAmount: number // 默认 12 + +// Actions +setForceDisableContentBlur(v) +setPreviewMode(v) +setPreviewUpdateState(v) +setOverlayOpacity(v) +setBlurAmount(v) +``` + +### updateStore + +应用更新。 + +```ts +// State +checking: boolean +downloading: boolean +installed: boolean +progress: DownloadProgress | null +update: Update | null +error: string | null + +// Actions +check() // 检查更新 (调用 @tauri-apps/plugin-updater) +install() // 下载并安装 +reset() // 清除状态 +``` + +### taskStore + +任务队列系统 (localStorage 持久化历史)。 + +```ts +// State (localStorage: "koring-task-history", max 50) +tasks: Task[] +sheetOpen: boolean + +// Derived +isRunning() // 是否有运行中/等待中的任务 +activeTasks() // 运行中+等待中的任务列表 +completedTasks() // 已完成/失败/取消的任务列表 + +// Actions +addTask(type, title, desc, executor) // 添加任务并自动开始 +cancelTask(id) // 取消任务 (AbortController) +removeTask(id) // 删除任务 +retryTask(id) // 重试失败任务 +clearHistory() // 清空历史 +openSheet() / closeSheet() +``` + +### authStore + +账户认证 (localStorage: "koring-user")。 + +```ts +// State +user: AuthResult | null // { username, uuid, accessToken, expiresAt, xboxProfile } +loading: boolean +error: string | null + +// Actions +loginOffline(username) +startMicrosoftLogin(clientId) +completeMicrosoftLogin(code, clientId) +logout() +``` + +### installStore + +Minecraft 安装。 + +```ts +// State +versions: VersionManifest | null +installing: boolean +loading: boolean +error: string | null + +// Actions +fetchVersions(type?) +install(version, gamePath, javaPath?) +installLoader(mcVersion, gamePath, loaderType, loaderVersion?, javaPath?) +``` + +### launchStore + +游戏启动。 + +```ts +// State +launching: boolean +launched: boolean +gameResult: LaunchResult | null +events: GameEvent[] +error: string | null + +// Actions +launch(options: LaunchOptions) +diagnose(gamePath, version) +reset() +``` + +### modsStore + +Mod 搜索与安装。 + +```ts +// State +searchResults: ModSearchResult[] +currentMod: ModSearchResult | null +modVersions: ModVersionResult[] + +// Actions +search(query?, gameVersion?, loader?, source?) +getDetail(projectId, source) +getVersions(projectId, gameVersion?, loader?, source?) +install(projectId, versionId, gamePath, source?) +``` + +### instanceStore + +游戏实例管理。 + +```ts +// State +instances: InstanceInfo[] +currentInstance: InstanceInfo | null + +// Actions +fetchInstances(instancesPath) +create(name, gamePath, mcVersion, ...) +remove(name, instancesPath) +select(name, instancesPath) +``` + +--- + +## 核心组件 + +### 三层布局 (RootLayout) + +``` +z-0 BackgroundLayer 全屏背景图 + 视差 + 模糊 + 强内容遮罩 +z-1 ContentLayer 页面内容区 (top: 40px, overflow-auto) +z-100 SystemLayer 自定义标题栏 (TitleBar) +z-110 TaskSheet 任务队列面板 (右滑入) +z-200 StartupPopup 启动弹窗 (环境变量控制) +``` + +### TitleBar 标题栏 + +三种模式: +- **`default`**: 左侧品牌文字 + 中间胶囊菜单 (可拖拽切换) + 右侧窗口控制 +- **`sub`**: 左侧返回按钮 + 品牌文字 + 右侧窗口控制 (隐藏 TaskButton) +- **`window`**: 仅窗口控制 + +### BackgroundLayer 背景层 + +- 支持 `image` (CSS background-image) 和 `color` (CSS background-color) 两种类型 +- 视差效果: 鼠标移动时背景偏移 ±20px, scale(1.05) +- 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制) +- 深色模式叠加层: `bg-black/35` + +### VersionCard 版本卡片 + +- Silk WebGL 动画背景 + 毛玻璃叠加层 +- 三种更新状态: `latest` / `hasUpdate` / `installed` +- 颜色方案: dev=amber, beta=emerald, run=blue +- 可通过 props 覆盖 mode 和 state (用于 debug) + +### TaskQueue 任务系统 + +- 执行器模式: `addTask(type, title, desc, async (ctx) => {...})` +- 支持并行执行 (多个任务同时运行) +- AbortController 取消机制 +- localStorage 持久化历史 (max 50) +- 任务类型: `install` / `download` / `update` / `launch` / `auth` / `sync` / `custom` + +--- + +## Tauri 窗口配置 + +| 窗口 | 尺寸 | 特性 | +|---|---|---| +| splashscreen | 480×320 | 无边框, 透明, 不可缩放, 居中 | +| main | 900×600 (min 800×600) | 无边框, 透明, 隐藏启动 | + +## 权限 (capabilities/default.json) + +```json +core:default, core:window:default, core:window:allow-*, +core:webview:allow-create-webview-window, +opener:default, process:default, shell:allow-execute, +updater:default, dialog:default +``` + +## Rust 插件 + +```toml +tauri-plugin-opener, tauri-plugin-process, tauri-plugin-dialog, +tauri-plugin-shell, tauri-plugin-updater +``` + +## IPC 协议 + +前端 → `invoke()` → Rust `commands::sidecar_request` → sidecar stdin (JSON) → sidecar stdout (JSON) → Tauri 事件 → 前端 + +Sidecar 命令: `install-minecraft`, `install-mod-loader`, `get-version-list`, `launch-game`, `offline-login`, `search-mods`, `install-mod`, `create-instance`, `list-instances`, `background:*` diff --git a/build-vs.cmd b/build-vs.cmd new file mode 100644 index 0000000..459643f --- /dev/null +++ b/build-vs.cmd @@ -0,0 +1,18 @@ +@echo off +call "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat" arm64 +set PATH=C:\Users\OseasyVM\scoop\apps\llvm\current\bin;%PATH% +set CC=clang +cd /d "%~dp0" + +if "%~1"=="--mode" if "%~2"=="beta" ( + echo [build-vs] Beta mode: building frontend first... + call pnpm build:beta + echo [build-vs] Running tauri build with skip beforeBuildCommand... + call npx tauri build --config src-tauri\tauri.beta.json +) else ( + echo [build-vs] Production mode + call pnpm tauri build +) else ( + echo [build-vs] Production mode + call pnpm tauri build +) diff --git a/package.json b/package.json index 8561ca6..8c616bf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "preview": "vite preview", "tauri": "tauri", "dev:t": "node scripts/switch-icon.js dev && tauri dev", - "dev:vs": "./dev-vs.cmd" + "dev:vs": "./dev-vs.cmd", + "build:vs": "./build-vs.cmd", + "build:beta:vs": "./build-vs.cmd --mode beta" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -21,6 +23,8 @@ "@fontsource-variable/inter": "^5.2.8", "@react-three/fiber": "^9.6.1", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c167768..a991d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.11.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.1 + version: 2.7.1 + '@tauri-apps/plugin-fs': + specifier: ^2.5.1 + version: 2.5.1 '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.4 @@ -860,6 +866,12 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + + '@tauri-apps/plugin-fs@2.5.1': + resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} + '@tauri-apps/plugin-opener@2.5.4': resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} @@ -2897,6 +2909,14 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.2 '@tauri-apps/cli-win32-x64-msvc': 2.11.2 + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-fs@2.5.1': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-opener@2.5.4': dependencies: '@tauri-apps/api': 2.11.1 diff --git a/splash.html b/splash.html index c6b62f1..49efe0c 100644 --- a/splash.html +++ b/splash.html @@ -5,68 +5,16 @@ koring-launcher @@ -77,8 +25,8 @@ Koring Launcher
- Provided by Lingke Koring Studio - UI预览版本 + Provided by Koring Studio + © Lingke
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7c6e37e..433f65a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1930,6 +1930,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-shell", @@ -2864,6 +2865,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3731,6 +3756,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 99df4f7..6a79bce 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-process = "2" +tauri-plugin-dialog = "2" tauri-plugin-shell = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/binaries/koring-sidecar-aarch64-pc-windows-msvc.exe b/src-tauri/binaries/koring-sidecar-aarch64-pc-windows-msvc.exe index e69de29..1fa7c58 100644 Binary files a/src-tauri/binaries/koring-sidecar-aarch64-pc-windows-msvc.exe and b/src-tauri/binaries/koring-sidecar-aarch64-pc-windows-msvc.exe differ diff --git a/src-tauri/binaries/koring-sidecar-x86_64-pc-windows-msvc.exe b/src-tauri/binaries/koring-sidecar-x86_64-pc-windows-msvc.exe new file mode 100644 index 0000000..1fa7c58 Binary files /dev/null and b/src-tauri/binaries/koring-sidecar-x86_64-pc-windows-msvc.exe differ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c0d8b6b..6bd149e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -20,6 +20,7 @@ "process:default", "shell:allow-execute", "updater:default", + "dialog:default", { "identifier": "shell:allow-execute", "allow": [ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce73c96..2744111 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(Mutex::new(SidecarManager::new())) @@ -57,8 +58,16 @@ pub fn run() { { let handle = app.handle().clone(); let state = app.state::>(); - let mut sidecar = state.inner().lock().map_err(|e| e.to_string())?; - sidecar.spawn(&handle)?; + match state.inner().lock() { + Ok(mut sidecar) => { + if let Err(e) = sidecar.spawn(&handle) { + eprintln!("[koring] sidecar spawn failed (non-fatal): {}", e); + } + } + Err(e) => { + eprintln!("[koring] sidecar lock failed (non-fatal): {}", e); + } + } } Ok(()) }) diff --git a/src-tauri/tauri.beta.json b/src-tauri/tauri.beta.json new file mode 100644 index 0000000..f9160cb --- /dev/null +++ b/src-tauri/tauri.beta.json @@ -0,0 +1,8 @@ +{ + "build": { + "beforeBuildCommand": "echo skip-beta-build" + }, + "bundle": { + "targets": [] + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1f179cd..9cef4de 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -46,7 +46,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["nsis"], "icon": [ "icons/icon.png" ], diff --git a/src/App.tsx b/src/App.tsx index cb161a6..a2e80b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,10 +6,12 @@ import { Store } from "./pages/store"; import { Today } from "./pages/today"; import { PlayLink } from "./pages/play-link"; import { Setting } from "./pages/setting"; +import { TaskQueue } from "./pages/task-queue"; import { Debug } from "./pages/debug"; import { SplashDebug } from "./pages/debug/splash-debug"; import { DisplayDebug } from "./pages/debug/display-debug"; import { VersionCardDebug } from "./pages/debug/version-card-debug"; +import { TaskDebug } from "./pages/debug/task-debug"; const pageMap = { home: Home, @@ -17,10 +19,12 @@ const pageMap = { today: Today, "play-link": PlayLink, setting: Setting, + "task-queue": TaskQueue, debug: Debug, "debug-splash": SplashDebug, "debug-display": DisplayDebug, "debug-version-card": VersionCardDebug, + "debug-task": TaskDebug, } as const; function App() { diff --git a/src/components/StartupPopup.tsx b/src/components/StartupPopup.tsx new file mode 100644 index 0000000..5e648f9 --- /dev/null +++ b/src/components/StartupPopup.tsx @@ -0,0 +1,47 @@ +import { useState, useEffect } from "react"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; +import { AlertTriangle } from "lucide-react"; + +const enabled = import.meta.env.VITE_START_POP === "true"; +const title = import.meta.env.VITE_START_POP_TITLE ?? ""; +const info = import.meta.env.VITE_START_POP_INFO ?? ""; +const buttonText = import.meta.env.VITE_START_POP_BOUTTON ?? "确定"; + +export function StartupPopup() { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (enabled) { + setOpen(true); + } + }, []); + + if (!enabled) return null; + + return ( + + + +
+ +
+ {title} + {info} +
+ + setOpen(false)}> + {buttonText} + + +
+
+ ); +} diff --git a/src/components/UnderConstruction.tsx b/src/components/UnderConstruction.tsx new file mode 100644 index 0000000..cdc732d --- /dev/null +++ b/src/components/UnderConstruction.tsx @@ -0,0 +1,23 @@ +import { Hammer } from "lucide-react"; + +interface UnderConstructionProps { + pageName: string; + description?: string; +} + +export function UnderConstruction({ pageName, description }: UnderConstructionProps) { + return ( +
+
+
+ +
+

{pageName}

+

此页面正在装修中,也许它很快就会与你见面

+ {description && ( +

{description}

+ )} +
+
+ ); +} diff --git a/src/components/background/BackgroundLayer.tsx b/src/components/background/BackgroundLayer.tsx index 780792b..6dc1cb8 100644 --- a/src/components/background/BackgroundLayer.tsx +++ b/src/components/background/BackgroundLayer.tsx @@ -1,87 +1,73 @@ -import { useEffect } from "react"; +import { useEffect, useRef, useCallback } from "react"; import { useBackgroundStore } from "@/stores/backgroundStore"; +import { useThemeStore } from "@/stores/themeStore"; import { useRouteStore } from "@/stores/routeStore"; import { useDevStore } from "@/stores/devStore"; const DEFAULT_BG = "/background.png"; export function BackgroundLayer() { - const { type, image, color, blur, opacity, animationSpeed, fetchConfig } = useBackgroundStore(); + const { type, image, blur, opacity } = useBackgroundStore(); + const parallax = useThemeStore((s) => s.parallax); const route = useRouteStore((s) => s.current); const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur); const showContentBlur = route !== "home"; + const bgRef = useRef(null); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!parallax || !bgRef.current) return; + const x = (e.clientX / window.innerWidth - 0.5) * 20; + const y = (e.clientY / window.innerHeight - 0.5) * 20; + bgRef.current.style.transform = `translate(${x}px, ${y}px) scale(1.05)`; + }, + [parallax], + ); + useEffect(() => { - fetchConfig(); - }, [fetchConfig]); + if (!parallax) { + if (bgRef.current) bgRef.current.style.transform = ""; + return; + } + window.addEventListener("mousemove", handleMouseMove); + return () => window.removeEventListener("mousemove", handleMouseMove); + }, [parallax, handleMouseMove]); + + const bgUrl = image || DEFAULT_BG; const getBackgroundStyle = (): React.CSSProperties => { const base: React.CSSProperties = { position: "fixed", - inset: 0, + inset: parallax ? -20 : 0, zIndex: 0, pointerEvents: "none", opacity, + transition: parallax ? "transform 0.1s ease-out" : undefined, }; if (blur > 0) { base.filter = `blur(${blur}px)`; } - switch (type) { - case "image": - return { - ...base, - backgroundImage: `url(${image || DEFAULT_BG})`, - backgroundSize: "cover", - backgroundPosition: "center", - }; - case "color": - return { - ...base, - backgroundColor: color || "#1a1a2e", - backgroundImage: `url(${DEFAULT_BG})`, - backgroundSize: "cover", - backgroundPosition: "center", - }; - case "gradient": - return { - ...base, - background: `linear-gradient(135deg, ${color || "#1a1a2e"}, #16213e, #0f3460)`, - animation: `gradient-shift ${10 / animationSpeed}s ease infinite`, - }; - case "particles": - return { - ...base, - background: `radial-gradient(circle at 20% 50%, rgba(${color || "26,26,46"}, 0.8) 0%, transparent 50%), - radial-gradient(circle at 80% 20%, rgba(22, 33, 62, 0.6) 0%, transparent 40%), - radial-gradient(circle at 50% 80%, rgba(15, 52, 96, 0.4) 0%, transparent 60%)`, - animation: `particles-float ${20 / animationSpeed}s ease-in-out infinite`, - }; - default: - return { - ...base, - backgroundImage: `url(${DEFAULT_BG})`, - backgroundSize: "cover", - backgroundPosition: "center", - }; + if (type === "color") { + return { + ...base, + backgroundColor: bgUrl, + }; } + + return { + ...base, + backgroundImage: `url(${bgUrl})`, + backgroundSize: "cover", + backgroundPosition: "center", + }; }; return ( <> - -
+
("enter"); + const [phase, setPhase] = useState<"enter" | "visible">("enter"); + const [version, setVersion] = useState(""); useEffect(() => { - const t1 = setTimeout(() => setPhase("visible"), 50); - const t2 = setTimeout(() => setPhase("exit"), 3500); - return () => { clearTimeout(t1); clearTimeout(t2); }; + getVersion().then(setVersion); + const t = setTimeout(() => setPhase("visible"), 50); + return () => clearTimeout(t); }, []); return ( -
+
{/* Centered logo */} -
+
Koring Launcher {/* Bottom bar */} -
+
Provided by Lingke Koring Studio - v{VERSION} + + {version && v{version}} + {BUILD_MODE !== "run" && ( + + {BUILD_MODE === "dev" ? "DEV" : "BETA"} + + )} +
); diff --git a/src/components/system/TitleBar.tsx b/src/components/system/TitleBar.tsx index be574cb..9f1631c 100644 --- a/src/components/system/TitleBar.tsx +++ b/src/components/system/TitleBar.tsx @@ -245,6 +245,7 @@ export function TitleBar({ showMinimize={showMinimize} showMaximize={showMaximize} showClose={showClose} + isSub={isSub} />
diff --git a/src/components/system/WindowControls.tsx b/src/components/system/WindowControls.tsx index 5e06d36..af5399b 100644 --- a/src/components/system/WindowControls.tsx +++ b/src/components/system/WindowControls.tsx @@ -1,18 +1,21 @@ import { useState, useEffect } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { BUILD_MODE } from "@/lib/mode"; +import { TaskButton } from "@/components/task/TaskButton"; import clsx from "clsx"; interface WindowControlsProps { showMinimize?: boolean; showMaximize?: boolean; showClose?: boolean; + isSub?: boolean; } export function WindowControls({ showMinimize = true, showMaximize = true, showClose = true, + isSub = false, }: WindowControlsProps) { const [isMaximized, setIsMaximized] = useState(false); const appWindow = getCurrentWindow(); @@ -49,6 +52,14 @@ export function WindowControls({ {badgeLabel} )} + + {/* Task button — hidden in sub mode */} + {!isSub && ( +
+ +
+ )} + {showMinimize && (
diff --git a/src/components/task/TaskButton.tsx b/src/components/task/TaskButton.tsx new file mode 100644 index 0000000..9a9b307 --- /dev/null +++ b/src/components/task/TaskButton.tsx @@ -0,0 +1,54 @@ +import { useTaskStore } from "@/stores/taskStore"; +import { useRouteStore } from "@/stores/routeStore"; +import { ListTodo } from "lucide-react"; + +export function TaskButton() { + const navigate = useRouteStore((s) => s.navigate); + const isRunning = useTaskStore((s) => s.isRunning); + const activeTasks = useTaskStore((s) => s.activeTasks); + const completedTasks = useTaskStore((s) => s.completedTasks); + const tasks = useTaskStore((s) => s.tasks); + + const running = isRunning(); + const active = activeTasks(); + const completed = completedTasks(); + const hasAny = tasks.length > 0; + + if (!hasAny) return null; + + const badgeCount = running ? active.length : completed.length; + + return ( + + {badgeCount} + + )} + + ); +} diff --git a/src/components/task/TaskCard.tsx b/src/components/task/TaskCard.tsx new file mode 100644 index 0000000..95a052c --- /dev/null +++ b/src/components/task/TaskCard.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import type { Task } from "@/types/task"; +import { useTaskStore } from "@/stores/taskStore"; +import { Button } from "@/components/ui/button"; +import { Progress, ProgressValue } from "@/components/ui/progress"; +import { + ChevronDown, + X, + Check, + AlertCircle, + Ban, + RefreshCw, +} from "lucide-react"; + +const statusConfig: Record< + Task["status"], + { label: string; color: string; icon: typeof Check; barColor: string } +> = { + pending: { label: "等待中", color: "text-muted-foreground bg-muted/50", icon: RefreshCw, barColor: "bg-muted-foreground/30" }, + running: { label: "运行中", color: "text-foreground bg-foreground/10", icon: RefreshCw, barColor: "bg-primary" }, + completed: { label: "已完成", color: "text-green-600 dark:text-green-400 bg-green-500/10", icon: Check, barColor: "bg-green-500" }, + failed: { label: "失败", color: "text-red-600 dark:text-red-400 bg-red-500/10", icon: AlertCircle, barColor: "bg-red-500" }, + cancelled: { label: "已取消", color: "text-muted-foreground bg-muted/50", icon: Ban, barColor: "bg-muted-foreground/30" }, +}; + +function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function logLevelColor(level: Task["logs"][number]["level"]): string { + switch (level) { + case "error": return "text-red-500"; + case "warn": return "text-amber-500"; + default: return "text-muted-foreground"; + } +} + +interface TaskCardProps { + task: Task; +} + +export function TaskCard({ task }: TaskCardProps) { + const [expanded, setExpanded] = useState(false); + const cancelTask = useTaskStore((s) => s.cancelTask); + const removeTask = useTaskStore((s) => s.removeTask); + const retryTask = useTaskStore((s) => s.retryTask); + + const sc = statusConfig[task.status]; + const isRunning = task.status === "running"; + const isPending = task.status === "pending"; + const isFinished = task.status === "completed" || task.status === "failed" || task.status === "cancelled"; + const canCancel = isRunning || isPending; + const canRetry = task.status === "failed"; + const hasLogs = task.logs.length > 0; + const progressPct = + task.progress && task.progress.total > 0 + ? Math.round((task.progress.current / task.progress.total) * 100) + : undefined; + + return ( +
+
+
+ {/* Content */} +
+
+

{task.title}

+ + {sc.label} + +
+ + {task.description && ( +

{task.description}

+ )} + + {/* Progress */} + {(isRunning || isPending) && ( +
+ + + + {task.progress?.stage && ( +

{task.progress.stage}

+ )} +
+ )} + + {isFinished && task.finishedAt && ( +

+ {new Date(task.finishedAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })} +

+ )} +
+ + {/* Actions */} +
+ {canCancel && ( + + )} + {canRetry && ( + + )} + {isFinished && ( + + )} + {hasLogs && ( + + )} +
+
+
+ + {/* Expandable logs */} + {expanded && hasLogs && ( +
+ {task.logs.map((log, i) => ( +
+ {formatTime(log.time)} + + {log.level === "error" ? "ERR" : log.level === "warn" ? "WRN" : "INF"} + + {log.message} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/task/index.ts b/src/components/task/index.ts new file mode 100644 index 0000000..602e73f --- /dev/null +++ b/src/components/task/index.ts @@ -0,0 +1,2 @@ +export { TaskButton } from "./TaskButton"; +export { TaskCard } from "./TaskCard"; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..fe0ad56 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,185 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + + +
+
+
+
+ ); +} diff --git a/src/pages/debug/index.tsx b/src/pages/debug/index.tsx new file mode 100644 index 0000000..f7c5e71 --- /dev/null +++ b/src/pages/debug/index.tsx @@ -0,0 +1,80 @@ +import { useRouteStore } from "@/stores/routeStore"; +import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical } from "lucide-react"; + +const debugPages = [ + { + key: "debug-splash" as const, + icon: Monitor, + title: "启动动画调试", + desc: "测试 Splash Screen 的显示、关闭与启动流程模拟", + color: "text-blue-500", + bg: "bg-blue-500/10", + }, + { + key: "debug-display" as const, + icon: Paintbrush, + title: "显示效果调试", + desc: "调试背景遮罩、磨砂效果与视觉表现", + color: "text-purple-500", + bg: "bg-purple-500/10", + }, + { + key: "debug-version-card" as const, + icon: CreditCard, + title: "版本卡片调试", + desc: "测试 VersionCard 在不同模式与更新状态下的表现", + color: "text-amber-500", + bg: "bg-amber-500/10", + }, + { + key: "debug-task" as const, + icon: ListTodo, + title: "任务队列调试", + desc: "测试任务调度、进度条、日志与 Sheet 面板", + color: "text-cyan-500", + bg: "bg-cyan-500/10", + }, +]; + +export function Debug() { + const navigate = useRouteStore((s) => s.navigate); + + return ( +
+
+
+ +
+
+

开发者工具

+

调试启动器的各项功能与视觉效果

+
+
+ +
+ {debugPages.map((p) => ( + + ))} +
+ +

+ 这些工具仅用于开发调试,不会影响启动器的正常运行 +

+
+ ); +} diff --git a/src/pages/debug/splash-debug.tsx b/src/pages/debug/splash-debug.tsx new file mode 100644 index 0000000..0ba311b --- /dev/null +++ b/src/pages/debug/splash-debug.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { Play, Square, RotateCw } from "lucide-react"; +import { GlassCard, SettingRow, PageHeader } from "./components"; + +const openSplash = async () => { + try { + const existing = await WebviewWindow.getByLabel("splashscreen"); + if (existing) { + await existing.show(); + await existing.setFocus(); + return; + } + const splash = new WebviewWindow("splashscreen", { + url: "/splash.html", + width: 480, + height: 320, + decorations: false, + transparent: true, + center: true, + visible: true, + resizable: false, + minWidth: 480, + maxWidth: 480, + minHeight: 320, + maxHeight: 320, + } as any); + splash.once("tauri://error", (e) => console.error("Splash window error:", e)); + } catch (err) { + console.error("Failed to open splash:", err); + } +}; + +const closeSplash = async () => { + try { + const splash = await WebviewWindow.getByLabel("splashscreen"); + if (splash) await splash.close(); + } catch (err) { + console.error("Failed to close splash:", err); + } +}; + +export function SplashDebug() { + const [splashVisible, setSplashVisible] = useState(false); + + const handleOpen = async () => { + await openSplash(); + setSplashVisible(true); + }; + + const handleClose = async () => { + await closeSplash(); + setSplashVisible(false); + }; + + return ( +
+ + +
+
+

+ 启动画面控制 +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/src/pages/debug/task-debug.tsx b/src/pages/debug/task-debug.tsx new file mode 100644 index 0000000..c4c7e4d --- /dev/null +++ b/src/pages/debug/task-debug.tsx @@ -0,0 +1,252 @@ +import { useTaskStore } from "@/stores/taskStore"; +import { useRouteStore } from "@/stores/routeStore"; +import type { TaskType } from "@/types/task"; +import { GlassCard, PageHeader } from "./components"; +import { + Download, + ArrowDownToLine, + RefreshCw, + Play, + User, + Zap, + Trash2, + ListTodo, + AlertCircle, + Check, + Ban, +} from "lucide-react"; + +const taskTypes: { type: TaskType; label: string; icon: typeof Download; color: string; bg: string }[] = [ + { type: "install", label: "安装", icon: Download, color: "text-blue-500", bg: "bg-blue-500/10" }, + { type: "download", label: "下载", icon: ArrowDownToLine, color: "text-cyan-500", bg: "bg-cyan-500/10" }, + { type: "update", label: "更新", icon: RefreshCw, color: "text-purple-500", bg: "bg-purple-500/10" }, + { type: "launch", label: "启动", icon: Play, color: "text-green-500", bg: "bg-green-500/10" }, + { type: "auth", label: "认证", icon: User, color: "text-amber-500", bg: "bg-amber-500/10" }, + { type: "sync", label: "同步", icon: RefreshCw, color: "text-indigo-500", bg: "bg-indigo-500/10" }, + { type: "custom", label: "自定义", icon: Zap, color: "text-gray-500", bg: "bg-gray-500/10" }, +]; + +function simulateTask(type: TaskType, title: string, duration: number, shouldFail = false) { + useTaskStore.getState().addTask(type, title, `模拟 ${duration / 1000}s 任务`, async (ctx) => { + const steps = 20; + const interval = duration / steps; + for (let i = 0; i <= steps; i++) { + if (ctx.abortSignal.aborted) throw new Error("已取消"); + ctx.updateProgress({ current: i, total: steps, stage: `步骤 ${i}/${steps}` }); + ctx.addLog("info", `进度 ${Math.round((i / steps) * 100)}%`); + if (i === Math.floor(steps / 2)) { + ctx.addLog("warn", "中间检查点"); + } + if (shouldFail && i === steps - 2) { + ctx.addLog("error", "模拟失败:网络连接超时"); + throw new Error("网络连接超时"); + } + await new Promise((r) => setTimeout(r, interval)); + } + ctx.addLog("info", "任务完成"); + }); +} + +const statusIcons = { + pending: RefreshCw, + running: RefreshCw, + completed: Check, + failed: AlertCircle, + cancelled: Ban, +}; + +export function TaskDebug() { + const tasks = useTaskStore((s) => s.tasks); + const clearHistory = useTaskStore((s) => s.clearHistory); + const removeTask = useTaskStore((s) => s.removeTask); + const navigate = useRouteStore((s) => s.navigate); + + const running = tasks.filter((t) => t.status === "running").length; + const pending = tasks.filter((t) => t.status === "pending").length; + const completed = tasks.filter((t) => t.status === "completed").length; + const failed = tasks.filter((t) => t.status === "failed").length; + + return ( +
+ + + {/* Stats */} +
+ {[ + { label: "运行中", value: running, color: "text-blue-500" }, + { label: "等待中", value: pending, color: "text-muted-foreground" }, + { label: "已完成", value: completed, color: "text-green-500" }, + { label: "失败", value: failed, color: "text-red-500" }, + ].map((s) => ( + +

{s.label}

+

{s.value}

+
+ ))} +
+ + {/* Quick actions */} +
+

+ 快速操作 +

+
+ +
+
+

打开任务队列

+

跳转到任务队列页面查看当前任务列表

+
+ +
+
+ +
+
+

清空所有历史

+

删除 localStorage 中的任务记录

+
+ +
+
+
+
+ + {/* Add tasks by type */} +
+

+ 添加模拟任务 +

+
+ {taskTypes.map((tt) => ( + +
+
+
+ +
+
+

{tt.label}任务

+

+ 模拟 3s 成功任务 +

+
+
+
+ + +
+
+
+ ))} +
+
+ + {/* Batch test */} +
+

+ 批量测试 +

+
+ +
+
+

并行任务测试

+

同时添加 3 个不同类型任务,验证并行执行

+
+ +
+
+ +
+
+

取消任务测试

+

添加 5s 长任务,可在任务队列中取消

+
+ +
+
+
+
+ + {/* Task list */} + {tasks.length > 0 && ( +
+

+ 当前任务 ({tasks.length}) +

+
+ {tasks.map((t) => { + const StatusIcon = statusIcons[t.status]; + return ( + +
+ +
+

{t.title}

+

{t.type} · {t.status} · {t.logs.length} 条日志

+
+ +
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/pages/debug/version-card-debug.tsx b/src/pages/debug/version-card-debug.tsx new file mode 100644 index 0000000..e4e255e --- /dev/null +++ b/src/pages/debug/version-card-debug.tsx @@ -0,0 +1,174 @@ +import { useDevStore } from "@/stores/devStore"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { VersionCard } from "@/components/VersionCard"; +import { checkForUpdates } from "@/api/update"; +import { GlassCard, PageHeader } from "./components"; + +const modeOptions = [ + { key: "dev", label: "开发版", color: "bg-amber-500" }, + { key: "beta", label: "测试版", color: "bg-emerald-500" }, + { key: "run", label: "正式版", color: "bg-blue-500" }, +] as const; + +const updateStateOptions = [ + { key: "latest", label: "最新版" }, + { key: "hasUpdate", label: "有更新" }, + { key: "installed", label: "已下载" }, +] as const; + +export function VersionCardDebug() { + const { + previewMode, + setPreviewMode, + previewUpdateState, + setPreviewUpdateState, + overlayOpacity, + setOverlayOpacity, + blurAmount, + setBlurAmount, + } = useDevStore(); + + const resetPreview = () => { + setPreviewMode(null); + setPreviewUpdateState(null); + setOverlayOpacity(30); + setBlurAmount(12); + }; + + return ( +
+ + + + +
+
+

+ 模式颜色 +

+ +
+ {modeOptions.map((m) => ( + + ))} +
+
+
+ +
+

+ 更新状态模拟 +

+ +
+ {updateStateOptions.map((s) => ( + + ))} +
+
+
+ +
+

+ 磨砂层参数 +

+ +
+
+
+ + 遮罩透明度 + + + {overlayOpacity}% + +
+ + setOverlayOpacity(Array.isArray(v) ? v[0] : v) + } + min={0} + max={100} + step={1} + /> +
+
+
+ + 模糊强度 + + + {blurAmount}px + +
+ + setBlurAmount(Array.isArray(v) ? v[0] : v) + } + min={0} + max={40} + step={1} + /> +
+
+
+
+ +
+

+ 快捷操作 +

+ +
+
+

重置与检查

+

+ 重置所有预览参数或强制检查更新 +

+
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/src/pages/setting/personalization/theme-bg.tsx b/src/pages/setting/personalization/theme-bg.tsx index bfc5be3..b5bff2d 100644 --- a/src/pages/setting/personalization/theme-bg.tsx +++ b/src/pages/setting/personalization/theme-bg.tsx @@ -1,10 +1,11 @@ import { useThemeStore, type DarkMode } from "@/stores/themeStore"; import { useBackgroundStore } from "@/stores/backgroundStore"; -import { useA11yStore } from "@/stores/a11yStore"; import { Switch } from "@/components/ui/switch"; import { Slider } from "@/components/ui/slider"; import { Button } from "@/components/ui/button"; import clsx from "clsx"; +import { open } from "@tauri-apps/plugin-dialog"; +import { convertFileSrc } from "@tauri-apps/api/core"; function GlassCard({ children }: { children: React.ReactNode }) { return
{children}
; @@ -107,11 +108,18 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte export function ThemeBgSetting() { const { darkMode, setDarkMode, parallax, setParallax } = useThemeStore(); - const { opacity, setOpacity, blur, setBlur, reset } = useBackgroundStore(); - const { contentBlurOpacity, setContentBlurOpacity } = useA11yStore(); + const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore(); const handlePickImage = async () => { - // TODO: 打开文件选择器 + const selected = await open({ + multiple: false, + filters: [ + { name: "图片", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }, + ], + }); + if (selected) { + setImage(convertFileSrc(selected)); + } }; const handleReset = async () => { @@ -146,6 +154,15 @@ export function ThemeBgSetting() { 选择图片 + {image && image !== "/background.png" && ( +
+ 背景预览 +
+ )} @@ -183,23 +200,6 @@ export function ThemeBgSetting() { - -
-
-

强内容遮罩不透明度

-

设置页面背景模糊遮罩的不透明度,当前 {contentBlurOpacity}%

-
- setContentBlurOpacity(Array.isArray(v) ? v[0] : v)} - /> -
-
- + )} +
+ + {/* Task list */} + {tasks.length === 0 ? ( +
+ +

暂无任务

+
+ ) : ( +
+ {activeTasks.length > 0 && ( +
+

+ 进行中 +

+
+ {activeTasks.map((t) => ( + + ))} +
+
+ )} + + {completedTasks.length > 0 && ( +
+

+ 已完成 +

+
+ {completedTasks.map((t) => ( + + ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/splash.tsx b/src/splash.tsx deleted file mode 100644 index cd1defa..0000000 --- a/src/splash.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./index.css"; -import Splash from "./components/splash/Splash"; - -createRoot(document.getElementById("root")!).render( - - - -); diff --git a/src/stores/backgroundStore.ts b/src/stores/backgroundStore.ts index c145543..0d28331 100644 --- a/src/stores/backgroundStore.ts +++ b/src/stores/backgroundStore.ts @@ -1,133 +1,74 @@ import { create } from "zustand"; -import { - setImageBackground, - setColorBackground, - setBackgroundBlur, - setBackgroundOpacity, - setBackgroundAnimation, - getBackgroundConfig, - setTheme, - resetBackground, -} from "../api/background"; -import type { AnimationType, Theme, BackgroundConfig } from "../api/background"; -interface BackgroundState { - type: "image" | "color" | "gradient" | "particles"; - image?: string; - color?: string; +const STORAGE_KEY = "koring-background"; + +type BackgroundType = "image" | "color"; + +interface BackgroundConfig { + type: BackgroundType; + image: string; blur: number; opacity: number; - animation: AnimationType; - animationSpeed: number; - theme: Theme; - loading: boolean; - error: string | null; - - setImage: (url: string, blur?: number, opacity?: number) => Promise; - setColor: (color: string) => Promise; - setBlur: (blur: number) => Promise; - setOpacity: (opacity: number) => Promise; - setAnimation: (type: AnimationType, speed?: number) => Promise; - setTheme: (theme: Theme) => Promise; - fetchConfig: () => Promise; - reset: () => Promise; - clearError: () => void; } -const defaultConfig: BackgroundConfig = { - type: "color", - color: "#1a1a2e", +const DEFAULT_CONFIG: BackgroundConfig = { + type: "image", + image: "/background.png", blur: 0, opacity: 1, - animation: "none", - animationSpeed: 1, - theme: "dark", }; +function loadConfig(): BackgroundConfig { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; + } catch {} + return { ...DEFAULT_CONFIG }; +} + +function saveConfig(config: BackgroundConfig) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); +} + +interface BackgroundState extends BackgroundConfig { + setImage: (url: string) => void; + setColor: (color: string) => void; + setBlur: (blur: number) => void; + setOpacity: (opacity: number) => void; + reset: () => void; +} + export const useBackgroundStore = create((set) => ({ - ...defaultConfig, - loading: false, - error: null, + ...loadConfig(), - setImage: async (url, blur, opacity) => { - set({ loading: true, error: null }); - try { - const config = await setImageBackground(url, blur, opacity); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } + setImage: (url) => { + const next: BackgroundConfig = { type: "image", image: url, blur: 0, opacity: 1 }; + saveConfig(next); + set(next); }, - setColor: async (color) => { - set({ loading: true, error: null }); - try { - const config = await setColorBackground(color); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } + setColor: (color) => { + const next: BackgroundConfig = { type: "color", image: color, blur: 0, opacity: 1 }; + saveConfig(next); + set(next); }, - setBlur: async (blur) => { - set({ loading: true, error: null }); - try { - const config = await setBackgroundBlur(blur); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } + setBlur: (blur) => { + const config = loadConfig(); + config.blur = blur; + saveConfig(config); + set({ blur }); }, - setOpacity: async (opacity) => { - set({ loading: true, error: null }); - try { - const config = await setBackgroundOpacity(opacity); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } + setOpacity: (opacity) => { + const config = loadConfig(); + config.opacity = opacity; + saveConfig(config); + set({ opacity }); }, - setAnimation: async (type, speed) => { - set({ loading: true, error: null }); - try { - const config = await setBackgroundAnimation(type, speed); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } + reset: () => { + saveConfig(DEFAULT_CONFIG); + set({ ...DEFAULT_CONFIG }); }, - - setTheme: async (theme) => { - set({ loading: true, error: null }); - try { - const config = await setTheme(theme); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } - }, - - fetchConfig: async () => { - set({ loading: true, error: null }); - try { - const config = await getBackgroundConfig(); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } - }, - - reset: async () => { - set({ loading: true, error: null }); - try { - const config = await resetBackground(); - set({ ...config, loading: false }); - } catch (e: any) { - set({ error: e.message, loading: false }); - } - }, - - clearError: () => set({ error: null }), })); diff --git a/src/stores/routeStore.ts b/src/stores/routeStore.ts index 399d116..6da9044 100644 --- a/src/stores/routeStore.ts +++ b/src/stores/routeStore.ts @@ -6,10 +6,12 @@ export type RouteKey = | "today" | "play-link" | "setting" + | "task-queue" | "debug" | "debug-splash" | "debug-display" - | "debug-version-card"; + | "debug-version-card" + | "debug-task"; export type TitleBarMode = "default" | "sub" | "window"; @@ -32,17 +34,21 @@ export const routes: RouteItem[] = [ export const allRoutes: RouteItem[] = [ ...routes, + { key: "task-queue", label: "任务队列", path: "/task-queue", hidden: true }, { key: "debug", label: "调试", path: "/debug", hidden: true }, { key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true }, { key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true }, { key: "debug-version-card", label: "版本卡片调试", path: "/debug/version-card", hidden: true }, + { key: "debug-task", label: "任务队列调试", path: "/debug/task", hidden: true }, ]; const parentMap: Partial> = { + "task-queue": "home", debug: "setting", "debug-splash": "debug", "debug-display": "debug", "debug-version-card": "debug", + "debug-task": "debug", }; const topLevelKeys = new Set(routes.map((r) => r.key)); diff --git a/src/stores/taskStore.ts b/src/stores/taskStore.ts new file mode 100644 index 0000000..be7d455 --- /dev/null +++ b/src/stores/taskStore.ts @@ -0,0 +1,232 @@ +import { create } from "zustand"; +import type { Task, TaskType, TaskProgress, TaskLog, TaskContext } from "@/types/task"; + +const STORAGE_KEY = "koring-task-history"; +const MAX_HISTORY = 50; + +function generateId(): string { + return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function loadHistory(): Task[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.slice(0, MAX_HISTORY) : []; + } catch { + return []; + } +} + +function saveHistory(tasks: Task[]) { + try { + const completed = tasks + .filter((t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled") + .slice(0, MAX_HISTORY); + localStorage.setItem(STORAGE_KEY, JSON.stringify(completed)); + } catch { + /* ignore quota errors */ + } +} + +interface TaskExecutor { + (ctx: TaskContext): Promise; +} + +interface TaskState { + tasks: Task[]; + executors: Map; + abortControllers: Map; + + // Derived + isRunning: () => boolean; + activeTasks: () => Task[]; + completedTasks: () => Task[]; + pendingCount: () => number; + runningCount: () => number; + + // Actions + addTask: (type: TaskType, title: string, description: string | undefined, executor: TaskExecutor) => string; + cancelTask: (id: string) => void; + removeTask: (id: string) => void; + clearHistory: () => void; + retryTask: (id: string) => void; + _startTask: (id: string) => void; +} + +export const useTaskStore = create((set, get) => ({ + tasks: loadHistory(), + executors: new Map(), + abortControllers: new Map(), + + isRunning: () => get().tasks.some((t) => t.status === "running" || t.status === "pending"), + activeTasks: () => get().tasks.filter((t) => t.status === "running" || t.status === "pending"), + completedTasks: () => get().tasks.filter((t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled"), + pendingCount: () => get().tasks.filter((t) => t.status === "pending").length, + runningCount: () => get().tasks.filter((t) => t.status === "running").length, + + addTask: (type, title, description, executor) => { + const id = generateId(); + const task: Task = { + id, + type, + title, + description, + status: "pending", + logs: [], + createdAt: Date.now(), + }; + + set((state) => { + const tasks = [...state.tasks, task]; + const executors = new Map(state.executors); + executors.set(id, executor); + return { tasks, executors }; + }); + + // Auto-start + get()._startTask(id); + return id; + }, + + cancelTask: (id) => { + const { abortControllers } = get(); + const controller = abortControllers.get(id); + if (controller) { + controller.abort(); + } + set((state) => { + const tasks = state.tasks.map((t) => + t.id === id && (t.status === "pending" || t.status === "running") + ? { ...t, status: "cancelled" as const, finishedAt: Date.now() } + : t, + ); + const abortControllers = new Map(state.abortControllers); + abortControllers.delete(id); + saveHistory(tasks); + return { tasks, abortControllers }; + }); + }, + + removeTask: (id) => { + set((state) => { + const tasks = state.tasks.filter((t) => t.id !== id); + const executors = new Map(state.executors); + executors.delete(id); + saveHistory(tasks); + return { tasks, executors }; + }); + }, + + clearHistory: () => { + set((state) => { + const tasks = state.tasks.filter((t) => t.status === "running" || t.status === "pending"); + saveHistory(tasks); + return { tasks }; + }); + }, + + retryTask: (id) => { + const { tasks, executors } = get(); + const original = tasks.find((t) => t.id === id); + const executor = executors.get(id); + if (!original || !executor) return; + + const newTask: Task = { + ...original, + id: generateId(), + status: "pending", + progress: undefined, + logs: [], + createdAt: Date.now(), + startedAt: undefined, + finishedAt: undefined, + }; + + set((state) => { + const tasks = [...state.tasks, newTask]; + const executors = new Map(state.executors); + executors.set(newTask.id, executor); + return { tasks, executors }; + }); + + get()._startTask(newTask.id); + }, + + _startTask: (id: string) => { + const { tasks, executors } = get(); + const task = tasks.find((t) => t.id === id); + const executor = executors.get(id); + if (!task || task.status !== "pending" || !executor) return; + + const controller = new AbortController(); + set((state) => { + const abortControllers = new Map(state.abortControllers); + abortControllers.set(id, controller); + const tasks = state.tasks.map((t) => + t.id === id + ? { ...t, status: "running" as const, startedAt: Date.now() } + : t, + ); + return { tasks, abortControllers }; + }); + + const ctx: TaskContext = { + updateProgress: (progress: TaskProgress) => { + set((state) => ({ + tasks: state.tasks.map((t) => + t.id === id ? { ...t, progress } : t, + ), + })); + }, + addLog: (level: TaskLog["level"], message: string) => { + const log: TaskLog = { time: Date.now(), level, message }; + set((state) => ({ + tasks: state.tasks.map((t) => + t.id === id ? { ...t, logs: [...t.logs, log] } : t, + ), + })); + }, + abortSignal: controller.signal, + }; + + executor(ctx) + .then(() => { + if (controller.signal.aborted) return; + set((state) => { + const tasks = state.tasks.map((t) => + t.id === id + ? { ...t, status: "completed" as const, finishedAt: Date.now() } + : t, + ); + saveHistory(tasks); + return { tasks }; + }); + }) + .catch((err) => { + if (controller.signal.aborted) return; + const message = err instanceof Error ? err.message : String(err); + set((state) => { + const tasks = state.tasks.map((t) => + t.id === id + ? { + ...t, + status: "failed" as const, + finishedAt: Date.now(), + logs: [...t.logs, { time: Date.now(), level: "error" as const, message }], + } + : t, + ); + saveHistory(tasks); + return { tasks }; + }); + }) + .finally(() => { + const { abortControllers } = get(); + const next = new Map(abortControllers); + next.delete(id); + set({ abortControllers: next }); + }); + }, +})); diff --git a/src/types/task.ts b/src/types/task.ts new file mode 100644 index 0000000..d7dbe29 --- /dev/null +++ b/src/types/task.ts @@ -0,0 +1,34 @@ +export type TaskType = "install" | "download" | "update" | "launch" | "auth" | "sync" | "custom"; + +export type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; + +export interface TaskLog { + time: number; + level: "info" | "warn" | "error"; + message: string; +} + +export interface TaskProgress { + current: number; + total: number; + stage?: string; +} + +export interface Task { + id: string; + type: TaskType; + title: string; + description?: string; + status: TaskStatus; + progress?: TaskProgress; + logs: TaskLog[]; + createdAt: number; + startedAt?: number; + finishedAt?: number; +} + +export interface TaskContext { + updateProgress: (progress: TaskProgress) => void; + addLog: (level: TaskLog["level"], message: string) => void; + abortSignal: AbortSignal; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..9943448 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + readonly VITE_START_POP: string; + readonly VITE_START_POP_TITLE: string; + readonly VITE_START_POP_INFO: string; + readonly VITE_START_POP_BOUTTON: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index f8b7087..c9fd2e2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig(async () => ({ rollupOptions: { input: { main: path.resolve(__dirname, "index.html"), + splash: path.resolve(__dirname, "splash.html"), }, }, },