Compare commits
1 Commits
main
...
codex/hong
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7deea00495 |
20
.eslintrc.cjs
Normal file
20
.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
"@electron-toolkit/eslint-config-ts",
|
||||
"@electron-toolkit/eslint-config-prettier",
|
||||
],
|
||||
ignorePatterns: [
|
||||
"dist/",
|
||||
"out/",
|
||||
"node_modules/",
|
||||
"*.config.js",
|
||||
"*.config.cjs",
|
||||
"*.config.mjs",
|
||||
"*.tsbuildinfo",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
},
|
||||
};
|
||||
55
.github/workflows/build.yml
vendored
Normal file
55
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build/Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: E2E smoke
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Build
|
||||
run: npm run build:win
|
||||
|
||||
- name: Generate SHA256
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem dist/*.exe | ForEach-Object {
|
||||
$hash = Get-FileHash $_.FullName -Algorithm SHA256
|
||||
"$($hash.Hash) $($_.Name)" | Out-File -Encoding ascii "$($_.FullName).sha256"
|
||||
}
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: laundry-desk-win
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*.exe.sha256
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,7 @@ out/
|
||||
build/
|
||||
release/
|
||||
*.asar
|
||||
test-results/
|
||||
|
||||
# packaged binaries
|
||||
*.exe
|
||||
@@ -26,6 +27,7 @@ release/
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -7,6 +7,7 @@ Codex 在本项目中的入场指引。
|
||||
**关键节点二审**。不做全量实现,不跟 Gemini 并行写 PR,避免冲突。
|
||||
|
||||
职责:
|
||||
|
||||
1. **架构审查**:每期开工前看 Claude 的 spec 与 Gemini 的初始 PR,发现重大架构问题
|
||||
2. **安全审查**:M1(IPC/CSP/sandbox)、M4(短信凭证加密、登录、审计完整性)重点介入
|
||||
3. **并发审查**:取件码生成、订单号生成、备份文件写入、SQLite WAL 等并发点
|
||||
@@ -21,28 +22,33 @@ Codex 在本项目中的入场指引。
|
||||
## 审查侧重点
|
||||
|
||||
### 通用
|
||||
|
||||
- **输入验证边界**:Zod schema 是否覆盖所有 IPC / 服务入口
|
||||
- **类型安全**:禁 `any`、禁不必要的 `as`、严守 `strict`
|
||||
- **事务**:多表写入(收件 / 取件 / 备份元数据更新)必须事务
|
||||
- **金额精度**:整型分、零浮点、汇总与明细一致性
|
||||
|
||||
### M1(基础)
|
||||
|
||||
- Electron 安全:`contextIsolation` / `sandbox` / `nodeIntegration` / CSP / preload 暴露面最小
|
||||
- DB 初始化 & migration 的幂等性
|
||||
- 取件码 / 订单号生成的并发安全(单实例进程内也要事务重试)
|
||||
- 备份 → zip → rotate 链路的原子性与失败回滚
|
||||
|
||||
### M2(收款 & 统计)
|
||||
|
||||
- 金额浮点陷阱(展示时再除 100)
|
||||
- 聚合查询性能(大表上的 `GROUP BY receive_date`)
|
||||
- Excel 导入的数据清洗与主键冲突处理
|
||||
|
||||
### M3(照片 & 打印)
|
||||
|
||||
- 大文件(照片)的磁盘占用与清理策略
|
||||
- 打印驱动抽象是否真能支持换型号
|
||||
- 打印失败是否阻塞业务流程(必须异步 / 可重试)
|
||||
|
||||
### M4(员工 & SMS)
|
||||
|
||||
- Argon2 参数(memory / time / parallelism)对 Windows 柜台 CPU 的影响
|
||||
- 会话机制(login 后怎么保持?Electron 单用户场景)
|
||||
- **短信 SecretKey 加密存储**:必须用 OS keychain(`keytar`),不入库明文
|
||||
|
||||
@@ -7,6 +7,7 @@ Claude(Opus 4.7)在本项目中的入场指引。
|
||||
**设计与门禁**。不写实现代码,不 scaffold,不装依赖。
|
||||
|
||||
职责:
|
||||
|
||||
1. **Brainstorm & Spec**:需求澄清、方案权衡、写设计文档
|
||||
2. **门禁验收**:每期结束对照验收清单判断是否可发版
|
||||
3. **Code Review**:审 Gemini 的 PR,重点看是否符合 spec 与架构约束
|
||||
@@ -24,6 +25,7 @@ Claude(Opus 4.7)在本项目中的入场指引。
|
||||
## 门禁清单(每期 Gemini 声明完成时用)
|
||||
|
||||
### 质量
|
||||
|
||||
- [ ] TypeScript `strict: true` 零错
|
||||
- [ ] ESLint + Prettier 零警告
|
||||
- [ ] 单文件 ≤ 400 行,函数 ≤ 50 行,嵌套 ≤ 4 层
|
||||
@@ -32,17 +34,20 @@ Claude(Opus 4.7)在本项目中的入场指引。
|
||||
- [ ] Renderer 零 Node/DB 直连(`contextIsolation: true` / `nodeIntegration: false` / `sandbox: true`)
|
||||
|
||||
### 测试
|
||||
|
||||
- [ ] Service 层 Vitest 覆盖率 ≥ 70%
|
||||
- [ ] Playwright E2E 覆盖本期核心路径
|
||||
- [ ] 备份文件可还原到全新安装
|
||||
|
||||
### 交付
|
||||
|
||||
- [ ] GH Actions `windows-latest` 构建绿灯
|
||||
- [ ] Windows 10/11 实机冒烟(manpengan 走查)
|
||||
- [ ] `.exe` 大小记录基线(防膨胀)
|
||||
- [ ] GitHub Release 附 NSIS 安装器 + SHA256
|
||||
|
||||
### 文档
|
||||
|
||||
- [ ] README 截图更新
|
||||
- [ ] `docs/CHANGELOG.md` 本期条目
|
||||
|
||||
|
||||
27
GEMINI.md
27
GEMINI.md
@@ -5,6 +5,7 @@ Gemini 在本项目中的入场指引。你是**主力实现者**。
|
||||
## 你在这个项目里的角色
|
||||
|
||||
把 Claude 的 spec 落成能在 Windows 10/11 上跑的 exe。包括:
|
||||
|
||||
- 代码实现(main / preload / renderer / shared / services)
|
||||
- 测试编写(Vitest + Playwright)
|
||||
- 构建配置(Vite / electron-vite / electron-builder)
|
||||
@@ -21,17 +22,17 @@ Gemini 在本项目中的入场指引。你是**主力实现者**。
|
||||
|
||||
## 代码红线(硬性)
|
||||
|
||||
| 项 | 红线 |
|
||||
|---|---|
|
||||
| TypeScript | `strict: true`,零 `any`,必要时用 `unknown` + Zod |
|
||||
| 文件 | ≤ 400 行 |
|
||||
| 函数 | ≤ 50 行 |
|
||||
| 嵌套 | ≤ 4 层 |
|
||||
| 金额 | 一律 `int`(分),禁浮点 |
|
||||
| 密钥 | 不入代码、不入库明文(M4 用 `keytar`) |
|
||||
| IPC | 入参过 Zod,返回 `{ ok: true, data } \| { ok: false, error: { code, message } }` |
|
||||
| Electron | `contextIsolation: true` / `nodeIntegration: false` / `sandbox: true` / 严格 CSP |
|
||||
| 不可变 | 数据操作返回新对象,避免就地修改 |
|
||||
| 项 | 红线 |
|
||||
| ---------- | -------------------------------------------------------------------------------- |
|
||||
| TypeScript | `strict: true`,零 `any`,必要时用 `unknown` + Zod |
|
||||
| 文件 | ≤ 400 行 |
|
||||
| 函数 | ≤ 50 行 |
|
||||
| 嵌套 | ≤ 4 层 |
|
||||
| 金额 | 一律 `int`(分),禁浮点 |
|
||||
| 密钥 | 不入代码、不入库明文(M4 用 `keytar`) |
|
||||
| IPC | 入参过 Zod,返回 `{ ok: true, data } \| { ok: false, error: { code, message } }` |
|
||||
| Electron | `contextIsolation: true` / `nodeIntegration: false` / `sandbox: true` / 严格 CSP |
|
||||
| 不可变 | 数据操作返回新对象,避免就地修改 |
|
||||
|
||||
## 目录骨架(M1 一次落盘)
|
||||
|
||||
@@ -51,6 +52,7 @@ tests/
|
||||
**按顺序做,不跳期。每期完成一轮"代码 → 测试 → CI 绿 → 实机冒烟 → 过门禁 → tag"。**
|
||||
|
||||
### M1(v0.1.0)— 基础
|
||||
|
||||
1. 脚手架:Electron + Vite + electron-vite + TS + Tailwind 4 + shadcn/ui + Framer Motion + Zustand + React Router 7
|
||||
2. Apple HIG 设计系统:字体、色彩 token、全局 CSS、基础组件(Button / Card / Input / Dialog / Sheet / Toast)
|
||||
3. DB 层:Drizzle schema + migrations(8 张表,见 spec §4)+ better-sqlite3 连接
|
||||
@@ -64,18 +66,21 @@ tests/
|
||||
11. E2E:收件→取件的完整流程
|
||||
|
||||
### M2(v0.2.0)— 收款 & 统计
|
||||
|
||||
- `settings.price_templates` + 价格 autocomplete
|
||||
- 付款方式、折扣、欠款
|
||||
- 日/月报表(Recharts)、逾期未取列表
|
||||
- Excel 导入导出(exceljs):客户 / 订单 / 明细
|
||||
|
||||
### M3(v0.3.0)— 照片 & 打印
|
||||
|
||||
- 收件页:调用摄像头 or 选本地文件,存 `userData/photos/YYYY-MM/<order>_<n>.jpg`
|
||||
- `PrinterDriver` 抽象 + 58mm ESC/POS 实现(`electron-pos-printer`)
|
||||
- 登记单模板:店名 / 单号 / 取件码 / 电话尾 4 位 / 明细表 / 总价
|
||||
- 取件条模板:取件码 / 单号 / 取件人 / 金额结清
|
||||
|
||||
### M4(v0.4.0 → v1.0.0)— 员工 & 短信
|
||||
|
||||
- Argon2 密码哈希,Login 页
|
||||
- 权限中间件(IPC handler 包装层):admin 可进 Settings / Customers,staff 仅收件 / 取件
|
||||
- `audit_log` 全面绑定(每个写入 IPC)
|
||||
|
||||
22
README.md
22
README.md
@@ -6,12 +6,12 @@
|
||||
|
||||
## 状态
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 阶段 | M0(设计完成,待 Gemini 实现 M1) |
|
||||
| 项 | 值 |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 阶段 | M0(设计完成,待 Gemini 实现 M1) |
|
||||
| 设计文档 | [docs/superpowers/specs/2026-04-23-laundry-desk-design.md](docs/superpowers/specs/2026-04-23-laundry-desk-design.md) |
|
||||
| 目标平台 | Windows 10 / 11(NSIS `.exe`) |
|
||||
| 开发平台 | macOS(GitHub Actions `windows-latest` 构建为准) |
|
||||
| 目标平台 | Windows 10 / 11(NSIS `.exe`) |
|
||||
| 开发平台 | macOS(GitHub Actions `windows-latest` 构建为准) |
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -19,12 +19,12 @@ Electron 32 · React 19 · TypeScript 5 · Tailwind CSS 4 · shadcn/ui · Framer
|
||||
|
||||
## 路线图
|
||||
|
||||
| 期 | Tag | 范围 |
|
||||
|---|---|---|
|
||||
| M1 | `v0.1.0` | 骨架 + Apple UI + 收件/取件/列表/详情 + 客户去重 + 自动备份 + Windows 打包 |
|
||||
| M2 | `v0.2.0` | 价格模板 + 按件计费 + 付款/欠款 + 日/月报表 + 逾期 + Excel 导入导出 |
|
||||
| M3 | `v0.3.0` | 收件拍照 + 58mm 热敏打印登记单 / 取件条 |
|
||||
| M4 | `v0.4.0` → `v1.0.0` | 登录 + 权限 + 审计 + 腾讯云 SMS |
|
||||
| 期 | Tag | 范围 |
|
||||
| --- | ------------------- | -------------------------------------------------------------------------- |
|
||||
| M1 | `v0.1.0` | 骨架 + Apple UI + 收件/取件/列表/详情 + 客户去重 + 自动备份 + Windows 打包 |
|
||||
| M2 | `v0.2.0` | 价格模板 + 按件计费 + 付款/欠款 + 日/月报表 + 逾期 + Excel 导入导出 |
|
||||
| M3 | `v0.3.0` | 收件拍照 + 58mm 热敏打印登记单 / 取件条 |
|
||||
| M4 | `v0.4.0` → `v1.0.0` | 登录 + 权限 + 审计 + 腾讯云 SMS |
|
||||
|
||||
## 分工
|
||||
|
||||
|
||||
186
docs/superpowers/plans/2026-04-23-m1-delivery.md
Normal file
186
docs/superpowers/plans/2026-04-23-m1-delivery.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# M1 Delivery Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Deliver `laundry-desk` M1 as a working Electron Windows NSIS app with receive, pickup, order list/detail, customer de-duplication, automatic/manual backup, type/lint/test gates, and CI artifact generation.
|
||||
|
||||
**Architecture:** Keep the approved Electron split: main owns SQLite and services, preload exposes a narrow typed API, renderer uses IPC only. Replace the current prototype DB/bootstrap with focused service modules, explicit Zod IPC schemas, Drizzle relations, and deterministic tests using in-memory SQLite. M2-M4 placeholders are removed or hidden unless needed by M1.
|
||||
|
||||
**Tech Stack:** Electron 41, React 19, TypeScript strict, Vite/electron-vite, better-sqlite3, Drizzle ORM, Zod, Vitest, Tailwind CSS, Playwright, electron-builder NSIS.
|
||||
|
||||
**Status 2026-04-23:** M1 local delivery completed by Codex. Windows x64 NSIS artifact generated at `dist/laundry-desk Setup 0.1.0.exe`; SHA256 stored next to it. Final gates passed: `npm run lint`, `npm run typecheck`, `npm test -- --run`, `npm run test:e2e`, `npm run build:win`, and `npm audit --omit=dev --audit-level=high` with no high-severity production findings. Remaining audit item is moderate `uuid` via `exceljs` / Tencent Cloud SDK; npm's automatic fix is breaking, so it is intentionally not applied for M1.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Build And Tooling Baseline
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `tsconfig.node.json`
|
||||
- Modify: `tsconfig.web.json`
|
||||
- Modify: `electron.vite.config.ts`
|
||||
- Create: `eslint.config.js`
|
||||
- Modify: `vitest.config.ts`
|
||||
|
||||
- [ ] **Step 1: Run current failing gates**
|
||||
|
||||
Run: `npm run typecheck`, `npm run lint`, `npm test -- --run`
|
||||
Expected: failures document the current baseline.
|
||||
|
||||
- [ ] **Step 2: Fix TypeScript project boundaries**
|
||||
|
||||
Set `rootDir` to `src` or use `rootDirs` so `src/shared` compiles for node and web. Keep `strict` inherited from electron-toolkit.
|
||||
|
||||
- [ ] **Step 3: Add working ESLint flat config**
|
||||
|
||||
Use TypeScript parser/config compatible with ESLint 8 and React JSX. Make the lint script run without "config not found".
|
||||
|
||||
- [ ] **Step 4: Rebuild native dependencies for local Node if needed**
|
||||
|
||||
Run: `npm rebuild better-sqlite3`.
|
||||
Expected: Vitest can import `better-sqlite3`.
|
||||
|
||||
- [ ] **Step 5: Re-run gates**
|
||||
|
||||
Run: `npm run typecheck`, `npm run lint`, `npm test -- --run`
|
||||
Expected: tooling errors are resolved or narrowed to real implementation failures.
|
||||
|
||||
### Task 2: Database And Service Core
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/db/schema.ts`
|
||||
- Modify: `src/main/db/index.ts`
|
||||
- Create: `src/main/db/migrate.ts`
|
||||
- Create: `src/main/db/client.ts`
|
||||
- Modify: `src/main/services/customerService.ts`
|
||||
- Modify: `src/main/services/orderService.ts`
|
||||
- Modify: `src/main/services/pickupCodeService.ts`
|
||||
- Modify: `src/main/services/auditService.ts`
|
||||
- Modify: `tests/unit/services.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write service tests for M1 behavior**
|
||||
|
||||
Cover customer upsert by phone, order creation transaction, integer cents validation, order number sequencing, pickup code format, pickup search by code/phone/order number, pickup balance settlement, and audit log creation.
|
||||
|
||||
- [ ] **Step 2: Verify tests fail for missing/incorrect behavior**
|
||||
|
||||
Run: `npm test -- --run tests/unit/services.test.ts`
|
||||
Expected: failures for missing search, transaction/audit, validation, or schema gaps.
|
||||
|
||||
- [ ] **Step 3: Implement DB initialization**
|
||||
|
||||
Expose a `createDbClient(sqlite)` helper for tests and `getDb()` for app runtime. Enable WAL and foreign keys. Run idempotent SQL migrations for all M1 tables and indexes from one source.
|
||||
|
||||
- [ ] **Step 4: Implement service behavior**
|
||||
|
||||
Use transactions for create order and pickup. Generate `order_no` and `pickup_code` inside the transaction with unique-conflict retry. Recompute totals from item rows, require integer cents, require `paidAmount <= totalAmount`, and write audit rows through the same transaction.
|
||||
|
||||
- [ ] **Step 5: Re-run unit tests**
|
||||
|
||||
Run: `npm test -- --run tests/unit/services.test.ts`
|
||||
Expected: service tests pass.
|
||||
|
||||
### Task 3: IPC Contract And Preload API
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/shared/index.ts`
|
||||
- Modify: `src/shared/schemas.ts`
|
||||
- Modify: `src/preload/index.ts`
|
||||
- Modify: `src/main/ipc/orders.ts`
|
||||
- Modify: `src/main/ipc/customers.ts`
|
||||
- Modify: `src/main/ipc/settings.ts`
|
||||
- Create: `src/main/ipc/backup.ts`
|
||||
- Modify: `src/main/index.ts`
|
||||
|
||||
- [ ] **Step 1: Write IPC schema tests where practical**
|
||||
|
||||
Cover invalid order id, invalid search query, invalid settings key, and invalid backup action schemas.
|
||||
|
||||
- [ ] **Step 2: Implement uniform IPC helpers**
|
||||
|
||||
Every handler returns `{ ok: true, data } | { ok: false, error }`, logs detailed errors in main, and returns user-safe messages.
|
||||
|
||||
- [ ] **Step 3: Narrow preload exposure**
|
||||
|
||||
Expose only `window.api`; remove default `window.electron`; use typed request/response signatures instead of `any`.
|
||||
|
||||
- [ ] **Step 4: Add CSP and production-safe window behavior**
|
||||
|
||||
Remove debug background, `alwaysOnTop`, devtools auto-open, and fixed startup delay. Add CSP to renderer HTML.
|
||||
|
||||
- [ ] **Step 5: Re-run typecheck/lint**
|
||||
|
||||
Run: `npm run typecheck && npm run lint`
|
||||
Expected: zero errors.
|
||||
|
||||
### Task 4: Renderer M1 Flows
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/index.html`
|
||||
- Modify: `src/renderer/src/assets/main.css`
|
||||
- Modify: `src/renderer/src/env.d.ts`
|
||||
- Modify: `src/renderer/src/routes/index.tsx`
|
||||
- Modify: `src/renderer/src/components/Layout.tsx`
|
||||
- Modify: `src/renderer/src/routes/Home.tsx`
|
||||
- Modify: `src/renderer/src/routes/Receive.tsx`
|
||||
- Modify: `src/renderer/src/routes/Pickup.tsx`
|
||||
- Modify: `src/renderer/src/routes/Orders.tsx`
|
||||
- Modify: `src/renderer/src/routes/OrderDetail.tsx`
|
||||
- Modify: `src/renderer/src/routes/Customers.tsx`
|
||||
- Modify: `src/renderer/src/routes/Settings.tsx`
|
||||
|
||||
- [ ] **Step 1: Add focused renderer tests if test harness exists, otherwise rely on typecheck and manual/E2E smoke**
|
||||
|
||||
Validate route imports compile and typed IPC calls are correct.
|
||||
|
||||
- [ ] **Step 2: Restore usable styling**
|
||||
|
||||
Either configure Tailwind correctly or replace with a compact CSS utility layer sufficient for the current class usage. Preserve Apple HIG-inspired layout and responsive behavior.
|
||||
|
||||
- [ ] **Step 3: Complete receive flow**
|
||||
|
||||
Phone lookup, customer upsert, item rows with cents conversion, partial payment selection, validation messages, success reset, and navigation to order detail.
|
||||
|
||||
- [ ] **Step 4: Complete pickup flow**
|
||||
|
||||
Search by pickup code, phone, order number, or customer name; render results; support balance settlement and picked-up transition.
|
||||
|
||||
- [ ] **Step 5: Complete list/detail/settings M1 routes**
|
||||
|
||||
Order list pagination basics, order detail with print button hidden/disabled until M3, customers list/search, settings shop name, manual backup, and backup list/restore placeholder only if clearly marked out-of-scope.
|
||||
|
||||
### Task 5: Backup And Delivery Verification
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/services/backupService.ts`
|
||||
- Modify: `src/main/ipc/backup.ts`
|
||||
- Modify: `tests/unit/services.test.ts`
|
||||
- Modify: `.github/workflows/build.yml`
|
||||
- Modify: `README.md`
|
||||
- Create: `docs/CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Add backup tests**
|
||||
|
||||
Use a temp directory and a test database. Verify backup zip is created atomically, contains `laundry.db`, and rotate keeps 30 newest files.
|
||||
|
||||
- [ ] **Step 2: Implement backup service**
|
||||
|
||||
Checkpoint WAL, write to temp zip, finalize, rename atomically, rotate after success, and expose manual backup IPC.
|
||||
|
||||
- [ ] **Step 3: Add CI gates**
|
||||
|
||||
Run install, typecheck, lint, test, build, and `build:win`. Upload NSIS artifact and SHA256.
|
||||
|
||||
- [ ] **Step 4: Run local verification**
|
||||
|
||||
Run: `npm run typecheck`, `npm run lint`, `npm test -- --run`, `npm run build`
|
||||
Expected: all pass locally.
|
||||
|
||||
- [ ] **Step 5: Attempt package verification**
|
||||
|
||||
On macOS run `npm run build:unpack` if supported. Final `.exe` must be generated by GitHub Actions `windows-latest`.
|
||||
@@ -16,6 +16,7 @@ authors: claude (brainstorm), manpengan (decision)
|
||||
- 数据:本地 SQLite(WAL + 每日自动备份)
|
||||
|
||||
**典型场景:**
|
||||
|
||||
- 收件:查回头客 → 录物品明细 → 计价收款 → 生成 4 位取件码 → 打印登记单
|
||||
- 取件:客户报取件码 / 电话 / 单号 → 查询 → 收尾款 → 标记已取 → 打印取件条
|
||||
- 管理:店主查看日/月营业额、逾期未取、回头率
|
||||
@@ -28,25 +29,25 @@ authors: claude (brainstorm), manpengan (decision)
|
||||
|
||||
## 3. 技术栈
|
||||
|
||||
| 层 | 选型 |
|
||||
|---|---|
|
||||
| 应用框架 | Electron 32+ |
|
||||
| 前端 | React 19 + TypeScript 5 (strict) |
|
||||
| UI | Tailwind CSS 4 + shadcn/ui |
|
||||
| 动效 | Framer Motion 11 |
|
||||
| 字体 | SF Pro Display/Text + PingFang SC |
|
||||
| 状态 | Zustand |
|
||||
| 路由 | React Router 7 |
|
||||
| 数据库 | better-sqlite3 + Drizzle ORM |
|
||||
| 密码哈希 | @node-rs/argon2 |
|
||||
| 构建 | Vite + electron-vite |
|
||||
| 打包 | electron-builder(NSIS) |
|
||||
| 图表 | Recharts |
|
||||
| 打印 | electron-pos-printer(58mm ESC/POS) |
|
||||
| 短信 | @tencentcloud/tencentcloud-sdk-nodejs-sms |
|
||||
| Excel | exceljs |
|
||||
| 测试 | Vitest(单测)+ Playwright(E2E) |
|
||||
| CI | GitHub Actions(`windows-latest` 构建 + artifact) |
|
||||
| 层 | 选型 |
|
||||
| -------- | -------------------------------------------------- |
|
||||
| 应用框架 | Electron 32+ |
|
||||
| 前端 | React 19 + TypeScript 5 (strict) |
|
||||
| UI | Tailwind CSS 4 + shadcn/ui |
|
||||
| 动效 | Framer Motion 11 |
|
||||
| 字体 | SF Pro Display/Text + PingFang SC |
|
||||
| 状态 | Zustand |
|
||||
| 路由 | React Router 7 |
|
||||
| 数据库 | better-sqlite3 + Drizzle ORM |
|
||||
| 密码哈希 | @node-rs/argon2 |
|
||||
| 构建 | Vite + electron-vite |
|
||||
| 打包 | electron-builder(NSIS) |
|
||||
| 图表 | Recharts |
|
||||
| 打印 | electron-pos-printer(58mm ESC/POS) |
|
||||
| 短信 | @tencentcloud/tencentcloud-sdk-nodejs-sms |
|
||||
| Excel | exceljs |
|
||||
| 测试 | Vitest(单测)+ Playwright(E2E) |
|
||||
| CI | GitHub Actions(`windows-latest` 构建 + artifact) |
|
||||
|
||||
**UI 视觉规范(Apple HIG):** SF Pro + PingFang SC、圆角 12–16px、半透明/毛玻璃卡片、macOS System Colors(`systemBlue #007AFF` 等)、Framer Motion 过渡、深色模式跟随系统、窗口 `titleBarStyle: 'hiddenInset'`(Mac 开发态)/ Windows 下降级为圆角无边框。
|
||||
|
||||
@@ -54,16 +55,16 @@ authors: claude (brainstorm), manpengan (decision)
|
||||
|
||||
金额一律用**整数分**存储(杜绝浮点)。
|
||||
|
||||
| 表 | 关键字段 |
|
||||
|---|---|
|
||||
| `customers` | id, name, phone (unique), vip_level, total_orders, total_spent, created_at, updated_at |
|
||||
| `orders` | id, order_no (unique, `YYYYMMDD-NNNN`), pickup_code (char(4)), customer_id (fk), status (pending/ready/picked_up/cancelled), total_amount, paid_amount, payment_method (cash/wechat/alipay/card/unpaid), receive_date, expected_pickup_date, actual_pickup_at, staff_id (fk), picked_up_by (fk nullable), notes, created_at, updated_at |
|
||||
| `order_items` | id, order_id (fk cascade), item_type, service_type (wash/dry_clean/iron), quantity, unit_price, subtotal, item_notes |
|
||||
| `order_photos` | id, order_id (fk cascade), file_path (相对 userData), taken_at |
|
||||
| `staffs` | id, username (unique), password_hash (Argon2), display_name, role (admin/staff), is_active, created_at, last_login_at |
|
||||
| `sms_log` | id, order_id (fk), phone, content, status (pending/sent/failed), provider_response (json), sent_at |
|
||||
| `settings` | key (pk), value (json), updated_at |
|
||||
| `audit_log` | id, staff_id (fk nullable), action (create/update/delete/pickup/cancel/login/export), entity, entity_id, diff (json), created_at |
|
||||
| 表 | 关键字段 |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `customers` | id, name, phone (unique), vip_level, total_orders, total_spent, created_at, updated_at |
|
||||
| `orders` | id, order_no (unique, `YYYYMMDD-NNNN`), pickup_code (char(4)), customer_id (fk), status (pending/ready/picked_up/cancelled), total_amount, paid_amount, payment_method (cash/wechat/alipay/card/unpaid), receive_date, expected_pickup_date, actual_pickup_at, staff_id (fk), picked_up_by (fk nullable), notes, created_at, updated_at |
|
||||
| `order_items` | id, order_id (fk cascade), item_type, service_type (wash/dry_clean/iron), quantity, unit_price, subtotal, item_notes |
|
||||
| `order_photos` | id, order_id (fk cascade), file_path (相对 userData), taken_at |
|
||||
| `staffs` | id, username (unique), password_hash (Argon2), display_name, role (admin/staff), is_active, created_at, last_login_at |
|
||||
| `sms_log` | id, order_id (fk), phone, content, status (pending/sent/failed), provider_response (json), sent_at |
|
||||
| `settings` | key (pk), value (json), updated_at |
|
||||
| `audit_log` | id, staff_id (fk nullable), action (create/update/delete/pickup/cancel/login/export), entity, entity_id, diff (json), created_at |
|
||||
|
||||
**索引:** `orders(pickup_code)`、`orders(customer_id, status, receive_date)`、`audit_log(created_at)`、`customers(phone) unique`、`orders(order_no) unique`。
|
||||
|
||||
@@ -97,6 +98,7 @@ src/
|
||||
```
|
||||
|
||||
**硬约束:**
|
||||
|
||||
- `contextIsolation: true` / `nodeIntegration: false` / `sandbox: true` / CSP 严格
|
||||
- Renderer 零 Node/DB 访问,全走 IPC
|
||||
- IPC 命名 `<domain>:<action>`(如 `orders:create`);入参一律过 Zod;返回 `{ ok: true, data } | { ok: false, error: { code, message } }`
|
||||
@@ -106,25 +108,29 @@ src/
|
||||
## 6. 核心工作流
|
||||
|
||||
### 6.1 收件
|
||||
|
||||
电话查回头客 → 明细多行 → 付款方式 + 实收 → 事务生成 `pickup_code` + `order_no` → 写 `orders` + `order_items`(+ 可选 `order_photos`)→ 更新 `customers.total_orders/total_spent` → `audit_log` → (M3)打印登记单。
|
||||
|
||||
### 6.2 取件
|
||||
|
||||
取件码(优先)/ 电话 / 单号 / 姓名查询 → 多条时列表选 → 有欠款先收 → `status = picked_up`、`actual_pickup_at`、`picked_up_by` → `audit_log` → (M3)打印取件条。
|
||||
|
||||
### 6.3 自动备份
|
||||
|
||||
`app.ready` 启动 `node-cron` 03:00 → `PRAGMA wal_checkpoint(TRUNCATE)` → 复制 `.db` → zip → 滚动保留 30 份。
|
||||
|
||||
### 6.4 短信通知(M4)
|
||||
|
||||
手动/批量触发 → Service 调腾讯云 SMS SDK → 写 `sms_log`(pending → sent/failed)→ 状态回填订单详情。
|
||||
|
||||
## 7. 分期路线图
|
||||
|
||||
| 期 | Tag | 交付 |
|
||||
|---|---|---|
|
||||
| **M1** | `v0.1.0` | 项目骨架 + Apple HIG 设计系统 + 收件/取件/列表/详情 + 客户自动去重 + 自动备份 + Windows NSIS 打包 |
|
||||
| **M2** | `v0.2.0` | 价格模板 + 按件计费 + 折扣 + 付款/欠款 + 日/月报表(Recharts)+ 逾期未取 + Excel 导入导出 |
|
||||
| **M3** | `v0.3.0` | 收件拍照(1–3 张,存 `userData/photos/YYYY-MM/`)+ 58mm 热敏打印登记单 / 取件条 |
|
||||
| **M4** | `v0.4.0` → `v1.0.0` | 登录(Argon2)+ 权限(admin/staff)+ 审计日志全面绑定 + 腾讯云 SMS 可取件通知(可关闭) |
|
||||
| 期 | Tag | 交付 |
|
||||
| ------ | ------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **M1** | `v0.1.0` | 项目骨架 + Apple HIG 设计系统 + 收件/取件/列表/详情 + 客户自动去重 + 自动备份 + Windows NSIS 打包 |
|
||||
| **M2** | `v0.2.0` | 价格模板 + 按件计费 + 折扣 + 付款/欠款 + 日/月报表(Recharts)+ 逾期未取 + Excel 导入导出 |
|
||||
| **M3** | `v0.3.0` | 收件拍照(1–3 张,存 `userData/photos/YYYY-MM/`)+ 58mm 热敏打印登记单 / 取件条 |
|
||||
| **M4** | `v0.4.0` → `v1.0.0` | 登录(Argon2)+ 权限(admin/staff)+ 审计日志全面绑定 + 腾讯云 SMS 可取件通知(可关闭) |
|
||||
|
||||
## 8. 验收门禁(每期必过)
|
||||
|
||||
@@ -135,25 +141,25 @@ src/
|
||||
|
||||
## 9. 分工
|
||||
|
||||
| 角色 | 职责 | 工具 |
|
||||
|---|---|---|
|
||||
| 角色 | 职责 | 工具 |
|
||||
| --------------------- | ------------------------------------------ | ----------- |
|
||||
| **Claude (Opus 4.7)** | brainstorm / spec / 门禁验收 / code review | Claude Code |
|
||||
| **Codex** | 关键节点二次审查(架构 / 安全 / 并发) | Codex CLI |
|
||||
| **Gemini** | M1~M4 主力实现、测试编写、修 build | Gemini CLI |
|
||||
| **manpengan** | 决策 / UI 走查 / 发版 | — |
|
||||
| **Codex** | 关键节点二次审查(架构 / 安全 / 并发) | Codex CLI |
|
||||
| **Gemini** | M1~M4 主力实现、测试编写、修 build | Gemini CLI |
|
||||
| **manpengan** | 决策 / UI 走查 / 发版 | — |
|
||||
|
||||
**节奏:** 每期开始 Claude 在 milestone issue 补实施细节 → Gemini 提 PR → Claude 审 → Codex 关键点复审 → Claude 过门禁清单 → manpengan tag release。
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 风险 | 缓解 |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Mac 交叉打 Windows exe,better-sqlite3 native 模块失败 | GH Actions `windows-latest` 为准;本地 `pnpm rebuild`;electron-builder `buildDependenciesFromSource: false` |
|
||||
| 打印机型号差异 | M3 抽象 `PrinterDriver` 接口,先支持 58mm 通用 ESC/POS,型号配置化 |
|
||||
| 腾讯云 SMS 模板审核周期 | Provider 可替换;模板 id 从 settings 读;无短信时仍可发版 |
|
||||
| SQLite 单文件损坏 | WAL + 每日 zip + 备份校验任务 |
|
||||
| Electron XSS → 任意代码 | contextIsolation + sandbox + CSP + 所有 IPC 过 Zod |
|
||||
| 密钥泄漏(短信 SecretKey) | M4 用 OS keychain(`keytar`)加密存储,非明文入库 |
|
||||
| 打印机型号差异 | M3 抽象 `PrinterDriver` 接口,先支持 58mm 通用 ESC/POS,型号配置化 |
|
||||
| 腾讯云 SMS 模板审核周期 | Provider 可替换;模板 id 从 settings 读;无短信时仍可发版 |
|
||||
| SQLite 单文件损坏 | WAL + 每日 zip + 备份校验任务 |
|
||||
| Electron XSS → 任意代码 | contextIsolation + sandbox + CSP + 所有 IPC 过 Zod |
|
||||
| 密钥泄漏(短信 SecretKey) | M4 用 OS keychain(`keytar`)加密存储,非明文入库 |
|
||||
|
||||
## 11. 假设
|
||||
|
||||
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: "./src/main/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: "laundry.db",
|
||||
},
|
||||
} satisfies Config;
|
||||
38
electron.vite.config.ts
Normal file
38
electron.vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@main": resolve("src/main"),
|
||||
"@shared": resolve("src/shared"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["@electron-toolkit/utils"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": resolve("src/shared"),
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@renderer": resolve("src/renderer/src"),
|
||||
"@shared": resolve("src/shared"),
|
||||
},
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
},
|
||||
});
|
||||
14893
package-lock.json
generated
Normal file
14893
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
107
package.json
Normal file
107
package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "laundry-desk",
|
||||
"version": "0.1.0",
|
||||
"description": "单店单机 Windows 桌面洗衣店柜台管理系统",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "manpengan",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win --x64",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.laundry-desk",
|
||||
"productName": "Hongfa Laundry",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"**/*.node"
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"include": "build/installer.nsh",
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Hongfa Laundry"
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "manpengan",
|
||||
"repo": "laundry-desk"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-pos-printer": "^1.1.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tencentcloud-sdk-nodejs-sms": "^4.0.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"electron": "^41.3.0",
|
||||
"electron-builder": "^24.13.0",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.3.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
8
playwright.config.ts
Normal file
8
playwright.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "tests/e2e",
|
||||
timeout: 30_000,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
});
|
||||
13
src/main/db/client.ts
Normal file
13
src/main/db/client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import type Database from "better-sqlite3";
|
||||
import * as schema from "./schema";
|
||||
import { migrate } from "./migrate";
|
||||
|
||||
export function createDbClient(sqlite: Database.Database) {
|
||||
migrate(sqlite);
|
||||
return drizzle(sqlite, { schema });
|
||||
}
|
||||
|
||||
export type AppDb = ReturnType<typeof createDbClient>;
|
||||
export type AppTransaction = Parameters<Parameters<AppDb["transaction"]>[0]>[0];
|
||||
export type DbExecutor = AppDb | AppTransaction;
|
||||
38
src/main/db/index.ts
Normal file
38
src/main/db/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { app } from "electron";
|
||||
import { join } from "path";
|
||||
import { createDbClient, type AppDb } from "./client";
|
||||
import * as schema from "./schema";
|
||||
|
||||
let db: AppDb | undefined;
|
||||
let sqlite: Database.Database | undefined;
|
||||
|
||||
export function getDbPath(): string {
|
||||
const isDev = !app.isPackaged;
|
||||
return isDev
|
||||
? join(process.cwd(), "laundry.db")
|
||||
: join(app.getPath("userData"), "laundry.db");
|
||||
}
|
||||
|
||||
export function getDb(): AppDb {
|
||||
if (db) return db;
|
||||
|
||||
sqlite = new Database(getDbPath());
|
||||
db = createDbClient(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getSqlite(): Database.Database {
|
||||
if (!sqlite) getDb();
|
||||
if (!sqlite) throw new Error("SQLite client is not initialized");
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
export function resetDbForTests(): void {
|
||||
sqlite?.close();
|
||||
sqlite = undefined;
|
||||
db = undefined;
|
||||
}
|
||||
|
||||
export { createDbClient, schema };
|
||||
export type { AppDb, AppTransaction, DbExecutor } from "./client";
|
||||
100
src/main/db/migrate.ts
Normal file
100
src/main/db/migrate.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export function migrate(sqlite: Database.Database): void {
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT NOT NULL UNIQUE,
|
||||
vip_level INTEGER NOT NULL DEFAULT 0,
|
||||
total_orders INTEGER NOT NULL DEFAULT 0,
|
||||
total_spent INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staffs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'staff',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
last_login_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT NOT NULL UNIQUE,
|
||||
pickup_code TEXT NOT NULL,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
total_amount INTEGER NOT NULL,
|
||||
paid_amount INTEGER NOT NULL DEFAULT 0,
|
||||
payment_method TEXT NOT NULL,
|
||||
receive_date INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
expected_pickup_date INTEGER,
|
||||
actual_pickup_at INTEGER,
|
||||
staff_id INTEGER REFERENCES staffs(id),
|
||||
picked_up_by INTEGER REFERENCES staffs(id),
|
||||
notes TEXT,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
service_type TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price INTEGER NOT NULL,
|
||||
subtotal INTEGER NOT NULL,
|
||||
item_notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
taken_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER REFERENCES orders(id),
|
||||
phone TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
provider_response TEXT,
|
||||
sent_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
staff_id INTEGER REFERENCES staffs(id),
|
||||
action TEXT NOT NULL,
|
||||
entity TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
diff TEXT,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS orders_pickup_code_idx
|
||||
ON orders(pickup_code);
|
||||
CREATE INDEX IF NOT EXISTS orders_customer_status_date_idx
|
||||
ON orders(customer_id, status, receive_date);
|
||||
CREATE INDEX IF NOT EXISTS audit_log_created_at_idx
|
||||
ON audit_log(created_at);
|
||||
`);
|
||||
}
|
||||
190
src/main/db/schema.ts
Normal file
190
src/main/db/schema.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const customers = sqliteTable("customers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
phone: text("phone").notNull().unique(),
|
||||
vipLevel: integer("vip_level").default(0).notNull(),
|
||||
totalOrders: integer("total_orders").default(0).notNull(),
|
||||
totalSpent: integer("total_spent").default(0).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
});
|
||||
|
||||
export const staffs = sqliteTable("staffs", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
role: text("role", { enum: ["admin", "staff"] })
|
||||
.default("staff")
|
||||
.notNull(),
|
||||
isActive: integer("is_active", { mode: "boolean" }).default(true).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
export const orders = sqliteTable(
|
||||
"orders",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
orderNo: text("order_no").notNull().unique(),
|
||||
pickupCode: text("pickup_code", { length: 4 }).notNull(),
|
||||
customerId: integer("customer_id")
|
||||
.notNull()
|
||||
.references(() => customers.id),
|
||||
status: text("status", {
|
||||
enum: ["pending", "ready", "picked_up", "cancelled"],
|
||||
})
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
totalAmount: integer("total_amount").notNull(),
|
||||
paidAmount: integer("paid_amount").default(0).notNull(),
|
||||
paymentMethod: text("payment_method", {
|
||||
enum: ["cash", "wechat", "alipay", "card", "unpaid"],
|
||||
}).notNull(),
|
||||
receiveDate: integer("receive_date", { mode: "timestamp" })
|
||||
.default(sql`(strftime('%s', 'now'))`)
|
||||
.notNull(),
|
||||
expectedPickupDate: integer("expected_pickup_date", { mode: "timestamp" }),
|
||||
actualPickupAt: integer("actual_pickup_at", { mode: "timestamp" }),
|
||||
staffId: integer("staff_id").references(() => staffs.id),
|
||||
pickedUpBy: integer("picked_up_by").references(() => staffs.id),
|
||||
notes: text("notes"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
},
|
||||
(table) => ({
|
||||
pickupCodeIdx: index("orders_pickup_code_idx").on(table.pickupCode),
|
||||
customerStatusDateIdx: index("orders_customer_status_date_idx").on(
|
||||
table.customerId,
|
||||
table.status,
|
||||
table.receiveDate,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const orderItems = sqliteTable("order_items", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
orderId: integer("order_id")
|
||||
.notNull()
|
||||
.references(() => orders.id, { onDelete: "cascade" }),
|
||||
itemType: text("item_type").notNull(),
|
||||
serviceType: text("service_type", {
|
||||
enum: ["wash", "dry_clean", "iron"],
|
||||
}).notNull(),
|
||||
quantity: integer("quantity").notNull(),
|
||||
unitPrice: integer("unit_price").notNull(),
|
||||
subtotal: integer("subtotal").notNull(),
|
||||
itemNotes: text("item_notes"),
|
||||
});
|
||||
|
||||
export const orderPhotos = sqliteTable("order_photos", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
orderId: integer("order_id")
|
||||
.notNull()
|
||||
.references(() => orders.id, { onDelete: "cascade" }),
|
||||
filePath: text("file_path").notNull(),
|
||||
takenAt: integer("taken_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
});
|
||||
|
||||
export const smsLog = sqliteTable("sms_log", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
orderId: integer("order_id").references(() => orders.id),
|
||||
phone: text("phone").notNull(),
|
||||
content: text("content").notNull(),
|
||||
status: text("status", { enum: ["pending", "sent", "failed"] })
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
providerResponse: text("provider_response"),
|
||||
sentAt: integer("sent_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
});
|
||||
|
||||
export const auditLog = sqliteTable(
|
||||
"audit_log",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
staffId: integer("staff_id").references(() => staffs.id),
|
||||
action: text("action").notNull(),
|
||||
entity: text("entity").notNull(),
|
||||
entityId: integer("entity_id"),
|
||||
diff: text("diff"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(
|
||||
sql`(strftime('%s', 'now'))`,
|
||||
),
|
||||
},
|
||||
(table) => ({
|
||||
createdAtIdx: index("audit_log_created_at_idx").on(table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const customersRelations = relations(customers, ({ many }) => ({
|
||||
orders: many(orders),
|
||||
}));
|
||||
|
||||
export const staffsRelations = relations(staffs, ({ many }) => ({
|
||||
createdOrders: many(orders, { relationName: "createdOrders" }),
|
||||
pickedOrders: many(orders, { relationName: "pickedOrders" }),
|
||||
auditLogs: many(auditLog),
|
||||
}));
|
||||
|
||||
export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||
customer: one(customers, {
|
||||
fields: [orders.customerId],
|
||||
references: [customers.id],
|
||||
}),
|
||||
staff: one(staffs, {
|
||||
fields: [orders.staffId],
|
||||
references: [staffs.id],
|
||||
relationName: "createdOrders",
|
||||
}),
|
||||
pickedUpStaff: one(staffs, {
|
||||
fields: [orders.pickedUpBy],
|
||||
references: [staffs.id],
|
||||
relationName: "pickedOrders",
|
||||
}),
|
||||
items: many(orderItems),
|
||||
photos: many(orderPhotos),
|
||||
}));
|
||||
|
||||
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
|
||||
order: one(orders, {
|
||||
fields: [orderItems.orderId],
|
||||
references: [orders.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const orderPhotosRelations = relations(orderPhotos, ({ one }) => ({
|
||||
order: one(orders, {
|
||||
fields: [orderPhotos.orderId],
|
||||
references: [orders.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||
staff: one(staffs, {
|
||||
fields: [auditLog.staffId],
|
||||
references: [staffs.id],
|
||||
}),
|
||||
}));
|
||||
90
src/main/index.ts
Normal file
90
src/main/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { app, BrowserWindow, net, protocol } from "electron";
|
||||
import { electronApp, is, optimizer } from "@electron-toolkit/utils";
|
||||
import { join, normalize } from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import { registerBackupIpc } from "./ipc/backup";
|
||||
import { registerCustomerIpc } from "./ipc/customers";
|
||||
import { registerExcelIpc } from "./ipc/excel";
|
||||
import { registerOrderIpc } from "./ipc/orders";
|
||||
import { registerPhotoIpc } from "./ipc/photos";
|
||||
import { registerPrinterIpc } from "./ipc/printer";
|
||||
import { registerSettingsIpc } from "./ipc/settings";
|
||||
import { BackupService } from "./services/backupService";
|
||||
import { PhotoService } from "./services/photoService";
|
||||
import { SettingsService } from "./services/settingsService";
|
||||
|
||||
function createWindow(): void {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1180,
|
||||
height: 780,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
center: true,
|
||||
backgroundColor: "#f2f2f7",
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
const devUrl = process.env["ELECTRON_RENDERER_URL"];
|
||||
if (is.dev && devUrl) {
|
||||
mainWindow.loadURL(devUrl);
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
electronApp.setAppUserModelId("com.laundry-desk");
|
||||
registerMediaProtocol();
|
||||
registerIpc();
|
||||
|
||||
try {
|
||||
await SettingsService.initDefaults();
|
||||
BackupService.initAutoBackup();
|
||||
} catch (error) {
|
||||
console.error("[Main] initialization failed:", error);
|
||||
}
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
function registerIpc(): void {
|
||||
registerOrderIpc();
|
||||
registerCustomerIpc();
|
||||
registerSettingsIpc();
|
||||
registerExcelIpc();
|
||||
registerPhotoIpc();
|
||||
registerPrinterIpc();
|
||||
registerBackupIpc();
|
||||
}
|
||||
|
||||
function registerMediaProtocol(): void {
|
||||
protocol.handle("media", (request) => {
|
||||
const rawName = decodeURIComponent(request.url.slice("media://".length));
|
||||
const safeName = normalize(rawName).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
const filePath = PhotoService.getPhotoPath(safeName);
|
||||
return net.fetch(pathToFileURL(filePath).toString());
|
||||
});
|
||||
}
|
||||
12
src/main/ipc/backup.ts
Normal file
12
src/main/ipc/backup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { BackupService } from "../services/backupService";
|
||||
import { registerIpcHandler } from "./helpers";
|
||||
|
||||
export function registerBackupIpc(): void {
|
||||
registerIpcHandler("backup:runNow", z.undefined(), () =>
|
||||
BackupService.performBackup(),
|
||||
);
|
||||
registerIpcHandler("backup:list", z.undefined(), () =>
|
||||
BackupService.listBackups(),
|
||||
);
|
||||
}
|
||||
19
src/main/ipc/customers.ts
Normal file
19
src/main/ipc/customers.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
CustomerSearchSchema,
|
||||
PhoneSchema,
|
||||
UpsertCustomerSchema,
|
||||
} from "../../shared/schemas";
|
||||
import { CustomerService } from "../services/customerService";
|
||||
import { registerIpcHandler } from "./helpers";
|
||||
|
||||
export function registerCustomerIpc(): void {
|
||||
registerIpcHandler("customers:upsert", UpsertCustomerSchema, (input) =>
|
||||
CustomerService.upsertByPhone(input.name, input.phone),
|
||||
);
|
||||
registerIpcHandler("customers:findByPhone", PhoneSchema, (phone) =>
|
||||
CustomerService.findByPhone(phone),
|
||||
);
|
||||
registerIpcHandler("customers:findAll", CustomerSearchSchema, (input) =>
|
||||
CustomerService.findAll(input?.query),
|
||||
);
|
||||
}
|
||||
12
src/main/ipc/excel.ts
Normal file
12
src/main/ipc/excel.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { ExcelService } from "../services/excelService";
|
||||
import { registerIpcHandler } from "./helpers";
|
||||
|
||||
export function registerExcelIpc(): void {
|
||||
registerIpcHandler("excel:exportOrders", z.undefined(), () =>
|
||||
ExcelService.exportOrders(),
|
||||
);
|
||||
registerIpcHandler("excel:exportCustomers", z.undefined(), () =>
|
||||
ExcelService.exportCustomers(),
|
||||
);
|
||||
}
|
||||
54
src/main/ipc/helpers.ts
Normal file
54
src/main/ipc/helpers.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { ZodError, type ZodType } from "zod";
|
||||
import { type ApiErrorCode, type ApiResponse } from "../../shared";
|
||||
|
||||
export function registerIpcHandler<Input, Output>(
|
||||
channel: string,
|
||||
schema: ZodType<Input>,
|
||||
handler: (input: Input) => Output | Promise<Output>,
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
channel,
|
||||
async (_event, rawInput): Promise<ApiResponse<Output>> => {
|
||||
try {
|
||||
const input = schema.parse(rawInput);
|
||||
const data = await handler(input);
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
return toApiError(channel, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function toApiError(channel: string, error: unknown): ApiResponse<never> {
|
||||
if (error instanceof ZodError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION_FAILED",
|
||||
message: error.issues[0]?.message ?? "输入参数不正确",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.error(`IPC Error [${channel}]:`, error);
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: toErrorCode(error),
|
||||
message: error instanceof Error ? error.message : "系统内部错误",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorCode(error: unknown): ApiErrorCode {
|
||||
if (!(error instanceof Error)) return "INTERNAL_ERROR";
|
||||
if (error.message.includes("不存在")) return "NOT_FOUND";
|
||||
if (error.message.includes("已取件") || error.message.includes("已取消"))
|
||||
return "CONFLICT";
|
||||
if (error.message.includes("金额") || error.message.includes("欠款"))
|
||||
return "VALIDATION_FAILED";
|
||||
return "INTERNAL_ERROR";
|
||||
}
|
||||
34
src/main/ipc/orders.ts
Normal file
34
src/main/ipc/orders.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CreateOrderSchema,
|
||||
IdSchema,
|
||||
PaginationSchema,
|
||||
PickupSchema,
|
||||
SearchOrdersSchema,
|
||||
} from "../../shared/schemas";
|
||||
import { OrderService } from "../services/orderService";
|
||||
import { registerIpcHandler } from "./helpers";
|
||||
|
||||
export function registerOrderIpc(): void {
|
||||
registerIpcHandler("orders:create", CreateOrderSchema, (input) =>
|
||||
OrderService.createOrder(input),
|
||||
);
|
||||
registerIpcHandler("orders:findAll", PaginationSchema, (input) =>
|
||||
OrderService.findAll(input),
|
||||
);
|
||||
registerIpcHandler("orders:findById", IdSchema, (id) =>
|
||||
OrderService.findById(id),
|
||||
);
|
||||
registerIpcHandler("orders:getStats", z.undefined(), () =>
|
||||
OrderService.getStats(),
|
||||
);
|
||||
registerIpcHandler("orders:pickup", PickupSchema, (input) =>
|
||||
OrderService.pickup(input),
|
||||
);
|
||||
registerIpcHandler("orders:searchForPickup", SearchOrdersSchema, (input) =>
|
||||
OrderService.searchForPickup(input.query),
|
||||
);
|
||||
registerIpcHandler("orders:getOverdue", z.undefined(), () =>
|
||||
OrderService.findOverdue(),
|
||||
);
|
||||
}
|
||||
18
src/main/ipc/photos.ts
Normal file
18
src/main/ipc/photos.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { SavePhotoSchema } from "../../shared/schemas";
|
||||
import { getDb, schema } from "../db";
|
||||
import { PhotoService } from "../services/photoService";
|
||||
import { registerIpcHandler } from "./helpers";
|
||||
|
||||
export function registerPhotoIpc(): void {
|
||||
registerIpcHandler("photos:save", SavePhotoSchema, (input) => {
|
||||
const fileName = PhotoService.savePhoto(input.orderId, input.base64Data);
|
||||
return getDb()
|
||||
.insert(schema.orderPhotos)
|
||||
.values({
|
||||
orderId: input.orderId,
|
||||
filePath: fileName,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
});
|
||||
}
|
||||
12
src/main/ipc/printer.ts
Normal file
12
src/main/ipc/printer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IdSchema } from "../../shared/schemas";
|
||||
import { OrderService } from "../services/orderService";
|
||||
import { PrinterService } from "../services/printerService";
|
||||
import { registerIpcHandler } from "./helpers";
|
||||
|
||||
export function registerPrinterIpc(): void {
|
||||
registerIpcHandler("printer:printReceipt", IdSchema, async (orderId) => {
|
||||
const order = await OrderService.findById(orderId);
|
||||
if (!order) throw new Error("订单不存在");
|
||||
return await PrinterService.printReceipt(order);
|
||||
});
|
||||
}
|
||||
12
src/main/ipc/settings.ts
Normal file
12
src/main/ipc/settings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { SetSettingSchema, SettingKeySchema } from "../../shared/schemas";
|
||||
import { SettingsService } from "../services/settingsService";
|
||||
import { registerIpcHandler } from "./helpers";
|
||||
|
||||
export function registerSettingsIpc(): void {
|
||||
registerIpcHandler("settings:get", SettingKeySchema, (key) =>
|
||||
SettingsService.get(key),
|
||||
);
|
||||
registerIpcHandler("settings:set", SetSettingSchema, (input) =>
|
||||
SettingsService.set(input.key, input.value),
|
||||
);
|
||||
}
|
||||
32
src/main/services/auditService.ts
Normal file
32
src/main/services/auditService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getDb, schema, type DbExecutor } from "../db";
|
||||
|
||||
export type AuditAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "pickup"
|
||||
| "cancel"
|
||||
| "login"
|
||||
| "export";
|
||||
|
||||
export interface AuditLogInput {
|
||||
staffId?: number;
|
||||
action: AuditAction;
|
||||
entity: string;
|
||||
entityId?: number;
|
||||
diff?: unknown;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
static log(params: AuditLogInput, db: DbExecutor = getDb()): void {
|
||||
db.insert(schema.auditLog)
|
||||
.values({
|
||||
staffId: params.staffId,
|
||||
action: params.action,
|
||||
entity: params.entity,
|
||||
entityId: params.entityId,
|
||||
diff: params.diff === undefined ? null : JSON.stringify(params.diff),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
118
src/main/services/backupService.ts
Normal file
118
src/main/services/backupService.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { app } from "electron";
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import archiver from "archiver";
|
||||
import cron from "node-cron";
|
||||
import type Database from "better-sqlite3";
|
||||
import { getDbPath, getSqlite } from "../db";
|
||||
|
||||
export interface BackupOptions {
|
||||
dbPath?: string;
|
||||
backupDir?: string;
|
||||
sqlite?: Database.Database;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
fileName: string;
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
static async performBackup(options: BackupOptions = {}): Promise<string> {
|
||||
const dbPath = options.dbPath ?? getDbPath();
|
||||
const backupDir = this.ensureBackupDir(options.backupDir);
|
||||
const sqlite = options.sqlite ?? getSqlite();
|
||||
|
||||
if (!existsSync(dbPath)) {
|
||||
throw new Error(`数据库文件不存在: ${dbPath}`);
|
||||
}
|
||||
|
||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const zipPath = join(backupDir, `backup-${timestamp}.zip`);
|
||||
const tempPath = `${zipPath}.tmp`;
|
||||
|
||||
try {
|
||||
await writeZip(dbPath, tempPath);
|
||||
renameSync(tempPath, zipPath);
|
||||
this.rotateBackups(backupDir);
|
||||
return zipPath;
|
||||
} catch (error) {
|
||||
if (existsSync(tempPath)) unlinkSync(tempPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static rotateBackups(backupDir = this.getBackupDir()): void {
|
||||
const files = this.listBackups(backupDir);
|
||||
|
||||
files.slice(30).forEach((file) => {
|
||||
unlinkSync(file.path);
|
||||
});
|
||||
}
|
||||
|
||||
static listBackups(backupDir = this.getBackupDir()): BackupInfo[] {
|
||||
if (!existsSync(backupDir)) return [];
|
||||
|
||||
return readdirSync(backupDir)
|
||||
.filter((file) => file.startsWith("backup-") && file.endsWith(".zip"))
|
||||
.map((file) => {
|
||||
const filePath = join(backupDir, file);
|
||||
const stat = statSync(filePath);
|
||||
return {
|
||||
fileName: file,
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
createdAt: stat.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
static initAutoBackup(): void {
|
||||
cron.schedule("0 3 * * *", async () => {
|
||||
try {
|
||||
await this.performBackup();
|
||||
} catch (error) {
|
||||
console.error("Auto backup failed:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static getBackupDir(): string {
|
||||
return join(app.getPath("userData"), "backups");
|
||||
}
|
||||
|
||||
private static ensureBackupDir(backupDir = this.getBackupDir()): string {
|
||||
if (!existsSync(backupDir)) {
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
return backupDir;
|
||||
}
|
||||
}
|
||||
|
||||
function writeZip(dbPath: string, zipPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
|
||||
output.on("close", () => resolve());
|
||||
output.on("error", reject);
|
||||
archive.on("error", reject);
|
||||
|
||||
archive.pipe(output);
|
||||
archive.file(dbPath, { name: "laundry.db" });
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
56
src/main/services/customerService.ts
Normal file
56
src/main/services/customerService.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { desc, eq, like, or } from "drizzle-orm";
|
||||
import { getDb, schema, type DbExecutor } from "../db";
|
||||
|
||||
export class CustomerService {
|
||||
static async upsertByPhone(
|
||||
name: string,
|
||||
phone: string,
|
||||
db: DbExecutor = getDb(),
|
||||
) {
|
||||
const trimmedName = name.trim();
|
||||
const trimmedPhone = phone.trim();
|
||||
const existing = await db.query.customers.findFirst({
|
||||
where: eq(schema.customers.phone, trimmedPhone),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.name === trimmedName) return existing;
|
||||
|
||||
return db
|
||||
.update(schema.customers)
|
||||
.set({ name: trimmedName, updatedAt: new Date() })
|
||||
.where(eq(schema.customers.id, existing.id))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(schema.customers)
|
||||
.values({
|
||||
name: trimmedName,
|
||||
phone: trimmedPhone,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
static async findByPhone(phone: string, db: DbExecutor = getDb()) {
|
||||
return await db.query.customers.findFirst({
|
||||
where: eq(schema.customers.phone, phone.trim()),
|
||||
});
|
||||
}
|
||||
|
||||
static async findAll(query?: string, db: DbExecutor = getDb()) {
|
||||
const trimmed = query?.trim();
|
||||
return await db.query.customers.findMany({
|
||||
where: trimmed
|
||||
? or(
|
||||
like(schema.customers.name, `%${trimmed}%`),
|
||||
like(schema.customers.phone, `%${trimmed}%`),
|
||||
)
|
||||
: undefined,
|
||||
orderBy: [desc(schema.customers.updatedAt)],
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
113
src/main/services/excelService.ts
Normal file
113
src/main/services/excelService.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { app, dialog } from "electron";
|
||||
import { join } from "path";
|
||||
import { getDb, schema } from "../db";
|
||||
import { desc } from "drizzle-orm";
|
||||
|
||||
export class ExcelService {
|
||||
/**
|
||||
* 导出订单列表
|
||||
*/
|
||||
static async exportOrders() {
|
||||
const db = getDb();
|
||||
const orders = await db.query.orders.findMany({
|
||||
orderBy: [desc(schema.orders.createdAt)],
|
||||
with: {
|
||||
customer: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("订单列表");
|
||||
|
||||
sheet.columns = [
|
||||
{ header: "订单号", key: "orderNo", width: 20 },
|
||||
{ header: "取件码", key: "pickupCode", width: 10 },
|
||||
{ header: "客户姓名", key: "customerName", width: 15 },
|
||||
{ header: "客户电话", key: "customerPhone", width: 15 },
|
||||
{ header: "总金额", key: "totalAmount", width: 12 },
|
||||
{ header: "已付金额", key: "paidAmount", width: 12 },
|
||||
{ header: "状态", key: "status", width: 10 },
|
||||
{ header: "收件日期", key: "receiveDate", width: 20 },
|
||||
];
|
||||
|
||||
orders.forEach((order) => {
|
||||
sheet.addRow({
|
||||
orderNo: order.orderNo,
|
||||
pickupCode: order.pickupCode,
|
||||
customerName: order.customer.name,
|
||||
customerPhone: order.customer.phone,
|
||||
totalAmount: order.totalAmount / 100,
|
||||
paidAmount: order.paidAmount / 100,
|
||||
status: order.status,
|
||||
receiveDate: new Date(order.receiveDate).toLocaleString(),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "导出订单",
|
||||
defaultPath: join(
|
||||
app.getPath("downloads"),
|
||||
`orders-${new Date().getTime()}.xlsx`,
|
||||
),
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await workbook.xlsx.writeFile(result.filePath);
|
||||
return result.filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出客户列表
|
||||
*/
|
||||
static async exportCustomers() {
|
||||
const db = getDb();
|
||||
const customers = await db.query.customers.findMany({
|
||||
orderBy: [desc(schema.customers.totalSpent)],
|
||||
});
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("客户列表");
|
||||
|
||||
sheet.columns = [
|
||||
{ header: "姓名", key: "name", width: 15 },
|
||||
{ header: "电话", key: "phone", width: 15 },
|
||||
{ header: "VIP 等级", key: "vipLevel", width: 10 },
|
||||
{ header: "订单总数", key: "totalOrders", width: 12 },
|
||||
{ header: "消费总额", key: "totalSpent", width: 12 },
|
||||
{ header: "最后更新", key: "updatedAt", width: 20 },
|
||||
];
|
||||
|
||||
customers.forEach((customer) => {
|
||||
sheet.addRow({
|
||||
name: customer.name,
|
||||
phone: customer.phone,
|
||||
vipLevel: customer.vipLevel,
|
||||
totalOrders: customer.totalOrders,
|
||||
totalSpent: customer.totalSpent / 100,
|
||||
updatedAt: customer.updatedAt
|
||||
? new Date(customer.updatedAt).toLocaleString()
|
||||
: "",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "导出客户",
|
||||
defaultPath: join(
|
||||
app.getPath("downloads"),
|
||||
`customers-${new Date().getTime()}.xlsx`,
|
||||
),
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await workbook.xlsx.writeFile(result.filePath);
|
||||
return result.filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
342
src/main/services/orderService.ts
Normal file
342
src/main/services/orderService.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { and, desc, eq, gte, inArray, like, lt, or, sql } from "drizzle-orm";
|
||||
import { getDb, schema, type DbExecutor } from "../db";
|
||||
import { type CreateOrderInput, type PickupInput } from "../../shared/schemas";
|
||||
import { AuditService } from "./auditService";
|
||||
import { PickupCodeService } from "./pickupCodeService";
|
||||
|
||||
export interface OrderSearchResult {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
pickupCode: string;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
receiveDate: Date;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
date: string;
|
||||
count: number;
|
||||
income: number;
|
||||
}
|
||||
|
||||
export class OrderService {
|
||||
static generateOrderNo(db: DbExecutor = getDb(), now = new Date()): string {
|
||||
const dayStart = startOfDay(now);
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||
const lastOrder = db
|
||||
.select({ orderNo: schema.orders.orderNo })
|
||||
.from(schema.orders)
|
||||
.where(
|
||||
and(
|
||||
gte(schema.orders.receiveDate, dayStart),
|
||||
lt(schema.orders.receiveDate, dayEnd),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.orders.orderNo))
|
||||
.get();
|
||||
const nextNum = lastOrder ? Number(lastOrder.orderNo.split("-")[1]) + 1 : 1;
|
||||
|
||||
if (nextNum > 9999) {
|
||||
throw new Error("今日订单号已达到 9999 上限");
|
||||
}
|
||||
|
||||
return `${formatLocalDate(now)}-${nextNum.toString().padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
static createOrder(data: CreateOrderInput, db = getDb()) {
|
||||
const totalAmount = calculateTotal(data.items);
|
||||
if (totalAmount !== data.totalAmount) {
|
||||
throw new Error("订单总额与明细小计不一致");
|
||||
}
|
||||
if (data.paidAmount > data.totalAmount) {
|
||||
throw new Error("实收金额不能超过订单总额");
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
return db.transaction((tx) => {
|
||||
const orderNo = OrderService.generateOrderNo(tx);
|
||||
const pickupCode = PickupCodeService.generate(tx);
|
||||
const order = tx
|
||||
.insert(schema.orders)
|
||||
.values({
|
||||
orderNo,
|
||||
pickupCode,
|
||||
customerId: data.customerId,
|
||||
totalAmount: data.totalAmount,
|
||||
paidAmount: data.paidAmount,
|
||||
paymentMethod: data.paymentMethod,
|
||||
expectedPickupDate: data.expectedPickupDate,
|
||||
notes: data.notes,
|
||||
staffId: data.staffId,
|
||||
status: "pending",
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
data.items.forEach((item) => {
|
||||
tx.insert(schema.orderItems)
|
||||
.values({
|
||||
orderId: order.id,
|
||||
itemType: item.itemType,
|
||||
serviceType: item.serviceType,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
subtotal: item.quantity * item.unitPrice,
|
||||
itemNotes: item.itemNotes,
|
||||
})
|
||||
.run();
|
||||
});
|
||||
|
||||
tx.update(schema.customers)
|
||||
.set({
|
||||
totalOrders: sql`${schema.customers.totalOrders} + 1`,
|
||||
totalSpent: sql`${schema.customers.totalSpent} + ${data.totalAmount}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.customers.id, data.customerId))
|
||||
.run();
|
||||
|
||||
AuditService.log(
|
||||
{
|
||||
staffId: data.staffId,
|
||||
action: "create",
|
||||
entity: "orders",
|
||||
entityId: order.id,
|
||||
diff: {
|
||||
orderNo,
|
||||
pickupCode,
|
||||
totalAmount: data.totalAmount,
|
||||
paidAmount: data.paidAmount,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
return order;
|
||||
});
|
||||
} catch (error) {
|
||||
if (attempt < 4 && isUniqueConstraintError(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("订单创建失败,请重试");
|
||||
}
|
||||
|
||||
static async findAll(
|
||||
params?: { limit?: number; offset?: number },
|
||||
db: DbExecutor = getDb(),
|
||||
) {
|
||||
return await db.query.orders.findMany({
|
||||
limit: params?.limit ?? 50,
|
||||
offset: params?.offset ?? 0,
|
||||
orderBy: [desc(schema.orders.createdAt)],
|
||||
with: {
|
||||
customer: true,
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findById(id: number, db: DbExecutor = getDb()) {
|
||||
return await db.query.orders.findFirst({
|
||||
where: eq(schema.orders.id, id),
|
||||
with: {
|
||||
customer: true,
|
||||
items: true,
|
||||
photos: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static searchForPickup(
|
||||
query: string,
|
||||
db: DbExecutor = getDb(),
|
||||
): OrderSearchResult[] {
|
||||
const trimmed = query.trim();
|
||||
return db
|
||||
.select({
|
||||
id: schema.orders.id,
|
||||
orderNo: schema.orders.orderNo,
|
||||
pickupCode: schema.orders.pickupCode,
|
||||
status: schema.orders.status,
|
||||
totalAmount: schema.orders.totalAmount,
|
||||
paidAmount: schema.orders.paidAmount,
|
||||
receiveDate: schema.orders.receiveDate,
|
||||
customerName: schema.customers.name,
|
||||
customerPhone: schema.customers.phone,
|
||||
})
|
||||
.from(schema.orders)
|
||||
.innerJoin(
|
||||
schema.customers,
|
||||
eq(schema.orders.customerId, schema.customers.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.orders.status, ["pending", "ready"]),
|
||||
or(
|
||||
eq(schema.orders.pickupCode, trimmed),
|
||||
eq(schema.orders.orderNo, trimmed),
|
||||
like(schema.customers.phone, `%${trimmed}%`),
|
||||
like(schema.customers.name, `%${trimmed}%`),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.orders.receiveDate))
|
||||
.limit(30)
|
||||
.all();
|
||||
}
|
||||
|
||||
static async getStats(db: DbExecutor = getDb()) {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const todayOrders = await db.query.orders.findMany({
|
||||
where: gte(schema.orders.receiveDate, today),
|
||||
});
|
||||
const monthOrders = await db.query.orders.findMany({
|
||||
where: gte(schema.orders.receiveDate, monthStart),
|
||||
});
|
||||
const pendingOrders = await db.query.orders.findMany({
|
||||
where: inArray(schema.orders.status, ["pending", "ready"]),
|
||||
});
|
||||
const chartData: ChartPoint[] = [];
|
||||
|
||||
for (let i = 6; i >= 0; i -= 1) {
|
||||
const day = new Date(today);
|
||||
day.setDate(day.getDate() - i);
|
||||
const nextDay = new Date(day);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
const dayOrders = await db.query.orders.findMany({
|
||||
where: and(
|
||||
gte(schema.orders.receiveDate, day),
|
||||
lt(schema.orders.receiveDate, nextDay),
|
||||
),
|
||||
});
|
||||
chartData.push({
|
||||
date: day.toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
count: dayOrders.length,
|
||||
income:
|
||||
dayOrders.reduce((sum, order) => sum + order.paidAmount, 0) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
todayIncome: todayOrders.reduce(
|
||||
(sum, order) => sum + order.paidAmount,
|
||||
0,
|
||||
),
|
||||
monthIncome: monthOrders.reduce(
|
||||
(sum, order) => sum + order.paidAmount,
|
||||
0,
|
||||
),
|
||||
todayCount: todayOrders.length,
|
||||
monthCount: monthOrders.length,
|
||||
pendingCount: pendingOrders.length,
|
||||
overdueCount: pendingOrders.filter(
|
||||
(order) =>
|
||||
order.expectedPickupDate !== null && order.expectedPickupDate < today,
|
||||
).length,
|
||||
dueTodayCount: pendingOrders.filter(
|
||||
(order) =>
|
||||
order.expectedPickupDate !== null &&
|
||||
order.expectedPickupDate >= today &&
|
||||
order.expectedPickupDate < tomorrow,
|
||||
).length,
|
||||
chartData,
|
||||
};
|
||||
}
|
||||
|
||||
static pickup(input: PickupInput, db = getDb()) {
|
||||
return db.transaction((tx) => {
|
||||
const order = tx
|
||||
.select()
|
||||
.from(schema.orders)
|
||||
.where(eq(schema.orders.id, input.orderId))
|
||||
.get();
|
||||
if (!order) throw new Error("订单不存在");
|
||||
if (order.status === "picked_up") throw new Error("订单已取件");
|
||||
if (order.status === "cancelled") throw new Error("订单已取消");
|
||||
|
||||
const paidExtra = input.paidAmount ?? 0;
|
||||
const nextPaidAmount = order.paidAmount + paidExtra;
|
||||
if (nextPaidAmount > order.totalAmount) {
|
||||
throw new Error("实收金额不能超过订单总额");
|
||||
}
|
||||
if (nextPaidAmount < order.totalAmount) {
|
||||
throw new Error("订单仍有欠款,需结清后取件");
|
||||
}
|
||||
|
||||
const updated = tx
|
||||
.update(schema.orders)
|
||||
.set({
|
||||
status: "picked_up",
|
||||
paidAmount: nextPaidAmount,
|
||||
actualPickupAt: new Date(),
|
||||
pickedUpBy: input.staffId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.orders.id, input.orderId))
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
AuditService.log(
|
||||
{
|
||||
staffId: input.staffId,
|
||||
action: "pickup",
|
||||
entity: "orders",
|
||||
entityId: input.orderId,
|
||||
diff: { paidExtra },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
static async findOverdue(db: DbExecutor = getDb()) {
|
||||
return await db.query.orders.findMany({
|
||||
where: and(
|
||||
lt(schema.orders.expectedPickupDate, new Date()),
|
||||
eq(schema.orders.status, "pending"),
|
||||
),
|
||||
with: { customer: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function calculateTotal(items: CreateOrderInput["items"]): number {
|
||||
return items.reduce(
|
||||
(total, item) => total + item.quantity * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
const day = new Date(date);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
return day;
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && error.message.includes("UNIQUE constraint failed")
|
||||
);
|
||||
}
|
||||
35
src/main/services/photoService.ts
Normal file
35
src/main/services/photoService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { app } from "electron";
|
||||
import { join } from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export class PhotoService {
|
||||
private static getPhotoDir() {
|
||||
const photoDir = join(app.getPath("userData"), "photos");
|
||||
if (!fs.existsSync(photoDir)) {
|
||||
fs.mkdirSync(photoDir, { recursive: true });
|
||||
}
|
||||
return photoDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Base64 图片
|
||||
*/
|
||||
static savePhoto(orderId: number, base64Data: string) {
|
||||
const photoDir = this.getPhotoDir();
|
||||
const fileName = `order-${orderId}-${Date.now()}.jpg`;
|
||||
const filePath = join(photoDir, fileName);
|
||||
|
||||
const data = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
||||
const buf = Buffer.from(data, "base64");
|
||||
|
||||
fs.writeFileSync(filePath, buf);
|
||||
return fileName; // 返回相对路径或文件名
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取照片绝对路径
|
||||
*/
|
||||
static getPhotoPath(fileName: string) {
|
||||
return join(this.getPhotoDir(), fileName);
|
||||
}
|
||||
}
|
||||
32
src/main/services/pickupCodeService.ts
Normal file
32
src/main/services/pickupCodeService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { and, eq, gte, lt } from "drizzle-orm";
|
||||
import { getDb, schema, type DbExecutor } from "../db";
|
||||
|
||||
export class PickupCodeService {
|
||||
static generate(db: DbExecutor = getDb(), now = new Date()): string {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const code = Math.floor(Math.random() * 10000)
|
||||
.toString()
|
||||
.padStart(4, "0");
|
||||
const existing = db
|
||||
.select({ id: schema.orders.id })
|
||||
.from(schema.orders)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.orders.pickupCode, code),
|
||||
gte(schema.orders.receiveDate, today),
|
||||
lt(schema.orders.receiveDate, tomorrow),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!existing) return code;
|
||||
}
|
||||
|
||||
throw new Error("无法生成唯一的取件码,请重试");
|
||||
}
|
||||
}
|
||||
105
src/main/services/printerService.ts
Normal file
105
src/main/services/printerService.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
PosPrinter,
|
||||
PosPrintData,
|
||||
PosPrintOptions,
|
||||
} from "electron-pos-printer";
|
||||
import { SettingsService } from "./settingsService";
|
||||
|
||||
export class PrinterService {
|
||||
/**
|
||||
* 打印收件登记单
|
||||
*/
|
||||
static async printReceipt(order: any) {
|
||||
const shopName = (await SettingsService.get("shop.name")) || "洗衣店";
|
||||
|
||||
const data: PosPrintData[] = [
|
||||
{
|
||||
type: "text",
|
||||
value: shopName,
|
||||
style: { fontWeight: "700", textAlign: "center", fontSize: "18px" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
value: "收件登记单",
|
||||
style: { textAlign: "center", fontSize: "12px", marginBottom: "10px" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
value: `订单号: ${order.orderNo}`,
|
||||
style: { fontSize: "12px" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
value: `取件码: ${order.pickupCode}`,
|
||||
style: {
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
margin: "10px 0",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
value: `客户: ${order.customer.name}`,
|
||||
style: { fontSize: "12px" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
value: `电话: ${order.customer.phone}`,
|
||||
style: { fontSize: "12px" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
value: "--------------------------------",
|
||||
style: { textAlign: "center" },
|
||||
},
|
||||
];
|
||||
|
||||
order.items.forEach((item: any) => {
|
||||
data.push({
|
||||
type: "text",
|
||||
value: `${item.itemType} x${item.quantity} ¥${item.subtotal / 100}`,
|
||||
style: { fontSize: "12px" },
|
||||
});
|
||||
});
|
||||
|
||||
data.push({
|
||||
type: "text",
|
||||
value: "--------------------------------",
|
||||
style: { textAlign: "center" },
|
||||
});
|
||||
data.push({
|
||||
type: "text",
|
||||
value: `总计: ¥${order.totalAmount / 100}`,
|
||||
style: { fontSize: "14px", fontWeight: "bold", textAlign: "right" },
|
||||
});
|
||||
data.push({
|
||||
type: "text",
|
||||
value: `日期: ${new Date().toLocaleString()}`,
|
||||
style: { fontSize: "10px", marginTop: "10px" },
|
||||
});
|
||||
data.push({
|
||||
type: "text",
|
||||
value: "谢谢惠顾,请妥善保管凭据",
|
||||
style: { fontSize: "10px", textAlign: "center", marginTop: "5px" },
|
||||
});
|
||||
|
||||
const options: PosPrintOptions = {
|
||||
preview: false,
|
||||
width: "170px", // 58mm
|
||||
margin: "0 0 0 0",
|
||||
copies: 1,
|
||||
printerName: "", // 使用默认打印机
|
||||
timeOutPerLine: 400,
|
||||
silent: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await PosPrinter.print(data, options);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("打印失败:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/main/services/settingsService.ts
Normal file
69
src/main/services/settingsService.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { getDb, schema } from "../db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export interface PriceTemplate {
|
||||
itemType: string;
|
||||
serviceType: "wash" | "dry_clean" | "iron";
|
||||
price: number; // 分
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
/**
|
||||
* 获取指定 key 的设置
|
||||
*/
|
||||
static async get<T = any>(key: string, db = getDb()): Promise<T | null> {
|
||||
const setting = await db.query.settings.findFirst({
|
||||
where: eq(schema.settings.key, key),
|
||||
});
|
||||
if (!setting) return null;
|
||||
try {
|
||||
return JSON.parse(setting.value) as T;
|
||||
} catch {
|
||||
return setting.value as any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新或创建设置
|
||||
*/
|
||||
static async set(key: string, value: any, db = getDb()) {
|
||||
const valueStr = typeof value === "string" ? value : JSON.stringify(value);
|
||||
|
||||
// 使用 ON CONFLICT DO UPDATE 逻辑
|
||||
const existing = await this.get(key, db);
|
||||
if (existing !== null) {
|
||||
await db
|
||||
.update(schema.settings)
|
||||
.set({ value: valueStr, updatedAt: new Date() })
|
||||
.where(eq(schema.settings.key, key));
|
||||
} else {
|
||||
await db.insert(schema.settings).values({
|
||||
key,
|
||||
value: valueStr,
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认设置
|
||||
*/
|
||||
static async initDefaults() {
|
||||
const templates = await this.get("price_templates");
|
||||
if (!templates) {
|
||||
const defaultTemplates: PriceTemplate[] = [
|
||||
{ itemType: "衬衫", serviceType: "wash", price: 1500 },
|
||||
{ itemType: "西装", serviceType: "dry_clean", price: 4500 },
|
||||
{ itemType: "大衣", serviceType: "dry_clean", price: 6000 },
|
||||
{ itemType: "裤子", serviceType: "wash", price: 1500 },
|
||||
{ itemType: "羽绒服", serviceType: "dry_clean", price: 8000 },
|
||||
];
|
||||
await this.set("price_templates", defaultTemplates);
|
||||
}
|
||||
|
||||
const shopName = await this.get("shop.name");
|
||||
if (!shopName) {
|
||||
await this.set("shop.name", "宏发洗衣店");
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/main/services/smsService.ts
Normal file
60
src/main/services/smsService.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as tencentcloud from "tencentcloud-sdk-nodejs-sms";
|
||||
import { SettingsService } from "./settingsService";
|
||||
import { getDb, schema } from "../db";
|
||||
|
||||
const SmsClient = tencentcloud.sms.v20210111.Client;
|
||||
|
||||
export class SmsService {
|
||||
/**
|
||||
* 发送取件通知
|
||||
*/
|
||||
static async sendPickupNotification(
|
||||
phone: string,
|
||||
orderNo: string,
|
||||
pickupCode: string,
|
||||
) {
|
||||
const enabled = await SettingsService.get("sms.enabled");
|
||||
if (!enabled) return;
|
||||
|
||||
const secretId = await SettingsService.get("sms.tencent.secret_id");
|
||||
const secretKey = await SettingsService.get("sms.tencent.secret_key");
|
||||
const templateId = await SettingsService.get("sms.template_id");
|
||||
const sdkAppId = await SettingsService.get("sms.sdk_app_id");
|
||||
|
||||
if (!secretId || !secretKey) {
|
||||
console.warn("SMS SecretKey 未配置");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SmsClient({
|
||||
credential: { secretId, secretKey },
|
||||
region: "ap-guangzhou",
|
||||
});
|
||||
|
||||
const params = {
|
||||
PhoneNumberSet: [`+86${phone}`],
|
||||
SmsSdkAppId: sdkAppId,
|
||||
TemplateId: templateId,
|
||||
TemplateParamSet: [orderNo, pickupCode],
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await client.SendSms(params);
|
||||
|
||||
// 记录到数据库
|
||||
const db = getDb();
|
||||
await db.insert(schema.smsLog).values({
|
||||
phone,
|
||||
content: `订单 ${orderNo} 已可取,取件码 ${pickupCode}`,
|
||||
status: res.SendStatusSet?.[0].Code === "Ok" ? "sent" : "failed",
|
||||
providerResponse: JSON.stringify(res),
|
||||
sentAt: new Date(),
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error("短信发送失败:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/preload/index.ts
Normal file
104
src/preload/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type {
|
||||
ApiResponse,
|
||||
BackupInfoDto,
|
||||
CustomerDto,
|
||||
OrderDto,
|
||||
OrderSearchResultDto,
|
||||
OrderWithDetailsDto,
|
||||
StatsDto,
|
||||
} from "../shared";
|
||||
import type {
|
||||
CreateOrderInput,
|
||||
PickupInput,
|
||||
UpsertCustomerInput,
|
||||
} from "../shared/schemas";
|
||||
|
||||
export interface LaundryDeskApi {
|
||||
orders: {
|
||||
create: (data: CreateOrderInput) => Promise<ApiResponse<OrderDto>>;
|
||||
findAll: (params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => Promise<ApiResponse<OrderWithDetailsDto[]>>;
|
||||
findById: (
|
||||
id: number,
|
||||
) => Promise<ApiResponse<OrderWithDetailsDto | undefined>>;
|
||||
getStats: () => Promise<ApiResponse<StatsDto>>;
|
||||
pickup: (input: PickupInput) => Promise<ApiResponse<OrderDto>>;
|
||||
searchForPickup: (
|
||||
query: string,
|
||||
) => Promise<ApiResponse<OrderSearchResultDto[]>>;
|
||||
getOverdue: () => Promise<ApiResponse<OrderWithDetailsDto[]>>;
|
||||
};
|
||||
customers: {
|
||||
upsert: (data: UpsertCustomerInput) => Promise<ApiResponse<CustomerDto>>;
|
||||
findByPhone: (
|
||||
phone: string,
|
||||
) => Promise<ApiResponse<CustomerDto | undefined>>;
|
||||
findAll: (params?: {
|
||||
query?: string;
|
||||
}) => Promise<ApiResponse<CustomerDto[]>>;
|
||||
};
|
||||
settings: {
|
||||
get: <T = unknown>(key: string) => Promise<ApiResponse<T | null>>;
|
||||
set: (key: string, value: unknown) => Promise<ApiResponse<unknown>>;
|
||||
};
|
||||
excel: {
|
||||
exportOrders: () => Promise<ApiResponse<string | null>>;
|
||||
exportCustomers: () => Promise<ApiResponse<string | null>>;
|
||||
};
|
||||
photos: {
|
||||
save: (
|
||||
orderId: number,
|
||||
base64Data: string,
|
||||
) => Promise<ApiResponse<unknown>>;
|
||||
};
|
||||
printer: {
|
||||
printReceipt: (orderId: number) => Promise<ApiResponse<boolean>>;
|
||||
};
|
||||
backup: {
|
||||
runNow: () => Promise<ApiResponse<string>>;
|
||||
list: () => Promise<ApiResponse<BackupInfoDto[]>>;
|
||||
};
|
||||
}
|
||||
|
||||
const api: LaundryDeskApi = {
|
||||
orders: {
|
||||
create: (data) => ipcRenderer.invoke("orders:create", data),
|
||||
findAll: (params) => ipcRenderer.invoke("orders:findAll", params),
|
||||
findById: (id) => ipcRenderer.invoke("orders:findById", id),
|
||||
getStats: () => ipcRenderer.invoke("orders:getStats"),
|
||||
pickup: (input) => ipcRenderer.invoke("orders:pickup", input),
|
||||
searchForPickup: (query) =>
|
||||
ipcRenderer.invoke("orders:searchForPickup", { query }),
|
||||
getOverdue: () => ipcRenderer.invoke("orders:getOverdue"),
|
||||
},
|
||||
customers: {
|
||||
upsert: (data) => ipcRenderer.invoke("customers:upsert", data),
|
||||
findByPhone: (phone) => ipcRenderer.invoke("customers:findByPhone", phone),
|
||||
findAll: (params) => ipcRenderer.invoke("customers:findAll", params),
|
||||
},
|
||||
settings: {
|
||||
get: (key) => ipcRenderer.invoke("settings:get", key),
|
||||
set: (key, value) => ipcRenderer.invoke("settings:set", { key, value }),
|
||||
},
|
||||
excel: {
|
||||
exportOrders: () => ipcRenderer.invoke("excel:exportOrders"),
|
||||
exportCustomers: () => ipcRenderer.invoke("excel:exportCustomers"),
|
||||
},
|
||||
photos: {
|
||||
save: (orderId, base64Data) =>
|
||||
ipcRenderer.invoke("photos:save", { orderId, base64Data }),
|
||||
},
|
||||
printer: {
|
||||
printReceipt: (orderId) =>
|
||||
ipcRenderer.invoke("printer:printReceipt", orderId),
|
||||
},
|
||||
backup: {
|
||||
runNow: () => ipcRenderer.invoke("backup:runNow"),
|
||||
list: () => ipcRenderer.invoke("backup:list"),
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("api", api);
|
||||
15
src/renderer/index.html
Normal file
15
src/renderer/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: media:; connect-src 'self' http://localhost:* ws://localhost:*;"
|
||||
/>
|
||||
<title>宏发洗衣店</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
src/renderer/src/App.tsx
Normal file
7
src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AppRouter } from "./routes";
|
||||
|
||||
function App() {
|
||||
return <AppRouter />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
31
src/renderer/src/assets/main.css
Normal file
31
src/renderer/src/assets/main.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color: #0f172a;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(0, 113, 227, 0.16), transparent 32%),
|
||||
radial-gradient(circle at 88% 0%, rgba(99, 102, 241, 0.13), transparent 28%),
|
||||
linear-gradient(135deg, #fbfbfd 0%, #f5f5f7 44%, #eef2f8 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: geometricPrecision;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-family:
|
||||
"SF Pro Display", "SF Pro Text", "PingFang SC", "Microsoft YaHei",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(0, 113, 227, 0.18);
|
||||
}
|
||||
118
src/renderer/src/components/Layout.tsx
Normal file
118
src/renderer/src/components/Layout.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NavLink, useLocation, useOutlet } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ReceiptText,
|
||||
PackageCheck,
|
||||
Users,
|
||||
Settings,
|
||||
BarChart3,
|
||||
ListOrdered,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", icon: LayoutDashboard, label: "总览" },
|
||||
{ to: "/receive", icon: ReceiptText, label: "收件登记" },
|
||||
{ to: "/pickup", icon: PackageCheck, label: "取件查询" },
|
||||
{ to: "/orders", icon: ListOrdered, label: "订单列表" },
|
||||
{ to: "/customers", icon: Users, label: "客户管理" },
|
||||
{ to: "/stats", icon: BarChart3, label: "统计报表" },
|
||||
{ to: "/settings", icon: Settings, label: "系统设置" },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const outlet = useOutlet();
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen overflow-hidden bg-[#f5f5f7] text-slate-950">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_22%_8%,rgba(0,113,227,0.18),transparent_30%),radial-gradient(circle_at_88%_6%,rgba(175,82,222,0.12),transparent_26%),linear-gradient(135deg,#fbfbfd_0%,#f5f5f7_42%,#edf2f8_100%)]" />
|
||||
<div className="pointer-events-none absolute left-[300px] top-20 h-48 w-48 rounded-full bg-sky-200/40 blur-3xl" />
|
||||
<div className="pointer-events-none absolute bottom-16 right-24 h-56 w-56 rounded-full bg-indigo-200/35 blur-3xl" />
|
||||
|
||||
<aside className="relative z-10 flex w-[280px] flex-col border-r border-white/70 bg-white/62 shadow-[20px_0_70px_rgba(15,23,42,0.06)] backdrop-blur-2xl">
|
||||
<div className="p-6 pb-4">
|
||||
<div className="mb-8 flex gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#febc2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
|
||||
</div>
|
||||
<h1 className="text-[28px] font-semibold tracking-[-0.04em] text-[#0071e3]">
|
||||
宏发洗衣店
|
||||
</h1>
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-[0.24em] text-slate-400">
|
||||
柜台管理系统
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-4">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group flex items-center gap-3 rounded-[20px] px-4 py-3 text-[15px] font-semibold transition-all",
|
||||
isActive
|
||||
? "bg-white/90 text-[#0071e3] shadow-[0_16px_36px_rgba(15,23,42,0.08)] ring-1 ring-white/80"
|
||||
: "text-slate-500 hover:bg-white/60 hover:text-slate-950",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon
|
||||
className={cn(
|
||||
"h-5 w-5 transition-colors group-hover:text-[#0071e3]",
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3 rounded-[24px] border border-white/70 bg-white/72 p-3 shadow-[0_12px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#0071e3]/10 text-sm font-bold text-[#0071e3]">
|
||||
AD
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">店长:周学胜</p>
|
||||
<p className="text-xs font-medium text-emerald-600">店长在线</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="relative z-10 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-7xl p-10">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 18, filter: "blur(8px)" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 0.26,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -12,
|
||||
filter: "blur(8px)",
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: [0.4, 0, 1, 1],
|
||||
},
|
||||
}}
|
||||
>
|
||||
{outlet}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/renderer/src/components/ui/Button.tsx
Normal file
56
src/renderer/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0071e3] active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[#0071e3] text-white shadow-[0_12px_30px_rgba(0,113,227,0.28)] hover:bg-[#0077ed]",
|
||||
destructive:
|
||||
"bg-[#ff3b30] text-white shadow-[0_12px_30px_rgba(255,59,48,0.24)] hover:bg-[#ff453a]",
|
||||
outline:
|
||||
"border border-white/70 bg-white/75 text-slate-900 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl hover:bg-white",
|
||||
secondary: "bg-slate-900/5 text-slate-900 hover:bg-slate-900/10",
|
||||
ghost: "text-slate-600 hover:bg-white/70 hover:text-slate-950",
|
||||
link: "text-[#0071e3] underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-6 py-2",
|
||||
sm: "h-9 px-4",
|
||||
lg: "h-12 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
81
src/renderer/src/components/ui/Card.tsx
Normal file
81
src/renderer/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-[28px] border border-white/70 bg-white/80 text-slate-950 shadow-[0_24px_70px_rgba(15,23,42,0.08)] backdrop-blur-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xl font-semibold leading-none tracking-[-0.02em]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-slate-500", className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
23
src/renderer/src/components/ui/Input.tsx
Normal file
23
src/renderer/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-2xl border border-white/70 bg-white/72 px-4 py-2 text-sm shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] ring-offset-white backdrop-blur-xl file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0071e3] transition-all disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
28
src/renderer/src/components/ui/Notice.tsx
Normal file
28
src/renderer/src/components/ui/Notice.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
type NoticeVariant = "info" | "success" | "warning" | "error";
|
||||
|
||||
const variantClassNames: Record<NoticeVariant, string> = {
|
||||
info: "border-blue-100 bg-blue-50/90 text-blue-700",
|
||||
success: "border-emerald-100 bg-emerald-50/90 text-emerald-700",
|
||||
warning: "border-amber-100 bg-amber-50/90 text-amber-700",
|
||||
error: "border-red-100 bg-red-50/90 text-red-700",
|
||||
};
|
||||
|
||||
interface NoticeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: NoticeVariant;
|
||||
}
|
||||
|
||||
export function Notice({ className, variant = "info", ...props }: NoticeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border px-4 py-3 text-sm font-medium shadow-sm",
|
||||
variantClassNames[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
src/renderer/src/env.d.ts
vendored
Normal file
10
src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="electron-vite/client" />
|
||||
|
||||
import type { LaundryDeskApi } from "../../preload";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: LaundryDeskApi;
|
||||
}
|
||||
}
|
||||
16
src/renderer/src/lib/utils.ts
Normal file
16
src/renderer/src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额(分转元)
|
||||
*/
|
||||
export function formatCurrency(cents: number) {
|
||||
return (cents / 100).toLocaleString("zh-CN", {
|
||||
style: "currency",
|
||||
currency: "CNY",
|
||||
});
|
||||
}
|
||||
10
src/renderer/src/main.tsx
Normal file
10
src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./assets/main.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
57
src/renderer/src/routes/Customers.tsx
Normal file
57
src/renderer/src/routes/Customers.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CustomerDto } from "@shared/index";
|
||||
import { Card, CardContent } from "../components/ui/Card";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import { formatCurrency } from "@renderer/lib/utils";
|
||||
|
||||
export default function Customers() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [customers, setCustomers] = useState<CustomerDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
window.api.customers.findAll({ query }).then((response) => {
|
||||
if (response.ok) setCustomers(response.data);
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">客户管理</h2>
|
||||
<p className="text-slate-500 mt-2">
|
||||
按手机号自动去重,累计订单和消费额。
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="搜索姓名或手机号"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div className="grid gap-4">
|
||||
{customers.map((customer) => (
|
||||
<Card key={customer.id} className="border-none shadow-sm">
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-lg">{customer.name}</div>
|
||||
<div className="text-sm text-slate-500">{customer.phone}</div>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div>{customer.totalOrders} 单</div>
|
||||
<div className="font-bold text-blue-600">
|
||||
{formatCurrency(customer.totalSpent)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{customers.length === 0 && (
|
||||
<div className="py-20 text-center text-slate-400">暂无客户记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/renderer/src/routes/Home.tsx
Normal file
234
src/renderer/src/routes/Home.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/Card";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Coins,
|
||||
PackageCheck,
|
||||
ReceiptText,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { StatsDto } from "@shared/index";
|
||||
import { motion } from "framer-motion";
|
||||
import { formatCurrency } from "@renderer/lib/utils";
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<StatsDto | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.api.orders.getStats().then((response) => {
|
||||
if (response.ok) setStats(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dashboardCards = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "今日收件",
|
||||
value: stats?.todayCount ?? 0,
|
||||
icon: ReceiptText,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
label: "待取件",
|
||||
value: stats?.pendingCount ?? 0,
|
||||
icon: PackageCheck,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-50",
|
||||
},
|
||||
{
|
||||
label: "逾期未取",
|
||||
value: stats?.overdueCount ?? 0,
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600",
|
||||
bg: "bg-red-50",
|
||||
},
|
||||
{
|
||||
label: "预计今日交付",
|
||||
value: stats?.dueTodayCount ?? 0,
|
||||
icon: Clock,
|
||||
color: "text-orange-600",
|
||||
bg: "bg-orange-50",
|
||||
},
|
||||
],
|
||||
[stats],
|
||||
);
|
||||
|
||||
const todayLabel = new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
}).format(new Date());
|
||||
|
||||
return (
|
||||
<div className="space-y-9">
|
||||
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-2 text-sm font-semibold text-slate-600 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl">
|
||||
<Sparkles className="h-4 w-4 text-[#0071e3]" />
|
||||
今日营业中
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="max-w-3xl text-[56px] font-semibold leading-[0.98] tracking-[-0.06em] text-slate-950">
|
||||
你好,周学胜
|
||||
</h2>
|
||||
<p className="mt-4 text-xl font-medium text-slate-500">
|
||||
今天是 {todayLabel},宏发洗衣店运行正常。
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, delay: 0.05, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<Card className="overflow-hidden bg-[linear-gradient(140deg,rgba(255,255,255,0.92),rgba(243,247,255,0.82))]">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Coins className="h-5 w-5 text-[#0071e3]" />
|
||||
经营概览
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
首页数据已切到实时统计,不再使用占位数字。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3 xl:grid-cols-1">
|
||||
<div className="rounded-[22px] border border-white/80 bg-white/80 p-4">
|
||||
<div className="text-sm font-medium text-slate-500">
|
||||
今日实收
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-slate-950">
|
||||
{formatCurrency(stats?.todayIncome ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/80 bg-white/80 p-4">
|
||||
<div className="text-sm font-medium text-slate-500">
|
||||
本月实收
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-slate-950">
|
||||
{formatCurrency(stats?.monthIncome ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/80 bg-white/80 p-4">
|
||||
<div className="text-sm font-medium text-slate-500">
|
||||
本月收件
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-slate-950">
|
||||
{(stats?.monthCount ?? 0).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{dashboardCards.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.08 + index * 0.05,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
>
|
||||
<Card className="group overflow-hidden transition-all hover:-translate-y-1 hover:bg-white/95">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-semibold text-slate-500">
|
||||
{stat.label}
|
||||
</CardTitle>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl p-3 transition-all group-hover:scale-105",
|
||||
stat.bg,
|
||||
stat.color,
|
||||
)}
|
||||
>
|
||||
<stat.icon className="h-5 w-5" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-[42px] font-semibold tracking-[-0.05em]">
|
||||
{stat.value.toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, delay: 0.28, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<Card className="overflow-hidden transition-all hover:-translate-y-1 hover:bg-white/95">
|
||||
<CardHeader>
|
||||
<CardTitle>快速收件</CardTitle>
|
||||
<CardDescription>
|
||||
录入客户、物品与付款信息,生成取件码。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="text-base font-medium leading-7 text-slate-500">
|
||||
点击进入收件流程,支持自动识别回头客。
|
||||
</p>
|
||||
<Link
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-[#0071e3]"
|
||||
to="/receive"
|
||||
>
|
||||
开始收件
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, delay: 0.34, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<Card className="overflow-hidden transition-all hover:-translate-y-1 hover:bg-white/95">
|
||||
<CardHeader>
|
||||
<CardTitle>快速取件</CardTitle>
|
||||
<CardDescription>
|
||||
输入取件码、订单号或手机号,实时完成出库。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="text-base font-medium leading-7 text-slate-500">
|
||||
报取件码或手机号快速结账取走衣物。
|
||||
</p>
|
||||
<Link
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-[#0071e3]"
|
||||
to="/pickup"
|
||||
>
|
||||
查询取件
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
src/renderer/src/routes/OrderDetail.tsx
Normal file
267
src/renderer/src/routes/OrderDetail.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/Card";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Notice } from "../components/ui/Notice";
|
||||
import { formatCurrency, cn } from "@renderer/lib/utils";
|
||||
import { ChevronLeft, Printer, CheckCircle2 } from "lucide-react";
|
||||
import type { OrderWithDetailsDto } from "@shared/index";
|
||||
|
||||
interface RouteNoticeState {
|
||||
notice?: {
|
||||
variant: "success" | "warning" | "error" | "info";
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrderDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [order, setOrder] = useState<OrderWithDetailsDto | null>(null);
|
||||
const [notice, setNotice] = useState<RouteNoticeState["notice"]>(
|
||||
(location.state as RouteNoticeState | null)?.notice,
|
||||
);
|
||||
const [pickupPending, setPickupPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
window.api.orders.findById(parseInt(id, 10)).then((res) => {
|
||||
if (res.ok) setOrder(res.data ?? null);
|
||||
});
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const preparePickup = () => {
|
||||
if (!order || order.status === "picked_up") return;
|
||||
setNotice({
|
||||
variant: "warning",
|
||||
message:
|
||||
order.totalAmount > order.paidAmount
|
||||
? `该订单还有 ${formatCurrency(order.totalAmount - order.paidAmount)} 欠款,点击“确认完成取件”后会一并补收。`
|
||||
: "请确认衣物已交接,再点击“确认完成取件”。",
|
||||
});
|
||||
setPickupPending(true);
|
||||
};
|
||||
|
||||
const confirmPickup = async () => {
|
||||
if (!order) return;
|
||||
|
||||
const balance = order.totalAmount - order.paidAmount;
|
||||
const paidExtra = balance > 0 ? balance : 0;
|
||||
|
||||
try {
|
||||
const res = await window.api.orders.pickup({
|
||||
orderId: order.id,
|
||||
paidAmount: paidExtra,
|
||||
});
|
||||
if (res.ok) {
|
||||
setNotice({ variant: "success", message: "取件成功" });
|
||||
setPickupPending(false);
|
||||
const updated = await window.api.orders.findById(order.id);
|
||||
if (updated.ok) setOrder(updated.data ?? null);
|
||||
} else {
|
||||
setNotice({ variant: "error", message: res.error.message });
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "取件失败,请稍后重试";
|
||||
setNotice({ variant: "error", message });
|
||||
setPickupPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!order)
|
||||
return (
|
||||
<div className="p-20 text-center text-slate-500">正在加载订单详情...</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/orders")}>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h2 className="text-2xl font-bold">订单详情: {order.orderNo}</h2>
|
||||
</div>
|
||||
|
||||
{notice && <Notice variant={notice.variant}>{notice.message}</Notice>}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<Card className="border-none shadow-sm overflow-hidden">
|
||||
<CardHeader className="bg-slate-50 border-b border-slate-100 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">物品明细</CardTitle>
|
||||
<p className="text-xs text-slate-500 uppercase mt-1">
|
||||
共 {order.items.length} 件物品
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{formatCurrency(order.totalAmount)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-slate-500 border-b border-slate-100">
|
||||
<th className="px-6 py-3 text-left font-medium">物品</th>
|
||||
<th className="px-6 py-3 text-left font-medium">服务</th>
|
||||
<th className="px-6 py-3 text-right font-medium">数量</th>
|
||||
<th className="px-6 py-3 text-right font-medium">小计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{order.items.map((item: any) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-slate-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 font-medium">{item.itemType}</td>
|
||||
<td className="px-6 py-4 text-slate-500 uppercase text-[10px] tracking-wider">
|
||||
{item.serviceType}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
x {item.quantity}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-bold text-slate-900">
|
||||
{formatCurrency(item.subtotal)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">备注</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-600">
|
||||
{order.notes || "无备注信息"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="border-none shadow-sm bg-blue-600 text-white overflow-hidden">
|
||||
<CardContent className="p-6 text-center space-y-2">
|
||||
<div className="text-xs text-blue-200 uppercase tracking-widest font-bold">
|
||||
取件码
|
||||
</div>
|
||||
<div className="text-5xl font-black tracking-tighter">
|
||||
{order.pickupCode}
|
||||
</div>
|
||||
<div className="pt-2 text-[10px] text-blue-100 uppercase tracking-widest font-bold opacity-70">
|
||||
{order.orderNo}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-slate-500">
|
||||
客户信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="font-bold text-lg">{order.customer.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{order.customer.phone}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-base text-slate-500">
|
||||
付款详情
|
||||
</CardTitle>
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-bold uppercase",
|
||||
order.totalAmount > order.paidAmount
|
||||
? "bg-red-50 text-red-600"
|
||||
: "bg-green-50 text-green-600",
|
||||
)}
|
||||
>
|
||||
{order.totalAmount > order.paidAmount ? "欠款" : "已结清"}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">应付总计</span>
|
||||
<span className="font-bold">
|
||||
{formatCurrency(order.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">实付金额</span>
|
||||
<span className="font-bold text-green-600">
|
||||
{formatCurrency(order.paidAmount)}
|
||||
</span>
|
||||
</div>
|
||||
{order.totalAmount > order.paidAmount && (
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-slate-100">
|
||||
<span className="text-slate-500">剩余欠款</span>
|
||||
<span className="font-bold text-red-600">
|
||||
{formatCurrency(order.totalAmount - order.paidAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full" variant="outline">
|
||||
<Printer className="w-4 h-4 mr-2" /> 打印票据
|
||||
</Button>
|
||||
{pickupPending && order.status !== "picked_up" && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white shadow-sm"
|
||||
onClick={() => void confirmPickup()}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
确认完成取件
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPickupPending(false);
|
||||
setNotice(undefined);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white shadow-sm"
|
||||
disabled={order.status === "picked_up"}
|
||||
onClick={preparePickup}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
{order.status === "picked_up"
|
||||
? "已取件"
|
||||
: pickupPending
|
||||
? "等待最终确认"
|
||||
: "开始取件"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/renderer/src/routes/Orders.tsx
Normal file
83
src/renderer/src/routes/Orders.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent } from "../components/ui/Card";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { formatCurrency, cn } from "@renderer/lib/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const STATUS_MAP = {
|
||||
pending: { label: "待处理", color: "bg-yellow-50 text-yellow-600" },
|
||||
ready: { label: "可取件", color: "bg-blue-50 text-blue-600" },
|
||||
picked_up: { label: "已取件", color: "bg-green-50 text-green-600" },
|
||||
cancelled: { label: "已取消", color: "bg-red-50 text-red-600" },
|
||||
};
|
||||
|
||||
export default function Orders() {
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
window.api.orders.findAll().then((res: any) => {
|
||||
if (res.ok) setOrders(res.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">订单列表</h2>
|
||||
<Button onClick={() => navigate("/receive")}>新收件</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{orders.map((order) => (
|
||||
<Card
|
||||
key={order.id}
|
||||
className="border-none shadow-sm hover:ring-1 hover:ring-blue-100 transition-all cursor-pointer"
|
||||
onClick={() => navigate(`/orders/${order.id}`)}
|
||||
>
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-50 flex items-center justify-center font-bold text-blue-600">
|
||||
{order.pickupCode}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-lg">{order.orderNo}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider",
|
||||
STATUS_MAP[order.status as keyof typeof STATUS_MAP]
|
||||
.color,
|
||||
)}
|
||||
>
|
||||
{
|
||||
STATUS_MAP[order.status as keyof typeof STATUS_MAP]
|
||||
.label
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">
|
||||
{order.customer.name} · {order.customer.phone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-lg text-blue-600">
|
||||
{formatCurrency(order.totalAmount)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
{new Date(order.receiveDate).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{orders.length === 0 && (
|
||||
<div className="py-20 text-center text-slate-400">暂无订单记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/renderer/src/routes/Pickup.tsx
Normal file
160
src/renderer/src/routes/Pickup.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
import type { OrderSearchResultDto } from "@shared/index";
|
||||
import { Card, CardContent } from "../components/ui/Card";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Notice } from "../components/ui/Notice";
|
||||
import { formatCurrency } from "@renderer/lib/utils";
|
||||
|
||||
export default function Pickup() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<OrderSearchResultDto[]>([]);
|
||||
const [message, setMessage] = useState("输入信息开始查询订单");
|
||||
const [messageVariant, setMessageVariant] = useState<
|
||||
"info" | "success" | "warning" | "error"
|
||||
>("info");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmingOrderId, setConfirmingOrderId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const searchOrders = async (
|
||||
value: string,
|
||||
options?: { preserveMessage?: boolean },
|
||||
) => {
|
||||
if (!value) {
|
||||
setMessageVariant("warning");
|
||||
setMessage("请输入取件码、手机号、单号或姓名");
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const response = await window.api.orders.searchForPickup(value);
|
||||
setLoading(false);
|
||||
|
||||
if (!response.ok) {
|
||||
setMessageVariant("error");
|
||||
setMessage(response.error.message);
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(response.data);
|
||||
setConfirmingOrderId(null);
|
||||
if (!options?.preserveMessage) {
|
||||
setMessageVariant("info");
|
||||
setMessage(response.data.length === 0 ? "未找到待取订单" : "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const value = query.trim();
|
||||
await searchOrders(value);
|
||||
};
|
||||
|
||||
const handlePickup = async (order: OrderSearchResultDto) => {
|
||||
const balance = order.totalAmount - order.paidAmount;
|
||||
if (confirmingOrderId !== order.id) {
|
||||
setConfirmingOrderId(order.id);
|
||||
setMessageVariant("warning");
|
||||
setMessage(
|
||||
balance > 0
|
||||
? `订单 ${order.orderNo} 尚有 ${formatCurrency(balance)} 欠款,再点一次“确认取件”会一并补收后完成取件。`
|
||||
: `订单 ${order.orderNo} 已准备交接,再点一次“确认取件”完成操作。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await window.api.orders.pickup({
|
||||
orderId: order.id,
|
||||
paidAmount: balance,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setMessageVariant("error");
|
||||
setMessage(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmingOrderId(null);
|
||||
setMessageVariant("success");
|
||||
setMessage(`订单 ${order.orderNo} 取件成功`);
|
||||
await searchOrders(query.trim(), { preserveMessage: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h2 className="text-2xl font-bold">取件查询</h2>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="输入 4 位取件码 / 手机号 / 订单号 / 姓名"
|
||||
className="pl-10"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleSearch} disabled={loading}>
|
||||
{loading ? "查询中..." : "查询"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{message &&
|
||||
(results.length > 0 ? (
|
||||
<Notice variant={messageVariant}>{message}</Notice>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{results.map((order) => {
|
||||
const balance = order.totalAmount - order.paidAmount;
|
||||
return (
|
||||
<Card key={order.id} className="border-none shadow-sm">
|
||||
<CardContent className="p-5 flex items-center justify-between gap-4">
|
||||
<button
|
||||
className="text-left flex-1"
|
||||
type="button"
|
||||
onClick={() => navigate(`/orders/${order.id}`)}
|
||||
>
|
||||
<div className="font-bold text-lg">{order.orderNo}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">
|
||||
{order.customerName} · {order.customerPhone} · 取件码{" "}
|
||||
{order.pickupCode}
|
||||
</div>
|
||||
<div className="text-sm mt-2">
|
||||
{balance > 0 ? (
|
||||
<span className="text-red-600">
|
||||
待补款 {formatCurrency(balance)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-600">已结清</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Button onClick={() => void handlePickup(order)}>
|
||||
{confirmingOrderId === order.id ? "再次确认取件" : "确认取件"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
327
src/renderer/src/routes/Receive.tsx
Normal file
327
src/renderer/src/routes/Receive.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CheckCircle2, Plus, Trash2 } from "lucide-react";
|
||||
import type { ServiceType } from "@shared/schemas";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/Card";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Notice } from "../components/ui/Notice";
|
||||
import { formatCurrency } from "@renderer/lib/utils";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
itemType: string;
|
||||
serviceType: ServiceType;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
interface PriceTemplate {
|
||||
itemType: string;
|
||||
serviceType: ServiceType;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export default function Receive() {
|
||||
const [customer, setCustomer] = useState({ name: "", phone: "" });
|
||||
const [items, setItems] = useState<OrderItem[]>([createBlankItem()]);
|
||||
const [templates, setTemplates] = useState<PriceTemplate[]>([]);
|
||||
const [paidAmount, setPaidAmount] = useState(0);
|
||||
const [paymentMethod, setPaymentMethod] = useState<
|
||||
"cash" | "wechat" | "alipay" | "card" | "unpaid"
|
||||
>("cash");
|
||||
const [error, setError] = useState("");
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const phoneInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const totalAmount = items.reduce(
|
||||
(acc, item) => acc + item.quantity * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.api.settings
|
||||
.get<PriceTemplate[]>("price_templates")
|
||||
.then((response) => {
|
||||
if (response.ok && response.data) setTemplates(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
phoneInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPaidAmount(totalAmount);
|
||||
}, [totalAmount]);
|
||||
|
||||
const handlePhoneChange = async (phone: string) => {
|
||||
setCustomer((current) => ({ ...current, phone }));
|
||||
if (/^1[3-9]\d{9}$/.test(phone)) {
|
||||
const response = await window.api.customers.findByPhone(phone);
|
||||
if (response.ok && response.data) {
|
||||
setCustomer({ name: response.data.name, phone: response.data.phone });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateItem = (id: string, patch: Partial<OrderItem>) => {
|
||||
setItems((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setItems((current) => [...current, createBlankItem()]);
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setItems((current) =>
|
||||
current.length === 1 ? current : current.filter((item) => item.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
const selectTemplate = (id: string, template: PriceTemplate) => {
|
||||
updateItem(id, {
|
||||
itemType: template.itemType,
|
||||
serviceType: template.serviceType,
|
||||
unitPrice: template.price,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
setSuccessMessage("");
|
||||
if (!customer.name.trim() || !/^1[3-9]\d{9}$/.test(customer.phone)) {
|
||||
setError("请填写客户姓名和正确手机号");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
items.some(
|
||||
(item) =>
|
||||
!item.itemType.trim() || item.quantity < 1 || item.unitPrice < 0,
|
||||
)
|
||||
) {
|
||||
setError("请完整填写物品明细");
|
||||
return;
|
||||
}
|
||||
if (paidAmount > totalAmount) {
|
||||
setError("实收金额不能超过订单总额");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const customerResponse = await window.api.customers.upsert(customer);
|
||||
if (!customerResponse.ok) {
|
||||
setError(customerResponse.error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderResponse = await window.api.orders.create({
|
||||
customerId: customerResponse.data.id,
|
||||
items: items.map(({ itemType, serviceType, quantity, unitPrice }) => ({
|
||||
itemType,
|
||||
serviceType,
|
||||
quantity,
|
||||
unitPrice,
|
||||
})),
|
||||
totalAmount,
|
||||
paidAmount,
|
||||
paymentMethod: paidAmount === 0 ? "unpaid" : paymentMethod,
|
||||
});
|
||||
setLoading(false);
|
||||
|
||||
if (!orderResponse.ok) {
|
||||
setError(orderResponse.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const pickupCode = orderResponse.data.pickupCode;
|
||||
setSuccessMessage(`收件成功,取件码 ${pickupCode}`);
|
||||
navigate(`/orders/${orderResponse.data.id}`, {
|
||||
state: {
|
||||
notice: {
|
||||
variant: "success",
|
||||
message: `收件成功,取件码 ${pickupCode}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h2 className="text-2xl font-bold">收件登记</h2>
|
||||
{successMessage && <Notice variant="success">{successMessage}</Notice>}
|
||||
{error && <Notice variant="error">{error}</Notice>}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="md:col-span-1 border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">客户信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
ref={phoneInputRef}
|
||||
placeholder="手机号"
|
||||
value={customer.phone}
|
||||
onChange={(event) => handlePhoneChange(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="客户姓名"
|
||||
value={customer.name}
|
||||
onChange={(event) =>
|
||||
setCustomer({ ...customer, name: event.target.value })
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 border-none shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">物品明细</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={addItem}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid gap-3 md:grid-cols-[1fr_120px_90px_44px] items-end"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="物品类型,例如衬衫"
|
||||
value={item.itemType}
|
||||
onChange={(event) =>
|
||||
updateItem(item.id, { itemType: event.target.value })
|
||||
}
|
||||
/>
|
||||
{item.itemType && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{templates
|
||||
.filter((template) =>
|
||||
template.itemType.includes(item.itemType),
|
||||
)
|
||||
.slice(0, 4)
|
||||
.map((template) => (
|
||||
<button
|
||||
key={`${template.itemType}-${template.serviceType}`}
|
||||
type="button"
|
||||
className="rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-600"
|
||||
onClick={() => selectTemplate(item.id, template)}
|
||||
>
|
||||
{template.itemType} {formatCurrency(template.price)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="单价(元)"
|
||||
value={centsToInput(item.unitPrice)}
|
||||
onChange={(event) =>
|
||||
updateItem(item.id, {
|
||||
unitPrice: yuanInputToCents(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(event) =>
|
||||
updateItem(item.id, {
|
||||
quantity: Number(event.target.value) || 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-400"
|
||||
onClick={() => removeItem(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-4 border-t border-slate-100 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-500">合计 {items.length} 件</span>
|
||||
<span className="text-xl font-bold text-blue-600">
|
||||
{formatCurrency(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="实收金额(元)"
|
||||
value={centsToInput(paidAmount)}
|
||||
onChange={(event) =>
|
||||
setPaidAmount(yuanInputToCents(event.target.value))
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="h-11 rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm"
|
||||
value={paymentMethod}
|
||||
onChange={(event) =>
|
||||
setPaymentMethod(event.target.value as typeof paymentMethod)
|
||||
}
|
||||
>
|
||||
<option value="cash">现金</option>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="alipay">支付宝</option>
|
||||
<option value="card">刷卡</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full mt-6"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
<CheckCircle2 className="w-5 h-5 mr-2" />
|
||||
{loading ? "提交中..." : "确认收件"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createBlankItem(): OrderItem {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
itemType: "",
|
||||
serviceType: "wash",
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function yuanInputToCents(value: string): number {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return 0;
|
||||
const [yuan = "0", fraction = ""] = normalized.split(".");
|
||||
return Number(yuan) * 100 + Number(fraction.padEnd(2, "0").slice(0, 2));
|
||||
}
|
||||
|
||||
function centsToInput(cents: number): string {
|
||||
return (cents / 100).toString();
|
||||
}
|
||||
112
src/renderer/src/routes/Settings.tsx
Normal file
112
src/renderer/src/routes/Settings.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Database, Save } from "lucide-react";
|
||||
import type { BackupInfoDto } from "@shared/index";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/Card";
|
||||
import { Input } from "../components/ui/Input";
|
||||
|
||||
export default function Settings() {
|
||||
const [shopName, setShopName] = useState("");
|
||||
const [backups, setBackups] = useState<BackupInfoDto[]>([]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.api.settings.get<string>("shop.name").then((response) => {
|
||||
if (response.ok) setShopName(response.data ?? "");
|
||||
});
|
||||
void refreshBackups();
|
||||
}, []);
|
||||
|
||||
const refreshBackups = async () => {
|
||||
const response = await window.api.backup.list();
|
||||
if (response.ok) setBackups(response.data);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
const response = await window.api.settings.set("shop.name", shopName);
|
||||
setLoading(false);
|
||||
setMessage(response.ok ? "设置已保存" : response.error.message);
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
setLoading(true);
|
||||
const response = await window.api.backup.runNow();
|
||||
setLoading(false);
|
||||
if (!response.ok) {
|
||||
setMessage(response.error.message);
|
||||
return;
|
||||
}
|
||||
setMessage(`备份成功: ${response.data}`);
|
||||
await refreshBackups();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h2 className="text-2xl font-bold">系统设置</h2>
|
||||
{message && (
|
||||
<div className="rounded-xl bg-blue-50 px-4 py-3 text-sm text-blue-600">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">店铺信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={shopName}
|
||||
onChange={(event) => setShopName(event.target.value)}
|
||||
placeholder="店铺名称"
|
||||
/>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
<Save className="w-4 h-4 mr-2" /> 保存更改
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">数据备份</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium">自动备份</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
每天凌晨 03:00 自动备份,保留最近 30 份。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleBackup} disabled={loading}>
|
||||
<Database className="w-4 h-4 mr-2" /> 立即备份
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.path}
|
||||
className="rounded-xl bg-slate-50 px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="font-medium">{backup.fileName}</div>
|
||||
<div className="text-slate-500">
|
||||
{new Date(backup.createdAt).toLocaleString()} ·{" "}
|
||||
{(backup.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{backups.length === 0 && (
|
||||
<p className="text-sm text-slate-400">暂无备份文件</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
src/renderer/src/routes/Stats.tsx
Normal file
212
src/renderer/src/routes/Stats.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/Card";
|
||||
import { formatCurrency } from "@renderer/lib/utils";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
} from "recharts";
|
||||
import {
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
ShoppingBag,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Notice } from "../components/ui/Notice";
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
window.api.orders.getStats().then((res: any) => {
|
||||
if (res.ok) setStats(res.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExport = async () => {
|
||||
const res = await window.api.excel.exportOrders();
|
||||
if (res.ok && res.data) {
|
||||
setMessage(`导出成功: ${res.data}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!stats)
|
||||
return (
|
||||
<div className="p-20 text-center text-slate-500">正在计算数据...</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">业务统计</h2>
|
||||
<p className="text-slate-500 mt-2">实时掌握店铺营收与经营动态。</p>
|
||||
</div>
|
||||
<Button onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" /> 导出报表
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message && <Notice variant="success">{message}</Notice>}
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-500">
|
||||
今日收入
|
||||
</CardTitle>
|
||||
<CreditCard className="w-4 h-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.todayIncome)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
共 {stats.todayCount} 笔订单
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-500">
|
||||
本月收入
|
||||
</CardTitle>
|
||||
<Calendar className="w-4 h-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.monthIncome)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
本月累计 {stats.monthCount} 笔
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-500">
|
||||
平均客单价
|
||||
</CardTitle>
|
||||
<TrendingUp className="w-4 h-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(
|
||||
stats.monthCount > 0 ? stats.monthIncome / stats.monthCount : 0,
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-500">
|
||||
收件总量
|
||||
</CardTitle>
|
||||
<ShoppingBag className="w-4 h-4 text-purple-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.monthCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 图表展示 */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">近 7 日收入趋势 (元)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px] pt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats.chartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="#f1f5f9"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "#94a3b8" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "#94a3b8" }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "#f8fafc" }}
|
||||
contentStyle={{
|
||||
borderRadius: "12px",
|
||||
border: "none",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="income" fill="#2563eb" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">近 7 日收件量趋势 (件)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px] pt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={stats.chartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="#f1f5f9"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "#94a3b8" }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: "#94a3b8" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: "12px",
|
||||
border: "none",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#2563eb"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 4, fill: "#2563eb" }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/renderer/src/routes/index.tsx
Normal file
59
src/renderer/src/routes/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createHashRouter, RouterProvider, Navigate } from "react-router-dom";
|
||||
import Home from "./Home";
|
||||
import Receive from "./Receive";
|
||||
import Pickup from "./Pickup";
|
||||
import Orders from "./Orders";
|
||||
import OrderDetail from "./OrderDetail";
|
||||
import Layout from "../components/Layout";
|
||||
import Customers from "./Customers";
|
||||
import Stats from "./Stats";
|
||||
import Settings from "./Settings";
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "receive",
|
||||
element: <Receive />,
|
||||
},
|
||||
{
|
||||
path: "pickup",
|
||||
element: <Pickup />,
|
||||
},
|
||||
{
|
||||
path: "orders",
|
||||
element: <Orders />,
|
||||
},
|
||||
{
|
||||
path: "orders/:id",
|
||||
element: <OrderDetail />,
|
||||
},
|
||||
{
|
||||
path: "customers",
|
||||
element: <Customers />,
|
||||
},
|
||||
{
|
||||
path: "stats",
|
||||
element: <Stats />,
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
98
src/shared/index.ts
Normal file
98
src/shared/index.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export type ApiErrorCode =
|
||||
| "VALIDATION_FAILED"
|
||||
| "NOT_FOUND"
|
||||
| "CONFLICT"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export type ApiResponse<T = unknown> =
|
||||
| { ok: true; data: T }
|
||||
| {
|
||||
ok: false;
|
||||
error: {
|
||||
code: ApiErrorCode;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const ERROR_CODES = {
|
||||
INTERNAL_ERROR: "INTERNAL_ERROR",
|
||||
VALIDATION_FAILED: "VALIDATION_FAILED",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
CONFLICT: "CONFLICT",
|
||||
} as const;
|
||||
|
||||
export interface CustomerDto {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
vipLevel: number;
|
||||
totalOrders: number;
|
||||
totalSpent: number;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface OrderItemDto {
|
||||
id: number;
|
||||
orderId: number;
|
||||
itemType: string;
|
||||
serviceType: "wash" | "dry_clean" | "iron";
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
subtotal: number;
|
||||
itemNotes: string | null;
|
||||
}
|
||||
|
||||
export interface OrderDto {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
pickupCode: string;
|
||||
customerId: number;
|
||||
status: "pending" | "ready" | "picked_up" | "cancelled";
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
paymentMethod: "cash" | "wechat" | "alipay" | "card" | "unpaid";
|
||||
receiveDate: Date;
|
||||
expectedPickupDate: Date | null;
|
||||
actualPickupAt: Date | null;
|
||||
staffId: number | null;
|
||||
pickedUpBy: number | null;
|
||||
notes: string | null;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface OrderWithDetailsDto extends OrderDto {
|
||||
customer: CustomerDto;
|
||||
items: OrderItemDto[];
|
||||
}
|
||||
|
||||
export interface OrderSearchResultDto {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
pickupCode: string;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
receiveDate: Date;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
}
|
||||
|
||||
export interface StatsDto {
|
||||
todayIncome: number;
|
||||
monthIncome: number;
|
||||
todayCount: number;
|
||||
monthCount: number;
|
||||
pendingCount: number;
|
||||
overdueCount: number;
|
||||
dueTodayCount: number;
|
||||
chartData: Array<{ date: string; count: number; income: number }>;
|
||||
}
|
||||
|
||||
export interface BackupInfoDto {
|
||||
fileName: string;
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
96
src/shared/schemas.ts
Normal file
96
src/shared/schemas.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ServiceTypeSchema = z.enum(["wash", "dry_clean", "iron"]);
|
||||
export const PaymentMethodSchema = z.enum([
|
||||
"cash",
|
||||
"wechat",
|
||||
"alipay",
|
||||
"card",
|
||||
"unpaid",
|
||||
]);
|
||||
|
||||
const centsSchema = z.number().int("金额必须是整数分").min(0, "金额不能为负数");
|
||||
|
||||
export const OrderItemInputSchema = z.object({
|
||||
itemType: z.string().trim().min(1, "物品类型不能为空"),
|
||||
serviceType: ServiceTypeSchema,
|
||||
quantity: z.number().int("数量必须是整数").positive("数量必须大于 0"),
|
||||
unitPrice: centsSchema,
|
||||
itemNotes: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
export const CreateOrderSchema = z.object({
|
||||
customerId: z.number().int().positive(),
|
||||
items: z.array(OrderItemInputSchema).min(1, "至少添加一件物品"),
|
||||
totalAmount: centsSchema,
|
||||
paidAmount: centsSchema,
|
||||
paymentMethod: PaymentMethodSchema,
|
||||
expectedPickupDate: z.coerce.date().optional(),
|
||||
notes: z.string().trim().max(1000).optional(),
|
||||
staffId: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const UpsertCustomerSchema = z.object({
|
||||
name: z.string().trim().min(1, "客户姓名不能为空").max(80),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^1[3-9]\d{9}$/, "手机号格式不正确"),
|
||||
});
|
||||
|
||||
export const IdSchema = z.number().int().positive();
|
||||
|
||||
export const PaginationSchema = z
|
||||
.object({
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const PickupSchema = z.object({
|
||||
orderId: IdSchema,
|
||||
paidAmount: centsSchema.optional(),
|
||||
staffId: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const SearchOrdersSchema = z.object({
|
||||
query: z.string().trim().min(1).max(80),
|
||||
});
|
||||
|
||||
export const CustomerSearchSchema = z
|
||||
.object({
|
||||
query: z.string().trim().max(80).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const PhoneSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^1[3-9]\d{9}$/, "手机号格式不正确");
|
||||
|
||||
export const SettingKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9._-]+$/i, "设置 key 格式不正确");
|
||||
|
||||
export const SetSettingSchema = z.object({
|
||||
key: SettingKeySchema,
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
export const SavePhotoSchema = z.object({
|
||||
orderId: IdSchema,
|
||||
base64Data: z.string().min(1),
|
||||
});
|
||||
|
||||
export const NoInputSchema = z.undefined();
|
||||
|
||||
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
|
||||
export type UpsertCustomerInput = z.infer<typeof UpsertCustomerSchema>;
|
||||
export type PickupInput = z.infer<typeof PickupSchema>;
|
||||
export type SearchOrdersInput = z.infer<typeof SearchOrdersSchema>;
|
||||
export type CustomerSearchInput = z.infer<typeof CustomerSearchSchema>;
|
||||
export type PaymentMethod = z.infer<typeof PaymentMethodSchema>;
|
||||
export type ServiceType = z.infer<typeof ServiceTypeSchema>;
|
||||
14
tests/e2e/app-smoke.spec.ts
Normal file
14
tests/e2e/app-smoke.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { _electron as electron, expect, test } from "@playwright/test";
|
||||
|
||||
test("launches the desktop shell and renders M1 navigation", async () => {
|
||||
const app = await electron.launch({ args: ["."] });
|
||||
const page = await app.firstWindow();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "宏发洗衣店" })).toBeVisible();
|
||||
await expect(page.getByText("店长:周学胜")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /收件登记/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /取件查询/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /订单列表/ })).toBeVisible();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
340
tests/unit/services.test.ts
Normal file
340
tests/unit/services.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
import { createDbClient, type AppDb } from "@main/db";
|
||||
import { BackupService } from "@main/services/backupService";
|
||||
import { CustomerService } from "@main/services/customerService";
|
||||
import { OrderService } from "@main/services/orderService";
|
||||
import { PickupCodeService } from "@main/services/pickupCodeService";
|
||||
|
||||
describe("M1 services", () => {
|
||||
let sqlite: Database.Database;
|
||||
let db: AppDb;
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
db = createDbClient(sqlite);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("upserts customers by unique phone", async () => {
|
||||
const first = await CustomerService.upsertByPhone(
|
||||
"张三",
|
||||
"13800138000",
|
||||
db,
|
||||
);
|
||||
const second = await CustomerService.upsertByPhone(
|
||||
"张先生",
|
||||
"13800138000",
|
||||
db,
|
||||
);
|
||||
const customers = await db.query.customers.findMany();
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(customers).toHaveLength(1);
|
||||
expect(customers[0].name).toBe("张先生");
|
||||
});
|
||||
|
||||
it("generates four digit pickup codes including leading zeroes", () => {
|
||||
const code = PickupCodeService.generate(db);
|
||||
|
||||
expect(code).toMatch(/^\d{4}$/);
|
||||
});
|
||||
|
||||
it("creates orders transactionally and writes customer stats plus audit log", async () => {
|
||||
const customer = await CustomerService.upsertByPhone(
|
||||
"李四",
|
||||
"13900139000",
|
||||
db,
|
||||
);
|
||||
const order = OrderService.createOrder(
|
||||
{
|
||||
customerId: customer.id,
|
||||
items: [
|
||||
{
|
||||
itemType: "衬衫",
|
||||
serviceType: "wash",
|
||||
quantity: 2,
|
||||
unitPrice: 1500,
|
||||
},
|
||||
],
|
||||
totalAmount: 3000,
|
||||
paidAmount: 1000,
|
||||
paymentMethod: "cash",
|
||||
},
|
||||
db,
|
||||
);
|
||||
const updatedCustomer = await db.query.customers.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, customer.id),
|
||||
});
|
||||
const auditRows = await db.query.auditLog.findMany();
|
||||
|
||||
expect(order.orderNo).toMatch(/^\d{8}-\d{4}$/);
|
||||
expect(order.pickupCode).toMatch(/^\d{4}$/);
|
||||
expect(updatedCustomer?.totalOrders).toBe(1);
|
||||
expect(updatedCustomer?.totalSpent).toBe(3000);
|
||||
expect(auditRows).toHaveLength(1);
|
||||
expect(auditRows[0].action).toBe("create");
|
||||
});
|
||||
|
||||
it("rejects orders whose submitted total does not match item subtotals", async () => {
|
||||
const customer = await CustomerService.upsertByPhone(
|
||||
"王五",
|
||||
"13700137000",
|
||||
db,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
OrderService.createOrder(
|
||||
{
|
||||
customerId: customer.id,
|
||||
items: [
|
||||
{
|
||||
itemType: "西装",
|
||||
serviceType: "dry_clean",
|
||||
quantity: 1,
|
||||
unitPrice: 4500,
|
||||
},
|
||||
],
|
||||
totalAmount: 4400,
|
||||
paidAmount: 4400,
|
||||
paymentMethod: "cash",
|
||||
},
|
||||
db,
|
||||
),
|
||||
).toThrow("订单总额与明细小计不一致");
|
||||
|
||||
expect(await db.query.orders.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("searches pickup candidates by code, phone, order number, and customer name", async () => {
|
||||
const customer = await CustomerService.upsertByPhone(
|
||||
"赵六",
|
||||
"13600136000",
|
||||
db,
|
||||
);
|
||||
const order = OrderService.createOrder(
|
||||
{
|
||||
customerId: customer.id,
|
||||
items: [
|
||||
{
|
||||
itemType: "裤子",
|
||||
serviceType: "wash",
|
||||
quantity: 1,
|
||||
unitPrice: 1500,
|
||||
},
|
||||
],
|
||||
totalAmount: 1500,
|
||||
paidAmount: 1500,
|
||||
paymentMethod: "wechat",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
expect(OrderService.searchForPickup(order.pickupCode, db)[0].id).toBe(
|
||||
order.id,
|
||||
);
|
||||
expect(OrderService.searchForPickup("13600136000", db)[0].id).toBe(
|
||||
order.id,
|
||||
);
|
||||
expect(OrderService.searchForPickup(order.orderNo, db)[0].id).toBe(
|
||||
order.id,
|
||||
);
|
||||
expect(OrderService.searchForPickup("赵六", db)[0].id).toBe(order.id);
|
||||
});
|
||||
|
||||
it("requires balance settlement before pickup and writes audit log", async () => {
|
||||
const customer = await CustomerService.upsertByPhone(
|
||||
"钱七",
|
||||
"13500135000",
|
||||
db,
|
||||
);
|
||||
const order = OrderService.createOrder(
|
||||
{
|
||||
customerId: customer.id,
|
||||
items: [
|
||||
{
|
||||
itemType: "大衣",
|
||||
serviceType: "dry_clean",
|
||||
quantity: 1,
|
||||
unitPrice: 6000,
|
||||
},
|
||||
],
|
||||
totalAmount: 6000,
|
||||
paidAmount: 1000,
|
||||
paymentMethod: "cash",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
OrderService.pickup({ orderId: order.id, paidAmount: 1000 }, db),
|
||||
).toThrow("订单仍有欠款");
|
||||
|
||||
const pickedUp = OrderService.pickup(
|
||||
{ orderId: order.id, paidAmount: 5000 },
|
||||
db,
|
||||
);
|
||||
const auditRows = await db.query.auditLog.findMany();
|
||||
|
||||
expect(pickedUp?.status).toBe("picked_up");
|
||||
expect(pickedUp?.paidAmount).toBe(6000);
|
||||
expect(auditRows.map((row) => row.action)).toEqual(["create", "pickup"]);
|
||||
});
|
||||
|
||||
it("returns live dashboard stats instead of placeholder values", async () => {
|
||||
const todayCustomer = await CustomerService.upsertByPhone(
|
||||
"今日客户",
|
||||
"13300133000",
|
||||
db,
|
||||
);
|
||||
const overdueCustomer = await CustomerService.upsertByPhone(
|
||||
"逾期客户",
|
||||
"13200132000",
|
||||
db,
|
||||
);
|
||||
const pickedUpCustomer = await CustomerService.upsertByPhone(
|
||||
"已取客户",
|
||||
"13100131000",
|
||||
db,
|
||||
);
|
||||
|
||||
const dueToday = new Date();
|
||||
dueToday.setHours(18, 0, 0, 0);
|
||||
const overdueDate = new Date();
|
||||
overdueDate.setDate(overdueDate.getDate() - 1);
|
||||
overdueDate.setHours(18, 0, 0, 0);
|
||||
|
||||
const todayOrder = OrderService.createOrder(
|
||||
{
|
||||
customerId: todayCustomer.id,
|
||||
items: [
|
||||
{
|
||||
itemType: "衬衫",
|
||||
serviceType: "wash",
|
||||
quantity: 2,
|
||||
unitPrice: 1200,
|
||||
},
|
||||
],
|
||||
totalAmount: 2400,
|
||||
paidAmount: 2000,
|
||||
paymentMethod: "cash",
|
||||
expectedPickupDate: dueToday,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
OrderService.createOrder(
|
||||
{
|
||||
customerId: overdueCustomer.id,
|
||||
items: [
|
||||
{
|
||||
itemType: "大衣",
|
||||
serviceType: "dry_clean",
|
||||
quantity: 1,
|
||||
unitPrice: 5600,
|
||||
},
|
||||
],
|
||||
totalAmount: 5600,
|
||||
paidAmount: 5600,
|
||||
paymentMethod: "wechat",
|
||||
expectedPickupDate: overdueDate,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const pickedUpOrder = OrderService.createOrder(
|
||||
{
|
||||
customerId: pickedUpCustomer.id,
|
||||
items: [
|
||||
{
|
||||
itemType: "裤子",
|
||||
serviceType: "wash",
|
||||
quantity: 1,
|
||||
unitPrice: 1800,
|
||||
},
|
||||
],
|
||||
totalAmount: 1800,
|
||||
paidAmount: 1800,
|
||||
paymentMethod: "cash",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
OrderService.pickup({ orderId: pickedUpOrder.id, paidAmount: 0 }, db);
|
||||
|
||||
const stats = await OrderService.getStats(db);
|
||||
|
||||
expect(stats.todayCount).toBe(3);
|
||||
expect(stats.pendingCount).toBe(2);
|
||||
expect(stats.overdueCount).toBe(1);
|
||||
expect(stats.dueTodayCount).toBe(1);
|
||||
expect(stats.todayIncome).toBe(9400);
|
||||
expect(stats.monthCount).toBeGreaterThanOrEqual(3);
|
||||
expect(stats.chartData).toHaveLength(7);
|
||||
expect(stats.chartData.at(-1)?.count).toBeGreaterThanOrEqual(3);
|
||||
expect(todayOrder.pickupCode).toMatch(/^\d{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupService", () => {
|
||||
let tempDir: string;
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(join(os.tmpdir(), "laundry-desk-test-"));
|
||||
sqlite = new Database(join(tempDir, "laundry.db"));
|
||||
const db = createDbClient(sqlite);
|
||||
await CustomerService.upsertByPhone("备份客户", "13400134000", db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sqlite.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes backup zip atomically and lists it newest first", async () => {
|
||||
const backupDir = join(tempDir, "backups");
|
||||
const zipPath = await BackupService.performBackup({
|
||||
dbPath: join(tempDir, "laundry.db"),
|
||||
backupDir,
|
||||
sqlite,
|
||||
});
|
||||
const backups = BackupService.listBackups(backupDir);
|
||||
|
||||
expect(zipPath.endsWith(".zip")).toBe(true);
|
||||
expect(fs.existsSync(zipPath)).toBe(true);
|
||||
expect(
|
||||
fs.readdirSync(backupDir).some((file) => file.endsWith(".tmp")),
|
||||
).toBe(false);
|
||||
expect(backups).toHaveLength(1);
|
||||
expect(backups[0].path).toBe(zipPath);
|
||||
});
|
||||
|
||||
it("rotates backups and keeps the newest 30 files", () => {
|
||||
const backupDir = join(tempDir, "backups");
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
for (let index = 0; index < 35; index += 1) {
|
||||
const filePath = join(
|
||||
backupDir,
|
||||
`backup-2026-04-23-${index.toString().padStart(2, "0")}.zip`,
|
||||
);
|
||||
fs.writeFileSync(filePath, "zip");
|
||||
const time = new Date(2026, 3, 23, 3, index);
|
||||
fs.utimesSync(filePath, time, time);
|
||||
}
|
||||
|
||||
BackupService.rotateBackups(backupDir);
|
||||
const backups = BackupService.listBackups(backupDir);
|
||||
|
||||
expect(backups).toHaveLength(30);
|
||||
expect(backups[0].fileName).toBe("backup-2026-04-23-34.zip");
|
||||
expect(backups.at(-1)?.fileName).toBe("backup-2026-04-23-05.zip");
|
||||
});
|
||||
});
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
15
tsconfig.node.json
Normal file
15
tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "out/main",
|
||||
"rootDir": "src",
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "electron-vite/node"],
|
||||
"paths": {
|
||||
"@main/*": ["./src/main/*"],
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/main/**/*", "src/shared/**/*"]
|
||||
}
|
||||
14
tsconfig.web.json
Normal file
14
tsconfig.web.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "out/renderer",
|
||||
"rootDir": "src",
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": ["src/renderer/src/*"],
|
||||
"@shared/*": ["src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/renderer/**/*", "src/shared/**/*"]
|
||||
}
|
||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/unit/**/*.{test,spec}.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@main": resolve(__dirname, "./src/main"),
|
||||
"@shared": resolve(__dirname, "./src/shared"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user