20260622214432
This commit is contained in:
1 parent
b5f6fafd74
commit
e9e3371be7
19 files changed
+1054
-107
No files matched your search
@@ -0,0 +1,81 @@
|
|||||||
|
# 2026-06-22
|
||||||
|
|
||||||
|
## 添加测试登录功能
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
前端 login.tsx 中的 test1/test2 需要实现模拟登录,后端接受并走完整 auth 流程(生成 JWT、查 family 等)。
|
||||||
|
|
||||||
|
### 改动文件
|
||||||
|
|
||||||
|
**后端:**
|
||||||
|
- `app-go/internal/types/auth.go` — 新增 `LoginTestReq` 结构体(user_id, nickname, avatar)
|
||||||
|
- `app-go/internal/handle/auth.go` — 新增 `LoginTest` handler:验证用户存在 → 更新登录时间 → 查 family → 生成 JWT → 返回 `LoginResp`
|
||||||
|
- `app-go/internal/router/router.go` — 新增路由 `POST /auth/login/test`
|
||||||
|
|
||||||
|
**前端:**
|
||||||
|
- `app-rn/src/types/auth.ts` — 新增 `LoginTestParams` 接口
|
||||||
|
- `app-rn/src/request/api.ts` — 新增 `loginTestApi()`,导入 `LoginTestParams`
|
||||||
|
- `app-rn/src/app/auth/login.tsx` — 实现 `test1()` 方法(硬编码用户 id=801787924787269, nickname=Zhang),修复两个测试按钮的 `onPress` 绑定(从 `handleLoginQQ` 改为 `test1` / `test2`)
|
||||||
|
|
||||||
|
### 关键设计
|
||||||
|
- 后端 `/auth/login/test` 不在白名单需要额外处理,但路由器中它属于 `/auth/login/**` 前缀,前端的 WhiteList 使用 prefix 匹配 → `/auth/login/test` 已被自动放行
|
||||||
|
- 生成的 token 是标准 JWT,auth 中间件无需任何改动即可正常识别
|
||||||
|
- `test2()` 目前为空占位,后续再实现
|
||||||
|
|
||||||
|
### 数据库依赖
|
||||||
|
需要确保 `b_user` 表中存在 id=801787924787269 的用户记录,否则测试登录会返回"测试用户不存在"。
|
||||||
|
|
||||||
|
## 子模块动画改为底部滑入/滑出(B 方案)
|
||||||
|
|
||||||
|
- ModuleLayout.tsx: handleClose 从 `router.replace("/(tabs)/home")` 改为 `router.back()`
|
||||||
|
- finance/(tabs)/_layout.tsx: 添加 `<Stack.Screen options={{ animation: "slide_from_bottom" }} />`
|
||||||
|
|
||||||
|
## tools/index.tsx 改为宫格布局
|
||||||
|
|
||||||
|
移除:列表组件(ListGroup/PressableFeedback/Separator/LegendList)、AZIndex 字母索引
|
||||||
|
改为:LayoutView.ScrollView + 3 列宫格(flexWrap),每个格子含图标、标题、描述
|
||||||
|
保留:顶部搜索框、底部关闭按钮、EmptyState
|
||||||
|
|
||||||
|
### 第二轮优化
|
||||||
|
- 去掉描述文案,卡片更紧凑
|
||||||
|
- 加回 AZIndex 字母导航,通过 onLayout 记录分组标题位置实现 scrollTo
|
||||||
|
- 卡片改为固定上下 padding(不设 aspectRatio),尺寸更小
|
||||||
|
- 按行分组渲染,末尾行空位用占位 View 填充,保证左对齐
|
||||||
|
- AZIndex 始终显示(搜索时位置基于复合 key 追踪)
|
||||||
|
|
||||||
|
## 修复测试登录 404 问题
|
||||||
|
|
||||||
|
`config.yaml` 中 `base_url: /app`,开发环境没有 nginx 剥离前缀,导致请求路径 `/auth/login/test` 无法匹配路由 `/app/auth/login/test`。
|
||||||
|
|
||||||
|
**修复:** `config.dev.yaml` 添加 `base_url: ""`,覆盖掉主配置的 `/app` 前缀。
|
||||||
|
|
||||||
|
## 工具页宫格样式对齐首页
|
||||||
|
|
||||||
|
- 组件:Pressable → PressableFeedback + PressableFeedback.Ripple
|
||||||
|
- 样式:h-20 / rounded-xl / shadow-lg / 去图标灰底 / 图标 26px / text-xs
|
||||||
|
|
||||||
|
## 子应用收藏(星标)功能
|
||||||
|
|
||||||
|
**数据库**: 新建 `b_app_favorite` 表(family_id + app_id 唯一约束)
|
||||||
|
**修复**: `b_app_favorite` 加入 `auditExcludeTables`(meta_field.go),因为表内已有 user_id 追踪、无需 create_by/update_by 审计字段。否则 saveData 自动注入不存在的列 → 500。
|
||||||
|
- **再次修复**: 用户要求不应跳过审计,改为给表添加 `create_by` / `update_by` 列,meta_field.go 撤回修改。
|
||||||
|
|
||||||
|
## PROJECT_CONTEXT.md 补充开发规范
|
||||||
|
|
||||||
|
新增 "Development Conventions" 章节:
|
||||||
|
- API 规则:禁止新增专用 CRUD 接口,一律使用 loadData/saveData/loadDataBySql/getUniqueId。如需新增接口须先询问,且新增的也应是通用接口。
|
||||||
|
- 数据库规则:所有业务表必须含 create_time/update_time/create_by/update_by 四列,不要随意加到 auditExcludeTables。
|
||||||
|
- 前端规则:复用现有 store 和通用 API,不创建 RPC 风格封装。
|
||||||
|
**后端**: 无新增路由/handler,全部复用已有通用接口(loadData / saveData / getUniqueId)
|
||||||
|
- 重新清理:删除了之前错误创建的 `types/app.go`、`handle/app.go` 和 `/app/favorite/*` 路由
|
||||||
|
**前端**:
|
||||||
|
- `request/api.ts`: 删除 toggleFavoriteApi / getFavoritesApi,改用 loadDataApi + saveDataApi + getUniqueIdApi
|
||||||
|
- `store/favorite.ts`: fetchFavorites 用 loadDataApi 查 b_app_favorite;toggleFavorite 用 loadDataApi 查存在 + saveDataApi 删/插 + getUniqueIdApi 取新 ID
|
||||||
|
- `tools/index.tsx`: 卡片右上角 Star 按钮(已修复事件冲突:Star 独立于导航主体,外层 View 容器 + z-10 避免触摸冒泡)+ 收藏分区置顶 + 搜索时不拆分收藏分区
|
||||||
|
- `home/index.tsx`: 改为 3 列宫格(同 tools,固定 itemWidth + 按行分组)+ 收藏优先 + "更多"占位自动补到末尾行 + 跳转 /tools
|
||||||
|
|
||||||
|
## 创建项目 Skill:allapp-conventions
|
||||||
|
|
||||||
|
位置:`.workbuddy/skills/allapp-conventions/`
|
||||||
|
- `SKILL.md`: 5 条规则(读 package.json、禁止专用 API、审计字段、不重复造轮子、家庭为中心)+ 项目结构速查 + 前端关键依赖库表 + 代码示例
|
||||||
|
- `references/db-config.md`: 数据库连接信息、所有表结构速查
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
name: allapp-conventions
|
||||||
|
description: >
|
||||||
|
Allapp 项目开发规范和数据库配置指南。该 skill 应在以下场景使用:(1) 在 Allapp 项目中新增功能、
|
||||||
|
创建数据库表、或调用后端 API 时;(2) 需要了解项目通用 API 接口用法、数据库连接信息、
|
||||||
|
表结构约定时;(3) 需要遵循项目的代码规范时。触发词包括:allapp、记账、app-go、app-rn、
|
||||||
|
数据库连接、API 接口、建表、收藏功能等。
|
||||||
|
agent_created: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Allapp 项目开发规范
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
|
||||||
|
Allapp 是一个家庭/个人空间型 mini 程序平台:
|
||||||
|
- **后端** `app-go/`:Go + Fiber v3 + PostgreSQL (pgx) + JWT
|
||||||
|
- **前端** `app-rn/`:React Native + Expo 55 + Expo Router + Zustand + Alova
|
||||||
|
- **数据库** PostgreSQL,库名 `allapp`,schema `public`
|
||||||
|
|
||||||
|
数据库连接信息见 `references/db-config.md`。
|
||||||
|
|
||||||
|
## 项目结构速查
|
||||||
|
|
||||||
|
```
|
||||||
|
app-go/ # Go 后端
|
||||||
|
├── configs/ # YAML 配置(config.yaml / config.dev.yaml)
|
||||||
|
├── internal/
|
||||||
|
│ ├── bootstrap/ # 启动入口
|
||||||
|
│ ├── config/ # 配置加载
|
||||||
|
│ ├── handle/ # 请求处理(auth / data / data_save / data_list / s3)
|
||||||
|
│ ├── httpx/ # 请求/响应工具
|
||||||
|
│ ├── middleware/ # 中间件(auth / logger)
|
||||||
|
│ ├── router/ # 路由注册
|
||||||
|
│ └── types/ # 请求/响应类型
|
||||||
|
├── pkg/
|
||||||
|
│ ├── db/ # 数据库操作(insert / update / meta_field)
|
||||||
|
│ └── jwtx/ # JWT 工具
|
||||||
|
└── main.go
|
||||||
|
|
||||||
|
app-rn/ # React Native 前端
|
||||||
|
├── app.json
|
||||||
|
├── package.json # ⚠️ 写页面必须先读这个
|
||||||
|
├── tailwind.config.ts
|
||||||
|
├── src/
|
||||||
|
│ ├── app/ # Expo Router 页面
|
||||||
|
│ │ ├── _layout.tsx # 根布局
|
||||||
|
│ │ ├── (tabs)/ # 主 Tab(home / space / tools / chat / mine)
|
||||||
|
│ │ ├── auth/ # 登录页
|
||||||
|
│ │ ├── tools/ # 工具广场
|
||||||
|
│ │ └── finance/ # 记账子模块
|
||||||
|
│ ├── components/ # 通用组件(layout/view / AZIndex 等)
|
||||||
|
│ ├── configs/ # pages.ts(子应用列表配置)
|
||||||
|
│ ├── layouts/ # 布局(ModuleLayout 等)
|
||||||
|
│ ├── request/ # API 封装(index.ts / api.ts)
|
||||||
|
│ ├── store/ # Zustand 状态管理
|
||||||
|
│ └── types/ # TypeScript 类型定义
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端关键依赖库
|
||||||
|
|
||||||
|
写页面/组件时必须优先使用项目已安装的库,不要引入新包:
|
||||||
|
|
||||||
|
| 库 | 用途 | 导入方式 |
|
||||||
|
|----|------|----------|
|
||||||
|
| `heroui-native` | UI 组件库(PressableFeedback、SearchField、Button 等) | `import { PressableFeedback, SearchField } from "heroui-native"` |
|
||||||
|
| `@legendapp/list` | 高性能长列表 | `import { LegendList } from "@legendapp/list"` |
|
||||||
|
| `lucide-react-native` | 图标库 | `import { Star, ... } from "lucide-react-native"` |
|
||||||
|
| `zustand` | 状态管理 | `import { create } from "zustand"` |
|
||||||
|
| `alova` + `@alova/adapter-axios` | 请求库 | `import { createAlova } from "alova"` |
|
||||||
|
| `dayjs` | 日期处理 | `import dayjs from "dayjs"` |
|
||||||
|
| `expo-router` | 路由 | `import { useRouter } from "expo-router"` |
|
||||||
|
| `react-native-reanimated` | 动画 | `import Animated from "react-native-reanimated"` |
|
||||||
|
| `react-native-gesture-handler` | 手势 | 与 reanimated 配合 |
|
||||||
|
| `react-native-safe-area-context` | 安全区域 | `import { useSafeAreaInsets } from "react-native-safe-area-context"` |
|
||||||
|
| `tailwindcss` + `tailwind-merge` | 样式 | className 方式,`import { twMerge } from "tailwind-merge"` |
|
||||||
|
| `sonner-native` | Toast 提示 | `import { toast } from "sonner-native"` |
|
||||||
|
| `@gorhom/bottom-sheet` | 底部弹出面板 | |
|
||||||
|
| `expo-image` | 图片组件 | `import { Image } from "expo-image"` |
|
||||||
|
|
||||||
|
**样式约定**:项目使用 NativeWind(Tailwind CSS),className 方式写样式,比如 `className="bg-white rounded-xl shadow-lg"`。不要写内联 `style={{}}` 除非必要。
|
||||||
|
|
||||||
|
## 核心开发规范
|
||||||
|
|
||||||
|
### 规则 0:写页面前必须先读 package.json
|
||||||
|
|
||||||
|
**每次写新页面或新组件之前**,必须先用 Read 工具读取 `app-rn/package.json`,了解项目已有的依赖库,确保:
|
||||||
|
- 使用项目已有的组件库和工具库,不要引入未安装的包
|
||||||
|
- 了解可用的第三方组件(如 heroui-native、@legendapp/list 等),优先复用
|
||||||
|
|
||||||
|
### 规则 1:禁止新增专用 API 接口
|
||||||
|
|
||||||
|
项目已有 4 个通用数据 API,所有 CRUD 操作必须复用它们,**不得再创建专用 REST 端点**:
|
||||||
|
|
||||||
|
| 接口 | 路径 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| loadData | `POST /data/loadData` | 查询:传入 `view_name`(表/视图名)、`search_condition`(WHERE)、`order_by`、`search_columns`、`args` |
|
||||||
|
| saveData | `POST /data/saveData` | 增删改:传入 `[{table_name, key_field, inserts, updates, deletes}]` 数组 |
|
||||||
|
| loadDataBySql | `POST /data/loadDataBySql` | 原生 SQL:传入 `sql` 和 `args` |
|
||||||
|
| getUniqueId | `POST /data/getUniqueId` | 获取雪花 ID:传入 `count`(1-100) |
|
||||||
|
|
||||||
|
**新增接口必须满足两个条件:**
|
||||||
|
1. 先向用户确认是否允许新增接口
|
||||||
|
2. 新接口必须是**通用接口**(类似上面四种),不能专属于某个功能
|
||||||
|
|
||||||
|
前端调用示例:
|
||||||
|
```ts
|
||||||
|
// 查询
|
||||||
|
import { loadDataApi, saveDataApi, getUniqueIdApi } from "@/request/api";
|
||||||
|
|
||||||
|
const res = await loadDataApi({
|
||||||
|
view_name: "b_user",
|
||||||
|
search_condition: "id = ?",
|
||||||
|
search_columns: [],
|
||||||
|
order_by: "",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插入
|
||||||
|
const idRes = await getUniqueIdApi(1);
|
||||||
|
await saveDataApi([{
|
||||||
|
table_name: "b_user",
|
||||||
|
key_field: "id",
|
||||||
|
inserts: [{ id: idRes.data[0], nickname: "test", ... }],
|
||||||
|
updates: [],
|
||||||
|
deletes: [],
|
||||||
|
}]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 规则 2:数据库表必须包含审计字段
|
||||||
|
|
||||||
|
所有业务表的**必需列**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id BIGINT PRIMARY KEY, -- 雪花 ID
|
||||||
|
create_time TIMESTAMPTZ NOT NULL, -- 创建时间
|
||||||
|
update_time TIMESTAMPTZ NOT NULL, -- 更新时间
|
||||||
|
create_by BIGINT, -- 创建者 user_id
|
||||||
|
update_by BIGINT -- 更新者 user_id
|
||||||
|
```
|
||||||
|
|
||||||
|
后端 `pkg/db/meta_field.go` 中的 `applyMetaFields` 函数会为 insert/update 自动注入这些字段。
|
||||||
|
只有 `b_user` 和 `b_user_oauth` 两类系统表在 `auditExcludeTables` 中豁免,**其他新表一律不要加豁免**。
|
||||||
|
|
||||||
|
ID 获取方式:
|
||||||
|
- 前端:调用 `getUniqueIdApi(count)` 获取
|
||||||
|
- 后端:调用 `uniqueid.NextId()` 获取
|
||||||
|
|
||||||
|
### 规则 3:不要重复造轮子
|
||||||
|
|
||||||
|
- **前端 API 请求**:统一使用 `@/request/api` 中已有的方法,不要创建新的 API 封装
|
||||||
|
- **前端状态管理**:优先复用已有的 Zustand store(`@/store/`),新 Store 只用通用 API
|
||||||
|
- **前端组件**:
|
||||||
|
- UI 组件优先用 `heroui-native`(PressableFeedback、SearchField、Button、Spinner 等)
|
||||||
|
- 长列表用 `@legendapp/list`(LegendList),不要用 FlatList/ScrollView 处理大量数据
|
||||||
|
- 图标统一用 `lucide-react-native`
|
||||||
|
- Toast 用 `sonner-native`
|
||||||
|
- 底部面板用 `@gorhom/bottom-sheet`
|
||||||
|
- 样式用 NativeWind className,避免内联 style
|
||||||
|
- **后端**:不要创建新的 handler 和路由,除非是通用基础设施
|
||||||
|
|
||||||
|
### 规则 4:家庭/空间为中心的设计
|
||||||
|
|
||||||
|
系统以家庭(Family)为核心实体。所有业务数据(记账、收藏等)都跟 `family_id` 关联。
|
||||||
|
用户通过 `family_member` 表关联到家庭。
|
||||||
|
|
||||||
|
## 关键文件索引
|
||||||
|
|
||||||
|
| 文件 | 说明 | 写页面必读 |
|
||||||
|
|------|------|:---:|
|
||||||
|
| `app-rn/package.json` | 前端依赖清单,了解可用库 | ⭐ |
|
||||||
|
| `PROJECT_CONTEXT.md` | 完整项目文档 | |
|
||||||
|
| `db.local.yml` | 本地数据库连接备忘 | |
|
||||||
|
| `app-go/internal/router/router.go` | 后端路由定义 | |
|
||||||
|
| `app-go/pkg/db/meta_field.go` | 审计字段自动注入逻辑 | |
|
||||||
|
| `app-rn/src/request/api.ts` | 前端 API 方法 | |
|
||||||
|
| `app-rn/src/store/` | Zustand 状态管理 | |
|
||||||
|
| `app-rn/src/configs/pages.ts` | 子应用列表配置 | |
|
||||||
|
| `app-rn/src/components/` | 通用组件 | |
|
||||||
|
|
||||||
|
## 当前数据库表
|
||||||
|
|
||||||
|
```
|
||||||
|
b_user - 用户
|
||||||
|
b_user_oauth - 第三方登录绑定
|
||||||
|
b_family - 家庭/空间
|
||||||
|
b_family_member - 家庭成员
|
||||||
|
b_finance - 记账记录
|
||||||
|
b_finance_category - 记账分类
|
||||||
|
b_finance_category_default - 默认分类模板
|
||||||
|
b_app_favorite - 子应用收藏
|
||||||
|
```
|
||||||
|
|
||||||
|
视图:`v_family_member`(join b_family_member + b_user)
|
||||||
|
|
||||||
|
## 常见操作示例
|
||||||
|
|
||||||
|
### 前端:拉取数据
|
||||||
|
```ts
|
||||||
|
import { loadDataApi } from "@/request/api";
|
||||||
|
|
||||||
|
// 查当前家庭的收藏
|
||||||
|
const res = await loadDataApi({
|
||||||
|
view_name: "b_app_favorite",
|
||||||
|
search_condition: "family_id = ?",
|
||||||
|
order_by: "id ASC",
|
||||||
|
search_columns: [],
|
||||||
|
args: [familyId],
|
||||||
|
});
|
||||||
|
if (res.isSuccess) {
|
||||||
|
const list = res.data; // any[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端:保存数据(新增)
|
||||||
|
```ts
|
||||||
|
import { saveDataApi, getUniqueIdApi } from "@/request/api";
|
||||||
|
|
||||||
|
const idRes = await getUniqueIdApi(1);
|
||||||
|
await saveDataApi([{
|
||||||
|
table_name: "b_app_favorite",
|
||||||
|
key_field: "id",
|
||||||
|
inserts: [{
|
||||||
|
id: idRes.data[0],
|
||||||
|
family_id: familyId,
|
||||||
|
user_id: userId,
|
||||||
|
app_id: "finance",
|
||||||
|
}],
|
||||||
|
updates: [],
|
||||||
|
deletes: [],
|
||||||
|
}]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端:保存数据(删除)
|
||||||
|
```ts
|
||||||
|
await saveDataApi([{
|
||||||
|
table_name: "b_app_favorite",
|
||||||
|
key_field: "id",
|
||||||
|
inserts: [],
|
||||||
|
updates: [],
|
||||||
|
deletes: [{ id: recordId }],
|
||||||
|
}]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python:连接数据库
|
||||||
|
```python
|
||||||
|
import psycopg2
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
'postgres://postgres:<password>@117.72.182.135:5432/allapp?sslmode=disable'
|
||||||
|
)
|
||||||
|
# 具体密码见 references/db-config.md
|
||||||
|
```
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Allapp 数据库连接配置
|
||||||
|
|
||||||
|
## 连接信息
|
||||||
|
|
||||||
|
| 参数 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| Host | 117.72.182.135 |
|
||||||
|
| Port | 5432 |
|
||||||
|
| Database | allapp |
|
||||||
|
| User | postgres |
|
||||||
|
| Password | zhang520.. |
|
||||||
|
| SSL | disable |
|
||||||
|
|
||||||
|
## 连接字符串
|
||||||
|
|
||||||
|
```
|
||||||
|
postgres://postgres:zhang520..@117.72.182.135:5432/allapp?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python 连接示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host="117.72.182.135",
|
||||||
|
port=5432,
|
||||||
|
database="allapp",
|
||||||
|
user="postgres",
|
||||||
|
password="zhang520..",
|
||||||
|
sslmode="disable",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相关配置文件
|
||||||
|
|
||||||
|
- `D:\workspace\code\app\db.local.yml` — 本地备忘
|
||||||
|
- `D:\workspace\code\app\app-go\configs\config.dev.yaml` — 后端开发配置(base_url、端口等)
|
||||||
|
- `D:\workspace\code\app\app-go\configs\config.yaml` — 后端主配置
|
||||||
|
|
||||||
|
## 表结构速查
|
||||||
|
|
||||||
|
所有表均在 `public` schema 下,无外键约束,关系由代码维护。
|
||||||
|
|
||||||
|
### b_user — 用户
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| id | BIGINT PK | 雪花 ID |
|
||||||
|
| nickname | VARCHAR | 昵称 |
|
||||||
|
| avatar | VARCHAR | 头像路径 |
|
||||||
|
| name | VARCHAR | 真实姓名 |
|
||||||
|
| phone | VARCHAR | 手机号 |
|
||||||
|
| birth_date | DATE | 生日 |
|
||||||
|
| last_login_time | TIMESTAMPTZ | 最后登录时间 |
|
||||||
|
| create_time | TIMESTAMPTZ | 创建时间 |
|
||||||
|
| update_time | TIMESTAMPTZ | 更新时间 |
|
||||||
|
|
||||||
|
### b_family — 家庭/空间
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| id | BIGINT PK | 雪花 ID |
|
||||||
|
| name | VARCHAR | 家庭名称 |
|
||||||
|
| code | VARCHAR | 邀请码 |
|
||||||
|
| create_by | BIGINT | 创建者 |
|
||||||
|
| create_time | TIMESTAMPTZ | 创建时间 |
|
||||||
|
| update_time | TIMESTAMPTZ | 更新时间 |
|
||||||
|
|
||||||
|
### b_family_member — 家庭成员
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| id | BIGINT PK | 雪花 ID |
|
||||||
|
| family_id | BIGINT | 家庭 ID |
|
||||||
|
| user_id | BIGINT | 用户 ID |
|
||||||
|
| role | INTEGER | 角色(0=成员) |
|
||||||
|
| nickname | VARCHAR | 在家庭中的昵称 |
|
||||||
|
|
||||||
|
### b_finance — 记账记录
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| id | BIGINT PK | 雪花 ID |
|
||||||
|
| family_id | BIGINT | 家庭 ID |
|
||||||
|
| category_id | BIGINT | 分类 ID |
|
||||||
|
| amount | NUMERIC | 金额 |
|
||||||
|
| type | INTEGER | 0=支出, 1=收入 |
|
||||||
|
| remark | TEXT | 备注 |
|
||||||
|
| record_time | TIMESTAMPTZ | 记账时间 |
|
||||||
|
|
||||||
|
### b_finance_category — 记账分类
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| id | BIGINT PK | 雪花 ID |
|
||||||
|
| family_id | BIGINT | 家庭 ID |
|
||||||
|
| name | VARCHAR | 分类名称 |
|
||||||
|
| icon | VARCHAR | 图标标识 |
|
||||||
|
| type | INTEGER | 0=支出, 1=收入 |
|
||||||
|
|
||||||
|
### b_app_favorite — 子应用收藏
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| id | BIGINT PK | 雪花 ID |
|
||||||
|
| family_id | BIGINT | 家庭 ID |
|
||||||
|
| user_id | BIGINT | 用户 ID |
|
||||||
|
| app_id | VARCHAR | 应用标识(对应 pages.ts 中 id) |
|
||||||
|
| create_time | TIMESTAMPTZ | 创建时间 |
|
||||||
|
| update_time | TIMESTAMPTZ | 更新时间 |
|
||||||
|
| create_by | BIGINT | 创建者 |
|
||||||
|
| update_by | BIGINT | 更新者 |
|
||||||
|
|
||||||
|
UNIQUE(family_id, app_id) — 同一家庭同一应用只能收藏一次
|
||||||
@@ -85,6 +85,7 @@ Important endpoints:
|
|||||||
```text
|
```text
|
||||||
POST /app/auth/login/qq
|
POST /app/auth/login/qq
|
||||||
POST /app/auth/login/wechat
|
POST /app/auth/login/wechat
|
||||||
|
POST /app/auth/login/test
|
||||||
POST /app/bind/qq
|
POST /app/bind/qq
|
||||||
POST /app/bind/wechat
|
POST /app/bind/wechat
|
||||||
|
|
||||||
@@ -326,6 +327,7 @@ b_family_member
|
|||||||
b_finance
|
b_finance
|
||||||
b_finance_category
|
b_finance_category
|
||||||
b_finance_category_default
|
b_finance_category_default
|
||||||
|
b_app_favorite
|
||||||
```
|
```
|
||||||
|
|
||||||
Views:
|
Views:
|
||||||
@@ -429,3 +431,49 @@ Backend:
|
|||||||
- Main tab route `/space` is missing.
|
- Main tab route `/space` is missing.
|
||||||
- Some pages are placeholders or demos.
|
- Some pages are placeholders or demos.
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
These rules MUST be followed. They exist to keep the codebase consistent and avoid unnecessary
|
||||||
|
duplication.
|
||||||
|
|
||||||
|
### API — No New Dedicated Endpoints
|
||||||
|
|
||||||
|
The project already has a set of generic data APIs. Do NOT create dedicated REST endpoints for
|
||||||
|
CRUD operations. Always use the existing generic APIs instead:
|
||||||
|
|
||||||
|
| Generic API | Usage |
|
||||||
|
|---|---|
|
||||||
|
| `POST /data/loadData` | Query with `view_name` (table/view), `search_condition`, `order_by`, `search_columns`, `args` |
|
||||||
|
| `POST /data/loadDataBySql` | Raw SQL query with `sql` and `args` |
|
||||||
|
| `POST /data/saveData` | Batch insert/update/delete by `table_name` and `key_field` |
|
||||||
|
| `POST /data/getUniqueId` | Get snowflake IDs, `count` param (1–100) |
|
||||||
|
|
||||||
|
If you genuinely believe a new endpoint is needed, **ask the user first**. Even then, new endpoints
|
||||||
|
should be generic (e.g. a new generic helper), not dedicated to a single feature.
|
||||||
|
|
||||||
|
### Database — Standard Audit Columns
|
||||||
|
|
||||||
|
All business tables MUST include these four standard columns:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create_time TIMESTAMPTZ -- auto-set by applyMetaFields on insert
|
||||||
|
update_time TIMESTAMPTZ -- auto-set by applyMetaFields on insert & update
|
||||||
|
create_by BIGINT -- auto-set by applyMetaFields on insert (from JWT user_id)
|
||||||
|
update_by BIGINT -- auto-set by applyMetaFields on update (from JWT user_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `applyMetaFields` function in `pkg/db/meta_field.go` injects these automatically for all tables
|
||||||
|
except those in the `auditExcludeTables` list (`b_user`, `b_user_oauth`).
|
||||||
|
|
||||||
|
When creating a new table:
|
||||||
|
1. Include all four columns
|
||||||
|
2. Do NOT add the table to `auditExcludeTables` unless it is a system-level table (user/auth)
|
||||||
|
3. Primary key `id` should be a snowflake BIGINT, obtained via `getUniqueIdApi` on the frontend or
|
||||||
|
`uniqueid.NextId()` on the backend
|
||||||
|
|
||||||
|
### Frontend State — Reuse Existing Stores
|
||||||
|
|
||||||
|
Before creating a new Zustand store or API wrapper, check whether the data can be fetched via the
|
||||||
|
existing `loadDataApi` / `saveDataApi` from the `@/request/api` module. New API wrapper functions
|
||||||
|
should only wrap these generic endpoints — never introduce feature-specific RPC-style endpoints.
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
app:
|
app:
|
||||||
port: 8087
|
port: 8087
|
||||||
|
base_url: ""
|
||||||
postgres:
|
postgres:
|
||||||
host: 117.72.182.135
|
host: 117.72.182.135
|
||||||
port: 5432
|
port: 5432
|
||||||
|
|||||||
@@ -59,6 +59,84 @@ func LoginWechat(c fiber.Ctx) error {
|
|||||||
return handleThirdLogin(c, openid, nickname, avatar, 0, vo)
|
return handleThirdLogin(c, openid, nickname, avatar, 0, vo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginTest ======================== 测试登录 ========================
|
||||||
|
func LoginTest(c fiber.Ctx) error {
|
||||||
|
var req types.LoginTestReq
|
||||||
|
vo := new(types.LoginResp)
|
||||||
|
|
||||||
|
if err := httpx.BindAndValidate(c, &req); err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbClient := db.New()
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
users, err := dbClient.LoadData(
|
||||||
|
c.Context(),
|
||||||
|
"b_user",
|
||||||
|
"id = ?",
|
||||||
|
"",
|
||||||
|
[]string{"id", "nickname", "avatar"},
|
||||||
|
[]any{req.UserId},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return httpx.Fail(c, "测试用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := users[0]
|
||||||
|
|
||||||
|
// 更新登录时间
|
||||||
|
if err := dbClient.Update(
|
||||||
|
c.Context(),
|
||||||
|
"b_user",
|
||||||
|
"id",
|
||||||
|
map[string]any{
|
||||||
|
"id": req.UserId,
|
||||||
|
"last_login_time": "NOW()",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查 family
|
||||||
|
familyMembers, err := dbClient.LoadData(
|
||||||
|
c.Context(),
|
||||||
|
"b_family_member",
|
||||||
|
"user_id = ?",
|
||||||
|
"",
|
||||||
|
[]string{"family_id"},
|
||||||
|
[]any{req.UserId},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(familyMembers) == 0 {
|
||||||
|
vo.FamilyId = 0
|
||||||
|
} else {
|
||||||
|
vo.FamilyId = toInt64(familyMembers[0]["family_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 JWT
|
||||||
|
token, err := jwtx.CreateToken(c.Context(), jwtx.TokenData{
|
||||||
|
UserID: req.UserId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vo.Token = token
|
||||||
|
vo.UserId = req.UserId
|
||||||
|
vo.Nickname, _ = user["nickname"].(string)
|
||||||
|
vo.Avatar, _ = user["avatar"].(string)
|
||||||
|
|
||||||
|
return httpx.OK(c, vo)
|
||||||
|
}
|
||||||
|
|
||||||
// ======================== 第三方登录主流程 ========================
|
// ======================== 第三方登录主流程 ========================
|
||||||
func handleThirdLogin(
|
func handleThirdLogin(
|
||||||
c fiber.Ctx,
|
c fiber.Ctx,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func SetupRouter(app *fiber.App, cfg *config.Config) {
|
|||||||
// ==================== auth ====================
|
// ==================== auth ====================
|
||||||
api.Post("/auth/login/qq", handle.LoginQq)
|
api.Post("/auth/login/qq", handle.LoginQq)
|
||||||
api.Post("/auth/login/wechat", handle.LoginWechat)
|
api.Post("/auth/login/wechat", handle.LoginWechat)
|
||||||
|
api.Post("/auth/login/test", handle.LoginTest)
|
||||||
|
|
||||||
bind := api.Group("/bind", middleware.Auth())
|
bind := api.Group("/bind", middleware.Auth())
|
||||||
bind.Post("/qq", handle.BindQq)
|
bind.Post("/qq", handle.BindQq)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ type LoginWechatReq struct {
|
|||||||
Code string `json:"code" validate:"required"`
|
Code string `json:"code" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoginTestReq struct {
|
||||||
|
UserId int64 `json:"user_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type LoginResp struct {
|
type LoginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
UserId int64 `json:"userId"`
|
UserId int64 `json:"userId"`
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
EXPO_PUBLIC_API_URL=https://dev.api.com
|
EXPO_PUBLIC_API_URL=http://192.168.5.2:8087
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import { loadDataApi } from "@/request/api";
|
import { loadDataApi } from "@/request/api";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text, useWindowDimensions } from "react-native";
|
||||||
import { useFamilyStore } from "@/store";
|
import { useFamilyStore, useFavoriteStore } from "@/store";
|
||||||
import { LayoutGridIcon, LayersIcon } from "lucide-react-native";
|
import { LayoutGridIcon, LayersIcon } from "lucide-react-native";
|
||||||
import { IconAppFinance, IconAppClothes } from "@/components/iconfont";
|
|
||||||
import { PressableFeedback } from "heroui-native";
|
import { PressableFeedback } from "heroui-native";
|
||||||
|
import { SUB_APPS, type SubAppItem } from "@/configs/pages";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
const NUM_COLUMNS = 3;
|
||||||
|
const GAP = 10;
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
const familyStore = useFamilyStore();
|
const familyStore = useFamilyStore();
|
||||||
|
const favoriteStore = useFavoriteStore();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
|
||||||
const [family, setFamily] = useState<any>({});
|
const [family, setFamily] = useState<any>({});
|
||||||
|
|
||||||
const [duration, setDuration] = useState({
|
const [duration, setDuration] = useState({
|
||||||
@@ -18,10 +26,16 @@ export default function Home() {
|
|||||||
seconds: 0,
|
seconds: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
|
||||||
|
const contentPadding = 16;
|
||||||
|
const itemWidth = Math.floor(
|
||||||
|
(screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
|
||||||
|
);
|
||||||
|
|
||||||
async function loadFamilyData() {
|
async function loadFamilyData() {
|
||||||
const res = await loadDataApi({
|
const res = await loadDataApi({
|
||||||
view_name: `b_family`,
|
view_name: "b_family",
|
||||||
search_condition: `id = ?`,
|
search_condition: "id = ?",
|
||||||
search_columns: [],
|
search_columns: [],
|
||||||
order_by: "",
|
order_by: "",
|
||||||
args: [familyStore.currentFamily?.family_id],
|
args: [familyStore.currentFamily?.family_id],
|
||||||
@@ -63,6 +77,46 @@ export default function Home() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [family.create_time]);
|
}, [family.create_time]);
|
||||||
|
|
||||||
|
// 监听家庭变化,拉取收藏
|
||||||
|
useEffect(() => {
|
||||||
|
const fid = familyStore.currentFamily.family_id;
|
||||||
|
if (fid) favoriteStore.fetchFavorites(fid);
|
||||||
|
}, [familyStore.currentFamily.family_id]);
|
||||||
|
|
||||||
|
// 首页展示:收藏在前,其余在后 + "更多" 铺满行
|
||||||
|
const displayItems: SubAppItem[] = (() => {
|
||||||
|
const favIds = favoriteStore.favorites;
|
||||||
|
const starred = SUB_APPS.filter((a) => favIds.has(a.id));
|
||||||
|
const unstarred = SUB_APPS.filter((a) => !favIds.has(a.id));
|
||||||
|
return [...starred, ...unstarred];
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 按 3 列分行使"更多"自然填充
|
||||||
|
const cardRows: { items: SubAppItem[]; hasMore: boolean }[] = (() => {
|
||||||
|
const rows: { items: SubAppItem[]; hasMore: boolean }[] = [];
|
||||||
|
const all = [...displayItems];
|
||||||
|
const hasMore = true; // 永远显示"更多"
|
||||||
|
|
||||||
|
for (let i = 0; i < all.length; i += NUM_COLUMNS) {
|
||||||
|
rows.push({ items: all.slice(i, i + NUM_COLUMNS), hasMore: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一行若不满 3 个,"更多" 补在末尾
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
if (last && last.items.length < NUM_COLUMNS) {
|
||||||
|
last.hasMore = true;
|
||||||
|
last.items.push({ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any);
|
||||||
|
} else {
|
||||||
|
// 最后一行已满或没有数据,新增一行
|
||||||
|
rows.push({
|
||||||
|
items: [{ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any],
|
||||||
|
hasMore: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<LayoutView.ScrollView>
|
<LayoutView.ScrollView>
|
||||||
@@ -75,16 +129,24 @@ export default function Home() {
|
|||||||
|
|
||||||
<View className="items-center mt-3">
|
<View className="items-center mt-3">
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<Text className="text-3xl font-bold text-accent">{duration.days}</Text>
|
<Text className="text-3xl font-bold text-accent">
|
||||||
|
{duration.days}
|
||||||
|
</Text>
|
||||||
<Text className="text-gray-400">天</Text>
|
<Text className="text-gray-400">天</Text>
|
||||||
|
|
||||||
<Text className="text-3xl font-bold text-accent">{duration.hours}</Text>
|
<Text className="text-3xl font-bold text-accent">
|
||||||
|
{duration.hours}
|
||||||
|
</Text>
|
||||||
<Text className="text-gray-400">时</Text>
|
<Text className="text-gray-400">时</Text>
|
||||||
|
|
||||||
<Text className="text-3xl font-bold text-accent">{duration.minutes}</Text>
|
<Text className="text-3xl font-bold text-accent">
|
||||||
|
{duration.minutes}
|
||||||
|
</Text>
|
||||||
<Text className="text-gray-400">分</Text>
|
<Text className="text-gray-400">分</Text>
|
||||||
|
|
||||||
<Text className="text-3xl font-bold text-accent">{duration.seconds}</Text>
|
<Text className="text-3xl font-bold text-accent">
|
||||||
|
{duration.seconds}
|
||||||
|
</Text>
|
||||||
<Text className="text-gray-400">秒</Text>
|
<Text className="text-gray-400">秒</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -92,35 +154,52 @@ export default function Home() {
|
|||||||
|
|
||||||
<View className="mt-6">
|
<View className="mt-6">
|
||||||
<View className="flex-row items-center mb-2 gap-2">
|
<View className="flex-row items-center mb-2 gap-2">
|
||||||
<View className="text-gray-500">
|
|
||||||
<LayoutGridIcon size={18} color="#6B7280" />
|
<LayoutGridIcon size={18} color="#6B7280" />
|
||||||
</View>
|
|
||||||
<Text className="text-sm text-muted">快捷应用</Text>
|
<Text className="text-sm text-muted">快捷应用</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-row flex-wrap gap-3">
|
{cardRows.map((row, rowIdx) => (
|
||||||
<PressableFeedback className="flex-1 items-center justify-center h-20 bg-white rounded-xl shadow-lg">
|
<View
|
||||||
<IconAppFinance size={26} />
|
key={rowIdx}
|
||||||
|
className="flex-row mb-2.5 last:mb-0"
|
||||||
<Text className="text-xs text-gray-700 mt-2 font-medium">记账</Text>
|
style={{ gap: GAP }}
|
||||||
|
>
|
||||||
|
{row.items.map((app) => {
|
||||||
|
const Icon = app.icon;
|
||||||
|
const isMoreBtn = app.id === "__more__";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PressableFeedback
|
||||||
|
key={app.id}
|
||||||
|
onPress={() =>
|
||||||
|
isMoreBtn
|
||||||
|
? router.navigate("/tools")
|
||||||
|
: router.navigate(app.id as any)
|
||||||
|
}
|
||||||
|
style={{ width: itemWidth }}
|
||||||
|
className="items-center justify-center h-20 bg-white rounded-xl shadow-lg"
|
||||||
|
>
|
||||||
|
<Icon size={26} />
|
||||||
|
<Text className="text-xs text-gray-700 mt-2 font-medium">
|
||||||
|
{app.title}
|
||||||
|
</Text>
|
||||||
<PressableFeedback.Ripple />
|
<PressableFeedback.Ripple />
|
||||||
</PressableFeedback>
|
</PressableFeedback>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<PressableFeedback className="flex-1 items-center justify-center h-20 bg-white rounded-xl shadow-lg">
|
{/* 末尾空位占位,保持左对齐 */}
|
||||||
<IconAppClothes size={26} />
|
{row.items.length < NUM_COLUMNS &&
|
||||||
|
Array.from({ length: NUM_COLUMNS - row.items.length }).map(
|
||||||
<Text className="text-xs text-gray-700 mt-2 font-medium">衣服搭配</Text>
|
(_, emptyIdx) => (
|
||||||
<PressableFeedback.Ripple />
|
<View
|
||||||
</PressableFeedback>
|
key={`empty-${emptyIdx}`}
|
||||||
|
style={{ width: itemWidth }}
|
||||||
<PressableFeedback className="flex-1 items-center justify-center h-20 bg-white rounded-xl shadow-lg">
|
/>
|
||||||
<LayersIcon size={26} />
|
)
|
||||||
|
)}
|
||||||
<Text className="text-xs text-gray-700 mt-2 font-medium">更多</Text>
|
|
||||||
<PressableFeedback.Ripple />
|
|
||||||
</PressableFeedback>
|
|
||||||
</View>
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</LayoutView.ScrollView>
|
</LayoutView.ScrollView>
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import ExpoQQ from "expo-qq";
|
|||||||
import ExpoWeChat from "expo-wechat";
|
import ExpoWeChat from "expo-wechat";
|
||||||
import { useEvent } from "expo";
|
import { useEvent } from "expo";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { loginQqApi, loginWxApi } from "@/request/api";
|
import { loginQqApi, loginWxApi, loginTestApi } from "@/request/api";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -192,6 +192,27 @@ export default function Login() {
|
|||||||
handleLogin();
|
handleLogin();
|
||||||
}, [authResult]);
|
}, [authResult]);
|
||||||
|
|
||||||
|
async function test1() {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginRes = await loginTestApi({
|
||||||
|
user_id: 801787924787269,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginRes.isSuccess) return;
|
||||||
|
|
||||||
|
await handlePostLogin(loginRes.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("测试登录失败", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function test2() {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -231,6 +252,29 @@ export default function Login() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
<Button variant="outline" onPress={test1} isDisabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner entering={FadeIn.delay(50)} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconQq />
|
||||||
|
<Button.Label>测试1</Button.Label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" onPress={test2} isDisabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner entering={FadeIn.delay(50)} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconQq />
|
||||||
|
<Button.Label>测试2</Button.Label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ControlField
|
<ControlField
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ModuleLayout from "@/layouts/ModuleLayout";
|
|||||||
import { useFinanceStore } from "@/store/finance";
|
import { useFinanceStore } from "@/store/finance";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { loadDataApi } from "@/request/api";
|
import { loadDataApi } from "@/request/api";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
@@ -45,5 +46,10 @@ export default function Layout() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <ModuleLayout tabs={tabs} />;
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||||
|
<ModuleLayout tabs={tabs} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,114 +1,212 @@
|
|||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import { SearchField, CloseButton, ListGroup, PressableFeedback, Separator } from "heroui-native";
|
import { SearchField, CloseButton, PressableFeedback } from "heroui-native";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text, useWindowDimensions } from "react-native";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { getGroupedApps } from "@/configs/pages";
|
import { getGroupedApps, type SubAppItem } from "@/configs/pages";
|
||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useMemo, useRef, useCallback, useEffect } from "react";
|
||||||
import AZIndex from "@/components/AZIndex";
|
|
||||||
import type { LegendListRef } from "@legendapp/list";
|
|
||||||
import { AppListItem } from "@/configs/pages";
|
|
||||||
import EmptyState from "@/components/EmptyStatus";
|
import EmptyState from "@/components/EmptyStatus";
|
||||||
|
import AZIndex from "@/components/AZIndex";
|
||||||
|
import { useFavoriteStore, useFamilyStore } from "@/store";
|
||||||
|
import { Star } from "lucide-react-native";
|
||||||
|
|
||||||
|
const NUM_COLUMNS = 3;
|
||||||
|
const GAP = 10;
|
||||||
|
|
||||||
export default function Tools() {
|
export default function Tools() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
const safeAreaInsets = useSafeAreaInsets();
|
||||||
const listRef = useRef<LegendListRef>(null);
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const scrollRef = useRef<any>(null);
|
||||||
|
const headerPositions = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
|
||||||
const data = useMemo(() => getGroupedApps(keyword), [keyword]);
|
const familyStore = useFamilyStore();
|
||||||
|
const favoriteStore = useFavoriteStore();
|
||||||
|
|
||||||
const scrollToLetter = (letter: string) => {
|
// 进入页面时拉取收藏
|
||||||
const index = data.findIndex((item) => item.type === "header" && item.title === letter);
|
useEffect(() => {
|
||||||
|
favoriteStore.fetchFavorites(familyStore.currentFamily.family_id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (index === -1) return;
|
// 收藏在前,其余在后
|
||||||
|
const sortedApps = useMemo(() => {
|
||||||
|
const grouped = getGroupedApps(keyword);
|
||||||
|
|
||||||
listRef.current?.scrollToIndex({
|
// 只收集 group 类型的条目
|
||||||
index,
|
const groups = grouped.filter((g) => g.type === "group") as {
|
||||||
animated: true,
|
type: "group";
|
||||||
viewOffset: safeAreaInsets.top + 56,
|
title: string;
|
||||||
|
data: SubAppItem[];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
if (groups.length === 0) return grouped;
|
||||||
|
|
||||||
|
// 合并所有 app 并按收藏排序
|
||||||
|
const allApps = groups.flatMap((g) => g.data);
|
||||||
|
const favIds = favoriteStore.favorites;
|
||||||
|
|
||||||
|
const starred = allApps.filter((a) => favIds.has(a.id));
|
||||||
|
const unstarred = allApps.filter((a) => !favIds.has(a.id));
|
||||||
|
|
||||||
|
const result: typeof grouped = [];
|
||||||
|
|
||||||
|
if (starred.length > 0 && !keyword) {
|
||||||
|
result.push({ type: "header", title: "收藏" });
|
||||||
|
result.push({ type: "group", title: "收藏", data: starred });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无关键词时保持原有分组展示非收藏项
|
||||||
|
if (!keyword) {
|
||||||
|
const groupMap = new Map<string, SubAppItem[]>();
|
||||||
|
unstarred.forEach((a) => {
|
||||||
|
const arr = groupMap.get(a.group) || [];
|
||||||
|
arr.push(a);
|
||||||
|
groupMap.set(a.group, arr);
|
||||||
});
|
});
|
||||||
|
const keys = Array.from(groupMap.keys()).sort();
|
||||||
setTimeout(() => {
|
keys.forEach((k) => {
|
||||||
listRef.current?.scrollToIndex({
|
result.push({ type: "header", title: k });
|
||||||
index,
|
result.push({ type: "group", title: k, data: groupMap.get(k)! });
|
||||||
animated: true,
|
|
||||||
viewOffset: safeAreaInsets.top + 56,
|
|
||||||
});
|
});
|
||||||
}, 100);
|
} else {
|
||||||
};
|
// 搜索模式下不分组,直接展示
|
||||||
|
if (unstarred.length > 0) {
|
||||||
|
result.push({ type: "header", title: "结果" });
|
||||||
|
result.push({ type: "group", title: "结果", data: unstarred });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [keyword, favoriteStore.favorites]);
|
||||||
|
|
||||||
|
// 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
|
||||||
|
const contentPadding = 16;
|
||||||
|
const itemWidth = Math.floor(
|
||||||
|
(screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLetterPress = useCallback(
|
||||||
|
(letter: string) => {
|
||||||
|
const y = headerPositions.current[letter];
|
||||||
|
if (y !== undefined && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTo({ y, animated: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||||
|
|
||||||
<LayoutView.LegendList<AppListItem>
|
<LayoutView.ScrollView
|
||||||
ref={listRef}
|
ref={scrollRef}
|
||||||
data={data}
|
showsVerticalScrollIndicator={false}
|
||||||
keyExtractor={(item, i) => String(i)}
|
topExtraPadding={70}
|
||||||
estimatedItemSize={120}
|
bottomExtraPadding={100}
|
||||||
recycleItems
|
>
|
||||||
initialContainerPoolRatio={20}
|
{sortedApps.length === 0 ? (
|
||||||
contentContainerStyle={{
|
<EmptyState />
|
||||||
paddingTop: safeAreaInsets.top + 60,
|
) : (
|
||||||
paddingBottom: safeAreaInsets.bottom + 100,
|
sortedApps.map((item, idx) => {
|
||||||
paddingRight: 32,
|
|
||||||
}}
|
|
||||||
ListEmptyComponent={<EmptyState />}
|
|
||||||
renderItem={({ item }: { item: AppListItem }) => {
|
|
||||||
if (item.type === "header") {
|
if (item.type === "header") {
|
||||||
return <Text className="text-xs text-gray-400 mb-2 px-2">{item.title}</Text>;
|
const headerKey = `${item.title}-${keyword || "all"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={`h-${idx}`}
|
||||||
|
onLayout={(e) => {
|
||||||
|
headerPositions.current[headerKey] =
|
||||||
|
e.nativeEvent.layout.y;
|
||||||
|
if (!keyword) {
|
||||||
|
headerPositions.current[item.title] =
|
||||||
|
e.nativeEvent.layout.y;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-[11px] text-gray-400/70 font-medium mb-2.5 ml-0.5 mt-3 first:mt-0 tracking-wider">
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: (typeof item.data)[] = [];
|
||||||
|
for (let i = 0; i < item.data.length; i += NUM_COLUMNS) {
|
||||||
|
rows.push(item.data.slice(i, i + NUM_COLUMNS));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-6">
|
<View key={`g-${idx}`} className="mb-4">
|
||||||
<ListGroup>
|
{rows.map((row, rowIdx) => (
|
||||||
{item.data.map((subItem, index) => {
|
<View
|
||||||
|
key={rowIdx}
|
||||||
|
className="flex-row mb-2.5 last:mb-0"
|
||||||
|
style={{ gap: GAP }}
|
||||||
|
>
|
||||||
|
{row.map((subItem) => {
|
||||||
const Icon = subItem.icon;
|
const Icon = subItem.icon;
|
||||||
|
const isFav = favoriteStore.isFavorite(subItem.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PressableFeedback
|
<View
|
||||||
key={subItem.id}
|
key={subItem.id}
|
||||||
onPress={() => {
|
style={{ width: itemWidth, height: 80 }}
|
||||||
router.navigate(subItem.id as any);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ListGroup.Item>
|
<PressableFeedback
|
||||||
<ListGroup.ItemPrefix>
|
onPress={() => router.navigate(subItem.id as any)}
|
||||||
<Icon size={22} />
|
className="absolute inset-0 items-center justify-center bg-white rounded-xl shadow-lg"
|
||||||
</ListGroup.ItemPrefix>
|
>
|
||||||
|
<Icon size={26} />
|
||||||
|
<Text className="text-xs text-gray-700 mt-2 font-medium text-center">
|
||||||
|
{subItem.title}
|
||||||
|
</Text>
|
||||||
|
<PressableFeedback.Ripple />
|
||||||
|
</PressableFeedback>
|
||||||
|
|
||||||
<ListGroup.ItemContent>
|
{/* 星标按钮 — 独立于导航主体,避免事件冲突 */}
|
||||||
<ListGroup.ItemTitle>{subItem.title}</ListGroup.ItemTitle>
|
<PressableFeedback
|
||||||
{subItem.description && (
|
onPress={() =>
|
||||||
<ListGroup.ItemDescription>
|
favoriteStore.toggleFavorite(
|
||||||
{subItem.description}
|
familyStore.currentFamily.family_id,
|
||||||
</ListGroup.ItemDescription>
|
subItem.id
|
||||||
)}
|
)
|
||||||
</ListGroup.ItemContent>
|
}
|
||||||
|
className="absolute top-1 right-1 p-1 z-10"
|
||||||
<ListGroup.ItemSuffix />
|
>
|
||||||
</ListGroup.Item>
|
<Star
|
||||||
|
size={14}
|
||||||
{index !== item.data.length - 1 && <Separator className="mx-4" />}
|
fill={isFav ? "#fbbf24" : "none"}
|
||||||
|
color={isFav ? "#fbbf24" : "#d1d5db"}
|
||||||
|
/>
|
||||||
<PressableFeedback.Ripple />
|
<PressableFeedback.Ripple />
|
||||||
</PressableFeedback>
|
</PressableFeedback>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}}
|
})}
|
||||||
|
{row.length < NUM_COLUMNS &&
|
||||||
|
Array.from({ length: NUM_COLUMNS - row.length }).map(
|
||||||
|
(_, emptyIdx) => (
|
||||||
|
<View
|
||||||
|
key={`empty-${emptyIdx}`}
|
||||||
|
style={{ width: itemWidth }}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</LayoutView.ScrollView>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
<View
|
<View
|
||||||
className="absolute top-0 left-0 right-0 px-4"
|
className="absolute top-0 left-0 right-0 px-4"
|
||||||
style={{
|
style={{ paddingTop: safeAreaInsets.top }}
|
||||||
paddingTop: safeAreaInsets.top,
|
|
||||||
paddingRight: 32,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SearchField value={keyword} onChange={setKeyword}>
|
<SearchField value={keyword} onChange={setKeyword}>
|
||||||
<SearchField.Group>
|
<SearchField.Group>
|
||||||
@@ -118,8 +216,10 @@ export default function Tools() {
|
|||||||
</SearchField>
|
</SearchField>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<AZIndex onSelect={scrollToLetter} />
|
{/* 字母导航 */}
|
||||||
|
<AZIndex onSelect={handleLetterPress} />
|
||||||
|
|
||||||
|
{/* 关闭按钮 */}
|
||||||
<View className="absolute bottom-6 left-0 right-0 items-center">
|
<View className="absolute bottom-6 left-0 right-0 items-center">
|
||||||
<CloseButton
|
<CloseButton
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
|
|||||||
}, [pathname, tabs]);
|
}, [pathname, tabs]);
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
router.replace("/(tabs)/home");
|
router.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { LoginQqParams, LoginResult, LoginWxParams } from "@/types/auth";
|
import type { LoginQqParams, LoginResult, LoginTestParams, LoginWxParams } from "@/types/auth";
|
||||||
import alovaInstance from "./index";
|
import alovaInstance from "./index";
|
||||||
|
|
||||||
export function loginQqApi(params: LoginQqParams) {
|
export function loginQqApi(params: LoginQqParams) {
|
||||||
@@ -9,6 +9,10 @@ export function loginWxApi(params: LoginWxParams) {
|
|||||||
return alovaInstance.Post<ResponseData<LoginResult>>(`/auth/login/wechat`, params);
|
return alovaInstance.Post<ResponseData<LoginResult>>(`/auth/login/wechat`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loginTestApi(params: LoginTestParams) {
|
||||||
|
return alovaInstance.Post<ResponseData<LoginResult>>(`/auth/login/test`, params);
|
||||||
|
}
|
||||||
|
|
||||||
export function bindQqApi(params: LoginQqParams) {
|
export function bindQqApi(params: LoginQqParams) {
|
||||||
return alovaInstance.Post<ResponseData<null>>(`/bind/qq`, params);
|
return alovaInstance.Post<ResponseData<null>>(`/bind/qq`, params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ const alovaInstance = createAlova({
|
|||||||
},
|
},
|
||||||
|
|
||||||
beforeRequest(method) {
|
beforeRequest(method) {
|
||||||
|
console.log(process.env.EXPO_PUBLIC_API_URL)
|
||||||
|
|
||||||
// 白名单接口直接放行
|
// 白名单接口直接放行
|
||||||
if (isWhiteListed(method.url)) return;
|
if (isWhiteListed(method.url)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import {
|
||||||
|
loadDataApi,
|
||||||
|
saveDataApi,
|
||||||
|
getUniqueIdApi,
|
||||||
|
} from "@/request/api";
|
||||||
|
import { useUserStore } from "./user";
|
||||||
|
|
||||||
|
interface FavoriteState {
|
||||||
|
/** 当前家庭已收藏的 app_id 集合 */
|
||||||
|
favorites: Set<string>;
|
||||||
|
/** 是否已加载 */
|
||||||
|
loaded: boolean;
|
||||||
|
/** 操作中的 app_id */
|
||||||
|
loadingIds: Set<string>;
|
||||||
|
|
||||||
|
/** 拉取收藏列表 */
|
||||||
|
fetchFavorites: (familyId: number) => Promise<void>;
|
||||||
|
/** 切换收藏状态 */
|
||||||
|
toggleFavorite: (familyId: number, appId: string) => Promise<boolean>;
|
||||||
|
/** 是否已收藏 */
|
||||||
|
isFavorite: (appId: string) => boolean;
|
||||||
|
/** 清空 */
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFavoriteStore = create<FavoriteState>()((set, get) => ({
|
||||||
|
favorites: new Set(),
|
||||||
|
loaded: false,
|
||||||
|
loadingIds: new Set(),
|
||||||
|
|
||||||
|
fetchFavorites: async (familyId: number) => {
|
||||||
|
if (familyId === 0) return;
|
||||||
|
const res = await loadDataApi({
|
||||||
|
view_name: "b_app_favorite",
|
||||||
|
search_condition: "family_id = ?",
|
||||||
|
order_by: "id ASC",
|
||||||
|
search_columns: [],
|
||||||
|
args: [familyId],
|
||||||
|
});
|
||||||
|
if (res.isSuccess) {
|
||||||
|
const ids = new Set(res.data.map((item: any) => item.app_id));
|
||||||
|
set({ favorites: ids, loaded: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFavorite: async (familyId: number, appId: string) => {
|
||||||
|
const { loadingIds, favorites } = get();
|
||||||
|
if (loadingIds.has(appId)) return favorites.has(appId);
|
||||||
|
|
||||||
|
const newLoading = new Set(loadingIds);
|
||||||
|
newLoading.add(appId);
|
||||||
|
set({ loadingIds: newLoading });
|
||||||
|
|
||||||
|
const isFav = get().favorites.has(appId);
|
||||||
|
|
||||||
|
if (isFav) {
|
||||||
|
// 已收藏 → 查 id → 删
|
||||||
|
const checkRes = await loadDataApi({
|
||||||
|
view_name: "b_app_favorite",
|
||||||
|
search_condition: "family_id = ? AND app_id = ?",
|
||||||
|
order_by: "",
|
||||||
|
search_columns: [],
|
||||||
|
args: [familyId, appId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextLoading = new Set(get().loadingIds);
|
||||||
|
nextLoading.delete(appId);
|
||||||
|
set({ loadingIds: nextLoading });
|
||||||
|
|
||||||
|
if (checkRes.isSuccess && checkRes.data.length > 0) {
|
||||||
|
const row = checkRes.data[0];
|
||||||
|
const delRes = await saveDataApi([
|
||||||
|
{
|
||||||
|
table_name: "b_app_favorite",
|
||||||
|
key_field: "id",
|
||||||
|
inserts: [],
|
||||||
|
updates: [],
|
||||||
|
deletes: [{ id: row.id }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (delRes.isSuccess) {
|
||||||
|
const newFavorites = new Set(get().favorites);
|
||||||
|
newFavorites.delete(appId);
|
||||||
|
set({ favorites: newFavorites });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // 失败时保持原状态
|
||||||
|
} else {
|
||||||
|
// 未收藏 → 取 id → 插入
|
||||||
|
const userId = useUserStore.getState().userInfo.id;
|
||||||
|
|
||||||
|
const idRes = await getUniqueIdApi(1);
|
||||||
|
|
||||||
|
const nextLoading = new Set(get().loadingIds);
|
||||||
|
nextLoading.delete(appId);
|
||||||
|
set({ loadingIds: nextLoading });
|
||||||
|
|
||||||
|
if (idRes.isSuccess && idRes.data.length > 0) {
|
||||||
|
const newId = idRes.data[0];
|
||||||
|
const saveRes = await saveDataApi([
|
||||||
|
{
|
||||||
|
table_name: "b_app_favorite",
|
||||||
|
key_field: "id",
|
||||||
|
inserts: [
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
family_id: familyId,
|
||||||
|
user_id: userId,
|
||||||
|
app_id: appId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updates: [],
|
||||||
|
deletes: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (saveRes.isSuccess) {
|
||||||
|
const newFavorites = new Set(get().favorites);
|
||||||
|
newFavorites.add(appId);
|
||||||
|
set({ favorites: newFavorites });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false; // 失败时保持原状态
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isFavorite: (appId: string) => get().favorites.has(appId),
|
||||||
|
|
||||||
|
clear: () => set({ favorites: new Set(), loaded: false, loadingIds: new Set() }),
|
||||||
|
}));
|
||||||
@@ -2,3 +2,4 @@ export * from "./app";
|
|||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./finance";
|
export * from "./finance";
|
||||||
export * from "./family";
|
export * from "./family";
|
||||||
|
export * from "./favorite";
|
||||||
@@ -4,6 +4,10 @@ export interface LoginQqParams {
|
|||||||
avatar: string;
|
avatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginTestParams {
|
||||||
|
user_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginWxParams {
|
export interface LoginWxParams {
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user