This commit is contained in:
oneao committed 2026-06-25 16:47:54 +08:00
1 parent 513a991b57
commit 8f8e8d03c8
22 files changed
+2188 -189

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
# 家庭聊天模块
## 概述
一个家庭一个聊天室,成员通过 WebSocket 实时收发消息。支持模块内容转发、图片/视频/文件、消息撤回与删除。
---
## 数据库表
### b_chat — 聊天消息
```sql
CREATE TABLE b_chat (
id BIGINT PRIMARY KEY,
family_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
content TEXT NOT NULL DEFAULT '',
type SMALLINT NOT NULL DEFAULT 0, -- 0=text 1=image 2=video 3=file 4=source
source_table VARCHAR(64),
source_id BIGINT,
source_data TEXT, -- 转发时存的快照 JSON
recalled SMALLINT NOT NULL DEFAULT 0, -- 0=否 1=撤回
deleted SMALLINT NOT NULL DEFAULT 0, -- 0=否 1=管理员删除
create_time TIMESTAMPTZ NOT NULL,
update_time TIMESTAMPTZ NOT NULL,
create_by BIGINT NOT NULL,
update_by BIGINT NOT NULL
);
```
**为什么不存 nickname / avatar**
消息只存 `user_id`,昵称和头像通过 `b_family_member` JOIN 实时取。这样用户更新资料后,所有历史消息自动显示最新的昵称和头像。如果成员已退出家庭(JOIN 不到),前端显示"已退出的成员"兜底。
**字段说明**
- type: `0` 文本 / `1` 图片 / `2` 视频 / `3` 文件 / `4` 转发(所有子模块均可转发,服务端校验 `source_table` 是否存在即可)
- recalled: 发送者 **2 分钟内**可撤回,全员看到"xxx 撤回了一条消息"
- deleted: `b_family_member.role = 0`(管理员)可删除任意消息,全员不可见
### b_file — 通用附件
```sql
CREATE TABLE b_file (
id BIGINT PRIMARY KEY,
target_id BIGINT NOT NULL,
file_key VARCHAR(256) NOT NULL,
file_name VARCHAR(256),
file_type SMALLINT NOT NULL, -- 1=image 2=video 3=file
mime_type VARCHAR(64),
file_size BIGINT,
width INT,
height INT,
duration INT,
sort_order INT NOT NULL DEFAULT 0,
create_time TIMESTAMPTZ NOT NULL,
update_time TIMESTAMPTZ NOT NULL,
create_by BIGINT NOT NULL,
update_by BIGINT NOT NULL
);
```
`target_id` 关联到任意表的记录(雪花 ID 全局唯一),chat / note / task 等模块共用。
### 存量表改动
```sql
ALTER TABLE b_family_member ADD COLUMN last_read_id BIGINT DEFAULT 0;
```
---
## 后端实现
### 文件结构
```
app-go/internal/
├── ws/
│ ├── hub.go -- Hub + Room(map[family_id]*Room)
│ ├── client.go -- 单连接读写 goroutine、心跳、断连处理
│ └── handler.go -- HTTP → WS 升级、鉴权、消息路由
└── router/
└── router.go -- 新增 GET /app/chat/ws
```
依赖:`github.com/gofiber/contrib/v3/websocket`(Fiber v3 官方 WS 支持)
### WebSocket 协议
连接:`GET /app/chat/ws?token=xxx&family_id=123`
鉴权独立实现(不复用 `middleware.Auth()`,WS 无法设自定义 header):token 解 user_id → family_id 校验归属 → 加入 Room。
客户端消息:
```jsonc
// 发送文本
{"type": "send", "content": "晚上吃啥"}
// 发送图片/视频/文件(先 S3 上传拿 key,再发消息)
{"type": "send", "content": "", "msg_type": 1, "attachments": [
{"file_key": "2026/06/25/uuid.jpg", "file_name": "photo.jpg", "file_type": 1, "mime_type": "image/jpeg", "file_size": 204800, "width": 1080, "height": 720}
]}
// 转发
{"type": "send", "content": "看看这笔", "msg_type": 4, "source_table": "b_finance", "source_id": 456}
// 撤回
{"type": "recall", "message_id": 123}
// 删除(管理员)
{"type": "delete", "message_id": 123}
```
服务端广播:
```jsonc
// 新消息
{"type": "message", "data": {
"id": 123, "family_id": 1, "user_id": 1,
"nickname": "小明", "avatar": "https://...", // 从 b_family_member JOIN
"content": "晚上吃啥", "msg_type": 0,
"attachments": null,
"source_table": null, "source_id": null, "source_data": null,
"create_time": "2026-06-25 21:30:00"
}}
// 撤回通知(全员)
{"type": "message_recalled", "message_id": 123}
// 删除通知(全员)
{"type": "message_deleted", "message_id": 123}
// 在线成员(全员广播,全量替换式)
{"type": "online", "users": [{"user_id": 1, "nickname": "小明", "avatar": "https://..."}]}
// 成员下线(单条)
{"type": "offline", "user_id": 3}
```
### 消息处理流程
```
客户端 WS 消息
→ 解析 type 字段路由:
send:
1. 从连接上下文取 family_id / user_id
2. getUniqueId 拿消息 ID
3. 若 msg_type = 4(转发):查 source_table WHERE id = source_id,整行转 JSON 存 source_data
4. 若 attachments 非空:批量 INSERT b_file(target_id = 消息 ID)
5. INSERT b_chat
6. JOIN b_family_member 取 nickname / avatar
7. 组装 message 广播到 Room
recall:
1. 查 b_chat WHERE id = message_id
2. 校验 user_id == 当前用户 && now - create_time <= 2 分钟
3. UPDATE b_chat SET recalled = 1
4. 广播 message_recalled
delete:
1. 查 b_family_member WHERE family_id = ? AND user_id = 当前用户
2. 校验 role = 0
3. UPDATE b_chat SET deleted = 1
4. 广播 message_deleted
```
在线状态:WS 连上时广播 `online`(全量),断开时广播 `offline`(单条)。
---
## 前端实现
### 文件结构
```
app-rn/src/
├── app/(main)/(tabs)/chat/
│ └── index.tsx -- 聊天室
├── app/(main)/chat/
│ └── history.tsx -- 历史消息搜索
├── components/chat/
│ ├── MessageList.tsx -- FlatList 倒序渲染
│ ├── TextBubble.tsx -- 文本气泡
│ ├── ImageBubble.tsx -- 图片气泡
│ ├── VideoBubble.tsx -- 视频气泡
│ ├── FileBubble.tsx -- 文件气泡
│ ├── ForwardCard.tsx -- 转发卡片(source_data 渲染)
│ ├── InputBar.tsx -- 输入框 + 附件按钮
│ └── OnlineBar.tsx -- 在线成员头像行
└── hooks/
└── useChatWebSocket.ts -- WS 连接、自动重连、消息去重
```
### 聊天页面数据流
```
进入页面
→ loadDataPageApi("b_chat", "family_id = ? AND deleted = 0", "create_time DESC", [familyId])
→ 取 b_family_member 获取成员昵称/头像 Map(发消息和渲染时用)
→ 若 msg_type IN (1,2,3):批量查 b_file WHERE target_id IN (...)
→ 连接 WS
收到 WS message
→ id 去重
→ 追加到 FlatList 底部
→ 更新 last_read_id
发送消息
→ 文本:直接 WS send
→ 图片/视频/文件:先 s3UploadFileApi → 拿 key → WS send
→ 转发:子模块调 WS send 带 source_table + source_id
→ 乐观更新(灰色气泡 → 成功实色 / 失败红色+重试)
撤回
→ WS send recall
→ 收到 message_recalled → 对应气泡替换为提示
删除(管理员可见删除按钮)
→ WS send delete
→ 收到 message_deleted → 移除该消息
```
依赖:React Native 内置 [`WebSocket`](https://reactnative.dev/docs/network#websocket),无需额外安装。重连逻辑在 `useChatWebSocket` hook 里封装。
### WS 连接管理
- 断线自动重连(exponential backoff:1s → 2s → 4s → … → 上限 30s)
- 重连后用 `loadDataPageApi` 拉 `WHERE id > lastReceivedId` 补洞
- `online` 事件更新 OnlineBar,`offline` 移除对应头像
### 转发卡片渲染
`JSON.parse(source_data)` 展示关键字段,点击跳转对应模块详情页。源记录删除后卡片仍可展示(快照自包含)。
### 历史消息搜索
独立页面 `chat/history.tsx`,走 `loadDataPageApi` + `loadDataBySqlApi`:
- 关键词搜索:`WHERE content ILIKE '%keyword%'`
- 按 msg_type 筛选
- 按日期范围筛选
- 点击消息跳回聊天室定位
+141
View File
@@ -0,0 +1,141 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Allapp is a family/space mini-program platform — a main shell with sub-apps inside it. The frontend owns most business logic; the backend is deliberately thin and generic (auth, file storage, ID generation, database access).
- **Backend**: `app-go/` — Go 1.25, Fiber v3.1, pgx (PostgreSQL), JWT, Viper, Zap
- **Frontend**: `app-rn/` — React Native 0.85, Expo 56, Expo Router, Zustand, Alova, HeroUI Native, Uniwind (Tailwind CSS)
- **Database**: PostgreSQL 18.1, database `allapp`, schema `public`
Read `PROJECT_CONTEXT.md` for deeper context; this file focuses on what you need day-to-day.
## Commands
### Backend (`app-go/`)
```bash
cd app-go
go build ./cmd/app # Build
go run ./cmd/app # Run server
go test ./... # Run all tests
go mod tidy # Tidy dependencies
```
### Frontend (`app-rn/`)
```bash
cd app-rn
pnpm start # Expo dev server (port 8083)
pnpm android # Android build
pnpm ios # iOS build
pnpm lint # oxlint
pnpm exec tsc --noEmit # TypeScript type check
pnpm exec oxfmt # Format code
```
WSL-specific: if `node`/`pnpm` are not found, run:
```bash
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env --shell bash)"
```
## Architecture
### Bootstrap Flow
```
Backend: config → logger → S3 → Postgres → db.Init → unique ID → JWT → WeChat → Fiber server
Frontend: /index → token check → auth/login or family/entry or /home
```
### Backend Design
**All CRUD goes through 5 generic data APIs.** Never create dedicated REST endpoints for feature-specific operations:
| Endpoint | Purpose |
|---|---|
| `POST /app/data/loadData` | Query by table/view name, conditions, columns, order, args |
| `POST /app/data/loadDataPage` | Paginated query — same as loadData + `page`/`page_size`, returns `{rows, total, page, page_size}` |
| `POST /app/data/loadDataBySql` | Raw SQL query |
| `POST /app/data/saveData` | Batch insert/update/delete by table name and key field |
| `POST /app/data/getUniqueId` | Get snowflake IDs (1–100) |
Auth endpoints: `/app/auth/login/{qq,wechat,test}`, `/app/bind/{qq,wechat}`.
**File endpoints:**
| Endpoint | Purpose |
|---|---|
| `POST /app/s3/upload` | Upload files (multipart form, field name `files`), stored under `YYYY/MM/DD/{uuid}.ext`, returns `string[]` keys |
| `POST /app/s3/delete` | Delete files by keys, body `{keys: string[]}`, failure is logged but still returns success |
On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type.
Auth token is passed in a custom `Token` header. JWT middleware stores `user_id` in context. Audit fields (`create_time`, `update_time`, `create_by`, `update_by`) are auto-injected by `pkg/db/meta_field.go` for all tables except `b_user` and `b_user_oauth`.
### Frontend Design
Expo Router file-based routing under `src/app/`:
- `(main)/(tabs)/` — main shell tabs (home, chat, my)
- `(main)/auth/`, `(main)/family/`, `(main)/tools/`, `(main)/activities/`
- `finance/(tabs)/` — finance sub-app (independent from main)
- Layouts: `AppLayout` for main shell, `ModuleLayout` for sub-app shells, `PageLayout` for simple pages
State: Zustand with `persist` middleware for `user` and `family` (AsyncStorage). Finance store is in-memory only.
API layer: single Alova instance (`src/request/index.ts`), API wrappers in `src/request/api.ts`. Key wrappers: `loadDataApi`, `loadDataPageApi` (paginated, returns `PageData<T>` with `rows`/`total`/`page`/`page_size`), `loadDataBySqlApi`, `saveDataApi`, `getUniqueIdApi`. Response codes: 1000 (success), 1001 (unauthorized → redirect to login), 2000 (business error), 3000 (system error).
Startup flow in `src/app/index.tsx`:
```
no token → /auth/login
token invalid → /auth/login
no family → /family/entry
has family → /home
```
## Critical Conventions
### 1. No New Dedicated API Endpoints
Always reuse the 5 generic data APIs. If you think a new endpoint is needed, **ask the user first**. Even then, new endpoints must be generic helpers, not feature-specific.
### 2. Database Tables Must Include Audit Columns
All new business tables must include: `id BIGINT PRIMARY KEY` (snowflake), `create_time TIMESTAMPTZ`, `update_time TIMESTAMPTZ`, `create_by BIGINT`, `update_by BIGINT`. Do not add new tables to `auditExcludeTables` — only `b_user` and `b_user_oauth` are exempt.
### 3. Family/Space-Centric Data Model
All business data (finance, favorites, etc.) associates with `family_id`. Users link to families via `b_family_member`. Always filter queries by the current user's `family_id`.
### 4. Family Feed Recording
Certain actions must call `recordFeed()` from `@/helpers/feed`. Use `{nickname}` placeholder in content — do NOT hardcode nicknames. The `loadFeeds()` helper replaces it dynamically. Scenarios: creating/joining family (`join`), renaming (`rename`), recording bills (`bill`), favoriting apps (`fav`).
### 5. Frontend: Use Existing Libraries Only
Before writing any page/component, check `app-rn/package.json` for available dependencies. Use HeroUI Native for UI, `@legendapp/list` for long lists, `lucide-react-native` for icons, `sonner-native` for toasts. Use NativeWind `className` for styling, not inline `style={}`.
### 6. Finance Convention
`type = 0` means expense, `type = 1` means income. Category icon names match iconfont names (e.g., `finance_jiaotong`, `finance_canyin`).
## Key Files
| File | Role |
|---|---|
| `PROJECT_CONTEXT.md` | Full project documentation, risks, feature status |
| `app-go/internal/bootstrap/app.go` | Backend initialization flow |
| `app-go/internal/router/router.go` | Route definitions |
| `app-go/pkg/db/meta_field.go` | Audit field auto-injection |
| `app-rn/src/app/index.tsx` | Startup/auth routing logic |
| `app-rn/src/request/index.ts` | Alova instance, auth interceptor |
| `app-rn/src/request/api.ts` | API wrapper functions |
| `app-rn/src/store/` | Zustand stores (user, family, finance, app, favorite) |
| `app-rn/src/layouts/` | Shell layouts (AppLayout, ModuleLayout, PageLayout) |
| `app-rn/src/configs/pages.ts` | Sub-app definitions |
| `app-rn/src/helpers/feed.ts` | Family feed recording |
## Known Risks
- Generic data APIs accept table names and raw SQL from the frontend — needs server-side whitelisting before production.
- `db.local.yml` contains database credentials and should never be committed.
- Finance category queries currently don't filter by `family_id`.
- Main tab `/space` route is missing its page file.
- `GetUniqueIdReq.Count` uses `binding` tag instead of `validate` tag — min/max rules may not run.
+16 -13
View File
@@ -4,7 +4,7 @@ go 1.25.0
require (
github.com/go-playground/validator/v10 v10.30.2
github.com/gofiber/fiber/v3 v3.1.0
github.com/gofiber/fiber/v3 v3.3.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.1
github.com/pkg/errors v0.9.1
@@ -14,7 +14,7 @@ require (
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.15 // indirect
@@ -33,39 +33,42 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect
github.com/gofiber/contrib/v3/websocket v1.2.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fasthttp v1.71.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
+34
View File
@@ -1,5 +1,7 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -39,12 +41,16 @@ github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqx
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -57,12 +63,20 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofiber/contrib/v3/websocket v1.2.0 h1:wjkzC3exbhRL3cPCVLRYN3MFq3yhMvtBeGwqEYQk+yc=
github.com/gofiber/contrib/v3/websocket v1.2.0/go.mod h1:fpqdn3mVAKAKfSOt8yHXhO70bZ1mz6z6NhY87CjM3k8=
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8=
github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs=
github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -79,6 +93,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -89,6 +105,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -99,10 +117,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 h1:McifyVxygw1d67y6vxUqls2D46J8W9nrki9c8c0eVvE=
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761/go.mod h1:Vi9gvHvTw4yCUHIznFl5TPULS7aXwgaTByGeBY75Wko=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -122,10 +144,14 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -140,15 +166,23 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -50,6 +50,31 @@ func LoadDataBySql(c fiber.Ctx) error {
return httpx.OK(c, data)
}
func LoadDataPage(c fiber.Ctx) error {
var req types.LoadDataPageReq
err := httpx.BindAndValidate(c, &req)
if err != nil {
return errors.WithStack(err)
}
dbClient := db.New()
rows, total, err := dbClient.LoadDataPage(c.Context(), req.ViewName, req.SearchCondition, req.OrderBy, req.SearchColumns, req.Args, req.Page, req.PageSize)
if err != nil {
return errors.WithStack(err)
}
return httpx.OK(c, types.LoadDataPageRes{
Rows: rows,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
})
}
func GetUniqueId(c fiber.Ctx) error {
var req types.GetUniqueIdReq
@@ -4,7 +4,9 @@ import (
"allapp-go/internal/config"
"allapp-go/internal/handle"
"allapp-go/internal/middleware"
"allapp-go/internal/ws"
"github.com/gofiber/contrib/v3/websocket"
"github.com/gofiber/fiber/v3"
)
@@ -15,6 +17,7 @@ func SetupRouter(app *fiber.App, cfg *config.Config) {
data := api.Group("/data", middleware.Auth())
data.Post("/loadData", handle.LoadData)
data.Post("/loadDataPage", handle.LoadDataPage)
data.Post("/loadDataBySql", handle.LoadDataBySql)
data.Post("/saveData", handle.SaveData)
data.Post("/getUniqueId", handle.GetUniqueId)
@@ -31,4 +34,8 @@ func SetupRouter(app *fiber.App, cfg *config.Config) {
bind := api.Group("/bind", middleware.Auth())
bind.Post("/qq", handle.BindQq)
bind.Post("/wechat", handle.BindWechat)
// ==================== chat ====================
chat := api.Group("/chat")
chat.Get("/ws", ws.AuthWS(), websocket.New(ws.HandleWebSocket))
}
@@ -13,6 +13,23 @@ type LoadDataBySqlReq struct {
Args []any `json:"args"`
}
type LoadDataPageReq struct {
ViewName string `json:"view_name" validate:"required"`
SearchCondition string `json:"search_condition"`
OrderBy string `json:"order_by"`
SearchColumns []string `json:"search_columns"`
Args []any `json:"args"`
Page int `json:"page" validate:"required,min=1"`
PageSize int `json:"page_size" validate:"required,min=1,max=100"`
}
type LoadDataPageRes struct {
Rows []map[string]any `json:"rows"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type GetUniqueIdReq struct {
Count int `json:"count" binding:"required,min=1,max=100"`
}
+372
View File
@@ -0,0 +1,372 @@
package ws
import (
"allapp-go/internal/httpx"
"allapp-go/pkg/db"
"allapp-go/pkg/jwtx"
"allapp-go/pkg/uniqueid"
"context"
"encoding/json"
"log"
"strconv"
"strings"
"time"
"github.com/gofiber/contrib/v3/websocket"
"github.com/gofiber/fiber/v3"
)
// Client 单个 WebSocket 连接
type Client struct {
conn *websocket.Conn
hub *Hub
userID int64
familyID int64
nickname string
avatar string
role int64
send chan []byte
}
// clientSendMsg 客户端发送的原始消息
type clientSendMsg struct {
Type string `json:"type"` // send | recall | delete
Content string `json:"content"`
MsgType int `json:"msg_type"` // 0=text 1=image 2=video 3=file 4=forward
SourceTable string `json:"source_table"`
SourceID int64 `json:"source_id"`
MessageID int64 `json:"message_id"`
Attachments []attachmentInput `json:"attachments"`
}
type attachmentInput struct {
FileKey string `json:"file_key"`
FileName string `json:"file_name"`
FileType int `json:"file_type"`
MimeType string `json:"mime_type"`
FileSize int64 `json:"file_size"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
}
// AuthWS WebSocket 鉴权中间件
func AuthWS() fiber.Handler {
return func(c fiber.Ctx) error {
if !websocket.IsWebSocketUpgrade(c) {
return fiber.ErrUpgradeRequired
}
token := strings.TrimSpace(c.Query("token"))
familyIDStr := c.Query("family_id")
if token == "" || familyIDStr == "" {
return httpx.Unauthorized(c, "缺少 token 或 family_id")
}
claims, ok := jwtx.VerifyToken(c.Context(), token)
if !ok {
return httpx.Unauthorized(c, "登录已过期")
}
userID := claims.Data.UserID
familyID, err := strconv.ParseInt(familyIDStr, 10, 64)
if err != nil {
return httpx.Unauthorized(c, "family_id 格式错误")
}
// 验证用户属于该家庭,同时获取 nickname / avatar / role
dbClient := db.New()
rows, err := dbClient.LoadData(c.Context(), "b_family_member",
"user_id = ? AND family_id = ?", "", nil,
[]any{userID, familyID})
if err != nil || len(rows) == 0 {
return httpx.Unauthorized(c, "不属于该家庭")
}
member := rows[0]
// 存入 Locals,WebSocket handler 中通过 conn.Locals 读取
c.Locals("user_id", userID)
c.Locals("family_id", familyID)
c.Locals("nickname", toString(member["nickname"]))
c.Locals("avatar", toString(member["avatar"]))
c.Locals("role", toInt64(member["role"]))
return c.Next()
}
}
// HandleWebSocket WebSocket 连接处理
func HandleWebSocket(conn *websocket.Conn) {
userID := conn.Locals("user_id").(int64)
familyID := conn.Locals("family_id").(int64)
nickname := conn.Locals("nickname").(string)
avatar := conn.Locals("avatar").(string)
role := conn.Locals("role").(int64)
client := &Client{
conn: conn,
hub: GetHub(),
userID: userID,
familyID: familyID,
nickname: nickname,
avatar: avatar,
role: role,
send: make(chan []byte, 64),
}
client.hub.Join(familyID, client)
defer client.hub.Leave(familyID, userID)
go client.writePump()
client.readPump()
}
const (
// 心跳间隔
pingPeriod = 10 * time.Second
// 读超时(超时未收到任何消息则断开)
readDeadline = 30 * time.Second
)
// writePump 从 send 通道写入 WebSocket,同时定期发 ping
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case msg, ok := <-c.send:
if !ok {
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// readPump 读取 WebSocket 消息并分发
func (c *Client) readPump() {
defer close(c.send)
// 设置 pong 处理器:收到 pong 时重置读超时
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(readDeadline))
return nil
})
c.conn.SetReadDeadline(time.Now().Add(readDeadline))
for {
_, raw, err := c.conn.ReadMessage()
if err != nil {
break
}
var msg clientSendMsg
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "send":
c.handleSend(msg)
case "recall":
c.handleRecall(msg)
case "delete":
c.handleDelete(msg)
}
}
}
// handleSend 处理发送消息
func (c *Client) handleSend(msg clientSendMsg) {
ctx := context.Background()
dbClient := db.New()
msgID := uniqueid.NextId()
now := time.Now()
// 转发:查源表生成 source_data
var sourceData string
if msg.MsgType == 4 && msg.SourceTable != "" && msg.SourceID > 0 {
rows, err := dbClient.LoadData(ctx, msg.SourceTable,
"id = ?", "", nil, []any{msg.SourceID})
if err == nil && len(rows) > 0 {
data, _ := json.Marshal(rows[0])
sourceData = string(data)
}
}
// 插入 b_chat
chatData := map[string]any{
"id": msgID,
"family_id": c.familyID,
"user_id": c.userID,
"content": msg.Content,
"type": msg.MsgType,
"source_table": msg.SourceTable,
"source_id": msg.SourceID,
"source_data": sourceData,
}
if err := dbClient.Insert(ctx, "b_chat", "id", chatData); err != nil {
log.Printf("[chat] insert b_chat error: %v", err)
return
}
// 附件 → 批量插入 b_file
if len(msg.Attachments) > 0 {
fileInserts := make([]map[string]any, 0, len(msg.Attachments))
for i, att := range msg.Attachments {
fileInserts = append(fileInserts, map[string]any{
"id": uniqueid.NextId(),
"target_id": msgID,
"file_key": att.FileKey,
"file_name": att.FileName,
"file_type": att.FileType,
"mime_type": att.MimeType,
"file_size": att.FileSize,
"width": att.Width,
"height": att.Height,
"duration": att.Duration,
"sort_order": i,
})
}
if err := dbClient.BatchInsert(ctx, "b_file", "id", fileInserts); err != nil {
log.Printf("[chat] insert b_file error: %v", err)
}
}
// 组装附件输出
attachmentsOut := make([]map[string]any, 0)
for _, att := range msg.Attachments {
attachmentsOut = append(attachmentsOut, map[string]any{
"file_key": att.FileKey,
"file_name": att.FileName,
"file_type": att.FileType,
"mime_type": att.MimeType,
"file_size": att.FileSize,
"width": att.Width,
"height": att.Height,
"duration": att.Duration,
})
}
if attachmentsOut == nil {
attachmentsOut = []map[string]any{}
}
// 广播
c.hub.Broadcast(c.familyID, map[string]any{
"type": "message",
"data": map[string]any{
"id": msgID,
"family_id": c.familyID,
"user_id": c.userID,
"nickname": c.nickname,
"avatar": c.avatar,
"content": msg.Content,
"msg_type": msg.MsgType,
"source_table": msg.SourceTable,
"source_id": msg.SourceID,
"source_data": sourceData,
"attachments": attachmentsOut,
"recalled": 0,
"deleted": 0,
"create_time": now.Format("2006-01-02 15:04:05"),
},
})
}
// handleRecall 处理撤回(发送者 2 分钟内)
func (c *Client) handleRecall(msg clientSendMsg) {
ctx := context.Background()
dbClient := db.New()
rows, err := dbClient.LoadData(ctx, "b_chat",
"id = ?", "", nil, []any{msg.MessageID})
if err != nil || len(rows) == 0 {
return
}
chatMsg := rows[0]
// 校验:只能撤回自己的消息
msgUserID, _ := chatMsg["user_id"].(int64)
if msgUserID != c.userID {
return
}
// 校验:2 分钟内
createTime, _ := chatMsg["create_time"].(time.Time)
if time.Since(createTime) > 2*time.Minute {
return
}
// 更新
if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{
"id": msg.MessageID,
"recalled": 1,
}); err != nil {
log.Printf("[chat] recall error: %v", err)
return
}
c.hub.Broadcast(c.familyID, map[string]any{
"type": "message_recalled",
"message_id": msg.MessageID,
})
}
// handleDelete 处理删除(仅管理员 role=0)
func (c *Client) handleDelete(msg clientSendMsg) {
if c.role != 0 {
return
}
ctx := context.Background()
dbClient := db.New()
if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{
"id": msg.MessageID,
"deleted": 1,
}); err != nil {
log.Printf("[chat] delete error: %v", err)
return
}
c.hub.Broadcast(c.familyID, map[string]any{
"type": "message_deleted",
"message_id": msg.MessageID,
})
}
func toString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func toInt64(v any) int64 {
switch val := v.(type) {
case int64:
return val
case int:
return int64(val)
case int32:
return int64(val)
case float64:
return int64(val)
}
return 0
}
+132
View File
@@ -0,0 +1,132 @@
package ws
import (
"encoding/json"
"sync"
)
// OnlineUser 在线成员信息
type OnlineUser struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// Room 按 family_id 分组的聊天室
type Room struct {
clients map[int64]*Client // user_id → Client
mu sync.RWMutex
}
// Hub 管理所有 Room
type Hub struct {
rooms map[int64]*Room // family_id → Room
mu sync.RWMutex
}
var defaultHub = &Hub{
rooms: make(map[int64]*Room),
}
func GetHub() *Hub {
return defaultHub
}
// getOrCreateRoom 获取或创建 Room
func (h *Hub) getOrCreateRoom(familyID int64) *Room {
h.mu.Lock()
defer h.mu.Unlock()
room, ok := h.rooms[familyID]
if !ok {
room = &Room{
clients: make(map[int64]*Client),
}
h.rooms[familyID] = room
}
return room
}
// Join 用户加入房间,广播 online 事件
func (h *Hub) Join(familyID int64, client *Client) {
room := h.getOrCreateRoom(familyID)
room.mu.Lock()
room.clients[client.userID] = client
onlineUsers := h.collectOnlineUsers(room)
room.mu.Unlock()
h.broadcastOnlineUsers(familyID, onlineUsers)
}
// Leave 用户离开房间,广播 offline 事件
func (h *Hub) Leave(familyID int64, userID int64) {
room := h.getOrCreateRoom(familyID)
room.mu.Lock()
delete(room.clients, userID)
onlineUsers := h.collectOnlineUsers(room)
room.mu.Unlock()
// 广播 offline 给房间内其他人
msg, _ := json.Marshal(map[string]any{
"type": "offline",
"user_id": userID,
})
h.broadcastRaw(familyID, msg)
// 广播更新后的在线列表
h.broadcastOnlineUsers(familyID, onlineUsers)
}
// Broadcast 向房间内所有连接广播消息
func (h *Hub) Broadcast(familyID int64, msg any) {
data, err := json.Marshal(msg)
if err != nil {
return
}
h.broadcastRaw(familyID, data)
}
// broadcastRaw 向房间内所有连接广播原始字节
func (h *Hub) broadcastRaw(familyID int64, data []byte) {
h.mu.RLock()
room, ok := h.rooms[familyID]
h.mu.RUnlock()
if !ok {
return
}
room.mu.RLock()
defer room.mu.RUnlock()
for _, client := range room.clients {
select {
case client.send <- data:
default:
// 发送通道满则跳过
}
}
}
// collectOnlineUsers 收集在线用户列表(调用方需持有 room.mu 锁)
func (h *Hub) collectOnlineUsers(room *Room) []OnlineUser {
users := make([]OnlineUser, 0, len(room.clients))
for _, client := range room.clients {
users = append(users, OnlineUser{
UserID: client.userID,
Nickname: client.nickname,
Avatar: client.avatar,
})
}
return users
}
// broadcastOnlineUsers 广播在线用户列表
func (h *Hub) broadcastOnlineUsers(familyID int64, users []OnlineUser) {
msg, _ := json.Marshal(map[string]any{
"type": "online",
"users": users,
})
h.broadcastRaw(familyID, msg)
}
+63
View File
@@ -50,6 +50,69 @@ func (c *Client) LoadData(
return c.query(ctx, sql, args...)
}
func (c *Client) LoadDataPage(
ctx context.Context,
viewName string,
searchCondition string,
orderBy string,
searchColumns []string,
args []any,
page int,
pageSize int,
) ([]map[string]any, int, error) {
selectCols := "*"
if len(searchColumns) > 0 {
selectCols = strings.Join(searchColumns, ", ")
}
where := ""
if searchCondition != "" {
where = " WHERE " + searchCondition
}
// 1. COUNT 查询
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", viewName, where)
countSQL = convertPlaceholder(countSQL)
var total int
err := c.pool.QueryRow(ctx, countSQL, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
// 2. 分页数据查询
order := ""
if orderBy != "" {
order = " ORDER BY " + orderBy
}
offset := (page - 1) * pageSize
dataSQL := fmt.Sprintf(
"SELECT %s FROM %s%s%s LIMIT %d OFFSET %d",
selectCols,
viewName,
where,
order,
pageSize,
offset,
)
logger.FromCtx(ctx).Info("LoadDataPage",
zap.String("countSQL", countSQL),
zap.String("dataSQL", dataSQL),
zap.Any("args", args),
zap.Int("total", total),
)
rows, err := c.query(ctx, dataSQL, args...)
if err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (c *Client) LoadDataBySQL(
ctx context.Context,
sql string,
@@ -1,104 +1,436 @@
import React, { useState } from "react";
import { View, Text, Button, ScrollView } from "react-native";
import LayoutView from "@/components/layout/view";
import DateTime from "@/components/DateTime";
import DateTimeRange from "@/components/DateTimeRange";
import EmptyState from "@/components/EmptyStatus";
import { loadDataApi, loadDataPageApi } from "@/request/api";
import { useUserStore, useFamilyStore } from "@/store";
import { useChatWebSocket, ChatMessage, OnlineUser } from "@/hooks/useChatWebSocket";
import { cn } from "@/utils/cn";
import { buildFileUrl } from "@/utils/file";
import { useCallback, useEffect, useRef, useState } from "react";
import { FlatList, Image, Text, TextInput, View, Keyboard } from "react-native";
import { useRouter } from "expo-router";
import {
Button,
Spinner,
BottomSheet,
PressableFeedback,
} from "heroui-native";
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
export default function ChatTest() {
// --- DateTime (单选) ---
const [showDate, setShowDate] = useState(false);
const [dateValue, setDateValue] = useState("2026-06-23");
function formatTime(ts: string) {
if (!ts) return "";
const d = new Date(ts.replace(" ", "T") + "Z");
const h = d.getHours().toString().padStart(2, "0");
const m = d.getMinutes().toString().padStart(2, "0");
return `${h}:${m}`;
}
const [showTime, setShowTime] = useState(false);
const [timeValue, setTimeValue] = useState("20:00:00");
// ====== 消息气泡 ======
function ChatMessageItem({
item,
isSelf,
members,
isAdmin,
onRecall,
onDelete,
}: {
item: ChatMessage;
isSelf: boolean;
members: Record<number, { nickname: string; avatar: string }>;
isAdmin: boolean;
onRecall: (id: number) => void;
onDelete: (id: number) => void;
}) {
const router = useRouter();
const member = members[item.user_id];
const nickname = item.nickname || member?.nickname || "未知";
const [showActions, setShowActions] = useState(false);
const [showDatetime, setShowDatetime] = useState(false);
const [datetimeValue, setDatetimeValue] = useState("2026-06-23 20:00");
const msgTime = new Date(item.create_time.replace(" ", "T") + "Z").getTime();
const within2Min = (Date.now() - msgTime) < 2 * 60 * 1000;
const canRecall = isSelf && within2Min;
const canDelete = isAdmin;
const [showMonth, setShowMonth] = useState(false);
const [monthValue, setMonthValue] = useState("2026-06");
const [showYear, setShowYear] = useState(false);
const [yearValue, setYearValue] = useState("2026");
// --- DateTimeRange (范围) ---
const [showRange, setShowRange] = useState(false);
const [rangeStart, setRangeStart] = useState("");
const [rangeEnd, setRangeEnd] = useState("");
// 撤回
if (item.recalled === 1) {
return (
<View className="items-center py-2">
<Text className="text-xs text-gray-400">{nickname} 撤回了一条消息</Text>
</View>
);
}
return (
<LayoutView>
<LayoutView.ScrollView>
<View style={{ padding: 16, gap: 16, marginTop: 30 }}>
<>
<PressableFeedback
onLongPress={() => (canRecall || canDelete) && setShowActions(true)}
>
<View className={cn("px-4 py-1", isSelf ? "items-end" : "items-start")}>
{!isSelf && (
<PressableFeedback onPress={() => router.navigate(`/family/member/${item.user_id}` as any)}>
<Text className="text-xs text-gray-400 mb-0.5 ml-1">{nickname}</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
{/* DateTime */}
<Text style={{ fontSize: 18, fontWeight: "bold" }}>DateTime(单选)</Text>
<Button title={`日期: ${dateValue}`} onPress={() => setShowDate(true)} />
<DateTime
show={showDate}
value={dateValue}
mode="date"
onConfirm={(v) => { setDateValue(v); setShowDate(false); }}
onClose={() => setShowDate(false)}
/>
{/* 文本 */}
{item.msg_type === 0 && (
<View
className={cn(
"max-w-[75%] rounded-2xl px-3 py-2",
isSelf ? "bg-green-500" : "bg-white"
)}
>
<Text className={cn("text-base leading-5", isSelf ? "text-white" : "text-gray-900")}>
{item.content}
</Text>
</View>
)}
<Button title={`时间: ${timeValue}`} onPress={() => setShowTime(true)} />
<DateTime
show={showTime}
value={timeValue}
mode="time"
onConfirm={(v) => { setTimeValue(v); setShowTime(false); }}
onClose={() => setShowTime(false)}
{/* 图片 */}
{item.msg_type === 1 && item.attachments && item.attachments.length > 0 && (
<View className="flex-row flex-wrap gap-1 max-w-[75%] justify-end">
{item.attachments.map((att, i) => (
<Image
key={i}
source={{ uri: buildFileUrl(att.file_key) }}
className="w-28 h-28 rounded-xl bg-gray-200"
resizeMode="cover"
/>
))}
</View>
)}
<Button title={`日期时间: ${datetimeValue}`} onPress={() => setShowDatetime(true)} />
<DateTime
show={showDatetime}
value={datetimeValue}
mode="datetime"
onConfirm={(v) => { setDatetimeValue(v); setShowDatetime(false); }}
onClose={() => setShowDatetime(false)}
/>
{/* 视频 */}
{item.msg_type === 2 && item.attachments && item.attachments.length > 0 && (
<View className="w-48 h-32 rounded-xl bg-gray-800 items-center justify-center">
<Text className="text-white text-2xl">▶</Text>
{item.attachments[0].duration ? (
<Text className="text-white text-xs absolute bottom-1 right-1">
{item.attachments[0].duration}s
</Text>
) : null}
</View>
)}
<Button title={`月份: ${monthValue}`} onPress={() => setShowMonth(true)} />
<DateTime
show={showMonth}
value={monthValue}
mode="month"
onConfirm={(v) => { setMonthValue(v); setShowMonth(false); }}
onClose={() => setShowMonth(false)}
/>
{/* 文件 */}
{item.msg_type === 3 && item.attachments && item.attachments.length > 0 && (
<View
className={cn(
"max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2",
isSelf ? "bg-green-500" : "bg-white"
)}
>
<Text className={cn(isSelf ? "text-white" : "text-gray-900")}>📎</Text>
<View className="flex-1">
<Text className={cn("text-sm", isSelf ? "text-white" : "text-gray-900")} numberOfLines={1}>
{item.attachments[0].file_name}
</Text>
<Text className={cn("text-xs", isSelf ? "text-white/70" : "text-gray-400")}>
{item.attachments[0].file_size
? (item.attachments[0].file_size / 1024).toFixed(0) + " KB"
: ""}
</Text>
</View>
</View>
)}
<Button title={`年份: ${yearValue}`} onPress={() => setShowYear(true)} />
<DateTime
show={showYear}
value={yearValue}
mode="year"
onConfirm={(v) => { setYearValue(v); setShowYear(false); }}
onClose={() => setShowYear(false)}
/>
{/* 转发 */}
{item.msg_type === 4 && item.source_data && (
<View className="max-w-[75%] rounded-xl overflow-hidden bg-white border border-gray-200">
<View className="px-3 py-2">
<Text className="text-xs text-gray-400">来自 {item.source_table}</Text>
<Text className="text-sm text-gray-700 mt-1" numberOfLines={3}>
{(() => {
try {
const d = JSON.parse(item.source_data);
return Object.entries(d)
.filter(([k]) => !["id", "create_time", "update_time", "create_by", "update_by", "family_id"].includes(k))
.map(([k, v]) => `${k}: ${v}`)
.join(" ");
} catch {
return item.source_data;
}
})()}
</Text>
</View>
{item.content ? (
<View className="px-3 pb-2">
<Text className="text-sm text-gray-900">{item.content}</Text>
</View>
) : null}
</View>
)}
{/* DateTimeRange */}
<Text style={{ fontSize: 18, fontWeight: "bold", marginTop: 16 }}>DateTimeRange(范围)</Text>
<Text className="text-[10px] text-gray-300 mt-0.5 mr-1">{formatTime(item.create_time)}</Text>
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
{/* 操作面板 */}
<BottomSheet
isOpen={showActions}
onClose={() => setShowActions(false)}
snapPoints={["auto"]}
>
<View className="px-4 py-4 gap-2">
{canRecall && (
<Button
title={rangeStart ? `${rangeStart} ~ ${rangeEnd || "..."}` : "选择日期范围"}
onPress={() => setShowRange(true)}
onPress={() => {
onRecall(item.id);
setShowActions(false);
}}
className="w-full"
>
撤回
</Button>
)}
{canDelete && (
<Button
onPress={() => {
onDelete(item.id);
setShowActions(false);
}}
className="w-full"
color="danger"
>
删除
</Button>
)}
<Button onPress={() => setShowActions(false)} className="w-full" variant="light">
取消
</Button>
</View>
</BottomSheet>
</>
);
}
// ====== 在线头像栏 ======
function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) {
const router = useRouter();
return (
<View className="h-12 px-4 flex-row items-center justify-between border-b border-gray-100 bg-white">
<View className="flex-row items-center gap-3">
<Text className="text-xs text-gray-400">{users.length} 人在线</Text>
{users.slice(0, 6).map((u) => (
<PressableFeedback
key={u.user_id}
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
>
<View className="items-center">
{u.avatar ? (
<Image source={{ uri: u.avatar }} className="w-7 h-7 rounded-full bg-gray-200" />
) : (
<View className="w-7 h-7 rounded-full bg-gray-300 items-center justify-center">
<Text className="text-xs text-white">{u.nickname?.charAt(0) || "?"}</Text>
</View>
)}
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
))}
</View>
<PressableFeedback onPress={onSearch}>
<View className="w-8 h-8 items-center justify-center">
<SearchIcon size={18} color="#9ca3af" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
);
}
// ====== 页面 ======
export default function ChatPage() {
const router = useRouter();
const userId = useUserStore((s) => s.userInfo.id);
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
const [history, setHistory] = useState<ChatMessage[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [members, setMembers] = useState<Record<number, { nickname: string; avatar: string; role: number }>>({});
const [inputText, setInputText] = useState("");
const [adminRole, setAdminRole] = useState(0);
const flatListRef = useRef<FlatList>(null);
const ws = useChatWebSocket();
// 成员映射
useEffect(() => {
if (!familyId) return;
loadDataApi({
view_name: "b_family_member",
search_condition: "family_id = ?",
search_columns: ["user_id", "nickname", "avatar", "role"],
args: [familyId],
order_by: "",
}).then((res) => {
if (res.isSuccess && res.data) {
const map: Record<number, any> = {};
for (const m of res.data) map[m.user_id] = m;
setMembers(map);
const me = map[userId];
if (me) setAdminRole(me.role ?? 99);
}
});
}, [familyId, userId]);
// 加载历史
const loadHistory = useCallback(
async (p: number) => {
if (!familyId) return;
const res = await loadDataPageApi({
view_name: "b_chat",
search_condition: "family_id = ? AND deleted = 0",
order_by: "create_time DESC",
args: [familyId],
page: p,
page_size: 50,
search_columns: [],
});
if (res.isSuccess && res.data) {
const rows = res.data.rows as ChatMessage[];
const mediaIds = rows.filter((r) => r.msg_type >= 1 && r.msg_type <= 3).map((r) => r.id);
if (mediaIds.length > 0) {
const fileRes = await loadDataApi({
view_name: "b_file",
search_condition: `target_id IN (${mediaIds.join(",")})`,
order_by: "sort_order ASC",
args: [],
search_columns: [],
});
if (fileRes.isSuccess && fileRes.data) {
const fileMap: Record<number, any[]> = {};
for (const f of fileRes.data) {
if (!fileMap[f.target_id]) fileMap[f.target_id] = [];
fileMap[f.target_id].push(f);
}
for (const r of rows) {
if (fileMap[r.id]) (r as any).attachments = fileMap[r.id];
}
}
}
if (p === 1) setHistory(rows.reverse());
else setHistory((prev) => [...rows.reverse(), ...prev]);
setHasMore(rows.length >= 50);
}
},
[familyId]
);
useEffect(() => {
loadHistory(1).finally(() => setLoading(false));
}, [loadHistory]);
const handleLoadMore = useCallback(async () => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
const nextPage = page + 1;
await loadHistory(nextPage);
setPage(nextPage);
setLoadingMore(false);
}, [loadingMore, hasMore, page, loadHistory]);
// 合并实时消息
const allMessages = (() => {
const ids = new Set(history.map((m) => m.id));
const merged = [...history];
for (const m of ws.messages) {
if (!ids.has(m.id)) {
merged.push(m);
ids.add(m.id);
}
}
return merged;
})();
// 发送
const handleSend = useCallback(() => {
const text = inputText.trim();
if (!text) return;
setInputText("");
Keyboard.dismiss();
ws.send({ type: "send", content: text });
}, [inputText, ws]);
const handleRecall = useCallback((id: number) => ws.recall(id), [ws]);
const handleDelete = useCallback((id: number) => ws.deleteMessage(id), [ws]);
if (loading) {
return (
<LayoutView>
<EmptyState type="loading" title="加载中..." />
</LayoutView>
);
}
return (
<LayoutView>
<View className="flex-1 bg-gray-50">
{/* 连接状态 */}
<View className="h-7 px-4 flex-row items-center gap-1.5">
<View className={cn("w-2 h-2 rounded-full", ws.isConnected ? "bg-green-500" : "bg-red-400")} />
<Text className="text-xs text-gray-400">{ws.isConnected ? "已连接" : "连接中..."}</Text>
</View>
{/* 在线成员 + 搜索入口 */}
<ChatOnlineBar users={ws.onlineUsers} onSearch={() => router.navigate("/chat/history")} />
{/* 消息列表 */}
<FlatList
ref={flatListRef}
data={allMessages}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<ChatMessageItem
item={item}
isSelf={item.user_id === userId}
members={members}
isAdmin={adminRole === 0}
onRecall={handleRecall}
onDelete={handleDelete}
/>
<DateTimeRange
show={showRange}
startDate={rangeStart || undefined}
endDate={rangeEnd || undefined}
onConfirm={(start, end) => {
setRangeStart(start);
setRangeEnd(end);
setShowRange(false);
}}
onClose={() => setShowRange(false)}
)}
inverted
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
className="flex-1"
contentContainerStyle={{ paddingVertical: 8 }}
ListFooterComponent={
loadingMore ? (
<View className="py-4 items-center">
<Spinner size="sm" />
</View>
) : null
}
/>
{/* 输入栏 */}
<View className="flex-row items-center gap-2 px-3 py-2 bg-white border-t border-gray-200">
<TextInput
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-base"
placeholder="输入消息..."
value={inputText}
onChangeText={setInputText}
onSubmitEditing={handleSend}
returnKeyType="send"
/>
<PressableFeedback onPress={handleSend} disabled={!inputText.trim()}>
<View
className={cn(
"w-10 h-10 rounded-full items-center justify-center",
inputText.trim() ? "bg-green-500" : "bg-gray-300"
)}
>
<SendHorizonalIcon size={18} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
</View>
</LayoutView.ScrollView>
</LayoutView>
);
}
@@ -6,7 +6,6 @@ import { useFamilyStore, useFavoriteStore } from "@/store";
import {
ChevronRight,
Grid3X3,
UserPlus,
User,
Receipt,
Heart,
@@ -30,10 +29,9 @@ function getGreeting() {
/* ─── 类型 → 图标 ─── */
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
join: UserPlus,
rename: User,
bill: Receipt,
fav: Heart,
family: User,
finance: Receipt,
favorite: Heart,
};
/* ─── 时间格式化 ─── */
@@ -1,19 +1,14 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import LayoutView from "@/components/layout/view";
import { View, Text } from "react-native";
import { View, Text, FlatList, ScrollView } from "react-native";
import { Stack, useRouter } from "expo-router";
import { CloseButton } from "heroui-native";
import { CloseButton, Button, PressableFeedback } from "heroui-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useFamilyStore } from "@/store";
import { loadFeeds, type FeedRecord } from "@/helpers/feed";
import { UserPlus, User, Receipt, Heart } from "lucide-react-native";
/* ─── 类型 → 图标 ─── */
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
join: UserPlus,
rename: User,
bill: Receipt,
fav: Heart,
};
import { loadFeedsPage, type FeedRecord } from "@/helpers/feed";
import EmptyState from "@/components/EmptyStatus";
import DateTimeRange from "@/components/DateTimeRange";
import { User, Receipt, Heart, ArrowUp, Calendar } from "lucide-react-native";
/* ─── 时间格式化 ─── */
function formatTime(dateStr: string) {
@@ -36,49 +31,109 @@ function formatTime(dateStr: string) {
export default function Activities() {
const router = useRouter();
const insets = useSafeAreaInsets();
const familyStore = useFamilyStore();
const flatListRef = useRef<FlatList<FeedRecord>>(null);
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [typeFilter, setTypeFilter] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [showDatePicker, setShowDatePicker] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const fetchFeeds = useCallback(async () => {
const hasMore = feeds.length < total;
/* ─── 数据加载 ─── */
const fetchFeeds = useCallback(
async (pageNum: number, isLoadMore = false) => {
if (isLoadMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const data = await loadFeeds(familyStore.currentFamily.family_id);
setFeeds(data);
const data = await loadFeedsPage(
familyStore.currentFamily.family_id,
pageNum,
20,
typeFilter || undefined,
dateFrom || undefined,
dateTo || undefined,
);
if (isLoadMore) {
setFeeds((prev) => [...prev, ...data.rows]);
} else {
setFeeds(data.rows);
}
setTotal(data.total);
setPage(pageNum);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [familyStore.currentFamily.family_id]);
},
[familyStore.currentFamily.family_id, typeFilter, dateFrom, dateTo],
);
/* ─── 首次加载 & 类别/日期变更时重新加载 ─── */
const isInitialMount = useRef(true);
useEffect(() => {
fetchFeeds();
if (isInitialMount.current) {
isInitialMount.current = false;
fetchFeeds(1);
return;
}
fetchFeeds(1);
flatListRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [typeFilter, dateFrom, dateTo]); // eslint-disable-line react-hooks/exhaustive-deps
/* ─── 滚动处理 ─── */
const handleScroll = useCallback((event: { nativeEvent: { contentOffset: { y: number } } }) => {
setShowScrollTop(event.nativeEvent.contentOffset.y > 400);
}, []);
return (
<LayoutView>
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
const handleLoadMore = useCallback(() => {
if (!loadingMore && hasMore) {
fetchFeeds(page + 1, true);
}
}, [loadingMore, hasMore, page, fetchFeeds]);
<LayoutView.ScrollView
showsVerticalScrollIndicator={false}
>
{loading ? (
<View className="flex-1 items-center justify-center py-20">
<Text className="text-sm text-gray-400">加载中...</Text>
</View>
) : feeds.length === 0 ? (
<View className="flex-1 items-center justify-center py-20">
<Text className="text-sm text-gray-400">暂无动态</Text>
</View>
) : (
feeds.map((item, idx) => {
const isLast = idx === feeds.length - 1;
const Icon = TYPE_ICONS[item.type] || Receipt;
const handleScrollToTop = useCallback(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, []);
/* ─── 日期范围确认 ─── */
const handleDateConfirm = useCallback((start: string, end: string) => {
setDateFrom(start);
setDateTo(end);
setShowDatePicker(false);
}, []);
/* ─── 日期范围显示文本 ─── */
const dateLabel = dateFrom
? dateTo
? `${dateFrom} ~ ${dateTo}`
: dateFrom
: "筛选日期";
/* ─── 渲染每条动态 ─── */
const renderItem = useCallback(
({ item, index }: { item: FeedRecord; index: number }) => {
const isLast = index === feeds.length - 1;
const Icon = item.type === "family" ? User : item.type === "finance" ? Receipt : item.type === "favorite" ? Heart : Receipt;
return (
<View key={item.id} className="flex-row">
<View className="flex-row">
{/* 时间轴列 */}
<View className="items-center mr-3" style={{ width: 24 }}>
<View className="w-7 h-7 rounded-full bg-accent/10 items-center justify-center mt-0.5">
@@ -89,22 +144,155 @@ export default function Activities() {
{/* 内容 */}
<View className={`flex-1 ${isLast ? "" : "pb-6"}`}>
<Text className="text-xs text-gray-400">
{formatTime(item.create_time)}
</Text>
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">
{item.content}
</Text>
<Text className="text-xs text-gray-400">{formatTime(item.create_time)}</Text>
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">{item.content}</Text>
</View>
</View>
);
})
},
[feeds.length],
);
/* ─── 空状态 ─── */
const renderEmpty = useCallback(() => {
if (loading) return null;
return <EmptyState type="empty" />;
}, [loading]);
/* ─── 底部加载指示器 ─── */
const renderFooter = useCallback(() => {
if (loadingMore) {
return (
<View className="py-4 items-center">
<Text className="text-xs text-gray-400">加载更多...</Text>
</View>
);
}
if (!loading && feeds.length > 0 && !hasMore) {
return (
<View className="py-4 items-center">
<Text className="text-xs text-gray-300">没有更多了</Text>
</View>
);
}
return null;
}, [loadingMore, loading, feeds.length, hasMore]);
return (
<LayoutView>
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView.FlatList
ref={flatListRef}
data={feeds}
keyExtractor={(item) => String(item.id)}
topExtraPadding={106 - insets.top}
bottomExtraPadding={20}
renderItem={renderItem}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
onScroll={handleScroll}
scrollEventThrottle={16}
/>
{/* ─── 浮动头部:类别筛选 + 日期筛选 ─── */}
<View
className="absolute top-0 left-0 right-0 z-10 bg-background"
style={{ paddingTop: insets.top }}
>
<View className="px-4 pt-3 pb-2">
{/* 类别筛选 chips */}
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{([
{ key: "", label: "全部" },
{ key: "family", label: "家庭", Icon: User },
{ key: "finance", label: "记账", Icon: Receipt },
{ key: "favorite", label: "收藏", Icon: Heart },
] as const).map((item) => {
const { key, label } = item;
const isActive = typeFilter === key;
const Icon = "Icon" in item ? item.Icon : null;
return (
<PressableFeedback
key={key}
onPress={() => setTypeFilter(key)}
className={`flex-row items-center gap-1 px-3.5 py-1.5 rounded-full mr-2 ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
{Icon && (
<Icon size={12} color={isActive ? "#fff" : "#9ca3af"} />
)}
<Text
className={`text-xs ${
isActive ? "text-white font-medium" : "text-gray-400"
}`}
>
{label}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
);
})}
</ScrollView>
{/* 日期范围筛选 */}
<View className="flex-row items-center gap-2 mt-2">
<PressableFeedback
onPress={() => setShowDatePicker(true)}
className={`flex-row items-center gap-1.5 px-3 py-1.5 rounded-full ${
dateFrom ? "bg-accent/10" : "bg-gray-100"
}`}
>
<Calendar size={12} color={dateFrom ? "#0d9488" : "#9ca3af"} />
<Text
className={`text-xs ${
dateFrom ? "text-accent font-medium" : "text-gray-400"
}`}
>
{dateLabel}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
{dateFrom ? (
<PressableFeedback
onPress={() => {
setDateFrom("");
setDateTo("");
}}
className="px-2 py-1.5"
>
<Text className="text-xs text-gray-400">清除</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
) : null}
</View>
</View>
</View>
{/* ─── 日期范围选择器 ─── */}
<DateTimeRange
show={showDatePicker}
startDate={dateFrom || undefined}
endDate={dateTo || undefined}
onConfirm={handleDateConfirm}
onClose={() => setShowDatePicker(false)}
/>
{/* ─── 滚动到顶部 ─── */}
{showScrollTop && (
<Button
className="absolute bottom-24 right-4 w-10 h-10 rounded-full shadow-md items-center justify-center bg-white border border-gray-100"
onPress={handleScrollToTop}
>
<ArrowUp size={18} color="#0d9488" />
</Button>
)}
<View className="h-4" />
</LayoutView.ScrollView>
{/* 关闭按钮 */}
{/* ─── 关闭按钮 ─── */}
<View className="absolute bottom-6 left-0 right-0 items-center">
<CloseButton
onPress={() => router.back()}
@@ -0,0 +1,188 @@
import LayoutView from "@/components/layout/view";
import PageLayout from "@/layouts/PageLayout";
import EmptyState from "@/components/EmptyStatus";
import { loadDataApi, loadDataPageApi } from "@/request/api";
import { useFamilyStore } from "@/store";
import { cn } from "@/utils/cn";
import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useRouter } from "expo-router";
import { SearchField, Spinner, PressableFeedback } from "heroui-native";
const MSG_TYPES = [
{ label: "全部", value: -1 },
{ label: "文本", value: 0 },
{ label: "图片", value: 1 },
{ label: "视频", value: 2 },
{ label: "文件", value: 3 },
{ label: "转发", value: 4 },
];
function formatTime(ts: string) {
if (!ts) return "";
const d = new Date(ts.replace(" ", "T") + "Z");
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
const h = d.getHours().toString().padStart(2, "0");
const min = d.getMinutes().toString().padStart(2, "0");
return `${m}-${day} ${h}:${min}`;
}
export default function ChatHistoryPage() {
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState(-1);
const [results, setResults] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [searched, setSearched] = useState(false);
const [memberMap, setMemberMap] = useState<Record<number, string>>({});
// 加载成员昵称映射
useEffect(() => {
if (!familyId) return;
loadDataApi({
view_name: "b_family_member",
search_condition: "family_id = ?",
search_columns: ["user_id", "nickname"],
args: [familyId],
order_by: "",
}).then((res) => {
if (res.isSuccess && res.data) {
const map: Record<number, string> = {};
for (const m of res.data) map[m.user_id] = m.nickname || "未知";
setMemberMap(map);
}
});
}, [familyId]);
const doSearch = useCallback(
async (p: number) => {
if (!familyId || !keyword.trim()) return;
if (p === 1) setLoading(true);
else setLoadingMore(true);
try {
const escaped = keyword.trim().replace(/'/g, "''");
let condition = `family_id = ? AND deleted = 0 AND content ILIKE '%${escaped}%'`;
if (typeFilter >= 0) condition += ` AND type = ${typeFilter}`;
const res = await loadDataPageApi({
view_name: "b_chat",
search_condition: condition,
order_by: "create_time DESC",
args: [familyId],
page: p,
page_size: 30,
search_columns: [],
});
if (res.isSuccess && res.data) {
if (p === 1) setResults(res.data.rows);
else setResults((prev) => [...prev, ...res.data.rows]);
setHasMore(res.data.rows.length >= 30);
setPage(p);
}
} finally {
setLoading(false);
setLoadingMore(false);
setSearched(true);
}
},
[familyId, keyword, typeFilter]
);
const handleLoadMore = () => {
if (loadingMore || !hasMore) return;
doSearch(page + 1);
};
const router = useRouter();
const typeLabel = (t: number) => MSG_TYPES.find((m) => m.value === t)?.label || "未知";
return (
<PageLayout title="聊天记录">
<LayoutView>
<View className="px-4 pt-4 gap-3">
{/* 搜索栏 */}
<SearchField value={keyword} onChange={setKeyword}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input
placeholder="搜索聊天记录..."
onSubmitEditing={() => doSearch(1)}
returnKeyType="search"
/>
</SearchField.Group>
</SearchField>
{/* 类型筛选 */}
<View className="flex-row gap-2 flex-wrap">
{MSG_TYPES.map((t) => (
<PressableFeedback key={t.value} onPress={() => setTypeFilter(t.value)}>
<View
className={cn(
"px-3 py-1 rounded-full",
typeFilter === t.value ? "bg-green-500" : "bg-gray-200"
)}
>
<Text className={cn("text-sm", typeFilter === t.value ? "text-white" : "text-gray-600")}>
{t.label}
</Text>
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
))}
</View>
</View>
{/* 结果列表 */}
<LayoutView.FlatList
data={results}
keyExtractor={(item: any) => String(item.id)}
renderItem={({ item }: { item: any }) => (
<View className="px-4 py-3 border-b border-gray-100">
<View className="flex-row items-center gap-2 mb-1">
<PressableFeedback
onPress={() => router.navigate(`/family/member/${item.user_id}` as any)}
>
<Text className="text-xs text-gray-500">
{memberMap[item.user_id] || "未知"}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
<Text className="text-xs text-gray-400">{formatTime(item.create_time)}</Text>
<View className="px-1.5 py-0.5 bg-gray-100 rounded">
<Text className="text-[10px] text-gray-500">{typeLabel(item.type)}</Text>
</View>
</View>
<Text className="text-sm text-gray-800" numberOfLines={3}>
{item.content}
</Text>
</View>
)}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={
loading ? (
<EmptyState type="loading" title="搜索中..." />
) : searched ? (
<EmptyState type="empty" title="没有找到相关消息" />
) : null
}
ListFooterComponent={
loadingMore ? (
<View className="py-4 items-center">
<Spinner size="sm" />
</View>
) : null
}
/>
</LayoutView>
</PageLayout>
);
}
@@ -273,7 +273,7 @@ export default function EntryFamily() {
setCreateForm({ name: "" });
// 记录动态
recordFeed(familyId, userStore.userInfo.id, "join", `{nickname} 创建了家庭「${createForm.name}」`);
recordFeed(familyId, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 创建了家庭「${createForm.name}」`);
enterFamily(familyId);
}
@@ -352,7 +352,7 @@ export default function EntryFamily() {
setJoinForm({ invite_code: "" });
// 记录动态
recordFeed(family.id, userStore.userInfo.id, "join", "{nickname} 加入了家庭");
recordFeed(family.id, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 加入了家庭`);
enterFamily(family.id);
}
@@ -422,8 +422,8 @@ export default function UserSetting() {
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo.id,
"rename",
`{nickname} 修改了昵称为「${renameForm.name}」`,
"family",
`${userStore.userInfo.nickname} 修改了昵称为「${renameForm.name}」`,
);
toast.success("名称修改成功");
@@ -176,8 +176,8 @@ export default function Tools() {
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo?.id ?? 0,
"fav",
`{nickname} 收藏了「${subItem.title}」`,
"favorite",
`${userStore.userInfo?.nickname ?? "成员"} 收藏了「${subItem.title}」`,
);
}
}}
@@ -0,0 +1,23 @@
import { View, Text } from "react-native";
import { Spinner } from "heroui-native";
interface LoadingOverlayProps {
visible: boolean;
text?: string;
}
export default function LoadingOverlay({ visible, text = "加载中..." }: LoadingOverlayProps) {
if (!visible) return null;
return (
<View
className="absolute inset-0 z-50 items-center justify-center bg-black/30"
pointerEvents="auto"
>
<View className="bg-white rounded-2xl px-8 py-6 items-center gap-3 shadow-lg">
<Spinner />
<Text className="text-sm text-gray-500">{text}</Text>
</View>
</View>
);
}
+47 -34
View File
@@ -1,6 +1,6 @@
import { loadDataApi, saveDataApi, getUniqueIdApi } from "@/request/api";
import { loadDataApi, loadDataPageApi, saveDataApi, getUniqueIdApi } from "@/request/api";
export type FeedType = "join" | "rename" | "bill" | "fav";
export type FeedType = "family" | "finance" | "favorite";
export interface FeedRecord {
id: number;
@@ -12,25 +12,7 @@ export interface FeedRecord {
create_time: string;
}
/** 获取用户在家庭中的昵称(来自 b_family_member,非 b_user) */
export async function getFamilyMemberNickname(
familyId: number,
userId: number,
): Promise<string> {
const res = await loadDataApi({
view_name: "b_family_member",
search_condition: "family_id = ? AND user_id = ?",
search_columns: ["nickname"],
order_by: "",
args: [familyId, userId],
});
if (res.isSuccess && res.data?.length > 0) {
return res.data[0].nickname || "成员";
}
return "成员";
}
/** 写入一条动态 — content 直接包含所有展示信息,用 {nickname} 占位 */
/** 写入一条动态 */
export async function recordFeed(
familyId: number,
userId: number,
@@ -60,7 +42,7 @@ export async function recordFeed(
]);
}
/** 加载家庭动态列表 — 自动替换 {nickname} 占位符 */
/** 加载家庭动态列表 */
export async function loadFeeds(familyId: number): Promise<FeedRecord[]> {
const res = await loadDataApi({
view_name: "b_family_feed",
@@ -69,18 +51,49 @@ export async function loadFeeds(familyId: number): Promise<FeedRecord[]> {
search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
args: [familyId],
});
const feeds = (res.data || []) as FeedRecord[];
// 批量加载昵称:收集唯一 user_id,逐个查 b_family_member
const uniqueUserIds = [...new Set(feeds.map((f) => f.user_id))];
const nickMap = new Map<number, string>();
for (const uid of uniqueUserIds) {
nickMap.set(uid, await getFamilyMemberNickname(familyId, uid));
return (res.data || []) as FeedRecord[];
}
// 替换 {nickname} 占位符,保证每次展示都是最新昵称
return feeds.map((f) => ({
...f,
content: f.content.replace(/\{nickname\}/g, nickMap.get(f.user_id) || "成员"),
}));
/** 分页加载家庭动态列表 */
export async function loadFeedsPage(
familyId: number,
page: number,
pageSize: number,
type?: string,
dateFrom?: string,
dateTo?: string,
): Promise<{ rows: FeedRecord[]; total: number; page: number; page_size: number }> {
const conditions: string[] = ["family_id = ?"];
const args: any[] = [familyId];
if (type) {
conditions.push("type = ?");
args.push(type);
}
if (dateFrom) {
conditions.push("create_time >= ?");
args.push(dateFrom);
}
if (dateTo) {
conditions.push("create_time <= ?");
args.push(dateTo + " 23:59:59");
}
const res = await loadDataPageApi({
view_name: "b_family_feed",
search_condition: conditions.join(" AND "),
order_by: "create_time DESC",
search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
args,
page,
page_size: pageSize,
});
if (!res.isSuccess || !res.data) {
return { rows: [], total: 0, page, page_size: pageSize };
}
return { rows: res.data.rows as FeedRecord[], total: res.data.total, page: res.data.page, page_size: res.data.page_size };
}
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner-native";
import { useUserStore, useFamilyStore } from "@/store";
const RECONNECT_BASE = 1000;
const RECONNECT_MAX = 30000;
export interface ChatMessage {
id: number;
family_id: number;
user_id: number;
nickname: string;
avatar: string;
content: string;
msg_type: number;
source_table: string | null;
source_id: number | null;
source_data: string | null;
attachments: Attachment[] | null;
recalled: number;
deleted: number;
create_time: string;
}
export interface Attachment {
file_key: string;
file_name: string;
file_type: number;
mime_type: string;
file_size: number;
width?: number;
height?: number;
duration?: number;
}
export interface OnlineUser {
user_id: number;
nickname: string;
avatar: string;
}
interface SendPayload {
type: "send";
content: string;
msg_type?: number;
source_table?: string;
source_id?: number;
attachments?: Omit<Attachment, "id">[];
}
export function useChatWebSocket() {
const token = useUserStore((s) => s.userInfo?.token);
const familyId = useFamilyStore((s) => s.currentFamily?.family_id);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const lastReceivedId = useRef<number>(0);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelay = useRef<number>(RECONNECT_BASE);
const mounted = useRef(true);
const wasConnected = useRef(false);
const buildWsUrl = useCallback(() => {
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "";
const wsProtocol = apiUrl.startsWith("https") ? "wss" : "ws";
const host = apiUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}://${host}/app/chat/ws?token=${token}&family_id=${familyId}`;
}, [token, familyId]);
const connect = useCallback(() => {
if (!token || !familyId) return;
if (wsRef.current) return;
const url = buildWsUrl();
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (!mounted.current) return;
setIsConnected(true);
reconnectDelay.current = RECONNECT_BASE;
if (wasConnected.current) {
toast.success("聊天已重连");
}
wasConnected.current = true;
};
ws.onmessage = (event) => {
if (!mounted.current) return;
try {
const msg = JSON.parse(event.data);
handleMessage(msg);
} catch {
// ignore parse errors
}
};
ws.onclose = () => {
if (!mounted.current) return;
setIsConnected(false);
wsRef.current = null;
if (wasConnected.current) {
toast.error("聊天已断开");
}
scheduleReconnect();
};
ws.onerror = () => {
ws.close();
};
}, [token, familyId, buildWsUrl]);
const scheduleReconnect = useCallback(() => {
if (!mounted.current) return;
reconnectTimer.current = setTimeout(() => {
reconnectDelay.current = Math.min(reconnectDelay.current * 2, RECONNECT_MAX);
connect();
}, reconnectDelay.current);
}, [connect]);
const handleMessage = useCallback((msg: any) => {
switch (msg.type) {
case "message": {
const data = msg.data as ChatMessage;
if (data.id <= lastReceivedId.current) return;
lastReceivedId.current = data.id;
setMessages((prev) => [...prev, data]);
break;
}
case "message_recalled": {
setMessages((prev) =>
prev.map((m) => (m.id === msg.message_id ? { ...m, recalled: 1 } : m))
);
break;
}
case "message_deleted": {
setMessages((prev) => prev.filter((m) => m.id !== msg.message_id));
break;
}
case "online": {
setOnlineUsers(msg.users || []);
break;
}
case "offline": {
setOnlineUsers((prev) =>
prev.filter((u) => u.user_id !== msg.user_id)
);
break;
}
}
}, []);
const send = useCallback((payload: SendPayload) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify(payload));
}, []);
const recall = useCallback((messageId: number) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: "recall", message_id: messageId }));
}, []);
const deleteMessage = useCallback((messageId: number) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: "delete", message_id: messageId }));
}, []);
useEffect(() => {
mounted.current = true;
connect();
return () => {
mounted.current = false;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
};
}, [connect]);
return {
messages,
onlineUsers,
isConnected,
send,
recall,
deleteMessage,
};
}
+21
View File
@@ -48,6 +48,27 @@ export function loadDataApi(params: LoadDataParams) {
return alovaInstance.Post<ResponseData<any[]>>(`/data/loadData`, params);
}
interface LoadDataPageParams {
view_name: string;
search_condition: string;
order_by: string;
search_columns: string[];
args: any[];
page: number;
page_size: number;
}
interface PageData<T> {
rows: T[];
total: number;
page: number;
page_size: number;
}
export function loadDataPageApi(params: LoadDataPageParams) {
return alovaInstance.Post<ResponseData<PageData<any>>>(`/data/loadDataPage`, params);
}
interface SaveDataParams {
table_name: string;
key_field: string;
+4 -1
View File
@@ -30,4 +30,7 @@ https://api.code-relay.com/register?aff=el8a
这个送20 ,gpt5.5
https://helpcoder.cc/register?aff=qWV9
这个送200 ,gpt系列
这个送200 ,gpt系列、
sk-95bc4f401d8b499bbe073c828d176157