chore: Storage 初版 — 家庭共享物品管理 (React+TS+Vite+Express+DuckDB)
Build and Deploy / build-image (push) Failing after 2m26s
Build and Deploy / build-image (push) Failing after 2m26s
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.git
|
||||
.gitea
|
||||
ota-package
|
||||
*.log
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Build and Deploy
|
||||
|
||||
# 每次主分支提交: 构建前端 + Docker 镜像 → 在 runner (baihu) 上重建容器自动部署
|
||||
# 端口 3003 (与 Wardrobe :3002 / iboard :3001 同机共存, 按部署环境分配, 见 部署环境.md)
|
||||
# 数据持久化: 具名卷 storage-data 挂载 /app/data (DuckDB + 图片), 重建容器不丢数据
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Typecheck & build
|
||||
run: |
|
||||
npm run check
|
||||
npm run build
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t storage:latest .
|
||||
|
||||
- name: Remove old container
|
||||
run: docker rm -vf storage || true
|
||||
|
||||
- name: Run container
|
||||
# 一键镜像: 静态前端 + API + 图片; 端口 3003
|
||||
run: docker run -d --name storage -p 3003:3003 \
|
||||
-v storage-data:/app/data \
|
||||
storage:latest
|
||||
|
||||
- name: Verify health
|
||||
run: |
|
||||
for i in $(seq 1 20); do
|
||||
if curl -sf http://127.0.0.1:3003/healthz >/dev/null 2>&1; then
|
||||
echo "storage healthy after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "ERROR: storage did not become healthy" >&2
|
||||
docker logs storage --tail 50 2>&1
|
||||
exit 1
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
*.local
|
||||
.DS_Store
|
||||
ota-package/
|
||||
@@ -0,0 +1,3 @@
|
||||
[tools]
|
||||
node = "24.21.0"
|
||||
rust = "stable"
|
||||
@@ -0,0 +1,427 @@
|
||||
# Storage 个人物品管理 App —— 设计文档
|
||||
|
||||
> 版本:v0.1(草稿)
|
||||
> 日期:2026-09
|
||||
> 关联:REQUIREMENTS.md
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
沿用 Wardrobe 已验证的 **三层薄客户端架构**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 客户端(Tauri 2 壳 + React SPA) │
|
||||
│ pages / stores(Zustand) / api(fetch) / types │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│ HTTP REST (Bearer Token)
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ 服务端(Express Node 22) │
|
||||
│ routes/app.js → 校验 + 权限(家庭角色) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ repository(server/db.js)DuckDB DAO + 迁移 │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│ 单机上写串行化(enqueue)
|
||||
┌──────────────────────▼───────────────────────┐
|
||||
│ DuckDB 文件(data/storage.duckdb) │
|
||||
│ 用户/会话/家庭/成员/盒子/物品/图片/日志 │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 客户端无本地数据库,仅缓存当前数据到 `localStorage`(沿用 Wardrobe 的 `RouterGate` 恢复方案)。
|
||||
- 服务端为唯一事实来源;所有写操作经家庭角色权限校验。
|
||||
- 客户端 UI 仅做展示层隐藏/禁用,不承载鉴权。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|---|---|---|
|
||||
| 前端 | React 18 + TypeScript 5 + Vite 5 | 沿用 Wardrobe |
|
||||
| 状态管理 | Zustand | stores/family.ts、stores/data.ts 等 |
|
||||
| 路由 | React Router v6 | `/home` `/boxes` `/items` `/families` `/profile` |
|
||||
| 桌面壳 | Tauri 2(Rust),hotswap 热更新可选 | 沿用 Wardrobe |
|
||||
| 后端 | Express 4(Node ≥ 18,`--experimental-strip-types`) | 沿用 |
|
||||
| 数据库 | DuckDB(@duckdb/node-api)单写串行化 | 沿用 `enqueue` |
|
||||
| 图片 | 本地文件存储 + 鉴权读取代理 | 新建(Wardrobe 未实现) |
|
||||
| 构建部署 | 单 Docker 镜像 + Gitea Actions | 沿用 Wardrobe |
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
D:\workbench\Storage\
|
||||
├── index.html / vite.config.ts / tsconfig.json / package.json
|
||||
├── REQUIREMENTS.md / DESIGN.md
|
||||
├── Dockerfile / .mise.toml
|
||||
├── scripts/ # 构建、签名、上传(沿用)
|
||||
├── server/
|
||||
│ ├── index.js # 启动、静态、SPA fallback
|
||||
│ ├── db.js # 连接 + 迁移 + DAO + MCP
|
||||
│ ├── auth.js # 验证码/会话
|
||||
│ └── routes/
|
||||
│ ├── app.js # 业务 REST API
|
||||
│ ├── images.js # 图片上传/读取(鉴权)
|
||||
│ └── ota.js # 热更新(沿用)
|
||||
│ └── data/uploads/ # 图片文件(数据卷)
|
||||
├── src-tauri/ # Tauri 壳(沿用)
|
||||
└── src/
|
||||
├── main.tsx / App.tsx / version.ts
|
||||
├── api/ # config.ts / index.ts(类型化 fetch)/ images.ts
|
||||
├── stores/ # auth / family / boxes / items(Zustand)
|
||||
├── types/index.ts # 全部领域类型(单一来源,避免 Wardrobe 重复定义问题)
|
||||
├── components/ # Layout, Breadcrumb, BoxCanvas, BoxNode, ItemCard, FamilySwitcher...
|
||||
├── pages/ # Home, Boxes(树+画布), BoxDetail, ItemList, ItemDetail, ItemEdit, FamilyManage, Profile
|
||||
└── styles/global.css # 设计令牌(沿用 Wardrobe 暖色系)
|
||||
```
|
||||
|
||||
> 改进点(相对 Wardrobe):类型集中到 `src/types/index.ts`,服务端用 `server/types.ts` 与之一致;新增正式迁移机制 §5。
|
||||
|
||||
---
|
||||
|
||||
## 4. 领域模型(ER)
|
||||
|
||||
```
|
||||
users ──< family_members >── families
|
||||
family_id 归属 │
|
||||
┌───────────────────────┼─────────────────────┐
|
||||
▼ ▼ ▼
|
||||
categories boxes ──<parent── boxes items
|
||||
(家庭自定义分类) (无限嵌套盒子) (物品, box_id 可空→根层)
|
||||
```
|
||||
|
||||
关键概念:
|
||||
|
||||
- **家庭根层**:不是一张表,而是 `box_id IS NULL` 的语义条件(物品直接挂在家庭根层;没有父盒子的盒子视为根层直接子盒)。
|
||||
- **盒子树**:一套 `boxes` 树(一个家庭 = 一棵树),`parent_box_id` 指向父盒,根节点「隐形」就是家庭本身。
|
||||
- **位置**:每个盒子在父容器内有一个归一化坐标 `x,y ∈ [0,1000]`,父容器内容区为坐标系;`w/h` 预留尺寸(v1 可为 NULL,自动尺寸)。
|
||||
- **权限**:角色存于 `family_members.role`,写接口在 `requireFamilyAdmin` 中间件中校验。
|
||||
|
||||
### 4.1 表结构(DDL,DuckDB 语法)
|
||||
|
||||
```sql
|
||||
-- 迁移表:storage.db.migrations 记录版本
|
||||
CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at VARCHAR NOT NULL);
|
||||
|
||||
CREATE TABLE users (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
email VARCHAR UNIQUE, phone VARCHAR UNIQUE,
|
||||
nickname VARCHAR NOT NULL DEFAULT '',
|
||||
avatar_path VARCHAR,
|
||||
created_at VARCHAR NOT NULL);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token VARCHAR PRIMARY KEY,
|
||||
user_id VARCHAR NOT NULL,
|
||||
created_at VARCHAR NOT NULL);
|
||||
|
||||
CREATE TABLE families (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
icon VARCHAR DEFAULT '🏠',
|
||||
owner_user_id VARCHAR NOT NULL, -- 创建者(超级管理员)
|
||||
invite_code VARCHAR UNIQUE,
|
||||
created_at VARCHAR NOT NULL,
|
||||
updated_at VARCHAR NOT NULL);
|
||||
|
||||
CREATE TABLE family_members (
|
||||
family_id VARCHAR NOT NULL,
|
||||
user_id VARCHAR NOT NULL,
|
||||
role VARCHAR NOT NULL CHECK (role IN ('admin','member')),
|
||||
joined_at VARCHAR NOT NULL,
|
||||
PRIMARY KEY (family_id, user_id));
|
||||
|
||||
CREATE TABLE categories (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
family_id VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
sort INTEGER DEFAULT 0,
|
||||
created_at VARCHAR NOT NULL);
|
||||
|
||||
CREATE TABLE boxes (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
family_id VARCHAR NOT NULL,
|
||||
parent_box_id VARCHAR, -- NULL = 家庭根层直接子盒
|
||||
name VARCHAR NOT NULL,
|
||||
icon VARCHAR DEFAULT '📦',
|
||||
color VARCHAR DEFAULT '#E8C4A0',
|
||||
pos_x INTEGER DEFAULT 0 CHECK (pos_x BETWEEN 0 AND 1000),
|
||||
pos_y INTEGER DEFAULT 0 CHECK (pos_y BETWEEN 0 AND 1000),
|
||||
width INTEGER, -- 预留:归一化尺寸(v1 NULL=自动)
|
||||
height INTEGER,
|
||||
created_at VARCHAR NOT NULL,
|
||||
updated_at VARCHAR NOT NULL);
|
||||
|
||||
CREATE TABLE items (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
family_id VARCHAR NOT NULL,
|
||||
box_id VARCHAR, -- NULL = 家庭根层
|
||||
name VARCHAR NOT NULL,
|
||||
category_id VARCHAR,
|
||||
tags VARCHAR DEFAULT '[]', -- JSON 数组字符串
|
||||
quantity INTEGER DEFAULT 1,
|
||||
brand VARCHAR, model VARCHAR, serial_no VARCHAR,
|
||||
purchase_date VARCHAR, purchase_price DOUBLE,
|
||||
expiry_date VARCHAR, -- NULL 表示无期限
|
||||
images VARCHAR DEFAULT '[]', -- JSON 数组:文件相对路径
|
||||
notes VARCHAR,
|
||||
is_favorite BOOLEAN DEFAULT FALSE,
|
||||
used_count INTEGER DEFAULT 0, last_used VARCHAR,
|
||||
created_at VARCHAR NOT NULL, updated_at VARCHAR NOT NULL);
|
||||
|
||||
-- 审计:跨家庭转移日志
|
||||
CREATE TABLE transfer_logs (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
entity_type VARCHAR NOT NULL, -- 'item' | 'box'
|
||||
entity_id VARCHAR NOT NULL,
|
||||
src_family_id VARCHAR NOT NULL,
|
||||
dst_family_id VARCHAR NOT NULL,
|
||||
operator_user_id VARCHAR NOT NULL,
|
||||
created_at VARCHAR NOT NULL);
|
||||
|
||||
CREATE INDEX idx_members_user ON family_members(user_id);
|
||||
CREATE INDEX idx_boxes_family ON boxes(family_id);
|
||||
CREATE INDEX idx_boxes_parent ON boxes(parent_box_id);
|
||||
CREATE INDEX idx_items_family ON items(family_id);
|
||||
CREATE INDEX idx_items_box ON items(box_id);
|
||||
CREATE INDEX idx_items_family_box ON items(family_id, box_id);
|
||||
```
|
||||
|
||||
### 4.2 ID 与时间
|
||||
|
||||
- ID:`crypto.randomUUID()`(沿用)。
|
||||
- 时间:UTC ISO 字符串,前端本地化展示。
|
||||
- `tags` / `images`:JSON 数组字符串;标签筛选用 `list_contains(string_split(tags, ','), ?)`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据迁移机制
|
||||
|
||||
区别于 Wardrobe(裸 `CREATE TABLE IF NOT EXISTS`),Storage 引入版本化迁移:
|
||||
|
||||
- `server/db.js` 启动时读取 `schema_version` 最大版本,按序执行 `MIGRATIONS[curVersion+1...]`(每个迁移为 `{version, up(conn)}`),逐条提交并更新版本号。
|
||||
- 迁移内容只增不改:任何 DDL/DML 变更都以新版本追加。
|
||||
- 幂等:`CREATE TABLE` 仍带 `IF NOT EXISTS`,INSERT 幂等;失败则中止启动并告警。
|
||||
|
||||
---
|
||||
|
||||
## 6. API 设计(REST)
|
||||
|
||||
沿用 Wardrobe 的 `request<T>` fetch 封装与 Bearer Token。所有写接口经 `requireFamilyAdmin(familyId)` 校验角色(管理员);读取接口 `requireFamilyMember` 校验成员身份。
|
||||
|
||||
### 6.1 路由总表
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/auth/send-code` `/register` `/login` `/logout` | 公开 | 沿用 Wardrobe |
|
||||
| GET | `/api/auth/me` | 登录 | |
|
||||
| GET | `/api/families` | 登录 | 我加入的家庭列表 |
|
||||
| POST | `/api/families` | 登录 | 创建家庭(成为管理员) |
|
||||
| POST | `/api/families/:id/join` | 登录 | 凭 invite_code 加入 |
|
||||
| GET | `/api/families/:id` | 成员 | 家庭详情 + 成员列表 |
|
||||
| PUT | `/api/families/:id` `DELETE` | 管理员/创建者 | 改名;解散 |
|
||||
| POST | `/api/families/:id/invite` | 管理员 | 生成/刷新/失效邀请码 |
|
||||
| POST | `/api/families/:id/members/:userId/role` | 管理员 | 改角色 admin/member |
|
||||
| DELETE | `/api/families/:id/members/:userId` | 管理员 | 移除成员;离开家庭为 `DELETE /members/me` |
|
||||
| GET | `/api/families/:id/categories` | 成员 | |
|
||||
| POST | `/api/families/:id/categories` | 管理员 | |
|
||||
| PUT/DELETE | `/api/families/:id/categories/:catId` | 管理员 | |
|
||||
| GET | `/api/families/:id/boxes?parent=null\|:boxId` | 成员 | 某容器的直接子盒(根层=parent 缺省) |
|
||||
| GET | `/api/families/:id/boxes/:boxId` | 成员 | 盒子详情 + 面包屑路径 |
|
||||
| POST | `/api/families/:id/boxes` | 管理员 | 新建盒子(parent_box_id + 位置) |
|
||||
| PUT | `/api/families/:id/boxes/:boxId` | 管理员 | 改名/图标/颜色 |
|
||||
| DELETE | `/api/families/:id/boxes/:boxId` | 管理员 | 删除(含子树,二次确认) |
|
||||
| PUT | `/api/families/:id/boxes/:boxId/move` | 管理员 | `{parent_box_id, pos_x, pos_y}` 整盒移动(跨容器) |
|
||||
| PUT | `/api/families/:id/boxes/:boxId/position` | 管理员 | 仅调整位置(拖拽保存) |
|
||||
| GET | `/api/families/:id/items?box=...&category=...&tag=...&q=&favorite=&expiring=` | 成员 | 列表/搜索/筛选;`box` 支持递归子树 |
|
||||
| POST | `/api/families/:id/items` | 管理员 | |
|
||||
| GET | `/api/families/:id/items/:itemId` | 成员 | 含所在路径 |
|
||||
| PUT | `/api/families/:id/items/:itemId` | 管理员 | 全字段更新 |
|
||||
| DELETE | `/api/families/:id/items/:itemId` | 管理员 | |
|
||||
| PUT | `/api/families/:id/items/:itemId/move` | 管理员 | `{box_id\|null}` 移动盒/根层 |
|
||||
| POST | `/api/families/:id/transfer` | 管理员 | `{type:'item'\|'box', entityId, dstFamilyId, dstParentBoxId?, pos?}` 跨家庭转移 |
|
||||
| GET | `/api/families/:id/stats` | 成员 | 概览统计 |
|
||||
| POST | `/api/images` | 登录 | multipart 上传,返回路径 |
|
||||
| GET | `/api/images/*` | 成员 | 鉴权读取(校验所在家庭成员) |
|
||||
| GET | `/api/transfer-logs?familyId=` | 管理员 | P1 |
|
||||
| GET/POST | `/api/mcp/tools` `/call` | 登录 | P1,沿用 Wardrobe |
|
||||
|
||||
### 6.2 盒子树读取与递归
|
||||
|
||||
- **单容器展开**(懒加载):`GET boxes?parent=X` 只返回直接子盒,前端按需展开——避免一次拉整棵树。
|
||||
- **全树**(用于画布俯视/统计):`GET boxes?tree=1`,服务端用递归 CTE 一次性返回子树(深度 <100 校验防环)。
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE subtree AS (
|
||||
SELECT id, parent_box_id, name FROM boxes WHERE id = ? AND family_id = ?
|
||||
UNION ALL
|
||||
SELECT b.id, b.parent_box_id, b.name
|
||||
FROM boxes b JOIN subtree s ON b.parent_box_id = s.id
|
||||
WHERE b.family_id = ?
|
||||
)
|
||||
SELECT * FROM subtree;
|
||||
```
|
||||
|
||||
- **子树的物品计数 / 汇总**:`items JOIN subtree` 聚合,一个查询得到整棵子树物品数与价值合计。
|
||||
|
||||
### 6.3 权限校验(中间件)
|
||||
|
||||
```js
|
||||
async function requireFamilyMember(req, res, next) {
|
||||
// 1) 认证会话 → req.user
|
||||
// 2) 查 family_members → 非成员 403
|
||||
}
|
||||
async function requireFamilyAdmin(familyId) {
|
||||
// 成员且 role==='admin',否则 403
|
||||
}
|
||||
```
|
||||
|
||||
- `requireFamilyMember` 用于读取/列表/搜索;`requireFamilyAdmin` 用于一切写操作。
|
||||
- `transfer` 需同时满足:操作者是 **源家庭管理员**,且是 **目标家庭成员**(防越权散落)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 盒子位置模型(画布)
|
||||
|
||||
### 7.1 坐标系
|
||||
|
||||
- 每个父容器(父盒子或家庭根层)的内容区 = 一张 **归一化虚拟画布**,坐标 `x,y ∈ [0,1000]`(整数)。
|
||||
- 子盒子渲染为其内部的一张方块,方块尺寸 v1 取「自动」(如 根据内容大小 / 固定网格),`width/height` 字段为预留尺寸。
|
||||
- 位置允许重叠;「自动整理」按钮将同一容器的直接子盒按蛇形序、以统一网格间距重新分配坐标(P1)。
|
||||
|
||||
### 7.2 交互
|
||||
|
||||
- **画布视图**:`BoxCanvas` 将父容器绘制为比例画布,子盒为绝对定位 `div`(`left=x/1000*100%`),支持拖拽更新 `pos_x/pos_y`;松手后 `PUT /boxes/:id/position` 保存。
|
||||
- **进入多层**:点击子盒 → 进入其内部(面包屑 `家庭 / 客厅 / 柜子1 / 收纳盒x`);根层面包屑第一项即家庭。
|
||||
- **列表视图**:同容器内容按位置/创建时间扁平排列,与画布视图同一数据源、可自由切换。
|
||||
- **整盒移动**:画布/列表中选择「移动到…」,弹窗选择目标容器(懒加载树选择器)→ `PUT /boxes/:id/move`(同时提交目标容器内新位置)。
|
||||
|
||||
### 7.3 约束
|
||||
|
||||
- `parent_box_id` 不允许设置成自身或自身后代(防环):服务端在 move 时先递归校验。
|
||||
- 移动/转移后清空并重载当前容器缓存。
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键业务流程
|
||||
|
||||
### 8.1 跨家庭转移(整盒含物品)
|
||||
|
||||
```
|
||||
1) 管理员对 box B(或 item I)发起 transfer,目标家庭 F2,目标容器 parent(可选)
|
||||
2) 服务端校验:源家庭管理员 + 目标家庭成员
|
||||
3) 防环校验(如为盒子)
|
||||
4) 事务中:递归取出 B 子树;源框 items 与 boxes 全部重写 family_id=F2;
|
||||
根盒置位 parent(或 F2 根层)+ 新位置;物品同步迁移
|
||||
5) 写 transfer_logs 审计
|
||||
6) 返回新实体;客户端切换当前家庭后可见
|
||||
```
|
||||
|
||||
### 8.2 权限矩阵
|
||||
|
||||
| 操作 | 管理员 | 成员 |
|
||||
|---|---|---|
|
||||
| 查看家成员/盒子树/物品/搜索/统计 | ✅ | ✅ |
|
||||
| 新建/编辑/删除 盒子、物品、分类 | ✅ | ❌ 403 |
|
||||
| 整盒移动、物品移动 | ✅ | ❌ |
|
||||
| 邀请/移员/改角色 | ✅ | ❌ |
|
||||
| 跨家庭转移 | ✅(需为目标家成员) | ❌ |
|
||||
| 解散家庭 | 仅创建者 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 9. 前端设计
|
||||
|
||||
### 9.1 状态(Zustand)
|
||||
|
||||
- `stores/auth.ts`:账号/验证码表单(沿用)。
|
||||
- `stores/app.ts`:`user`, `token`, `currentFamilyId`, `families[]`, `boxesById`, `items[]`, `stats`, `breadcrumb[]`;`logout` 清空;`RouterGate` 持久化到 `localStorage["storage_user"]`。
|
||||
- 数据加载沿用「页面 useEffect 拉取写 store」模式;切换家庭时清空该家庭缓存。
|
||||
|
||||
### 9.2 页面与导航
|
||||
|
||||
- 底部导航 5 项:**首页** · **盒子**(树+画布)· **+**(新增物品 FAB)· **物品**(列表/搜索)· **我的**(家庭管理/退出)。
|
||||
- 顶部:家庭切换器(下拉)— 切换即重载全部数据。
|
||||
- 页面:`Home.tsx`(统计+待办)、`Boxes.tsx`(树+画布俯视)、`BoxDetail.tsx`(面包屑+子盒+物品)、`ItemList.tsx`、`ItemDetail.tsx`、`ItemEdit.tsx`、`FamilyManage.tsx`(成员/邀请码/分类)、`Profile.tsx`。
|
||||
|
||||
### 9.3 关键组件
|
||||
|
||||
- `BoxCanvas`:归一化画布 + 绝对定位子盒 + 拖拽(pointer events)+ 自动整理。
|
||||
- `BoxTreePicker`:懒加载树选择器(选择目标容器)。
|
||||
- `Breadcrumb`:位置路径导航。
|
||||
- `FamilySwitcher`:当前家庭下拉。
|
||||
- `PermissionGuard`:按 `role` 隐藏/禁用写操作控件(仅展示层,服务端仍校验)。
|
||||
|
||||
### 9.4 图片
|
||||
|
||||
- 上传:`POST /api/images`(multipart)→ 存 `server/data/uploads/<uuid>.<ext>`,返回相对路径。
|
||||
- 读取:`GET /api/images/<uuid>.<ext>`,中间件解析 Token 并校验当前用户为文件所属家庭(文件名前缀含 family_id 便于校验,或读库校验)成员。
|
||||
- 前端:`<img>` 直接引用 `/api/images/...`(带 Token 的 header 方式对 `<img>` 不便 → 用 fetch 转 blob 或 cookie 会话,二选一,见 §11 风险)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 统计实现(首页)
|
||||
|
||||
沿用 Wardrobe 的服务器端 SQL 聚合思路(`GET /stats`):
|
||||
|
||||
```sql
|
||||
-- 总物品数(当前家庭)
|
||||
SELECT COUNT(*) FROM items WHERE family_id = ?;
|
||||
-- 分类分布
|
||||
SELECT COALESCE(c.name,'未分类'), COUNT(i.id)
|
||||
FROM periods... -- 简略:LEFT JOIN categories c, GROUP BY 零填充 6 项 → 交给前端补零
|
||||
-- 盒子占用 Top N(含嵌套子盒物品数,递归 CTE)
|
||||
-- 临期提醒:expiry_date BETWEEN today AND today+30
|
||||
-- 深度:递归 CTE 求 family_id 下每盒子最大 depth,取 MAX
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 安全、并发与风险
|
||||
|
||||
| 主题 | 方案 |
|
||||
|---|---|
|
||||
| 权限 | 服务端 `requireFamilyMember/Admin` 为唯一权威;客户端仅隐藏 UI |
|
||||
| 防环 | move/transfer 前递归校验目标不得为自身或后代 |
|
||||
| 并发写 | DuckDB 单写 → 全局 `enqueue` 串行化(沿用);事务包裹转移 |
|
||||
| 图片鉴权 | 经 `/api/images` 代理读取,未授权 403;不上传落地任何静态目录 |
|
||||
| 会话 | Bearer Token + 服务端 sessions 表,可撤销 |
|
||||
| 验证码 | 开发=固定码 1111;预留 provider 抽象,上线换真实短信 |
|
||||
| Token 与 `<img>` | 若 header 不便,改用 SSE 前先采用「短时签名 URL」或 cookie;评审时定 |
|
||||
| 迁移失败 | 启动中止 + 明确报错,禁止隐式降级 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 迁移与部署
|
||||
|
||||
- **本地开发**:`npm run dev`(Vite:1420)→ `npm run tauri dev`。
|
||||
- **产物**:`vite-plugin-singlefile` 单文件 + 服务端;`docker build` 单镜像 `node:22-slim`,`DB_PATH` 默认 `./data/storage.duckdb`(数据卷 `storage-data`),图片存在 `data/uploads`(同卷),端口沿用习惯(建议 3002 或另定)。
|
||||
- **CI**:沿用 Wardrobe 的 Gitea Actions —— `docker-build.yml`(主分支)、`android-build.yml`(APK 签名发布)、可选 `ota-build.yml`(hotswap 热更新,P1 接入)。
|
||||
- **OEM 差异**:首发可只做 Web + Docker + Android APK,桌面壳作为增量。
|
||||
|
||||
---
|
||||
|
||||
## 13. 分阶段实施计划
|
||||
|
||||
| 阶段 | 内容 | 交付物 |
|
||||
|---|---|---|
|
||||
| M1 | 骨架:auth、家庭创建/切换、`schema_version` 迁移、types 集中 | 登录 + 家庭空壳可运行 |
|
||||
| M2 | 盒子树 + 画布位置 + 面包屑;物品 CRUD + 移动 | 核心收纳闭环 |
|
||||
| M3 | 搜索/筛选/定位;分类/标签;图片上传鉴权 | 日常可用 |
|
||||
| M4 | 权限矩阵(admin/member 中间件);跨家庭转移 + 审计 | 家庭协作闭环 |
|
||||
| M5 | 统计首页;邀请码/成员管理;MCP;打包部署(Docker/APK) | v0.1 发布 |
|
||||
| P1+ | 操作日志 UI、导出、备份、真实短信、使用记录 | 迭代 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 待评审决策点
|
||||
|
||||
1. 图片鉴权采用短时签名 URL。
|
||||
2. 目标家庭转移权限仅能转移到自己拥有管理员的家庭。
|
||||
3. 盒子尺寸(width/height)支持自定义。
|
||||
4. 端口根据部署环境分配。
|
||||
5. 部署环境请参考部署环境.md文件
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# ===============================================================
|
||||
# Storage 一体化镜像: 静态前端 (dist/) + Express/DuckDB 服务端 (server/) 同进程
|
||||
# 构建: docker build -t storage:latest .
|
||||
# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址 (默认同源 /api)
|
||||
# 运行: docker run -d --name storage -p 3003:3003 -v storage-data:/app/data storage:latest
|
||||
# 端口: 与 Wardrobe(:3002) / iboard(:3001) 同机共存, 按部署环境分配 (见 部署环境.md)
|
||||
# 数据: DuckDB (data/storage.duckdb) + 图片 (data/uploads) 都在 /app/data 卷, 部署不丢数据
|
||||
# ===============================================================
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 国内构建时可换源
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 先拷贝 package 文件, 利用 Docker 缓存
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
# 拷贝源码
|
||||
COPY . .
|
||||
|
||||
# 构建前端 (输出到 dist/); 留空 = 同源 /api
|
||||
ARG VITE_API_BASE=
|
||||
ENV VITE_API_BASE=$VITE_API_BASE
|
||||
RUN npm run build
|
||||
|
||||
# 运行时阶段 (slim: @duckdb/node-api 用 glibc 预编译, 不能用 alpine musl)
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 只装运行时依赖
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# 业务数据目录: DuckDB + 图片上传, 运行时挂 -v storage-data:/app/data 持久化
|
||||
RUN mkdir -p /app/data
|
||||
ENV DB_PATH=/app/data/storage.duckdb
|
||||
ENV UPLOAD_DIR=/app/data/uploads
|
||||
|
||||
# 拷贝构建产物 (server/ 为纯 JS, 直接 node 运行, 无需 strip-types)
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/server ./server
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3003/healthz || exit 1
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3003
|
||||
EXPOSE 3003
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Storage 个人物品管理 App —— 需求文档
|
||||
|
||||
> 版本:v0.1(草稿)
|
||||
> 日期:2026-09
|
||||
> 状态:待评审
|
||||
> 参考实现:D:\workbench\Wardrobe(Tauri 2 + React + TS + Express + DuckDB 架构)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目背景与目标
|
||||
|
||||
Wardrobe 是一款只管理「衣服」的衣柜 App,其记录、整理、统计分析的能力已被验证。
|
||||
Storage 将沿用 Wardrobe 的架构与产品理念,但把管理对象从「衣服」扩展到「个人所有物品」。
|
||||
|
||||
典型使用场景:
|
||||
|
||||
- 搬进新家后,把客厅、卧室、厨房的每件物品登记下来,想找东西时快速定位「在哪个柜子的哪个收纳盒里」。
|
||||
- 给每一件物品拍照、打标签、记录购买信息与保质期,临期自动提醒。
|
||||
- 全家共用一份物品账本,谁都想把东西登记进去,但只有管理员能改,避免误删。
|
||||
- 把物品从「A 家庭(如父母家)」搬到「B 家庭(自己的家)」,盒子整体搬走。
|
||||
|
||||
### 1.1 与 Wardrobe 的核心差异
|
||||
|
||||
| 维度 | Wardrobe | Storage |
|
||||
|---|---|---|
|
||||
| 管理对象 | 仅衣物 | 所有物品 |
|
||||
| 归属单位 | 个人(user_id 隔离) | 家庭(family_id 隔离,家庭内共享) |
|
||||
| 收纳体系 | 无(物品平铺) | 无限层级的「盒子」树 + 盒内相对位置 |
|
||||
| 分类方式 | 固定 6 类枚举 | 家庭可自定义分类 + 自由标签 |
|
||||
| 权限 | 仅本人 | 管理员(可改)/ 成员(只读) |
|
||||
| 跨域移动 | 不适用 | 物品与盒子可整体跨家庭转移 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 产品定位与范围
|
||||
|
||||
- **形态**:个人/家庭物品台账 + 收纳可视化定位系统。
|
||||
- **核心价值**:(1) 分类、定位、一目了然;(2) 家庭共享协作;(3) 物品生命周期记录(购买、保质期、保养)。
|
||||
- **范围外**(本期不包含):
|
||||
- 购物、求购、二手交易等电商功能;
|
||||
- 相机的 AR 识别、AI 自动分类(预留接口,不作为本期承诺);
|
||||
- 物品维修/送洗的线下对接;
|
||||
- 多端实时协同编辑(采用服务端为准、前端请求式刷新,不做 CRDT)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 术语定义
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| 家庭(Family) | 一组用户共享的协作空间,是所有物品、盒子、分类的归属单位。物品与盒子在家庭内共享。 |
|
||||
| 家庭根层(Root) | 家庭内除盒子外的「顶层空间」。物品可以散放在根层,不必须放入盒子。 |
|
||||
| 盒子(Box) | 收纳容器,可无限嵌套。盒子从属一个家庭,可整体移动/转移。 |
|
||||
| 子盒(Sub-Box) | 某盒子内部的下一层盒子。 |
|
||||
| 位置(Position) | 盒子在「父容器」内部的相对坐标(归一化 0~1000),用于将其绘制为父容器内的一块方块。 |
|
||||
| 物品(Item) | 被管理的具体物件。强绑定一个家庭;可放入任意盒子,也可只在根层。 |
|
||||
| 管理员 / 成员 | 家庭内的两种角色。管理员可修改,成员只读。 |
|
||||
| 跨家庭转移(Transfer) | 将物品或整个盒子(含全部子盒与物品)从 A 家庭移动到 B 家庭。 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户与权限
|
||||
|
||||
### 4.1 账号体系
|
||||
|
||||
- 沿用 Wardrobe:手机号/邮箱 + 验证码登录,Bearer Token 会话(`/api/auth/*`)。
|
||||
- 验证码:开发期使用固定码 `1111`;上线前接入真实短信/邮件通道(见非功能需求)。
|
||||
- 一个用户可属于 **多个家庭**,任意时刻「当前家庭」为全局上下文。
|
||||
|
||||
### 4.2 家庭
|
||||
|
||||
- 用户可 **创建家庭**,创建者为「创建者」并自动成为管理员。
|
||||
- 用户可 **加入已有家庭**:通过邀请码或管理员邀请(先做邀请码)。
|
||||
- 用户可 **离开家庭** / 被移除;**创建者可解散家庭**。
|
||||
- 家庭名、家庭头像(首页家庭切换器展示)。
|
||||
|
||||
### 4.3 角色与权限(关键规则)
|
||||
|
||||
- 角色仅两级且不限数量:
|
||||
- **管理员**:可增/删/改家庭内所有物品、盒子、分类;可管理成员(邀请、移除、改角色);可把物品/盒子转移到其他家庭;可解散家庭(仅创建者)。
|
||||
- **成员**:**只读**。可查看、搜索、筛选、进入盒子查看;不可新增/编辑/删除/转移任何数据。
|
||||
- 权限 **以服务端校验为准**(每个写接口按 family 角色校验),前端仅做展示层隐藏/禁用。
|
||||
- 创建者为「超级管理员」,不可被降级或移除;可转让主持权(P2)。
|
||||
- 成员操作越权时返回 `403`,前端给出「无权限」提示。
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能需求
|
||||
|
||||
优先级标记:`P0` 首版必须 / `P1` 次版 / `P2` 远期可选。
|
||||
|
||||
### 5.1 家庭管理(P0)
|
||||
|
||||
- FR-家庭-1 创建/加入/离开/解散家庭。
|
||||
- FR-家庭-2 家庭切换器:全局顶部展示当前家庭,点击切换,切换后所有数据按新家庭重载。
|
||||
- FR-家庭-3 成员列表与角色管理(管理员操作)。
|
||||
- FR-家庭-4 邀请码:生成/刷新/失效,凭码加入即成为成员。
|
||||
- FR-家庭-5 各家庭数据完全隔离:列表、搜索、统计均只返回当前家庭数据。
|
||||
|
||||
### 5.2 盒子与收纳(P0)
|
||||
|
||||
- FR-盒-1 **无限层级嵌套**:盒子内可再建盒子,深度不设上限(如 客厅 → 柜子1 → 收纳盒x → 内胆)。
|
||||
- FR-盒-2 盒子的基本属性:名称、图标(emoji)、颜色(自由自定义,可重复)。
|
||||
- FR-盒-3 **相对位置**:每个盒子在其「父容器」(父盒子或家庭根层)内拥有一个归一化坐标 (x, y),渲染为父容器内的一块方块(如把「柜子1」画在「客厅」左上角)。
|
||||
- FR-盒-4 进入/退出盒子:通过面包屑导航(家庭 / 客厅 / 柜子1 / 收纳盒x),逐层查看。
|
||||
- FR-盒-5 盒子内两种视图:**画布视图**(俯视布局,方块按位置摆放,可拖拽改位置)与 **列表视图**(按创建顺序平铺,适合快速浏览)。切换自由。
|
||||
- FR-盒-6 盒子的增删改、改名;删除盒子时要求确认(提示将删除其中全部物品,P2 提供「合并到上级」选项)。
|
||||
- FR-盒-7 **整盒移动**:将某盒子(含子树)拖拽/移至另一盒子的内部,或直接移到家庭根层;位置在目标容器内重新指定。
|
||||
- FR-盒-8 盒子的嵌套统计:每个盒子显示「物品总数(含嵌套)」与「深度」。
|
||||
- FR-盒-9 画布中允许方块重叠,位置由用户自由摆放;提供「自动整理」按钮(按蛇形序排布,P1)。
|
||||
|
||||
### 5.3 物品管理(P0)
|
||||
|
||||
- FR-物-1 新增/编辑/删除物品,字段见「物品字段定义」。
|
||||
- FR-物-2 物品必须属于某家庭;可放入盒子(box_id)或散放在家庭根层(box_id 为空)。
|
||||
- FR-物-3 物品从一盒移至另一盒 / 移至根层。
|
||||
- FR-物-4 物品图片:每个物品 0~9 张;拍照与相册上传(桌面/移动端);详情页缩略图 + 大图查看。
|
||||
- FR-物-5 搜索与筛选(P0):
|
||||
- 关键字:名称、品牌、型号、备注;
|
||||
- 筛选:分类、标签、所在盒子(含子树)、是否收藏、数量>0、临期提醒;
|
||||
- 结果可一键「定位所在位置」(逐层路径,如 客厅>柜子1>收纳盒x)。
|
||||
- FR-物-6 收藏夹(P0),常用物品置顶。
|
||||
- FR-物-7 物品生命周期:数量、购买日期/价格、保质期(临期提醒)、开始使用/最近使用(P1)、保养/清洁备注(P1)、借出记录(P2)。
|
||||
|
||||
### 5.4 分类与标签(P0)
|
||||
|
||||
- FR-类-1 分类为家庭内可自定义列表(管理所有东西,无法预置固定枚举)。允许用户自建分类层级(P1 支持两级,P0 平铺列表即可)。
|
||||
- FR-类-2 标签(tags):自由多选,一个物品可打多个标签;标签全局共享、由管理员维护。
|
||||
- FR-类-3 分类/标签的增删改由管理员操作;删除时物品回到「未分类」。
|
||||
|
||||
### 5.5 跨家庭移动(P0)
|
||||
|
||||
- FR-转移-1 **物品转移**:源家庭管理员可将物品转移到另一个家庭(前置条件:操作者同时是目标家庭成员,防越权散落;精确规则见设计文档 §6.3、§8.1)。
|
||||
- FR-转移-2 **整盒转移**:含全部子盒与物品,整体移动到目标家庭的某盒子或根层。
|
||||
- FR-转移-3 转移带审计:记录操作人、时间、源/目标家庭(P1 提供操作日志查询)。
|
||||
- FR-转移-4 转移后:源家庭不可再看到该项;如无权限目标家庭则不可见。
|
||||
|
||||
### 5.6 首页与统计(P0)
|
||||
|
||||
- FR-统-1 当前家庭概览:总物品数、盒子总数、直接子盒数量、最大嵌套深度。
|
||||
- FR-统-2 分类分布(数量、占比);盒子占用 Top N(物品最多的盒子)。
|
||||
- FR-统-3 待办提醒:即将过期物品(30 天内)、无图物品、散放在根层未整理物品。
|
||||
- FR-统-4 价值合计(存在 purchase_price 时求和)。
|
||||
- FR-统-5 最近新增/最近操作列表。
|
||||
|
||||
### 5.7 其他
|
||||
|
||||
- FR-他-1 全局搜索入口(跨家庭可选,P1)。
|
||||
- FR-他-2 导出数据(JSON/CSV,P1)。
|
||||
- FR-他-3 深色模式(P2)。
|
||||
- FR-他-4 数据备份(服务端磁盘备份 + 可下载快照,P1)。
|
||||
- FR-他-5 MCP 能力:沿用 Wardrobe 的 MCP 服务,供 AI 助手查询物品位置(P1)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 物品字段定义
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 名称 | string | 是 | 用户可见名字 |
|
||||
| 分类 | category_id | 否 | 家庭自定义分类;为空表示未分类 |
|
||||
| 标签 | string[] | 否 | 自由标签 |
|
||||
| 数量 | int | 否 | 默认 1 |
|
||||
| 品牌/型号 | string | 否 | 便于搜索 |
|
||||
| 序列号 | string | 否 | P1 |
|
||||
| 购买日期/价格 | date / number | 否 | 用于统计与价值合计 |
|
||||
| 保质期/有效期 | date | 否 | 临期提醒依据(可为「无期限」) |
|
||||
| 图片 | string[] | 否 | 文件路径列表(最多 9 张) |
|
||||
| 备注 | string | 否 | 自由文本 |
|
||||
| 是否收藏 | bool | 否 | 默认 false |
|
||||
| 使用记录 | 见 5.3-FR-物-7 | 否 | P1 |
|
||||
| 创建/更新时间 | datetime | 否 | 系统生成 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 非功能需求
|
||||
|
||||
| 编号 | 要求 |
|
||||
|---|---|
|
||||
| NFR-1 | 性能:家庭内物品 ≤5 万时,列表/搜索/盒子树展开响应 <300ms;盒子树(递归)加载 <500ms。 |
|
||||
| NFR-2 | 数据安全:服务端为准,所有写接口必须通过家庭角色校验;不得在客户端以任何方式绕过权限。 |
|
||||
| NFR-3 | 可靠性:DuckDB 单机文件持久化 + 启动迁移机制(schema_version),预留备份。 |
|
||||
| NFR-4 | 跨端:Windows/macOS 桌面(Tauri)、Android(Tauri)、Web(浏览器),共享同一服务端与前端代码。 |
|
||||
| NFR-5 | 可部署性:单镜像 Docker 部署(前端静态 + Express API + DuckDB 数据卷),沿用 Wardrobe 的 Gitea Actions 流水线。 |
|
||||
| NFR-6 | 安全:会话 Token 存储于客户端(localStorage),Token 可撤销;图片路径做鉴权(经 /api 代理读取)。 |
|
||||
| NFR-7 | 验证码:P0 开发期固定码 1111;上线前接入真实短信 API(预留 channel 抽象)。 |
|
||||
| NFR-8 | 迁移友好:任何表结构变更通过迁移脚本顺序执行,禁止裸 `CREATE TABLE IF NOT EXISTS` 演进。 |
|
||||
| NFR-9 | 时区:统一以 UTC 存储,前端本地化展示。 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 需求优先级汇总
|
||||
|
||||
| 优先级 | 范围 |
|
||||
|---|---|
|
||||
| **P0(首版)** | 家庭创建/加入/切换;角色权限(管理/只读);物品 CRUD + 图片;盒子无限嵌套 + 相对位置 + 画布/列表视图 + 整盒移动;分类与标签;搜索/筛选/定位;跨家庭转移(物品 + 整盒);首页统计;验证码登录。 |
|
||||
| **P1(次版)** | 操作日志;自动整理;导出;备份快照;真实短信;使用/保养记录;全局跨家庭搜索;MCP。 |
|
||||
| **P2(远期)** | 借出记录;分类层级深化;深色模式;转让家庭主持权;AI 自动分类。 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收要点(供评审确认)
|
||||
|
||||
1. 未登录不可访问任何业务接口;非家庭成员访问返回 403。
|
||||
2. 成员对物品/盒子的任何写操作均被服务端拒绝(非仅前端隐藏)。
|
||||
3. 盒子嵌套(含 3+ 层)下,物品定位路径显示正确;整盒移动后子树物品随迁。
|
||||
4. 画布中拖拽盒子位置并刷新后位置保持;归一化坐标在 0~1000 内。
|
||||
5. 物品跨家庭转移后,仅目标家庭可见,源家庭不可见,并留下审计记录。
|
||||
6. 搜索可命中名称/品牌/标签;「定位」点击后可逐层导航到物品所在盒子。
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Storage 收纳</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5055
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "storage",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"android:dev": "tauri android dev",
|
||||
"android:build": "tauri android build",
|
||||
"check": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"dev:server": "node --watch --experimental-strip-types --no-warnings server/index.js",
|
||||
"start": "node --experimental-strip-types --no-warnings server/index.js",
|
||||
"build:apk": "powershell -ExecutionPolicy Bypass -File scripts/build-apk.ps1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@duckdb/node-api": "1.5.5-r.5",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.0",
|
||||
"@typescript-eslint/parser": "^7.14.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-singlefile": "^2.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Storage Android APK 构建 (Tauri 2)
|
||||
# 前置: Android SDK + JVM + Rust + src-tauri/
|
||||
# 用法: npm run build:apk (或 powershell -ExecutionPolicy Bypass -File scripts/build-apk.ps1)
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$tauriDir = Join-Path $root "src-tauri"
|
||||
if (-not (Test-Path $tauriDir)) {
|
||||
Write-Host @"
|
||||
[build-apk] 缺少 src-tauri/ 目录。Storage 的 Tauri 壳尚未初始化, 先执行:
|
||||
npm run tauri init
|
||||
然后重新运行本脚本。
|
||||
"@ -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 前端打内嵌资源 (Android 端相对 /api 会打到内嵌服务, 需指向远端后端)
|
||||
# 生产后端地址由环境决定 (与 部署环境.md 一致): baihu.vps.honor3.com:3003
|
||||
# (Android APP 页面源是 http://tauri.localhost, 访问外网明文 HTTP 需放行 usesCleartextTraffic)
|
||||
$VITE_API_BASE = if ($env:VITE_API_BASE) { $env:VITE_API_BASE } else { "http://baihu.vps.honor3.com:3003" }
|
||||
$env:VITE_API_BASE = $VITE_API_BASE
|
||||
|
||||
Write-Host "[build-apk] VITE_API_BASE=$VITE_API_BASE"
|
||||
|
||||
Push-Location $root
|
||||
try {
|
||||
npx tauri android build --apk
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host "[build-apk] 完成 (release APK 见 src-tauri/gen/android/... )"
|
||||
@@ -0,0 +1,66 @@
|
||||
// 登录/注册: 验证码(服务端一次性校验) + 服务端签发会话 token
|
||||
// 注意: 短信/邮件发送仍是假通道 (验证码固定 1111), 后续按 NFR-7 接真实短信
|
||||
import { uu, dbCreateUser, dbGetUserByEmail, dbGetUserByPhone, dbGetUserById, dbGetSession, dbCreateSession, dbDeleteSession } from './db.js'
|
||||
|
||||
const VERIFICATION_CODE = '1111'
|
||||
const CODE_EXPIRY_SECONDS = 300
|
||||
const codes = new Map()
|
||||
|
||||
export function sendVerification(target) {
|
||||
codes.set(target, { code: VERIFICATION_CODE, expiresAt: Date.now() + CODE_EXPIRY_SECONDS * 1000 })
|
||||
console.log(`[auth] verification code for ${target}: ${VERIFICATION_CODE}`)
|
||||
return `验证码已发送至 ${target}`
|
||||
}
|
||||
|
||||
function verifyCode(target, code) {
|
||||
const v = codes.get(target)
|
||||
if (!v) return false
|
||||
if (v.code !== code || Date.now() >= v.expiresAt) return false
|
||||
codes.delete(target)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function register({ email, phone, code, nickname }) {
|
||||
const target = email || phone
|
||||
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
|
||||
if (!verifyCode(target, code)) {
|
||||
return { success: false, message: '验证码无效或已过期', user: null, token: null }
|
||||
}
|
||||
if (email && (await dbGetUserByEmail(email))) {
|
||||
return { success: false, message: '该邮箱已注册', user: null, token: null }
|
||||
}
|
||||
if (phone && (await dbGetUserByPhone(phone))) {
|
||||
return { success: false, message: '该手机号已注册', user: null, token: null }
|
||||
}
|
||||
const id = uu()
|
||||
const user = await dbCreateUser(id, email || null, phone || null, nickname || '收纳达人')
|
||||
const token = 'tok_' + uu().replaceAll('-', '')
|
||||
await dbCreateSession(token, id)
|
||||
console.log(`[auth] user registered: ${id}`)
|
||||
return { success: true, message: '注册成功', user, token }
|
||||
}
|
||||
|
||||
export async function login({ email, phone, code }) {
|
||||
const target = email || phone
|
||||
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
|
||||
if (!verifyCode(target, code)) {
|
||||
return { success: false, message: '验证码无效或已过期', user: null, token: null }
|
||||
}
|
||||
const user = email ? await dbGetUserByEmail(email) : await dbGetUserByPhone(phone)
|
||||
if (!user) return { success: false, message: '账号不存在,请先注册', user: null, token: null }
|
||||
const token = 'tok_' + uu().replaceAll('-', '')
|
||||
await dbCreateSession(token, user.id)
|
||||
console.log(`[auth] user login: ${user.id}`)
|
||||
return { success: true, message: '登录成功', user, token }
|
||||
}
|
||||
|
||||
export async function logout(token) {
|
||||
if (token) await dbDeleteSession(token)
|
||||
}
|
||||
|
||||
export async function resolveSession(token) {
|
||||
const s = token ? await dbGetSession(token) : null
|
||||
if (!s) return null
|
||||
const user = await dbGetUserById(s.user_id)
|
||||
return user
|
||||
}
|
||||
+841
@@ -0,0 +1,841 @@
|
||||
// Storage 服务端数据层: DuckDB (@duckdb/node-api)
|
||||
// 业务数据全部存储在服务端数据库文件 (DB_PATH, 生产建议挂持久化数据卷),
|
||||
// 客户端不再持有数据库, 仅保留最近数据的读缓存。
|
||||
// 采用版本化迁移机制 (schema_version), 禁止用裸 CREATE TABLE 演进。
|
||||
//
|
||||
// 重要约定: 由于 DuckDB 单写入者 + 全局 enqueue 串行化, 任何"enqueue"的公共函数
|
||||
// 内部不得再调用其它 enqueue 函数 (会自锁)。因此所有底层读操作都实现为
|
||||
// 内部函数 (直接 conn.run), 公共 API 一律是薄 enqueue 包装。
|
||||
import { DuckDBInstance } from '@duckdb/node-api'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
let instance = null
|
||||
let conn = null
|
||||
|
||||
function dbFile() {
|
||||
const p = process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'storage.duckdb')
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true })
|
||||
return p
|
||||
}
|
||||
|
||||
let chain = Promise.resolve()
|
||||
export function enqueue(fn) {
|
||||
const p = chain.then(fn)
|
||||
chain = p.catch(() => {})
|
||||
return p
|
||||
}
|
||||
|
||||
// ── 内部底层读 (非 enqueue) ──
|
||||
async function rows(sql, params = []) {
|
||||
const r = await conn.run(sql, params)
|
||||
return (await r.getRowObjects()) ?? []
|
||||
}
|
||||
async function one(sql, params = []) {
|
||||
const r = await rows(sql, params)
|
||||
return r.length ? r[0] : null
|
||||
}
|
||||
async function count(sql, params = []) {
|
||||
const r = await rows(sql, params)
|
||||
return r.length ? Number(r[0].c) : 0
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString()
|
||||
export const uu = () => globalThis.crypto.randomUUID()
|
||||
|
||||
function parseArr(v) {
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
const p = JSON.parse(v)
|
||||
return Array.isArray(p) ? p : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function mapBox(x) {
|
||||
if (!x) return null
|
||||
return {
|
||||
...x,
|
||||
pos_x: Number(x.pos_x) || 0,
|
||||
pos_y: Number(x.pos_y) || 0,
|
||||
width: x.width === null || x.width === undefined ? null : Number(x.width),
|
||||
height: x.height === null || x.height === undefined ? null : Number(x.height),
|
||||
direct_item_count: x.direct_item_count === undefined ? 0 : Number(x.direct_item_count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function mapItem(x) {
|
||||
if (!x) return null
|
||||
return {
|
||||
...x,
|
||||
tags: parseArr(x.tags),
|
||||
images: parseArr(x.images),
|
||||
quantity: Number(x.quantity || 1),
|
||||
purchase_price: x.purchase_price === null || x.purchase_price === undefined ? null : Number(x.purchase_price),
|
||||
is_favorite: !!x.is_favorite,
|
||||
used_count: Number(x.used_count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 迁移机制 ──
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
version: 1,
|
||||
up: async (c) => {
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
email VARCHAR UNIQUE,
|
||||
phone VARCHAR UNIQUE,
|
||||
nickname VARCHAR NOT NULL DEFAULT '',
|
||||
avatar_path VARCHAR,
|
||||
created_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
token VARCHAR PRIMARY KEY,
|
||||
user_id VARCHAR NOT NULL,
|
||||
created_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS families (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
icon VARCHAR DEFAULT '🏠',
|
||||
owner_user_id VARCHAR NOT NULL,
|
||||
invite_code VARCHAR,
|
||||
created_at VARCHAR NOT NULL,
|
||||
updated_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS family_members (
|
||||
family_id VARCHAR NOT NULL,
|
||||
user_id VARCHAR NOT NULL,
|
||||
role VARCHAR NOT NULL CHECK (role IN ('admin','member')),
|
||||
joined_at VARCHAR NOT NULL,
|
||||
PRIMARY KEY (family_id, user_id)
|
||||
)`)
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS categories (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
family_id VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
sort INTEGER DEFAULT 0,
|
||||
created_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS boxes (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
family_id VARCHAR NOT NULL,
|
||||
parent_box_id VARCHAR,
|
||||
name VARCHAR NOT NULL,
|
||||
icon VARCHAR DEFAULT '📦',
|
||||
color VARCHAR DEFAULT '#E8C4A0',
|
||||
pos_x INTEGER DEFAULT 0 CHECK (pos_x BETWEEN 0 AND 1000),
|
||||
pos_y INTEGER DEFAULT 0 CHECK (pos_y BETWEEN 0 AND 1000),
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
created_at VARCHAR NOT NULL,
|
||||
updated_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS items (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
family_id VARCHAR NOT NULL,
|
||||
box_id VARCHAR,
|
||||
name VARCHAR NOT NULL,
|
||||
category_id VARCHAR,
|
||||
tags VARCHAR DEFAULT '[]',
|
||||
quantity INTEGER DEFAULT 1,
|
||||
brand VARCHAR,
|
||||
model VARCHAR,
|
||||
serial_no VARCHAR,
|
||||
purchase_date VARCHAR,
|
||||
purchase_price DOUBLE,
|
||||
expiry_date VARCHAR,
|
||||
images VARCHAR DEFAULT '[]',
|
||||
notes VARCHAR,
|
||||
is_favorite BOOLEAN DEFAULT FALSE,
|
||||
used_count INTEGER DEFAULT 0,
|
||||
last_used VARCHAR,
|
||||
created_at VARCHAR NOT NULL,
|
||||
updated_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await c.run(`CREATE TABLE IF NOT EXISTS transfer_logs (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
entity_type VARCHAR NOT NULL,
|
||||
entity_id VARCHAR NOT NULL,
|
||||
src_family_id VARCHAR NOT NULL,
|
||||
dst_family_id VARCHAR NOT NULL,
|
||||
operator_user_id VARCHAR NOT NULL,
|
||||
created_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await c.run(`CREATE INDEX IF NOT EXISTS idx_members_user ON family_members(user_id)`)
|
||||
await c.run(`CREATE INDEX IF NOT EXISTS idx_boxes_family ON boxes(family_id)`)
|
||||
await c.run(`CREATE INDEX IF NOT EXISTS idx_boxes_parent ON boxes(parent_box_id)`)
|
||||
await c.run(`CREATE INDEX IF NOT EXISTS idx_items_family ON items(family_id)`)
|
||||
await c.run(`CREATE INDEX IF NOT EXISTS idx_items_box ON items(box_id)`)
|
||||
await c.run(`CREATE INDEX IF NOT EXISTS idx_items_family_box ON items(family_id, box_id)`)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export async function initDb() {
|
||||
const file = dbFile()
|
||||
instance = await DuckDBInstance.create(file)
|
||||
conn = await instance.connect()
|
||||
await conn.run(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at VARCHAR NOT NULL)`)
|
||||
const applied = await rows('SELECT version FROM schema_version ORDER BY version')
|
||||
const appliedSet = new Set(applied.map((r) => Number(r.version)))
|
||||
for (const m of MIGRATIONS) {
|
||||
if (appliedSet.has(m.version)) continue
|
||||
console.log(`[db] applying migration v${m.version} ...`)
|
||||
await conn.run('BEGIN TRANSACTION')
|
||||
try {
|
||||
await m.up(conn)
|
||||
await conn.run('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)', [m.version, nowIso()])
|
||||
await conn.run('COMMIT')
|
||||
console.log(`[db] migration v${m.version} ok`)
|
||||
} catch (e) {
|
||||
await conn.run('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
console.log(`[db] DuckDB ready at ${file} (schema v${applied.length ? Math.max(...applied.map((r) => Number(r.version))) : 0})`)
|
||||
}
|
||||
|
||||
async function tx(fn) {
|
||||
await conn.run('BEGIN TRANSACTION')
|
||||
try {
|
||||
const r = await fn()
|
||||
await conn.run('COMMIT')
|
||||
return r
|
||||
} catch (e) {
|
||||
try { await conn.run('ROLLBACK') } catch { /* ignore */ }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════ users / sessions ══════════════
|
||||
|
||||
export function dbCreateUser(id, email, phone, nickname) {
|
||||
return enqueue(async () => {
|
||||
await conn.run('INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?,?,?,?,?)', [id, email, phone, nickname, nowIso()])
|
||||
return { id, email, phone, nickname, avatar_path: null, created_at: nowIso() }
|
||||
})
|
||||
}
|
||||
|
||||
export const dbGetUserByEmail = (email) => enqueue(() =>
|
||||
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?', [email]))
|
||||
|
||||
export const dbGetUserByPhone = (phone) => enqueue(() =>
|
||||
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?', [phone]))
|
||||
|
||||
export const dbGetUserById = (id) => enqueue(() =>
|
||||
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE id = ?', [id]))
|
||||
|
||||
export const dbUpdateUserNickname = (id, nickname) => enqueue(async () => {
|
||||
await conn.run('UPDATE users SET nickname = ? WHERE id = ?', [nickname, id])
|
||||
})
|
||||
|
||||
export const dbCreateSession = (token, userId) => enqueue(async () => {
|
||||
await conn.run('INSERT INTO sessions (token, user_id, created_at) VALUES (?,?,?)', [token, userId, nowIso()])
|
||||
})
|
||||
|
||||
export const dbGetSession = (token) => enqueue(() =>
|
||||
one('SELECT token, user_id, created_at FROM sessions WHERE token = ?', [token]))
|
||||
|
||||
export const dbDeleteSession = (token) => enqueue(async () => {
|
||||
await conn.run('DELETE FROM sessions WHERE token = ?', [token])
|
||||
})
|
||||
|
||||
// ══════════════ families / members ══════════════
|
||||
|
||||
function genInviteCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let s = ''
|
||||
for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)]
|
||||
return s
|
||||
}
|
||||
|
||||
export function dbCreateFamily(ownerId, name, icon) {
|
||||
return enqueue(async () => {
|
||||
const family = { id: uu(), name: name || '我的家庭', icon: icon || '🏠', owner_user_id: ownerId, invite_code: genInviteCode(), created_at: nowIso(), updated_at: nowIso() }
|
||||
await tx(async () => {
|
||||
await conn.run('INSERT INTO families (id, name, icon, owner_user_id, invite_code, created_at, updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
[family.id, family.name, family.icon, family.owner_user_id, family.invite_code, family.created_at, family.updated_at])
|
||||
await conn.run('INSERT INTO family_members (family_id, user_id, role, joined_at) VALUES (?,?,?,?)',
|
||||
[family.id, ownerId, 'admin', nowIso()])
|
||||
})
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export const dbListFamiliesForUser = (userId) => enqueue(async () => {
|
||||
return rows(
|
||||
`SELECT f.id, f.name, f.icon, f.owner_user_id, f.invite_code, f.created_at, m.role
|
||||
FROM family_members m JOIN families f ON f.id = m.family_id
|
||||
WHERE m.user_id = ? ORDER BY f.created_at`,
|
||||
[userId],
|
||||
)
|
||||
})
|
||||
|
||||
export const dbGetFamily = (familyId) => enqueue(() =>
|
||||
one('SELECT id, name, icon, owner_user_id, invite_code, created_at, updated_at FROM families WHERE id = ?', [familyId]))
|
||||
|
||||
export const dbUpdateFamily = (familyId, data) => enqueue(async () => {
|
||||
await conn.run('UPDATE families SET name = ?, icon = ?, updated_at = ? WHERE id = ?',
|
||||
[data.name, data.icon || '🏠', nowIso(), familyId])
|
||||
})
|
||||
|
||||
export const dbDeleteFamily = (familyId) => enqueue(async () => {
|
||||
await tx(async () => {
|
||||
await conn.run('DELETE FROM family_members WHERE family_id = ?', [familyId])
|
||||
await conn.run('DELETE FROM categories WHERE family_id = ?', [familyId])
|
||||
const boxIds = (await rows('SELECT id FROM boxes WHERE family_id = ?', [familyId])).map((r) => r.id)
|
||||
if (boxIds.length) {
|
||||
const placeholders = boxIds.map(() => '?').join(',')
|
||||
await conn.run(`DELETE FROM items WHERE family_id = ? OR box_id IN (${placeholders})`, [familyId, ...boxIds])
|
||||
await conn.run(`DELETE FROM boxes WHERE family_id = ?`, [familyId])
|
||||
} else {
|
||||
await conn.run('DELETE FROM items WHERE family_id = ?', [familyId])
|
||||
}
|
||||
await conn.run('DELETE FROM transfer_logs WHERE src_family_id = ? OR dst_family_id = ?', [familyId, familyId])
|
||||
await conn.run('DELETE FROM families WHERE id = ?', [familyId])
|
||||
})
|
||||
})
|
||||
|
||||
export const dbGetRole = (familyId, userId) => enqueue(async () => {
|
||||
const r = await one('SELECT role FROM family_members WHERE family_id = ? AND user_id = ?', [familyId, userId])
|
||||
return r ? r.role : null
|
||||
})
|
||||
|
||||
export const dbAddMember = (familyId, userId, role) => enqueue(async () => {
|
||||
await conn.run('INSERT INTO family_members (family_id, user_id, role, joined_at) VALUES (?,?,?,?)', [familyId, userId, role || 'member', nowIso()])
|
||||
})
|
||||
|
||||
export const dbRemoveMember = (familyId, userId) => enqueue(async () => {
|
||||
await conn.run('DELETE FROM family_members WHERE family_id = ? AND user_id = ?', [familyId, userId])
|
||||
})
|
||||
|
||||
export const dbSetRole = (familyId, userId, role) => enqueue(async () => {
|
||||
await conn.run('UPDATE family_members SET role = ? WHERE family_id = ? AND user_id = ?', [role, familyId, userId])
|
||||
})
|
||||
|
||||
export const dbSetInviteCode = (familyId, code) => enqueue(async () => {
|
||||
await conn.run('UPDATE families SET invite_code = ?, updated_at = ? WHERE id = ?', [code, nowIso(), familyId])
|
||||
})
|
||||
|
||||
export const dbJoinByCode = (code, userId) => enqueue(async () => {
|
||||
const family = await one('SELECT id FROM families WHERE invite_code = ?', [code])
|
||||
if (!family) return { success: false, message: '邀请码无效' }
|
||||
const existing = await one('SELECT role FROM family_members WHERE family_id = ? AND user_id = ?', [family.id, userId])
|
||||
if (existing) return { success: false, message: '你已在该家庭中' }
|
||||
await conn.run('INSERT INTO family_members (family_id, user_id, role, joined_at) VALUES (?,?,?,?)', [family.id, userId, 'member', nowIso()])
|
||||
return { success: true, message: '加入成功', familyId: family.id }
|
||||
})
|
||||
|
||||
export const dbListMembers = (familyId) => enqueue(async () => {
|
||||
return rows(
|
||||
`SELECT m.family_id, m.user_id, m.role, m.joined_at, u.nickname, u.email, u.phone
|
||||
FROM family_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.family_id = ? ORDER BY m.joined_at`,
|
||||
[familyId],
|
||||
)
|
||||
})
|
||||
|
||||
// ══════════════ categories ══════════════
|
||||
|
||||
export const dbListCategories = (familyId) => enqueue(async () => {
|
||||
return rows('SELECT id, family_id, name, sort, created_at FROM categories WHERE family_id = ? ORDER BY sort, created_at', [familyId])
|
||||
})
|
||||
|
||||
export const dbCreateCategory = (familyId, name) => enqueue(async () => {
|
||||
const cat = { id: uu(), family_id: familyId, name, sort: 0, created_at: nowIso() }
|
||||
await conn.run('INSERT INTO categories (id, family_id, name, sort, created_at) VALUES (?,?,?,?,?)',
|
||||
[cat.id, cat.family_id, cat.name, cat.sort, cat.created_at])
|
||||
return cat
|
||||
})
|
||||
|
||||
export const dbUpdateCategory = (familyId, catId, name) => enqueue(async () => {
|
||||
await conn.run('UPDATE categories SET name = ? WHERE id = ? AND family_id = ?', [name, catId, familyId])
|
||||
})
|
||||
|
||||
export const dbDeleteCategory = (familyId, catId) => enqueue(async () => {
|
||||
await tx(async () => {
|
||||
await conn.run('UPDATE items SET category_id = NULL WHERE category_id = ? AND family_id = ?', [catId, familyId])
|
||||
await conn.run('DELETE FROM categories WHERE id = ? AND family_id = ?', [catId, familyId])
|
||||
})
|
||||
})
|
||||
|
||||
// ══════════════ 盒子树内部工具 (非 enqueue) ══════════════
|
||||
|
||||
async function allBoxes(familyId) {
|
||||
const r = await rows(
|
||||
`SELECT b.*, (
|
||||
SELECT COUNT(*) FROM items i WHERE i.box_id = b.id
|
||||
) AS direct_item_count
|
||||
FROM boxes b WHERE b.family_id = ?`,
|
||||
[familyId],
|
||||
)
|
||||
return r.map(mapBox)
|
||||
}
|
||||
|
||||
function buildPath(boxes, boxId) {
|
||||
const byId = new Map(boxes.map((b) => [b.id, b]))
|
||||
const path = []
|
||||
let cur = byId.get(boxId)
|
||||
const guard = new Set()
|
||||
while (cur && !guard.has(cur.id)) {
|
||||
guard.add(cur.id)
|
||||
path.unshift({ id: cur.id, name: cur.name })
|
||||
cur = cur.parent_box_id ? byId.get(cur.parent_box_id) : null
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function directChildren(boxes, parentId) {
|
||||
return boxes.filter((b) => (b.parent_box_id ?? null) === (parentId ?? null)).sort((a, b) => a.pos_y - b.pos_y || a.pos_x - b.pos_x)
|
||||
}
|
||||
|
||||
async function isDescendant(familyId, boxId, targetId) {
|
||||
if (boxId === targetId) return true
|
||||
const all = await rows('SELECT id, parent_box_id FROM boxes WHERE family_id = ?', [familyId])
|
||||
const parentOf = new Map(all.map((b) => [b.id, b.parent_box_id]))
|
||||
let cur = parentOf.get(targetId)
|
||||
while (cur) {
|
||||
if (cur === boxId) return true
|
||||
cur = parentOf.get(cur)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function subtreeIds(familyId, boxId) {
|
||||
const all = await rows('SELECT id, parent_box_id FROM boxes WHERE family_id = ?', [familyId])
|
||||
const childrenOf = new Map()
|
||||
for (const b of all) {
|
||||
if (!childrenOf.has(b.parent_box_id)) childrenOf.set(b.parent_box_id, [])
|
||||
childrenOf.get(b.parent_box_id).push(b.id)
|
||||
}
|
||||
const out = []
|
||||
const stack = [boxId]
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()
|
||||
out.push(cur)
|
||||
for (const c of childrenOf.get(cur) || []) stack.push(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function subtreeItemCount(boxes, boxId) {
|
||||
const childrenOf = new Map()
|
||||
for (const b of boxes) {
|
||||
const p = b.parent_box_id ?? 'ROOT'
|
||||
if (!childrenOf.has(p)) childrenOf.set(p, [])
|
||||
childrenOf.get(p).push(b)
|
||||
}
|
||||
let total = 0
|
||||
const stack = [boxId]
|
||||
const seen = new Set()
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()
|
||||
if (seen.has(cur)) continue
|
||||
seen.add(cur)
|
||||
const b = boxes.find((x) => x.id === cur)
|
||||
if (b) total += b.direct_item_count || 0
|
||||
for (const c of childrenOf.get(cur) || []) stack.push(c.id)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function childBoxes(boxes) {
|
||||
const childCounts = new Map()
|
||||
for (const b of boxes) {
|
||||
const key = b.parent_box_id ?? 'ROOT'
|
||||
if (!childCounts.has(key)) childCounts.set(key, [])
|
||||
childCounts.get(key).push(b)
|
||||
}
|
||||
return childCounts
|
||||
}
|
||||
|
||||
async function buildTree(familyId) {
|
||||
const boxes = await allBoxes(familyId)
|
||||
const childrenOf = childBoxes(boxes)
|
||||
return boxes.map((box) => {
|
||||
const path = buildPath(boxes, box.id)
|
||||
return {
|
||||
...box,
|
||||
depth: path.length,
|
||||
path: path.map((p) => ({ id: p.id, name: p.name })),
|
||||
total_item_count: subtreeItemCount(boxes, box.id),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ══════════════ boxes (公共 enqueue API) ══════════════
|
||||
|
||||
export const dbAllBoxes = (familyId) => enqueue(() => allBoxes(familyId))
|
||||
|
||||
export const dbGetBox = (familyId, boxId) => enqueue(async () => {
|
||||
const b = await one('SELECT * FROM boxes WHERE id = ? AND family_id = ?', [boxId, familyId])
|
||||
return mapBox(b)
|
||||
})
|
||||
|
||||
export const dbBoxPath = (familyId, boxId) => enqueue(async () => {
|
||||
const boxes = await allBoxes(familyId)
|
||||
return buildPath(boxes, boxId)
|
||||
})
|
||||
|
||||
const clampPos = (v) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return 0
|
||||
return Math.max(0, Math.min(1000, Math.round(n)))
|
||||
}
|
||||
|
||||
export function dbCreateBox(familyId, data) {
|
||||
return enqueue(async () => {
|
||||
const t = nowIso()
|
||||
const box = {
|
||||
id: uu(),
|
||||
family_id: familyId,
|
||||
parent_box_id: data.parent_box_id ?? null,
|
||||
name: data.name,
|
||||
icon: data.icon || '📦',
|
||||
color: data.color || '#E8C4A0',
|
||||
pos_x: clampPos(data.pos_x),
|
||||
pos_y: clampPos(data.pos_y),
|
||||
width: data.width ?? null,
|
||||
height: data.height ?? null,
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
}
|
||||
if (box.parent_box_id) {
|
||||
const parent = await one('SELECT id, family_id FROM boxes WHERE id = ?', [box.parent_box_id])
|
||||
if (!parent || parent.family_id !== familyId) throw { status: 400, message: '父盒子不存在' }
|
||||
}
|
||||
await conn.run(
|
||||
'INSERT INTO boxes (id, family_id, parent_box_id, name, icon, color, pos_x, pos_y, width, height, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
[box.id, box.family_id, box.parent_box_id, box.name, box.icon, box.color, box.pos_x, box.pos_y, box.width, box.height, box.created_at, box.updated_at],
|
||||
)
|
||||
return mapBox({ ...box, direct_item_count: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
export function dbUpdateBox(familyId, boxId, data) {
|
||||
return enqueue(async () => {
|
||||
const sets = []
|
||||
const params = []
|
||||
for (const f of ['name', 'icon', 'color']) {
|
||||
if (data[f] !== undefined) { sets.push(`${f} = ?`); params.push(data[f]) }
|
||||
}
|
||||
if (data.width !== undefined) { sets.push('width = ?'); params.push(data.width) }
|
||||
if (data.height !== undefined) { sets.push('height = ?'); params.push(data.height) }
|
||||
sets.push('updated_at = ?'); params.push(nowIso())
|
||||
params.push(boxId, familyId)
|
||||
await conn.run(`UPDATE boxes SET ${sets.join(', ')} WHERE id = ? AND family_id = ?`, params)
|
||||
})
|
||||
}
|
||||
|
||||
export function dbMoveBox(familyId, boxId, parentBoxId, posX, posY) {
|
||||
return enqueue(async () => {
|
||||
const box = await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [boxId, familyId])
|
||||
if (!box) throw { status: 404, message: '盒子不存在' }
|
||||
if (parentBoxId) {
|
||||
const parent = await one('SELECT id, family_id FROM boxes WHERE id = ?', [parentBoxId])
|
||||
if (!parent || parent.family_id !== familyId) throw { status: 400, message: '目标父盒子不存在' }
|
||||
if (await isDescendant(familyId, boxId, parentBoxId)) throw { status: 400, message: '不能移动到自身或其子盒中' }
|
||||
}
|
||||
await conn.run('UPDATE boxes SET parent_box_id = ?, pos_x = ?, pos_y = ?, updated_at = ? WHERE id = ?',
|
||||
[parentBoxId ?? null, clampPos(posX), clampPos(posY), nowIso(), boxId])
|
||||
})
|
||||
}
|
||||
|
||||
export function dbSetBoxPosition(familyId, boxId, posX, posY) {
|
||||
return enqueue(async () => {
|
||||
await conn.run('UPDATE boxes SET pos_x = ?, pos_y = ?, updated_at = ? WHERE id = ? AND family_id = ?',
|
||||
[clampPos(posX), clampPos(posY), nowIso(), boxId, familyId])
|
||||
})
|
||||
}
|
||||
|
||||
export function dbDeleteBox(familyId, boxId) {
|
||||
return enqueue(async () => {
|
||||
const ids = await subtreeIds(familyId, boxId)
|
||||
await tx(async () => {
|
||||
const ph = ids.map(() => '?').join(',')
|
||||
await conn.run(`DELETE FROM items WHERE family_id = ? AND box_id IN (${ph})`, [familyId, ...ids])
|
||||
await conn.run(`DELETE FROM boxes WHERE id IN (${ph})`, ids)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function dbListChildBoxes(familyId, parentBoxId) {
|
||||
return enqueue(async () => {
|
||||
const boxes = await allBoxes(familyId)
|
||||
return directChildren(boxes, parentBoxId).map((box) => ({
|
||||
...box,
|
||||
total_item_count: subtreeItemCount(boxes, box.id),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export const dbBuildTree = (familyId) => enqueue(() => buildTree(familyId))
|
||||
|
||||
export const dbSubtreeIds = (familyId, boxId) => enqueue(() => subtreeIds(familyId, boxId))
|
||||
|
||||
// ══════════════ items ══════════════
|
||||
|
||||
export function dbCreateItem(familyId, data) {
|
||||
return enqueue(async () => {
|
||||
const t = nowIso()
|
||||
const item = {
|
||||
id: uu(),
|
||||
family_id: familyId,
|
||||
box_id: data.box_id ?? null,
|
||||
name: data.name,
|
||||
category_id: data.category_id ?? null,
|
||||
tags: data.tags ?? [],
|
||||
quantity: data.quantity ?? 1,
|
||||
brand: data.brand ?? null,
|
||||
model: data.model ?? null,
|
||||
serial_no: data.serial_no ?? null,
|
||||
purchase_date: data.purchase_date ?? null,
|
||||
purchase_price: data.purchase_price === '' ? null : (data.purchase_price ?? null),
|
||||
expiry_date: data.expiry_date ?? null,
|
||||
images: data.images ?? [],
|
||||
notes: data.notes ?? null,
|
||||
is_favorite: !!data.is_favorite,
|
||||
used_count: 0,
|
||||
last_used: null,
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
}
|
||||
if (item.box_id && (await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [item.box_id, familyId])) === null) throw { status: 400, message: '盒子不存在' }
|
||||
await conn.run(
|
||||
`INSERT INTO items (id, family_id, box_id, name, category_id, tags, quantity, brand, model, serial_no, purchase_date, purchase_price, expiry_date, images, notes, is_favorite, used_count, last_used, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
item.id, item.family_id, item.box_id, item.name, item.category_id,
|
||||
JSON.stringify(item.tags), item.quantity, item.brand, item.model, item.serial_no,
|
||||
item.purchase_date, item.purchase_price, item.expiry_date,
|
||||
JSON.stringify(item.images), item.notes, item.is_favorite,
|
||||
item.used_count, item.last_used, item.created_at, item.updated_at,
|
||||
],
|
||||
)
|
||||
return mapItem(item)
|
||||
})
|
||||
}
|
||||
|
||||
export const dbGetItem = (familyId, itemId) => enqueue(async () => {
|
||||
const it = await one('SELECT * FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
|
||||
return mapItem(it)
|
||||
})
|
||||
|
||||
// 内部 listItems: 建议在 enqueue 内使用 (不嵌套 enqueue)
|
||||
async function listItems(familyId, filters = {}) {
|
||||
let sql = 'SELECT * FROM items WHERE family_id = ?'
|
||||
const params = [familyId]
|
||||
if (filters.boxId) {
|
||||
if (filters.recursive) {
|
||||
const ids = await subtreeIds(familyId, filters.boxId)
|
||||
if (!ids.length) return []
|
||||
sql += ` AND box_id IN (${ids.map(() => '?').join(',')})`
|
||||
params.push(...ids)
|
||||
} else {
|
||||
sql += ' AND box_id = ?'
|
||||
params.push(filters.boxId)
|
||||
}
|
||||
} else if (filters.looseOnly) {
|
||||
sql += ' AND box_id IS NULL'
|
||||
}
|
||||
if (filters.categoryId) { sql += ' AND category_id = ?'; params.push(filters.categoryId) }
|
||||
if (filters.tag) {
|
||||
// tags 存为 JSON 数组字符串: 去掉 [ ] " 后按逗号拆为列表再判断包含
|
||||
sql += " AND list_contains(string_split(regexp_replace(tags, '[\\[\\]\" ]', '', 'g'), ','), ?)"
|
||||
params.push(filters.tag)
|
||||
}
|
||||
if (filters.q) {
|
||||
sql += ' AND (name ILIKE ? OR COALESCE(brand, \'\') ILIKE ? OR COALESCE(model, \'\') ILIKE ? OR COALESCE(notes, \'\') ILIKE ?)'
|
||||
const like = `%${filters.q}%`
|
||||
params.push(like, like, like, like)
|
||||
}
|
||||
if (filters.favorite) sql += ' AND is_favorite = TRUE'
|
||||
if (filters.expiring) {
|
||||
sql += " AND expiry_date IS NOT NULL AND CAST(expiry_date AS DATE) >= CURRENT_DATE AND CAST(expiry_date AS DATE) < CURRENT_DATE + INTERVAL '30 days'"
|
||||
}
|
||||
sql += ' ORDER BY (is_favorite = TRUE) DESC, updated_at DESC'
|
||||
const limit = Math.min(Number(filters.limit) || 200, 1000)
|
||||
sql += ` LIMIT ${limit}`
|
||||
const r = await rows(sql, params)
|
||||
return r.map(mapItem)
|
||||
}
|
||||
|
||||
export const dbListItems = (familyId, filters = {}) => enqueue(() => listItems(familyId, filters))
|
||||
|
||||
const ITEM_SET_FIELDS = ['name', 'category_id', 'quantity', 'brand', 'model', 'serial_no', 'purchase_date', 'purchase_price', 'expiry_date', 'notes', 'is_favorite']
|
||||
|
||||
export function dbUpdateItem(familyId, itemId, data) {
|
||||
return enqueue(async () => {
|
||||
const item = await one('SELECT id FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
|
||||
if (!item) throw { status: 404, message: '物品不存在' }
|
||||
const sets = []
|
||||
const params = []
|
||||
for (const f of ITEM_SET_FIELDS) {
|
||||
if (data[f] === undefined) continue
|
||||
if (f === 'quantity') { sets.push('quantity = ?'); params.push(Number(data[f]) || 1) }
|
||||
else if (f === 'is_favorite') { sets.push('is_favorite = ?'); params.push(!!data[f]) }
|
||||
else if (f === 'category_id' || f === 'brand' || f === 'model' || f === 'serial_no' || f === 'purchase_date' || f === 'purchase_price' || f === 'expiry_date' || f === 'notes') {
|
||||
sets.push(`${f} = ?`); params.push(data[f] === '' ? null : data[f])
|
||||
}
|
||||
else { sets.push(`${f} = ?`); params.push(data[f]) }
|
||||
}
|
||||
if (data.box_id !== undefined) { sets.push('box_id = ?'); params.push(data.box_id || null) }
|
||||
if (data.tags !== undefined) { sets.push('tags = ?'); params.push(JSON.stringify(data.tags ?? [])) }
|
||||
if (data.images !== undefined) { sets.push('images = ?'); params.push(JSON.stringify(data.images ?? [])) }
|
||||
if (!sets.length) { sets.push('name = name') }
|
||||
sets.push('updated_at = ?'); params.push(nowIso())
|
||||
params.push(itemId, familyId)
|
||||
await conn.run(`UPDATE items SET ${sets.join(', ')} WHERE id = ? AND family_id = ?`, params)
|
||||
})
|
||||
}
|
||||
|
||||
export function dbDeleteItem(familyId, itemId) {
|
||||
return enqueue(async () => {
|
||||
await conn.run('DELETE FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
|
||||
})
|
||||
}
|
||||
|
||||
export function dbMoveItem(familyId, itemId, boxId) {
|
||||
return enqueue(async () => {
|
||||
const item = await one('SELECT id FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
|
||||
if (!item) throw { status: 404, message: '物品不存在' }
|
||||
if (boxId) {
|
||||
const box = await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [boxId, familyId])
|
||||
if (!box) throw { status: 400, message: '目标盒子不存在' }
|
||||
}
|
||||
await conn.run('UPDATE items SET box_id = ?, updated_at = ? WHERE id = ?', [boxId ?? null, nowIso(), itemId])
|
||||
})
|
||||
}
|
||||
|
||||
// ══════════════ transfer (跨家庭转移) ══════════════
|
||||
|
||||
export function dbTransfer(familyId, data) {
|
||||
return enqueue(async () => {
|
||||
const { type, entityId, dstFamilyId, dstParentBoxId, posX, posY, operatorUserId } = data
|
||||
if (!['item', 'box'].includes(type)) throw { status: 400, message: 'type 非法' }
|
||||
if (familyId === dstFamilyId) throw { status: 400, message: '目标家庭不能是源家庭' }
|
||||
|
||||
return tx(async () => {
|
||||
if (type === 'item') {
|
||||
const item = await one('SELECT * FROM items WHERE id = ? AND family_id = ?', [entityId, familyId])
|
||||
if (!item) throw { status: 404, message: '物品不存在' }
|
||||
if (dstParentBoxId && (await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [dstParentBoxId, dstFamilyId])) === null) throw { status: 400, message: '目标盒子不存在于目标家庭' }
|
||||
await conn.run('UPDATE items SET family_id = ?, box_id = ?, updated_at = ? WHERE id = ?', [dstFamilyId, dstParentBoxId ?? null, nowIso(), entityId])
|
||||
await logTransfer('item', entityId, familyId, dstFamilyId, operatorUserId)
|
||||
return { type, entityId, dstFamilyId, ok: true }
|
||||
}
|
||||
|
||||
const roots = await rows('SELECT id, parent_box_id, pos_x, pos_y FROM boxes WHERE id = ? AND family_id = ?', [entityId, familyId])
|
||||
if (!roots.length) throw { status: 404, message: '盒子不存在' }
|
||||
if (dstParentBoxId && (await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [dstParentBoxId, dstFamilyId])) === null) throw { status: 400, message: '目标盒子不存在于目标家庭' }
|
||||
const ids = await subtreeIds(familyId, entityId)
|
||||
const ph = ids.map(() => '?').join(',')
|
||||
await conn.run(`UPDATE boxes SET family_id = ? WHERE id IN (${ph})`, [dstFamilyId, ...ids])
|
||||
await conn.run('UPDATE boxes SET parent_box_id = ?, pos_x = ?, pos_y = ?, updated_at = ? WHERE id = ?',
|
||||
[dstParentBoxId ?? null, clampPos(posX), clampPos(posY), nowIso(), entityId])
|
||||
await conn.run(`UPDATE items SET family_id = ? WHERE family_id = ? AND box_id IN (${ph})`, [dstFamilyId, familyId, ...ids])
|
||||
await logTransfer('box', entityId, familyId, dstFamilyId, operatorUserId)
|
||||
return { type, entityId, dstFamilyId, ok: true }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function logTransfer(type, entityId, srcFamilyId, dstFamilyId, operatorUserId) {
|
||||
await conn.run('INSERT INTO transfer_logs (id, entity_type, entity_id, src_family_id, dst_family_id, operator_user_id, created_at) VALUES (?,?,?,?,?,?,?)',
|
||||
[uu(), type, entityId, srcFamilyId, dstFamilyId, operatorUserId, nowIso()])
|
||||
}
|
||||
|
||||
export const dbListTransferLogs = (familyId) => enqueue(async () => {
|
||||
return rows('SELECT * FROM transfer_logs WHERE src_family_id = ? OR dst_family_id = ? ORDER BY created_at DESC LIMIT 100', [familyId, familyId])
|
||||
})
|
||||
|
||||
// ══════════════ stats ══════════════
|
||||
|
||||
export function dbGetFamilyStats(familyId) {
|
||||
return enqueue(async () => {
|
||||
const [totalItems, totalBoxes] = await Promise.all([
|
||||
count('SELECT COUNT(*) AS c FROM items WHERE family_id = ?', [familyId]),
|
||||
count('SELECT COUNT(*) AS c FROM boxes WHERE family_id = ?', [familyId]),
|
||||
])
|
||||
const boxes = await allBoxes(familyId)
|
||||
const rootBoxes = directChildren(boxes, null)
|
||||
const valueRow = await one('SELECT COALESCE(SUM(purchase_price), 0) AS v FROM items WHERE family_id = ? AND purchase_price IS NOT NULL', [familyId])
|
||||
const looseCount = await count('SELECT COUNT(*) AS c FROM items WHERE family_id = ? AND box_id IS NULL', [familyId])
|
||||
const noImageCount = await count("SELECT COUNT(*) AS c FROM items WHERE family_id = ? AND images = '[]'", [familyId])
|
||||
const maxDepth = boxes.reduce((m, b) => Math.max(m, buildPath(boxes, b.id).length), 0)
|
||||
const topBoxes = boxes
|
||||
.map((b) => ({ id: b.id, name: b.name, icon: b.icon, count: subtreeItemCount(boxes, b.id), path: buildPath(boxes, b.id) }))
|
||||
.filter((b) => b.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5)
|
||||
const catRows = await rows(
|
||||
`SELECT COALESCE(c.name, '未分类') AS name, COUNT(i.id) AS c
|
||||
FROM items i LEFT JOIN categories c ON c.id = i.category_id
|
||||
WHERE i.family_id = ? GROUP BY COALESCE(c.name, '未分类') ORDER BY c DESC`,
|
||||
[familyId],
|
||||
)
|
||||
const expiring = (await listItems(familyId, { expiring: true, limit: 50 }))
|
||||
const recent = (await listItems(familyId, { limit: 8 }))
|
||||
return {
|
||||
total_items: totalItems,
|
||||
total_boxes: totalBoxes,
|
||||
root_boxes: rootBoxes.length,
|
||||
max_depth: maxDepth,
|
||||
total_value: Number(valueRow?.v || 0),
|
||||
expiring_count: expiring.length,
|
||||
expiring_items: expiring,
|
||||
loose_items: looseCount,
|
||||
no_image_count: noImageCount,
|
||||
category_distribution: catRows.map((r) => ({ name: r.name, count: Number(r.c) })),
|
||||
top_boxes: topBoxes,
|
||||
recent_items: recent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ══════════════ MCP (P1: AI 助手查询) ══════════════
|
||||
|
||||
export function mcpTools() {
|
||||
return [
|
||||
{ name: 'list_my_families', description: '列出我加入的所有家庭', input_schema: '{"type":"object","properties":{},"required":[]}' },
|
||||
{ name: 'search_items', description: '按关键字或标签搜索物品(需 family_id), 返回物品及所在位置', input_schema: '{"type":"object","properties":{"family_id":{"type":"string"},"q":{"type":"string"},"tag":{"type":"string"}},"required":["family_id"]}' },
|
||||
{ name: 'get_item', description: '根据 item_id 和 family_id 获取物品详情与所在路径', input_schema: '{"type":"object","properties":{"item_id":{"type":"string"},"family_id":{"type":"string"}},"required":["item_id","family_id"]}' },
|
||||
{ name: 'get_family_stats', description: '获取家庭物品统计概览', input_schema: '{"type":"object","properties":{"family_id":{"type":"string"}},"required":["family_id"]}' },
|
||||
]
|
||||
}
|
||||
|
||||
export async function mcpCall(toolName, args = {}, userId) {
|
||||
switch (toolName) {
|
||||
case 'list_my_families':
|
||||
return { ok: true, value: await dbListFamiliesForUser(userId) }
|
||||
case 'search_items': {
|
||||
const { family_id, q, tag } = args
|
||||
const role = await dbGetRole(family_id, userId)
|
||||
if (!role) throw { status: 403, message: '非该家庭成员' }
|
||||
return { ok: true, value: await dbListItems(family_id, { q, tag, limit: 100 }) }
|
||||
}
|
||||
case 'get_item': {
|
||||
const { item_id, family_id } = args
|
||||
const role = await dbGetRole(family_id, userId)
|
||||
if (!role) throw { status: 403, message: '非该家庭成员' }
|
||||
const item = await dbGetItem(family_id, item_id)
|
||||
if (!item) throw { status: 404, message: '物品不存在' }
|
||||
const boxes = await allBoxes(family_id)
|
||||
return { ok: true, value: { ...item, box_path: item.box_id ? buildPath(boxes, item.box_id) : [] } }
|
||||
}
|
||||
case 'get_family_stats': {
|
||||
const { family_id } = args
|
||||
const role = await dbGetRole(family_id, userId)
|
||||
if (!role) throw { status: 403, message: '非该家庭成员' }
|
||||
return { ok: true, value: await dbGetFamilyStats(family_id) }
|
||||
}
|
||||
default:
|
||||
throw { status: 400, message: `Unknown tool: ${toolName}` }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Storage 服务端入口: 业务 API + 图片 API + 静态托管 (SPA)
|
||||
// 端口由部署环境决定 (PORT env), 本地缺省 3002。见 部署环境.md
|
||||
import 'dotenv/config'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import appRouter from './routes/app.js'
|
||||
import imagesRouter from './routes/images.js'
|
||||
import { initDb } from './db.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3002
|
||||
const DIST_DIR = path.join(process.cwd(), 'dist')
|
||||
const HAS_DIST = fs.existsSync(DIST_DIR)
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
|
||||
app.get('/healthz', (_req, res) => res.json({ ok: true, ts: Date.now() }))
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
|
||||
|
||||
// 业务接口
|
||||
app.use('/api', appRouter)
|
||||
// 图片接口 (签名 URL 鉴权), 需在 SPA fallback 之前挂载
|
||||
app.use('/api', imagesRouter)
|
||||
|
||||
if (HAS_DIST) {
|
||||
app.use(express.static(DIST_DIR, { index: false, maxAge: '1h', etag: true }))
|
||||
app.get(/^\/(?!api\/).*/, (_req, res, next) => {
|
||||
const indexHtml = path.join(DIST_DIR, 'index.html')
|
||||
if (!fs.existsSync(indexHtml)) return next()
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
res.sendFile(indexHtml)
|
||||
})
|
||||
}
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[api error]', err)
|
||||
const code = err && err.status ? Number(err.status) : 500
|
||||
if (code >= 500) console.error(err.stack || err)
|
||||
res.status(code).json({ error: (err && err.message) || '服务器内部错误' })
|
||||
})
|
||||
|
||||
initDb()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[storage-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[storage-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[db] failed to init DuckDB:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,356 @@
|
||||
// Storage 业务 REST API: 家庭 / 盒子 / 物品 / 分类 / 转移 / 统计 / MCP
|
||||
// 权限模型: 管理员(admin)可写, 成员(member)只读。服务端为唯一权威。
|
||||
import { Router } from 'express'
|
||||
import * as auth from '../auth.js'
|
||||
import { finalizeImages, deleteImageFiles } from './images.js'
|
||||
import {
|
||||
// users/sessions
|
||||
dbGetUserById, dbUpdateUserNickname,
|
||||
// families
|
||||
dbCreateFamily, dbListFamiliesForUser, dbGetFamily, dbUpdateFamily, dbDeleteFamily,
|
||||
dbGetRole, dbAddMember, dbRemoveMember, dbSetRole, dbSetInviteCode, dbJoinByCode,
|
||||
dbListMembers,
|
||||
// categories
|
||||
dbListCategories, dbCreateCategory, dbUpdateCategory, dbDeleteCategory,
|
||||
// boxes
|
||||
dbCreateBox, dbGetBox, dbUpdateBox, dbMoveBox, dbSetBoxPosition, dbDeleteBox,
|
||||
dbListChildBoxes, dbBuildTree, dbBoxPath,
|
||||
// items
|
||||
dbCreateItem, dbGetItem, dbListItems, dbUpdateItem, dbDeleteItem, dbMoveItem,
|
||||
// transfer
|
||||
dbTransfer, dbListTransferLogs,
|
||||
// stats / mcp
|
||||
dbGetFamilyStats, mcpTools, mcpCall,
|
||||
uu,
|
||||
} from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
|
||||
|
||||
// ── 鉴权中间件: Authorization: Bearer <token> ──
|
||||
async function requireAuth(req, res, next) {
|
||||
try {
|
||||
const h = req.headers.authorization || ''
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null
|
||||
const user = await auth.resolveSession(token)
|
||||
if (!user) return res.status(401).json({ error: '未登录或登录已过期' })
|
||||
req.user = user
|
||||
req.token = token
|
||||
next()
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 家庭成员校验: familyId 取 URL 中的 :id
|
||||
async function requireMember(req, res, next) {
|
||||
const role = await dbGetRole(req.params.id, req.user.id)
|
||||
if (!role) return res.status(403).json({ error: '你不是该家庭成员' })
|
||||
req.role = role
|
||||
next()
|
||||
}
|
||||
|
||||
// 家庭管理员校验 (所有写操作)
|
||||
async function requireAdmin(req, res, next) {
|
||||
const role = await dbGetRole(req.params.id, req.user.id)
|
||||
if (role !== 'admin') return res.status(403).json({ error: '仅管理员可执行此操作' })
|
||||
req.role = role
|
||||
next()
|
||||
}
|
||||
|
||||
// ── Auth ──
|
||||
router.post('/auth/send-code', asyncH(async (req, res) => {
|
||||
const target = String(req.body?.target || '').trim()
|
||||
if (!target) return res.status(400).json({ error: '请提供邮箱或手机号' })
|
||||
res.json({ message: auth.sendVerification(target) })
|
||||
}))
|
||||
|
||||
router.post('/auth/register', asyncH(async (req, res) => {
|
||||
const { email, phone, code, nickname } = req.body || {}
|
||||
res.json(await auth.register({ email: email || null, phone: phone || null, code, nickname }))
|
||||
}))
|
||||
|
||||
router.post('/auth/login', asyncH(async (req, res) => {
|
||||
const { email, phone, code } = req.body || {}
|
||||
res.json(await auth.login({ email: email || null, phone: phone || null, code }))
|
||||
}))
|
||||
|
||||
router.post('/auth/logout', requireAuth, asyncH(async (req, res) => {
|
||||
await auth.logout(req.token)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.get('/auth/me', requireAuth, asyncH(async (req, res) => {
|
||||
res.json({ user: await dbGetUserById(req.user.id) })
|
||||
}))
|
||||
|
||||
router.put('/auth/me', requireAuth, asyncH(async (req, res) => {
|
||||
const nickname = String(req.body?.nickname || '').trim()
|
||||
if (!nickname) return res.status(400).json({ error: '昵称不能为空' })
|
||||
await dbUpdateUserNickname(req.user.id, nickname)
|
||||
res.json({ user: await dbGetUserById(req.user.id) })
|
||||
}))
|
||||
|
||||
// ── Families ──
|
||||
router.get('/families', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbListFamiliesForUser(req.user.id))
|
||||
}))
|
||||
|
||||
router.post('/families', requireAuth, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入家庭名称' })
|
||||
res.json(await dbCreateFamily(req.user.id, name, req.body?.icon || '🏠'))
|
||||
}))
|
||||
|
||||
router.get('/families/:id', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
res.json({
|
||||
family: await dbGetFamily(req.params.id),
|
||||
members: await dbListMembers(req.params.id),
|
||||
my_role: req.role,
|
||||
})
|
||||
}))
|
||||
|
||||
router.put('/families/:id', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入家庭名称' })
|
||||
await dbUpdateFamily(req.params.id, { name, icon: req.body?.icon || '🏠' })
|
||||
res.json({ success: true, family: await dbGetFamily(req.params.id) })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const family = await dbGetFamily(req.params.id)
|
||||
if (family.owner_user_id !== req.user.id) return res.status(403).json({ error: '仅创建者可解散家庭' })
|
||||
await dbDeleteFamily(req.params.id)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// 通过邀请码加入
|
||||
router.post('/families/:id/join', requireAuth, asyncH(async (req, res) => {
|
||||
const code = String(req.body?.invite_code || '').trim().toUpperCase()
|
||||
if (!code) return res.status(400).json({ error: '请输入邀请码' })
|
||||
res.json(await dbJoinByCode(code, req.user.id))
|
||||
}))
|
||||
|
||||
// 邀请码管理 (管理员)
|
||||
router.post('/families/:id/invite', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const action = req.body?.action || 'refresh'
|
||||
if (action === 'clear') {
|
||||
await dbSetInviteCode(req.params.id, null)
|
||||
return res.json({ invite_code: null })
|
||||
}
|
||||
let code
|
||||
do { code = 'ST-' + uu().replaceAll('-', '').slice(0, 8).toUpperCase() } while (code.includes('0') || code.includes('O'))
|
||||
await dbSetInviteCode(req.params.id, code)
|
||||
res.json({ invite_code: code })
|
||||
}))
|
||||
|
||||
router.post('/families/:id/members/:userId/role', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const role = req.body?.role
|
||||
if (!['admin', 'member'].includes(role)) return res.status(400).json({ error: 'role 非法' })
|
||||
const family = await dbGetFamily(req.params.id)
|
||||
const targetExists = (await dbListMembers(req.params.id)).some((m) => m.user_id === req.params.userId)
|
||||
if (!targetExists) return res.status(404).json({ error: '成员不存在' })
|
||||
if (req.params.userId === family.owner_user_id) return res.status(403).json({ error: '不能修改创建者角色' })
|
||||
await dbSetRole(req.params.id, req.params.userId, role)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.post('/families/:id/members/:userId/add', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
await dbAddMember(req.params.id, req.params.userId, req.body?.role || 'member')
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// 移除成员; userId === 'me' 表示自己离开
|
||||
router.delete('/families/:id/members/:userId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const family = await dbGetFamily(req.params.id)
|
||||
const target = req.params.userId === 'me' ? req.user.id : req.params.userId
|
||||
if (target === family.owner_user_id) return res.status(403).json({ error: '创建者不能离开/被移除, 请先解散家庭' })
|
||||
if (target === req.user.id && req.params.userId === 'me') {
|
||||
await dbRemoveMember(req.params.id, target)
|
||||
return res.json({ success: true, left: true })
|
||||
}
|
||||
await dbRemoveMember(req.params.id, target)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Categories ──
|
||||
router.get('/families/:id/categories', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
res.json(await dbListCategories(req.params.id))
|
||||
}))
|
||||
|
||||
router.post('/families/:id/categories', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入分类名称' })
|
||||
res.json(await dbCreateCategory(req.params.id, name))
|
||||
}))
|
||||
|
||||
router.put('/families/:id/categories/:catId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '请输入分类名称' })
|
||||
await dbUpdateCategory(req.params.id, req.params.catId, name)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id/categories/:catId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
await dbDeleteCategory(req.params.id, req.params.catId)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Boxes ──
|
||||
// 直接子盒 (根层 = 不带 parent 参数)
|
||||
router.get('/families/:id/boxes', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
if (req.query.tree === '1') return res.json(await dbBuildTree(req.params.id))
|
||||
const parentId = req.query.parent && req.query.parent !== 'root' ? String(req.query.parent) : null
|
||||
res.json(await dbListChildBoxes(req.params.id, parentId))
|
||||
}))
|
||||
|
||||
router.get('/families/:id/boxes/:boxId', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
const box = await dbGetBox(req.params.id, req.params.boxId)
|
||||
if (!box) return res.status(404).json({ error: '盒子不存在' })
|
||||
const [path, children] = await Promise.all([
|
||||
dbBoxPath(req.params.id, req.params.boxId),
|
||||
dbListChildBoxes(req.params.id, req.params.boxId),
|
||||
])
|
||||
res.json({ box, path, children })
|
||||
}))
|
||||
|
||||
router.post('/families/:id/boxes', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name) return res.status(400).json({ error: '请输入盒子名称' })
|
||||
res.json(await dbCreateBox(req.params.id, {
|
||||
name: String(b.name).trim(),
|
||||
parent_box_id: b.parent_box_id || null,
|
||||
icon: b.icon, color: b.color,
|
||||
pos_x: b.pos_x, pos_y: b.pos_y,
|
||||
width: b.width ?? null, height: b.height ?? null,
|
||||
}))
|
||||
}))
|
||||
|
||||
router.put('/families/:id/boxes/:boxId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const patch = {}
|
||||
if (b.name !== undefined) patch.name = String(b.name).trim()
|
||||
if (b.icon !== undefined) patch.icon = b.icon
|
||||
if (b.color !== undefined) patch.color = b.color
|
||||
if (b.width !== undefined) patch.width = b.width
|
||||
if (b.height !== undefined) patch.height = b.height
|
||||
if (!Object.keys(patch).length) return res.status(400).json({ error: '没有可更新的字段' })
|
||||
await dbUpdateBox(req.params.id, req.params.boxId, patch)
|
||||
res.json({ success: true, box: await dbGetBox(req.params.id, req.params.boxId) })
|
||||
}))
|
||||
|
||||
// 整盒移动 (跨容器 / 根层)
|
||||
router.put('/families/:id/boxes/:boxId/move', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const parent = b.parent_box_id && b.parent_box_id !== 'root' ? String(b.parent_box_id) : null
|
||||
await dbMoveBox(req.params.id, req.params.boxId, parent, b.pos_x ?? 0, b.pos_y ?? 0)
|
||||
res.json({ success: true, box: await dbGetBox(req.params.id, req.params.boxId) })
|
||||
}))
|
||||
|
||||
// 仅调整位置 (画布拖拽)
|
||||
router.put('/families/:id/boxes/:boxId/position', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
await dbSetBoxPosition(req.params.id, req.params.boxId, b.pos_x ?? 0, b.pos_y ?? 0)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id/boxes/:boxId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
await dbDeleteBox(req.params.id, req.params.boxId)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Items ──
|
||||
router.get('/families/:id/items', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
const q = req.query
|
||||
const filters = {
|
||||
boxId: q.box && q.box !== 'root' ? String(q.box) : null,
|
||||
recursive: q.recursive === '1',
|
||||
looseOnly: q.loose === '1',
|
||||
categoryId: q.category || null,
|
||||
tag: q.tag || null,
|
||||
q: q.q ? String(q.q) : null,
|
||||
favorite: q.favorite === '1',
|
||||
expiring: q.expiring === '1',
|
||||
limit: q.limit,
|
||||
}
|
||||
res.json(await dbListItems(req.params.id, filters))
|
||||
}))
|
||||
|
||||
router.post('/families/:id/items', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name) return res.status(400).json({ error: '请输入物品名称' })
|
||||
b.images = finalizeImages(b.images || [], req.params.id)
|
||||
res.json(await dbCreateItem(req.params.id, b))
|
||||
}))
|
||||
|
||||
router.get('/families/:id/items/:itemId', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
const item = await dbGetItem(req.params.id, req.params.itemId)
|
||||
if (!item) return res.status(404).json({ error: '物品不存在' })
|
||||
const path = item.box_id ? await dbBoxPath(req.params.id, item.box_id) : []
|
||||
res.json({ item, box_path: path })
|
||||
}))
|
||||
|
||||
router.put('/families/:id/items/:itemId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (b.images !== undefined) b.images = finalizeImages(b.images || [], req.params.id)
|
||||
await dbUpdateItem(req.params.id, req.params.itemId, b)
|
||||
res.json({ success: true, item: await dbGetItem(req.params.id, req.params.itemId) })
|
||||
}))
|
||||
|
||||
router.delete('/families/:id/items/:itemId', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const item = await dbGetItem(req.params.id, req.params.itemId)
|
||||
if (item?.images?.length) deleteImageFiles(item.images)
|
||||
await dbDeleteItem(req.params.id, req.params.itemId)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// 物品移动 (换盒 / 根层)
|
||||
router.put('/families/:id/items/:itemId/move', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const boxId = b.box_id && b.box_id !== 'root' ? String(b.box_id) : null
|
||||
await dbMoveItem(req.params.id, req.params.itemId, boxId)
|
||||
res.json({ success: true, box_id: boxId })
|
||||
}))
|
||||
|
||||
// ── Transfer (跨家庭转移) ──
|
||||
// 权限: 操作者必须同时是 源家庭管理员 与 目标家庭管理员 (见 §14 决策点 2)
|
||||
router.post('/families/:id/transfer', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
const dstFamilyId = String(b.dstFamilyId || '')
|
||||
if (!dstFamilyId) return res.status(400).json({ error: '缺少目标家庭' })
|
||||
const dstRole = await dbGetRole(dstFamilyId, req.user.id)
|
||||
if (dstRole !== 'admin') return res.status(403).json({ error: '仅能转移到自己拥有管理权限的家庭' })
|
||||
const dstParent = b.dstParentBoxId && b.dstParentBoxId !== 'root' ? String(b.dstParentBoxId) : null
|
||||
const r = await dbTransfer(req.params.id, {
|
||||
type: b.type,
|
||||
entityId: String(b.entityId || ''),
|
||||
dstFamilyId,
|
||||
dstParentBoxId: dstParent,
|
||||
posX: b.pos_x, posY: b.pos_y,
|
||||
operatorUserId: req.user.id,
|
||||
})
|
||||
res.json(r)
|
||||
}))
|
||||
|
||||
router.get('/families/:id/transfer-logs', requireAuth, requireAdmin, asyncH(async (req, res) => {
|
||||
res.json(await dbListTransferLogs(req.params.id))
|
||||
}))
|
||||
|
||||
// ── Stats ──
|
||||
router.get('/families/:id/stats', requireAuth, requireMember, asyncH(async (req, res) => {
|
||||
res.json(await dbGetFamilyStats(req.params.id))
|
||||
}))
|
||||
|
||||
// ── MCP (P1: AI 助手查询) ──
|
||||
router.get('/mcp/tools', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(mcpTools())
|
||||
}))
|
||||
|
||||
router.post('/mcp/call', requireAuth, asyncH(async (req, res) => {
|
||||
const { tool_name, args } = req.body || {}
|
||||
if (!tool_name) return res.status(400).json({ error: '缺少 tool_name' })
|
||||
const r = await mcpCall(tool_name, args || {}, req.user.id)
|
||||
res.json(r.value)
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,169 @@
|
||||
// 图片: multipart 上传 + 短时签名 URL 鉴权 (决策点 1)
|
||||
// - 上传: POST /api/images (登录即可, 归属家庭由后续挂到物品时确定)
|
||||
// - 签名: POST /api/images/sign {path} -> {url}, 15 分钟内有效, 校验家庭成员
|
||||
// - 读取: GET /api/images/:familyId/:file?exp=&sig=, 校验签名与成员身份
|
||||
// - 物理文件: <UPLOAD_DIR>/<familyId>/<uuid>.<ext>, 不落入任何静态目录
|
||||
import { Router } from 'express'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import multer from 'multer'
|
||||
import * as auth from '../auth.js'
|
||||
import { dbGetRole, dbListFamiliesForUser } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve(process.cwd(), 'data', 'uploads')
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
const SIGN_TTL_MS = 15 * 60 * 1000
|
||||
const MAX_SIZE = 8 * 1024 * 1024
|
||||
|
||||
// 签名密钥: 优先环境变量; 否则持久化到 data/.image-secret, 保证重启后旧签名仍可校验
|
||||
function secret() {
|
||||
if (process.env.IMAGE_SECRET) return process.env.IMAGE_SECRET
|
||||
const f = path.resolve(process.cwd(), 'data', '.image-secret')
|
||||
fs.mkdirSync(path.dirname(f), { recursive: true })
|
||||
if (fs.existsSync(f)) return fs.readFileSync(f, 'utf8').trim()
|
||||
const s = crypto.randomBytes(32).toString('hex')
|
||||
fs.writeFileSync(f, s, { mode: 0o600 })
|
||||
return s
|
||||
}
|
||||
|
||||
const extWhitelist = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||
|
||||
async function requireAuth(req, res, next) {
|
||||
try {
|
||||
const h = req.headers.authorization || ''
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null
|
||||
const user = await auth.resolveSession(token)
|
||||
if (!user) return res.status(401).json({ error: '未登录或登录已过期' })
|
||||
req.user = user
|
||||
next()
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (_req, file, cb) => cb(null, crypto.randomUUID() + path.extname(file.originalname).toLowerCase()),
|
||||
}),
|
||||
limits: { fileSize: MAX_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
cb(extWhitelist.has(ext) ? null : new Error('仅支持 jpg/png/webp/gif'), extWhitelist.has(ext))
|
||||
},
|
||||
})
|
||||
|
||||
function sign(fileKey, exp) {
|
||||
return crypto.createHmac('sha256', secret()).update(`${exp}:${fileKey}`).digest('hex')
|
||||
}
|
||||
|
||||
// 上传: 存到 staging 目录, 返回临时文件名 (带 familyId 前缀由客户端后续挂载时确定)
|
||||
router.post('/images', requireAuth, upload.single('file'), asyncH(async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: '未收到文件' })
|
||||
const name = req.file.filename
|
||||
res.json({ path: name, name, size: req.file.size })
|
||||
}))
|
||||
|
||||
// 生成短时签名 URL; path 形如 <familyId>/<fileName> 或 <fileName>
|
||||
router.post('/images/sign', requireAuth, asyncH(async (req, res) => {
|
||||
const rawPath = String(req.body?.path || '').trim()
|
||||
if (!rawPath) return res.status(400).json({ error: '缺少 path' })
|
||||
const parts = rawPath.split('/')
|
||||
const fileName = parts[parts.length - 1]
|
||||
const familyId = parts.length >= 2 ? parts[0] : null
|
||||
// 旧格式 (无 familyId 前缀): 从用户所属家庭中匹配该文件
|
||||
let fileKey
|
||||
if (familyId) {
|
||||
const role = await dbGetRole(familyId, req.user.id)
|
||||
if (role) fileKey = `${familyId}/${fileName}`
|
||||
} else {
|
||||
const families = await dbListFamiliesForUser(req.user.id)
|
||||
for (const f of families) {
|
||||
const p = path.join(UPLOAD_DIR, f.id, fileName)
|
||||
if (fs.existsSync(p)) { fileKey = `${f.id}/${fileName}`; break }
|
||||
const p2 = path.join(UPLOAD_DIR, fileName)
|
||||
if (fs.existsSync(p2)) { fileKey = fileName; break }
|
||||
}
|
||||
}
|
||||
if (!fileKey) return res.status(403).json({ error: '无权访问该图片' })
|
||||
res.json({ url: signedUrl(fileKey) })
|
||||
}))
|
||||
|
||||
function signedUrl(fileKey) {
|
||||
const exp = Date.now() + SIGN_TTL_MS
|
||||
const sig = sign(fileKey, exp)
|
||||
return `/api/images/${fileKey}?exp=${exp}&sig=${sig}`
|
||||
}
|
||||
|
||||
// 读取: 验签即鉴权 (签名只能由服务端在成员校验通过后签发, 15 分钟过期)
|
||||
// 不要求 Bearer 头, 以便 <img> 直接加载 (决策点 1: 短时签名 URL 免登录浏览)
|
||||
router.get('/images/:familyId/:file', asyncH(async (req, res) => {
|
||||
const { familyId, file } = req.params
|
||||
const { exp, sig } = req.query
|
||||
if (!exp || !sig) return res.status(403).json({ error: '缺少签名参数' })
|
||||
const expNum = Number(exp)
|
||||
if (!Number.isFinite(expNum) || expNum < Date.now()) return res.status(403).json({ error: '链接已过期' })
|
||||
const fileKey = `${familyId}/${file}`
|
||||
const expect = sign(fileKey, expNum)
|
||||
const sigBuf = Buffer.from(String(sig), 'utf8')
|
||||
const expectBuf = Buffer.from(expect, 'utf8')
|
||||
if (sigBuf.length !== expectBuf.length || !crypto.timingSafeEqual(sigBuf, expectBuf)) {
|
||||
return res.status(403).json({ error: '签名无效' })
|
||||
}
|
||||
const full = path.join(UPLOAD_DIR, familyId, file)
|
||||
if (!fs.existsSync(full)) return res.status(404).json({ error: '文件不存在' })
|
||||
res.sendFile(full)
|
||||
}))
|
||||
|
||||
// 旧格式兼容: /api/images/:file (无家庭前缀, 文件名反查; 同样验签)
|
||||
router.get('/images/:file([a-zA-Z0-9-]+[.][a-zA-Z0-9]+)', asyncH(async (req, res) => {
|
||||
const { file } = req.params
|
||||
const { exp, sig } = req.query
|
||||
if (!exp || !sig) return res.status(403).json({ error: '缺少签名参数' })
|
||||
const expNum = Number(exp)
|
||||
if (!Number.isFinite(expNum) || expNum < Date.now()) return res.status(403).json({ error: '链接已过期' })
|
||||
const fileKey = file
|
||||
const expect = sign(fileKey, expNum)
|
||||
const sigBuf = Buffer.from(String(sig), 'utf8')
|
||||
const expectBuf = Buffer.from(expect, 'utf8')
|
||||
if (sigBuf.length !== expectBuf.length || !crypto.timingSafeEqual(sigBuf, expectBuf)) {
|
||||
return res.status(403).json({ error: '签名无效' })
|
||||
}
|
||||
const full = path.join(UPLOAD_DIR, file)
|
||||
if (!fs.existsSync(full)) return res.status(404).json({ error: '文件不存在' })
|
||||
res.sendFile(full)
|
||||
}))
|
||||
|
||||
// 物品保存时把暂存的上传文件归入家庭目录, 得到规范路径 <familyId>/<file>
|
||||
// 返回清理后的路径数组; 已是本家庭路径的保持不变
|
||||
export function finalizeImages(images, familyId) {
|
||||
const dir = path.join(UPLOAD_DIR, familyId)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const out = []
|
||||
for (const img of images || []) {
|
||||
const s = String(img).trim()
|
||||
if (!s) continue
|
||||
if (s.startsWith(familyId + '/')) { out.push(s); continue }
|
||||
const fileName = s.includes('/') ? s.split('/').pop() : s
|
||||
const staging = path.join(UPLOAD_DIR, fileName)
|
||||
if (!fs.existsSync(staging)) continue
|
||||
const dest = path.join(dir, fileName)
|
||||
if (!fs.existsSync(dest)) fs.copyFileSync(staging, dest)
|
||||
try { fs.unlink(staging) } catch { /* ignore */ }
|
||||
out.push(`${familyId}/${fileName}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 删除物品时顺带清理其图片文件
|
||||
export function deleteImageFiles(images) {
|
||||
for (const img of images || []) {
|
||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, img)) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export default router
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
|
||||
import { useAppStore } from "./stores/app";
|
||||
import { onToast } from "./lib/toast";
|
||||
import Login from "./pages/Login";
|
||||
import Home from "./pages/Home";
|
||||
import Boxes from "./pages/Boxes";
|
||||
import BoxDetail from "./pages/BoxDetail";
|
||||
import ItemList from "./pages/ItemList";
|
||||
import ItemDetail from "./pages/ItemDetail";
|
||||
import ItemEdit from "./pages/ItemEdit";
|
||||
import FamilyManage from "./pages/FamilyManage";
|
||||
import Profile from "./pages/Profile";
|
||||
import Layout from "./components/Layout";
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const user = useAppStore((s) => s.user);
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function getStoredUser(): unknown {
|
||||
try {
|
||||
const saved = localStorage.getItem("storage_user");
|
||||
if (!saved) return null;
|
||||
const p = JSON.parse(saved);
|
||||
return p?.user ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function RootGate() {
|
||||
const user = useAppStore((s) => s.user);
|
||||
const effective = user ?? getStoredUser();
|
||||
return <Navigate to={effective ? "/home" : "/login"} replace />;
|
||||
}
|
||||
|
||||
function ToastHost() {
|
||||
const [msg, setMsg] = React.useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const off = onToast((m) => {
|
||||
setMsg(m);
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => setMsg(null), 2400);
|
||||
});
|
||||
return off;
|
||||
}, []);
|
||||
if (!msg) return null;
|
||||
return <div className="toast">{msg}</div>;
|
||||
}
|
||||
|
||||
function RouterGate() {
|
||||
const user = useAppStore((s) => s.user);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("storage_user");
|
||||
if (saved && !user) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
useAppStore.getState().setUser(parsed.user, parsed.token);
|
||||
navigate("/home", { replace: true });
|
||||
} catch { /* ignore */ }
|
||||
} else if (user) {
|
||||
localStorage.setItem("storage_user", JSON.stringify(useAppStore.getState()));
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastHost />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootGate />} />
|
||||
<Route path="/login" element={<div className="page-enter"><Login /></div>} />
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/home" element={<Home />} />
|
||||
<Route path="/boxes" element={<Boxes />} />
|
||||
<Route path="/boxes/:id" element={<BoxDetail />} />
|
||||
<Route path="/items" element={<ItemList />} />
|
||||
<Route path="/items/new" element={<ItemEdit />} />
|
||||
<Route path="/items/:id/edit" element={<ItemEdit />} />
|
||||
<Route path="/items/:id" element={<ItemDetail />} />
|
||||
<Route path="/families" element={<FamilyManage />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<RouterGate />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// 统一 API 基址: 生产同源 (/api), 也可用 VITE_API_BASE 强制绝对地址
|
||||
export const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "";
|
||||
@@ -0,0 +1,64 @@
|
||||
// 图片上传与短时签名 URL (与 server/routes/images.js 对应)
|
||||
import { API_BASE } from "./config";
|
||||
import { getToken } from "./index";
|
||||
|
||||
function base(p: string) {
|
||||
return API_BASE ? API_BASE + p : p;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const t = getToken();
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json", ...authHeaders() };
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(base(path), { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
} catch {
|
||||
throw new Error("网络错误,请检查网络连接");
|
||||
}
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch { /* no body */ }
|
||||
if (!res.ok) {
|
||||
const msg = (data as { error?: string } | null)?.error ?? `请求失败 (${res.status})`;
|
||||
if (res.status === 401) localStorage.removeItem("storage_user");
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// 上传: multipart/form-data, 返回暂存文件名 (挂到物品后由服务端归入家庭目录)
|
||||
export async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(base("/api/images"), { method: "POST", headers: authHeaders(), body: fd });
|
||||
} catch {
|
||||
throw new Error("网络错误,请检查网络连接");
|
||||
}
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch { /* no body */ }
|
||||
if (!res.ok) {
|
||||
const msg = (data as { error?: string } | null)?.error ?? `上传失败 (${res.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return (data as { path: string }).path;
|
||||
}
|
||||
|
||||
// 生成短时签名 URL (15 分钟), 供 <img> 直接使用
|
||||
export function signImagePath(path: string) {
|
||||
return request<{ url: string }>("POST", "/api/images/sign", { path });
|
||||
}
|
||||
|
||||
// 带鉴权的输入框支持 (web 浏览器限制多, 移动端/桌面经 Tauri shell 无缝)
|
||||
export function imageAuthHeader(): Record<string, string> {
|
||||
const t = getToken();
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// 数据层统一走 HTTP API (服务端 DuckDB 是唯一数据源)
|
||||
// token 从 localStorage["storage_user"] 快照取; 401 自动清理
|
||||
import { API_BASE } from "./config";
|
||||
import type {
|
||||
User, AuthResult, Family, FamilySummary, FamilyDetail, Category,
|
||||
Box, BoxTreeNode, Item, ItemDetail, TransferLog, FamilyStats, McpTool, Role,
|
||||
} from "../types";
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
const saved = localStorage.getItem("storage_user");
|
||||
if (!saved) return null;
|
||||
const p = JSON.parse(saved);
|
||||
return p?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function base(p: string) {
|
||||
return API_BASE ? API_BASE + p : p;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(base(path), {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new Error("网络错误,请检查网络连接");
|
||||
}
|
||||
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* 无 body */
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = (data as { error?: string; message?: string } | null)?.error
|
||||
?? (data as { message?: string } | null)?.message
|
||||
?? `请求失败 (${res.status})`;
|
||||
if (res.status === 401) localStorage.removeItem("storage_user");
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ── Auth ──
|
||||
export const sendCode = (target: string) =>
|
||||
request<{ message: string }>("POST", "/api/auth/send-code", { target }).then((r) => r.message);
|
||||
export const register = (email: string | null, phone: string | null, code: string, nickname: string) =>
|
||||
request<AuthResult>("POST", "/api/auth/register", { email, phone, code, nickname });
|
||||
export const login = (email: string | null, phone: string | null, code: string) =>
|
||||
request<AuthResult>("POST", "/api/auth/login", { email, phone, code });
|
||||
export const logout = () =>
|
||||
request<{ success: boolean }>("POST", "/api/auth/logout").then(() => undefined);
|
||||
export const fetchMe = () =>
|
||||
request<{ user: User }>("GET", "/api/auth/me").then((r) => r.user);
|
||||
export const updateMe = (nickname: string) =>
|
||||
request<{ user: User }>("PUT", "/api/auth/me", { nickname }).then((r) => r.user);
|
||||
|
||||
// ── Families ──
|
||||
export const listFamilies = () =>
|
||||
request<FamilySummary[]>("GET", "/api/families");
|
||||
export const createFamily = (name: string, icon?: string) =>
|
||||
request<Family>("POST", "/api/families", { name, icon });
|
||||
export const getFamilyDetail = (familyId: string) =>
|
||||
request<FamilyDetail>("GET", `/api/families/${familyId}`);
|
||||
export const updateFamily = (familyId: string, name: string, icon?: string) =>
|
||||
request<{ success: boolean; family: Family }>("PUT", `/api/families/${familyId}`, { name, icon });
|
||||
export const deleteFamily = (familyId: string) =>
|
||||
request<{ success: boolean }>("DELETE", `/api/families/${familyId}`);
|
||||
export const joinFamily = (familyId: string, inviteCode: string) =>
|
||||
request<{ success: boolean; message: string; familyId?: string }>("POST", `/api/families/${familyId}/join`, { invite_code: inviteCode });
|
||||
export const refreshInviteCode = (familyId: string) =>
|
||||
request<{ invite_code: string | null }>("POST", `/api/families/${familyId}/invite`, { action: "refresh" });
|
||||
export const clearInviteCode = (familyId: string) =>
|
||||
request<{ invite_code: string | null }>("POST", `/api/families/${familyId}/invite`, { action: "clear" });
|
||||
export const setMemberRole = (familyId: string, userId: string, role: Role) =>
|
||||
request<{ success: boolean }>("POST", `/api/families/${familyId}/members/${userId}/role`, { role });
|
||||
export const removeMember = (familyId: string, userId: string) =>
|
||||
request<{ success: boolean }>("DELETE", `/api/families/${familyId}/members/${userId}`);
|
||||
|
||||
// ── Categories ──
|
||||
export const listCategories = (familyId: string) =>
|
||||
request<Category[]>("GET", `/api/families/${familyId}/categories`);
|
||||
export const createCategory = (familyId: string, name: string) =>
|
||||
request<Category>("POST", `/api/families/${familyId}/categories`, { name });
|
||||
export const updateCategory = (familyId: string, catId: string, name: string) =>
|
||||
request<{ success: boolean }>("PUT", `/api/families/${familyId}/categories/${catId}`, { name });
|
||||
export const deleteCategory = (familyId: string, catId: string) =>
|
||||
request<{ success: boolean }>("DELETE", `/api/families/${familyId}/categories/${catId}`);
|
||||
|
||||
// ── Boxes ──
|
||||
export const listChildBoxes = (familyId: string, parentBoxId?: string | null) =>
|
||||
request<Box[]>("GET", `/api/families/${familyId}/boxes${parentBoxId ? `?parent=${parentBoxId}` : ""}`);
|
||||
export const fetchBoxTree = (familyId: string) =>
|
||||
request<BoxTreeNode[]>("GET", `/api/families/${familyId}/boxes?tree=1`);
|
||||
export const getBoxDetail = (familyId: string, boxId: string) =>
|
||||
request<{ box: Box; path: { id: string; name: string }[]; children: Box[] }>(
|
||||
"GET", `/api/families/${familyId}/boxes/${boxId}`);
|
||||
export const createBox = (familyId: string, data: { name: string; parent_box_id?: string | null; icon?: string; color?: string; pos_x?: number; pos_y?: number; width?: number | null; height?: number | null }) =>
|
||||
request<Box>("POST", `/api/families/${familyId}/boxes`, data);
|
||||
export const updateBox = (familyId: string, boxId: string, data: { name?: string; icon?: string; color?: string; width?: number | null; height?: number | null }) =>
|
||||
request<{ success: boolean; box: Box }>("PUT", `/api/families/${familyId}/boxes/${boxId}`, data);
|
||||
export const moveBox = (familyId: string, boxId: string, parent_box_id: string | null, pos_x?: number, pos_y?: number) =>
|
||||
request<{ success: boolean; box: Box }>("PUT", `/api/families/${familyId}/boxes/${boxId}/move`, { parent_box_id, pos_x, pos_y });
|
||||
export const setBoxPosition = (familyId: string, boxId: string, pos_x: number, pos_y: number) =>
|
||||
request<{ success: boolean }>("PUT", `/api/families/${familyId}/boxes/${boxId}/position`, { pos_x, pos_y });
|
||||
export const deleteBox = (familyId: string, boxId: string) =>
|
||||
request<{ success: boolean }>("DELETE", `/api/families/${familyId}/boxes/${boxId}`);
|
||||
|
||||
// ── Items ──
|
||||
export interface ItemFilters {
|
||||
box?: string | null;
|
||||
recursive?: boolean;
|
||||
loose?: boolean;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
q?: string;
|
||||
favorite?: boolean;
|
||||
expiring?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
export const listItems = (familyId: string, f: ItemFilters = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (f.box) p.set("box", f.box);
|
||||
if (f.recursive) p.set("recursive", "1");
|
||||
if (f.loose) p.set("loose", "1");
|
||||
if (f.category) p.set("category", f.category);
|
||||
if (f.tag) p.set("tag", f.tag);
|
||||
if (f.q) p.set("q", f.q);
|
||||
if (f.favorite) p.set("favorite", "1");
|
||||
if (f.expiring) p.set("expiring", "1");
|
||||
if (f.limit) p.set("limit", String(f.limit));
|
||||
const qs = p.toString();
|
||||
return request<Item[]>("GET", `/api/families/${familyId}/items${qs ? `?${qs}` : ""}`);
|
||||
};
|
||||
export const createItem = (familyId: string, data: Partial<Item>) =>
|
||||
request<Item>("POST", `/api/families/${familyId}/items`, data);
|
||||
export const getItemDetail = (familyId: string, itemId: string) =>
|
||||
request<ItemDetail>("GET", `/api/families/${familyId}/items/${itemId}`);
|
||||
export const updateItem = (familyId: string, itemId: string, data: Partial<Item>) =>
|
||||
request<{ success: boolean; item: Item }>("PUT", `/api/families/${familyId}/items/${itemId}`, data);
|
||||
export const deleteItem = (familyId: string, itemId: string) =>
|
||||
request<{ success: boolean }>("DELETE", `/api/families/${familyId}/items/${itemId}`);
|
||||
export const moveItem = (familyId: string, itemId: string, box_id: string | null) =>
|
||||
request<{ success: boolean; box_id: string | null }>("PUT", `/api/families/${familyId}/items/${itemId}/move`, { box_id });
|
||||
|
||||
// ── Transfer ──
|
||||
export const transferEntity = (familyId: string, data: { type: "item" | "box"; entityId: string; dstFamilyId: string; dstParentBoxId?: string | null; pos_x?: number; pos_y?: number }) =>
|
||||
request<{ ok: boolean; type: string; entityId: string; dstFamilyId: string }>("POST", `/api/families/${familyId}/transfer`, data);
|
||||
export const listTransferLogs = (familyId: string) =>
|
||||
request<TransferLog[]>("GET", `/api/families/${familyId}/transfer-logs`);
|
||||
|
||||
// ── Stats ──
|
||||
export const getFamilyStats = (familyId: string) =>
|
||||
request<FamilyStats>("GET", `/api/families/${familyId}/stats`);
|
||||
|
||||
// ── MCP ──
|
||||
export const mcpListTools = () =>
|
||||
request<McpTool[]>("GET", "/api/mcp/tools");
|
||||
export const mcpCallTool = (toolName: string, args: Record<string, unknown>) =>
|
||||
request<unknown>("POST", "/api/mcp/call", { tool_name: toolName, args });
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useRef, useState, type PointerEvent } from "react";
|
||||
import type { BoxNode } from "../lib/boxTree";
|
||||
|
||||
export const NORM = 1000; // 归一化坐标系上限
|
||||
|
||||
// 盒子画布: 父容器内容区 = 0~1000 归一化坐标系, 子盒按 pos_x/pos_y 绝对定位
|
||||
// v1 尺寸: 未设置 width/height 时用固定瓦片; 设置了则按宽度比例缩放 (width/10 = %)
|
||||
export default function BoxCanvas({
|
||||
boxes, canWrite, onOpen, onEdit, onPositionChange, onAutoArrange, containerKey,
|
||||
}: {
|
||||
boxes: BoxNode[];
|
||||
canWrite: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
onPositionChange?: (id: string, x: number, y: number) => void;
|
||||
onAutoArrange?: (positions: { id: string; x: number; y: number }[]) => void;
|
||||
containerKey: string;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragPos, setDragPos] = useState<Record<string, { x: number; y: number }>>({});
|
||||
const drag = useRef<{ id: string; startX: number; startY: number; moved: boolean } | null>(null);
|
||||
|
||||
const toNorm = (clientX: number, clientY: number) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect || !rect.width || !rect.height) return { x: 0, y: 0 };
|
||||
const x = Math.max(0, Math.min(NORM, Math.round(((clientX - rect.left) / rect.width) * NORM)));
|
||||
const y = Math.max(0, Math.min(NORM, Math.round(((clientY - rect.top) / rect.height) * NORM)));
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent, boxId: string) => {
|
||||
if (!canWrite) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
drag.current = { id: boxId, startX: e.clientX, startY: e.clientY, moved: false };
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const d = drag.current;
|
||||
if (!d || !canWrite) return;
|
||||
if (Math.abs(e.clientX - d.startX) > 6 || Math.abs(e.clientY - d.startY) > 6) d.moved = true;
|
||||
if (!d.moved) return;
|
||||
const pos = toNorm(e.clientX, e.clientY);
|
||||
setDragPos((prev) => ({ ...prev, [d.id]: pos }));
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
const d = drag.current;
|
||||
drag.current = null;
|
||||
if (!d || !d.moved) return;
|
||||
const pos = dragPos[d.id];
|
||||
if (pos) onPositionChange?.(d.id, pos.x, pos.y);
|
||||
};
|
||||
|
||||
const arrange = () => {
|
||||
if (!onAutoArrange) return;
|
||||
const positions: { id: string; x: number; y: number }[] = [];
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(boxes.length * 1.5)));
|
||||
boxes.forEach((b, i) => {
|
||||
positions.push({ id: b.id, x: (i % cols) * 240, y: Math.floor(i / cols) * 240 });
|
||||
});
|
||||
onAutoArrange(positions);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{onAutoArrange && boxes.length > 1 && (
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "6px 14px" }} onClick={arrange}>自动整理</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={containerKey}
|
||||
ref={containerRef}
|
||||
className="box-canvas"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
{boxes.length === 0 && (
|
||||
<div className="canvas-empty">暂无盒子{canWrite ? " · 点击下方新建" : ""}</div>
|
||||
)}
|
||||
{boxes.map((b) => {
|
||||
const dp = dragPos[b.id];
|
||||
const x = dp?.x ?? b.pos_x ?? 0;
|
||||
const y = dp?.y ?? b.pos_y ?? 0;
|
||||
const sizeW = b.width && b.width > 0 ? `${Math.min(70, b.width / 10)}%` : 96;
|
||||
const sizeH = b.height && b.height > 0 ? `${Math.min(70, b.height / 10)}%` : 96;
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="canvas-box"
|
||||
style={{
|
||||
left: `${(x / NORM) * 100}%`,
|
||||
top: `${(y / NORM) * 100}%`,
|
||||
width: sizeW,
|
||||
height: sizeH,
|
||||
background: b.color || "var(--accent-light)",
|
||||
...(canWrite ? { touchAction: "none" as const } : {}),
|
||||
}}
|
||||
onPointerDown={(e) => onPointerDown(e, b.id)}
|
||||
onClick={() => { if (!drag.current?.moved) onOpen(b.id); }}
|
||||
>
|
||||
<span className="canvas-box-icon">{b.icon}</span>
|
||||
<span className="canvas-box-name" title={b.name}>{b.name}</span>
|
||||
<span className="canvas-box-count">{b.total_item_count ?? 0} 件</span>
|
||||
{canWrite && onEdit && (
|
||||
<button className="canvas-box-edit" onClick={(e) => { e.stopPropagation(); onEdit(b.id); }}>…</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Modal from "./Modal";
|
||||
|
||||
const COLORS = ["#E8C4A0", "#D9E8C4", "#C4DCE8", "#E8C4D9", "#E0D6F0", "#F0E0B8", "#C4E8E0"];
|
||||
|
||||
// 新建 / 编辑盒子: 名称、图标、颜色、尺寸(width/height 归一化 0~1000, 可选)
|
||||
export default function BoxFormModal({
|
||||
open, title, initial, onClose, onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
initial?: { name: string; icon: string; color: string; width?: number | null; height?: number | null };
|
||||
onClose?: () => void;
|
||||
onSubmit?: (data: { name: string; icon: string; color: string; width?: number | null; height?: number | null }) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState("📦");
|
||||
const [color, setColor] = useState(COLORS[0]);
|
||||
const [width, setWidth] = useState("");
|
||||
const [height, setHeight] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial?.name ?? "");
|
||||
setIcon(initial?.icon ?? "📦");
|
||||
setColor(initial?.color ?? COLORS[0]);
|
||||
setWidth(initial?.width ? String(initial.width) : "");
|
||||
setHeight(initial?.height ? String(initial.height) : "");
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const icons = ["📦", "🗄️", "🚪", "🛏️", "🛋️", "🪑", "🍳", "🚿", "👔", "📚", "🎮", "🧰", "📺", "🧺", "👟", "🍷"];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title ?? (initial ? "编辑盒子" : "新建盒子")}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-outline" onClick={onClose}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSubmit?.({
|
||||
name: name.trim(),
|
||||
icon: icon || "📦",
|
||||
color,
|
||||
width: width ? Math.max(0, Math.min(1000, Number(width))) : null,
|
||||
height: height ? Math.max(0, Math.min(1000, Number(height))) : null,
|
||||
});
|
||||
onClose?.();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="input-group">
|
||||
<label>名称</label>
|
||||
<input className="input" placeholder="如:客厅 / 柜子1 / 收纳盒" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>图标</label>
|
||||
<div className="icon-grid">
|
||||
{icons.map((i) => (
|
||||
<button key={i} className={"icon-opt" + (icon === i ? " icon-opt-active" : "")} onClick={() => setIcon(i)}>{i}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>颜色</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{COLORS.map((c) => (
|
||||
<button key={c} className="color-opt" style={{ background: c, borderColor: color === c ? "var(--text-primary)" : "transparent" }} onClick={() => setColor(c)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>尺寸(可选,归一化 0~1000;留空则自动)</label>
|
||||
<div className="code-row">
|
||||
<input className="input" inputMode="numeric" placeholder="宽" value={width} onChange={(e) => setWidth(e.target.value)} />
|
||||
<input className="input" inputMode="numeric" placeholder="高" value={height} onChange={(e) => setHeight(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PathEntry } from "../types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function Breadcrumb({ path }: { path: PathEntry[] }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="breadcrumb">
|
||||
<span className="breadcrumb-item" onClick={() => navigate("/boxes")}>🏠 客厅</span>
|
||||
{path.map((p) => (
|
||||
<span key={p.id} className="breadcrumb-entry">
|
||||
<span className="breadcrumb-sep"> / </span>
|
||||
<span className="breadcrumb-item" onClick={() => navigate(`/boxes/${p.id}`)}>{p.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "./Modal";
|
||||
|
||||
export default function ConfirmModal({
|
||||
open, title = "确认操作", message, danger = true, onCancel, onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
onCancel?: () => void;
|
||||
onConfirm?: () => void | Promise<unknown>;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={onCancel}>
|
||||
<p style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.6 }}>{message}</p>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-outline" onClick={onCancel}>取消</button>
|
||||
<button
|
||||
className={"btn" + (danger ? " btn-danger" : " btn-primary")}
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (onConfirm) await onConfirm();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? "处理中..." : "确定"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Modal from "./Modal";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { indexBoxes } from "../lib/boxTree";
|
||||
|
||||
// 盒子目标选择器: 根层 / 家庭盒子树 (可选择"放进某盒子内部")
|
||||
// excludeBoxId: 移动盒子时, 排除自身及其全部后代
|
||||
export default function DestinationPicker({
|
||||
open, title = "选择位置", excludeBoxId, onClose, onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
excludeBoxId?: string;
|
||||
onClose?: () => void;
|
||||
onSelect?: (parentId: string | null) => void;
|
||||
}) {
|
||||
const boxes = useAppStore((s) => s.boxes);
|
||||
if (!open) return null;
|
||||
|
||||
const index = indexBoxes(boxes);
|
||||
const excluded = new Set<string>();
|
||||
if (excludeBoxId) {
|
||||
excluded.add(excludeBoxId);
|
||||
const stack = [excludeBoxId];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
for (const c of index.byParent.get(cur) ?? []) {
|
||||
excluded.add(c.id);
|
||||
stack.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = index.roots.filter((n) => !excluded.has(n.id));
|
||||
|
||||
const renderNodes = (nodes: typeof options, depth: number): ReactNode => {
|
||||
return nodes.map((n) => (
|
||||
<div key={n.id}>
|
||||
<button
|
||||
className="picker-option"
|
||||
style={{ paddingLeft: 12 + depth * 16 }}
|
||||
onClick={() => onSelect?.(n.id)}
|
||||
>
|
||||
<span>{n.icon}</span> {n.name}
|
||||
<span className="picker-count">{n.total_item_count} 件</span>
|
||||
</button>
|
||||
{renderNodes((index.byParent.get(n.id) ?? []).filter((c) => !excluded.has(c.id)), depth + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={onClose}>
|
||||
<button className="picker-option" onClick={() => onSelect?.(null)}>
|
||||
<span>🏠</span> 家庭根层(不放入盒子)
|
||||
</button>
|
||||
{options.length === 0 && <p style={{ color: "var(--text-tertiary)", fontSize: 13, margin: "8px 0" }}>暂无可用的盒子</p>}
|
||||
<div className="picker-tree">{renderNodes(options, 0)}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAppStore, selectCurrentFamily } from "../stores/app";
|
||||
import { listFamilies, fetchBoxTree, listCategories } from "../api";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAppStore((s) => s.user);
|
||||
const families = useAppStore((s) => s.families);
|
||||
const currentFamily = useAppStore(selectCurrentFamily);
|
||||
const setFamilies = useAppStore((s) => s.setFamilies);
|
||||
const setCurrentFamily = useAppStore((s) => s.setCurrentFamily);
|
||||
const setBoxes = useAppStore((s) => s.setBoxes);
|
||||
const setCategories = useAppStore((s) => s.setCategories);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// 加载用户加入的家庭; 无家庭时进入 /families 引导创建
|
||||
useEffect(() => {
|
||||
if (!user || families.length) return;
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
listFamilies()
|
||||
.then((fs) => setFamilies(fs))
|
||||
.catch((e) => showToast(String(e)))
|
||||
.finally(() => { loadingRef.current = false; });
|
||||
}, [user, families.length, setFamilies]);
|
||||
|
||||
// 家庭切换后: 重载该家庭的全量盒子树与分类 (页面各自再拉统计/列表)
|
||||
useEffect(() => {
|
||||
if (!user || !currentFamily?.id) return;
|
||||
fetchBoxTree(currentFamily.id).then(setBoxes).catch(() => setBoxes([]));
|
||||
listCategories(currentFamily.id).then(setCategories).catch(() => setCategories([]));
|
||||
}, [user, currentFamily?.id, setBoxes, setCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
}, [location.pathname]);
|
||||
|
||||
if (families.length === 0 && location.pathname !== "/families" && location.pathname !== "/profile") {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div style={{ fontSize: 48 }}>📦</div>
|
||||
<h2>还没有家庭</h2>
|
||||
<p>创建一个家庭,开始整理你的所有物品</p>
|
||||
<button className="btn btn-primary" onClick={() => navigate("/families")}>去创建</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = (path: string) =>
|
||||
path === "/home" ? location.pathname === "/home" : location.pathname.startsWith(path);
|
||||
|
||||
const goAdd = () => {
|
||||
if (!currentFamily) { showToast("请先创建或加入家庭"); return; }
|
||||
if (currentFamily.role === "member") { showToast("只读成员不可新增物品"); return; }
|
||||
navigate("/items/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<button className="family-chip" onClick={() => navigate("/families")}>
|
||||
<span>{currentFamily?.icon ?? "🏠"}</span>
|
||||
<span className="family-name">{currentFamily?.name ?? "未选择家庭"}</span>
|
||||
<span className="role-badge">{currentFamily?.role === "admin" ? "管理员" : "成员"}</span>
|
||||
</button>
|
||||
{families.length > 1 && (
|
||||
<select
|
||||
className="family-switch"
|
||||
value={currentFamily?.id ?? ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setCurrentFamily(e.target.value);
|
||||
}}
|
||||
>
|
||||
{families.map((f) => <option key={f.id} value={f.id}>{f.icon} {f.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="app-content" ref={contentRef}>
|
||||
<div key={location.pathname} className="page-enter">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="bottom-nav">
|
||||
<button className={"nav-item" + (isActive("/home") ? " active" : "")} onClick={() => navigate("/home")}>
|
||||
<span className="nav-icon">🏠</span><span>首页</span>
|
||||
</button>
|
||||
<button className={"nav-item" + (isActive("/boxes") ? " active" : "")} onClick={() => navigate("/boxes")}>
|
||||
<span className="nav-icon">🗃️</span><span>盒子</span>
|
||||
</button>
|
||||
<button className="nav-item center-nav-btn" onClick={goAdd}>
|
||||
<span className="fab-circle">+</span>
|
||||
</button>
|
||||
<button className={"nav-item" + (isActive("/items") ? " active" : "")} onClick={() => navigate("/items")}>
|
||||
<span className="nav-icon">📦</span><span>物品</span>
|
||||
</button>
|
||||
<button className={"nav-item" + (isActive("/profile") ? " active" : "")} onClick={() => navigate("/profile")}>
|
||||
<span className="nav-icon">👤</span><span>我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Modal({
|
||||
open, title, onClose, children, footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
{title && <div className="modal-title">{title}</div>}
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { signImagePath } from "../api/images";
|
||||
|
||||
// 图片 URL 内存缓存, 避免同一张图反复请求签名
|
||||
const urlCache = new Map<string, string>();
|
||||
|
||||
export default function SignedImg({
|
||||
path, alt = "", className, style,
|
||||
}: {
|
||||
path: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [url, setUrl] = useState<string | null>(urlCache.get(path) ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const cached = urlCache.get(path);
|
||||
if (cached) { setUrl(cached); return; }
|
||||
signImagePath(path)
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
urlCache.set(path, r.url);
|
||||
setUrl(r.url);
|
||||
})
|
||||
.catch(() => { /* 签名失败不展示 */ });
|
||||
return () => { alive = false; };
|
||||
}, [path]);
|
||||
|
||||
if (!url) return <div className={className} style={{ ...style, background: "var(--bg-warm)" }} />;
|
||||
return <img className={className} style={style} src={url} alt={alt} loading="lazy" />;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "./Modal";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { transferEntity } from "../api";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
// 跨家庭转移: 将物品/整盒转移到"自己拥有管理权限"的另一个家庭 (决策点 2)
|
||||
export default function TransferModal({
|
||||
open, type, entityId, onClose, onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
type: "item" | "box";
|
||||
entityId: string;
|
||||
onClose?: () => void;
|
||||
onDone?: () => void;
|
||||
}) {
|
||||
const sourceFamilyId = useAppStore((s) => s.currentFamilyId);
|
||||
const families = useAppStore((s) => s.families);
|
||||
const [dst, setDst] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const eligible = families.filter((f) => f.id !== sourceFamilyId && f.role === "admin");
|
||||
|
||||
const doTransfer = async () => {
|
||||
if (!dst || !sourceFamilyId) { showToast("请选择目标家庭"); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await transferEntity(sourceFamilyId, {
|
||||
type, entityId, dstFamilyId: dst, dstParentBoxId: null,
|
||||
});
|
||||
showToast(`已转移到目标家庭`);
|
||||
onDone?.();
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={type === "box" ? "转移整个盒子(含全部子盒与物品)" : "转移物品"}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-outline" onClick={onClose}>取消</button>
|
||||
<button className="btn btn-primary" disabled={!dst || loading} onClick={doTransfer}>
|
||||
{loading ? "转移中..." : "转移"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{eligible.length === 0 ? (
|
||||
<p style={{ color: "var(--text-tertiary)", fontSize: 13 }}>没有可转移的家庭(需要你拥有该家庭的管理权限)</p>
|
||||
) : (
|
||||
<div className="picker-tree">
|
||||
{eligible.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={"picker-option" + (dst === f.id ? " picker-option-active" : "")}
|
||||
onClick={() => setDst(f.id)}
|
||||
>
|
||||
<span>{f.icon}</span> {f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{dst && <p style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 10 }}>转移后将从当前家庭移除,转移到目标家庭根层。</p>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// 盒子树计算工具: 由扁平的 boxes 数组构建层级、路径、计数
|
||||
// 前端服务端共用同一套"扁平 + JS 建树"的算法思路
|
||||
import type { Box, PathEntry } from "../types";
|
||||
|
||||
export interface BoxNode extends Box {
|
||||
depth: number;
|
||||
path: PathEntry[];
|
||||
childCount: number;
|
||||
totalItemCount: number;
|
||||
}
|
||||
|
||||
export interface BoxIndex {
|
||||
byId: Map<string, BoxNode>;
|
||||
roots: BoxNode[];
|
||||
byParent: Map<string, BoxNode[]>; // key: 父盒 id 或 "ROOT"
|
||||
}
|
||||
|
||||
export function indexBoxes(boxes: Box[]): BoxIndex {
|
||||
const byParent = new Map<string, BoxNode[]>();
|
||||
|
||||
const flattened = boxes.map((b) => {
|
||||
const path: PathEntry[] = [];
|
||||
let curId: string | undefined = b.id;
|
||||
const guard = new Set<string>();
|
||||
while (curId && !guard.has(curId)) {
|
||||
guard.add(curId);
|
||||
const cur = boxes.find((x) => x.id === curId);
|
||||
if (!cur) break;
|
||||
path.unshift({ id: cur.id, name: cur.name });
|
||||
curId = cur.parent_box_id ?? undefined;
|
||||
}
|
||||
return { box: b, path };
|
||||
});
|
||||
|
||||
const nodeMap = new Map<string, BoxNode>();
|
||||
for (const { box, path } of flattened) {
|
||||
const node: BoxNode = {
|
||||
...box,
|
||||
depth: path.length,
|
||||
path,
|
||||
childCount: 0,
|
||||
totalItemCount: box.total_item_count ?? box.direct_item_count ?? 0,
|
||||
};
|
||||
nodeMap.set(box.id, node);
|
||||
}
|
||||
for (const box of boxes) {
|
||||
const parentKey = box.parent_box_id ?? "ROOT";
|
||||
if (!byParent.has(parentKey)) byParent.set(parentKey, []);
|
||||
byParent.get(parentKey)!.push(nodeMap.get(box.id)!);
|
||||
if (box.parent_box_id && nodeMap.has(box.parent_box_id)) {
|
||||
nodeMap.get(box.parent_box_id)!.childCount += 1;
|
||||
}
|
||||
}
|
||||
// 递归汇总物品数
|
||||
const counted = new Set<string>();
|
||||
const sumTotal = (id: string): number => {
|
||||
if (counted.has(id)) return nodeMap.get(id)!.totalItemCount;
|
||||
counted.add(id);
|
||||
for (const child of byParent.get(id) ?? []) {
|
||||
nodeMap.get(id)!.totalItemCount += sumTotal(child.id);
|
||||
}
|
||||
return nodeMap.get(id)!.totalItemCount;
|
||||
};
|
||||
for (const box of boxes) sumTotal(box.id);
|
||||
|
||||
return { byId: nodeMap, roots: byParent.get("ROOT") ?? [], byParent };
|
||||
}
|
||||
|
||||
export function childrenOf(index: BoxIndex, parentId: string | null): BoxNode[] {
|
||||
return index.byParent.get(parentId ?? "ROOT") ?? [];
|
||||
}
|
||||
|
||||
export function pathOf(index: BoxIndex, boxId: string): PathEntry[] {
|
||||
return index.byId.get(boxId)?.path ?? [];
|
||||
}
|
||||
|
||||
// 供表单/树选择器使用的扁平树状选项
|
||||
export function flattenTree(nodes: BoxNode[], depth = 0): { id: string; label: string; depth: number }[] {
|
||||
const out: { id: string; label: string; depth: number }[] = [];
|
||||
for (const n of nodes) {
|
||||
out.push({ id: n.id, label: n.name, depth });
|
||||
out.push(...flattenTree(childrenOf2(n.id, nodes), depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function childrenOf2(parentId: string, nodes: BoxNode[]): BoxNode[] {
|
||||
return nodes.filter((n) => (n.parent_box_id ?? null) === parentId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 全局轻量 Toast (模块级发布订阅, 无需 Context)
|
||||
type Listener = (msg: string) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function showToast(msg: string) {
|
||||
listeners.forEach((l) => l(msg));
|
||||
}
|
||||
|
||||
export function onToast(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/global.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getBoxDetail, createBox, updateBox, moveBox, deleteBox, setBoxPosition, fetchBoxTree, listItems } from "../api";
|
||||
import type { Item } from "../types";
|
||||
import { indexBoxes, childrenOf } from "../lib/boxTree";
|
||||
import { showToast } from "../lib/toast";
|
||||
import type { PathEntry } from "../types";
|
||||
import BoxCanvas from "../components/BoxCanvas";
|
||||
import Breadcrumb from "../components/Breadcrumb";
|
||||
import BoxFormModal from "../components/BoxFormModal";
|
||||
import DestinationPicker from "../components/DestinationPicker";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import TransferModal from "../components/TransferModal";
|
||||
import SignedImg from "../components/SignedImg";
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
export default function BoxDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const familyId = useAppStore((s) => s.currentFamilyId);
|
||||
const role = useAppStore((s) => s.families.find((f) => f.id === s.currentFamilyId)?.role);
|
||||
const boxes = useAppStore((s) => s.boxes);
|
||||
const setBoxes = useAppStore((s) => s.setBoxes);
|
||||
const [path, setPath] = useState<PathEntry[]>([]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editBox, setEditBox] = useState<{ id: string; name: string; icon: string; color: string; width: number | null; height: number | null } | null>(null);
|
||||
const [actionBoxId, setActionBoxId] = useState<string | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<string | null>(null);
|
||||
const [view, setView] = useState<"canvas" | "list">("canvas");
|
||||
|
||||
const canWrite = role === "admin";
|
||||
|
||||
const refresh = () => {
|
||||
if (!familyId || !id) return;
|
||||
Promise.all([
|
||||
getBoxDetail(familyId, id),
|
||||
fetchBoxTree(familyId),
|
||||
listItems(familyId, { box: id }),
|
||||
]).then(([d, tree, its]) => {
|
||||
setPath(d.path);
|
||||
setBoxes(tree);
|
||||
setItems(its);
|
||||
}).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh(); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [familyId, id]);
|
||||
|
||||
const index = useMemo(() => indexBoxes(boxes), [boxes]);
|
||||
const childNodes = useMemo(() => (id ? childrenOf(index, id) : []), [index, id]);
|
||||
const current = id ? index.byId.get(id) : null;
|
||||
|
||||
const nextPos = () => {
|
||||
const cols = 3;
|
||||
return { x: (childNodes.length % cols) * 280, y: Math.floor(childNodes.length / cols) * 280 };
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (data: { name: string; icon: string; color: string; width?: number | null; height?: number | null }) => {
|
||||
if (!familyId) return;
|
||||
if (editBox) {
|
||||
await updateBox(familyId, editBox.id, data);
|
||||
showToast("盒子已更新");
|
||||
} else {
|
||||
const pos = nextPos();
|
||||
await createBox(familyId, { ...data, parent_box_id: id, pos_x: pos.x, pos_y: pos.y });
|
||||
showToast("盒子已创建");
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleMove = async (parentId: string | null) => {
|
||||
if (!familyId || !moveTarget) return;
|
||||
try {
|
||||
await moveBox(familyId, moveTarget, parentId);
|
||||
showToast("已移动");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setMoveTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!familyId || !deleteTarget) return;
|
||||
try {
|
||||
await deleteBox(familyId, deleteTarget);
|
||||
showToast("已删除");
|
||||
if (deleteTarget === id) navigate("/boxes");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePosition = async (bid: string, x: number, y: number) => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
await setBoxPosition(familyId, bid, x, y);
|
||||
refresh();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleAutoArrange = async (positions: { id: string; x: number; y: number }[]) => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
for (const p of positions) await setBoxPosition(familyId, p.id, p.x, p.y);
|
||||
showToast("已自动整理");
|
||||
refresh();
|
||||
} catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
const openEdit = (bid: string) => {
|
||||
const b = index.byId.get(bid);
|
||||
if (b) setEditBox({ id: b.id, name: b.name, icon: b.icon, color: b.color, width: b.width ?? null, height: b.height ?? null });
|
||||
setActionBoxId(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const actionBox = actionBoxId ? index.byId.get(actionBoxId) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header" style={{ alignItems: "flex-start", flexDirection: "column", gap: 6 }}>
|
||||
<div style={{ display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<h2 className="page-title" style={{ fontSize: 20 }}>{current?.icon} {current?.name ?? "…"}</h2>
|
||||
{canWrite && (
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "6px 12px" }} onClick={openEdit.bind(null, id!)}>编辑</button>
|
||||
)}
|
||||
</div>
|
||||
<Breadcrumb path={path} />
|
||||
</div>
|
||||
|
||||
<div className="view-switch">
|
||||
<button className={"view-switch-btn" + (view === "canvas" ? " active" : "")} onClick={() => setView("canvas")}>画布</button>
|
||||
<button className={"view-switch-btn" + (view === "list" ? " active" : "")} onClick={() => setView("list")}>列表</button>
|
||||
</div>
|
||||
|
||||
{view === "canvas" ? (
|
||||
<BoxCanvas
|
||||
containerKey={`box-${id}`}
|
||||
boxes={childNodes}
|
||||
canWrite={!!canWrite}
|
||||
onOpen={(bid) => navigate(`/boxes/${bid}`)}
|
||||
onEdit={canWrite ? (bid) => setActionBoxId(bid) : undefined}
|
||||
onPositionChange={handlePosition}
|
||||
onAutoArrange={canWrite ? handleAutoArrange : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="card" style={{ marginTop: 8 }}>
|
||||
{childNodes.length === 0 && <div className="muted">该容器内还没有子盒</div>}
|
||||
{childNodes.map((n) => (
|
||||
<div key={n.id} className="todo-row" onClick={() => navigate(`/boxes/${n.id}`)}>
|
||||
<span>{n.icon} {n.name}</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>{n.total_item_count} 件</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<button className="btn btn-secondary" style={{ marginTop: 12 }} onClick={() => { setEditBox(null); setFormOpen(true); }}>
|
||||
+ 在该容器内新建盒子
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", margin: "20px 0 10px" }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>本层物品({items.length})</div>
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "6px 12px" }} onClick={() => navigate(`/items/new?box=${id}`)}>
|
||||
+ 添加物品
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 && <div className="muted" style={{ padding: "12px 0" }}>这里还没有物品</div>}
|
||||
<div className="item-grid">
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="item-card" onClick={() => navigate(`/items/${it.id}`)}>
|
||||
{it.images[0] ? (
|
||||
<SignedImg path={it.images[0]} className="item-thumb" />
|
||||
) : (
|
||||
<div className="item-thumb" style={{ display: "flex", alignItems: "center", justifyContent: "center", fontSize: 26, background: "var(--bg-warm)" }}>📦</div>
|
||||
)}
|
||||
<div className="item-name" title={it.name}>{it.name}</div>
|
||||
{it.is_favorite && <span className="favorite-mark">♥</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal open={!!actionBoxId} title={`@${actionBox?.name ?? ""}`} onClose={() => setActionBoxId(null)}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<button className="btn btn-secondary" onClick={() => openEdit(actionBoxId!)}>编辑名称/图标/尺寸</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setMoveTarget(actionBoxId); setActionBoxId(null); }}>移动到其他位置</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setTransferTarget(actionBoxId); setActionBoxId(null); }}>转移到其他家庭</button>
|
||||
<button className="btn btn-danger" onClick={() => { setDeleteTarget(actionBoxId); setActionBoxId(null); }}>删除(含内容)</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<BoxFormModal open={formOpen} initial={editBox ?? undefined} onClose={() => setFormOpen(false)} onSubmit={handleSubmitForm} />
|
||||
<DestinationPicker open={!!moveTarget} excludeBoxId={moveTarget ?? undefined} onClose={() => setMoveTarget(null)} onSelect={handleMove} />
|
||||
<ConfirmModal open={!!deleteTarget} message="删除盒子将同时删除其中所有子盒与物品,不可恢复。确定删除吗?" onCancel={() => setDeleteTarget(null)} onConfirm={handleDelete} />
|
||||
<TransferModal open={!!transferTarget} type="box" entityId={transferTarget ?? ""} onClose={() => setTransferTarget(null)} onDone={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { fetchBoxTree, createBox, updateBox, moveBox, deleteBox, setBoxPosition } from "../api";
|
||||
import { indexBoxes } from "../lib/boxTree";
|
||||
import { showToast } from "../lib/toast";
|
||||
import BoxCanvas from "../components/BoxCanvas";
|
||||
import BoxFormModal from "../components/BoxFormModal";
|
||||
import DestinationPicker from "../components/DestinationPicker";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import TransferModal from "../components/TransferModal";
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
export default function Boxes() {
|
||||
const navigate = useNavigate();
|
||||
const familyId = useAppStore((s) => s.currentFamilyId);
|
||||
const role = useAppStore((s) => s.families.find((f) => f.id === s.currentFamilyId)?.role);
|
||||
const boxes = useAppStore((s) => s.boxes);
|
||||
const setBoxes = useAppStore((s) => s.setBoxes);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editBox, setEditBox] = useState<{ id: string; name: string; icon: string; color: string; width: number | null; height: number | null } | null>(null);
|
||||
const [actionBoxId, setActionBoxId] = useState<string | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<string | null>(null);
|
||||
|
||||
const canWrite = role === "admin";
|
||||
|
||||
const refresh = () => {
|
||||
if (!familyId) return;
|
||||
fetchBoxTree(familyId).then(setBoxes).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh(); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [familyId]);
|
||||
|
||||
const index = useMemo(() => indexBoxes(boxes), [boxes]);
|
||||
const rootChildren = index.roots;
|
||||
|
||||
const nextPos = () => {
|
||||
const cols = 3;
|
||||
return {
|
||||
x: (rootChildren.length % cols) * 280,
|
||||
y: Math.floor(rootChildren.length / cols) * 280,
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (data: { name: string; icon: string; color: string; width?: number | null; height?: number | null }) => {
|
||||
if (!familyId) return;
|
||||
if (editBox) {
|
||||
await updateBox(familyId, editBox.id, data);
|
||||
showToast("盒子已更新");
|
||||
} else {
|
||||
const pos = nextPos();
|
||||
await createBox(familyId, { ...data, parent_box_id: null, pos_x: pos.x, pos_y: pos.y });
|
||||
showToast("盒子已创建");
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleMove = async (parentId: string | null) => {
|
||||
if (!familyId || !moveTarget) return;
|
||||
try {
|
||||
await moveBox(familyId, moveTarget, parentId);
|
||||
showToast("已移动");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setMoveTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!familyId || !deleteTarget) return;
|
||||
try {
|
||||
await deleteBox(familyId, deleteTarget);
|
||||
showToast("盒子已删除");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePosition = async (id: string, x: number, y: number) => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
await setBoxPosition(familyId, id, x, y);
|
||||
refresh();
|
||||
} catch { /* 忽略拖拽保存失败 */ }
|
||||
};
|
||||
|
||||
const handleAutoArrange = async (positions: { id: string; x: number; y: number }[]) => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
for (const p of positions) await setBoxPosition(familyId, p.id, p.x, p.y);
|
||||
showToast("已自动整理");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (id: string) => {
|
||||
const b = index.byId.get(id);
|
||||
if (b) setEditBox({ id: b.id, name: b.name, icon: b.icon, color: b.color, width: b.width ?? null, height: b.height ?? null });
|
||||
setActionBoxId(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const actionBox = actionBoxId ? index.byId.get(actionBoxId) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">盒子</h2>
|
||||
{canWrite && (
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "6px 12px" }} onClick={() => { setEditBox(null); setFormOpen(true); }}>+ 新建盒子</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BoxCanvas
|
||||
containerKey={`root-${familyId}`}
|
||||
boxes={rootChildren}
|
||||
canWrite={!!canWrite}
|
||||
onOpen={(id) => navigate(`/boxes/${id}`)}
|
||||
onEdit={canWrite ? (id) => setActionBoxId(id) : undefined}
|
||||
onPositionChange={handlePosition}
|
||||
onAutoArrange={canWrite ? handleAutoArrange : undefined}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<div className="card-title">根层备忘</div>
|
||||
<ul style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.8, paddingLeft: 18 }}>
|
||||
<li>盒子可无限嵌套,整盒拖动可调整相对位置(画布 0~1000 坐标)。</li>
|
||||
<li>点击盒子进入内部;管理员点击「…」可编辑、移动、转移、删除。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={!!actionBoxId}
|
||||
title={`@${actionBox?.name ?? ""}`}
|
||||
onClose={() => setActionBoxId(null)}
|
||||
footer={<div className="modal-actions"><button className="btn btn-outline" onClick={() => setActionBoxId(null)}>关闭</button></div>}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<button className="btn btn-secondary" onClick={() => openEdit(actionBoxId!)}>编辑名称/图标/尺寸</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setMoveTarget(actionBoxId); setActionBoxId(null); }}>移动到其他位置</button>
|
||||
{canWrite && <button className="btn btn-secondary" onClick={() => { setTransferTarget(actionBoxId); setActionBoxId(null); }}>转移到其他家庭</button>}
|
||||
<button className="btn btn-danger" onClick={() => { setDeleteTarget(actionBoxId); setActionBoxId(null); }}>删除(含内容)</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<BoxFormModal open={formOpen} initial={editBox ?? undefined} onClose={() => setFormOpen(false)} onSubmit={handleSubmitForm} />
|
||||
<DestinationPicker open={!!moveTarget} excludeBoxId={moveTarget ?? undefined} onClose={() => setMoveTarget(null)} onSelect={handleMove} />
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
message="删除该盒子将同时删除其中所有子盒与物品,该操作不可恢复。确定删除吗?"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
<TransferModal open={!!transferTarget} type="box" entityId={transferTarget ?? ""} onClose={() => setTransferTarget(null)} onDone={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import {
|
||||
listFamilies, createFamily, updateFamily, deleteFamily, joinFamily,
|
||||
refreshInviteCode, clearInviteCode, getFamilyDetail, setMemberRole, removeMember,
|
||||
listCategories, createCategory, updateCategory, deleteCategory, listTransferLogs,
|
||||
} from "../api";
|
||||
import type { FamilyDetail, TransferLog } from "../types";
|
||||
import { showToast } from "../lib/toast";
|
||||
import Modal from "../components/Modal";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
|
||||
export default function FamilyManage() {
|
||||
const navigate = useNavigate();
|
||||
const families = useAppStore((s) => s.families);
|
||||
const currentFamilyId = useAppStore((s) => s.currentFamilyId);
|
||||
const setFamilies = useAppStore((s) => s.setFamilies);
|
||||
const setCurrentFamily = useAppStore((s) => s.setCurrentFamily);
|
||||
const [detail, setDetail] = useState<FamilyDetail | null>(null);
|
||||
const [cats, setCats] = useState<Awaited<ReturnType<typeof listCategories>>>([]);
|
||||
const [logs, setLogs] = useState<TransferLog[]>([]);
|
||||
const [newFamilyName, setNewFamilyName] = useState("");
|
||||
const [joinCode, setJoinCode] = useState("");
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
|
||||
const current = families.find((f) => f.id === currentFamilyId);
|
||||
const isOwner = current?.id ? current.owner_user_id === useAppStore.getState().user?.id : false;
|
||||
const isAdmin = current?.role === "admin";
|
||||
|
||||
const refreshFamilies = async () => {
|
||||
const list = await listFamilies();
|
||||
setFamilies(list);
|
||||
};
|
||||
|
||||
const loadDetail = () => {
|
||||
if (!currentFamilyId) { setDetail(null); return; }
|
||||
getFamilyDetail(currentFamilyId).then(setDetail).catch(() => setDetail(null));
|
||||
listCategories(currentFamilyId).then(setCats).catch(() => setCats([]));
|
||||
listTransferLogs(currentFamilyId).then(setLogs).catch(() => setLogs([]));
|
||||
};
|
||||
|
||||
useEffect(() => { loadDetail(); }, [currentFamilyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const create = async () => {
|
||||
if (!newFamilyName.trim()) return;
|
||||
try {
|
||||
await createFamily(newFamilyName.trim());
|
||||
setNewFamilyName("");
|
||||
await refreshFamilies();
|
||||
showToast("家庭已创建");
|
||||
} catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
const join = async () => {
|
||||
if (!joinCode.trim() || !currentFamilyId) { showToast("请输入邀请码"); return; }
|
||||
try {
|
||||
const r = await joinFamily(currentFamilyId, joinCode.trim().toUpperCase());
|
||||
showToast(r.message);
|
||||
if (r.success) {
|
||||
setJoinCode("");
|
||||
await refreshFamilies();
|
||||
setCurrentFamily(r.familyId!);
|
||||
}
|
||||
} catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
const rename = async () => {
|
||||
if (!currentFamilyId) return;
|
||||
try {
|
||||
await updateFamily(currentFamilyId, renameName.trim(), current?.icon);
|
||||
showToast("已保存");
|
||||
setRenameOpen(false);
|
||||
await refreshFamilies();
|
||||
} catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
const disband = async () => {
|
||||
if (!currentFamilyId) return;
|
||||
try {
|
||||
await deleteFamily(currentFamilyId);
|
||||
showToast("家庭已解散");
|
||||
setDisbandOpen(false);
|
||||
await refreshFamilies();
|
||||
} catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
const catAdd = async (name: string) => {
|
||||
if (!currentFamilyId) return;
|
||||
try { await createCategory(currentFamilyId, name); loadDetail(); } catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
const addCatName = () => {
|
||||
const n = window.prompt("分类名称");
|
||||
if (n) catAdd(n.trim());
|
||||
};
|
||||
|
||||
const renameCat = (catId: string, old: string) => {
|
||||
const n = window.prompt("重命名分类", old);
|
||||
if (n && currentFamilyId) updateCategory(currentFamilyId, catId, n.trim()).then(loadDetail).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
const delCat = (catId: string) => {
|
||||
if (!currentFamilyId) return;
|
||||
if (!confirm("删除分类后,该分类下物品将变为未分类。确定?")) return;
|
||||
deleteCategory(currentFamilyId, catId).then(loadDetail).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
const changeRole = (userId: string, role: string) => {
|
||||
if (!currentFamilyId) return;
|
||||
setMemberRole(currentFamilyId, userId, role as "admin" | "member").then(loadDetail).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
const remove = (userId: string) => {
|
||||
if (!currentFamilyId) return;
|
||||
if (!confirm("确定移除该成员?")) return;
|
||||
removeMember(currentFamilyId, userId).then(loadDetail).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
const leaveMyself = async () => {
|
||||
if (!currentFamilyId) return;
|
||||
try {
|
||||
await removeMember(currentFamilyId, "me");
|
||||
showToast("已离开家庭");
|
||||
await refreshFamilies();
|
||||
navigate("/home");
|
||||
} catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
const showInvite = async () => {
|
||||
if (!currentFamilyId) return;
|
||||
try {
|
||||
const r = await refreshInviteCode(currentFamilyId);
|
||||
await refreshFamilies();
|
||||
alert(`邀请码:${r.invite_code}`);
|
||||
} catch (e) { showToast(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">家庭</h2>
|
||||
</div>
|
||||
|
||||
{/* 我的家庭列表(切换) */}
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">我的家庭</div>
|
||||
{families.length === 0 && <div className="muted">还没有家庭,创建一个吧</div>}
|
||||
{families.map((f) => (
|
||||
<div key={f.id} className={"todo-row" + (f.id === currentFamilyId ? " selected-row" : "")} onClick={() => setCurrentFamily(f.id)}>
|
||||
<span>{f.icon} {f.name}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>{f.role === "admin" ? "管理员" : "成员"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">创建家庭</div>
|
||||
<div className="code-row">
|
||||
<input className="input" placeholder="家庭名称,如:我的家" value={newFamilyName} onChange={(e) => setNewFamilyName(e.target.value)} />
|
||||
<button className="btn btn-primary" style={{ width: "auto", padding: "0 20px" }} onClick={create}>创建</button>
|
||||
</div>
|
||||
<div className="card-title" style={{ marginTop: 16 }}>通过邀请码加入</div>
|
||||
<div className="code-row">
|
||||
<input className="input" placeholder="8 位邀请码" value={joinCode} onChange={(e) => setJoinCode(e.target.value)} />
|
||||
<button className="btn btn-primary" style={{ width: "auto", padding: "0 20px" }} onClick={join}>加入</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{current.icon} {current.name}</div>
|
||||
{isAdmin && <button className="btn btn-outline" style={{ width: "auto", padding: "4px 12px" }} onClick={() => { setRenameName(current.name); setRenameOpen(true); }}>重命名</button>}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 4 }}>身份:{isAdmin ? "管理员(可修改)" : "成员(只读)"}</p>
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="card-title">邀请码</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{detail?.family.invite_code ? (
|
||||
<span style={{ fontSize: 18, fontWeight: 700, letterSpacing: 2 }}>{detail.family.invite_code}</span>
|
||||
) : (
|
||||
<span className="muted">未生成</span>
|
||||
)}
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "4px 12px" }} onClick={showInvite}>生成/刷新</button>
|
||||
{detail?.family.invite_code && (
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "4px 12px" }} onClick={async () => { if (currentFamilyId) { await clearInviteCode(currentFamilyId); await refreshFamilies(); loadDetail(); } }}>失效</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">成员({detail.members.length})</div>
|
||||
{detail.members.map((m) => (
|
||||
<div key={m.user_id} className="todo-row">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14 }}>{m.nickname}{m.user_id === detail.family.owner_user_id ? "(创建者)" : ""}</div>
|
||||
{m.email && <div style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{m.email}</div>}
|
||||
</div>
|
||||
{m.user_id === detail.family.owner_user_id ? (
|
||||
<span className="role-badge">创建者</span>
|
||||
) : isAdmin ? (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<select className="input" style={{ padding: "6px 8px", width: 84 }} value={m.role} onChange={(e) => changeRole(m.user_id, e.target.value)}>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="member">成员</option>
|
||||
</select>
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "6px 10px" }} onClick={() => remove(m.user_id)}>移除</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="role-badge">{m.role === "admin" ? "管理员" : "成员"}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">分类管理</div>
|
||||
{cats.length === 0 && <div className="muted">暂无分类</div>}
|
||||
{cats.map((c) => (
|
||||
<div key={c.id} className="todo-row">
|
||||
<span style={{ flex: 1 }}>{c.name}</span>
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "4px 10px" }} onClick={() => renameCat(c.id, c.name)}>改名</button>
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "4px 10px" }} onClick={() => delCat(c.id)}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-secondary" style={{ marginTop: 8 }} onClick={addCatName}>+ 添加分类</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="card-title">转移记录</div>
|
||||
{logs.map((l) => (
|
||||
<div key={l.id} className="todo-row">
|
||||
<span>{l.entity_type === "box" ? "📦 盒子" : "📄 物品"}</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>{new Date(l.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{!isOwner && <button className="btn btn-outline" style={{ flex: 1 }} onClick={leaveMyself}>离开家庭</button>}
|
||||
{isOwner && <button className="btn btn-danger" style={{ flex: 1 }} onClick={() => setDisbandOpen(true)}>解散家庭</button>}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 8 }}>解散家庭将删除全部物品与盒子,不可恢复。</p>
|
||||
</div>
|
||||
|
||||
<Modal open={renameOpen} title="重命名家庭" onClose={() => setRenameOpen(false)}
|
||||
footer={<div className="modal-actions">
|
||||
<button className="btn btn-outline" onClick={() => setRenameOpen(false)}>取消</button>
|
||||
<button className="btn btn-primary" onClick={rename}>保存</button>
|
||||
</div>}
|
||||
>
|
||||
<input className="input" value={renameName} onChange={(e) => setRenameName(e.target.value)} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={disbandOpen} message="解散家庭将删除家庭内全部物品与盒子(不可恢复)。确定解散吗?" onCancel={() => setDisbandOpen(false)} onConfirm={disband} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getFamilyStats } from "../api";
|
||||
import { showToast } from "../lib/toast";
|
||||
import SignedImg from "../components/SignedImg";
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
const familyId = useAppStore((s) => s.currentFamilyId);
|
||||
const currentFamily = useAppStore((s) => s.families.find((f) => f.id === s.currentFamilyId));
|
||||
const stats = useAppStore((s) => s.stats);
|
||||
const setStats = useAppStore((s) => s.setStats);
|
||||
|
||||
const load = () => {
|
||||
if (!familyId) return;
|
||||
getFamilyStats(familyId).then(setStats).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
useEffect(load, [familyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!familyId) {
|
||||
return (
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">还没有家庭</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasContent = stats && stats.total_items > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">首页</h2>
|
||||
<button className="btn btn-outline" style={{ width: "auto", padding: "6px 12px" }} onClick={load}>刷新</button>
|
||||
</div>
|
||||
|
||||
<div className="card hero-card">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: 40 }}>{currentFamily?.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>{currentFamily?.name}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-tertiary)" }}>{currentFamily?.role === "admin" ? "你是管理员(可修改)" : "你是成员(只读)"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasContent && (
|
||||
<div style={{ marginTop: 16, fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
|
||||
还没有物品。去 <span className="link" onClick={() => navigate("/families")}>家庭管理</span> 或直接开始:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card" onClick={() => navigate("/items")}>
|
||||
<div className="stat-num">{stats.total_items}</div>
|
||||
<div className="stat-label">物品总数</div>
|
||||
</div>
|
||||
<div className="stat-card" onClick={() => navigate("/boxes")}>
|
||||
<div className="stat-num">{stats.total_boxes}</div>
|
||||
<div className="stat-label">盒子总数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-num">{stats.max_depth}</div>
|
||||
<div className="stat-label">最深嵌套</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-num">¥{stats.total_value.toLocaleString()}</div>
|
||||
<div className="stat-label">物品估价</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.expiring_count > 0 && (
|
||||
<div className="card todo-card">
|
||||
<div className="card-title">⏰ 即将过期({stats.expiring_count})</div>
|
||||
{stats.expiring_items.slice(0, 5).map((it) => (
|
||||
<div key={it.id} className="todo-row" onClick={() => navigate(`/items/${it.id}`)}>
|
||||
<span>{it.name}</span>
|
||||
<span style={{ color: "var(--danger)", fontSize: 12 }}>{it.expiry_date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="card">
|
||||
<div className="card-title">分类分布</div>
|
||||
{stats.category_distribution.length === 0 && <div className="muted">暂无分类数据</div>}
|
||||
{stats.category_distribution.map((c) => (
|
||||
<div key={c.name} className="cat-row">
|
||||
<span style={{ width: 80 }}>{c.name}</span>
|
||||
<div className="cat-bar-wrap"><div className="cat-bar" style={{ width: `${Math.min(100, (c.count / Math.max(1, stats.total_items)) * 100)}%` }} /></div>
|
||||
<span style={{ width: 40, textAlign: "right", color: "var(--text-secondary)" }}>{c.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.top_boxes.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">物品最多的盒子</div>
|
||||
{stats.top_boxes.map((b) => (
|
||||
<div key={b.id} className="todo-row" onClick={() => navigate(`/boxes/${b.id}`)}>
|
||||
<span>{b.icon} {b.path.map((p) => p.name).join(" / ")}</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>{b.count} 件</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.recent_items.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">最近新增</div>
|
||||
<div style={{ display: "flex", gap: 10, overflowX: "auto" }}>
|
||||
{stats.recent_items.map((it) => (
|
||||
<div key={it.id} className="recent-item" onClick={() => navigate(`/items/${it.id}`)}>
|
||||
{it.images[0] ? (
|
||||
<SignedImg path={it.images[0]} className="recent-thumb" />
|
||||
) : (
|
||||
<div className="recent-thumb" style={{ display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, background: "var(--bg-warm)" }}>📦</div>
|
||||
)}
|
||||
<div className="recent-name">{it.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.loose_items > 0 && (
|
||||
<div className="card todo-card">
|
||||
<div className="card-title">待整理提醒</div>
|
||||
<div className="todo-row" onClick={() => navigate(`/items?loose=1`)}>
|
||||
<span>散放在家庭根层未归类到盒子的物品</span>
|
||||
<span style={{ color: "var(--accent)" }}>{stats.loose_items} 件 ›</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getItemDetail, updateItem, deleteItem, moveItem } from "../api";
|
||||
import { showToast } from "../lib/toast";
|
||||
import Breadcrumb from "../components/Breadcrumb";
|
||||
import SignedImg from "../components/SignedImg";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import DestinationPicker from "../components/DestinationPicker";
|
||||
import TransferModal from "../components/TransferModal";
|
||||
|
||||
export default function ItemDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const familyId = useAppStore((s) => s.currentFamilyId);
|
||||
const role = useAppStore((s) => s.families.find((f) => f.id === s.currentFamilyId)?.role);
|
||||
const categories = useAppStore((s) => s.categories);
|
||||
const [item, setItem] = useState<Awaited<ReturnType<typeof getItemDetail>> | null>(null);
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
|
||||
const canWrite = role === "admin";
|
||||
|
||||
const load = () => {
|
||||
if (!familyId || !id) return;
|
||||
getItemDetail(familyId, id).then(setItem).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
useEffect(load, [familyId, id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!item) return <div className="muted" style={{ padding: 24 }}>加载中…</div>;
|
||||
|
||||
const { item: it, box_path: boxPath } = item;
|
||||
const catName = categories.find((c) => c.id === it.category_id)?.name;
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
if (!familyId || !canWrite) return;
|
||||
try {
|
||||
await updateItem(familyId, it.id, { is_favorite: !it.is_favorite });
|
||||
load();
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const doMove = async (parentId: string | null) => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
await moveItem(familyId, it.id, parentId);
|
||||
showToast("已移动");
|
||||
setMoveOpen(false);
|
||||
load();
|
||||
navigate(`/items/${it.id}`);
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!familyId) return;
|
||||
try {
|
||||
await deleteItem(familyId, it.id);
|
||||
showToast("已删除");
|
||||
navigate("/items");
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label: string, value?: string | number | null) =>
|
||||
value !== undefined && value !== null && value !== "" ? (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">{label}</span>
|
||||
<span className="detail-value">{value}</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header" style={{ alignItems: "flex-start", flexDirection: "column", gap: 6 }}>
|
||||
<h2 className="page-title" style={{ fontSize: 22 }}>{it.name}</h2>
|
||||
<div style={{ width: "100%" }}>
|
||||
<Breadcrumb path={boxPath} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{it.images.length > 0 && (
|
||||
<div className="detail-img-row">
|
||||
{it.images.map((p) => <SignedImg key={p} path={p} className="detail-img" />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginTop: 12 }}>
|
||||
{field("数量", it.quantity)}
|
||||
{field("分类", catName)}
|
||||
{it.tags.length > 0 && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">标签</span>
|
||||
<span className="detail-value">{it.tags.join("、")}</span>
|
||||
</div>
|
||||
)}
|
||||
{field("品牌", it.brand)}
|
||||
{field("型号", it.model)}
|
||||
{field("序列号", it.serial_no)}
|
||||
{field("购买日期", it.purchase_date)}
|
||||
{field("购买价格", it.purchase_price != null ? `¥${it.purchase_price}` : null)}
|
||||
{field("保质期", it.expiry_date ? `${it.expiry_date}${isExpiring(it.expiry_date) ? "(临期)" : ""}` : null)}
|
||||
{field("使用次数", it.used_count)}
|
||||
{field("最后使用", it.last_used)}
|
||||
{field("备注", it.notes)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 16, flexWrap: "wrap" }}>
|
||||
{canWrite && (
|
||||
<>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => navigate(`/items/${it.id}/edit`)}>编辑</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setMoveOpen(true)}>移动位置</button>
|
||||
<button className="btn btn-outline" style={{ flex: 1 }} onClick={() => setTransferOpen(true)}>转移到家庭</button>
|
||||
<button className="btn btn-danger" style={{ flex: 1 }} onClick={() => setDeleteOpen(true)}>删除</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={toggleFavorite}>
|
||||
{it.is_favorite ? "取消收藏 ♥" : "收藏 ♥"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DestinationPicker open={moveOpen} onClose={() => setMoveOpen(false)} onSelect={doMove} />
|
||||
<ConfirmModal open={deleteOpen} message={`确定删除「${it.name}」吗?`} onCancel={() => setDeleteOpen(false)} onConfirm={doDelete} />
|
||||
<TransferModal open={transferOpen} type="item" entityId={it.id} onClose={() => setTransferOpen(false)} onDone={() => { load(); navigate("/items"); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isExpiring(exp: string): boolean {
|
||||
const d = new Date(exp + "T00:00:00");
|
||||
const now = new Date();
|
||||
return d.getTime() < now.getTime() + 30 * 864e5;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { createItem, updateItem, getItemDetail, listCategories } from "../api";
|
||||
import { indexBoxes } from "../lib/boxTree";
|
||||
import { uploadImage } from "../api/images";
|
||||
import { showToast } from "../lib/toast";
|
||||
import SignedImg from "../components/SignedImg";
|
||||
|
||||
const MAX_IMAGES = 9;
|
||||
|
||||
export default function ItemEdit() {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const familyId = useAppStore((s) => s.currentFamilyId);
|
||||
const role = useAppStore((s) => s.families.find((f) => f.id === s.currentFamilyId)?.role);
|
||||
const categories = useAppStore((s) => s.categories);
|
||||
const setCategories = useAppStore((s) => s.setCategories);
|
||||
const boxes = useAppStore((s) => s.boxes);
|
||||
const isEdit = !!id;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [quantity, setQuantity] = useState("1");
|
||||
const [brand, setBrand] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [serialNo, setSerialNo] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [purchasePrice, setPurchasePrice] = useState("");
|
||||
const [expiryDate, setExpiryDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [boxId, setBoxId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!familyId) return;
|
||||
if (categories.length === 0) listCategories(familyId).then(setCategories).catch(() => undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [familyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
setBoxId(params.get("box") ?? "");
|
||||
return;
|
||||
}
|
||||
if (!familyId) return;
|
||||
getItemDetail(familyId, id!).then((d) => {
|
||||
const it = d.item;
|
||||
setName(it.name);
|
||||
setCategoryId(it.category_id ?? "");
|
||||
setTags(it.tags.join(", "));
|
||||
setQuantity(String(it.quantity ?? 1));
|
||||
setBrand(it.brand ?? "");
|
||||
setModel(it.model ?? "");
|
||||
setSerialNo(it.serial_no ?? "");
|
||||
setPurchaseDate(it.purchase_date ?? "");
|
||||
setPurchasePrice(it.purchase_price != null ? String(it.purchase_price) : "");
|
||||
setExpiryDate(it.expiry_date ?? "");
|
||||
setNotes(it.notes ?? "");
|
||||
setImages(it.images);
|
||||
setBoxId(it.box_id ?? "");
|
||||
}).catch((e) => showToast(String(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEdit, id, familyId]);
|
||||
|
||||
if (role === "member") {
|
||||
return (
|
||||
<div className="card" style={{ margin: 24 }}>
|
||||
<div style={{ fontSize: 15 }}>你当前只读,不能编辑物品。</div>
|
||||
<button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => navigate(-1)}>返回</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const boxOptions = (() => {
|
||||
const index = indexBoxes(boxes);
|
||||
return [...index.byId.values()].sort((a, b) => a.depth - b.depth);
|
||||
})();
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || !files.length) return;
|
||||
if (images.length + files.length > MAX_IMAGES) { showToast(`最多 ${MAX_IMAGES} 张图片`); return; }
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded: string[] = [];
|
||||
for (const f of Array.from(files)) {
|
||||
const p = await uploadImage(f);
|
||||
uploaded.push(p);
|
||||
}
|
||||
setImages((prev) => [...prev, ...uploaded]);
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (idx: number) => setImages((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
const save = async () => {
|
||||
if (!familyId) return;
|
||||
if (!name.trim()) { showToast("请输入物品名称"); return; }
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
category_id: categoryId || null,
|
||||
tags: tags.split(/[,,]/).map((t) => t.trim()).filter(Boolean),
|
||||
quantity: Number(quantity) || 1,
|
||||
brand: brand.trim() || null,
|
||||
model: model.trim() || null,
|
||||
serial_no: serialNo.trim() || null,
|
||||
purchase_date: purchaseDate || null,
|
||||
purchase_price: purchasePrice ? Number(purchasePrice) : null,
|
||||
expiry_date: expiryDate || null,
|
||||
notes: notes.trim() || null,
|
||||
images,
|
||||
box_id: boxId || null,
|
||||
};
|
||||
try {
|
||||
const saved = isEdit
|
||||
? (await updateItem(familyId, id!, payload)).item
|
||||
: await createItem(familyId, payload);
|
||||
showToast(isEdit ? "已保存" : "已添加");
|
||||
navigate(`/items/${saved.id}`);
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{isEdit ? "编辑物品" : "新增物品"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="input-group">
|
||||
<label>名称 *</label>
|
||||
<input className="input" placeholder="物品名称" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>分类</label>
|
||||
<select className="input" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
{categories.length === 0 && <p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 4 }}>暂无分类,可在「我的 › 家庭管理」中添加</p>}
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>位置</label>
|
||||
<select className="input" value={boxId} onChange={(e) => setBoxId(e.target.value)}>
|
||||
<option value="">家庭根层(不放入盒子)</option>
|
||||
{boxOptions.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.path.map((p) => p.name).join(" > ") || b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>标签(用逗号分隔)</label>
|
||||
<input className="input" placeholder="如:方便面, 纸巾, 应急" value={tags} onChange={(e) => setTags(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>数量</label>
|
||||
<input className="input" inputMode="numeric" placeholder="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="code-row">
|
||||
<div className="input-group" style={{ flex: 1 }}>
|
||||
<label>品牌</label>
|
||||
<input className="input" value={brand} onChange={(e) => setBrand(e.target.value)} />
|
||||
</div>
|
||||
<div className="input-group" style={{ flex: 1 }}>
|
||||
<label>型号</label>
|
||||
<input className="input" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>序列号</label>
|
||||
<input className="input" value={serialNo} onChange={(e) => setSerialNo(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="code-row">
|
||||
<div className="input-group" style={{ flex: 1 }}>
|
||||
<label>购买日期</label>
|
||||
<input className="input" type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="input-group" style={{ flex: 1 }}>
|
||||
<label>购买价格</label>
|
||||
<input className="input" inputMode="decimal" placeholder="¥" value={purchasePrice} onChange={(e) => setPurchasePrice(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>保质期 / 有效期(可选)</label>
|
||||
<input className="input" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>备注</label>
|
||||
<textarea className="input" rows={3} placeholder="补充说明…" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>图片({images.length}/{MAX_IMAGES})</label>
|
||||
<div className="img-picker">
|
||||
{images.map((p, i) => (
|
||||
<div key={p + i} className="img-preview">
|
||||
<SignedImg path={p} className="img-preview-photo" />
|
||||
<button className="img-remove" onClick={() => removeImage(i)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < MAX_IMAGES && (
|
||||
<button className="img-add" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? "上传中…" : "+"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => handleFiles(e.target.files)} />
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" disabled={busy} onClick={save}>
|
||||
{busy ? "保存中…" : isEdit ? "保存修改" : "添加物品"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { listItems, type ItemFilters } from "../api";
|
||||
import type { Item } from "../types";
|
||||
import { indexBoxes } from "../lib/boxTree";
|
||||
import { showToast } from "../lib/toast";
|
||||
import SignedImg from "../components/SignedImg";
|
||||
|
||||
export default function ItemList() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const familyId = useAppStore((s) => s.currentFamilyId);
|
||||
const categories = useAppStore((s) => s.categories);
|
||||
const boxes = useAppStore((s) => s.boxes);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [tag, setTag] = useState("");
|
||||
const [favorite, setFavorite] = useState(params.get("favorite") === "1");
|
||||
const [expiring, setExpiring] = useState(params.get("expiring") === "1");
|
||||
const [loose, setLoose] = useState(params.get("loose") === "1");
|
||||
const [boxFilter, setBoxFilter] = useState("");
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const index = useMemo(() => indexBoxes(boxes), [boxes]);
|
||||
const boxOptions = useMemo(() => [...index.byId.values()].sort((a, b) => a.depth - b.depth), [index]);
|
||||
|
||||
const load = (f: ItemFilters = {}) => {
|
||||
if (!familyId) return;
|
||||
const filters: ItemFilters = {};
|
||||
if (f.q !== undefined) filters.q = f.q;
|
||||
else if (q.trim()) filters.q = q.trim();
|
||||
if (loose) filters.loose = true;
|
||||
if (boxFilter) filters.box = boxFilter;
|
||||
if (category) filters.category = category;
|
||||
if (tag.trim()) filters.tag = tag.trim();
|
||||
if (favorite) filters.favorite = true;
|
||||
if (expiring) filters.expiring = true;
|
||||
listItems(familyId, filters).then(setItems).catch((e) => showToast(String(e)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [familyId, category, tag, favorite, expiring, loose, boxFilter]);
|
||||
|
||||
const onSearch = (v: string) => {
|
||||
setQ(v);
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => load({ q: v.trim() }), 350);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">物品</h2>
|
||||
</div>
|
||||
|
||||
<input className="input" placeholder="搜索名称 / 品牌 / 备注…" value={q} onChange={(e) => onSearch(e.target.value)} />
|
||||
|
||||
<div className="filter-row">
|
||||
<select className="input filter-select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="">全部分类</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<select className="input filter-select" value={boxFilter} onChange={(e) => setBoxFilter(e.target.value)}>
|
||||
<option value="">全部位置</option>
|
||||
{boxOptions.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.path.map((p) => p.name).join(" > ")}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<button className={"tag-pill" + (favorite ? " tag-pill-active" : "")} onClick={() => setFavorite(!favorite)}>♥ 收藏</button>
|
||||
<button className={"tag-pill" + (expiring ? " tag-pill-active" : "")} onClick={() => setExpiring(!expiring)}>⏰ 临期</button>
|
||||
<button className={"tag-pill" + (loose ? " tag-pill-active" : "")} onClick={() => { setLoose(!loose); setBoxFilter(""); }}>未收纳</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<input className="input" placeholder="按标签筛选,回车" value={tag} onChange={(e) => setTag(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") load(); }} />
|
||||
</div>
|
||||
|
||||
{items.length === 0 && <div className="muted" style={{ padding: "24px 0", textAlign: "center" }}>没有符合条件的物品</div>}
|
||||
|
||||
<div className="item-grid">
|
||||
{items.map((it) => {
|
||||
const boxPath = it.box_id ? (index.byId.get(it.box_id)?.path ?? []) : [];
|
||||
return (
|
||||
<div key={it.id} className="item-card" onClick={() => navigate(`/items/${it.id}`)}>
|
||||
{it.images[0] ? (
|
||||
<SignedImg path={it.images[0]} className="item-thumb" />
|
||||
) : (
|
||||
<div className="item-thumb" style={{ display: "flex", alignItems: "center", justifyContent: "center", fontSize: 26, background: "var(--bg-warm)" }}>📦</div>
|
||||
)}
|
||||
<div className="item-name" title={it.name}>{it.name}</div>
|
||||
{it.is_favorite && <span className="favorite-mark">♥</span>}
|
||||
{it.quantity > 1 && <span className="qty-mark">×{it.quantity}</span>}
|
||||
<div className="item-loc" title={boxPath.map((p) => p.name).join(" / ")}>
|
||||
{boxPath.length ? boxPath.map((p) => p.name).join(" / ") : "根层"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { sendCode, register, login } from "../api";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { account, code, nickname, isRegister, loading, setAccount, setCode, setNickname, setMode, setLoading } = useAuthStore();
|
||||
const setUser = useAppStore((s) => s.setUser);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
const isEmail = account.includes("@");
|
||||
const canSendCode = account.length >= 6;
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setLoading(true);
|
||||
setStatus("");
|
||||
try {
|
||||
const result = await sendCode(account);
|
||||
setStatus(result);
|
||||
} catch (e) {
|
||||
setStatus(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
setStatus("");
|
||||
try {
|
||||
const email = isEmail ? account : null;
|
||||
const phone = isEmail ? null : account;
|
||||
const result = isRegister
|
||||
? await register(email, phone, code, nickname || "收纳达人")
|
||||
: await login(email, phone, code);
|
||||
if (result.success && result.user) {
|
||||
setUser(result.user, result.token);
|
||||
navigate("/home", { replace: true });
|
||||
} else {
|
||||
setStatus(result.message);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: "calc(env(safe-area-inset-top) + 20px) 28px 0", maxWidth: 420, margin: "0 auto" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 40 }}>
|
||||
<div style={{ fontSize: 56, marginBottom: 8 }}>📦</div>
|
||||
<h1 style={{ fontSize: 32, letterSpacing: -0.5 }}>Storage</h1>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 14, marginTop: 4 }}>家庭物品收纳 · 全家人一起管理的东西</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
|
||||
<button
|
||||
style={{ flex: 1, padding: "10px 0", borderRadius: "var(--radius-sm)", fontWeight: 600, fontSize: 14, background: isRegister ? "transparent" : "var(--text-primary)", color: isRegister ? "var(--text-secondary)" : "#fff" }}
|
||||
onClick={() => setMode("login")}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
style={{ flex: 1, padding: "10px 0", borderRadius: "var(--radius-sm)", fontWeight: 600, fontSize: 14, background: isRegister ? "var(--text-primary)" : "transparent", color: isRegister ? "#fff" : "var(--text-secondary)" }}
|
||||
onClick={() => setMode("register")}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{isEmail ? "邮箱" : "手机号"}</label>
|
||||
<input className="input" placeholder={isEmail ? "you@example.com" : "13800001234"} value={account} onChange={(e) => setAccount(e.target.value.trim())} />
|
||||
</div>
|
||||
|
||||
{isRegister && (
|
||||
<div className="input-group">
|
||||
<label>昵称</label>
|
||||
<input className="input" placeholder="你的昵称" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-group">
|
||||
<label>验证码</label>
|
||||
<div className="code-row">
|
||||
<input className="input" placeholder="验证码" maxLength={4} inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||
<button className="code-btn" disabled={!canSendCode || loading} onClick={handleSendCode}>
|
||||
获取验证码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && <p style={{ fontSize: 13, color: status.includes("成功") ? "var(--success)" : "var(--danger)", marginBottom: 12 }}>{status}</p>}
|
||||
|
||||
<button className="btn btn-primary" disabled={loading || !account || code.length < 4} onClick={handleSubmit}>
|
||||
{loading ? "处理中..." : isRegister ? "注册" : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ textAlign: "center", fontSize: 12, color: "var(--text-tertiary)", marginTop: 16 }}>开发期验证码固定 1111</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { updateMe, logout } from "../api";
|
||||
import { APP_VERSION } from "../version";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAppStore((s) => s.user);
|
||||
const families = useAppStore((s) => s.families);
|
||||
const logoutStore = useAppStore((s) => s.logout);
|
||||
const [nickname, setNickname] = useState(user?.nickname ?? "");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const saveNickname = async () => {
|
||||
if (!nickname.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const u = await updateMe(nickname.trim());
|
||||
useAppStore.getState().setUser(u);
|
||||
showToast("已保存昵称");
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
showToast(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doLogout = async () => {
|
||||
try { await logout(); } catch { /* ignore */ }
|
||||
logoutStore();
|
||||
localStorage.removeItem("storage_user");
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">我的</h2>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ width: 56, height: 56, borderRadius: "50%", background: "var(--bg-warm)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 26 }}>👤</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editing ? (
|
||||
<div className="code-row">
|
||||
<input className="input" value={nickname} onChange={(e) => setNickname(e.target.value)} autoFocus />
|
||||
<button className="btn btn-primary" style={{ width: "auto", padding: "0 16px" }} disabled={busy} onClick={saveNickname}>保存</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 17, fontWeight: 700 }}>{user?.nickname}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)" }}>{user?.email || user?.phone}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!editing && <button className="btn btn-outline" style={{ width: "auto", padding: "6px 12px" }} onClick={() => setEditing(true)}>改昵称</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 12 }} onClick={() => navigate("/families")}>
|
||||
<div className="todo-row">
|
||||
<span>🏠 家庭管理</span>
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>{families.length} 个 ›</span>
|
||||
</div>
|
||||
{families.map((f) => (
|
||||
<div key={f.id} style={{ padding: "6px 0 0 24px", fontSize: 13, color: "var(--text-secondary)" }}>
|
||||
{f.icon} {f.name} · {f.role === "admin" ? "管理员" : "成员"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="todo-row">
|
||||
<span>版本</span>
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>v{APP_VERSION}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-tertiary)", padding: "6px 0" }}>
|
||||
物品数据保存在服务器(DuckDB)。图片经短时签名 URL 鉴权读取。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-danger" onClick={doLogout}>退出登录</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { create } from "zustand";
|
||||
import type { User, FamilySummary, Category, Box, FamilyStats, Role } from "../types";
|
||||
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
families: FamilySummary[];
|
||||
currentFamilyId: string | null;
|
||||
boxes: Box[];
|
||||
categories: Category[];
|
||||
stats: FamilyStats | null;
|
||||
setUser: (user: User | null, token?: string | null) => void;
|
||||
setFamilies: (families: FamilySummary[]) => void;
|
||||
setCurrentFamily: (id: string | null) => void;
|
||||
setBoxes: (boxes: Box[]) => void;
|
||||
upsertBox: (box: Box) => void;
|
||||
removeBoxes: (ids: string[]) => void;
|
||||
setCategories: (categories: Category[]) => void;
|
||||
setStats: (stats: FamilyStats) => void;
|
||||
clearFamilyData: () => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
families: [],
|
||||
currentFamilyId: null,
|
||||
boxes: [],
|
||||
categories: [],
|
||||
stats: null,
|
||||
setUser: (user, token = null) => set({ user, token }),
|
||||
setFamilies: (families) => set((s) => {
|
||||
// 保持当前家庭 (若仍存在)
|
||||
const stillExists = families.some((f) => f.id === s.currentFamilyId);
|
||||
return {
|
||||
families,
|
||||
currentFamilyId: stillExists ? s.currentFamilyId : (families[0]?.id ?? null),
|
||||
};
|
||||
}),
|
||||
setCurrentFamily: (currentFamilyId) => set({
|
||||
currentFamilyId,
|
||||
boxes: [],
|
||||
categories: [],
|
||||
stats: null,
|
||||
}),
|
||||
setBoxes: (boxes) => set({ boxes }),
|
||||
upsertBox: (box) => set((s) => {
|
||||
const exists = s.boxes.some((b) => b.id === box.id);
|
||||
return { boxes: exists ? s.boxes.map((b) => (b.id === box.id ? box : b)) : [...s.boxes, box] };
|
||||
}),
|
||||
removeBoxes: (ids) => set((s) => {
|
||||
const idSet = new Set(ids);
|
||||
return { boxes: s.boxes.filter((b) => !idSet.has(b.id)) };
|
||||
}),
|
||||
setCategories: (categories) => set({ categories }),
|
||||
setStats: (stats) => set({ stats }),
|
||||
clearFamilyData: () => set({ boxes: [], categories: [], stats: null }),
|
||||
logout: () => set({
|
||||
user: null, token: null, families: [], currentFamilyId: null,
|
||||
boxes: [], categories: [], stats: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// 便捷选择器: 当前家庭的角色 (写权限判断用)
|
||||
export function selectMyRole(s: AppState): Role | null {
|
||||
if (!s.currentFamilyId) return null;
|
||||
const f = s.families.find((x) => x.id === s.currentFamilyId);
|
||||
return f?.role ?? null;
|
||||
}
|
||||
|
||||
export function selectCurrentFamily(s: AppState): FamilySummary | null {
|
||||
if (!s.currentFamilyId) return null;
|
||||
return s.families.find((x) => x.id === s.currentFamilyId) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface AuthState {
|
||||
account: string;
|
||||
isEmail: boolean;
|
||||
code: string;
|
||||
nickname: string;
|
||||
isRegister: boolean;
|
||||
loading: boolean;
|
||||
setAccount: (v: string) => void;
|
||||
setCode: (v: string) => void;
|
||||
setNickname: (v: string) => void;
|
||||
setMode: (m: "login" | "register") => void;
|
||||
setLoading: (b: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
account: "",
|
||||
isEmail: true,
|
||||
code: "",
|
||||
nickname: "",
|
||||
isRegister: false,
|
||||
loading: false,
|
||||
setAccount: (account) => set({ account, isEmail: account.includes("@") }),
|
||||
setCode: (code) => set({ code }),
|
||||
setNickname: (nickname) => set({ nickname }),
|
||||
setMode: (m) => set({ isRegister: m === "register" }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
}));
|
||||
@@ -0,0 +1,861 @@
|
||||
:root {
|
||||
--bg: #FCF9F4;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-warm: #F5EDE6;
|
||||
--text-primary: #1A1A1A;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--accent: #E8C4A0;
|
||||
--accent-light: #FFF6ED;
|
||||
--accent-peach: #FFE8E0;
|
||||
--border: #F0E8DF;
|
||||
--danger: #E74C3C;
|
||||
--success: #27AE60;
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--radius-xs: 6px;
|
||||
--nav-height: 68px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
/* 顶部:家庭切换器 */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px 4px;
|
||||
}
|
||||
|
||||
.family-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 7px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.family-name {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: #B8734A;
|
||||
background: var(--accent-light);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.family-switch {
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
max-width: 150px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 20px 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Bottom Nav */
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 6px 8px;
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.nav-item:active .nav-icon {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.nav-item .nav-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.center-nav-btn {
|
||||
position: relative;
|
||||
top: -14px;
|
||||
}
|
||||
|
||||
.center-nav-btn .fab-circle {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 12px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--text-primary);
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-warm);
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 13px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-row .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
padding: 13px 18px;
|
||||
background: var(--accent-light);
|
||||
color: #B8734A;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.code-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Tag Pills */
|
||||
.tag-pill {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tag-pill-active {
|
||||
background: var(--text-primary);
|
||||
border-color: var(--text-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 10px 24px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
z-index: 1000;
|
||||
animation: toast-in 0.3s;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.page-enter {
|
||||
animation: page-in 0.26s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 首页统计 */
|
||||
.hero-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.todo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.todo-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.selected-row {
|
||||
background: var(--accent-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cat-bar-wrap {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-warm);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cat-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
flex-shrink: 0;
|
||||
width: 76px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recent-thumb {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.recent-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 面包屑 */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* 画布 */
|
||||
.box-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: var(--bg-warm);
|
||||
border-radius: var(--radius);
|
||||
/* 网格线示意 */
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(0, 0, 0, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
|
||||
background-size: calc(100% / 10) calc(100% / 10);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.canvas-box {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-box-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.canvas-box-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 84%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.canvas-box-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.canvas-box-edit {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.view-switch {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.view-switch-btn {
|
||||
flex: 1;
|
||||
padding: 9px 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.view-switch-btn.active {
|
||||
background: var(--text-primary);
|
||||
color: #fff;
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* 物品网格 */
|
||||
.item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-thumb {
|
||||
width: 100%;
|
||||
height: 86px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-loc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.favorite-mark {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
text-shadow: 0 1px 3px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.qty-mark {
|
||||
position: absolute;
|
||||
bottom: 34px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* 筛选 */
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 详情页 */
|
||||
.detail-row {
|
||||
display: flex;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
width: 84px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-img-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.detail-img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 图片选择器 */
|
||||
.img-picker {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
position: relative;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
}
|
||||
|
||||
.img-preview-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.img-remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.img-add {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--bg-card);
|
||||
font-size: 26px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: fade-in 0.2s;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 20px;
|
||||
max-height: 82vh;
|
||||
overflow-y: auto;
|
||||
animation: slide-up 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 选择器 */
|
||||
.picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
margin: 4px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.picker-option-active {
|
||||
border-color: var(--text-primary);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.picker-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.picker-tree {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 图标选择 */
|
||||
.icon-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon-opt {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-opt-active {
|
||||
border: 2px solid var(--text-primary);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.color-opt {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Storage 领域类型 —— 单一来源 (前端与服务端保持一致)
|
||||
|
||||
export type Role = "admin" | "member";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
nickname: string;
|
||||
avatar_path?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
user?: User;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface Family {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner_user_id: string;
|
||||
invite_code?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FamilySummary extends Family {
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
family_id: string;
|
||||
user_id: string;
|
||||
role: Role;
|
||||
joined_at: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface FamilyDetail {
|
||||
family: Family;
|
||||
members: Member[];
|
||||
my_role: Role;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
family_id: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Box {
|
||||
id: string;
|
||||
family_id: string;
|
||||
parent_box_id?: string | null;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
pos_x: number;
|
||||
pos_y: number;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
direct_item_count?: number;
|
||||
total_item_count?: number;
|
||||
}
|
||||
|
||||
export interface PathEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BoxTreeNode extends Box {
|
||||
depth: number;
|
||||
path: PathEntry[];
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
family_id: string;
|
||||
box_id?: string | null;
|
||||
name: string;
|
||||
category_id?: string | null;
|
||||
tags: string[];
|
||||
quantity: number;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
serial_no?: string | null;
|
||||
purchase_date?: string | null;
|
||||
purchase_price?: number | null;
|
||||
expiry_date?: string | null;
|
||||
images: string[];
|
||||
notes?: string | null;
|
||||
is_favorite: boolean;
|
||||
used_count: number;
|
||||
last_used?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ItemDetail {
|
||||
item: Item;
|
||||
box_path: PathEntry[];
|
||||
}
|
||||
|
||||
export interface TransferLog {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
src_family_id: string;
|
||||
dst_family_id: string;
|
||||
operator_user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FamilyStats {
|
||||
total_items: number;
|
||||
total_boxes: number;
|
||||
root_boxes: number;
|
||||
max_depth: number;
|
||||
total_value: number;
|
||||
expiring_count: number;
|
||||
expiring_items: Item[];
|
||||
loose_items: number;
|
||||
no_image_count: number;
|
||||
category_distribution: { name: string; count: number }[];
|
||||
top_boxes: { id: string; name: string; icon: string; count: number; path: PathEntry[] }[];
|
||||
recent_items: Item[];
|
||||
}
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
declare const __BUILD_ID__: string;
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
export const BUILD_ID = typeof __BUILD_ID__ !== "undefined" ? __BUILD_ID__ : "dev";
|
||||
export const APP_VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.0.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
function buildId() {
|
||||
// YYYYMMDD.HHmmss: 每次构建生成新的 BUILD_ID, 用于 OTA/web 升级比对
|
||||
// 统一按 UTC+8 (Asia/Shanghai) 取时, 避免 CI (UTC) 与本地时区漂移导致误判升级
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const cn = new Date(Date.now() + 8 * 3600 * 1000);
|
||||
return (
|
||||
[cn.getUTCFullYear(), pad(cn.getUTCMonth() + 1), pad(cn.getUTCDate())].join("") +
|
||||
"." +
|
||||
[pad(cn.getUTCHours()), pad(cn.getUTCMinutes()), pad(cn.getUTCSeconds())].join("")
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig(async () => {
|
||||
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
||||
|
||||
const writeBuildId: Plugin = {
|
||||
name: "storage:write-build-id",
|
||||
closeBundle() {
|
||||
const dir = path.resolve(process.cwd(), "dist");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path.join(dir, ".build-id"), buildId(), "utf8");
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
plugins: [react(), writeBuildId],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__BUILD_ID__: JSON.stringify(buildId()),
|
||||
__APP_VERSION__: JSON.stringify(pkg.version || "0.0.0"),
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user