Compare commits
19 Commits
fc73243687
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5513979ebc | |||
| fb018d2f76 | |||
| 02560fb3f6 | |||
| 4b69095a8d | |||
| 2acf387ffd | |||
| 7656a371a0 | |||
| 8a8b873642 | |||
| 9d062abe69 | |||
| 92da223d1e | |||
| e703ef669a | |||
| bbae8d04ae | |||
| 4035fcb202 | |||
| 0df0bcb855 | |||
| a91f7f9d58 | |||
| 72c566b721 | |||
| 6721a06d7f | |||
| 7f27221081 | |||
| 2ce049965c | |||
| 10895b2c9f |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,6 +6,12 @@
|
|||||||
# Nitro
|
# Nitro
|
||||||
.output/
|
.output/
|
||||||
|
|
||||||
|
# Bun build
|
||||||
|
*.bun-build
|
||||||
|
|
||||||
|
# Turborepo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
### Node ###
|
### Node ###
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
|
|||||||
350
AGENTS.md
350
AGENTS.md
@@ -1,280 +1,237 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
# AGENTS.md - AI Coding Agent Guidelines
|
||||||
|
|
||||||
This document provides comprehensive guidelines for AI coding agents working in this TanStack Start fullstack starter codebase.
|
本文档为 AI 编程助手提供此 TanStack Start 全栈项目的开发规范和指南。
|
||||||
|
|
||||||
## Project Overview
|
## 项目概览
|
||||||
|
|
||||||
- **Framework**: TanStack Start (React SSR framework with file-based routing)
|
- **框架**: TanStack Start (React SSR 框架,文件路由)
|
||||||
- **Runtime**: Bun
|
- **运行时**: Bun
|
||||||
- **Language**: TypeScript (strict mode, ESNext)
|
- **语言**: TypeScript (strict mode, ESNext)
|
||||||
- **Styling**: Tailwind CSS v4
|
- **样式**: Tailwind CSS v4
|
||||||
- **Database**: PostgreSQL with Drizzle ORM
|
- **数据库**: PostgreSQL + Drizzle ORM
|
||||||
- **State Management**: TanStack Query
|
- **状态管理**: TanStack Query
|
||||||
- **Routing**: TanStack Router (file-based)
|
- **路由**: TanStack Router (文件路由)
|
||||||
- **RPC**: ORPC (type-safe RPC with contract-first design)
|
- **RPC**: ORPC (类型安全 RPC,契约优先)
|
||||||
- **Build Tool**: Vite
|
- **构建工具**: Vite + Turbo
|
||||||
- **Linter/Formatter**: Biome
|
- **代码质量**: Biome (格式化 + Lint)
|
||||||
|
- **桌面壳** (可选): Tauri v2 (详见 `src-tauri/AGENTS.md`)
|
||||||
|
|
||||||
## Build, Lint, and Test Commands
|
## 构建、Lint 和测试命令
|
||||||
|
|
||||||
### Development
|
### 开发
|
||||||
```bash
|
```bash
|
||||||
bun dev # Start development server
|
bun dev # 使用 Turbo 并行启动 Tauri + Vite 开发服务器
|
||||||
bun db:studio # Open Drizzle Studio for database management
|
bun dev:vite # 仅启动 Vite 开发服务器 (localhost:3000)
|
||||||
|
bun dev:tauri # 启动 Tauri 桌面应用
|
||||||
|
bun db:studio # 打开 Drizzle Studio 数据库管理界面
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
### 构建
|
||||||
```bash
|
```bash
|
||||||
bun build # Build for production (outputs to .output/)
|
bun build # 完整构建 (Vite → 编译 → Tauri 打包)
|
||||||
bun compile # Compile to standalone executable (out/server)
|
bun build:vite # 仅构建 Vite (输出到 .output/)
|
||||||
bun serve # Preview production build
|
bun build:compile # 编译为独立可执行文件 (使用 build.ts)
|
||||||
|
bun build:tauri # 构建 Tauri 桌面安装包
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
### 代码质量
|
||||||
```bash
|
```bash
|
||||||
bun typecheck # Run TypeScript compiler (tsc -b)
|
bun typecheck # 运行 TypeScript 编译器检查 (tsc -b)
|
||||||
bun fix # Run Biome linter and formatter (auto-fix issues)
|
bun fix # 运行 Biome 自动修复格式和 Lint 问题
|
||||||
biome check . # Check without auto-fix
|
biome check . # 检查但不自动修复
|
||||||
biome format --write . # Format code only
|
biome format --write . # 仅格式化代码
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database
|
### 数据库
|
||||||
```bash
|
```bash
|
||||||
bun db:generate # Generate migration files from schema
|
bun db:generate # 从 schema 生成迁移文件
|
||||||
bun db:migrate # Run migrations
|
bun db:migrate # 执行数据库迁移
|
||||||
bun db:push # Push schema changes directly (dev only)
|
bun db:push # 直接推送 schema 变更 (仅开发环境)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### 测试
|
||||||
**Note**: No test framework is currently configured. When adding tests:
|
**注意**: 当前未配置测试框架。添加测试时:
|
||||||
- Use Vitest or Bun's built-in test runner
|
- 使用 Vitest 或 Bun 内置测试运行器
|
||||||
- Run single test file: `bun test path/to/test.ts`
|
- 运行单个测试文件: `bun test path/to/test.ts`
|
||||||
- Run specific test: `bun test -t "test name pattern"`
|
- 运行特定测试: `bun test -t "测试名称模式"`
|
||||||
|
|
||||||
## Code Style Guidelines
|
## 代码风格指南
|
||||||
|
|
||||||
### Formatting (Biome)
|
### 格式化 (Biome)
|
||||||
|
|
||||||
**Indentation**: 2 spaces (not tabs)
|
**缩进**: 2 空格 (不使用 tab)
|
||||||
**Line Endings**: LF (Unix-style)
|
**换行符**: LF (Unix 风格)
|
||||||
**Quotes**: Single quotes for strings
|
**引号**: 单引号 `'string'`
|
||||||
**Semicolons**: As needed (ASI - automatic semicolon insertion)
|
**分号**: 按需 (ASI - 自动分号插入)
|
||||||
**Arrow Parens**: Always use parentheses `(x) => x`
|
**箭头函数括号**: 始终使用 `(x) => x`
|
||||||
|
|
||||||
Example:
|
示例:
|
||||||
```typescript
|
```typescript
|
||||||
const myFunc = (value: string) => {
|
const myFunc = (value: string) => {
|
||||||
return value.toUpperCase()
|
return value.toUpperCase()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import Organization
|
### 导入组织
|
||||||
|
|
||||||
Imports are auto-organized by Biome. Order:
|
Biome 自动组织导入。顺序:
|
||||||
1. External dependencies
|
1. 外部依赖
|
||||||
2. Internal imports using `@/*` alias
|
2. 内部导入 (使用 `@/*` 别名)
|
||||||
3. Type imports (use `type` keyword when importing only types)
|
3. 类型导入 (仅导入类型时使用 `type` 关键字)
|
||||||
|
|
||||||
Example:
|
示例:
|
||||||
```typescript
|
```typescript
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { createServerFn } from '@tanstack/react-start'
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
import { db } from '@/db'
|
import { db } from '@/db'
|
||||||
|
import { todoTable } from '@/db/schema'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
```
|
```
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
**Strict Mode**: Enabled with additional strictness flags
|
**严格模式**: 启用了额外的严格检查
|
||||||
- `strict: true`
|
- `strict: true`
|
||||||
- `noUncheckedIndexedAccess: true` - Array/object indexing returns `T | undefined`
|
- `noUncheckedIndexedAccess: true` - 数组/对象索引返回 `T | undefined`
|
||||||
- `noImplicitOverride: true`
|
- `noImplicitOverride: true`
|
||||||
- `noFallthroughCasesInSwitch: true`
|
- `noFallthroughCasesInSwitch: true`
|
||||||
|
|
||||||
**Module Resolution**: `bundler` mode with `verbatimModuleSyntax`
|
**模块解析**: `bundler` 模式 + `verbatimModuleSyntax`
|
||||||
- Always use `.ts`/`.tsx` extensions in imports
|
- 导入时始终使用 `.ts`/`.tsx` 扩展名
|
||||||
- Use `@/*` path alias for `src/*`
|
- 使用 `@/*` 路径别名指向 `src/*`
|
||||||
|
|
||||||
**Type Annotations**:
|
**类型注解**:
|
||||||
- Always annotate function parameters and return types for public APIs
|
- 公共 API 的函数参数和返回类型必须注解
|
||||||
- Prefer explicit types over `any`
|
- 优先使用显式类型而非 `any`
|
||||||
- Use `type` for object shapes, `interface` for extendable contracts
|
- 对象形状用 `type`,可扩展契约用 `interface`
|
||||||
- Use `Readonly<T>` for immutable props
|
- 不可变 props 使用 `Readonly<T>`
|
||||||
|
|
||||||
Example:
|
### 命名规范
|
||||||
```typescript
|
|
||||||
function getTodos(): Promise<Todo[]> {
|
|
||||||
return db.query.todoTable.findMany()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = Readonly<{
|
- **文件**: 工具函数用 kebab-case,组件用 PascalCase
|
||||||
children: ReactNode
|
|
||||||
}>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- **Files**: kebab-case for utilities, PascalCase for components
|
|
||||||
- `utils.ts`, `todo.tsx`, `NotFound.tsx`
|
- `utils.ts`, `todo.tsx`, `NotFound.tsx`
|
||||||
- **Routes**: Use TanStack Router conventions
|
- **路由**: 遵循 TanStack Router 约定
|
||||||
- `routes/index.tsx` → `/`
|
- `routes/index.tsx` → `/`
|
||||||
- `routes/todo.tsx` → `/todo`
|
- `routes/__root.tsx` → 根布局
|
||||||
- `routes/__root.tsx` → Root layout
|
- **组件**: PascalCase 箭头函数 (Biome 规则 `useArrowFunction` 强制)
|
||||||
- **Components**: PascalCase function declarations
|
- **函数**: camelCase
|
||||||
- **Functions**: camelCase
|
- **常量**: 真常量用 UPPER_SNAKE_CASE,配置对象用 camelCase
|
||||||
- **Constants**: UPPER_SNAKE_CASE for true constants, camelCase for config objects
|
- **类型/接口**: PascalCase
|
||||||
- **Types/Interfaces**: PascalCase
|
|
||||||
|
|
||||||
### React Patterns
|
### React 模式
|
||||||
|
|
||||||
**Components**: Use arrow functions (enforced by Biome rule `useArrowFunction`)
|
**组件**: 使用箭头函数
|
||||||
```typescript
|
```typescript
|
||||||
const MyComponent = ({ title }: { title: string }) => {
|
const MyComponent = ({ title }: { title: string }) => {
|
||||||
return <div>{title}</div>
|
return <div>{title}</div>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Server Functions**: Use TanStack Start's `createServerFn`
|
**路由**: 使用 `createFileRoute` 定义路由
|
||||||
```typescript
|
```typescript
|
||||||
const getTodos = createServerFn({ method: 'GET' }).handler(async () => {
|
export const Route = createFileRoute('/')({
|
||||||
const todos = await db.query.todoTable.findMany()
|
component: Home,
|
||||||
return todos
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**Routing**: Use `createFileRoute` for route definitions
|
**数据获取**: 使用 TanStack Query hooks
|
||||||
```typescript
|
- `useSuspenseQuery` - 保证有数据
|
||||||
export const Route = createFileRoute('/todo')({
|
- `useQuery` - 数据可能为空
|
||||||
component: Todo,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Data Fetching**: Use TanStack Query hooks
|
**Props**: 禁止直接修改 props (Biome 规则 `noReactPropAssignments`)
|
||||||
- `useSuspenseQuery` for guaranteed data
|
|
||||||
- `useQuery` when data might be optional
|
|
||||||
|
|
||||||
**Props**: No direct prop mutations (enforced by `noReactPropAssignments`)
|
### 数据库 Schema (Drizzle)
|
||||||
|
|
||||||
### Database Schema (Drizzle)
|
- 在 `src/db/schema/*.ts` 定义 schema
|
||||||
|
- 从 `src/db/schema/index.ts` 导出
|
||||||
|
- 使用 `drizzle-orm/pg-core` 的 PostgreSQL 类型
|
||||||
|
- 主键使用 `uuidv7()` (需要 PostgreSQL 扩展)
|
||||||
|
- 始终包含 `createdAt` 和 `updatedAt` 时间戳
|
||||||
|
|
||||||
- Define schemas in `src/db/schema/*.ts`
|
示例:
|
||||||
- Export from `src/db/schema/index.ts`
|
|
||||||
- Use PostgreSQL types from `drizzle-orm/pg-core`
|
|
||||||
- Use `uuidv7()` for primary keys (requires PostgreSQL extension)
|
|
||||||
- Always include `createdAt` and `updatedAt` timestamps
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```typescript
|
```typescript
|
||||||
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||||
import { sql } from 'drizzle-orm'
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
export const myTable = pgTable('my_table', {
|
export const myTable = pgTable('my_table', {
|
||||||
id: uuid('id').primaryKey().default(sql`uuidv7()`),
|
id: uuid().primaryKey().default(sql`uuidv7()`),
|
||||||
name: text('name').notNull(),
|
name: text().notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true })
|
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||||
.notNull()
|
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
|
||||||
.defaultNow(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
|
||||||
.notNull()
|
|
||||||
.defaultNow()
|
|
||||||
.$onUpdateFn(() => new Date()),
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### 环境变量
|
||||||
|
|
||||||
- Use `@t3-oss/env-core` for type-safe env validation
|
- 使用 `@t3-oss/env-core` 进行类型安全的环境变量验证
|
||||||
- Define schema in `src/env.ts`
|
- 在 `src/env.ts` 定义 schema
|
||||||
- Server vars: No prefix
|
- 服务端变量: 无前缀
|
||||||
- Client vars: Must have `VITE_` prefix
|
- 客户端变量: 必须有 `VITE_` 前缀
|
||||||
- Always validate with Zod schemas
|
- 使用 Zod schema 验证
|
||||||
|
|
||||||
### Error Handling
|
### 错误处理
|
||||||
|
|
||||||
- Use try-catch for async operations
|
- 异步操作使用 try-catch
|
||||||
- Throw errors with descriptive messages
|
- 抛出带有描述性消息的错误
|
||||||
- Prefer Result types or error boundaries for user-facing errors
|
- 用户界面错误优先使用 Result 类型或错误边界
|
||||||
- Log errors appropriately (avoid logging sensitive data)
|
- 适当记录错误 (避免记录敏感数据)
|
||||||
|
|
||||||
### Styling (Tailwind CSS)
|
### 样式 (Tailwind CSS)
|
||||||
|
|
||||||
- Use Tailwind v4 utility classes
|
- 使用 Tailwind v4 工具类
|
||||||
- Import styles via `@/styles.css?url`
|
- 通过 `@/styles.css?url` 导入样式
|
||||||
- Prefer composition over custom CSS
|
- 优先使用组合而非自定义 CSS
|
||||||
- Use responsive modifiers: `sm:`, `md:`, `lg:`
|
- 响应式修饰符: `sm:`, `md:`, `lg:`
|
||||||
- Use Chinese text for UI when appropriate (as seen in codebase)
|
- UI 文本适当使用中文
|
||||||
|
|
||||||
## File Structure
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── components/ # Reusable React components
|
├── components/ # 可复用 React 组件
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── schema/ # Drizzle schema definitions
|
│ ├── schema/ # Drizzle schema 定义
|
||||||
│ └── index.ts # Database instance
|
│ └── index.ts # 数据库实例
|
||||||
├── lib/ # Utility functions
|
├── integrations/ # 第三方集成 (TanStack Query/Router)
|
||||||
├── orpc/ # ORPC (RPC layer)
|
├── lib/ # 工具函数
|
||||||
│ ├── contracts/ # Contract definitions (input/output schemas)
|
├── orpc/ # ORPC (RPC 层)
|
||||||
│ ├── handlers/ # Server-side procedure implementations
|
│ ├── contracts/ # 契约定义 (input/output schemas)
|
||||||
│ ├── middlewares/ # Middleware (e.g., DB provider)
|
│ ├── handlers/ # 服务端过程实现
|
||||||
│ ├── contract.ts # Contract aggregation
|
│ ├── middlewares/ # 中间件 (如 DB provider)
|
||||||
│ ├── router.ts # Router composition
|
│ ├── contract.ts # 契约聚合
|
||||||
│ ├── server.ts # Server instance
|
│ ├── router.ts # 路由组合
|
||||||
│ ├── client.ts # Isomorphic client
|
│ ├── server.ts # 服务端实例
|
||||||
│ └── types.ts # Type utilities
|
│ └── client.ts # 同构客户端
|
||||||
├── routes/ # TanStack Router file-based routes
|
├── routes/ # TanStack Router 文件路由
|
||||||
│ ├── __root.tsx # Root layout
|
│ ├── __root.tsx # 根布局
|
||||||
│ ├── index.tsx # Home page
|
│ ├── index.tsx # 首页
|
||||||
│ └── api/rpc.$.ts # ORPC HTTP endpoint
|
│ └── api/rpc.$.ts # ORPC HTTP 端点
|
||||||
├── env.ts # Environment variable validation
|
├── env.ts # 环境变量验证
|
||||||
├── index.ts # Application entry point
|
└── router.tsx # 路由配置
|
||||||
├── router.tsx # Router configuration
|
|
||||||
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
|
|
||||||
└── styles.css # Global styles
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Important Notes
|
## 重要提示
|
||||||
|
|
||||||
- **DO NOT** edit `src/routeTree.gen.ts` - it's auto-generated
|
- **禁止** 编辑 `src/routeTree.gen.ts` - 自动生成
|
||||||
- **DO NOT** commit `.env` files - use `.env.example` for templates
|
- **禁止** 提交 `.env` 文件 - 使用 `.env.example` 作为模板
|
||||||
- **DO** run `bun fix` before committing to ensure code quality
|
- **必须** 在提交前运行 `bun fix`
|
||||||
- **DO** use the `@/*` path alias instead of relative imports
|
- **必须** 使用 `@/*` 路径别名而非相对导入
|
||||||
- **DO** leverage React Compiler (babel-plugin-react-compiler) - avoid manual memoization
|
- **必须** 利用 React Compiler (babel-plugin-react-compiler) - 避免手动 memoization
|
||||||
|
|
||||||
## Git Workflow
|
## Git 工作流
|
||||||
|
|
||||||
1. Make changes following the style guidelines above
|
1. 按照上述风格指南进行修改
|
||||||
2. Run `bun fix` to auto-format and lint
|
2. 运行 `bun fix` 自动格式化和 lint
|
||||||
3. Run `bun typecheck` to ensure type safety
|
3. 运行 `bun typecheck` 确保类型安全
|
||||||
4. Test changes locally with `bun dev`
|
4. 使用 `bun dev` 本地测试变更
|
||||||
5. Commit with clear, descriptive messages
|
5. 使用清晰的描述性消息提交
|
||||||
|
|
||||||
## Common Patterns
|
## 常见模式
|
||||||
|
|
||||||
### Adding a New Route
|
### 创建 ORPC 过程
|
||||||
1. Create `src/routes/my-route.tsx`
|
|
||||||
2. Export route with `createFileRoute`
|
|
||||||
3. Route tree auto-updates on save
|
|
||||||
|
|
||||||
### Adding Database Table
|
**步骤 1: 定义契约** (`src/orpc/contracts/my-feature.ts`)
|
||||||
1. Create schema in `src/db/schema/my-table.ts`
|
|
||||||
2. Export from `src/db/schema/index.ts`
|
|
||||||
3. Run `bun db:generate` to create migration
|
|
||||||
4. Run `bun db:migrate` to apply migration
|
|
||||||
|
|
||||||
### Creating Server Function
|
|
||||||
```typescript
|
|
||||||
const myServerFn = createServerFn({ method: 'POST' })
|
|
||||||
.validator((data) => mySchema.parse(data))
|
|
||||||
.handler(async ({ data }) => {
|
|
||||||
// Server-side logic here
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating ORPC Procedures
|
|
||||||
|
|
||||||
**Step 1: Define Contract** (`src/orpc/contracts/my-feature.ts`)
|
|
||||||
```typescript
|
```typescript
|
||||||
import { oc } from '@orpc/contract'
|
import { oc } from '@orpc/contract'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -285,7 +242,7 @@ export const myContract = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2: Implement Handler** (`src/orpc/handlers/my-feature.ts`)
|
**步骤 2: 实现处理器** (`src/orpc/handlers/my-feature.ts`)
|
||||||
```typescript
|
```typescript
|
||||||
import { os } from '@/orpc/server'
|
import { os } from '@/orpc/server'
|
||||||
import { dbProvider } from '@/orpc/middlewares'
|
import { dbProvider } from '@/orpc/middlewares'
|
||||||
@@ -293,24 +250,21 @@ import { dbProvider } from '@/orpc/middlewares'
|
|||||||
export const get = os.myFeature.get
|
export const get = os.myFeature.get
|
||||||
.use(dbProvider)
|
.use(dbProvider)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const item = await context.db.query.myTable.findFirst(...)
|
return await context.db.query.myTable.findFirst(...)
|
||||||
return item
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3: Register in Contract & Router**
|
**步骤 3: 注册到契约和路由**
|
||||||
```typescript
|
```typescript
|
||||||
// src/orpc/contract.ts
|
// src/orpc/contract.ts
|
||||||
export const contract = {
|
export const contract = { myFeature: myContract }
|
||||||
myFeature: myContract,
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/orpc/router.ts
|
// src/orpc/router.ts
|
||||||
import * as myFeature from './handlers/my-feature'
|
import * as myFeature from './handlers/my-feature'
|
||||||
export const router = os.router({ myFeature })
|
export const router = os.router({ myFeature })
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 4: Use in Component**
|
**步骤 4: 在组件中使用**
|
||||||
```typescript
|
```typescript
|
||||||
import { orpc } from '@/orpc'
|
import { orpc } from '@/orpc'
|
||||||
const query = useSuspenseQuery(orpc.myFeature.get.queryOptions({ id }))
|
const query = useSuspenseQuery(orpc.myFeature.get.queryOptions({ id }))
|
||||||
@@ -319,5 +273,5 @@ const mutation = useMutation(orpc.myFeature.create.mutationOptions())
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-01-18
|
**最后更新**: 2026-01-18
|
||||||
**Project Version**: Based on package.json dependencies
|
**项目版本**: 基于 package.json 依赖版本
|
||||||
|
|||||||
10
build.ts
10
build.ts
@@ -93,9 +93,9 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
|
|||||||
static readonly Live = Layer.effect(
|
static readonly Live = Layer.effect(
|
||||||
BuildConfigService,
|
BuildConfigService,
|
||||||
BuildConfigService.fromRaw({
|
BuildConfigService.fromRaw({
|
||||||
entrypoint: './.output/server/index.mjs',
|
entrypoint: '.output/server/index.mjs',
|
||||||
// outputDir: './out',
|
// outputDir: 'out',
|
||||||
outputDir: './src-tauri/binaries',
|
outputDir: 'src-tauri/binaries',
|
||||||
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -150,7 +150,7 @@ class BuildService extends Context.Tag('BuildService')<
|
|||||||
Bun.build({
|
Bun.build({
|
||||||
entrypoints: [config.entrypoint],
|
entrypoints: [config.entrypoint],
|
||||||
compile: {
|
compile: {
|
||||||
outfile: `server-${targetMap[target]}`,
|
outfile: `app-${targetMap[target]}`,
|
||||||
target: target,
|
target: target,
|
||||||
},
|
},
|
||||||
outdir: config.outputDir,
|
outdir: config.outputDir,
|
||||||
@@ -181,7 +181,7 @@ class BuildService extends Context.Tag('BuildService')<
|
|||||||
Bun.build({
|
Bun.build({
|
||||||
entrypoints: [config.entrypoint],
|
entrypoints: [config.entrypoint],
|
||||||
compile: {
|
compile: {
|
||||||
outfile: `server-${targetMap[target]}`,
|
outfile: `app-${targetMap[target]}`,
|
||||||
target: target,
|
target: target,
|
||||||
},
|
},
|
||||||
outdir: config.outputDir,
|
outdir: config.outputDir,
|
||||||
|
|||||||
18
bun.lock
18
bun.lock
@@ -15,6 +15,7 @@
|
|||||||
"@tanstack/react-router": "^1.151.0",
|
"@tanstack/react-router": "^1.151.0",
|
||||||
"@tanstack/react-router-ssr-query": "^1.151.0",
|
"@tanstack/react-router-ssr-query": "^1.151.0",
|
||||||
"@tanstack/react-start": "^1.151.0",
|
"@tanstack/react-start": "^1.151.0",
|
||||||
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"effect": "^3.19.14",
|
"effect": "^3.19.14",
|
||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"turbo": "^2.7.5",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.0-beta.8",
|
"vite": "^8.0.0-beta.8",
|
||||||
"vite-tsconfig-paths": "^6.0.4",
|
"vite-tsconfig-paths": "^6.0.4",
|
||||||
@@ -532,6 +534,8 @@
|
|||||||
|
|
||||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.145.4", "", {}, "sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ=="],
|
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.145.4", "", {}, "sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ=="],
|
||||||
|
|
||||||
|
"@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.9.6", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="],
|
"@tauri-apps/cli": ["@tauri-apps/cli@2.9.6", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ=="],
|
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ=="],
|
||||||
@@ -926,6 +930,20 @@
|
|||||||
|
|
||||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||||
|
|
||||||
|
"turbo": ["turbo@2.7.5", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.5", "turbo-darwin-arm64": "2.7.5", "turbo-linux-64": "2.7.5", "turbo-linux-arm64": "2.7.5", "turbo-windows-64": "2.7.5", "turbo-windows-arm64": "2.7.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-7Imdmg37joOloTnj+DPrab9hIaQcDdJ5RwSzcauo/wMOSAgO+A/I/8b3hsGGs6PWQz70m/jkPgdqWsfNKtwwDQ=="],
|
||||||
|
|
||||||
|
"turbo-darwin-64": ["turbo-darwin-64@2.7.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-nN3wfLLj4OES/7awYyyM7fkU8U8sAFxsXau2bYJwAWi6T09jd87DgHD8N31zXaJ7LcpyppHWPRI2Ov9MuZEwnQ=="],
|
||||||
|
|
||||||
|
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wCoDHMiTf3FgLAbZHDDx/unNNonSGhsF5AbbYODbxnpYyoKDpEYacUEPjZD895vDhNvYCH0Nnk24YsP4n/cD6g=="],
|
||||||
|
|
||||||
|
"turbo-linux-64": ["turbo-linux-64@2.7.5", "", { "os": "linux", "cpu": "x64" }, "sha512-KKPvhOmJMmzWj/yjeO4LywkQ85vOJyhru7AZk/+c4B6OUh/odQ++SiIJBSbTG2lm1CuV5gV5vXZnf/2AMlu3Zg=="],
|
||||||
|
|
||||||
|
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-8PIva4L6BQhiPikUTds9lSFSHXVDAsEvV6QUlgwPsXrtXVQMVi6Sv9p+IxtlWQFvGkdYJUgX9GnK2rC030Xcmw=="],
|
||||||
|
|
||||||
|
"turbo-windows-64": ["turbo-windows-64@2.7.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rupskv/mkIUgQXzX/wUiK00mKMorQcK8yzhGFha/D5lm05FEnLx8dsip6rWzMcVpvh+4GUMA56PgtnOgpel2AA=="],
|
||||||
|
|
||||||
|
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-G377Gxn6P42RnCzfMyDvsqQV7j69kVHKlhz9J4RhtJOB5+DyY4yYh/w0oTIxZQ4JRMmhjwLu3w9zncMoQ6nNDw=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@5.4.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ=="],
|
"type-fest": ["type-fest@5.4.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run build:vite && bun run build:compile",
|
"build": "turbo build:tauri",
|
||||||
"build:compile": "bun build.ts",
|
"build:compile": "bun build.ts",
|
||||||
"build:tauri": "tauri build",
|
"build:tauri": "tauri build",
|
||||||
"build:vite": "vite build",
|
"build:vite": "vite build",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"dev": "bun run dev:vite",
|
"dev": "turbo dev:tauri",
|
||||||
"dev:tauri": "tauri dev",
|
"dev:tauri": "tauri dev",
|
||||||
"dev:vite": "vite dev",
|
"dev:vite": "vite dev",
|
||||||
"fix": "biome check --write",
|
"fix": "biome check --write",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@tanstack/react-router": "^1.151.0",
|
"@tanstack/react-router": "^1.151.0",
|
||||||
"@tanstack/react-router-ssr-query": "^1.151.0",
|
"@tanstack/react-router-ssr-query": "^1.151.0",
|
||||||
"@tanstack/react-start": "^1.151.0",
|
"@tanstack/react-start": "^1.151.0",
|
||||||
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"effect": "^3.19.14",
|
"effect": "^3.19.14",
|
||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"turbo": "^2.7.5",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.0-beta.8",
|
"vite": "^8.0.0-beta.8",
|
||||||
"vite-tsconfig-paths": "^6.0.4"
|
"vite-tsconfig-paths": "^6.0.4"
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
- **项目类型**: Tauri v2 桌面应用(轻量级壳子)
|
- **项目类型**: Tauri v2 桌面应用(轻量级壳子)
|
||||||
- **后端**: Rust (Edition 2021)
|
- **后端**: Rust (Edition 2021)
|
||||||
- **架构**: Sidecar 模式 - Sidecar Server 承载主要业务逻辑
|
- **架构**: Sidecar 模式 - Sidecar App 承载主要业务逻辑
|
||||||
- **设计理念**: Tauri 仅提供原生桌面能力(文件对话框、系统通知等),Web 逻辑全部由 Sidecar Server 处理
|
- **设计理念**: Tauri 仅提供原生桌面能力(文件对话框、系统通知等),Web 逻辑全部由 Sidecar App 处理
|
||||||
- **开发模式**: 使用 localhost:3000(需手动启动开发服务器)
|
- **开发模式**: 使用 localhost:3000(需手动启动开发服务器)
|
||||||
- **生产模式**: 自动启动 Sidecar 二进制
|
- **生产模式**: 自动启动 Sidecar 二进制
|
||||||
- **异步运行时**: Tokio
|
- **异步运行时**: Tokio
|
||||||
@@ -91,7 +91,7 @@ cargo clean
|
|||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
tauri-shell/
|
app-desktop/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── main.rs # 入口文件 (仅调用 lib::run)
|
│ ├── main.rs # 入口文件 (仅调用 lib::run)
|
||||||
│ ├── lib.rs # 核心应用逻辑 (注册插件、命令、状态)
|
│ ├── lib.rs # 核心应用逻辑 (注册插件、命令、状态)
|
||||||
@@ -99,7 +99,7 @@ tauri-shell/
|
|||||||
│ │ └── mod.rs # 原生桌面功能命令 (文件对话框、通知等)
|
│ │ └── mod.rs # 原生桌面功能命令 (文件对话框、通知等)
|
||||||
│ └── sidecar.rs # Sidecar 进程管理 (启动、端口扫描、清理)
|
│ └── sidecar.rs # Sidecar 进程管理 (启动、端口扫描、清理)
|
||||||
├── binaries/ # Sidecar 二进制文件
|
├── binaries/ # Sidecar 二进制文件
|
||||||
│ └── server-* # Sidecar Server 可执行文件 (示例: server)
|
│ └── app-* # Sidecar App 可执行文件 (示例: app)
|
||||||
├── capabilities/ # Tauri v2 权限配置
|
├── capabilities/ # Tauri v2 权限配置
|
||||||
│ └── default.json
|
│ └── default.json
|
||||||
├── icons/ # 应用图标资源
|
├── icons/ # 应用图标资源
|
||||||
@@ -182,14 +182,14 @@ async fn is_port_available(port: u16) -> bool {
|
|||||||
// ✅ 推荐
|
// ✅ 推荐
|
||||||
let sidecar = app_handle
|
let sidecar = app_handle
|
||||||
.shell()
|
.shell()
|
||||||
.sidecar("server")
|
.sidecar("app")
|
||||||
.expect("无法找到 server sidecar");
|
.expect("无法找到 app sidecar");
|
||||||
|
|
||||||
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
|
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
|
||||||
|
|
||||||
// 日志记录
|
// 日志记录
|
||||||
eprintln!("✗ Sidecar Server 启动失败");
|
eprintln!("✗ Sidecar App 启动失败");
|
||||||
println!("✓ Sidecar Server 启动成功!");
|
println!("✓ Sidecar App 启动成功!");
|
||||||
|
|
||||||
// ❌ 避免
|
// ❌ 避免
|
||||||
let data = read_file().unwrap(); // 无上下文信息
|
let data = read_file().unwrap(); // 无上下文信息
|
||||||
@@ -226,7 +226,7 @@ tauri::async_runtime::spawn(async move {
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
// ✅ 推荐
|
// ✅ 推荐
|
||||||
// 全局状态:存储 Sidecar Server 进程句柄
|
// 全局状态:存储 Sidecar App 进程句柄
|
||||||
struct SidecarProcess(Mutex<Option<CommandChild>>);
|
struct SidecarProcess(Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
// 检查端口是否可用
|
// 检查端口是否可用
|
||||||
@@ -297,9 +297,9 @@ if let Some(state) = app_handle.try_state::<SidecarProcess>() {
|
|||||||
// 启动 sidecar
|
// 启动 sidecar
|
||||||
let sidecar = app_handle
|
let sidecar = app_handle
|
||||||
.shell()
|
.shell()
|
||||||
.sidecar("server")
|
.sidecar("app")
|
||||||
.expect("无法找到 server sidecar")
|
.expect("无法找到 app sidecar")
|
||||||
.env("NITRO_PORT", port.to_string());
|
.env("PORT", port.to_string());
|
||||||
|
|
||||||
// 清理进程
|
// 清理进程
|
||||||
match event {
|
match event {
|
||||||
|
|||||||
22
src-tauri/Cargo.lock
generated
22
src-tauri/Cargo.lock
generated
@@ -47,6 +47,17 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "app-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"tauri",
|
||||||
|
"tauri-build",
|
||||||
|
"tauri-plugin-shell",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -3423,17 +3434,6 @@ dependencies = [
|
|||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri-shell"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"tauri",
|
|
||||||
"tauri-build",
|
|
||||||
"tauri-plugin-shell",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.8.1"
|
version = "2.8.1"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tauri-shell"
|
name = "app-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["imbytecat"]
|
authors = ["imbytecat"]
|
||||||
@@ -11,7 +11,7 @@ edition = "2021"
|
|||||||
# The `_lib` suffix may seem redundant but it is necessary
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
name = "tauri_shell_lib"
|
name = "app_desktop_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|||||||
@@ -3,13 +3,22 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
|
"local": true,
|
||||||
|
"remote": {
|
||||||
|
"urls": [
|
||||||
|
"http://localhost:*",
|
||||||
|
"http://127.0.0.1:*",
|
||||||
|
"http{s}?://localhost(:\\d+)?/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-set-title",
|
||||||
{
|
{
|
||||||
"identifier": "shell:allow-execute",
|
"identifier": "shell:allow-execute",
|
||||||
"allow": [
|
"allow": [
|
||||||
{
|
{
|
||||||
"name": "binaries/server",
|
"name": "binaries/app",
|
||||||
"sidecar": true
|
"sidecar": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ pub fn run() {
|
|||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app_handle, event| {
|
.run(|app_handle, event| {
|
||||||
// 监听应用退出事件,清理 Sidecar 进程
|
// 监听应用退出事件,清理 Sidecar 进程
|
||||||
match event {
|
if let tauri::RunEvent::Exit = event {
|
||||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
|
// 只在 Exit 事件时清理,避免重复执行
|
||||||
sidecar::cleanup_sidecar_process(app_handle);
|
sidecar::cleanup_sidecar_process(app_handle);
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri_shell_lib::run()
|
app_desktop_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tauri_plugin_shell::ShellExt;
|
|||||||
|
|
||||||
// ===== 配置常量 =====
|
// ===== 配置常量 =====
|
||||||
|
|
||||||
/// Sidecar Server 启动超时时间(秒)
|
/// Sidecar App 启动超时时间(秒)
|
||||||
const STARTUP_TIMEOUT_SECS: u64 = 5;
|
const STARTUP_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
/// 默认起始端口
|
/// 默认起始端口
|
||||||
@@ -54,26 +54,30 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
|
|
||||||
if is_dev {
|
if is_dev {
|
||||||
// 开发模式:直接创建窗口连接到 Vite 开发服务器
|
// 开发模式:直接创建窗口连接到 Vite 开发服务器
|
||||||
println!("🔧 开发模式:连接到 Vite 开发服务器 (localhost:3000)");
|
println!("🔧 开发模式");
|
||||||
|
|
||||||
let url = "http://localhost:3000";
|
match tauri::WebviewWindowBuilder::new(
|
||||||
tauri::WebviewWindowBuilder::new(
|
|
||||||
&app_handle,
|
&app_handle,
|
||||||
"main",
|
"main",
|
||||||
tauri::WebviewUrl::External(url.parse().unwrap()),
|
tauri::WebviewUrl::External("http://localhost:3000".parse().unwrap()),
|
||||||
)
|
)
|
||||||
.title(WINDOW_TITLE)
|
.title(WINDOW_TITLE)
|
||||||
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
|
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
|
||||||
.center()
|
.center()
|
||||||
.build()
|
.build()
|
||||||
.expect("创建窗口失败");
|
{
|
||||||
|
Ok(_) => println!("✓ 开发窗口创建成功"),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("✗ 窗口创建失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生产模式:启动 sidecar 二进制
|
// 生产模式:启动 sidecar 二进制
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
println!("🚀 生产模式:启动 Sidecar Server");
|
println!("🚀 生产模式");
|
||||||
|
|
||||||
// 查找可用端口
|
// 查找可用端口
|
||||||
let port = find_available_port(DEFAULT_PORT).await;
|
let port = find_available_port(DEFAULT_PORT).await;
|
||||||
@@ -82,9 +86,9 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
// 启动 sidecar
|
// 启动 sidecar
|
||||||
let sidecar = app_handle
|
let sidecar = app_handle
|
||||||
.shell()
|
.shell()
|
||||||
.sidecar("server")
|
.sidecar("app")
|
||||||
.expect("无法找到 server")
|
.expect("无法找到 app")
|
||||||
.env("NITRO_PORT", port.to_string());
|
.env("PORT", port.to_string());
|
||||||
|
|
||||||
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
|
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
|
||||||
|
|
||||||
@@ -96,17 +100,17 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
// 监听 stdout,等待服务器就绪信号
|
// 监听 stdout,等待服务器就绪信号
|
||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
let timeout = Duration::from_secs(STARTUP_TIMEOUT_SECS);
|
let timeout = Duration::from_secs(STARTUP_TIMEOUT_SECS);
|
||||||
let mut server_ready = false;
|
let mut app_ready = false;
|
||||||
|
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
if let CommandEvent::Stdout(line) = event {
|
if let CommandEvent::Stdout(line) = event {
|
||||||
let output = String::from_utf8_lossy(&line);
|
let output = String::from_utf8_lossy(&line);
|
||||||
println!("Server: {}", output);
|
println!("App: {}", output);
|
||||||
|
|
||||||
// 检测服务器启动成功的标志
|
// 检测 App 启动成功的标志
|
||||||
if output.contains("Listening on:") || output.contains("localhost") {
|
if output.contains("Listening on:") || output.contains("localhost") {
|
||||||
server_ready = true;
|
app_ready = true;
|
||||||
println!("✓ Server 启动成功!");
|
println!("✓ App 启动成功!");
|
||||||
|
|
||||||
// 创建主窗口
|
// 创建主窗口
|
||||||
let url = format!("http://localhost:{}", port);
|
let url = format!("http://localhost:{}", port);
|
||||||
@@ -127,16 +131,13 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
|
|
||||||
// 超时检查
|
// 超时检查
|
||||||
if start_time.elapsed() > timeout {
|
if start_time.elapsed() > timeout {
|
||||||
eprintln!(
|
eprintln!("✗ 启动超时: App 未能在 {} 秒内启动", STARTUP_TIMEOUT_SECS);
|
||||||
"✗ 启动超时: Server 未能在 {} 秒内启动",
|
|
||||||
STARTUP_TIMEOUT_SECS
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !server_ready {
|
if !app_ready {
|
||||||
eprintln!("✗ Server 启动失败");
|
eprintln!("✗ App 启动失败");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -144,6 +145,15 @@ pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
|
|||||||
|
|
||||||
/// 清理 Sidecar 进程 (在应用退出时调用)
|
/// 清理 Sidecar 进程 (在应用退出时调用)
|
||||||
pub fn cleanup_sidecar_process(app_handle: &tauri::AppHandle) {
|
pub fn cleanup_sidecar_process(app_handle: &tauri::AppHandle) {
|
||||||
|
let is_dev = cfg!(debug_assertions);
|
||||||
|
|
||||||
|
if is_dev {
|
||||||
|
// 开发模式:退出时发送异常信号(exit 1),让 Turbo 停止 Vite 服务器
|
||||||
|
println!("🔧 开发模式退出,终止所有依赖任务...");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产模式:正常清理 sidecar 进程
|
||||||
println!("应用退出,正在清理 Sidecar 进程...");
|
println!("应用退出,正在清理 Sidecar 进程...");
|
||||||
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
|
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
|
||||||
if let Ok(mut process) = state.0.lock() {
|
if let Ok(mut process) = state.0.lock() {
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "tauri-shell",
|
"productName": "app-desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.imbytecat.tauri-shell",
|
"identifier": "com.imbytecat.app-desktop",
|
||||||
"build": {
|
|
||||||
"beforeDevCommand": "bun run dev:vite",
|
|
||||||
"beforeBuildCommand": "bun run build"
|
|
||||||
},
|
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [],
|
"windows": [],
|
||||||
@@ -24,6 +20,6 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"externalBin": ["binaries/server"]
|
"externalBin": ["binaries/app"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,9 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as TodosRouteImport } from './routes/todos'
|
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||||
|
|
||||||
const TodosRoute = TodosRouteImport.update({
|
|
||||||
id: '/todos',
|
|
||||||
path: '/todos',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -31,43 +25,32 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/todos': typeof TodosRoute
|
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/todos': typeof TodosRoute
|
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/todos': typeof TodosRoute
|
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/todos' | '/api/rpc/$'
|
fullPaths: '/' | '/api/rpc/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/todos' | '/api/rpc/$'
|
to: '/' | '/api/rpc/$'
|
||||||
id: '__root__' | '/' | '/todos' | '/api/rpc/$'
|
id: '__root__' | '/' | '/api/rpc/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
TodosRoute: typeof TodosRoute
|
|
||||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/todos': {
|
|
||||||
id: '/todos'
|
|
||||||
path: '/todos'
|
|
||||||
fullPath: '/todos'
|
|
||||||
preLoaderRoute: typeof TodosRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -87,7 +70,6 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
TodosRoute: TodosRoute,
|
|
||||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -1,7 +1,215 @@
|
|||||||
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { isTauri } from '@tauri-apps/api/core'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import type { ChangeEventHandler, FormEventHandler } from 'react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { orpc } from '@/orpc'
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({ component: App })
|
export const Route = createFileRoute('/')({
|
||||||
|
component: Todos,
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function App() {
|
function Todos() {
|
||||||
return <div>Hello, World!</div>
|
const [newTodoTitle, setNewTodoTitle] = useState('')
|
||||||
|
|
||||||
|
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
||||||
|
const createMutation = useMutation(orpc.todo.create.mutationOptions())
|
||||||
|
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
|
||||||
|
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return
|
||||||
|
getCurrentWindow().setTitle('待办事项')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (newTodoTitle.trim()) {
|
||||||
|
createMutation.mutate({ title: newTodoTitle.trim() })
|
||||||
|
setNewTodoTitle('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
setNewTodoTitle(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
|
||||||
|
updateMutation.mutate({
|
||||||
|
id,
|
||||||
|
data: { completed: !currentCompleted },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTodo = (id: string) => {
|
||||||
|
deleteMutation.mutate({ id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const todos = listQuery.data
|
||||||
|
const completedCount = todos.filter((todo) => todo.completed).length
|
||||||
|
const totalCount = todos.length
|
||||||
|
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
|
||||||
|
<div className="max-w-2xl mx-auto space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
||||||
|
我的待办
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 mt-1">保持专注,逐个击破</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-semibold text-slate-900">
|
||||||
|
{completedCount}
|
||||||
|
<span className="text-slate-400 text-lg">/{totalCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
已完成
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Todo Form */}
|
||||||
|
<form onSubmit={handleCreateTodo} className="relative group z-10">
|
||||||
|
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTodoTitle}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="添加新任务..."
|
||||||
|
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending || !newTodoTitle.trim()}
|
||||||
|
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? '添加中' : '添加'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Progress Bar (Only visible when there are tasks) */}
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Todo List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{todos.length === 0 ? (
|
||||||
|
<div className="py-20 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-slate-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 text-lg font-medium">没有待办事项</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
输入上方内容添加您的第一个任务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
todos.map((todo) => (
|
||||||
|
<div
|
||||||
|
key={todo.id}
|
||||||
|
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
|
||||||
|
todo.completed ? 'bg-slate-50/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleTodo(todo.id, todo.completed)}
|
||||||
|
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
||||||
|
todo.completed
|
||||||
|
? 'bg-indigo-500 border-indigo-500'
|
||||||
|
: 'border-slate-300 hover:border-indigo-500 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{todo.completed && (
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-lg transition-all duration-200 truncate ${
|
||||||
|
todo.completed
|
||||||
|
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
|
||||||
|
: 'text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{todo.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
|
||||||
|
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
|
||||||
|
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteTodo(todo.id)}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import type { ChangeEventHandler, FormEventHandler } from 'react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { orpc } from '@/orpc'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/todos')({
|
|
||||||
component: Todos,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function Todos() {
|
|
||||||
const [newTodoTitle, setNewTodoTitle] = useState('')
|
|
||||||
|
|
||||||
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
|
||||||
const createMutation = useMutation(orpc.todo.create.mutationOptions())
|
|
||||||
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
|
|
||||||
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
|
|
||||||
|
|
||||||
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (newTodoTitle.trim()) {
|
|
||||||
createMutation.mutate({ title: newTodoTitle.trim() })
|
|
||||||
setNewTodoTitle('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
setNewTodoTitle(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
id,
|
|
||||||
data: { completed: !currentCompleted },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteTodo = (id: string) => {
|
|
||||||
deleteMutation.mutate({ id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const todos = listQuery.data
|
|
||||||
const completedCount = todos.filter((todo) => todo.completed).length
|
|
||||||
const totalCount = todos.length
|
|
||||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
|
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-end justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
|
||||||
我的待办
|
|
||||||
</h1>
|
|
||||||
<p className="text-slate-500 mt-1">保持专注,逐个击破</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-semibold text-slate-900">
|
|
||||||
{completedCount}
|
|
||||||
<span className="text-slate-400 text-lg">/{totalCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
|
||||||
已完成
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Todo Form */}
|
|
||||||
<form onSubmit={handleCreateTodo} className="relative group z-10">
|
|
||||||
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newTodoTitle}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder="添加新任务..."
|
|
||||||
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending || !newTodoTitle.trim()}
|
|
||||||
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? '添加中' : '添加'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Progress Bar (Only visible when there are tasks) */}
|
|
||||||
{totalCount > 0 && (
|
|
||||||
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Todo List */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{todos.length === 0 ? (
|
|
||||||
<div className="py-20 text-center">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-8 h-8 text-slate-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-500 text-lg font-medium">没有待办事项</p>
|
|
||||||
<p className="text-slate-400 text-sm mt-1">
|
|
||||||
输入上方内容添加您的第一个任务
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
todos.map((todo) => (
|
|
||||||
<div
|
|
||||||
key={todo.id}
|
|
||||||
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
|
|
||||||
todo.completed ? 'bg-slate-50/50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleTodo(todo.id, todo.completed)}
|
|
||||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
|
||||||
todo.completed
|
|
||||||
? 'bg-indigo-500 border-indigo-500'
|
|
||||||
: 'border-slate-300 hover:border-indigo-500 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.completed && (
|
|
||||||
<svg
|
|
||||||
className="w-3.5 h-3.5 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={3}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p
|
|
||||||
className={`text-lg transition-all duration-200 truncate ${
|
|
||||||
todo.completed
|
|
||||||
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
|
|
||||||
: 'text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
|
|
||||||
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
|
|
||||||
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteTodo(todo.id)}
|
|
||||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
32
turbo.json
Normal file
32
turbo.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/turbo/schema.json",
|
||||||
|
"dangerouslyDisablePackageManagerCheck": true,
|
||||||
|
"tasks": {
|
||||||
|
"build:compile": {
|
||||||
|
"dependsOn": ["build:vite"],
|
||||||
|
"outputs": ["out/**", "src-tauri/binaries/**"]
|
||||||
|
},
|
||||||
|
"build:tauri": {
|
||||||
|
"dependsOn": ["build:compile"],
|
||||||
|
"outputs": ["src-tauri/target/release/bundle/**"]
|
||||||
|
},
|
||||||
|
"build:vite": {
|
||||||
|
"outputs": [".output/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"dev:tauri": {
|
||||||
|
"cache": false,
|
||||||
|
"dependsOn": ["build:compile"],
|
||||||
|
"persistent": true,
|
||||||
|
"with": ["dev:vite"]
|
||||||
|
},
|
||||||
|
"dev:vite": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ui": "tui"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user