mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
UI完善
This commit is contained in:
@@ -1,2 +1,7 @@
|
|||||||
VITE_BUILD_MODE=beta
|
VITE_BUILD_MODE=beta
|
||||||
VITE_APP_ICON=/beta.png
|
VITE_APP_ICON=/beta.png
|
||||||
|
|
||||||
|
VITE_START_POP=true
|
||||||
|
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
|
||||||
|
VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失"
|
||||||
|
VITE_START_POP_BOUTTON="我已了解"
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
VITE_BUILD_MODE=dev
|
VITE_BUILD_MODE=dev
|
||||||
VITE_APP_ICON=/dev.png
|
VITE_APP_ICON=/dev.png
|
||||||
|
|
||||||
|
VITE_START_POP=true
|
||||||
|
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
|
||||||
|
VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失"
|
||||||
|
VITE_START_POP_BOUTTON="我已了解"
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
VITE_BUILD_MODE=run
|
VITE_BUILD_MODE=run
|
||||||
VITE_APP_ICON=/run.png
|
VITE_APP_ICON=/run.png
|
||||||
|
|
||||||
|
VITE_START_POP=false
|
||||||
|
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
|
||||||
|
VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失"
|
||||||
|
VITE_START_POP_BOUTTON="我已了解"
|
||||||
@@ -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:*`
|
||||||
@@ -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
|
||||||
|
)
|
||||||
+5
-1
@@ -13,7 +13,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"dev:t": "node scripts/switch-icon.js dev && tauri dev",
|
"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": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
@@ -21,6 +23,8 @@
|
|||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@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-opener": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
|||||||
Generated
+20
@@ -23,6 +23,12 @@ importers:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.11.1
|
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':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.5.4
|
version: 2.5.4
|
||||||
@@ -860,6 +866,12 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
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':
|
'@tauri-apps/plugin-opener@2.5.4':
|
||||||
resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
|
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-ia32-msvc': 2.11.2
|
||||||
'@tauri-apps/cli-win32-x64-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':
|
'@tauri-apps/plugin-opener@2.5.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|||||||
+11
-63
@@ -5,68 +5,16 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>koring-launcher</title>
|
<title>koring-launcher</title>
|
||||||
<style>
|
<style>
|
||||||
* {
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
margin: 0;
|
html, body, #root { width: 100%; height: 100%; overflow: hidden; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
padding: 0;
|
.container { width: 100%; height: 100%; display: flex; flex-direction: column; background: #ffffff; transition: background 0.3s; }
|
||||||
box-sizing: border-box;
|
.logo-area { flex: 1; display: flex; align-items: center; justify-content: center; }
|
||||||
}
|
.logo-area img { width: 260px; height: auto; animation: fadeIn 0.6s ease-out; }
|
||||||
|
.bottom-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 24px 16px; font-size: 12px; color: #888888; }
|
||||||
html, body, #root {
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #ffffff;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-area {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-area img {
|
|
||||||
width: 260px;
|
|
||||||
height: auto;
|
|
||||||
animation: fadeIn 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 24px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888888;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.container {
|
.container { background: #1a1a2e; }
|
||||||
background: #1a1a2e;
|
.logo-area img { filter: invert(1); }
|
||||||
}
|
|
||||||
.logo-area img {
|
|
||||||
filter: invert(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -77,8 +25,8 @@
|
|||||||
<img src="/koring-licon.svg" alt="Koring Launcher" />
|
<img src="/koring-licon.svg" alt="Koring Launcher" />
|
||||||
</div>
|
</div>
|
||||||
<div class="bottom-bar">
|
<div class="bottom-bar">
|
||||||
<span>Provided by Lingke Koring Studio</span>
|
<span>Provided by Koring Studio</span>
|
||||||
<span>UI预览版本</span>
|
<span>© Lingke</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+67
@@ -1930,6 +1930,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
@@ -2864,6 +2865,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3731,6 +3756,48 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.4"
|
version = "2.5.4"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,7 @@
|
|||||||
"process:default",
|
"process:default",
|
||||||
"shell:allow-execute",
|
"shell:allow-execute",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
|
"dialog:default",
|
||||||
{
|
{
|
||||||
"identifier": "shell:allow-execute",
|
"identifier": "shell:allow-execute",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
|||||||
+11
-2
@@ -10,6 +10,7 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.manage(Mutex::new(SidecarManager::new()))
|
.manage(Mutex::new(SidecarManager::new()))
|
||||||
@@ -57,8 +58,16 @@ pub fn run() {
|
|||||||
{
|
{
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
let state = app.state::<Mutex<SidecarManager>>();
|
let state = app.state::<Mutex<SidecarManager>>();
|
||||||
let mut sidecar = state.inner().lock().map_err(|e| e.to_string())?;
|
match state.inner().lock() {
|
||||||
sidecar.spawn(&handle)?;
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"beforeBuildCommand": "echo skip-beta-build"
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"targets": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["nsis"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/icon.png"
|
"icons/icon.png"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { Store } from "./pages/store";
|
|||||||
import { Today } from "./pages/today";
|
import { Today } from "./pages/today";
|
||||||
import { PlayLink } from "./pages/play-link";
|
import { PlayLink } from "./pages/play-link";
|
||||||
import { Setting } from "./pages/setting";
|
import { Setting } from "./pages/setting";
|
||||||
|
import { TaskQueue } from "./pages/task-queue";
|
||||||
import { Debug } from "./pages/debug";
|
import { Debug } from "./pages/debug";
|
||||||
import { SplashDebug } from "./pages/debug/splash-debug";
|
import { SplashDebug } from "./pages/debug/splash-debug";
|
||||||
import { DisplayDebug } from "./pages/debug/display-debug";
|
import { DisplayDebug } from "./pages/debug/display-debug";
|
||||||
import { VersionCardDebug } from "./pages/debug/version-card-debug";
|
import { VersionCardDebug } from "./pages/debug/version-card-debug";
|
||||||
|
import { TaskDebug } from "./pages/debug/task-debug";
|
||||||
|
|
||||||
const pageMap = {
|
const pageMap = {
|
||||||
home: Home,
|
home: Home,
|
||||||
@@ -17,10 +19,12 @@ const pageMap = {
|
|||||||
today: Today,
|
today: Today,
|
||||||
"play-link": PlayLink,
|
"play-link": PlayLink,
|
||||||
setting: Setting,
|
setting: Setting,
|
||||||
|
"task-queue": TaskQueue,
|
||||||
debug: Debug,
|
debug: Debug,
|
||||||
"debug-splash": SplashDebug,
|
"debug-splash": SplashDebug,
|
||||||
"debug-display": DisplayDebug,
|
"debug-display": DisplayDebug,
|
||||||
"debug-version-card": VersionCardDebug,
|
"debug-version-card": VersionCardDebug,
|
||||||
|
"debug-task": TaskDebug,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<div className="mb-2 inline-flex size-10 items-center justify-center rounded-md bg-amber-500/10 sm:group-data-[size=default]/alert-dialog-content:row-span-2">
|
||||||
|
<AlertTriangle className="size-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{info}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction onClick={() => setOpen(false)}>
|
||||||
|
{buttonText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Hammer } from "lucide-react";
|
||||||
|
|
||||||
|
interface UnderConstructionProps {
|
||||||
|
pageName: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnderConstruction({ pageName, description }: UnderConstructionProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center max-w-sm">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-foreground/[0.04] mb-5">
|
||||||
|
<Hammer className="w-8 h-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-2">{pageName}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">此页面正在装修中,也许它很快就会与你见面</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-[13px] text-muted-foreground/60">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,87 +1,73 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import { useBackgroundStore } from "@/stores/backgroundStore";
|
import { useBackgroundStore } from "@/stores/backgroundStore";
|
||||||
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
import { useRouteStore } from "@/stores/routeStore";
|
import { useRouteStore } from "@/stores/routeStore";
|
||||||
import { useDevStore } from "@/stores/devStore";
|
import { useDevStore } from "@/stores/devStore";
|
||||||
|
|
||||||
const DEFAULT_BG = "/background.png";
|
const DEFAULT_BG = "/background.png";
|
||||||
|
|
||||||
export function BackgroundLayer() {
|
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 route = useRouteStore((s) => s.current);
|
||||||
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
|
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
|
||||||
const showContentBlur = route !== "home";
|
const showContentBlur = route !== "home";
|
||||||
|
|
||||||
|
const bgRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
if (!parallax) {
|
||||||
}, [fetchConfig]);
|
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 getBackgroundStyle = (): React.CSSProperties => {
|
||||||
const base: React.CSSProperties = {
|
const base: React.CSSProperties = {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: parallax ? -20 : 0,
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
opacity,
|
opacity,
|
||||||
|
transition: parallax ? "transform 0.1s ease-out" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (blur > 0) {
|
if (blur > 0) {
|
||||||
base.filter = `blur(${blur}px)`;
|
base.filter = `blur(${blur}px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
if (type === "color") {
|
||||||
case "image":
|
return {
|
||||||
return {
|
...base,
|
||||||
...base,
|
backgroundColor: bgUrl,
|
||||||
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",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
backgroundImage: `url(${bgUrl})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<div ref={bgRef} style={getBackgroundStyle()} />
|
||||||
@keyframes gradient-shift {
|
|
||||||
0%, 100% { background-position: 0% 50%; }
|
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
}
|
|
||||||
@keyframes particles-float {
|
|
||||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
|
||||||
33% { transform: translateY(-10px) rotate(1deg); }
|
|
||||||
66% { transform: translateY(10px) rotate(-1deg); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div style={getBackgroundStyle()} />
|
|
||||||
<div
|
<div
|
||||||
className="content-blur-overlay"
|
className="content-blur-overlay"
|
||||||
style={{ opacity: showContentBlur && !forceDisableContentBlur ? 1 : 0 }}
|
style={{ opacity: showContentBlur && !forceDisableContentBlur ? 1 : 0 }}
|
||||||
|
|||||||
@@ -1,35 +1,33 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
const VERSION = "0.1.0";
|
import { BUILD_MODE } from "@/lib/mode";
|
||||||
|
|
||||||
export default function Splash() {
|
export default function Splash() {
|
||||||
const [phase, setPhase] = useState<"enter" | "visible" | "exit">("enter");
|
const [phase, setPhase] = useState<"enter" | "visible">("enter");
|
||||||
|
const [version, setVersion] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t1 = setTimeout(() => setPhase("visible"), 50);
|
getVersion().then(setVersion);
|
||||||
const t2 = setTimeout(() => setPhase("exit"), 3500);
|
const t = setTimeout(() => setPhase("visible"), 50);
|
||||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
return () => clearTimeout(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
width: "100%",
|
className="w-full h-full flex flex-col overflow-hidden"
|
||||||
height: "100%",
|
style={{
|
||||||
display: "flex",
|
background: "var(--splash-bg)",
|
||||||
flexDirection: "column",
|
}}
|
||||||
overflow: "hidden",
|
>
|
||||||
background: "var(--splash-bg)",
|
|
||||||
}}>
|
|
||||||
{/* Centered logo */}
|
{/* Centered logo */}
|
||||||
<div style={{
|
<div
|
||||||
flex: 1,
|
className="flex-1 flex items-center justify-center"
|
||||||
display: "flex",
|
style={{
|
||||||
alignItems: "center",
|
opacity: phase === "enter" ? 0 : 1,
|
||||||
justifyContent: "center",
|
transform: phase === "enter" ? "translateY(10px)" : "translateY(0)",
|
||||||
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
|
transition: "all 0.7s ease-out",
|
||||||
transform: phase === "enter" ? "translateY(10px)" : phase === "exit" ? "translateY(-10px)" : "translateY(0)",
|
}}
|
||||||
transition: "all 0.7s ease-out",
|
>
|
||||||
}}>
|
|
||||||
<img
|
<img
|
||||||
src="/koring-licon.svg"
|
src="/koring-licon.svg"
|
||||||
alt="Koring Launcher"
|
alt="Koring Launcher"
|
||||||
@@ -39,32 +37,31 @@ export default function Splash() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar */}
|
{/* Bottom bar */}
|
||||||
<div style={{
|
<div className="flex items-center justify-between px-6 pb-4 text-xs text-muted-foreground">
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "0 24px 16px",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--splash-muted)",
|
|
||||||
}}>
|
|
||||||
<span>Provided by Lingke Koring Studio</span>
|
<span>Provided by Lingke Koring Studio</span>
|
||||||
<span>v{VERSION}</span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
{version && <span>v{version}</span>}
|
||||||
|
{BUILD_MODE !== "run" && (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none select-none",
|
||||||
|
BUILD_MODE === "dev"
|
||||||
|
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{BUILD_MODE === "dev" ? "DEV" : "BETA"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
:root {
|
:root {
|
||||||
--splash-bg: #ffffff;
|
--splash-bg: #ffffff;
|
||||||
--splash-muted: #888888;
|
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--splash-bg: #1a1a2e;
|
--splash-bg: #1a1a2e;
|
||||||
--splash-muted: #888888;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root:not(.light) {
|
|
||||||
--splash-bg: #1a1a2e;
|
|
||||||
--splash-muted: #888888;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.splash-logo {
|
.splash-logo {
|
||||||
filter: none;
|
filter: none;
|
||||||
@@ -72,11 +69,6 @@ export default function Splash() {
|
|||||||
.dark .splash-logo {
|
.dark .splash-logo {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root:not(.light) .splash-logo {
|
|
||||||
filter: invert(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export function TitleBar({
|
|||||||
showMinimize={showMinimize}
|
showMinimize={showMinimize}
|
||||||
showMaximize={showMaximize}
|
showMaximize={showMaximize}
|
||||||
showClose={showClose}
|
showClose={showClose}
|
||||||
|
isSub={isSub}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { BUILD_MODE } from "@/lib/mode";
|
import { BUILD_MODE } from "@/lib/mode";
|
||||||
|
import { TaskButton } from "@/components/task/TaskButton";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface WindowControlsProps {
|
interface WindowControlsProps {
|
||||||
showMinimize?: boolean;
|
showMinimize?: boolean;
|
||||||
showMaximize?: boolean;
|
showMaximize?: boolean;
|
||||||
showClose?: boolean;
|
showClose?: boolean;
|
||||||
|
isSub?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WindowControls({
|
export function WindowControls({
|
||||||
showMinimize = true,
|
showMinimize = true,
|
||||||
showMaximize = true,
|
showMaximize = true,
|
||||||
showClose = true,
|
showClose = true,
|
||||||
|
isSub = false,
|
||||||
}: WindowControlsProps) {
|
}: WindowControlsProps) {
|
||||||
const [isMaximized, setIsMaximized] = useState(false);
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
@@ -49,6 +52,14 @@ export function WindowControls({
|
|||||||
{badgeLabel}
|
{badgeLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Task button — hidden in sub mode */}
|
||||||
|
{!isSub && (
|
||||||
|
<div className="mr-0.5">
|
||||||
|
<TaskButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showMinimize && (
|
{showMinimize && (
|
||||||
<div onClick={handleMinimize} className={btnClass}>
|
<div onClick={handleMinimize} className={btnClass}>
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("task-queue")}
|
||||||
|
className="flex items-center justify-center w-[25px] h-[25px] rounded transition-colors cursor-default hover:bg-black/10 dark:hover:bg-white/15 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white relative"
|
||||||
|
data-no-drag
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
/* Circular progress indicator */
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 16 16">
|
||||||
|
<circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="37.7"
|
||||||
|
strokeDashoffset="9.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="animate-spin origin-center"
|
||||||
|
style={{ animationDuration: "1.2s" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<ListTodo className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{badgeCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-bold px-1 tabular-nums">
|
||||||
|
{badgeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="rounded-xl bg-muted/30 overflow-hidden">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{task.title}</p>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${sc.color}`}>
|
||||||
|
{sc.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-[12px] text-muted-foreground truncate mb-2">{task.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{(isRunning || isPending) && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<Progress
|
||||||
|
value={progressPct ?? (isPending ? 0 : 0)}
|
||||||
|
className="gap-0"
|
||||||
|
>
|
||||||
|
<ProgressValue className="text-[11px]" />
|
||||||
|
</Progress>
|
||||||
|
{task.progress?.stage && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">{task.progress.stage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFinished && task.finishedAt && (
|
||||||
|
<p className="text-[11px] text-muted-foreground/50 mt-1">
|
||||||
|
{new Date(task.finishedAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{canCancel && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => cancelTask(task.id)}
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canRetry && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => retryTask(task.id)}
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isFinished && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => removeTask(task.id)}
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasLogs && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="p-1 rounded hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3.5 h-3.5 text-muted-foreground transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable logs */}
|
||||||
|
{expanded && hasLogs && (
|
||||||
|
<div className="px-4 py-2 max-h-[200px] overflow-y-auto bg-black/[0.03] dark:bg-white/[0.03]">
|
||||||
|
{task.logs.map((log, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 py-0.5 text-[11px] leading-tight font-mono">
|
||||||
|
<span className="text-muted-foreground/50 shrink-0 tabular-nums">{formatTime(log.time)}</span>
|
||||||
|
<span className={`shrink-0 ${logLevelColor(log.level)}`}>
|
||||||
|
{log.level === "error" ? "ERR" : log.level === "warn" ? "WRN" : "INF"}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground/80 break-all">{log.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { TaskButton } from "./TaskButton";
|
||||||
|
export { TaskCard } from "./TaskCard";
|
||||||
@@ -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 <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: ProgressPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
value={value}
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn("flex flex-wrap gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ProgressTrack>
|
||||||
|
<ProgressIndicator />
|
||||||
|
</ProgressTrack>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Track
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-track"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ProgressPrimitive.Indicator.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn("h-full bg-primary transition-all", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Label
|
||||||
|
className={cn("text-sm font-medium", className)}
|
||||||
|
data-slot="progress-label"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Value
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-value"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Progress,
|
||||||
|
ProgressTrack,
|
||||||
|
ProgressIndicator,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-[110] bg-black/20 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-[110] flex flex-col bg-popover text-sm text-popover-foreground shadow-2xl transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
|
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
|
||||||
import { SystemLayer } from "@/components/system/SystemLayer";
|
import { SystemLayer } from "@/components/system/SystemLayer";
|
||||||
|
import { StartupPopup } from "@/components/StartupPopup";
|
||||||
import { useA11yStore } from "@/stores/a11yStore";
|
import { useA11yStore } from "@/stores/a11yStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
@@ -48,6 +49,9 @@ export function RootLayout({
|
|||||||
showMaximize={showMaximize}
|
showMaximize={showMaximize}
|
||||||
showClose={showClose}
|
showClose={showClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Startup popup — only when VITE_START_POP=true */}
|
||||||
|
<StartupPopup />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
|
export function GlassCard({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="glass-card px-5 py-4">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingRow({
|
||||||
|
label,
|
||||||
|
desc,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-xl font-bold text-foreground">{title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">{desc}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useDevStore } from "@/stores/devStore";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GlassCard, SettingRow, PageHeader } from "./components";
|
||||||
|
|
||||||
|
export function DisplayDebug() {
|
||||||
|
const { forceDisableContentBlur, setForceDisableContentBlur } = useDevStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-8">
|
||||||
|
<PageHeader title="显示效果调试" desc="调试背景遮罩、磨砂效果与视觉表现" />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
遮罩控制
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<GlassCard>
|
||||||
|
<SettingRow
|
||||||
|
label="强制关闭背景「强内容模式」"
|
||||||
|
desc="覆盖系统设置,在所有页面禁用背景模糊遮罩,用于对比测试"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={forceDisableContentBlur ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setForceDisableContentBlur(!forceDisableContentBlur)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{forceDisableContentBlur ? "已开启" : "已关闭"}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="max-w-2xl mx-auto p-8">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="p-2.5 rounded-xl bg-foreground/[0.06]">
|
||||||
|
<FlaskConical className="w-5 h-5 text-foreground/60" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground">开发者工具</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">调试启动器的各项功能与视觉效果</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{debugPages.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => navigate(p.key)}
|
||||||
|
className="glass-card w-full px-5 py-4 text-left hover:scale-[1.01] active:scale-[0.99] transition-transform cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-2.5 rounded-xl ${p.bg}`}>
|
||||||
|
<p.icon className={`w-5 h-5 ${p.color}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">{p.title}</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">{p.desc}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-foreground/20 group-hover:text-foreground/40 transition-colors shrink-0" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[12px] text-muted-foreground/50 mt-6 text-center">
|
||||||
|
这些工具仅用于开发调试,不会影响启动器的正常运行
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="max-w-2xl mx-auto p-8">
|
||||||
|
<PageHeader title="启动动画调试" desc="测试 Splash Screen 的显示与关闭" />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
启动画面控制
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<GlassCard>
|
||||||
|
<SettingRow
|
||||||
|
label="打开启动画面"
|
||||||
|
desc="立即创建并显示 Splash Screen 窗口(480×320)"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOpen}
|
||||||
|
disabled={splashVisible}
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
打开
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard>
|
||||||
|
<SettingRow
|
||||||
|
label="关闭启动画面"
|
||||||
|
desc="立即关闭当前显示的 Splash Screen 窗口"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={!splashVisible}
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard>
|
||||||
|
<SettingRow
|
||||||
|
label="模拟启动流程"
|
||||||
|
desc="打开 Splash → 等待 4 秒 → 自动关闭,模拟真实启动"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
await handleOpen();
|
||||||
|
setTimeout(async () => {
|
||||||
|
await handleClose();
|
||||||
|
}, 4000);
|
||||||
|
}}
|
||||||
|
disabled={splashVisible}
|
||||||
|
>
|
||||||
|
<RotateCw className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
模拟
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="max-w-2xl mx-auto p-8">
|
||||||
|
<PageHeader title="任务队列调试" desc="测试任务调度、进度条、日志与任务队列页面" />
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-8">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<GlassCard key={s.label}>
|
||||||
|
<p className="text-[11px] text-muted-foreground uppercase tracking-wider">{s.label}</p>
|
||||||
|
<p className={`text-2xl font-bold tabular-nums mt-1 ${s.color}`}>{s.value}</p>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
快速操作
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">打开任务队列</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">跳转到任务队列页面查看当前任务列表</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("task-queue")}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-foreground/[0.06] hover:bg-foreground/[0.1] text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ListTodo className="w-4 h-4" />
|
||||||
|
打开
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">清空所有历史</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">删除 localStorage 中的任务记录</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearHistory}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-destructive/10 hover:bg-destructive/20 text-destructive text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add tasks by type */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
添加模拟任务
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{taskTypes.map((tt) => (
|
||||||
|
<GlassCard key={tt.type}>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${tt.bg}`}>
|
||||||
|
<tt.icon className={`w-4 h-4 ${tt.color}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">{tt.label}任务</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">
|
||||||
|
模拟 3s 成功任务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => simulateTask(tt.type, `模拟${tt.label}任务`, 3000)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-foreground/[0.06] hover:bg-foreground/[0.1] text-[12px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
成功
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => simulateTask(tt.type, `模拟${tt.label}任务(失败)`, 3000, true)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-destructive/10 hover:bg-destructive/20 text-destructive text-[12px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
失败
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch test */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
批量测试
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">并行任务测试</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">同时添加 3 个不同类型任务,验证并行执行</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
simulateTask("install", "并行安装", 4000);
|
||||||
|
simulateTask("download", "并行下载", 3000);
|
||||||
|
simulateTask("sync", "并行同步", 5000);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg bg-foreground/[0.06] hover:bg-foreground/[0.1] text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
运行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">取消任务测试</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">添加 5s 长任务,可在任务队列中取消</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => simulateTask("download", "可取消任务", 5000)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg bg-foreground/[0.06] hover:bg-foreground/[0.1] text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
运行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
当前任务 ({tasks.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((t) => {
|
||||||
|
const StatusIcon = statusIcons[t.status];
|
||||||
|
return (
|
||||||
|
<GlassCard key={t.id}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusIcon
|
||||||
|
className={`w-4 h-4 shrink-0 ${
|
||||||
|
t.status === "running"
|
||||||
|
? "animate-spin text-blue-500"
|
||||||
|
: t.status === "completed"
|
||||||
|
? "text-green-500"
|
||||||
|
: t.status === "failed"
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{t.title}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t.type} · {t.status} · {t.logs.length} 条日志</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeTask(t.id)}
|
||||||
|
className="p-1 rounded hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="max-w-2xl mx-auto p-8">
|
||||||
|
<PageHeader title="版本卡片调试" desc="测试 VersionCard 在不同模式与状态下的表现" />
|
||||||
|
|
||||||
|
<VersionCard
|
||||||
|
className="mb-8"
|
||||||
|
overrideMode={previewMode}
|
||||||
|
overrideState={previewUpdateState}
|
||||||
|
overlayOpacity={overlayOpacity}
|
||||||
|
blurAmount={blurAmount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
模式颜色
|
||||||
|
</h3>
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{modeOptions.map((m) => (
|
||||||
|
<Button
|
||||||
|
key={m.key}
|
||||||
|
size="sm"
|
||||||
|
variant={previewMode === m.key ? "default" : "outline"}
|
||||||
|
onClick={() =>
|
||||||
|
setPreviewMode(previewMode === m.key ? null : m.key)
|
||||||
|
}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${m.color}`} />
|
||||||
|
{m.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
更新状态模拟
|
||||||
|
</h3>
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updateStateOptions.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s.key}
|
||||||
|
size="sm"
|
||||||
|
variant={previewUpdateState === s.key ? "default" : "outline"}
|
||||||
|
onClick={() =>
|
||||||
|
setPreviewUpdateState(
|
||||||
|
previewUpdateState === s.key ? null : s.key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
磨砂层参数
|
||||||
|
</h3>
|
||||||
|
<GlassCard>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[13px] text-muted-foreground">
|
||||||
|
遮罩透明度
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-muted-foreground tabular-nums">
|
||||||
|
{overlayOpacity}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[overlayOpacity]}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setOverlayOpacity(Array.isArray(v) ? v[0] : v)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[13px] text-muted-foreground">
|
||||||
|
模糊强度
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-muted-foreground tabular-nums">
|
||||||
|
{blurAmount}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[blurAmount]}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setBlurAmount(Array.isArray(v) ? v[0] : v)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={40}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
|
||||||
|
快捷操作
|
||||||
|
</h3>
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">重置与检查</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-0.5">
|
||||||
|
重置所有预览参数或强制检查更新
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button size="sm" variant="outline" onClick={resetPreview}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => checkForUpdates()}>
|
||||||
|
检查更新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useThemeStore, type DarkMode } from "@/stores/themeStore";
|
import { useThemeStore, type DarkMode } from "@/stores/themeStore";
|
||||||
import { useBackgroundStore } from "@/stores/backgroundStore";
|
import { useBackgroundStore } from "@/stores/backgroundStore";
|
||||||
import { useA11yStore } from "@/stores/a11yStore";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
function GlassCard({ children }: { children: React.ReactNode }) {
|
function GlassCard({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="glass-card px-5 py-4">{children}</div>;
|
return <div className="glass-card px-5 py-4">{children}</div>;
|
||||||
@@ -107,11 +108,18 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
|
|||||||
|
|
||||||
export function ThemeBgSetting() {
|
export function ThemeBgSetting() {
|
||||||
const { darkMode, setDarkMode, parallax, setParallax } = useThemeStore();
|
const { darkMode, setDarkMode, parallax, setParallax } = useThemeStore();
|
||||||
const { opacity, setOpacity, blur, setBlur, reset } = useBackgroundStore();
|
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
|
||||||
const { contentBlurOpacity, setContentBlurOpacity } = useA11yStore();
|
|
||||||
|
|
||||||
const handlePickImage = async () => {
|
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 () => {
|
const handleReset = async () => {
|
||||||
@@ -146,6 +154,15 @@ export function ThemeBgSetting() {
|
|||||||
选择图片
|
选择图片
|
||||||
</Button>
|
</Button>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
{image && image !== "/background.png" && (
|
||||||
|
<div className="mt-3 rounded-lg overflow-hidden border border-border/50">
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt="背景预览"
|
||||||
|
className="w-full h-[120px] object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
@@ -183,23 +200,6 @@ export function ThemeBgSetting() {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">强内容遮罩不透明度</p>
|
|
||||||
<p className="text-[13px] text-muted-foreground mt-0.5">设置页面背景模糊遮罩的不透明度,当前 {contentBlurOpacity}%</p>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
className="w-[180px] shrink-0"
|
|
||||||
value={[contentBlurOpacity]}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
onValueChange={(v) => setContentBlurOpacity(Array.isArray(v) ? v[0] : v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<SettingRow label="恢复默认" desc="重置所有背景设置为初始状态">
|
<SettingRow label="恢复默认" desc="重置所有背景设置为初始状态">
|
||||||
<Button variant="destructive" size="sm" onClick={handleReset}>
|
<Button variant="destructive" size="sm" onClick={handleReset}>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useTaskStore } from "@/stores/taskStore";
|
||||||
|
import { TaskCard } from "@/components/task/TaskCard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2, Inbox } from "lucide-react";
|
||||||
|
|
||||||
|
export function TaskQueue() {
|
||||||
|
const tasks = useTaskStore((s) => s.tasks);
|
||||||
|
const clearHistory = useTaskStore((s) => s.clearHistory);
|
||||||
|
|
||||||
|
const activeTasks = tasks.filter(
|
||||||
|
(t) => t.status === "pending" || t.status === "running",
|
||||||
|
);
|
||||||
|
const completedTasks = tasks.filter(
|
||||||
|
(t) =>
|
||||||
|
t.status === "completed" ||
|
||||||
|
t.status === "failed" ||
|
||||||
|
t.status === "cancelled",
|
||||||
|
);
|
||||||
|
const hasCompleted = completedTasks.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto px-6 py-5">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
任务队列
|
||||||
|
</h1>
|
||||||
|
{activeTasks.length > 0 && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium tabular-nums">
|
||||||
|
{activeTasks.length} 进行中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCompleted && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearHistory}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
清空历史
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||||
|
<Inbox className="w-12 h-12 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">暂无任务</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{activeTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-foreground/30 mb-2.5">
|
||||||
|
进行中
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{activeTasks.map((t) => (
|
||||||
|
<TaskCard key={t.id} task={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{completedTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-foreground/30 mb-2.5">
|
||||||
|
已完成
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{completedTasks.map((t) => (
|
||||||
|
<TaskCard key={t.id} task={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
|
||||||
<StrictMode>
|
|
||||||
<Splash />
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
+52
-111
@@ -1,133 +1,74 @@
|
|||||||
import { create } from "zustand";
|
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 {
|
const STORAGE_KEY = "koring-background";
|
||||||
type: "image" | "color" | "gradient" | "particles";
|
|
||||||
image?: string;
|
type BackgroundType = "image" | "color";
|
||||||
color?: string;
|
|
||||||
|
interface BackgroundConfig {
|
||||||
|
type: BackgroundType;
|
||||||
|
image: string;
|
||||||
blur: number;
|
blur: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
animation: AnimationType;
|
|
||||||
animationSpeed: number;
|
|
||||||
theme: Theme;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
|
|
||||||
setImage: (url: string, blur?: number, opacity?: number) => Promise<void>;
|
|
||||||
setColor: (color: string) => Promise<void>;
|
|
||||||
setBlur: (blur: number) => Promise<void>;
|
|
||||||
setOpacity: (opacity: number) => Promise<void>;
|
|
||||||
setAnimation: (type: AnimationType, speed?: number) => Promise<void>;
|
|
||||||
setTheme: (theme: Theme) => Promise<void>;
|
|
||||||
fetchConfig: () => Promise<void>;
|
|
||||||
reset: () => Promise<void>;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: BackgroundConfig = {
|
const DEFAULT_CONFIG: BackgroundConfig = {
|
||||||
type: "color",
|
type: "image",
|
||||||
color: "#1a1a2e",
|
image: "/background.png",
|
||||||
blur: 0,
|
blur: 0,
|
||||||
opacity: 1,
|
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<BackgroundState>((set) => ({
|
export const useBackgroundStore = create<BackgroundState>((set) => ({
|
||||||
...defaultConfig,
|
...loadConfig(),
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
setImage: async (url, blur, opacity) => {
|
setImage: (url) => {
|
||||||
set({ loading: true, error: null });
|
const next: BackgroundConfig = { type: "image", image: url, blur: 0, opacity: 1 };
|
||||||
try {
|
saveConfig(next);
|
||||||
const config = await setImageBackground(url, blur, opacity);
|
set(next);
|
||||||
set({ ...config, loading: false });
|
|
||||||
} catch (e: any) {
|
|
||||||
set({ error: e.message, loading: false });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setColor: async (color) => {
|
setColor: (color) => {
|
||||||
set({ loading: true, error: null });
|
const next: BackgroundConfig = { type: "color", image: color, blur: 0, opacity: 1 };
|
||||||
try {
|
saveConfig(next);
|
||||||
const config = await setColorBackground(color);
|
set(next);
|
||||||
set({ ...config, loading: false });
|
|
||||||
} catch (e: any) {
|
|
||||||
set({ error: e.message, loading: false });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setBlur: async (blur) => {
|
setBlur: (blur) => {
|
||||||
set({ loading: true, error: null });
|
const config = loadConfig();
|
||||||
try {
|
config.blur = blur;
|
||||||
const config = await setBackgroundBlur(blur);
|
saveConfig(config);
|
||||||
set({ ...config, loading: false });
|
set({ blur });
|
||||||
} catch (e: any) {
|
|
||||||
set({ error: e.message, loading: false });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setOpacity: async (opacity) => {
|
setOpacity: (opacity) => {
|
||||||
set({ loading: true, error: null });
|
const config = loadConfig();
|
||||||
try {
|
config.opacity = opacity;
|
||||||
const config = await setBackgroundOpacity(opacity);
|
saveConfig(config);
|
||||||
set({ ...config, loading: false });
|
set({ opacity });
|
||||||
} catch (e: any) {
|
|
||||||
set({ error: e.message, loading: false });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setAnimation: async (type, speed) => {
|
reset: () => {
|
||||||
set({ loading: true, error: null });
|
saveConfig(DEFAULT_CONFIG);
|
||||||
try {
|
set({ ...DEFAULT_CONFIG });
|
||||||
const config = await setBackgroundAnimation(type, speed);
|
|
||||||
set({ ...config, loading: false });
|
|
||||||
} catch (e: any) {
|
|
||||||
set({ error: e.message, loading: false });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
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 }),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ export type RouteKey =
|
|||||||
| "today"
|
| "today"
|
||||||
| "play-link"
|
| "play-link"
|
||||||
| "setting"
|
| "setting"
|
||||||
|
| "task-queue"
|
||||||
| "debug"
|
| "debug"
|
||||||
| "debug-splash"
|
| "debug-splash"
|
||||||
| "debug-display"
|
| "debug-display"
|
||||||
| "debug-version-card";
|
| "debug-version-card"
|
||||||
|
| "debug-task";
|
||||||
|
|
||||||
export type TitleBarMode = "default" | "sub" | "window";
|
export type TitleBarMode = "default" | "sub" | "window";
|
||||||
|
|
||||||
@@ -32,17 +34,21 @@ export const routes: RouteItem[] = [
|
|||||||
|
|
||||||
export const allRoutes: RouteItem[] = [
|
export const allRoutes: RouteItem[] = [
|
||||||
...routes,
|
...routes,
|
||||||
|
{ key: "task-queue", label: "任务队列", path: "/task-queue", hidden: true },
|
||||||
{ key: "debug", label: "调试", path: "/debug", hidden: true },
|
{ key: "debug", label: "调试", path: "/debug", hidden: true },
|
||||||
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
|
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
|
||||||
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
|
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
|
||||||
{ key: "debug-version-card", label: "版本卡片调试", path: "/debug/version-card", 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<Record<RouteKey, RouteKey>> = {
|
const parentMap: Partial<Record<RouteKey, RouteKey>> = {
|
||||||
|
"task-queue": "home",
|
||||||
debug: "setting",
|
debug: "setting",
|
||||||
"debug-splash": "debug",
|
"debug-splash": "debug",
|
||||||
"debug-display": "debug",
|
"debug-display": "debug",
|
||||||
"debug-version-card": "debug",
|
"debug-version-card": "debug",
|
||||||
|
"debug-task": "debug",
|
||||||
};
|
};
|
||||||
|
|
||||||
const topLevelKeys = new Set(routes.map((r) => r.key));
|
const topLevelKeys = new Set(routes.map((r) => r.key));
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskState {
|
||||||
|
tasks: Task[];
|
||||||
|
executors: Map<string, TaskExecutor>;
|
||||||
|
abortControllers: Map<string, AbortController>;
|
||||||
|
|
||||||
|
// 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<TaskState>((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 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+11
@@ -1 +1,12 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineConfig(async () => ({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
main: path.resolve(__dirname, "index.html"),
|
main: path.resolve(__dirname, "index.html"),
|
||||||
|
splash: path.resolve(__dirname, "splash.html"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user