20260623212917

This commit is contained in:
oneao committed 2026-06-23 21:29:18 +08:00
1 parent e9e3371be7
commit 056cd4970f
30 files changed
+2093 -636

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
# 2026-06-23 工作日志
## Skills 配置
- 将 `heroui-native` skill 从临时目录安装到用户级 (`~/.workbuddy/skills/`) 和项目级 (`项目/.workbuddy/skills/`)
- 项目中仅使用两个 skills:`heroui-native` 和 `allapp-conventions`
- heroui-native 来源:通过 `npx skills` 安装的 `heroui-inc/heroui` 仓库,原始路径在临时目录 `C:\Users\admin\AppData\Local\Temp\skills-oUbMIJ\`
## 前端页面优化
### tools/index.tsx — 多轮迭代
- 去掉卡片背景(`bg-white shadow-sm`),极简化
- 图标保持原生色,不加彩色圆底
- 收藏:用 `Heart`(非 `Star`)图标,18px 右上角独立触摸,teal 实心/灰色空心
- 图标 26→32px,文字 11→13px,整体放大更利于触摸
### home/index.tsx — 重构
- 宫格布局 → 横向功能卡片(图标圆底 + 标题 + 描述 + 右箭头)
- 计时数字层级化:天 `text-3xl` 主力,时分降级
- 去掉卡片背景,与 tools 页风格统一
- 更多工具独立卡片,用 `Grid3X3` 图标
### 项目目录结构重组
- 主应用所有页面收进 `(main)/` route group:`(tabs)/`, `auth/`, `family/`, `tools/`
- 子应用 `finance/` 保持独立在顶层
- 删除未用的 `star/` 占位页
- 路由引用 `/(tabs)/home` → `/home`(4 处更新)
- 最终目录:`app/(main)/*` + `app/finance/*`,主副清晰分离
### 最近动态功能 — 完整实现
**数据库:**
- 建表 `b_feed`(动态),字段:id, family_id, user_id, type, content, meta(VARCHAR), create_time, update_time, create_by, update_by
- 插入 7 条模拟数据(加入家庭/修改昵称/记账/收藏工具)
**前端新增:**
- `src/helpers/feed.ts` — `recordFeed()` 写入、`loadFeeds()` 查询,全程用通用接口
- `src/app/(main)/activities/index.tsx` — 完整动态列表页,时间轴布局(图标圆底 + 竖线 + 内容)
- 首页 `/activities` 路由,CloseButton 关闭
**埋点位置(4 处):**
- `tools/index.tsx` — 收藏工具时 → `favorite_app`
- `family/entry.tsx` — 创建家庭 → `join_family`(xxx 创建了家庭)
- `family/entry.tsx` — 加入家庭 → `join_family`(xxx 加入了家庭)
- `family/user.tsx` — 修改昵称 → `change_nickname`(旧名 修改了昵称为「新名」)
**首页改造:**
- 最近动态区从 Demo 数据 → 真实 `loadFeeds` 加载,展示最新 3 条
- 右上角「更多 →」入口跳转 `/activities`
- 时间格式化:刚刚 / N分钟前 / N小时前 / N天前 / 日期
### 动态功能优化 — 表名 + 昵称修复
**表名重命名:**
- `b_feed` → `b_family_feed`(统一 `b_family_*` 风格)
- `feed.ts` 中 `table_name` / `view_name` 同步更新
**昵称来源修正:**
- 新增 `getFamilyMemberNickname(familyId, userId)` — 从 `b_family_member.nickname` 查询
- 所有 `recordFeed()` 调用处改用此函数,不再从 `userStore.userInfo.nickname` 取
- `family/user.tsx` 改名时同步更新 `b_family_member.nickname`(之前只更新 `b_user.name`)
**Skill 更新:**
- `allapp-conventions` 新增「家庭动态记录规范」章节,含 5 种触发场景、昵称来源规则、代码示例
- 目录结构修正为 `(main)/` route group
### 动态 type 简化 + content 占位符化
**type 简码:**
- `join_family` → `join`,`change_nickname` → `rename`,`add_finance` → `bill`,`favorite_app` → `fav`
- 数据库存量数据已批量 UPDATE,前端 `TYPE_ICONS` 全部更新
**content 占位符:**
- content 存储模板 `{nickname} 加入了家庭`,不再拼接具体昵称
- `loadFeeds()` 内部通过 `getFamilyMemberNickname()` 动态替换 `{nickname}` 为 `b_family_member.nickname` 最新值
- 改名后历史动态自动显示新昵称,彻底解决写死问题
- `recordFeed()` 调用处不再需要 `getFamilyMemberNickname`,只需传模板字符串
- `entry.tsx` / `user.tsx` / `tools/index.tsx` 的 `getFamilyMemberNickname` import 已移除
### 动态 meta 字段清理
**原则变更**:content 自包含,不依赖 meta。`recordFeed()` 不再接收 meta 参数,所有展示信息直接写入 content。
**改动**:
- `helpers/feed.ts` — `recordFeed` 签名去掉 `meta?` 参数,inserts 不再写 meta 字段
- `tools/index.tsx` — fav 调用去掉 `{ app_name: subItem.title }` 冗余 meta
- `family/user.tsx` — rename content 改为 `{nickname} 修改了昵称为「{新昵称}」`
- `activities/index.tsx` — 去掉 meta JSON.parse + amount 显示逻辑
- 数据库:7 条存量记录 meta 全部置 NULL,bill 记录金额已嵌入 content(如「午餐」¥35)
- Skill 更新:content 模板表同步,新增「content 自包含原则」说明
### tools 卡片样式
- item 加 `bg-white rounded-xl shadow-sm`,去底部分割线
- 左右间距 `mx-3`;标题 + 描述双行布局,`items-center` 垂直居中
### DateTime/DateTimeRange 组件重构
- **替换依赖**:`@yz1311/react-native-wheel-picker` → `react-native-dates-picker`(纯 JS、样式可控)
- `DateTime.tsx` — `mode="single"` + `initialView` 映射(year/month/day)+ `timePicker`
- `DateTimeRange.tsx` — `mode="range"` 原生支持起止日期选择,代码大幅简化(去掉 active/toggle 逻辑)
- 选中色 `selectedItemColor="#0d9488"` 与 teal 主题统一
- BottomSheet 包裹保留
- `chat/index.tsx` 写入完整测试代码(5 种 DateTime 模式 + DateTimeRange)
### 记账页面重构
- 页面分为上下两部分:上 — 大字号金额(40px bold,点击可输入)+ 日期行(文字 + ChevronDown 图标);下 — 类别网格(一行 5 个,flex-wrap)
- 金额使用 `TextInput` 内联,点击切换显示/输入态,`inputMode="decimal"`
- 日期不包在 Input 里,改为 `bg-gray-50 rounded-xl` 行 + 右侧 `ChevronDown`
- 类别区域顶部加本地搜索框(`useMemo` 过滤 `name.includes`),无 API
- 去掉横向滚动的 `LegendList`,改为 `flex-row flex-wrap` 的 `View`
- BottomSheet "更多"面板保留,同样含搜索框 + 5 列网格
@@ -44,10 +44,13 @@ app-rn/ # React Native 前端
├── src/
│ ├── app/ # Expo Router 页面
│ │ ├── _layout.tsx # 根布局
│ │ ├── (tabs)/ # 主 Tab(home / space / tools / chat / mine)
│ │ ├── auth/ # 登录页
│ │ ├── tools/ # 工具广场
│ │ └── finance/ # 记账子模块
│ │ ├── (main)/ # 主应用(route group,不影响 URL)
│ │ │ ├── (tabs)/ # 主 Tab(home / chat / my)
│ │ │ ├── auth/ # 登录页
│ │ │ ├── family/ # 家庭管理
│ │ │ ├── tools/ # 工具广场
│ │ │ └── activities/ # 动态列表
│ │ └── finance/ # 记账子模块(独立于 main)
│ ├── components/ # 通用组件(layout/view / AZIndex 等)
│ ├── configs/ # pages.ts(子应用列表配置)
│ ├── layouts/ # 布局(ModuleLayout 等)
@@ -188,11 +191,47 @@ b_finance - 记账记录
b_finance_category - 记账分类
b_finance_category_default - 默认分类模板
b_app_favorite - 子应用收藏
b_family_feed - 家庭动态
```
视图:`v_family_member`(join b_family_member + b_user)
## 常见操作示例
## 家庭动态(b_family_feed)记录规范
以下场景**必须**调用 `recordFeed()` 写入动态。
**type 简码对照**:`join`(加入/创建)、`rename`(改名)、`bill`(记账)、`fav`(收藏)
| 触发场景 | type | content 模板 | 触发位置 |
|---------|------|-------------|---------|
| 创建家庭 | `join` | `{nickname} 创建了家庭「{名称}」` | `family/entry.tsx` handleCreate |
| 加入家庭 | `join` | `{nickname} 加入了家庭` | `family/entry.tsx` handleJoin |
| 修改昵称 | `rename` | `{nickname} 修改了昵称为「{新昵称}」` | `family/user.tsx` handleRename |
| 记账 | `bill` | `{nickname} 记了一笔「{备注}」¥{金额}` | `finance/add/` 保存成功 |
| 收藏应用 | `fav` | `{nickname} 收藏了「{应用名}」` | `tools/index.tsx` 收藏操作 |
**⚠️ content 自包含原则**:所有展示信息直接写在 content 字符串中,不依赖 meta 字段。`recordFeed()` 不接收 meta 参数,`loadFeeds()` 只做 `{nickname}` 替换。meta 列保留但始终为 NULL。
**content 占位符规则**:content 存储模板字符串,用户昵称用 `{nickname}` 占位,**不在写入时拼接**。`loadFeeds()` 会自动将 `{nickname}` 替换为 `b_family_member.nickname` 的最新值,确保改名后历史动态也显示最新昵称。
```ts
import { recordFeed } from "@/helpers/feed";
// 正确写法 — 用 {nickname} 占位符,不拼接昵称
recordFeed(familyId, userId, "join", "{nickname} 加入了家庭");
// 错误写法 — 禁止拼接昵称写死
recordFeed(familyId, userId, "join", `${nick} 加入了家庭`); // ❌
```
**修改昵称时同步更新**:`family/user.tsx` 的 `handleRename` 除了更新 `b_user.name`,还必须同步更新 `b_family_member.nickname`:
```ts
saveDataApi([
{ table_name: "b_user", updates: [{ id: uid, name: newName }] },
{ table_name: "b_family_member", updates: [{ id: memberId, nickname: newName }] },
]);
```
### 前端:拉取数据
```ts
@@ -0,0 +1,199 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an approval
from the project maintainers before using the Apache License.
Copyright 2025 NextUI Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,229 @@
---
name: heroui-native
description: "HeroUI Native component library for React Native (Tailwind v4 via Uniwind). Use when building mobile UIs with HeroUI Native — creating Buttons, Cards, TextFields, Dialogs; installing heroui-native; configuring dark/light themes; or fetching component docs. Keywords: HeroUI Native, heroui-native, React Native UI, Uniwind, mobile components."
metadata:
author: heroui
version: "2.0.1"
---
# HeroUI Native Development Guide
HeroUI Native is a component library built on **Uniwind (Tailwind CSS for React Native)** and **React Native**, providing accessible, customizable UI components for mobile applications.
---
## Installation
```bash
curl -fsSL https://heroui.com/install | bash -s heroui-native
```
---
## CRITICAL: Native Only - Do Not Use Web Patterns
**This guide is for HeroUI Native ONLY.** Do NOT apply HeroUI React (web) patterns — the package, styling engine, and color format all differ:
| Feature | React (Web) | Native (Mobile) |
| ------------ | -------------------- | ----------------------------------- |
| **Styling** | Tailwind CSS v4 | Uniwind (Tailwind for React Native) |
| **Colors** | oklch format | HSL format |
| **Package** | `@heroui/react` | `heroui-native` |
| **Platform** | Web browsers | iOS & Android |
```tsx
// CORRECT — Native pattern
import { Button } from "heroui-native";
<Button variant="primary" onPress={() => console.log("Pressed!")}>
Click me
</Button>;
```
**Always fetch Native docs before implementing.**
---
## Core Principles
- Semantic variants (`primary`, `secondary`, `tertiary`) over visual descriptions
- Composition over configuration (compound components)
- Theme variables with HSL color format
- React Native StyleSheet patterns with Uniwind utilities
---
## Accessing Documentation & Component Information
**For component details, examples, props, and implementation patterns, always fetch documentation:**
### Using Scripts
```bash
# List all available components
node scripts/list_components.mjs
# Get component documentation (MDX)
node scripts/get_component_docs.mjs Button
node scripts/get_component_docs.mjs Button Card TextField
# Get theme variables
node scripts/get_theme.mjs
# Get non-component docs (guides, releases)
node scripts/get_docs.mjs /docs/native/getting-started/theming
```
### Direct MDX URLs
Component docs: `https://heroui.com/docs/native/components/{component-name}.mdx`
Examples:
- Button: `https://heroui.com/docs/native/components/button.mdx`
- Dialog: `https://heroui.com/docs/native/components/dialog.mdx`
- TextField: `https://heroui.com/docs/native/components/text-field.mdx`
Getting started guides: `https://heroui.com/docs/native/getting-started/{topic}.mdx`
**Important:** Always fetch component docs before implementing. The MDX docs include complete examples, props, anatomy, and API references.
---
## Installation Essentials
### Quick Install
```bash
npm i heroui-native react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
```
### Framework Setup (Expo - Recommended)
1. **Install dependencies:**
```bash
npx create-expo-app MyApp
cd MyApp
npm i heroui-native uniwind tailwindcss
npm i react-native-reanimated react-native-gesture-handler react-native-safe-area-context @gorhom/bottom-sheet react-native-svg react-native-worklets tailwind-merge tailwind-variants
```
2. **Create `global.css`:**
```css
@import "tailwindcss";
@import "uniwind";
@import "heroui-native/styles";
@source "./node_modules/heroui-native/lib";
```
3. **Wrap app with providers:**
```tsx
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { HeroUINativeProvider } from "heroui-native";
import "./global.css";
export default function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<HeroUINativeProvider>
<App />
</HeroUINativeProvider>
</GestureHandlerRootView>
);
}
```
### Critical Setup Requirements
1. **Uniwind is Required** - HeroUI Native uses Uniwind (Tailwind CSS for React Native)
2. **HeroUINativeProvider Required** - Wrap your app with `HeroUINativeProvider`
3. **GestureHandlerRootView Required** - Wrap with `GestureHandlerRootView` from react-native-gesture-handler
4. **Use Compound Components** - Components use compound structure (e.g., `Card.Header`, `Card.Body`)
5. **Use onPress, not onClick** - React Native uses `onPress` event handlers
6. **Platform-Specific Code** - Use `Platform.OS` for iOS/Android differences
---
## Component Patterns
HeroUI Native uses **compound component patterns**. Each component has subcomponents accessed via dot notation.
**Example - Card:**
```tsx
<Card>
<Card.Header>{/* Icons, badges */}</Card.Header>
<Card.Body>
<Card.Title>Title</Card.Title>
<Card.Description>Description</Card.Description>
</Card.Body>
<Card.Footer>{/* Actions */}</Card.Footer>
</Card>
```
**Key Points:**
- Always use compound structure - don't flatten to props
- Subcomponents are accessed via dot notation (e.g., `Card.Header`)
- Native Card uses `Card.Body` (not `Card.Content`); Title and Description go inside Body
- **Fetch component docs for complete anatomy and examples**
---
## Semantic Variants
HeroUI uses semantic naming to communicate functional intent:
| Variant | Purpose | Usage |
| ------------- | --------------------------------- | -------------- |
| `primary` | Main action to move forward | 1 per context |
| `secondary` | Alternative actions | Multiple |
| `tertiary` | Dismissive actions (cancel, skip) | Sparingly |
| `danger` | Destructive actions | When needed |
| `danger-soft` | Soft destructive actions | Less prominent |
| `ghost` | Low-emphasis actions | Minimal weight |
| `outline` | Secondary actions | Bordered style |
**Don't use raw colors** - semantic variants adapt to themes and accessibility.
---
## Theming
HeroUI Native uses CSS variables via Tailwind/Uniwind for theming. Theme colors are defined in `global.css`:
```css
@theme {
--color-accent: hsl(260, 100%, 70%);
--color-accent-foreground: hsl(0, 0%, 100%);
}
```
**Get current theme variables:**
```bash
node scripts/get_theme.mjs
```
**Access theme colors programmatically:**
```tsx
import { useThemeColor } from "heroui-native";
const accentColor = useThemeColor("accent");
```
**Theme switching (Light/Dark Mode):**
```tsx
import { Uniwind, useUniwind } from "uniwind";
const { theme } = useUniwind();
Uniwind.setTheme(theme === "light" ? "dark" : "light");
```
For detailed theming, fetch: `https://heroui.com/docs/native/getting-started/theming.mdx`
@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Get complete component documentation (MDX) for HeroUI Native components.
*
* Usage:
* node get_component_docs.mjs Button
* node get_component_docs.mjs Button Card TextField
*
* Output:
* MDX documentation including imports, usage, variants, props, examples
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const FALLBACK_BASE = "https://heroui.com";
const APP_PARAM = "app=native-skills";
/**
* Convert PascalCase to kebab-case.
*/
function toKebabCase(name) {
return name
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
}
/**
* Fetch data from HeroUI Native API with app parameter for analytics.
*/
async function fetchApi(endpoint, method = "GET", body = null) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const options = {
headers: {
"Content-Type": "application/json",
"User-Agent": "HeroUI-Native-Skill/1.0",
},
method,
signal: AbortSignal.timeout(30000),
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
return null;
}
return await response.json();
} catch {
return null;
}
}
/**
* Fetch MDX directly from v3.heroui.com as fallback.
*/
async function fetchFallback(component) {
const kebabName = toKebabCase(component);
const url = `${FALLBACK_BASE}/docs/native/components/${kebabName}.mdx`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return {component, error: `Failed to fetch docs for ${component}`};
}
const content = await response.text();
return {
component,
content,
contentType: "mdx",
source: "fallback",
url,
};
} catch {
return {component, error: `Failed to fetch docs for ${component}`};
}
}
/**
* Main function to get component documentation.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_component_docs.mjs <Component1> [Component2] ...");
console.error("Example: node get_component_docs.mjs Button Card");
process.exit(1);
}
const components = args;
// Try API first - use POST /v1/components/docs for batch requests
console.error(`# Fetching Native docs for: ${components.join(", ")}...`);
const data = await fetchApi("/v1/components/docs", "POST", {components});
if (data && data.results) {
// Output results
if (data.results.length === 1) {
// Single component - output content directly for easier reading
const result = data.results[0];
if (result.content) {
console.log(result.content);
} else if (result.error) {
console.error(`# Error for ${result.component}: ${result.error}`);
console.log(JSON.stringify(result, null, 2));
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
// Multiple components - output as JSON array
console.log(JSON.stringify(data, null, 2));
}
return;
}
// Fallback to individual component fetches
console.error("# API failed, using fallback...");
const results = [];
for (const component of components) {
const result = await fetchFallback(component);
results.push(result);
}
// Output results
if (results.length === 1) {
// Single component - output content directly for easier reading
const result = results[0];
if (result.content) {
console.log(result.content);
} else {
console.log(JSON.stringify(result, null, 2));
}
} else {
// Multiple components - output as JSON array
console.log(JSON.stringify(results, null, 2));
}
}
main();
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Get non-component HeroUI Native documentation (guides, theming, releases).
*
* Usage:
* node get_docs.mjs /docs/native/getting-started/theming
* node get_docs.mjs /docs/native/releases/beta-12
*
* Output:
* MDX documentation content
*
* Note: For component docs, use get_component_docs.mjs instead.
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const FALLBACK_BASE = "https://heroui.com";
const APP_PARAM = "app=native-skills";
/**
* Fetch documentation from HeroUI Native API.
* Uses v1 endpoint pattern: /v1/docs/:path
*/
async function fetchApi(path) {
// The v1 API expects path without /docs/ prefix
// Input: /docs/native/getting-started/theming
// API expects: native/getting-started/theming (route is /v1/docs/:path(*))
let apiPath = path.startsWith("/docs/")
? path.slice(6) // Remove /docs/ prefix
: path.startsWith("/")
? path.slice(1) // Remove leading /
: path;
const separator = "?";
const url = `${API_BASE}/v1/docs/${apiPath}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Fetch MDX directly from v3.heroui.com as fallback.
*/
async function fetchFallback(path) {
// Ensure path starts with /docs and ends with .mdx
let cleanPath = path.replace(/^\//, "");
if (!cleanPath.endsWith(".mdx")) {
cleanPath = `${cleanPath}.mdx`;
}
const url = `${FALLBACK_BASE}/${cleanPath}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return {error: `HTTP ${response.status}: ${response.statusText}`, path};
}
const content = await response.text();
return {
content,
contentType: "mdx",
path,
source: "fallback",
url,
};
} catch (error) {
return {error: `Fetch Error: ${error.message}`, path};
}
}
/**
* Main function to get documentation for specified path.
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node get_docs.mjs <path>");
console.error("Example: node get_docs.mjs /docs/native/getting-started/theming");
console.error();
console.error("Available paths include:");
console.error(" /docs/native/getting-started/theming");
console.error(" /docs/native/getting-started/colors");
console.error(" /docs/native/getting-started/styling");
console.error(" /docs/native/releases/beta-12");
console.error();
console.error("Note: For component docs, use get_component_docs.mjs instead.");
process.exit(1);
}
const path = args[0];
// Check if user is trying to get component docs
if (path.includes("/components/")) {
console.error("# Warning: Use get_component_docs.mjs for component documentation.");
const componentName = path.split("/").pop().replace(".mdx", "");
const titleCase = componentName.charAt(0).toUpperCase() + componentName.slice(1);
console.error(`# Example: node get_component_docs.mjs ${titleCase}`);
}
// Validate Native path
if (!path.startsWith("/docs/native/")) {
console.error("# Warning: Native documentation paths should start with /docs/native/");
console.error(`# Provided path: ${path}`);
}
console.error(`# Fetching Native documentation for ${path}...`);
// Try API first
const data = await fetchApi(path);
if (data && data.content) {
data.source = "api";
console.log(data.content);
return;
}
// Fallback to direct fetch
console.error("# API failed, using fallback...");
const fallbackData = await fetchFallback(path);
if (fallbackData.content) {
console.log(fallbackData.content);
} else {
console.log(JSON.stringify(fallbackData, null, 2));
process.exit(1);
}
}
main();
@@ -0,0 +1,222 @@
#!/usr/bin/env node
/**
* Get theme variables and design tokens for HeroUI Native.
*
* Usage:
* node get_theme.mjs
*
* Output:
* Theme variables organized by light/dark with HSL color format
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const APP_PARAM = "app=native-skills";
// Fallback theme reference when API is unavailable
const FALLBACK_THEME = {
borderRadius: {
full: 9999,
lg: 12,
md: 8,
sm: 6,
},
dark: {
colors: [
{
category: "base",
name: "--color-background",
value: "hsl(0, 0%, 14.5%)",
},
{
category: "semantic",
name: "--color-foreground",
value: "hsl(0, 0%, 98.4%)",
},
{
category: "semantic",
name: "--color-accent",
value: "hsl(264.1, 100%, 55.1%)",
},
{
category: "status",
name: "--color-danger",
value: "hsl(25.3, 100%, 63.7%)",
},
{
category: "status",
name: "--color-success",
value: "hsl(163.2, 100%, 76.5%)",
},
{
category: "status",
name: "--color-warning",
value: "hsl(86.0, 100%, 79.5%)",
},
],
},
latestVersion: "beta",
light: {
colors: [
{
category: "base",
name: "--color-background",
value: "hsl(0, 0%, 100%)",
},
{
category: "semantic",
name: "--color-foreground",
value: "hsl(285.89, 5.9%, 21.03%)",
},
{
category: "semantic",
name: "--color-accent",
value: "hsl(253.83, 100%, 62.04%)",
},
{
category: "status",
name: "--color-danger",
value: "hsl(25.74, 100%, 65.32%)",
},
{
category: "status",
name: "--color-success",
value: "hsl(150.81, 100%, 73.29%)",
},
{
category: "status",
name: "--color-warning",
value: "hsl(72.33, 100%, 78.19%)",
},
],
},
note: "This is a fallback. For complete theme variables, ensure the API is accessible.",
opacity: {
disabled: 0.4,
hover: 0.8,
pressed: 0.6,
},
source: "fallback",
theme: "default",
};
/**
* Fetch data from HeroUI Native API with app parameter for analytics.
*/
async function fetchApi(endpoint) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`# API Error: HTTP ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`# API Error: ${error.message}`);
return null;
}
}
/**
* Format colors grouped by category.
*/
function formatColors(colors) {
const grouped = {};
for (const color of colors) {
const category = color.category || "semantic";
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(color);
}
const lines = [];
for (const [category, tokens] of Object.entries(grouped)) {
lines.push(` /* ${category.charAt(0).toUpperCase() + category.slice(1)} Colors */`);
for (const token of tokens) {
const name = token.name || "";
const value = token.value || "";
lines.push(` ${name}: ${value};`);
}
lines.push("");
}
return lines.join("\n");
}
/**
* Main function to get theme variables.
*/
async function main() {
console.error("# Fetching Native theme variables...");
const rawData = await fetchApi("/v1/themes/variables?theme=default");
let data;
let version;
if (!rawData) {
console.error("# API failed, using fallback theme reference...");
data = FALLBACK_THEME;
version = FALLBACK_THEME.latestVersion || "unknown";
} else {
// Handle API response format
data = rawData;
version = rawData.latestVersion || "unknown";
}
// Output as formatted structure for readability
console.log("/* HeroUI Native Theme Variables */");
console.log(`/* Theme: ${data.theme || "default"} */`);
console.log(`/* Version: ${version} */`);
console.log();
// Light mode colors
if (data.light && data.light.colors) {
console.log("/* Light Mode Colors */");
console.log(formatColors(data.light.colors));
}
// Dark mode colors
if (data.dark && data.dark.colors) {
console.log("/* Dark Mode Colors */");
console.log(formatColors(data.dark.colors));
}
// Border radius
if (data.borderRadius) {
console.log("/* Border Radius */");
for (const [key, value] of Object.entries(data.borderRadius)) {
console.log(` --radius-${key}: ${value};`);
}
console.log();
}
// Opacity
if (data.opacity) {
console.log("/* Opacity */");
for (const [key, value] of Object.entries(data.opacity)) {
console.log(` --opacity-${key}: ${value};`);
}
console.log();
}
// Also output raw JSON to stderr for programmatic use
console.error("\n# Raw JSON output:");
console.error(JSON.stringify(rawData || data, null, 2));
}
main();
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* List all available HeroUI Native components.
*
* Usage:
* node list_components.mjs
*
* Output:
* JSON with components array, latestVersion, and count
*/
const API_BASE = process.env.HEROUI_NATIVE_API_BASE || "https://native-mcp-api.heroui.com";
const APP_PARAM = "app=native-skills";
const LLMS_TXT_URL = "https://heroui.com/native/llms.txt";
/**
* Fetch data from HeroUI Native API with app parameter for analytics.
*/
async function fetchApi(endpoint) {
const separator = endpoint.includes("?") ? "&" : "?";
const url = `${API_BASE}${endpoint}${separator}${APP_PARAM}`;
try {
const response = await fetch(url, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
console.error(`HTTP Error ${response.status}: ${response.statusText}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`API Error: ${error.message}`);
return null;
}
}
/**
* Fetch component list from llms.txt fallback URL.
*/
async function fetchFallback() {
try {
const response = await fetch(LLMS_TXT_URL, {
headers: {"User-Agent": "HeroUI-Native-Skill/1.0"},
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
return null;
}
const content = await response.text();
// Parse markdown to extract component names from pattern: - [ComponentName](url)
// Look for links under the Components section (### Components)
const components = [];
let inComponentsSection = false;
for (const line of content.split("\n")) {
// Check if we're entering the Components section (uses ### header)
if (line.trim() === "### Components") {
inComponentsSection = true;
continue;
}
// Check if we're leaving the Components section (another ### header)
if (inComponentsSection && line.trim().startsWith("### ")) {
break;
}
// Extract component name from markdown link pattern
// Match: - [ComponentName](https://www.heroui.com/docs/native/components/component-name)
// Skip "All Components" which links to /components without a specific component
if (inComponentsSection) {
const match = line.match(
/^\s*-\s*\[([^\]]+)\]\(https:\/\/www\.heroui\.com\/docs\/native\/components\/[a-z]/,
);
if (match) {
components.push(match[1]);
}
}
}
if (components.length > 0) {
console.error(`# Using fallback: ${LLMS_TXT_URL}`);
return {
components: components.sort(),
count: components.length,
latestVersion: "unknown",
};
}
return null;
} catch (error) {
console.error(`Fallback Error: ${error.message}`);
return null;
}
}
/**
* Main function to list all available HeroUI Native components.
*/
async function main() {
let data = await fetchApi("/v1/components");
// Check if API returned valid data with components
if (!data || !data.components || data.components.length === 0) {
console.error("# API returned no components, trying fallback...");
data = await fetchFallback();
}
if (!data || !data.components || data.components.length === 0) {
console.error("Error: Failed to fetch component list from API and fallback");
process.exit(1);
}
// Output formatted JSON
console.log(JSON.stringify(data, null, 2));
// Print summary to stderr for human readability
console.error(
`\n# Found ${data.components.length} Native components (${data.latestVersion || "unknown"})`,
);
}
main();
+1
View File
@@ -49,6 +49,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.4",
"react-native-dates-picker": "^0.2.4",
"react-native-gesture-handler": "~2.30.0",
"react-native-image-crop-picker": "^0.51.1",
"react-native-keyboard-controller": "1.20.7",
+51
View File
@@ -116,6 +116,9 @@ importers:
react-native:
specifier: 0.83.4
version: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
react-native-dates-picker:
specifier: ^0.2.4
version: 0.2.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
react-native-gesture-handler:
specifier: ~2.30.0
version: 2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -1028,48 +1031,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.44.0':
resolution: {integrity: sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
resolution: {integrity: sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
resolution: {integrity: sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
resolution: {integrity: sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
resolution: {integrity: sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.44.0':
resolution: {integrity: sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.44.0':
resolution: {integrity: sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.44.0':
resolution: {integrity: sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==}
@@ -1142,48 +1153,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.60.0':
resolution: {integrity: sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.60.0':
resolution: {integrity: sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.60.0':
resolution: {integrity: sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.60.0':
resolution: {integrity: sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.60.0':
resolution: {integrity: sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.60.0':
resolution: {integrity: sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.60.0':
resolution: {integrity: sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.60.0':
resolution: {integrity: sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA==}
@@ -1605,24 +1624,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@@ -1696,10 +1719,12 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@xmldom/xmldom@0.8.12':
resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@yz1311/react-native-wheel-picker@0.4.5':
resolution: {integrity: sha512-07ga/fYdgHYI3JTAd+ntaXcJ1D3VxbgaqCPxABjxigehmFYD3VpBbMMVRhxmZC3oVnpedc+SKJXHTNwGtLriCQ==}
@@ -2907,72 +2932,84 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-gnu@1.31.1:
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3518,6 +3555,13 @@ packages:
react-native-animatable@1.4.0:
resolution: {integrity: sha512-DZwaDVWm2NBvBxf7I0wXKXLKb/TxDnkV53sWhCvei1pRyTX3MVFpkvdYBknNBqPrxYuAIlPxEp7gJOidIauUkw==}
react-native-dates-picker@0.2.4:
resolution: {integrity: sha512-zKNri33Ci8ozvWuZW9ErlYcrs66CUy3ZyVvLqqaYz9quE+sRyu2AMouLl1LQrwq4Fsk0hUjoSDuK2PPc0zUE/g==}
engines: {node: '>= 16.0.0'}
peerDependencies:
react: '>=17.0.0'
react-native: '*'
react-native-gesture-handler@2.30.1:
resolution: {integrity: sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA==}
peerDependencies:
@@ -4076,6 +4120,7 @@ packages:
uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-name@5.0.1:
@@ -8017,6 +8062,12 @@ snapshots:
dependencies:
prop-types: 15.8.1
react-native-dates-picker@0.2.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
dayjs: 1.11.20
react: 19.2.0
react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
react-native-gesture-handler@2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
'@egjs/hammerjs': 2.0.17
@@ -0,0 +1,104 @@
import React, { useState } from "react";
import { View, Text, Button, ScrollView } from "react-native";
import LayoutView from "@/components/layout/view";
import DateTime from "@/components/DateTime";
import DateTimeRange from "@/components/DateTimeRange";
export default function ChatTest() {
// --- DateTime (单选) ---
const [showDate, setShowDate] = useState(false);
const [dateValue, setDateValue] = useState("2026-06-23");
const [showTime, setShowTime] = useState(false);
const [timeValue, setTimeValue] = useState("20:00:00");
const [showDatetime, setShowDatetime] = useState(false);
const [datetimeValue, setDatetimeValue] = useState("2026-06-23 20:00");
const [showMonth, setShowMonth] = useState(false);
const [monthValue, setMonthValue] = useState("2026-06");
const [showYear, setShowYear] = useState(false);
const [yearValue, setYearValue] = useState("2026");
// --- DateTimeRange (范围) ---
const [showRange, setShowRange] = useState(false);
const [rangeStart, setRangeStart] = useState("");
const [rangeEnd, setRangeEnd] = useState("");
return (
<LayoutView>
<LayoutView.ScrollView>
<View style={{ padding: 16, gap: 16, marginTop: 30 }}>
{/* DateTime */}
<Text style={{ fontSize: 18, fontWeight: "bold" }}>DateTime(单选)</Text>
<Button title={`日期: ${dateValue}`} onPress={() => setShowDate(true)} />
<DateTime
show={showDate}
value={dateValue}
mode="date"
onConfirm={(v) => { setDateValue(v); setShowDate(false); }}
onClose={() => setShowDate(false)}
/>
<Button title={`时间: ${timeValue}`} onPress={() => setShowTime(true)} />
<DateTime
show={showTime}
value={timeValue}
mode="time"
onConfirm={(v) => { setTimeValue(v); setShowTime(false); }}
onClose={() => setShowTime(false)}
/>
<Button title={`日期时间: ${datetimeValue}`} onPress={() => setShowDatetime(true)} />
<DateTime
show={showDatetime}
value={datetimeValue}
mode="datetime"
onConfirm={(v) => { setDatetimeValue(v); setShowDatetime(false); }}
onClose={() => setShowDatetime(false)}
/>
<Button title={`月份: ${monthValue}`} onPress={() => setShowMonth(true)} />
<DateTime
show={showMonth}
value={monthValue}
mode="month"
onConfirm={(v) => { setMonthValue(v); setShowMonth(false); }}
onClose={() => setShowMonth(false)}
/>
<Button title={`年份: ${yearValue}`} onPress={() => setShowYear(true)} />
<DateTime
show={showYear}
value={yearValue}
mode="year"
onConfirm={(v) => { setYearValue(v); setShowYear(false); }}
onClose={() => setShowYear(false)}
/>
{/* DateTimeRange */}
<Text style={{ fontSize: 18, fontWeight: "bold", marginTop: 16 }}>DateTimeRange(范围)</Text>
<Button
title={rangeStart ? `${rangeStart} ~ ${rangeEnd || "..."}` : "选择日期范围"}
onPress={() => setShowRange(true)}
/>
<DateTimeRange
show={showRange}
startDate={rangeStart || undefined}
endDate={rangeEnd || undefined}
onConfirm={(start, end) => {
setRangeStart(start);
setRangeEnd(end);
setShowRange(false);
}}
onClose={() => setShowRange(false)}
/>
</View>
</LayoutView.ScrollView>
</LayoutView>
);
}
@@ -0,0 +1,266 @@
import { useEffect, useState, useCallback } from "react";
import LayoutView from "@/components/layout/view";
import { loadDataApi } from "@/request/api";
import { View, Text } from "react-native";
import { useFamilyStore, useFavoriteStore } from "@/store";
import {
ChevronRight,
Grid3X3,
UserPlus,
User,
Receipt,
Heart,
Clock3,
} from "lucide-react-native";
import { PressableFeedback } from "heroui-native";
import { SUB_APPS } from "@/configs/pages";
import { useRouter } from "expo-router";
import { loadFeeds, type FeedRecord } from "@/helpers/feed";
/* ─── 问候 ─── */
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 "晚上好";
}
/* ─── 类型 → 图标 ─── */
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
join: UserPlus,
rename: User,
bill: Receipt,
fav: 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",
});
}
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({
view_name: "b_family",
search_condition: "id = ?",
search_columns: [],
order_by: "",
args: [familyStore.currentFamily?.family_id],
});
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}
>
{/* ═══════════ 头部 ═══════════ */}
<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">
{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>
{SUB_APPS.map((app) => {
const Icon = app.icon;
return (
<PressableFeedback
key={app.id}
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>
</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" />
</LayoutView.ScrollView>
</LayoutView>
);
}
@@ -0,0 +1,117 @@
import { useEffect, useState, useCallback } from "react";
import LayoutView from "@/components/layout/view";
import { View, Text } from "react-native";
import { Stack, useRouter } from "expo-router";
import { CloseButton } from "heroui-native";
import { useFamilyStore } from "@/store";
import { loadFeeds, type FeedRecord } from "@/helpers/feed";
import { UserPlus, User, Receipt, Heart } from "lucide-react-native";
/* ─── 类型 → 图标 ─── */
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
join: UserPlus,
rename: User,
bill: Receipt,
fav: Heart,
};
/* ─── 时间格式化 ─── */
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 familyStore = useFamilyStore();
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
const [loading, setLoading] = useState(true);
const fetchFeeds = useCallback(async () => {
setLoading(true);
try {
const data = await loadFeeds(familyStore.currentFamily.family_id);
setFeeds(data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [familyStore.currentFamily.family_id]);
useEffect(() => {
fetchFeeds();
}, []);
return (
<LayoutView>
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView.ScrollView
showsVerticalScrollIndicator={false}
>
{loading ? (
<View className="flex-1 items-center justify-center py-20">
<Text className="text-sm text-gray-400">加载中...</Text>
</View>
) : feeds.length === 0 ? (
<View className="flex-1 items-center justify-center py-20">
<Text className="text-sm text-gray-400">暂无动态</Text>
</View>
) : (
feeds.map((item, idx) => {
const isLast = idx === feeds.length - 1;
const Icon = TYPE_ICONS[item.type] || Receipt;
return (
<View key={item.id} 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>
);
})
)}
<View className="h-4" />
</LayoutView.ScrollView>
{/* 关闭按钮 */}
<View className="absolute bottom-6 left-0 right-0 items-center">
<CloseButton
onPress={() => router.back()}
iconProps={{ size: 28 }}
style={{ padding: 24 }}
/>
</View>
</LayoutView>
);
}
@@ -130,7 +130,7 @@ export default function Login() {
family_id: user.family_id,
});
router.replace("/(tabs)/home");
router.replace("/home");
}
});
};
@@ -21,6 +21,7 @@ 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();
@@ -98,7 +99,7 @@ export default function EntryFamily() {
toast.success("你已经有家庭了");
familyStore.setCurrentFamily({ family_id: res.data[0].family_id });
router.replace("/(tabs)/home");
router.replace("/home");
}
// 否则保持在 entry 页面
};
@@ -271,6 +272,9 @@ export default function EntryFamily() {
setShowCreate(false);
setCreateForm({ name: "" });
// 记录动态
recordFeed(familyId, userStore.userInfo.id, "join", `{nickname} 创建了家庭「${createForm.name}」`);
enterFamily(familyId);
}
} catch (e) {
@@ -347,6 +351,9 @@ export default function EntryFamily() {
setShowJoin(false);
setJoinForm({ invite_code: "" });
// 记录动态
recordFeed(family.id, userStore.userInfo.id, "join", "{nickname} 加入了家庭");
enterFamily(family.id);
}
} catch (e) {
@@ -35,6 +35,7 @@ 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);
@@ -384,6 +385,26 @@ export default function UserSetting() {
deletes: [],
});
// 同步更新家庭成员昵称
const memberRes = await loadDataApi({
view_name: "b_family_member",
search_condition: "family_id = ? AND user_id = ?",
search_columns: ["id", "nickname"],
order_by: "",
args: [familyStore.currentFamily.family_id, userStore.userInfo.id],
});
if (memberRes.isSuccess && memberRes.data?.length > 0) {
const memberId = memberRes.data[0].id;
requestData.push({
table_name: "b_family_member",
key_field: "id",
inserts: [],
updates: [{ id: memberId, nickname: renameForm.name }],
deletes: [],
});
}
const saveRes = await saveDataApi(requestData);
if (saveRes.isSuccess) {
@@ -397,6 +418,14 @@ export default function UserSetting() {
name: "",
});
// 记录动态
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo.id,
"rename",
`{nickname} 修改了昵称为「${renameForm.name}」`,
);
toast.success("名称修改成功");
}
} catch (e) {
@@ -1,22 +1,20 @@
import LayoutView from "@/components/layout/view";
import { SearchField, CloseButton, PressableFeedback } from "heroui-native";
import { View, Text, useWindowDimensions } from "react-native";
import { toast } from "sonner-native";
import { View, Text } from "react-native";
import { Stack, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getGroupedApps, type SubAppItem } from "@/configs/pages";
import { useState, useMemo, useRef, useCallback, useEffect } from "react";
import EmptyState from "@/components/EmptyStatus";
import AZIndex from "@/components/AZIndex";
import { useFavoriteStore, useFamilyStore } from "@/store";
import { Star } from "lucide-react-native";
const NUM_COLUMNS = 3;
const GAP = 10;
import { useFavoriteStore, useFamilyStore, useUserStore } from "@/store";
import { Heart } from "lucide-react-native";
import { recordFeed } from "@/helpers/feed";
export default function Tools() {
const router = useRouter();
const safeAreaInsets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const scrollRef = useRef<any>(null);
const headerPositions = useRef<Record<string, number>>({});
@@ -24,6 +22,7 @@ export default function Tools() {
const familyStore = useFamilyStore();
const favoriteStore = useFavoriteStore();
const userStore = useUserStore();
// 进入页面时拉取收藏
useEffect(() => {
@@ -81,12 +80,6 @@ export default function Tools() {
return result;
}, [keyword, favoriteStore.favorites]);
// 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
const contentPadding = 16;
const itemWidth = Math.floor(
(screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
);
const handleLetterPress = useCallback(
(letter: string) => {
const y = headerPositions.current[letter];
@@ -104,8 +97,6 @@ export default function Tools() {
<LayoutView.ScrollView
ref={scrollRef}
showsVerticalScrollIndicator={false}
topExtraPadding={70}
bottomExtraPadding={100}
>
{sortedApps.length === 0 ? (
<EmptyState />
@@ -126,77 +117,82 @@ export default function Tools() {
}
}}
>
<Text className="text-[11px] text-gray-400/70 font-medium mb-2.5 ml-0.5 mt-3 first:mt-0 tracking-wider">
{item.title}
</Text>
<View className="flex-row items-center mb-3 mt-5 first:mt-0">
{/* 收藏分组特殊标记 */}
{item.title === "收藏" && (
<View className="w-5 h-5 rounded-full bg-accent/15 items-center justify-center mr-1.5">
<Heart size={10} fill="#0d9488" color="#0d9488" />
</View>
)}
{/* 普通分组标记 */}
{item.title !== "收藏" && (
<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">
{item.title}
</Text>
</View>
</View>
);
}
const rows: (typeof item.data)[] = [];
for (let i = 0; i < item.data.length; i += NUM_COLUMNS) {
rows.push(item.data.slice(i, i + NUM_COLUMNS));
}
return (
<View key={`g-${idx}`} className="mb-4">
{rows.map((row, rowIdx) => (
<View
key={rowIdx}
className="flex-row mb-2.5 last:mb-0"
style={{ gap: GAP }}
>
{row.map((subItem) => {
const Icon = subItem.icon;
const isFav = favoriteStore.isFavorite(subItem.id);
{item.data.map((subItem) => {
const Icon = subItem.icon;
const isFav = favoriteStore.isFavorite(subItem.id);
return (
<View
key={subItem.id}
style={{ width: itemWidth, height: 80 }}
>
<PressableFeedback
onPress={() => router.navigate(subItem.id as any)}
className="absolute inset-0 items-center justify-center bg-white rounded-xl shadow-lg"
>
<Icon size={26} />
<Text className="text-xs text-gray-700 mt-2 font-medium text-center">
{subItem.title}
return (
<View key={subItem.id} className="bg-white rounded-xl shadow-sm mx-3 mb-2 overflow-hidden relative">
<PressableFeedback
onPress={() => router.navigate(subItem.id as any)}
className="flex-row items-center py-3.5 px-4"
>
<Icon size={28} />
<View className="flex-1 ml-3">
<Text className="text-[15px] text-gray-700 font-medium leading-5">
{subItem.title}
</Text>
{subItem.description ? (
<Text className="text-[12px] text-gray-400 leading-4 mt-0.5" numberOfLines={1}>
{subItem.description}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
) : null}
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
{/* 星标按钮 — 独立于导航主体,避免事件冲突 */}
<PressableFeedback
onPress={() =>
favoriteStore.toggleFavorite(
{/* 收藏 — 行右侧,独立触摸 */}
<PressableFeedback
onPress={async () => {
const result =
await favoriteStore.toggleFavorite(
familyStore.currentFamily.family_id,
subItem.id
)
);
toast(result ? "已收藏" : "已取消收藏");
// 记录收藏动态(仅收藏时)
if (result) {
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo?.id ?? 0,
"fav",
`{nickname} 收藏了「${subItem.title}」`,
);
}
className="absolute top-1 right-1 p-1 z-10"
>
<Star
size={14}
fill={isFav ? "#fbbf24" : "none"}
color={isFav ? "#fbbf24" : "#d1d5db"}
/>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
);
})}
{row.length < NUM_COLUMNS &&
Array.from({ length: NUM_COLUMNS - row.length }).map(
(_, emptyIdx) => (
<View
key={`empty-${emptyIdx}`}
style={{ width: itemWidth }}
/>
)
)}
</View>
))}
}}
className="absolute right-4 top-0 bottom-0 justify-center px-2 z-10"
>
<Heart
size={18}
fill={isFav ? "#0d9488" : "none"}
color={isFav ? "#0d9488" : "#9ca3af"}
/>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
);
})}
</View>
);
})
@@ -227,6 +223,7 @@ export default function Tools() {
style={{ padding: 24 }}
/>
</View>
</LayoutView>
);
}
@@ -1,83 +0,0 @@
import React, { useState } from "react";
import { View, Button, Image, ScrollView, StyleSheet } from "react-native";
import ImagePicker from "react-native-image-crop-picker";
export default function ImageCropPickerDemo() {
const [images, setImages] = useState<any[]>([]);
// 选择单张 + 裁剪
const pickSingle = async () => {
try {
const image = await ImagePicker.openPicker({
freeStyleCropEnabled: true,
cropping: true,
compressImageQuality: 0.7,
});
setImages([image]);
} catch (e) {
console.log(e);
}
};
// 拍照 + 裁剪
const takePhoto = async () => {
try {
const image = await ImagePicker.openCamera({
width: 300,
height: 300,
cropping: true,
compressImageQuality: 0.7,
});
setImages([image]);
} catch (e) {
console.log(e);
}
};
// 多选(不裁剪)
const pickMultiple = async () => {
try {
const result = await ImagePicker.openPicker({
multiple: true,
compressImageQuality: 0.7,
});
setImages(result);
} catch (e) {
console.log(e);
}
};
return (
<View style={styles.container}>
<Button title="选择图片(裁剪)" onPress={pickSingle} />
<Button title="拍照(裁剪)" onPress={takePhoto} />
<Button title="多选图片" onPress={pickMultiple} />
<ScrollView style={styles.preview}>
{images.map((img, index) => (
<Image key={index} source={{ uri: img.path }} style={styles.image} />
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
marginTop: 60,
},
preview: {
marginTop: 20,
},
image: {
width: "100%",
height: 200,
marginBottom: 10,
borderRadius: 10,
},
});
@@ -1,207 +0,0 @@
import { useEffect, useState } from "react";
import LayoutView from "@/components/layout/view";
import { loadDataApi } from "@/request/api";
import { View, Text, useWindowDimensions } from "react-native";
import { useFamilyStore, useFavoriteStore } from "@/store";
import { LayoutGridIcon, LayersIcon } from "lucide-react-native";
import { PressableFeedback } from "heroui-native";
import { SUB_APPS, type SubAppItem } from "@/configs/pages";
import { useRouter } from "expo-router";
const NUM_COLUMNS = 3;
const GAP = 10;
export default function Home() {
const router = useRouter();
const familyStore = useFamilyStore();
const favoriteStore = useFavoriteStore();
const { width: screenWidth } = useWindowDimensions();
const [family, setFamily] = useState<any>({});
const [duration, setDuration] = useState({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
});
// 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
const contentPadding = 16;
const itemWidth = Math.floor(
(screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
);
async function loadFamilyData() {
const res = await loadDataApi({
view_name: "b_family",
search_condition: "id = ?",
search_columns: [],
order_by: "",
args: [familyStore.currentFamily?.family_id],
});
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);
}, [familyStore.currentFamily.family_id]);
// 首页展示:收藏在前,其余在后 + "更多" 铺满行
const displayItems: SubAppItem[] = (() => {
const favIds = favoriteStore.favorites;
const starred = SUB_APPS.filter((a) => favIds.has(a.id));
const unstarred = SUB_APPS.filter((a) => !favIds.has(a.id));
return [...starred, ...unstarred];
})();
// 按 3 列分行使"更多"自然填充
const cardRows: { items: SubAppItem[]; hasMore: boolean }[] = (() => {
const rows: { items: SubAppItem[]; hasMore: boolean }[] = [];
const all = [...displayItems];
const hasMore = true; // 永远显示"更多"
for (let i = 0; i < all.length; i += NUM_COLUMNS) {
rows.push({ items: all.slice(i, i + NUM_COLUMNS), hasMore: false });
}
// 最后一行若不满 3 个,"更多" 补在末尾
const last = rows[rows.length - 1];
if (last && last.items.length < NUM_COLUMNS) {
last.hasMore = true;
last.items.push({ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any);
} else {
// 最后一行已满或没有数据,新增一行
rows.push({
items: [{ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any],
hasMore: true,
});
}
return rows;
})();
return (
<LayoutView>
<LayoutView.ScrollView>
<View className="items-center justify-center">
<View className="flex-row items-center justify-center gap-2">
<Text className="text-4xl font-bold tracking-tight text-gray-900">
{family.name || "-"}
</Text>
</View>
<View className="items-center mt-3">
<View className="flex-row items-center gap-2">
<Text className="text-3xl font-bold text-accent">
{duration.days}
</Text>
<Text className="text-gray-400">天</Text>
<Text className="text-3xl font-bold text-accent">
{duration.hours}
</Text>
<Text className="text-gray-400">时</Text>
<Text className="text-3xl font-bold text-accent">
{duration.minutes}
</Text>
<Text className="text-gray-400">分</Text>
<Text className="text-3xl font-bold text-accent">
{duration.seconds}
</Text>
<Text className="text-gray-400">秒</Text>
</View>
</View>
</View>
<View className="mt-6">
<View className="flex-row items-center mb-2 gap-2">
<LayoutGridIcon size={18} color="#6B7280" />
<Text className="text-sm text-muted">快捷应用</Text>
</View>
{cardRows.map((row, rowIdx) => (
<View
key={rowIdx}
className="flex-row mb-2.5 last:mb-0"
style={{ gap: GAP }}
>
{row.items.map((app) => {
const Icon = app.icon;
const isMoreBtn = app.id === "__more__";
return (
<PressableFeedback
key={app.id}
onPress={() =>
isMoreBtn
? router.navigate("/tools")
: router.navigate(app.id as any)
}
style={{ width: itemWidth }}
className="items-center justify-center h-20 bg-white rounded-xl shadow-lg"
>
<Icon size={26} />
<Text className="text-xs text-gray-700 mt-2 font-medium">
{app.title}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
);
})}
{/* 末尾空位占位,保持左对齐 */}
{row.items.length < NUM_COLUMNS &&
Array.from({ length: NUM_COLUMNS - row.items.length }).map(
(_, emptyIdx) => (
<View
key={`empty-${emptyIdx}`}
style={{ width: itemWidth }}
/>
)
)}
</View>
))}
</View>
</LayoutView.ScrollView>
</LayoutView>
);
}
@@ -1,13 +0,0 @@
import React from "react";
import LayoutView from "@/components/layout/view";
import { Text } from "react-native";
export default function Start() {
return (
<LayoutView>
<LayoutView.ScrollView>
<Text className="text-base text-black">Hello</Text>
</LayoutView.ScrollView>
</LayoutView>
);
}
-119
View File
@@ -1,119 +0,0 @@
import { Stack } from "expo-router";
import { Image, Text, View } from "react-native";
import { Button, LinkButton, ControlField, Checkbox, Spinner } from "heroui-native";
import { IconQq } from "@/components/iconfont";
import { useState } from "react";
import { FadeIn } from "react-native-reanimated";
import { useUserStore, useFamilyStore } from "@/store";
import { useRouter } from "expo-router";
export default function Login() {
const router = useRouter();
const userStore = useUserStore();
const familyStore = useFamilyStore();
const [loading, setLoading] = useState(false);
const [isAgreed, setIsAgreed] = useState(true);
/**
* {"code": 1000, "data": {"avatar": "http://thirdqq.qlogo.cn/ek_qqapp/AQHVialeUibE42oV8RvTTVZ4tyRlPYeJrUibWTwtCPfV68E4rSzn9cw00GkXQ3QDTiawwhjoqQb7/100", "family_id": 797225979793477, "nickname": "Zhang", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJfaWQiOjc5NjU0MTkwMTM4OTg5M30sImlhdCI6MTc3Njc3MTcyOX0.JldxjA-0u2hza3qNx20xgUyVBVVV6hQuVuTW1GcJyr8", "userId": 796541901389893}, "isSuccess": true, "message": "success", "track_id": "hI5gEsPHu1LGwkQaJ6lf1dDQby0rzxGVJj0KcNC3Nxw"}
*/
function handleTest() {
if (loading) {
return;
}
setLoading(true);
setTimeout(() => {
requestAnimationFrame(() => {
userStore.setUserInfo({
id: 796541901389893,
token:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJfaWQiOjc5NjU0MTkwMTM4OTg5M30sImlhdCI6MTc3Njc3MTcyOX0.JldxjA-0u2hza3qNx20xgUyVBVVV6hQuVuTW1GcJyr8",
nickname: "Zhang",
avatar:
"http://thirdqq.qlogo.cn/ek_qqapp/AQHVialeUibE42oV8RvTTVZ4tyRlPYeJrUibWTwtCPfV68E4rSzn9cw00GkXQ3QDTiawwhjoqQb7/100",
});
userStore.setAuthExpired(false);
console.log(familyStore.inFamily());
// if (familyStore.inFamily()) {
// router.replace("/(tabs)/home");
// } else {
// router.replace("/family/entry");
// return;
// }
familyStore.setCurrentFamily({
family_id: 797225979793477,
});
router.replace("/(tabs)/home");
});
setLoading(false);
}, 500);
}
return (
<>
<Stack.Screen
options={{
animation: "fade",
}}
/>
<View className="flex-1 bg-white px-6 justify-center">
<View className="items-center mb-12">
<Image
source={require("@/static/images/logo.png")}
className="w-20 h-20 mb-4"
resizeMode="contain"
/>
<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="white" />
)}
<Button.Label>WeChat</Button.Label>
</Button>
<Button variant="outline" onPress={handleLoginQQ}>
{loading ? <Spinner entering={FadeIn.delay(50)} /> : <IconQq />}
<Button.Label>QQ</Button.Label>
</Button> */}
<Button variant="outline" onPress={handleTest}>
{loading ? <Spinner entering={FadeIn.delay(50)} /> : <IconQq />}
<Button.Label>Test</Button.Label>
</Button>
</View>
<ControlField
isSelected={isAgreed}
onSelectedChange={setIsAgreed}
className="items-start mt-10"
>
<ControlField.Indicator>
<Checkbox className="w-5 h-5 rounded-sm" />
</ControlField.Indicator>
<View className="flex-row flex-wrap flex-1 gap-1">
<Text className="text-sm text-muted">登录即表示你同意我们的</Text>
<LinkButton size="sm">
<LinkButton.Label className="text-accent">用户协议</LinkButton.Label>
</LinkButton>
<Text className="text-sm text-muted">和</Text>
<LinkButton size="sm">
<LinkButton.Label className="text-accent">隐私政策</LinkButton.Label>
</LinkButton>
</View>
</ControlField>
</View>
</>
);
}
+1 -1
View File
@@ -63,7 +63,7 @@ export default function Index() {
family_id: res.data[0].family_id,
});
router.replace("/(tabs)/home");
router.replace("/home");
} catch (e) {
console.error(e);
toast.error("初始化失败,请稍后重试");
+46 -25
View File
@@ -1,15 +1,19 @@
import { DatePicker as YzDatePicker } from "@yz1311/react-native-wheel-picker";
import DateTimePicker from "react-native-dates-picker";
import dayjs from "dayjs";
import { BottomSheet, Button, Spinner } from "heroui-native";
import { useState, useEffect } from "react";
import { toDate } from "@/utils/time";
import { useState, useEffect, useCallback, useRef } from "react";
import { View } from "react-native";
import { CheckIcon } from "lucide-react-native";
import { FadeIn } from "react-native-reanimated";
export type DatePickerMode = "year" | "month" | "date" | "time" | "datetime";
export type DateFormatType = "YYYY" | "YYYY-MM" | "YYYY-MM-DD" | "HH:mm:ss" | "YYYY-MM-DD HH:mm";
export type DateFormatType =
| "YYYY"
| "YYYY-MM"
| "YYYY-MM-DD"
| "HH:mm:ss"
| "YYYY-MM-DD HH:mm";
interface DatePickerProps {
value: Date | string;
@@ -28,6 +32,14 @@ const defaultFormatMap: Record<DatePickerMode, DateFormatType> = {
datetime: "YYYY-MM-DD HH:mm",
};
const modeViewMap: Record<DatePickerMode, "day" | "month" | "year"> = {
year: "year",
month: "month",
date: "day",
time: "day",
datetime: "day",
};
export default function DateTime({
value,
show = false,
@@ -38,51 +50,60 @@ export default function DateTime({
}: DatePickerProps) {
const finalFormat = format ?? defaultFormatMap[mode];
const [tempDate, setTempDate] = useState<Date>(toDate(value) ?? new Date());
const [open, setOpen] = useState(false);
const [tempDate, setTempDate] = useState<dayjs.Dayjs | undefined>(dayjs());
const [loading, setLoading] = useState(false);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// 打开
useEffect(() => {
if (show) {
setTempDate(toDate(value) ?? new Date());
const d = dayjs(value);
setTempDate(d.isValid() ? d : dayjs());
setOpen(true);
}
}, [show, value]);
const handleClose = () => {
if (loading) return;
onClose?.();
};
// 关闭:BottomSheet 的 onOpenChange(false) 或 confirm 后 setOpen(false)
// 立刻通知父组件,不延迟 — BottomSheet 自身驱动关闭动画
useEffect(() => {
if (!open && show) {
onCloseRef.current?.();
}
}, [open, show]);
const handleConfirm = async () => {
if (!tempDate || loading) return;
const handleConfirm = useCallback(async () => {
if (loading || !tempDate) return;
try {
setLoading(true);
const result = dayjs(tempDate).format(finalFormat);
const result = tempDate.format(finalFormat);
await onConfirm?.(result);
handleClose();
setOpen(false);
} catch (e) {
console.log(e);
} finally {
setLoading(false);
}
};
}, [loading, tempDate, finalFormat, onConfirm]);
return (
<BottomSheet isOpen={show} onOpenChange={(v) => !v && handleClose()}>
<BottomSheet isOpen={open} onOpenChange={setOpen}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View style={{ width: "100%", alignItems: "center" }}>
<YzDatePicker
showHeader={false}
<DateTimePicker
mode="single"
date={tempDate}
mode={mode}
isModal={false}
onDateChange={(d: Date) => setTempDate(d)}
initialView={modeViewMap[mode]}
timePicker={mode === "time" || mode === "datetime"}
selectedItemColor="#0d9488"
onChange={({ date }) => {
if (date) setTempDate(dayjs(date));
}}
/>
<Button className="w-full" onPress={handleConfirm} isDisabled={loading}>
+63 -106
View File
@@ -1,17 +1,17 @@
import React, { useState, useEffect } from "react";
import { DatePicker as YzDatePicker } from "@yz1311/react-native-wheel-picker";
import { BottomSheet, Button } from "heroui-native";
import { View, Text, TouchableOpacity } from "react-native";
import DateTimePicker, { DateType } from "react-native-dates-picker";
import dayjs from "dayjs";
import { toDate } from "@/utils/time";
import { BottomSheet, Button, Spinner } from "heroui-native";
import { View } from "react-native";
import { useState, useEffect, useCallback, useRef } from "react";
import { CheckIcon } from "lucide-react-native";
import { FadeIn } from "react-native-reanimated";
interface DateRangePickerProps {
startDate?: Date | string;
endDate?: Date | string;
show?: boolean;
onClose?: () => void;
onConfirm?: (start: string, end: string) => void;
onConfirm?: (start: string, end: string) => void | Promise<void>;
}
export default function DateTimeRange({
@@ -21,121 +21,78 @@ export default function DateTimeRange({
onClose,
onConfirm,
}: DateRangePickerProps) {
const [tempStart, setTempStart] = useState<Date | undefined>(undefined);
const [tempEnd, setTempEnd] = useState<Date | undefined>(undefined);
const [open, setOpen] = useState(false);
const [range, setRange] = useState<{
startDate: DateType;
endDate: DateType;
}>({ startDate: undefined, endDate: undefined });
const [loading, setLoading] = useState(false);
const [active, setActive] = useState<"start" | "end">("start");
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// ✅ 每次打开重置
useEffect(() => {
if (show) {
setTempStart(toDate(startDate) ?? new Date());
setTempEnd(toDate(endDate) ?? undefined);
setActive("start");
setRange({
startDate: startDate ? dayjs(startDate) : undefined,
endDate: endDate ? dayjs(endDate) : undefined,
});
setOpen(true);
}
}, [show, startDate, endDate]);
const handleClose = () => {
onClose?.();
};
useEffect(() => {
if (!open && show) {
onCloseRef.current?.();
}
}, [open, show]);
const handleConfirm = () => {
if (!tempStart) return;
const handleConfirm = useCallback(async () => {
if (loading) return;
if (!range.startDate) return;
onConfirm?.(
dayjs(tempStart).format("YYYY-MM-DD"),
tempEnd ? dayjs(tempEnd).format("YYYY-MM-DD") : "",
);
handleClose();
};
// ❗ start 修改 → 清空 end
const handleStartChange = (date: Date) => {
setTempStart(date);
setTempEnd(undefined);
};
// ❗ end 修改
const handleEndChange = (date: Date) => {
if (!tempStart) return;
setTempEnd(date);
};
const formatDate = (date?: Date) => (date ? dayjs(date).format("YYYY-MM-DD") : "请选择");
try {
setLoading(true);
await onConfirm?.(
dayjs(range.startDate).format("YYYY-MM-DD"),
range.endDate ? dayjs(range.endDate).format("YYYY-MM-DD") : "",
);
setOpen(false);
} catch (e) {
console.log(e);
} finally {
setLoading(false);
}
}, [loading, range, onConfirm]);
return (
<BottomSheet isOpen={show} onOpenChange={(v) => !v && handleClose()}>
<BottomSheet isOpen={open} onOpenChange={setOpen}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="w-full items-center">
{/* 顶部选择区 */}
<View className="flex-row items-center justify-between w-full">
{/* start */}
<TouchableOpacity className="flex-1 items-center" onPress={() => setActive("start")}>
<Text>开始时间</Text>
<Text
className={`text-base ${
active === "start" ? "text-accent font-bold" : "text-gray-400"
}`}
>
{formatDate(tempStart)}
</Text>
</TouchableOpacity>
<View style={{ width: "100%", alignItems: "center" }}>
<DateTimePicker
mode="range"
startDate={range.startDate}
endDate={range.endDate}
selectedItemColor="#0d9488"
selectedRangeBackgroundColor="rgba(13,148,136,0.15)"
onChange={(params) => setRange(params)}
/>
<View className="w-px h-10 bg-gray-300 mx-2" />
{/* end */}
<TouchableOpacity
className="flex-1 items-center"
style={{ opacity: tempStart ? 1 : 0.4 }}
disabled={!tempStart}
onPress={() => {
if (!tempStart) return;
// ✅ 核心优化:未选 end → 自动补 start
if (!tempEnd) {
setTempEnd(tempStart);
}
setActive("end");
}}
>
<Text>结束时间</Text>
<Text
className={`text-base ${
active === "end" ? "text-accent font-bold" : "text-gray-400"
}`}
>
{formatDate(tempEnd)}
</Text>
</TouchableOpacity>
</View>
{/* Picker */}
{active === "start" ? (
<YzDatePicker
showHeader={false}
mode="date"
date={tempStart ?? new Date()}
onDateChange={handleStartChange}
/>
) : (
<YzDatePicker
showHeader={false}
mode="date"
date={tempEnd ?? tempStart ?? new Date()}
minDate={tempStart}
onDateChange={handleEndChange}
/>
)}
{/* confirm */}
<Button className="w-full" onPress={handleConfirm}>
<CheckIcon color="#fff" />
<Button.Label>确认</Button.Label>
<Button
className="w-full"
onPress={handleConfirm}
isDisabled={loading || !range.startDate}
>
{loading ? (
<Spinner entering={FadeIn.delay(50)} color="white" />
) : (
<>
<CheckIcon color="#fff" />
<Button.Label>确认</Button.Label>
</>
)}
</Button>
</View>
</BottomSheet.Content>
+86
View File
@@ -0,0 +1,86 @@
import { loadDataApi, saveDataApi, getUniqueIdApi } from "@/request/api";
export type FeedType = "join" | "rename" | "bill" | "fav";
export interface FeedRecord {
id: number;
family_id: number;
user_id: number;
type: FeedType;
content: string;
meta: string | null;
create_time: string;
}
/** 获取用户在家庭中的昵称(来自 b_family_member,非 b_user) */
export async function getFamilyMemberNickname(
familyId: number,
userId: number,
): Promise<string> {
const res = await loadDataApi({
view_name: "b_family_member",
search_condition: "family_id = ? AND user_id = ?",
search_columns: ["nickname"],
order_by: "",
args: [familyId, userId],
});
if (res.isSuccess && res.data?.length > 0) {
return res.data[0].nickname || "成员";
}
return "成员";
}
/** 写入一条动态 — content 直接包含所有展示信息,用 {nickname} 占位 */
export async function recordFeed(
familyId: number,
userId: number,
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: [],
},
]);
}
/** 加载家庭动态列表 — 自动替换 {nickname} 占位符 */
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],
});
const feeds = (res.data || []) as FeedRecord[];
// 批量加载昵称:收集唯一 user_id,逐个查 b_family_member
const uniqueUserIds = [...new Set(feeds.map((f) => f.user_id))];
const nickMap = new Map<number, string>();
for (const uid of uniqueUserIds) {
nickMap.set(uid, await getFamilyMemberNickname(familyId, uid));
}
// 替换 {nickname} 占位符,保证每次展示都是最新昵称
return feeds.map((f) => ({
...f,
content: f.content.replace(/\{nickname\}/g, nickMap.get(f.user_id) || "成员"),
}));
}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function PageLayout({ title, titleIcon, children, renderTitle }:
}
function handleClose() {
router.navigate("/(tabs)/home");
router.navigate("/home");
}
return (