From e9e3371be7d8cf5ea1894f15f1b9c1e6d3ff4393 Mon Sep 17 00:00:00 2001 From: oneao Date: Mon, 22 Jun 2026 21:44:33 +0800 Subject: [PATCH] 20260622214432 --- code/app/.workbuddy/memory/2026-06-22.md | 81 ++++++ .../skills/allapp-conventions/SKILL.md | 251 +++++++++++++++++ .../references/db-config.md | 109 +++++++ code/app/PROJECT_CONTEXT.md | 48 ++++ code/app/app-go/configs/config.dev.yaml | 1 + code/app/app-go/internal/handle/auth.go | 78 +++++ code/app/app-go/internal/router/router.go | 1 + code/app/app-go/internal/types/auth.go | 4 + code/app/app-rn/.env.development | 2 +- code/app/app-rn/src/app/(tabs)/home/index.tsx | 145 +++++++--- code/app/app-rn/src/app/auth/login.tsx | 46 ++- .../app-rn/src/app/finance/(tabs)/_layout.tsx | 8 +- code/app/app-rn/src/app/tools/index.tsx | 266 ++++++++++++------ code/app/app-rn/src/layouts/ModuleLayout.tsx | 2 +- code/app/app-rn/src/request/api.ts | 6 +- code/app/app-rn/src/request/index.ts | 2 + code/app/app-rn/src/store/favorite.ts | 134 +++++++++ code/app/app-rn/src/store/index.ts | 1 + code/app/app-rn/src/types/auth.ts | 4 + 19 files changed, 1068 insertions(+), 121 deletions(-) create mode 100644 code/app/.workbuddy/memory/2026-06-22.md create mode 100644 code/app/.workbuddy/skills/allapp-conventions/SKILL.md create mode 100644 code/app/.workbuddy/skills/allapp-conventions/references/db-config.md create mode 100644 code/app/app-rn/src/store/favorite.ts diff --git a/code/app/.workbuddy/memory/2026-06-22.md b/code/app/.workbuddy/memory/2026-06-22.md new file mode 100644 index 00000000..6a75eb18 --- /dev/null +++ b/code/app/.workbuddy/memory/2026-06-22.md @@ -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: 添加 `` + +## 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`: 数据库连接信息、所有表结构速查 diff --git a/code/app/.workbuddy/skills/allapp-conventions/SKILL.md b/code/app/.workbuddy/skills/allapp-conventions/SKILL.md new file mode 100644 index 00000000..118a213f --- /dev/null +++ b/code/app/.workbuddy/skills/allapp-conventions/SKILL.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:@117.72.182.135:5432/allapp?sslmode=disable' +) +# 具体密码见 references/db-config.md +``` diff --git a/code/app/.workbuddy/skills/allapp-conventions/references/db-config.md b/code/app/.workbuddy/skills/allapp-conventions/references/db-config.md new file mode 100644 index 00000000..13db4065 --- /dev/null +++ b/code/app/.workbuddy/skills/allapp-conventions/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) — 同一家庭同一应用只能收藏一次 diff --git a/code/app/PROJECT_CONTEXT.md b/code/app/PROJECT_CONTEXT.md index a88936ba..2c37af78 100644 --- a/code/app/PROJECT_CONTEXT.md +++ b/code/app/PROJECT_CONTEXT.md @@ -85,6 +85,7 @@ Important endpoints: ```text POST /app/auth/login/qq POST /app/auth/login/wechat +POST /app/auth/login/test POST /app/bind/qq POST /app/bind/wechat @@ -326,6 +327,7 @@ b_family_member b_finance b_finance_category b_finance_category_default +b_app_favorite ``` Views: @@ -429,3 +431,49 @@ Backend: - Main tab route `/space` is missing. - 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. + diff --git a/code/app/app-go/configs/config.dev.yaml b/code/app/app-go/configs/config.dev.yaml index 08032689..f1901045 100644 --- a/code/app/app-go/configs/config.dev.yaml +++ b/code/app/app-go/configs/config.dev.yaml @@ -1,5 +1,6 @@ app: port: 8087 + base_url: "" postgres: host: 117.72.182.135 port: 5432 diff --git a/code/app/app-go/internal/handle/auth.go b/code/app/app-go/internal/handle/auth.go index f8f0f72a..03164493 100644 --- a/code/app/app-go/internal/handle/auth.go +++ b/code/app/app-go/internal/handle/auth.go @@ -59,6 +59,84 @@ func LoginWechat(c fiber.Ctx) error { 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( c fiber.Ctx, diff --git a/code/app/app-go/internal/router/router.go b/code/app/app-go/internal/router/router.go index 260f67cc..88475780 100644 --- a/code/app/app-go/internal/router/router.go +++ b/code/app/app-go/internal/router/router.go @@ -26,6 +26,7 @@ func SetupRouter(app *fiber.App, cfg *config.Config) { // ==================== auth ==================== api.Post("/auth/login/qq", handle.LoginQq) api.Post("/auth/login/wechat", handle.LoginWechat) + api.Post("/auth/login/test", handle.LoginTest) bind := api.Group("/bind", middleware.Auth()) bind.Post("/qq", handle.BindQq) diff --git a/code/app/app-go/internal/types/auth.go b/code/app/app-go/internal/types/auth.go index 3223801e..477a1fa8 100644 --- a/code/app/app-go/internal/types/auth.go +++ b/code/app/app-go/internal/types/auth.go @@ -10,6 +10,10 @@ type LoginWechatReq struct { Code string `json:"code" validate:"required"` } +type LoginTestReq struct { + UserId int64 `json:"user_id" validate:"required"` +} + type LoginResp struct { Token string `json:"token"` UserId int64 `json:"userId"` diff --git a/code/app/app-rn/.env.development b/code/app/app-rn/.env.development index 9af85c75..8dd73f76 100644 --- a/code/app/app-rn/.env.development +++ b/code/app/app-rn/.env.development @@ -1 +1 @@ -EXPO_PUBLIC_API_URL=https://dev.api.com \ No newline at end of file +EXPO_PUBLIC_API_URL=http://192.168.5.2:8087 \ No newline at end of file diff --git a/code/app/app-rn/src/app/(tabs)/home/index.tsx b/code/app/app-rn/src/app/(tabs)/home/index.tsx index 94619911..2cc8d2ea 100644 --- a/code/app/app-rn/src/app/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/(tabs)/home/index.tsx @@ -1,14 +1,22 @@ import { useEffect, useState } from "react"; import LayoutView from "@/components/layout/view"; import { loadDataApi } from "@/request/api"; -import { View, Text } from "react-native"; -import { useFamilyStore } from "@/store"; +import { View, Text, useWindowDimensions } from "react-native"; +import { useFamilyStore, useFavoriteStore } from "@/store"; import { LayoutGridIcon, LayersIcon } from "lucide-react-native"; -import { IconAppFinance, IconAppClothes } from "@/components/iconfont"; 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() { + const router = useRouter(); const familyStore = useFamilyStore(); + const favoriteStore = useFavoriteStore(); + const { width: screenWidth } = useWindowDimensions(); + const [family, setFamily] = useState({}); const [duration, setDuration] = useState({ @@ -18,10 +26,16 @@ export default function Home() { 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() { const res = await loadDataApi({ - view_name: `b_family`, - search_condition: `id = ?`, + view_name: "b_family", + search_condition: "id = ?", search_columns: [], order_by: "", args: [familyStore.currentFamily?.family_id], @@ -63,6 +77,46 @@ export default function Home() { return () => clearInterval(timer); }, [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 ( @@ -75,16 +129,24 @@ export default function Home() { - {duration.days} + + {duration.days} + 天 - {duration.hours} + + {duration.hours} + 时 - {duration.minutes} + + {duration.minutes} + 分 - {duration.seconds} + + {duration.seconds} + 秒 @@ -92,35 +154,52 @@ export default function Home() { - - - + 快捷应用 - - - + {cardRows.map((row, rowIdx) => ( + + {row.items.map((app) => { + const Icon = app.icon; + const isMoreBtn = app.id === "__more__"; - 记账 + return ( + + 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" + > + + + {app.title} + + + + ); + })} - - - - - - - 衣服搭配 - - - - - - - 更多 - - - + {/* 末尾空位占位,保持左对齐 */} + {row.items.length < NUM_COLUMNS && + Array.from({ length: NUM_COLUMNS - row.items.length }).map( + (_, emptyIdx) => ( + + ) + )} + + ))} diff --git a/code/app/app-rn/src/app/auth/login.tsx b/code/app/app-rn/src/app/auth/login.tsx index 79038069..3b8da28d 100644 --- a/code/app/app-rn/src/app/auth/login.tsx +++ b/code/app/app-rn/src/app/auth/login.tsx @@ -11,7 +11,7 @@ import ExpoQQ from "expo-qq"; import ExpoWeChat from "expo-wechat"; import { useEvent } from "expo"; import { toast } from "sonner-native"; -import { loginQqApi, loginWxApi } from "@/request/api"; +import { loginQqApi, loginWxApi, loginTestApi } from "@/request/api"; export default function Login() { const router = useRouter(); @@ -192,6 +192,27 @@ export default function Login() { handleLogin(); }, [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 ( + <> + + + + ); } diff --git a/code/app/app-rn/src/app/tools/index.tsx b/code/app/app-rn/src/app/tools/index.tsx index 97ab4c97..0a86ddd2 100644 --- a/code/app/app-rn/src/app/tools/index.tsx +++ b/code/app/app-rn/src/app/tools/index.tsx @@ -1,114 +1,212 @@ import LayoutView from "@/components/layout/view"; -import { SearchField, CloseButton, ListGroup, PressableFeedback, Separator } from "heroui-native"; -import { View, Text } from "react-native"; +import { SearchField, CloseButton, PressableFeedback } from "heroui-native"; +import { View, Text, useWindowDimensions } from "react-native"; import { Stack, useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { getGroupedApps } from "@/configs/pages"; -import { useState, useMemo, useRef } from "react"; -import AZIndex from "@/components/AZIndex"; -import type { LegendListRef } from "@legendapp/list"; -import { AppListItem } from "@/configs/pages"; +import { getGroupedApps, type SubAppItem } from "@/configs/pages"; +import { useState, useMemo, useRef, useCallback, useEffect } from "react"; 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() { const router = useRouter(); const safeAreaInsets = useSafeAreaInsets(); - const listRef = useRef(null); + const { width: screenWidth } = useWindowDimensions(); + const scrollRef = useRef(null); + const headerPositions = useRef>({}); 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({ - index, - animated: true, - viewOffset: safeAreaInsets.top + 56, - }); + // 只收集 group 类型的条目 + const groups = grouped.filter((g) => g.type === "group") as { + type: "group"; + title: string; + data: SubAppItem[]; + }[]; - setTimeout(() => { - listRef.current?.scrollToIndex({ - index, - animated: true, - viewOffset: safeAreaInsets.top + 56, + 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(); + unstarred.forEach((a) => { + const arr = groupMap.get(a.group) || []; + arr.push(a); + groupMap.set(a.group, arr); }); - }, 100); - }; + const keys = Array.from(groupMap.keys()).sort(); + keys.forEach((k) => { + result.push({ type: "header", title: k }); + result.push({ type: "group", title: k, data: groupMap.get(k)! }); + }); + } 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 ( - - ref={listRef} - data={data} - keyExtractor={(item, i) => String(i)} - estimatedItemSize={120} - recycleItems - initialContainerPoolRatio={20} - contentContainerStyle={{ - paddingTop: safeAreaInsets.top + 60, - paddingBottom: safeAreaInsets.bottom + 100, - paddingRight: 32, - }} - ListEmptyComponent={} - renderItem={({ item }: { item: AppListItem }) => { - if (item.type === "header") { - return {item.title}; - } + + {sortedApps.length === 0 ? ( + + ) : ( + sortedApps.map((item, idx) => { + if (item.type === "header") { + const headerKey = `${item.title}-${keyword || "all"}`; - return ( - - - {item.data.map((subItem, index) => { - const Icon = subItem.icon; + return ( + { + headerPositions.current[headerKey] = + e.nativeEvent.layout.y; + if (!keyword) { + headerPositions.current[item.title] = + e.nativeEvent.layout.y; + } + }} + > + + {item.title} + + + ); + } - return ( - { - router.navigate(subItem.id as any); - }} - > - - - - + 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)); + } - - {subItem.title} - {subItem.description && ( - - {subItem.description} - - )} - + return ( + + {rows.map((row, rowIdx) => ( + + {row.map((subItem) => { + const Icon = subItem.icon; + const isFav = favoriteStore.isFavorite(subItem.id); - - + return ( + + router.navigate(subItem.id as any)} + className="absolute inset-0 items-center justify-center bg-white rounded-xl shadow-lg" + > + + + {subItem.title} + + + - {index !== item.data.length - 1 && } - - - - ); - })} - - - ); - }} - /> + {/* 星标按钮 — 独立于导航主体,避免事件冲突 */} + + favoriteStore.toggleFavorite( + familyStore.currentFamily.family_id, + subItem.id + ) + } + className="absolute top-1 right-1 p-1 z-10" + > + + + + + ); + })} + {row.length < NUM_COLUMNS && + Array.from({ length: NUM_COLUMNS - row.length }).map( + (_, emptyIdx) => ( + + ) + )} + + ))} + + ); + }) + )} + + {/* 搜索框 */} @@ -118,8 +216,10 @@ export default function Tools() { - + {/* 字母导航 */} + + {/* 关闭按钮 */} router.back()} diff --git a/code/app/app-rn/src/layouts/ModuleLayout.tsx b/code/app/app-rn/src/layouts/ModuleLayout.tsx index b332bf3d..6a06f043 100644 --- a/code/app/app-rn/src/layouts/ModuleLayout.tsx +++ b/code/app/app-rn/src/layouts/ModuleLayout.tsx @@ -28,7 +28,7 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) { }, [pathname, tabs]); function handleClose() { - router.replace("/(tabs)/home"); + router.back(); } return ( diff --git a/code/app/app-rn/src/request/api.ts b/code/app/app-rn/src/request/api.ts index 1f9cd84c..dfc036db 100644 --- a/code/app/app-rn/src/request/api.ts +++ b/code/app/app-rn/src/request/api.ts @@ -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"; export function loginQqApi(params: LoginQqParams) { @@ -9,6 +9,10 @@ export function loginWxApi(params: LoginWxParams) { return alovaInstance.Post>(`/auth/login/wechat`, params); } +export function loginTestApi(params: LoginTestParams) { + return alovaInstance.Post>(`/auth/login/test`, params); +} + export function bindQqApi(params: LoginQqParams) { return alovaInstance.Post>(`/bind/qq`, params); } diff --git a/code/app/app-rn/src/request/index.ts b/code/app/app-rn/src/request/index.ts index a4d78f9d..c5f5d3b6 100644 --- a/code/app/app-rn/src/request/index.ts +++ b/code/app/app-rn/src/request/index.ts @@ -62,6 +62,8 @@ const alovaInstance = createAlova({ }, beforeRequest(method) { + console.log(process.env.EXPO_PUBLIC_API_URL) + // 白名单接口直接放行 if (isWhiteListed(method.url)) return; diff --git a/code/app/app-rn/src/store/favorite.ts b/code/app/app-rn/src/store/favorite.ts new file mode 100644 index 00000000..bf50cd4f --- /dev/null +++ b/code/app/app-rn/src/store/favorite.ts @@ -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; + /** 是否已加载 */ + loaded: boolean; + /** 操作中的 app_id */ + loadingIds: Set; + + /** 拉取收藏列表 */ + fetchFavorites: (familyId: number) => Promise; + /** 切换收藏状态 */ + toggleFavorite: (familyId: number, appId: string) => Promise; + /** 是否已收藏 */ + isFavorite: (appId: string) => boolean; + /** 清空 */ + clear: () => void; +} + +export const useFavoriteStore = create()((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() }), +})); diff --git a/code/app/app-rn/src/store/index.ts b/code/app/app-rn/src/store/index.ts index 0eca216c..387de0d4 100644 --- a/code/app/app-rn/src/store/index.ts +++ b/code/app/app-rn/src/store/index.ts @@ -2,3 +2,4 @@ export * from "./app"; export * from "./user"; export * from "./finance"; export * from "./family"; +export * from "./favorite"; diff --git a/code/app/app-rn/src/types/auth.ts b/code/app/app-rn/src/types/auth.ts index 4a95220a..8768431b 100644 --- a/code/app/app-rn/src/types/auth.ts +++ b/code/app/app-rn/src/types/auth.ts @@ -4,6 +4,10 @@ export interface LoginQqParams { avatar: string; } +export interface LoginTestParams { + user_id: number; +} + export interface LoginWxParams { code: string; }