20260625173106
This commit is contained in:
commit
2e928710df
178 files changed
+14812
-4314
No files matched your search
@@ -1 +1 @@
|
||||
EXPO_PUBLIC_API_URL=http://192.168.5.2:8087
|
||||
EXPO_PUBLIC_API_URL=http://192.168.5.12:8087
|
||||
@@ -1,304 +0,0 @@
|
||||
# 项目分析计划
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
这是一个基于 **React Native (Expo)** 的跨平台移动应用,采用 **React Navigation** 路由 + **expo-router** 文件约定式路由架构。
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
### 框架与核心库
|
||||
|
||||
| 库 | 版本 | 用途 |
|
||||
| ------------ | --------- | ------- |
|
||||
| expo | \~55.0.15 | 基础框架 |
|
||||
| expo-router | \~55.0.12 | 文件约定式路由 |
|
||||
| react-native | 0.83.4 | 原生视图层 |
|
||||
| react | 19.2.0 | UI 框架 |
|
||||
|
||||
### 状态管理
|
||||
|
||||
| 库 | 版本 | 用途 |
|
||||
| ----------------------------------------- | ------- | ------ |
|
||||
| zustand | ^5.0.12 | 轻量状态管理 |
|
||||
| @react-native-async-storage/async-storage | ^2.2.0 | 持久化存储 |
|
||||
|
||||
### 请求与数据
|
||||
|
||||
| 库 | 版本 | 用途 |
|
||||
| -------------------- | ------- | --------- |
|
||||
| alova | ^3.5.1 | 请求库 |
|
||||
| @alova/adapter-axios | ^2.0.18 | Axios 适配器 |
|
||||
| axios | ^1.15.0 | HTTP 客户端 |
|
||||
|
||||
### UI 组件
|
||||
|
||||
| 库 | 版本 | 用途 |
|
||||
| ----------------------- | ------ | ------- |
|
||||
| heroui-native | ^1.0.1 | UI 组件库 |
|
||||
| lucide-react-native | ^1.8.0 | 图标库 |
|
||||
| react-native-reanimated | 4.2.1 | 动画库 |
|
||||
| tailwindcss | ^4.2.2 | 原子化 CSS |
|
||||
|
||||
## 三、项目架构
|
||||
|
||||
### 3.1 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # 页面 (expo-router 文件约定式路由)
|
||||
│ ├── (tabs)/ # 底部 Tab 页面组
|
||||
│ │ ├── home/ # 首页
|
||||
│ │ ├── chat/ # 聊天页
|
||||
│ │ ├── star/ # 收藏页
|
||||
│ │ ├── my/ # 个人中心
|
||||
│ │ └── _layout.tsx # Tab 布局
|
||||
│ ├── auth/ # 认证页面
|
||||
│ │ └── login.tsx # 登录页
|
||||
│ ├── family/ # 家庭模块
|
||||
│ │ ├── entry.tsx # 家庭入口(创建/加入)
|
||||
│ │ ├── settings.tsx # 家庭设置
|
||||
│ │ ├── user.tsx # 家庭成员
|
||||
│ │ └── member/[id].tsx # 成员详情
|
||||
│ ├── finance/ # 记账模块
|
||||
│ │ ├── (tabs)/ # 记账子 Tab
|
||||
│ │ │ ├── home/ # 记账首页
|
||||
│ │ │ ├── analysis/ # 统计分析
|
||||
│ │ │ └── _layout.tsx
|
||||
│ │ ├── add/ # 新增记账
|
||||
│ │ └── sub/ # 订阅管理
|
||||
│ ├── tools/ # 工具页面
|
||||
│ ├── _layout.tsx # 根布局
|
||||
│ └── index.tsx # 根页面
|
||||
├── components/ # 组件
|
||||
│ ├── iconfont/ # IconFont 图标组件
|
||||
│ └── layout/ # 布局组件 (Navbar, Tabbar)
|
||||
├── configs/ # 配置
|
||||
│ └── pages.ts # 子应用配置
|
||||
├── hooks/ # 自定义 Hooks
|
||||
│ ├── use-color-scheme.ts
|
||||
│ └── use-theme-color.ts
|
||||
├── layouts/ # 布局模板
|
||||
│ ├── AppLayout.tsx # App 主布局 (带 Navbar + Tabbar)
|
||||
│ ├── ModuleLayout.tsx # 模块布局 (独立功能模块)
|
||||
│ └── PageLayout.tsx # 页面布局
|
||||
├── request/ # 请求相关
|
||||
│ ├── api.ts # API 接口定义
|
||||
│ └── index.ts # Alova 实例
|
||||
├── store/ # Zustand 状态库
|
||||
│ ├── app.ts # App 全局状态
|
||||
│ ├── user.ts # 用户状态
|
||||
│ ├── finance.ts # 记账状态
|
||||
│ └── family.ts # 家庭状态
|
||||
├── types/ # TypeScript 类型
|
||||
│ └── auth.ts # 认证类型
|
||||
└── utils/ # 工具函数
|
||||
├── http.ts # HTTP 封装
|
||||
├── time.ts # 时间处理
|
||||
└── cn.ts # 样式合并
|
||||
```
|
||||
|
||||
### 3.2 路由架构
|
||||
|
||||
```
|
||||
根布局 (_layout.tsx)
|
||||
├── 认证流程
|
||||
│ └── auth/login.tsx (未登录跳转)
|
||||
├── 主应用 (已登录)
|
||||
│ ├── (tabs) 底部 Tab 导航
|
||||
│ │ ├── home (首页)
|
||||
│ │ ├── space (空间)
|
||||
│ │ ├── tools (工具中心 - 中心按钮)
|
||||
│ │ ├── chat (聊天)
|
||||
│ │ └── my (我的)
|
||||
│ └── 独立页面
|
||||
│ ├── family/entry (家庭入口)
|
||||
│ ├── family/settings (家庭设置)
|
||||
│ └── finance/(tabs) (记账模块 Tab)
|
||||
│ ├── home (记账)
|
||||
│ ├── add (新增 - 中心按钮)
|
||||
│ └── analysis (分析)
|
||||
```
|
||||
|
||||
### 3.3 布局系统
|
||||
|
||||
| 布局组件 | 用途 | 层级 |
|
||||
| ------------ | ------------------------ | ---- |
|
||||
| AppLayout | 主应用布局,提供 Navbar + Tabbar | 页面容器 |
|
||||
| ModuleLayout | 功能模块布局,独立 Tabbar | 模块容器 |
|
||||
| PageLayout | 页面基础布局,处理安全区域 | 内容容器 |
|
||||
|
||||
## 四、API 接口分析
|
||||
|
||||
### 4.1 请求封装
|
||||
|
||||
使用 **Alova** 请求库,配合 **Axios** 适配器。请求基地址: `EXPO_PUBLIC_API_URL` (开发环境: `https://dev.api.com`)
|
||||
|
||||
### 4.2 HTTP 拦截器
|
||||
|
||||
**请求拦截器**:
|
||||
|
||||
* 白名单接口直接放行 (`/auth/login/**`)
|
||||
|
||||
* 非白名单接口检查登录状态
|
||||
|
||||
* 未登录拦截并跳转登录页
|
||||
|
||||
* 已登录则注入 JWT Token 到 Header
|
||||
|
||||
**响应拦截器**:
|
||||
|
||||
* `code: 1000` → 成功
|
||||
|
||||
* `code: 1001` → Token 过期,跳转登录
|
||||
|
||||
* `code: 2000` → 业务错误,显示错误提示
|
||||
|
||||
* `code: 3000` → 系统错误,显示错误提示
|
||||
|
||||
* 其他 → 请求失败
|
||||
|
||||
### 4.3 核心 API
|
||||
|
||||
| 接口 | 方法 | 参数 | 用途 |
|
||||
| --------------------- | ---- | --------------------------------------------------------------- | ------- |
|
||||
| `/auth/login/qq` | POST | openid, nickname, avatar | QQ 登录 |
|
||||
| `/auth/login/wechat` | POST | code | 微信登录 |
|
||||
| `/bind/qq` | POST | openid, nickname, avatar | 绑定 QQ |
|
||||
| `/bind/wechat` | POST | code | 绑定微信 |
|
||||
| `/data/getUniqueId` | POST | count | 获取自增 ID |
|
||||
| `/data/loadData` | POST | view\_name, search\_condition, order\_by, search\_columns, args | 查询数据 |
|
||||
| `/data/loadDataBySql` | POST | sql, args | SQL 查询 |
|
||||
| `/data/saveData` | POST | table\_name, key\_field, inserts, updates, deletes | 保存数据 |
|
||||
| `/s3/upload` | POST | files | 文件上传 |
|
||||
|
||||
### 4.4 数据表结构 (推测)
|
||||
|
||||
| 表名 | 用途 |
|
||||
| ----------------------------- | --------- |
|
||||
| b\_family | 家庭表 |
|
||||
| b\_family\_member | 家庭成员表 |
|
||||
| b\_finance\_category | 记账分类表 |
|
||||
| b\_finance\_category\_default | 默认记账分类 |
|
||||
| b\_finance\_record | 记账记录 (推测) |
|
||||
| v\_family\_member | 家庭成员视图 |
|
||||
|
||||
## 五、状态管理
|
||||
|
||||
### 5.1 Store 概览
|
||||
|
||||
| Store | 持久化 | 用途 |
|
||||
| ------------ | -------------- | --------------- |
|
||||
| userStore | ✅ AsyncStorage | 用户信息、Token、登录状态 |
|
||||
| familyStore | ✅ AsyncStorage | 当前家庭 ID |
|
||||
| financeStore | ❌ 内存 | 记账分类数据 |
|
||||
| appStore | ❌ 内存 | App 全局状态 |
|
||||
|
||||
### 5.2 用户状态 (userStore)
|
||||
|
||||
```typescript
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
token: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 家庭状态 (familyStore)
|
||||
|
||||
```typescript
|
||||
interface Family {
|
||||
family_id: number;
|
||||
}
|
||||
```
|
||||
|
||||
## 六、第三方登录
|
||||
|
||||
### 6.1 QQ 登录
|
||||
|
||||
* 使用 `expo-qq` 库
|
||||
|
||||
* 需要配置 App ID: `102826474`
|
||||
|
||||
### 6.2 微信登录
|
||||
|
||||
* 使用 `expo-wechat` 库
|
||||
|
||||
* 需要配置 App ID: `wxdab3e21a1f7e392f`
|
||||
|
||||
## 七、核心业务流程
|
||||
|
||||
### 7.1 登录流程
|
||||
|
||||
```
|
||||
启动 → 检查登录状态
|
||||
├── 未登录 → 跳转 /auth/login
|
||||
│ ├── 微信登录 → 调接口 → 写入 Store → 跳转 /family/entry 或 /(tabs)/home
|
||||
│ └── QQ 登录 → 调接口 → 写入 Store → 跳转 /family/entry 或 /(tabs)/home
|
||||
│
|
||||
└── 已登录 → 跳转 /(tabs)/home
|
||||
```
|
||||
|
||||
### 7.2 家庭流程
|
||||
|
||||
```
|
||||
首次登录 → /family/entry
|
||||
├── 创建家庭 → 生成邀请码 → 保存家庭 + 成员 → 跳转 /(tabs)/home
|
||||
└── 加入家庭 → 输入邀请码 → 保存成员 → 跳转 /(tabs)/home
|
||||
```
|
||||
|
||||
### 7.3 记账流程
|
||||
|
||||
```
|
||||
点击 Tab → /finance/home
|
||||
├── 查看记录 → 列表展示
|
||||
├── 新增记账 → 点击中心 "+" → /finance/add
|
||||
│ ├── 选择类别
|
||||
│ ├── 输入金额
|
||||
│ ├── 选择日期
|
||||
│ └── 保存
|
||||
└── 数据分析 → 点击 "分析" Tab → /finance/analysis
|
||||
```
|
||||
|
||||
## 八、环境配置
|
||||
|
||||
| 环境变量 | 开发环境 | 生产环境 |
|
||||
| ------------------------ | --------------------------- | ----- |
|
||||
| EXPO\_PUBLIC\_API\_URL | <https://dev.api.com> | (待配置) |
|
||||
| EXPO\_PUBLIC\_G3\_URL | <http://118.89.70.199:9001> | (待配置) |
|
||||
| EXPO\_PUBLIC\_G3\_BUCKET | test | (待配置) |
|
||||
|
||||
## 九、关键实现细节
|
||||
|
||||
### 9.1 路由跳转
|
||||
|
||||
* 使用 `expo-router` 的 `useRouter` 和 `router.push/replace`
|
||||
|
||||
* 动态路由: `/family/member/[id].tsx`
|
||||
|
||||
### 9.2 安全区域
|
||||
|
||||
* 使用 `useSafeAreaInsets()` 处理 iOS 刘海屏和 Android 挖孔屏
|
||||
|
||||
* Navbar 和 Tabbar 绝对定位,内容区占满屏幕
|
||||
|
||||
### 9.3 动画
|
||||
|
||||
* 使用 `react-native-reanimated` 实现 Tabbar 点击缩放动画
|
||||
|
||||
* `withTiming` 控制动画时长
|
||||
|
||||
### 9.4 主题
|
||||
|
||||
* 支持浅色/深色模式
|
||||
|
||||
* 使用 `useIsDark()` hook 判断当前主题
|
||||
|
||||
## 十、后续对话需关注的重点
|
||||
|
||||
1. **API 接口**: 所有后端交互通过 `src/request/api.ts` 封装
|
||||
2. **状态管理**: 核心状态使用 Zustand + AsyncStorage 持久化
|
||||
3. **路由**: 使用 expo-router 文件约定式路由
|
||||
4. **UI 组件**: 主要使用 heroui-native 组件库
|
||||
5. **登录机制**: JWT Token 存储在 userStore,通过 http.ts 拦截器自动注入
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"Print LayoutView Scroll Example (NativeWind)": {
|
||||
"scope": "typescriptreact,javascriptreact",
|
||||
"prefix": "rn",
|
||||
"body": [
|
||||
"import React from \"react\";",
|
||||
"import LayoutView from \"@/components/layout/view\";",
|
||||
"import { Text } from \"react-native\";",
|
||||
"",
|
||||
"export default function ${1/(.*)/${1:/pascalcase}/}() {",
|
||||
" return (",
|
||||
" <LayoutView>",
|
||||
" <LayoutView.ScrollView>",
|
||||
" <Text className=\"text-base text-black\">${2:Hello}</Text>",
|
||||
" </LayoutView.ScrollView>",
|
||||
" </LayoutView>",
|
||||
" );",
|
||||
"}"
|
||||
],
|
||||
"description": "LayoutView + ScrollView 结构组件模板"
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"oxc.fmt.configPath": ".oxfmtrc.json",
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
|
||||
"tailwindCSS.classAttributes": [
|
||||
"class",
|
||||
"className",
|
||||
"headerClassName",
|
||||
"contentContainerClassName",
|
||||
"columnWrapperClassName",
|
||||
"endFillColorClassName",
|
||||
"imageClassName",
|
||||
"tintColorClassName",
|
||||
"ios_backgroundColorClassName",
|
||||
"thumbColorClassName",
|
||||
"trackColorOnClassName",
|
||||
"trackColorOffClassName",
|
||||
"selectionColorClassName",
|
||||
"cursorColorClassName",
|
||||
"underlineColorAndroidClassName",
|
||||
"placeholderTextColorClassName",
|
||||
"selectionHandleColorClassName",
|
||||
"colorsClassName",
|
||||
"progressBackgroundColorClassName",
|
||||
"titleColorClassName",
|
||||
"underlayColorClassName",
|
||||
"colorClassName",
|
||||
"drawerBackgroundColorClassName",
|
||||
"statusBarBackgroundColorClassName",
|
||||
"backdropColorClassName",
|
||||
"backgroundColorClassName",
|
||||
"ListFooterComponentClassName",
|
||||
"ListHeaderComponentClassName"
|
||||
],
|
||||
"tailwindCSS.classFunctions": ["useResolveClassNames"]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
## 2026-06-24
|
||||
|
||||
- 改进登录页 loading 交互:
|
||||
- 创建 `src/hooks/useLoading.ts`:基于 sonner-native 的 toast.loading / toast.success / toast.error / toast.dismiss 封装,提供 `startLoading`、`stopLoading`、`withLoading` 和 `isLoading` 状态。
|
||||
- 创建 `src/components/LoadingOverlay.tsx`:全屏半透明遮罩,`pointerEvents="auto"` 在 loading 时拦截页面所有点击事件。
|
||||
- 重写 `src/app/(main)/auth/login.tsx`:
|
||||
- 移除按钮级 Spinner 和 `loading` state。
|
||||
- 微信/QQ/测试登录全部使用 `useLoading` 的 `startLoading`/`stopLoading`。
|
||||
- Loading 时页面禁止点击(通过 `LoadingOverlay` 实现)。
|
||||
- 错误信息统一用 `toast.error` 显示,避免使用按钮级 loading。
|
||||
- 三轮迭代优化 `src/components/layout/tabbar.tsx` 中心按钮突起效果:
|
||||
- V1(球面版):64×64 三色强渐变 + 白色底座 + 高光 → 过于突兀复杂。
|
||||
- V2(极简版):48×48 纯色 #5FAF7B + 浅阴影 → 太扁平,无突起感。
|
||||
- V3(现代分层版,最终):50×50、top:-6、两色微渐变(#6CC587→#4D9E6A)、1.5px 白色窄边收光、底部投影椭圆、悬浮阴影(elevation:8 / shadowRadius:12 / opacity:0.12)。参考 Dimensional Layering + Soft UI Evolution 风格,有突起感但克制不过度。
|
||||
- V6(全量重构):用户反馈多次微调后仍不满意,决定从头重构整个 tabbar 组件。
|
||||
- 设计系统:参考 Apple Bento Grid 风格——20px 圆角白色条、极轻阴影、干净克制。
|
||||
- 常量驱动:ICON_SIZE=20、LABEL_SIZE=10、BAR_HEIGHT=56、BAR_RADIUS=20、CENTER_SIZE=44、CENTER_LIFT=-5、COLOR_ACTIVE=#10B981、COLOR_IDLE=#9CA3AF。
|
||||
- 大改动:
|
||||
- tabbar:圆角 999→20,不要胶囊形,更像 Apple 风格浮动条。shadow 从多层降为单层。
|
||||
- 普通 TabItem:icon 24→20,label 10px,间距 2px。选中态线宽加粗(strokeWidth 2.2 vs 1.8),视觉权重自然。
|
||||
- CenterButton:去掉 LinearGradient(纯色更适合工具类),44×44 纯色 #10B981 圆钮 + 同色系阴影(shadowColor=#10B981,opacity 0.35),top:-5 微突起。保留脉冲动画。
|
||||
- 统一 color 常量在文件顶部,后续调色改一处即可。
|
||||
- 去掉 expo-linear-gradient 依赖(不再使用渐变)。
|
||||
- V7(阴影调整失败,V8 重构):
|
||||
- 尝试用 `react-native-shadow-2` 的 `Shadow` 组件模仿 navbar 弥散阴影,导致 tabbar 渲染/样式完全错误。回退到原生阴影。
|
||||
- V8(Soft UI Evolution):参考 ui-ux-pro-max 搜索结果的 Soft UI Evolution 风格——极轻阴影、微妙深度。
|
||||
- 移除 `react-native-shadow-2` 依赖。shadowColor 统一用 `#000`,bar: shadowOpacity 0.06 / elevation 2 / shadowRadius 10;中心按钮外圈白色(58×58)+ 内圈绿色(52×52),外圈投影 shadowOpacity 0.08 / elevation 3。
|
||||
- CENTER_SIZE 44→52,CENTER_LIFT -5→-8,LABEL_SIZE 10→11,BAR_RADIUS 20→16。
|
||||
- 中心按钮结构:外圈白色圆(bar 相同材质)+ 内圈绿色圆,产生"从 bar 表面隆起"的自然嵌套感。不再使用绿色色感阴影。
|
||||
- V9(统一 shadow-2xl):
|
||||
- bar 阴影:去掉手动 shadow 属性,改用 NativeWind `shadow-2xl`(与 navbar 的 groupShadow/titleShadown 一致)。
|
||||
- 中心按钮:完全去掉阴影(centerOuter 无任何 shadow/elevation),纯白色外圈 + 绿色内圈,干净利落。
|
||||
- V10(比例协调优化):
|
||||
- 问题:52×52 绿色面积太大,22px icon 占比仅 42%,显得空。
|
||||
- 调整:CENTER_SIZE 52→42(绿色区域缩小 19%),CENTER_ICON 22→24(icon 增大),icon/绿色面积比从 42%→57%。
|
||||
- 外圈 ring 从 3px→4px 常量化(CENTER_RING=4),外圈整体 50×50。
|
||||
- CENTER_LIFT -8→-5,不飞太高。
|
||||
- V11(极简嵌入):
|
||||
- 方向转变:不再向上突起,中心按钮嵌入 bar 内居中,纯粹靠颜色区分。
|
||||
- 去掉白色外圈结构,简化为单层 40×40 纯色绿色圆。
|
||||
- icon 24,icon:button 直径比 60%(24/40),视觉饱满。
|
||||
- centerSlot 改为 justifyContent: "center",按钮在 56px bar 内垂直居中,和其他 tab 同一基线。
|
||||
- 保留脉冲动画和弹性按压缩放。
|
||||
- V12(极简化,最终):
|
||||
- 用户决定完全放弃中心突起效果。移除整个 `CenterButton` 组件(含 pulse 呼吸动画、弹性回弹、绿色背景、白圈等)。
|
||||
- 中心 tab 复用普通 `TabItem`,与其他 tab icon/label/尺寸完全一致。
|
||||
- 通过 `showMarker={tab.isCenter}` 在标签下方渲染一个 4×4 小圆点作为"中间标志",颜色跟随选中态(绿/灰)。
|
||||
- 清理 `withRepeat`、`Easing` import。移除 `centerSlot`、`centerButton` 样式。
|
||||
- 最终:用户反复调整中心 tab 的突起/标记/背景效果均不满意,最终全部去掉,五个 tab 完全一致,不再区分 center。`isCenter` 字段保留在类型中但组件不再读取。
|
||||
- 重构首页 `src/app/(main)/(tabs)/home/index.tsx`:
|
||||
- 快捷工具从列表布局改为**宫格布局**(3列 grid),icon 在上 + title 在下。
|
||||
- GridItem: 白色圆角卡片 `rounded-2xl` + `shadow-sm`,icon 区域 44×44 浅绿底(`#10B98118`),title 13px 灰色。
|
||||
- 外容器白卡包裹所有宫格 cell,统一圆角和间距(gap-2.5, p-3)。
|
||||
- 统一设计语言:所有区域卡片均用 `rounded-2xl shadow-sm`(16px 圆角 + 统一阴影)。
|
||||
- SectionTitle 复用为组件:左侧绿色竖条 + uppercase 灰色标题。
|
||||
- 最近动态也包入白卡,时间轴线条统一 `#E5E7EB`,空状态中心展示。
|
||||
- 统一颜色常量 `GREEN = "#10B981"`,用于全部 accent 色。
|
||||
- V13(微背景高亮,最终):
|
||||
- 用户认为小圆点不协调。
|
||||
- 去掉小圆点,改为中心 tab 背景包裹层微背景:
|
||||
- `showMarker` tab 使用 `centerBg` 包裹 icon+label。
|
||||
- 未选中:`rgba(16, 185, 129, 0.06)`(极淡绿,几乎不可见)。
|
||||
- 选中:`rgba(16, 185, 129, 0.12)`(微微加深,隐约可见)。
|
||||
- padding 12×6,borderRadius 12。不突兀,仅靠颜色暗示"这个 tab 是特殊的"。
|
||||
|
||||
- 用户明确要求不使用视觉常量(const GREEN 等),全部使用 Tailwind CSS 类。
|
||||
- GridItem 的 Icon 不设置 color prop,使用默认颜色。
|
||||
- 头部布局重构:问候语和辅助文字放一行,空间名单行,大数字和"天"放一行,时分秒单独一行,更紧凑不分散。字体缩小(24px 空间名 / 40px 数字)。
|
||||
- 首页重构:删除时间计时和问候语,仅保留家庭名称居中显示 (28px bold centered)。时间轴点改为 heroui-native Avatar,匹配 activities 页风格。时间轴列宽 10→28px,内容间距 pb-4→pb-6。
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["src", "scripts"];
|
||||
const exampleDir = "example";
|
||||
const newAppDir = "src/app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View, StyleSheet } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Edit src/app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /src/app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /src/app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 src/app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 src/app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
|
||||
userInput === "y"
|
||||
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import React from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
import { AnimatedSplashOverlay } from '@/components/animated-icon';
|
||||
import AppTabs from '@/components/app-tabs';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<AnimatedSplashOverlay />
|
||||
<AppTabs />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
import React from 'react';
|
||||
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { WebBadge } from '@/components/web-badge';
|
||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const insets = {
|
||||
...safeAreaInsets,
|
||||
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
|
||||
};
|
||||
const theme = useTheme();
|
||||
|
||||
const contentPlatformStyle = Platform.select({
|
||||
android: {
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
web: {
|
||||
paddingTop: Spacing.six,
|
||||
paddingBottom: Spacing.four,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.scrollView, { backgroundColor: theme.background }]}
|
||||
contentInset={insets}
|
||||
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="subtitle">Explore</ThemedText>
|
||||
<ThemedText style={styles.centerText} themeColor="textSecondary">
|
||||
This starter app includes example{'\n'}code to help you get started.
|
||||
</ThemedText>
|
||||
|
||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
||||
<Pressable style={({ pressed }) => pressed && styles.pressed}>
|
||||
<ThemedView type="backgroundElement" style={styles.linkButton}>
|
||||
<ThemedText type="link">Expo documentation</ThemedText>
|
||||
<SymbolView
|
||||
tintColor={theme.text}
|
||||
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
|
||||
size={12}
|
||||
/>
|
||||
</ThemedView>
|
||||
</Pressable>
|
||||
</ExternalLink>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.sectionsWrapper}>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText type="small">
|
||||
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="code">src/app/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText type="small">
|
||||
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
|
||||
the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
|
||||
<ThemedText type="small">
|
||||
You can open this project on Android, iOS, and the web. To open the web version,
|
||||
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
|
||||
project.
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/tutorial-web.png')}
|
||||
style={styles.imageTutorial}
|
||||
/>
|
||||
</ThemedView>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Images">
|
||||
<ThemedText type="small">
|
||||
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
|
||||
screen densities.
|
||||
</ThemedText>
|
||||
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText type="small">
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
|
||||
user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText type="small">
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
|
||||
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
|
||||
animate opening this hint.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
</ThemedView>
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
container: {
|
||||
maxWidth: MaxContentWidth,
|
||||
flexGrow: 1,
|
||||
},
|
||||
titleContainer: {
|
||||
gap: Spacing.three,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingVertical: Spacing.six,
|
||||
},
|
||||
centerText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
linkButton: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingVertical: Spacing.two,
|
||||
borderRadius: Spacing.five,
|
||||
justifyContent: 'center',
|
||||
gap: Spacing.one,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionsWrapper: {
|
||||
gap: Spacing.five,
|
||||
paddingHorizontal: Spacing.four,
|
||||
paddingTop: Spacing.three,
|
||||
},
|
||||
collapsibleContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageTutorial: {
|
||||
width: '100%',
|
||||
aspectRatio: 296 / 171,
|
||||
borderRadius: Spacing.three,
|
||||
marginTop: Spacing.two,
|
||||
},
|
||||
imageReact: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import * as Device from 'expo-device';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { AnimatedIcon } from '@/components/animated-icon';
|
||||
import { HintRow } from '@/components/hint-row';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { WebBadge } from '@/components/web-badge';
|
||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
|
||||
function getDevMenuHint() {
|
||||
if (Platform.OS === 'web') {
|
||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
||||
}
|
||||
if (Device.isDevice) {
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.heroSection}>
|
||||
<AnimatedIcon />
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
Welcome to Expo
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedText type="code" style={styles.code}>
|
||||
get started
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
||||
<HintRow
|
||||
title="Try editing"
|
||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
||||
/>
|
||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
||||
<HintRow
|
||||
title="Fresh start"
|
||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
alignItems: 'center',
|
||||
gap: Spacing.three,
|
||||
paddingBottom: BottomTabInset + Spacing.three,
|
||||
maxWidth: MaxContentWidth,
|
||||
},
|
||||
heroSection: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.four,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
code: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
stepContainer: {
|
||||
gap: Spacing.three,
|
||||
alignSelf: 'stretch',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.four,
|
||||
borderRadius: Spacing.four,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
.expoLogoBackground {
|
||||
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
|
||||
border-radius: 40px;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
import { Dimensions, StyleSheet, View } from 'react-native';
|
||||
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
|
||||
import { scheduleOnRN } from 'react-native-worklets';
|
||||
|
||||
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
|
||||
const DURATION = 600;
|
||||
|
||||
export function AnimatedSplashOverlay() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const splashKeyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{ scale: INITIAL_SCALE_FACTOR }],
|
||||
opacity: 1,
|
||||
},
|
||||
20: {
|
||||
opacity: 1,
|
||||
},
|
||||
70: {
|
||||
opacity: 0,
|
||||
easing: Easing.elastic(0.7),
|
||||
},
|
||||
100: {
|
||||
opacity: 0,
|
||||
transform: [{ scale: 1 }],
|
||||
easing: Easing.elastic(0.7),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
|
||||
'worklet';
|
||||
if (finished) {
|
||||
scheduleOnRN(setVisible, false);
|
||||
}
|
||||
})}
|
||||
style={styles.backgroundSolidColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const keyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{ scale: INITIAL_SCALE_FACTOR }],
|
||||
},
|
||||
100: {
|
||||
transform: [{ scale: 1 }],
|
||||
easing: Easing.elastic(0.7),
|
||||
},
|
||||
});
|
||||
|
||||
const logoKeyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{ scale: 1.3 }],
|
||||
opacity: 0,
|
||||
},
|
||||
40: {
|
||||
transform: [{ scale: 1.3 }],
|
||||
opacity: 0,
|
||||
easing: Easing.elastic(0.7),
|
||||
},
|
||||
100: {
|
||||
opacity: 1,
|
||||
transform: [{ scale: 1 }],
|
||||
easing: Easing.elastic(0.7),
|
||||
},
|
||||
});
|
||||
|
||||
const glowKeyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{ rotateZ: '0deg' }],
|
||||
},
|
||||
100: {
|
||||
transform: [{ rotateZ: '7200deg' }],
|
||||
},
|
||||
});
|
||||
|
||||
export function AnimatedIcon() {
|
||||
return (
|
||||
<View style={styles.iconContainer}>
|
||||
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
|
||||
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
|
||||
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
|
||||
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
glow: {
|
||||
width: 201,
|
||||
height: 201,
|
||||
position: 'absolute',
|
||||
},
|
||||
iconContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 128,
|
||||
height: 128,
|
||||
zIndex: 100,
|
||||
},
|
||||
image: {
|
||||
position: 'absolute',
|
||||
width: 76,
|
||||
height: 71,
|
||||
},
|
||||
background: {
|
||||
borderRadius: 40,
|
||||
experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
|
||||
width: 128,
|
||||
height: 128,
|
||||
position: 'absolute',
|
||||
},
|
||||
backgroundSolidColor: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#208AEF',
|
||||
zIndex: 1000,
|
||||
},
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, { Keyframe, Easing } from 'react-native-reanimated';
|
||||
|
||||
import classes from './animated-icon.module.css';
|
||||
const DURATION = 300;
|
||||
|
||||
export function AnimatedSplashOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{ scale: 0 }],
|
||||
},
|
||||
60: {
|
||||
transform: [{ scale: 1.2 }],
|
||||
easing: Easing.elastic(1.2),
|
||||
},
|
||||
100: {
|
||||
transform: [{ scale: 1 }],
|
||||
easing: Easing.elastic(1.2),
|
||||
},
|
||||
});
|
||||
|
||||
const logoKeyframe = new Keyframe({
|
||||
0: {
|
||||
opacity: 0,
|
||||
},
|
||||
60: {
|
||||
transform: [{ scale: 1.2 }],
|
||||
opacity: 0,
|
||||
easing: Easing.elastic(1.2),
|
||||
},
|
||||
100: {
|
||||
transform: [{ scale: 1 }],
|
||||
opacity: 1,
|
||||
easing: Easing.elastic(1.2),
|
||||
},
|
||||
});
|
||||
|
||||
const glowKeyframe = new Keyframe({
|
||||
0: {
|
||||
transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
|
||||
opacity: 0,
|
||||
},
|
||||
[DURATION / 1000]: {
|
||||
transform: [{ rotateZ: '0deg' }, { scale: 1 }],
|
||||
opacity: 1,
|
||||
easing: Easing.elastic(0.7),
|
||||
},
|
||||
100: {
|
||||
transform: [{ rotateZ: '7200deg' }],
|
||||
},
|
||||
});
|
||||
|
||||
export function AnimatedIcon() {
|
||||
return (
|
||||
<View style={styles.iconContainer}>
|
||||
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
|
||||
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
|
||||
<div className={classes.expoLogoBackground} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
|
||||
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
zIndex: 1000,
|
||||
position: 'absolute',
|
||||
top: 128 / 2 + 138,
|
||||
},
|
||||
imageContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
glow: {
|
||||
width: 201,
|
||||
height: 201,
|
||||
position: 'absolute',
|
||||
},
|
||||
iconContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 128,
|
||||
height: 128,
|
||||
},
|
||||
image: {
|
||||
position: 'absolute',
|
||||
width: 76,
|
||||
height: 71,
|
||||
},
|
||||
background: {
|
||||
width: 128,
|
||||
height: 128,
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||
import React from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
|
||||
export default function AppTabs() {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
return (
|
||||
<NativeTabs
|
||||
backgroundColor={colors.background}
|
||||
indicatorColor={colors.backgroundElement}
|
||||
labelStyle={{ selected: { color: colors.text } }}>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/home.png')}
|
||||
renderingMode="template"
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="explore">
|
||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/explore.png')}
|
||||
renderingMode="template"
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import {
|
||||
Tabs,
|
||||
TabList,
|
||||
TabTrigger,
|
||||
TabSlot,
|
||||
TabTriggerSlotProps,
|
||||
TabListProps,
|
||||
} from 'expo-router/ui';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
import React from 'react';
|
||||
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
|
||||
|
||||
import { ExternalLink } from './external-link';
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
|
||||
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
|
||||
export default function AppTabs() {
|
||||
return (
|
||||
<Tabs>
|
||||
<TabSlot style={{ height: '100%' }} />
|
||||
<TabList asChild>
|
||||
<CustomTabList>
|
||||
<TabTrigger name="home" href="/" asChild>
|
||||
<TabButton>Home</TabButton>
|
||||
</TabTrigger>
|
||||
<TabTrigger name="explore" href="/explore" asChild>
|
||||
<TabButton>Explore</TabButton>
|
||||
</TabTrigger>
|
||||
</CustomTabList>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
|
||||
return (
|
||||
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
|
||||
<ThemedView
|
||||
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
|
||||
style={styles.tabButtonView}>
|
||||
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
|
||||
{children}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomTabList(props: TabListProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
return (
|
||||
<View {...props} style={styles.tabListContainer}>
|
||||
<ThemedView type="backgroundElement" style={styles.innerContainer}>
|
||||
<ThemedText type="smallBold" style={styles.brandText}>
|
||||
Expo Starter
|
||||
</ThemedText>
|
||||
|
||||
{props.children}
|
||||
|
||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
||||
<Pressable style={styles.externalPressable}>
|
||||
<ThemedText type="link">Docs</ThemedText>
|
||||
<SymbolView
|
||||
tintColor={colors.text}
|
||||
name={{ ios: 'arrow.up.right.square', web: 'link' }}
|
||||
size={12}
|
||||
/>
|
||||
</Pressable>
|
||||
</ExternalLink>
|
||||
</ThemedView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabListContainer: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
padding: Spacing.three,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
innerContainer: {
|
||||
paddingVertical: Spacing.two,
|
||||
paddingHorizontal: Spacing.five,
|
||||
borderRadius: Spacing.five,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
gap: Spacing.two,
|
||||
maxWidth: MaxContentWidth,
|
||||
},
|
||||
brandText: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
tabButtonView: {
|
||||
paddingVertical: Spacing.one,
|
||||
paddingHorizontal: Spacing.three,
|
||||
borderRadius: Spacing.three,
|
||||
},
|
||||
externalPressable: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.one,
|
||||
marginLeft: Spacing.three,
|
||||
},
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
|
||||
import { Spacing } from '@/constants/theme';
|
||||
|
||||
type HintRowProps = {
|
||||
title?: string;
|
||||
hint?: ReactNode;
|
||||
};
|
||||
|
||||
export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
|
||||
return (
|
||||
<View style={styles.stepRow}>
|
||||
<ThemedText type="small">{title}</ThemedText>
|
||||
<ThemedView type="backgroundSelected" style={styles.codeSnippet}>
|
||||
<ThemedText themeColor="textSecondary">{hint}</ThemedText>
|
||||
</ThemedView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
codeSnippet: {
|
||||
borderRadius: Spacing.two,
|
||||
paddingVertical: Spacing.half,
|
||||
paddingHorizontal: Spacing.two,
|
||||
},
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { Fonts, ThemeColor } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
|
||||
themeColor?: ThemeColor;
|
||||
};
|
||||
|
||||
export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color: theme[themeColor ?? 'text'] },
|
||||
type === 'default' && styles.default,
|
||||
type === 'title' && styles.title,
|
||||
type === 'small' && styles.small,
|
||||
type === 'smallBold' && styles.smallBold,
|
||||
type === 'subtitle' && styles.subtitle,
|
||||
type === 'link' && styles.link,
|
||||
type === 'linkPrimary' && styles.linkPrimary,
|
||||
type === 'code' && styles.code,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
small: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: 500,
|
||||
},
|
||||
smallBold: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: 700,
|
||||
},
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: 500,
|
||||
},
|
||||
title: {
|
||||
fontSize: 48,
|
||||
fontWeight: 600,
|
||||
lineHeight: 52,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 32,
|
||||
lineHeight: 44,
|
||||
fontWeight: 600,
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 14,
|
||||
},
|
||||
linkPrimary: {
|
||||
lineHeight: 30,
|
||||
fontSize: 14,
|
||||
color: '#3c87f7',
|
||||
},
|
||||
code: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontWeight: Platform.select({ android: 700 }) ?? 500,
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
import { ThemeColor } from "@/constants/theme";
|
||||
import { useTheme } from "@/hooks/use-theme";
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: ThemeColor;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return <View style={[{ backgroundColor: theme[type ?? "background"] }, style]} {...otherProps} />;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
|
||||
onPress={() => setIsOpen((value) => !value)}>
|
||||
<ThemedView type="backgroundElement" style={styles.button}>
|
||||
<SymbolView
|
||||
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
|
||||
size={14}
|
||||
weight="bold"
|
||||
tintColor={theme.text}
|
||||
style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedText type="small">{title}</ThemedText>
|
||||
</Pressable>
|
||||
{isOpen && (
|
||||
<Animated.View entering={FadeIn.duration(200)}>
|
||||
<ThemedView type="backgroundElement" style={styles.content}>
|
||||
{children}
|
||||
</ThemedView>
|
||||
</Animated.View>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
pressedHeading: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
button: {
|
||||
width: Spacing.four,
|
||||
height: Spacing.four,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
marginTop: Spacing.three,
|
||||
borderRadius: Spacing.three,
|
||||
marginLeft: Spacing.four,
|
||||
padding: Spacing.four,
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { version } from 'expo/package.json';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { useColorScheme, StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from './themed-text';
|
||||
import { ThemedView } from './themed-view';
|
||||
|
||||
import { Spacing } from '@/constants/theme';
|
||||
|
||||
export function WebBadge() {
|
||||
const scheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
|
||||
v{version}
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={
|
||||
scheme === 'dark'
|
||||
? require('@/assets/images/expo-badge-white.png')
|
||||
: require('@/assets/images/expo-badge.png')
|
||||
}
|
||||
style={styles.badgeImage}
|
||||
/>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: Spacing.five,
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
versionText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
badgeImage: {
|
||||
width: 123,
|
||||
aspectRatio: 123 / 24,
|
||||
},
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import '@/global.css';
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#000000',
|
||||
background: '#ffffff',
|
||||
backgroundElement: '#F0F0F3',
|
||||
backgroundSelected: '#E0E1E6',
|
||||
textSecondary: '#60646C',
|
||||
},
|
||||
dark: {
|
||||
text: '#ffffff',
|
||||
background: '#000000',
|
||||
backgroundElement: '#212225',
|
||||
backgroundSelected: '#2E3135',
|
||||
textSecondary: '#B0B4BA',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: 'system-ui',
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: 'ui-serif',
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: 'ui-rounded',
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
default: {
|
||||
sans: 'normal',
|
||||
serif: 'serif',
|
||||
rounded: 'normal',
|
||||
mono: 'monospace',
|
||||
},
|
||||
web: {
|
||||
sans: 'var(--font-display)',
|
||||
serif: 'var(--font-serif)',
|
||||
rounded: 'var(--font-rounded)',
|
||||
mono: 'var(--font-mono)',
|
||||
},
|
||||
});
|
||||
|
||||
export const Spacing = {
|
||||
half: 2,
|
||||
one: 4,
|
||||
two: 8,
|
||||
three: 16,
|
||||
four: 24,
|
||||
five: 32,
|
||||
six: 64,
|
||||
} as const;
|
||||
|
||||
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
|
||||
export const MaxContentWidth = 800;
|
||||
@@ -1,9 +0,0 @@
|
||||
:root {
|
||||
--font-display:
|
||||
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
|
||||
Segoe UI Symbol, Noto Color Emoji;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
|
||||
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
|
||||
--font-serif: Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useTheme() {
|
||||
const scheme = useColorScheme();
|
||||
const theme = scheme === 'unspecified' ? 'light' : scheme;
|
||||
|
||||
return Colors[theme];
|
||||
}
|
||||
Generated
+1358
-1456
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
||||
nodeLinker: hoisted
|
||||
@@ -26,7 +26,7 @@ const tabs: TabbarItem[] = [
|
||||
},
|
||||
{
|
||||
id: "/tools",
|
||||
title: "",
|
||||
title: "工具",
|
||||
icon: LayoutGridIcon,
|
||||
isCenter: true,
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PressableFeedback,
|
||||
} from "heroui-native";
|
||||
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
|
||||
import { ChatMsgType } from "@/types";
|
||||
|
||||
function formatTime(ts: string) {
|
||||
if (!ts) return "";
|
||||
@@ -59,6 +60,15 @@ function ChatMessageItem({
|
||||
);
|
||||
}
|
||||
|
||||
// 系统消息
|
||||
if (item.msg_type === ChatMsgType.System) {
|
||||
return (
|
||||
<View className="items-center py-2">
|
||||
<Text className="text-xs text-gray-400">{item.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PressableFeedback
|
||||
@@ -73,7 +83,7 @@ function ChatMessageItem({
|
||||
)}
|
||||
|
||||
{/* 文本 */}
|
||||
{item.msg_type === 0 && (
|
||||
{item.msg_type === ChatMsgType.Text && (
|
||||
<View
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2",
|
||||
@@ -87,7 +97,7 @@ function ChatMessageItem({
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
{item.msg_type === 1 && item.attachments && item.attachments.length > 0 && (
|
||||
{item.msg_type === ChatMsgType.Image && 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
|
||||
@@ -101,7 +111,7 @@ function ChatMessageItem({
|
||||
)}
|
||||
|
||||
{/* 视频 */}
|
||||
{item.msg_type === 2 && item.attachments && item.attachments.length > 0 && (
|
||||
{item.msg_type === ChatMsgType.Video && 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 ? (
|
||||
@@ -113,7 +123,7 @@ function ChatMessageItem({
|
||||
)}
|
||||
|
||||
{/* 文件 */}
|
||||
{item.msg_type === 3 && item.attachments && item.attachments.length > 0 && (
|
||||
{item.msg_type === ChatMsgType.File && item.attachments && item.attachments.length > 0 && (
|
||||
<View
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2",
|
||||
@@ -135,7 +145,7 @@ function ChatMessageItem({
|
||||
)}
|
||||
|
||||
{/* 转发 */}
|
||||
{item.msg_type === 4 && item.source_data && (
|
||||
{item.msg_type === ChatMsgType.Forward && 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>
|
||||
@@ -293,7 +303,7 @@ export default function ChatPage() {
|
||||
});
|
||||
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);
|
||||
const mediaIds = rows.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File).map((r) => r.id);
|
||||
if (mediaIds.length > 0) {
|
||||
const fileRes = await loadDataApi({
|
||||
view_name: "b_file",
|
||||
|
||||
@@ -1,71 +1,72 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { loadDataApi } from "@/request/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useFamilyStore, useFavoriteStore } from "@/store";
|
||||
import {
|
||||
ChevronRight,
|
||||
Grid3X3,
|
||||
User,
|
||||
Receipt,
|
||||
Heart,
|
||||
Clock3,
|
||||
} from "lucide-react-native";
|
||||
import { PressableFeedback } from "heroui-native";
|
||||
import { useFamilyStore, useFavoriteStore } from "@/store";
|
||||
import { loadDataApi } from "@/request/api";
|
||||
import { SUB_APPS } from "@/configs/pages";
|
||||
import { useRouter } from "expo-router";
|
||||
import { loadFeeds, type FeedRecord } from "@/helpers/feed";
|
||||
import { ChevronRight, Grid3X3 } from "lucide-react-native";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
|
||||
/* ─── 问候 ─── */
|
||||
function getGreeting() {
|
||||
const h = new Date().getHours();
|
||||
if (h < 6) return "夜深了";
|
||||
if (h < 9) return "早上好";
|
||||
if (h < 12) return "上午好";
|
||||
if (h < 14) return "中午好";
|
||||
if (h < 18) return "下午好";
|
||||
return "晚上好";
|
||||
// ── 组件:Section 标签 ────────────────────────────
|
||||
function SectionLabel({
|
||||
title,
|
||||
more,
|
||||
onMore,
|
||||
}: {
|
||||
title: string;
|
||||
more?: string;
|
||||
onMore?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-1 mb-3">
|
||||
<Text className="text-[13px] font-semibold text-gray-800">
|
||||
{title}
|
||||
</Text>
|
||||
{more ? (
|
||||
<PressableFeedback onPress={onMore} className="flex-row items-center">
|
||||
<Text className="text-[12px] text-gray-400">{more}</Text>
|
||||
<ChevronRight size={14} color="#9CA3AF" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 类型 → 图标 ─── */
|
||||
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
|
||||
family: User,
|
||||
finance: Receipt,
|
||||
favorite: Heart,
|
||||
};
|
||||
|
||||
/* ─── 时间格式化 ─── */
|
||||
function formatFeedTime(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
// ── 组件:宫格项 ──────────────────────────────────
|
||||
function GridItem({
|
||||
icon: Icon,
|
||||
title,
|
||||
onPress,
|
||||
}: {
|
||||
icon: any;
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PressableFeedback
|
||||
onPress={onPress}
|
||||
className="flex-1 items-center py-3"
|
||||
>
|
||||
<Icon size={28} />
|
||||
<Text className="text-[12px] text-gray-600 mt-2">
|
||||
{title}
|
||||
</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// 🏠 首页
|
||||
// ═══════════════════════════════════════════════════
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const familyStore = useFamilyStore();
|
||||
const favoriteStore = useFavoriteStore();
|
||||
|
||||
const [family, setFamily] = useState<any>({});
|
||||
const [duration, setDuration] = useState({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
|
||||
|
||||
async function loadFamilyData() {
|
||||
const res = await loadDataApi({
|
||||
@@ -78,186 +79,54 @@ export default function Home() {
|
||||
if (res.isSuccess && res.data.length > 0) setFamily(res.data[0]);
|
||||
}
|
||||
|
||||
function calcDuration(startTime: string) {
|
||||
const start = new Date(startTime).getTime();
|
||||
const now = Date.now();
|
||||
let diff = Math.floor((now - start) / 1000);
|
||||
const days = Math.floor(diff / (3600 * 24));
|
||||
diff %= 3600 * 24;
|
||||
const hours = Math.floor(diff / 3600);
|
||||
diff %= 3600;
|
||||
const minutes = Math.floor(diff / 60);
|
||||
const seconds = diff % 60;
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFamilyData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!family.create_time) return;
|
||||
const timer = setInterval(
|
||||
() => setDuration(calcDuration(family.create_time)),
|
||||
1000,
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [family.create_time]);
|
||||
|
||||
useEffect(() => {
|
||||
const fid = familyStore.currentFamily.family_id;
|
||||
if (fid) {
|
||||
favoriteStore.fetchFavorites(fid);
|
||||
loadFeeds(fid).then(setFeeds).catch(console.error);
|
||||
}
|
||||
}, [familyStore.currentFamily.family_id]);
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<LayoutView.ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* ═══════════ 头部 ═══════════ */}
|
||||
<View className="pt-2 pb-8">
|
||||
<Text className="text-[13px] text-gray-400 tracking-wide">
|
||||
{getGreeting()}
|
||||
</Text>
|
||||
<Text className="text-[28px] font-bold text-gray-900 mt-1 tracking-tight">
|
||||
<View className="pt-8 pb-6 items-center">
|
||||
<Text className="text-[34px] font-bold text-gray-800 text-center">
|
||||
{family.name || "我的空间"}
|
||||
</Text>
|
||||
|
||||
{/* 计时 — 开放式,无卡片 */}
|
||||
<View className="mt-6">
|
||||
<View className="flex-row items-baseline">
|
||||
<Text className="text-[44px] font-bold text-accent tracking-tighter">
|
||||
{duration.days}
|
||||
</Text>
|
||||
<Text className="text-[16px] text-accent/60 font-medium ml-1.5">
|
||||
天
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm text-gray-500 mt-2">
|
||||
{String(duration.hours).padStart(2, "0")} 小时{" "}
|
||||
{String(duration.minutes).padStart(2, "0")} 分钟{" "}
|
||||
{String(duration.seconds).padStart(2, "0")} 秒
|
||||
</Text>
|
||||
|
||||
<Text className="text-[13px] text-gray-400/70 mt-1.5">
|
||||
已经在一起这么久啦
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ═══════════ 快捷工具 ═══════════ */}
|
||||
<View className="flex-row items-center mb-3">
|
||||
<View className="w-1.5 h-4 rounded-full bg-accent mr-1.5" />
|
||||
<Text className="text-[11px] text-gray-500 font-semibold tracking-wider uppercase">
|
||||
快捷工具
|
||||
</Text>
|
||||
</View>
|
||||
<SectionLabel title="快捷工具" />
|
||||
|
||||
{SUB_APPS.map((app) => {
|
||||
const Icon = app.icon;
|
||||
return (
|
||||
<PressableFeedback
|
||||
<View className="flex-row">
|
||||
{SUB_APPS.map((app) => (
|
||||
<GridItem
|
||||
key={app.id}
|
||||
icon={app.icon}
|
||||
title={app.title}
|
||||
onPress={() => router.navigate(app.id as any)}
|
||||
className="flex-row items-center bg-white rounded-xl shadow-sm mb-2 px-4 py-3.5"
|
||||
>
|
||||
<View className="w-11 h-11 rounded-xl bg-accent/10 items-center justify-center mr-3.5">
|
||||
<Icon size={22} />
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="text-[15px] font-semibold text-gray-800">
|
||||
{app.title}
|
||||
</Text>
|
||||
<Text className="text-[13px] text-gray-400 mt-0.5">
|
||||
{app.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ChevronRight size={18} color="#d1d5db" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 更多工具 */}
|
||||
<PressableFeedback
|
||||
onPress={() => router.navigate("/tools")}
|
||||
className="flex-row items-center bg-white rounded-xl shadow-sm mb-1 mt-1 px-4 py-3.5"
|
||||
>
|
||||
<View className="w-11 h-11 rounded-xl bg-gray-100 items-center justify-center mr-3.5">
|
||||
<Grid3X3 size={20} color="#9ca3af" />
|
||||
</View>
|
||||
|
||||
<Text className="flex-1 text-[15px] font-medium text-gray-500">
|
||||
更多工具
|
||||
</Text>
|
||||
|
||||
<ChevronRight size={18} color="#d1d5db" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{/* ═══════════ 最近动态 ═══════════ */}
|
||||
<View className="flex-row items-center justify-between mb-4 mt-7">
|
||||
<View className="flex-row items-center">
|
||||
<View className="w-1.5 h-4 rounded-full bg-accent mr-1.5" />
|
||||
<Text className="text-[11px] text-gray-500 font-semibold tracking-wider uppercase">
|
||||
最近动态
|
||||
</Text>
|
||||
</View>
|
||||
<PressableFeedback
|
||||
onPress={() => router.navigate("/activities")}
|
||||
className="flex-row items-center"
|
||||
>
|
||||
<Text className="text-[12px] text-gray-400">更多</Text>
|
||||
<ChevronRight size={14} color="#d1d5db" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
/>
|
||||
))}
|
||||
<GridItem
|
||||
icon={Grid3X3}
|
||||
title="更多工具"
|
||||
onPress={() => router.navigate("/tools")}
|
||||
/>
|
||||
{/* 填充空位保证对齐 */}
|
||||
{Array.from({
|
||||
length: Math.max(0, 2 - SUB_APPS.length),
|
||||
}).map((_, i) => (
|
||||
<View key={`ph-${i}`} className="flex-1" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{feeds.length === 0 ? (
|
||||
<Text className="text-sm text-gray-400 mb-6">
|
||||
暂无动态
|
||||
</Text>
|
||||
) : (
|
||||
feeds.slice(0, 3).map((item, idx) => {
|
||||
const isLast = idx === Math.min(feeds.length, 3) - 1;
|
||||
const Icon = TYPE_ICONS[item.type] || Clock3;
|
||||
const meta = item.meta ? JSON.parse(item.meta) : null;
|
||||
|
||||
return (
|
||||
<View key={item.id} className="flex-row">
|
||||
{/* 时间轴列 */}
|
||||
<View className="items-center mr-3" style={{ width: 16 }}>
|
||||
<View className="w-2 h-2 rounded-full bg-accent mt-1.5" />
|
||||
{!isLast && <View className="flex-1 w-px bg-gray-200" />}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<View className={`flex-1 ${isLast ? "" : "pb-5"}`}>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{formatFeedTime(item.create_time)}
|
||||
</Text>
|
||||
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">
|
||||
{item.content}
|
||||
</Text>
|
||||
{meta?.amount ? (
|
||||
<Text className="text-[13px] font-semibold text-accent mt-0.5">
|
||||
¥{meta.amount}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 底部留白 */}
|
||||
<View className="h-4" />
|
||||
<View className="h-8" />
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
);
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { View, Text, FlatList, ScrollView } from "react-native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { CloseButton, Button, PressableFeedback } from "heroui-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useFamilyStore } from "@/store";
|
||||
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) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
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 hasMore = feeds.length < total;
|
||||
|
||||
/* ─── 数据加载 ─── */
|
||||
const fetchFeeds = useCallback(
|
||||
async (pageNum: number, isLoadMore = false) => {
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
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, typeFilter, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
/* ─── 首次加载 & 类别/日期变更时重新加载 ─── */
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!loadingMore && hasMore) {
|
||||
fetchFeeds(page + 1, true);
|
||||
}
|
||||
}, [loadingMore, hasMore, page, fetchFeeds]);
|
||||
|
||||
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 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">
|
||||
<Icon size={14} color="#0d9488" />
|
||||
</View>
|
||||
{!isLast && <View className="flex-1 w-px bg-gray-200 mt-1" />}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<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>
|
||||
</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="absolute bottom-6 left-0 right-0 items-center">
|
||||
<CloseButton
|
||||
onPress={() => router.back()}
|
||||
iconProps={{ size: 28 }}
|
||||
style={{ padding: 24 }}
|
||||
/>
|
||||
</View>
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { Stack } from "expo-router";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { Button, LinkButton, ControlField, Checkbox, Spinner } from "heroui-native";
|
||||
import { Button, LinkButton, ControlField, Checkbox } from "heroui-native";
|
||||
import { IconQq, IconWeixin } from "@/components/iconfont";
|
||||
import { useState } from "react";
|
||||
import { FadeIn } from "react-native-reanimated";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||
import { useUserStore, useFamilyStore } from "@/store";
|
||||
import { useRouter } from "expo-router";
|
||||
import ExpoQQ from "expo-qq";
|
||||
@@ -17,8 +18,8 @@ export default function Login() {
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const familyStore = useFamilyStore();
|
||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAgreed, setIsAgreed] = useState(true);
|
||||
const [form, setForm] = useState({
|
||||
type: 0,
|
||||
@@ -38,16 +39,13 @@ export default function Login() {
|
||||
|
||||
// 微信登录
|
||||
async function handleLoginWechat() {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
if (isLoading) return;
|
||||
startLoading("正在唤起微信...");
|
||||
|
||||
// 检查微信是否安装
|
||||
const isInstalled = await ExpoWeChat.isWXAppInstalled();
|
||||
if (!isInstalled) {
|
||||
toast.warning(onLoginFinished?.errorMessage || "请先安装微信");
|
||||
setLoading(false);
|
||||
stopLoading({ type: "error", message: "请先安装微信" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,11 +59,8 @@ export default function Login() {
|
||||
|
||||
// QQ 登录
|
||||
async function handleLoginQQ() {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
if (isLoading) return;
|
||||
startLoading("正在唤起 QQ...");
|
||||
|
||||
setForm((val) => ({
|
||||
...val,
|
||||
@@ -76,12 +71,15 @@ export default function Login() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!onLoginFinished) {
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onLoginFinished.success) {
|
||||
toast.warning(onLoginFinished?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权");
|
||||
setLoading(false);
|
||||
toast.error(
|
||||
onLoginFinished?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"
|
||||
);
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,8 +87,10 @@ export default function Login() {
|
||||
try {
|
||||
const tokenInfo = await ExpoQQ.getLoginTokenInfo();
|
||||
if (!tokenInfo.openId) {
|
||||
toast.warning(tokenInfo?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权");
|
||||
setLoading(false);
|
||||
toast.error(
|
||||
tokenInfo?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"
|
||||
);
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,8 +102,7 @@ export default function Login() {
|
||||
await ExpoQQ.sendGetUserInfoReq();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,7 +138,7 @@ export default function Login() {
|
||||
useEffect(() => {
|
||||
const handleLogin = async () => {
|
||||
if (!onGetUserInfo) {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,13 +149,16 @@ export default function Login() {
|
||||
avatar: onGetUserInfo.figureurlQq_2 || onGetUserInfo.figureurl_2,
|
||||
});
|
||||
|
||||
if (!loginRes.isSuccess) return;
|
||||
if (!loginRes.isSuccess) {
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
await handlePostLogin(loginRes.data);
|
||||
} catch (err) {
|
||||
console.error("QQ登录失败", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,7 +172,7 @@ export default function Login() {
|
||||
const handleLogin = async () => {
|
||||
if (authResult.errorCode !== 0) {
|
||||
console.log("授权失败:", authResult.errorMessage);
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,13 +181,16 @@ export default function Login() {
|
||||
code: authResult.code,
|
||||
});
|
||||
|
||||
if (!loginRes.isSuccess) return;
|
||||
if (!loginRes.isSuccess) {
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
await handlePostLogin(loginRes.data);
|
||||
} catch (err) {
|
||||
console.error("微信登录失败", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,25 +198,28 @@ export default function Login() {
|
||||
}, [authResult]);
|
||||
|
||||
async function test1() {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
if (isLoading) return;
|
||||
|
||||
startLoading("正在登录...");
|
||||
try {
|
||||
const loginRes = await loginTestApi({
|
||||
user_id: 801787924787269,
|
||||
});
|
||||
|
||||
if (!loginRes.isSuccess) return;
|
||||
if (!loginRes.isSuccess) {
|
||||
stopLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
await handlePostLogin(loginRes.data);
|
||||
stopLoading();
|
||||
} catch (err) {
|
||||
console.error("测试登录失败", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
stopLoading({ type: "error", message: "登录失败" });
|
||||
}
|
||||
}
|
||||
|
||||
function test2() {};
|
||||
function test2() {}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -227,53 +235,30 @@ export default function Login() {
|
||||
className="w-20 h-20 mb-4"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text className="text-xl font-semibold text-gray-900">Welcome to Allapp</Text>
|
||||
<Text className="text-xl font-semibold text-gray-900">
|
||||
Welcome to Allapp
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col gap-4">
|
||||
<Button onPress={handleLoginWechat}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} color="white" />
|
||||
) : (
|
||||
<>
|
||||
<IconWeixin color={"#fff"} />
|
||||
<Button.Label>WeChat</Button.Label>
|
||||
</>
|
||||
)}
|
||||
<IconWeixin color={"#fff"} />
|
||||
<Button.Label>WeChat</Button.Label>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onPress={handleLoginQQ} isDisabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} />
|
||||
) : (
|
||||
<>
|
||||
<IconQq />
|
||||
<Button.Label>QQ</Button.Label>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onPress={handleLoginQQ}>
|
||||
<IconQq />
|
||||
<Button.Label>QQ</Button.Label>
|
||||
</Button>
|
||||
|
||||
|
||||
<Button variant="outline" onPress={test1} isDisabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} />
|
||||
) : (
|
||||
<>
|
||||
<IconQq />
|
||||
<Button.Label>测试1</Button.Label>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onPress={test1}>
|
||||
<IconQq />
|
||||
<Button.Label>测试1</Button.Label>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onPress={test2} isDisabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} />
|
||||
) : (
|
||||
<>
|
||||
<IconQq />
|
||||
<Button.Label>测试2</Button.Label>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onPress={test2}>
|
||||
<IconQq />
|
||||
<Button.Label>测试2</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -296,6 +281,8 @@ export default function Login() {
|
||||
</LinkButton>
|
||||
</View>
|
||||
</ControlField>
|
||||
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,16 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { SearchField, Spinner, PressableFeedback } from "heroui-native";
|
||||
import { ChatMsgType } from "@/types";
|
||||
|
||||
const MSG_TYPES = [
|
||||
{ label: "全部", value: -1 },
|
||||
{ label: "文本", value: 0 },
|
||||
{ label: "图片", value: 1 },
|
||||
{ label: "视频", value: 2 },
|
||||
{ label: "文件", value: 3 },
|
||||
{ label: "转发", value: 4 },
|
||||
{ label: "文本", value: ChatMsgType.Text },
|
||||
{ label: "图片", value: ChatMsgType.Image },
|
||||
{ label: "视频", value: ChatMsgType.Video },
|
||||
{ label: "文件", value: ChatMsgType.File },
|
||||
{ label: "转发", value: ChatMsgType.Forward },
|
||||
{ label: "系统", value: ChatMsgType.System },
|
||||
];
|
||||
|
||||
function formatTime(ts: string) {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { generateCode } from "@/utils/common";
|
||||
import { FadeIn } from "react-native-reanimated";
|
||||
import { Keyboard } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { recordFeed } from "@/helpers/feed";
|
||||
|
||||
const BottomSheetTextInput = forwardRef<any, React.ComponentProps<typeof Input>>((props, ref) => {
|
||||
const { onFocus, onBlur } = useBottomSheetAwareHandlers();
|
||||
@@ -272,9 +271,6 @@ export default function EntryFamily() {
|
||||
setShowCreate(false);
|
||||
setCreateForm({ name: "" });
|
||||
|
||||
// 记录动态
|
||||
recordFeed(familyId, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 创建了家庭「${createForm.name}」`);
|
||||
|
||||
enterFamily(familyId);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -351,9 +347,6 @@ export default function EntryFamily() {
|
||||
setShowJoin(false);
|
||||
setJoinForm({ invite_code: "" });
|
||||
|
||||
// 记录动态
|
||||
recordFeed(family.id, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 加入了家庭`);
|
||||
|
||||
enterFamily(family.id);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -35,7 +35,6 @@ import { forwardRef, useRef } from "react";
|
||||
import { FadeIn } from "react-native-reanimated";
|
||||
import DateTime from "@/components/DateTime";
|
||||
import { toDateString } from "@/utils/time";
|
||||
import { recordFeed } from "@/helpers/feed";
|
||||
|
||||
const StyledIonicons = withUniwind(Ionicons);
|
||||
|
||||
@@ -418,14 +417,6 @@ export default function UserSetting() {
|
||||
name: "",
|
||||
});
|
||||
|
||||
// 记录动态
|
||||
recordFeed(
|
||||
familyStore.currentFamily.family_id,
|
||||
userStore.userInfo.id,
|
||||
"family",
|
||||
`${userStore.userInfo.nickname} 修改了昵称为「${renameForm.name}」`,
|
||||
);
|
||||
|
||||
toast.success("名称修改成功");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import EmptyState from "@/components/EmptyStatus";
|
||||
import AZIndex from "@/components/AZIndex";
|
||||
import { useFavoriteStore, useFamilyStore, useUserStore } from "@/store";
|
||||
import { Heart } from "lucide-react-native";
|
||||
import { recordFeed } from "@/helpers/feed";
|
||||
|
||||
export default function Tools() {
|
||||
const router = useRouter();
|
||||
@@ -171,15 +170,6 @@ export default function Tools() {
|
||||
subItem.id
|
||||
);
|
||||
toast(result ? "已收藏" : "已取消收藏");
|
||||
// 记录收藏动态(仅收藏时)
|
||||
if (result) {
|
||||
recordFeed(
|
||||
familyStore.currentFamily.family_id,
|
||||
userStore.userInfo?.id ?? 0,
|
||||
"favorite",
|
||||
`${userStore.userInfo?.nickname ?? "成员"} 收藏了「${subItem.title}」`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="absolute right-4 top-0 bottom-0 justify-center px-2 z-10"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useIsDark } from "@/hooks/use-color-scheme";
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { HeroUINativeProvider } from "heroui-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
|
||||
@@ -1,241 +1,314 @@
|
||||
import { View, Text, TouchableOpacity } from "react-native";
|
||||
import { View, Text, TouchableOpacity, TextInput } from "react-native";
|
||||
import { Stack } from "expo-router";
|
||||
import PageLayout from "@/layouts/PageLayout";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { ScrollShadow, Tabs, TextField, Label, Button, Input, BottomSheet } from "heroui-native";
|
||||
import { Tabs, Button, BottomSheet, Input, Label, TextField, PressableFeedback } from "heroui-native";
|
||||
import { useFinanceStore } from "@/store/finance";
|
||||
import { useUserStore, useFamilyStore } from "@/store";
|
||||
import { getUniqueIdApi, saveDataApi } from "@/request/api";
|
||||
import IconFont from "@/components/iconfont";
|
||||
import { LegendList } from "@legendapp/list";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { FinanceType } from "@/types";
|
||||
import { getCurrentDate } from "@/utils/time";
|
||||
import DateTime from "@/components/DateTime";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||
import { toast } from "sonner-native";
|
||||
import { SearchIcon, ChevronLeft, ChevronRight } from "lucide-react-native";
|
||||
|
||||
export default function Add() {
|
||||
const { getIncomeCategories, getExpenseCategories } = useFinanceStore((state) => state);
|
||||
// ─── 简易日历 ─────────────────────────────
|
||||
function MiniCalendar({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string; // YYYY-MM-DD
|
||||
onChange: (d: string) => void;
|
||||
}) {
|
||||
const today = new Date();
|
||||
const [viewYear, setViewYear] = useState(today.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(today.getMonth() + 1);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("0");
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [showMoreCategories, setShowMoreCategories] = useState(false);
|
||||
const selected = useMemo(() => {
|
||||
const [y, m, d] = value.split("-").map(Number);
|
||||
return { y, m, d };
|
||||
}, [value]);
|
||||
|
||||
const listRef = useRef<any>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const days = useMemo(() => {
|
||||
const firstDay = new Date(viewYear, viewMonth - 1, 1).getDay();
|
||||
const daysInMonth = new Date(viewYear, viewMonth, 0).getDate();
|
||||
const result: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDay; i++) result.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) result.push(d);
|
||||
return result;
|
||||
}, [viewYear, viewMonth]);
|
||||
|
||||
const [showRecordTime, setShowRecordTime] = useState(false);
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 1) { setViewYear(viewYear - 1); setViewMonth(12); }
|
||||
else setViewMonth(viewMonth - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 12) { setViewYear(viewYear + 1); setViewMonth(1); }
|
||||
else setViewMonth(viewMonth + 1);
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<any>({
|
||||
id: 0,
|
||||
amount: "",
|
||||
record_time: getCurrentDate(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const newCategories = activeTab === "0" ? getExpenseCategories() : getIncomeCategories();
|
||||
|
||||
setCategories(newCategories);
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCategoryId) {
|
||||
listRef.current?.scrollToOffset?.({
|
||||
offset: 0,
|
||||
animated: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const item = categories.find((c) => c.id === selectedCategoryId);
|
||||
|
||||
if (!item) {
|
||||
listRef.current?.scrollToOffset?.({
|
||||
offset: 0,
|
||||
animated: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
listRef.current?.scrollToItem?.({
|
||||
item,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
});
|
||||
}, [selectedCategoryId, categories]);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
return (
|
||||
<PageLayout title="新增记账">
|
||||
<View className="bg-gray-50 rounded-xl p-3">
|
||||
{/* 月份切换 */}
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<PressableFeedback onPress={prevMonth} className="p-1">
|
||||
<ChevronLeft size={18} color="#6B7280" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
<Text className="text-sm font-semibold text-gray-700">
|
||||
{viewYear} 年 {viewMonth} 月
|
||||
</Text>
|
||||
<PressableFeedback onPress={nextMonth} className="p-1">
|
||||
<ChevronRight size={18} color="#6B7280" />
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
{/* 星期头 */}
|
||||
<View className="flex-row mb-1">
|
||||
{["日", "一", "二", "三", "四", "五", "六"].map((w) => (
|
||||
<View key={w} className="flex-1 items-center py-1">
|
||||
<Text className="text-[11px] text-gray-400">{w}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* 日期网格 */}
|
||||
<View className="flex-row flex-wrap">
|
||||
{days.map((d, i) => {
|
||||
const isToday = d === today.getDate() && viewMonth === today.getMonth() + 1 && viewYear === today.getFullYear();
|
||||
const isSel = d === selected.d && viewMonth === selected.m && viewYear === selected.y;
|
||||
return (
|
||||
<View key={i} className="w-[14.28%] items-center py-0.5">
|
||||
{d ? (
|
||||
<PressableFeedback
|
||||
onPress={() => onChange(`${viewYear}-${pad(viewMonth)}-${pad(d)}`)}
|
||||
>
|
||||
<View
|
||||
className={`w-8 h-8 rounded-full items-center justify-center ${
|
||||
isSel ? "bg-accent" : isToday ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${
|
||||
isSel ? "text-white font-semibold" : isToday ? "text-accent font-semibold" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</Text>
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
) : (
|
||||
<View className="w-8 h-8" />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 页面 ─────────────────────────────────
|
||||
export default function Add() {
|
||||
const { getExpenseCategories, getIncomeCategories } = useFinanceStore();
|
||||
const userId = useUserStore((s) => s.userInfo.id);
|
||||
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
|
||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||
|
||||
const [activeTab, setActiveTab] = useState("0"); // 0=支出 1=收入
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<any>(null);
|
||||
|
||||
const [amount, setAmount] = useState("");
|
||||
const [date, setDate] = useState(getCurrentDate());
|
||||
const [remark, setRemark] = useState("");
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const cats = activeTab === "0" ? getExpenseCategories() : getIncomeCategories();
|
||||
if (!keyword.trim()) return cats;
|
||||
return cats.filter((c: any) => c.name.includes(keyword.trim()));
|
||||
}, [activeTab, keyword, getExpenseCategories, getIncomeCategories]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setAmount("");
|
||||
setDate(getCurrentDate());
|
||||
setRemark("");
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (keepOpen: boolean) => {
|
||||
if (!amount || !selectedCategory) {
|
||||
toast.error("请选择分类并填写金额");
|
||||
return;
|
||||
}
|
||||
if (isLoading) return;
|
||||
startLoading("保存中...");
|
||||
try {
|
||||
const idRes = await getUniqueIdApi(1);
|
||||
const newId = idRes.data?.[0];
|
||||
if (!newId) { toast.error("获取ID失败"); stopLoading(); return; }
|
||||
|
||||
await saveDataApi([{
|
||||
table_name: "b_finance",
|
||||
key_field: "id",
|
||||
inserts: [{
|
||||
id: newId,
|
||||
category_id: selectedCategory.id,
|
||||
family_id: familyId,
|
||||
amount: Number(amount),
|
||||
remark,
|
||||
record_time: date,
|
||||
}],
|
||||
updates: [],
|
||||
deletes: [],
|
||||
}]);
|
||||
|
||||
const label = activeTab === "0" ? "支出" : "收入";
|
||||
toast.success(`已记${label} ¥${amount}`);
|
||||
if (keepOpen) {
|
||||
setAmount("");
|
||||
setRemark("");
|
||||
} else {
|
||||
resetForm();
|
||||
setSelectedCategory(null);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("保存失败");
|
||||
} finally {
|
||||
stopLoading();
|
||||
}
|
||||
},
|
||||
[amount, selectedCategory, activeTab, resetForm, isLoading, startLoading, stopLoading, familyId, date, remark]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout title="记一笔">
|
||||
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView bottomOffset={50}>
|
||||
<View className="flex-1">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} variant="primary">
|
||||
<Tabs.List>
|
||||
<Tabs.Indicator />
|
||||
<Tabs.Trigger value="0" className="flex-1">
|
||||
<Tabs.Label>支出</Tabs.Label>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="1" className="flex-1">
|
||||
<Tabs.Label>收入</Tabs.Label>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<View className="my-4 gap-4">
|
||||
{/* <View className="w-full items-center justify-center py-3">
|
||||
<Input className="w-full" inputMode="decimal" />
|
||||
</View> */}
|
||||
<TextField isRequired>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Label>类别</Label>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={() => {
|
||||
setShowMoreCategories(true);
|
||||
}}
|
||||
{/* 支出/收入 Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} variant="primary">
|
||||
<Tabs.List>
|
||||
<Tabs.Indicator />
|
||||
<Tabs.Trigger value="0" className="flex-1">
|
||||
<Tabs.Label>支出</Tabs.Label>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="1" className="flex-1">
|
||||
<Tabs.Label>收入</Tabs.Label>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{/* 搜索 */}
|
||||
<View className="mt-3 mb-2 flex-row items-center bg-gray-100 rounded-full px-3 h-9">
|
||||
<SearchIcon size={14} color="#9CA3AF" />
|
||||
<TextInput
|
||||
className="flex-1 ml-1.5 text-sm"
|
||||
placeholder="搜索分类..."
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 分类网格 */}
|
||||
<View className="flex-row flex-wrap">
|
||||
{allCategories.map((item: any) => {
|
||||
const isActive = selectedCategory?.id === item.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
onPress={() => setSelectedCategory(item)}
|
||||
className="w-1/4 items-center mb-4"
|
||||
>
|
||||
<View
|
||||
className={`w-14 h-14 rounded-2xl items-center justify-center ${
|
||||
isActive ? "bg-accent" : "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-sm text-gray-400">更多</Text>
|
||||
<ChevronRight size={16} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
|
||||
</View>
|
||||
<Text
|
||||
className={`text-[11px] mt-1.5 text-center ${isActive ? "font-semibold text-gray-900" : "text-gray-500"}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
|
||||
{/* 记账 BottomSheet */}
|
||||
<BottomSheet isOpen={!!selectedCategory} onClose={() => setSelectedCategory(null)}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content>
|
||||
<View className="px-4 pb-6 gap-4">
|
||||
{/* 选中的分类 */}
|
||||
{selectedCategory && (
|
||||
<View className="flex-row items-center gap-3 py-2">
|
||||
<View className="w-10 h-10 rounded-xl bg-accent/10 items-center justify-center">
|
||||
<IconFont name={selectedCategory.icon || "IconFinanceZhichu"} size={22} />
|
||||
</View>
|
||||
<Text className="text-base font-semibold text-gray-900">
|
||||
{selectedCategory.name}
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollShadow LinearGradientComponent={LinearGradient}>
|
||||
<LegendList
|
||||
ref={listRef}
|
||||
horizontal
|
||||
data={categories}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
extraData={selectedCategoryId}
|
||||
renderItem={({ item }) => {
|
||||
const isActive = selectedCategoryId === item.id;
|
||||
)}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSelectedCategoryId(item.id);
|
||||
}}
|
||||
className="items-center justify-center mr-3"
|
||||
>
|
||||
<View
|
||||
className={`w-12 aspect-square rounded-full items-center justify-center ${
|
||||
isActive ? "bg-accent" : "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
|
||||
</View>
|
||||
|
||||
<Text
|
||||
className={`text-[10px] mt-1 text-center ${
|
||||
isActive ? "font-semibold text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ScrollShadow>
|
||||
</TextField>
|
||||
{/* 金额 */}
|
||||
<TextField isRequired>
|
||||
<Label>金额</Label>
|
||||
<Input
|
||||
placeholder="0.00"
|
||||
inputMode="decimal"
|
||||
value={form.amount}
|
||||
onChangeText={(value) => {
|
||||
setForm((prev: any) => ({
|
||||
...prev,
|
||||
amount: value,
|
||||
}));
|
||||
}}
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
className="text-2xl font-bold h-14"
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
{/* 日期 — 日历 */}
|
||||
<TextField isRequired>
|
||||
<Label>日期</Label>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
console.log("open date picker");
|
||||
setShowRecordTime(true);
|
||||
}}
|
||||
>
|
||||
<View pointerEvents="none">
|
||||
<Input placeholder="请选择日期" value={form.record_time} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TextField>
|
||||
<MiniCalendar value={date} onChange={setDate} />
|
||||
|
||||
{/* 备注 */}
|
||||
<TextField>
|
||||
<Label>备注</Label>
|
||||
<Input placeholder="请输入备注" />
|
||||
<Input placeholder="写点什么..." value={remark} onChangeText={setRemark} />
|
||||
</TextField>
|
||||
</View>
|
||||
|
||||
<Button>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</LayoutView.ScrollView>
|
||||
</LayoutView>
|
||||
|
||||
<DateTime
|
||||
show={showRecordTime}
|
||||
value={form.record_time}
|
||||
onConfirm={(value) => {
|
||||
setForm((prev: any) => ({
|
||||
...prev,
|
||||
record_time: value,
|
||||
}));
|
||||
setShowRecordTime(false);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowRecordTime(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BottomSheet isOpen={showMoreCategories} onOpenChange={setShowMoreCategories}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content>
|
||||
<View className="flex-row flex-wrap">
|
||||
{categories.map((item) => {
|
||||
const isActive = selectedCategoryId === item.id;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
onPress={() => {
|
||||
setSelectedCategoryId(item.id);
|
||||
|
||||
setShowMoreCategories(false);
|
||||
}}
|
||||
className="items-center justify-center w-1/4 mb-4"
|
||||
>
|
||||
<View
|
||||
className={`w-12 aspect-square rounded-full items-center justify-center ${
|
||||
isActive ? "bg-accent" : "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
|
||||
</View>
|
||||
|
||||
<Text
|
||||
className={`text-[10px] mt-1 text-center ${
|
||||
isActive ? "font-semibold text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
{/* 按钮 */}
|
||||
<View className="flex-row gap-3 mt-2">
|
||||
<Button
|
||||
onPress={() => handleSave(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => handleSave(true)}
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
>
|
||||
<Button.Label>保存并再记</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function DateTimeRange({
|
||||
<BottomSheet isOpen={open} onOpenChange={setOpen}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content>
|
||||
<BottomSheet.Content backgroundClassName="bg-white">
|
||||
<View style={{ width: "100%", alignItems: "center" }}>
|
||||
<DateTimePicker
|
||||
mode="range"
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { Spinner } from "heroui-native";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function LoadingOverlay({ visible, text = "加载中..." }: LoadingOverlayProps) {
|
||||
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="absolute inset-0 z-50 items-center justify-center bg-black/30"
|
||||
className="absolute top-0 left-0 right-0 bottom-0 z-50 bg-black/5"
|
||||
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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import { View, StyleProp, ViewStyle, Pressable, StyleSheet } from "react-native";
|
||||
import { View, Text, StyleProp, ViewStyle, Pressable, StyleSheet } from "react-native";
|
||||
import { LucideIcon } from "lucide-react-native";
|
||||
import { cn } from "@/utils/cn";
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// ── 视觉常量 ──────────────────────────────────────
|
||||
const ICON_SIZE = 20;
|
||||
const LABEL_SIZE = 11;
|
||||
const BAR_HEIGHT = 56;
|
||||
const BAR_RADIUS = 16;
|
||||
const COLOR_ACTIVE = "#10B981";
|
||||
const COLOR_IDLE = "#9CA3AF";
|
||||
const BAR_BG = "#FFFFFF";
|
||||
|
||||
// ── 类型 ──────────────────────────────────────────
|
||||
export interface TabbarItem {
|
||||
id: string;
|
||||
icon: LucideIcon;
|
||||
@@ -14,54 +29,46 @@ export interface TabbarProps {
|
||||
tabs: TabbarItem[];
|
||||
value?: string;
|
||||
onChange?: (id: string) => void;
|
||||
|
||||
className?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
// ── 主组件 ────────────────────────────────────────
|
||||
export default function Tabbar({ tabs, value, onChange, className, style }: TabbarProps) {
|
||||
const activeTab = value;
|
||||
|
||||
const handlePress = (id: string) => {
|
||||
onChange?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={cn("m-3", className)} style={style}>
|
||||
<View className="shadow-2xl" style={styles.tabbarShadow}>
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
{tabs.map((tab) =>
|
||||
tab.isCenter ? (
|
||||
<CenterButton key={tab.id} tab={tab} onPress={() => handlePress(tab.id)} />
|
||||
) : (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={activeTab === tab.id}
|
||||
onPress={() => handlePress(tab.id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
<View className={cn("px-4", className)} style={style}>
|
||||
<View className="shadow-2xl" style={s.bar}>
|
||||
{tabs.map((tab) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
active={value === tab.id}
|
||||
onPress={() => onChange?.(tab.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 普通 Tab ──────────────────────────────────────
|
||||
function TabItem({
|
||||
tab,
|
||||
isActive,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
tab: TabbarItem;
|
||||
isActive: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const Icon = tab.icon;
|
||||
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const scaleStyle = useAnimatedStyle(() => ({
|
||||
useEffect(() => {
|
||||
scale.value = withTiming(1, { duration: 150 });
|
||||
}, [active]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
@@ -69,104 +76,62 @@ function TabItem({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={() => {
|
||||
scale.value = withTiming(0.9, { duration: 80 });
|
||||
scale.value = withTiming(0.92, { duration: 80 });
|
||||
}}
|
||||
onPressOut={() => {
|
||||
scale.value = withTiming(1, { duration: 120 });
|
||||
scale.value = withTiming(1, { duration: 150 });
|
||||
}}
|
||||
className="flex-1"
|
||||
style={s.item}
|
||||
>
|
||||
<Animated.View style={scaleStyle} className="items-center justify-center">
|
||||
<Icon size={24} color={isActive ? "#5FAF7B" : "#666"} />
|
||||
{tab.title && (
|
||||
<Animated.Text
|
||||
<Animated.View style={[s.itemInner, animatedStyle]}>
|
||||
<Icon
|
||||
size={ICON_SIZE}
|
||||
color={active ? COLOR_ACTIVE : COLOR_IDLE}
|
||||
strokeWidth={active ? 2.2 : 1.8}
|
||||
/>
|
||||
{tab.title ? (
|
||||
<Text
|
||||
style={[
|
||||
s.label,
|
||||
{
|
||||
fontSize: 10,
|
||||
marginTop: 4,
|
||||
fontWeight: isActive ? "600" : "400",
|
||||
color: isActive ? "#5FAF7B" : "#666",
|
||||
color: active ? COLOR_ACTIVE : COLOR_IDLE,
|
||||
fontWeight: active ? "600" : "400",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tab.title}
|
||||
</Animated.Text>
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function CenterButton({ tab, onPress }: { tab: TabbarItem; onPress?: () => void }) {
|
||||
const Icon = tab.icon;
|
||||
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Pressable
|
||||
className="absolute"
|
||||
onPress={onPress}
|
||||
onPressIn={() => {
|
||||
scale.value = withTiming(0.9, { duration: 80 });
|
||||
}}
|
||||
onPressOut={() => {
|
||||
scale.value = withTiming(1, { duration: 120 });
|
||||
}}
|
||||
>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#22C55E",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 8,
|
||||
borderColor: "white",
|
||||
top: -4,
|
||||
}}
|
||||
>
|
||||
{/* <Shadow
|
||||
distance={6}
|
||||
startColor="rgba(0,0,0,0.03)"
|
||||
endColor="rgba(0,0,0,0)"
|
||||
offset={[0, 2]}
|
||||
style={[styles.centerButtonShadown]}
|
||||
> */}
|
||||
<Icon size={30} color="#fff" />
|
||||
{/* </Shadow> */}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centerButtonShadown: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 999,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 0.5,
|
||||
borderColor: "rgba(255,255,255,0.6)",
|
||||
backgroundColor: "#22C55E",
|
||||
},
|
||||
tabbarShadow: {
|
||||
borderRadius: 999,
|
||||
// ── 样式 ──────────────────────────────────────────
|
||||
const s = StyleSheet.create({
|
||||
// === Bar ===
|
||||
bar: {
|
||||
height: BAR_HEIGHT,
|
||||
borderRadius: BAR_RADIUS,
|
||||
backgroundColor: BAR_BG,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
|
||||
// === Tab Item ===
|
||||
item: {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
},
|
||||
itemInner: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 0.5,
|
||||
borderColor: "rgba(255,255,255,0.6)",
|
||||
height: 56,
|
||||
backgroundColor: "#fff",
|
||||
gap: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: LABEL_SIZE,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { loadDataApi, loadDataPageApi, saveDataApi, getUniqueIdApi } from "@/request/api";
|
||||
|
||||
export type FeedType = "family" | "finance" | "favorite";
|
||||
|
||||
export interface FeedRecord {
|
||||
id: number;
|
||||
family_id: number;
|
||||
user_id: number;
|
||||
type: FeedType;
|
||||
content: string;
|
||||
meta: string | null;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
/** 写入一条动态 */
|
||||
export async function recordFeed(
|
||||
familyId: number,
|
||||
userId: number,
|
||||
type: FeedType,
|
||||
content: string,
|
||||
) {
|
||||
const idRes = await getUniqueIdApi(1);
|
||||
const feedId = idRes.data?.[0];
|
||||
if (!feedId) return;
|
||||
|
||||
await saveDataApi([
|
||||
{
|
||||
table_name: "b_family_feed",
|
||||
key_field: "id",
|
||||
inserts: [
|
||||
{
|
||||
id: feedId,
|
||||
family_id: familyId,
|
||||
user_id: userId,
|
||||
type,
|
||||
content,
|
||||
},
|
||||
],
|
||||
updates: [],
|
||||
deletes: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** 加载家庭动态列表 */
|
||||
export async function loadFeeds(familyId: number): Promise<FeedRecord[]> {
|
||||
const res = await loadDataApi({
|
||||
view_name: "b_family_feed",
|
||||
search_condition: "family_id = ?",
|
||||
order_by: "create_time DESC",
|
||||
search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
|
||||
args: [familyId],
|
||||
});
|
||||
return (res.data || []) as FeedRecord[];
|
||||
}
|
||||
|
||||
/** 分页加载家庭动态列表 */
|
||||
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,61 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
interface StopOptions {
|
||||
type?: "success" | "error" | "none";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function useLoading() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const toastIdRef = useRef<string | number | null>(null);
|
||||
|
||||
const startLoading = useCallback((message: string = "加载中...") => {
|
||||
setIsLoading(true);
|
||||
const id = toast.loading(message);
|
||||
toastIdRef.current = id;
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const stopLoading = useCallback((options?: StopOptions) => {
|
||||
setIsLoading(false);
|
||||
const id = toastIdRef.current;
|
||||
if (id) {
|
||||
if (options?.type === "success") {
|
||||
toast.success(options.message || "操作成功", { id });
|
||||
} else if (options?.type === "error") {
|
||||
toast.error(options.message || "操作失败", { id });
|
||||
} else {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
toastIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const withLoading = useCallback(
|
||||
async <T,>(
|
||||
promise: Promise<T>,
|
||||
loadingMessage: string = "加载中...",
|
||||
successMessage?: string,
|
||||
errorMessage?: string
|
||||
): Promise<T> => {
|
||||
const id = startLoading(loadingMessage);
|
||||
try {
|
||||
const result = await promise;
|
||||
stopLoading({ type: "success", message: successMessage || "操作成功" });
|
||||
return result;
|
||||
} catch (error) {
|
||||
stopLoading({ type: "error", message: errorMessage || "操作失败" });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[startLoading, stopLoading]
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
withLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 聊天消息类型 */
|
||||
export enum ChatMsgType {
|
||||
/** 文本 */
|
||||
Text = 0,
|
||||
/** 图片 */
|
||||
Image = 1,
|
||||
/** 视频 */
|
||||
Video = 2,
|
||||
/** 文件 */
|
||||
File = 3,
|
||||
/** 转发(模块内容分享) */
|
||||
Forward = 4,
|
||||
/** 系统消息(加入家庭、改名、记账、收藏等) */
|
||||
System = 5,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 附件文件类型 */
|
||||
export enum FileType {
|
||||
/** 图片 */
|
||||
Image = 1,
|
||||
/** 视频 */
|
||||
Video = 2,
|
||||
/** 文件 */
|
||||
File = 3,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 记账类型 */
|
||||
export enum FinanceType {
|
||||
/** 支出 */
|
||||
Expense = 0,
|
||||
/** 收入 */
|
||||
Income = 1,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ChatMsgType } from "./chat";
|
||||
export { FileType } from "./file";
|
||||
export { FinanceType } from "./finance";
|
||||
@@ -1,264 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>附件组件(最终版)</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
/* grid */
|
||||
.grid {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* card */
|
||||
.card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.card.selected {
|
||||
background: #eaf2ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
/* checkbox(左上) */
|
||||
.check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* 状态(右上) */
|
||||
.status {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* thumb */
|
||||
.thumb {
|
||||
height: 90px;
|
||||
background: #f0f2f5;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width:100%;
|
||||
height:100%;
|
||||
object-fit:cover;
|
||||
}
|
||||
|
||||
/* info */
|
||||
.info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions button:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
bottom: 35px;
|
||||
right: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more-menu div {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more-menu div:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<script>
|
||||
const files = [
|
||||
{ id: 1, name: "合同.pdf", type: "doc", size: "800KB", date: "2026-04-25" },
|
||||
{ id: 2, name: "设计图.png", type: "img", url: "https://picsum.photos/400/300?1", size: "1.2MB", date: "2026-04-24", status: "未上传" }
|
||||
];
|
||||
|
||||
let selected = new Set();
|
||||
|
||||
function render() {
|
||||
const grid = document.getElementById("grid");
|
||||
grid.innerHTML = "";
|
||||
|
||||
files.forEach(f => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card" + (selected.has(f.id) ? " selected" : "");
|
||||
|
||||
card.onclick = () => toggleSelect(f.id);
|
||||
|
||||
/* checkbox */
|
||||
const check = document.createElement("input");
|
||||
check.type = "checkbox";
|
||||
check.className = "check";
|
||||
check.checked = selected.has(f.id);
|
||||
|
||||
check.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(f.id);
|
||||
};
|
||||
|
||||
/* 状态 */
|
||||
if (f.status) {
|
||||
const st = document.createElement("div");
|
||||
st.className = "status";
|
||||
st.innerText = f.status;
|
||||
card.appendChild(st);
|
||||
}
|
||||
|
||||
/* thumb */
|
||||
const thumb = document.createElement("div");
|
||||
thumb.className = "thumb";
|
||||
|
||||
if (f.type === "img") {
|
||||
thumb.innerHTML = `<img src="${f.url}">`;
|
||||
} else {
|
||||
thumb.innerText = "📄";
|
||||
}
|
||||
|
||||
/* info */
|
||||
const info = document.createElement("div");
|
||||
info.className = "info";
|
||||
info.innerHTML = `
|
||||
<div class="name">${f.name}</div>
|
||||
<div class="meta">${f.date} · ${f.size}</div>
|
||||
`;
|
||||
|
||||
/* actions */
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
|
||||
const btnView = createBtn("预览", () => preview(f));
|
||||
const btnDown = createBtn("下载", () => alert("下载 " + f.name));
|
||||
const btnDelete = createBtn("删除", () => deleteFile(f.id), "danger");
|
||||
|
||||
/* 更多 */
|
||||
const btnMore = document.createElement("button");
|
||||
btnMore.innerText = "⋯";
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "more-menu";
|
||||
|
||||
menu.innerHTML = `
|
||||
<div>重命名</div>
|
||||
<div>查看详情</div>
|
||||
`;
|
||||
|
||||
btnMore.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
menu.style.display = menu.style.display === "block" ? "none" : "block";
|
||||
};
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
menu.style.display = "none";
|
||||
});
|
||||
|
||||
actions.append(btnView, btnDown, btnDelete, btnMore);
|
||||
|
||||
card.append(check, thumb, info, actions, menu);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function createBtn(text, fn, cls="") {
|
||||
const btn = document.createElement("button");
|
||||
btn.innerText = text;
|
||||
btn.className = cls;
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
return btn;
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
render();
|
||||
}
|
||||
|
||||
function preview(f) {
|
||||
alert("预览 " + f.name);
|
||||
}
|
||||
|
||||
function deleteFile(id) {
|
||||
if (!confirm("确定删除?")) return;
|
||||
const i = files.findIndex(f => f.id === id);
|
||||
if (i > -1) files.splice(i, 1);
|
||||
selected.delete(id);
|
||||
render();
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user