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

+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 (