20260622214432
This commit is contained in:
1 parent
b5f6fafd74
commit
e9e3371be7
19 files changed
+1068
-121
No files matched your search
@@ -1,14 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { loadDataApi } from "@/request/api";
|
||||
import { View, Text } from "react-native";
|
||||
import { useFamilyStore } from "@/store";
|
||||
import { View, Text, useWindowDimensions } from "react-native";
|
||||
import { useFamilyStore, useFavoriteStore } from "@/store";
|
||||
import { LayoutGridIcon, LayersIcon } from "lucide-react-native";
|
||||
import { IconAppFinance, IconAppClothes } from "@/components/iconfont";
|
||||
import { PressableFeedback } from "heroui-native";
|
||||
import { SUB_APPS, type SubAppItem } from "@/configs/pages";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
const NUM_COLUMNS = 3;
|
||||
const GAP = 10;
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const familyStore = useFamilyStore();
|
||||
const favoriteStore = useFavoriteStore();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const [family, setFamily] = useState<any>({});
|
||||
|
||||
const [duration, setDuration] = useState({
|
||||
@@ -18,10 +26,16 @@ export default function Home() {
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
// 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
|
||||
const contentPadding = 16;
|
||||
const itemWidth = Math.floor(
|
||||
(screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
|
||||
);
|
||||
|
||||
async function loadFamilyData() {
|
||||
const res = await loadDataApi({
|
||||
view_name: `b_family`,
|
||||
search_condition: `id = ?`,
|
||||
view_name: "b_family",
|
||||
search_condition: "id = ?",
|
||||
search_columns: [],
|
||||
order_by: "",
|
||||
args: [familyStore.currentFamily?.family_id],
|
||||
@@ -63,6 +77,46 @@ export default function Home() {
|
||||
return () => clearInterval(timer);
|
||||
}, [family.create_time]);
|
||||
|
||||
// 监听家庭变化,拉取收藏
|
||||
useEffect(() => {
|
||||
const fid = familyStore.currentFamily.family_id;
|
||||
if (fid) favoriteStore.fetchFavorites(fid);
|
||||
}, [familyStore.currentFamily.family_id]);
|
||||
|
||||
// 首页展示:收藏在前,其余在后 + "更多" 铺满行
|
||||
const displayItems: SubAppItem[] = (() => {
|
||||
const favIds = favoriteStore.favorites;
|
||||
const starred = SUB_APPS.filter((a) => favIds.has(a.id));
|
||||
const unstarred = SUB_APPS.filter((a) => !favIds.has(a.id));
|
||||
return [...starred, ...unstarred];
|
||||
})();
|
||||
|
||||
// 按 3 列分行使"更多"自然填充
|
||||
const cardRows: { items: SubAppItem[]; hasMore: boolean }[] = (() => {
|
||||
const rows: { items: SubAppItem[]; hasMore: boolean }[] = [];
|
||||
const all = [...displayItems];
|
||||
const hasMore = true; // 永远显示"更多"
|
||||
|
||||
for (let i = 0; i < all.length; i += NUM_COLUMNS) {
|
||||
rows.push({ items: all.slice(i, i + NUM_COLUMNS), hasMore: false });
|
||||
}
|
||||
|
||||
// 最后一行若不满 3 个,"更多" 补在末尾
|
||||
const last = rows[rows.length - 1];
|
||||
if (last && last.items.length < NUM_COLUMNS) {
|
||||
last.hasMore = true;
|
||||
last.items.push({ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any);
|
||||
} else {
|
||||
// 最后一行已满或没有数据,新增一行
|
||||
rows.push({
|
||||
items: [{ id: "__more__", title: "更多", icon: LayersIcon, group: "" } as any],
|
||||
hasMore: true,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
})();
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView>
|
||||
@@ -75,16 +129,24 @@ export default function Home() {
|
||||
|
||||
<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-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-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-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-3xl font-bold text-accent">
|
||||
{duration.seconds}
|
||||
</Text>
|
||||
<Text className="text-gray-400">秒</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -92,35 +154,52 @@ export default function Home() {
|
||||
|
||||
<View className="mt-6">
|
||||
<View className="flex-row items-center mb-2 gap-2">
|
||||
<View className="text-gray-500">
|
||||
<LayoutGridIcon size={18} color="#6B7280" />
|
||||
</View>
|
||||
<LayoutGridIcon size={18} color="#6B7280" />
|
||||
<Text className="text-sm text-muted">快捷应用</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row flex-wrap gap-3">
|
||||
<PressableFeedback className="flex-1 items-center justify-center h-20 bg-white rounded-xl shadow-lg">
|
||||
<IconAppFinance size={26} />
|
||||
{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__";
|
||||
|
||||
<Text className="text-xs text-gray-700 mt-2 font-medium">记账</Text>
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
<PressableFeedback className="flex-1 items-center justify-center h-20 bg-white rounded-xl shadow-lg">
|
||||
<IconAppClothes size={26} />
|
||||
|
||||
<Text className="text-xs text-gray-700 mt-2 font-medium">衣服搭配</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
<PressableFeedback className="flex-1 items-center justify-center h-20 bg-white rounded-xl shadow-lg">
|
||||
<LayersIcon size={26} />
|
||||
|
||||
<Text className="text-xs text-gray-700 mt-2 font-medium">更多</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
</View>
|
||||
{/* 末尾空位占位,保持左对齐 */}
|
||||
{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>
|
||||
|
||||
@@ -11,7 +11,7 @@ import ExpoQQ from "expo-qq";
|
||||
import ExpoWeChat from "expo-wechat";
|
||||
import { useEvent } from "expo";
|
||||
import { toast } from "sonner-native";
|
||||
import { loginQqApi, loginWxApi } from "@/request/api";
|
||||
import { loginQqApi, loginWxApi, loginTestApi } from "@/request/api";
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
@@ -192,6 +192,27 @@ export default function Login() {
|
||||
handleLogin();
|
||||
}, [authResult]);
|
||||
|
||||
async function test1() {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const loginRes = await loginTestApi({
|
||||
user_id: 801787924787269,
|
||||
});
|
||||
|
||||
if (!loginRes.isSuccess) return;
|
||||
|
||||
await handlePostLogin(loginRes.data);
|
||||
} catch (err) {
|
||||
console.error("测试登录失败", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function test2() {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
@@ -231,6 +252,29 @@ export default function Login() {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button variant="outline" onPress={test1} isDisabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} />
|
||||
) : (
|
||||
<>
|
||||
<IconQq />
|
||||
<Button.Label>测试1</Button.Label>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onPress={test2} isDisabled={loading}>
|
||||
{loading ? (
|
||||
<Spinner entering={FadeIn.delay(50)} />
|
||||
) : (
|
||||
<>
|
||||
<IconQq />
|
||||
<Button.Label>测试2</Button.Label>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<ControlField
|
||||
|
||||
@@ -3,6 +3,7 @@ import ModuleLayout from "@/layouts/ModuleLayout";
|
||||
import { useFinanceStore } from "@/store/finance";
|
||||
import { useEffect } from "react";
|
||||
import { loadDataApi } from "@/request/api";
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -45,5 +46,10 @@ export default function Layout() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <ModuleLayout tabs={tabs} />;
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||
<ModuleLayout tabs={tabs} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,212 @@
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { SearchField, CloseButton, ListGroup, PressableFeedback, Separator } from "heroui-native";
|
||||
import { View, Text } from "react-native";
|
||||
import { SearchField, CloseButton, PressableFeedback } from "heroui-native";
|
||||
import { View, Text, useWindowDimensions } from "react-native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { getGroupedApps } from "@/configs/pages";
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import AZIndex from "@/components/AZIndex";
|
||||
import type { LegendListRef } from "@legendapp/list";
|
||||
import { AppListItem } from "@/configs/pages";
|
||||
import { getGroupedApps, type SubAppItem } from "@/configs/pages";
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from "react";
|
||||
import EmptyState from "@/components/EmptyStatus";
|
||||
import AZIndex from "@/components/AZIndex";
|
||||
import { useFavoriteStore, useFamilyStore } from "@/store";
|
||||
import { Star } from "lucide-react-native";
|
||||
|
||||
const NUM_COLUMNS = 3;
|
||||
const GAP = 10;
|
||||
|
||||
export default function Tools() {
|
||||
const router = useRouter();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const listRef = useRef<LegendListRef>(null);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const scrollRef = useRef<any>(null);
|
||||
const headerPositions = useRef<Record<string, number>>({});
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const data = useMemo(() => getGroupedApps(keyword), [keyword]);
|
||||
const familyStore = useFamilyStore();
|
||||
const favoriteStore = useFavoriteStore();
|
||||
|
||||
const scrollToLetter = (letter: string) => {
|
||||
const index = data.findIndex((item) => item.type === "header" && item.title === letter);
|
||||
// 进入页面时拉取收藏
|
||||
useEffect(() => {
|
||||
favoriteStore.fetchFavorites(familyStore.currentFamily.family_id);
|
||||
}, []);
|
||||
|
||||
if (index === -1) return;
|
||||
// 收藏在前,其余在后
|
||||
const sortedApps = useMemo(() => {
|
||||
const grouped = getGroupedApps(keyword);
|
||||
|
||||
listRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewOffset: safeAreaInsets.top + 56,
|
||||
});
|
||||
// 只收集 group 类型的条目
|
||||
const groups = grouped.filter((g) => g.type === "group") as {
|
||||
type: "group";
|
||||
title: string;
|
||||
data: SubAppItem[];
|
||||
}[];
|
||||
|
||||
setTimeout(() => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewOffset: safeAreaInsets.top + 56,
|
||||
if (groups.length === 0) return grouped;
|
||||
|
||||
// 合并所有 app 并按收藏排序
|
||||
const allApps = groups.flatMap((g) => g.data);
|
||||
const favIds = favoriteStore.favorites;
|
||||
|
||||
const starred = allApps.filter((a) => favIds.has(a.id));
|
||||
const unstarred = allApps.filter((a) => !favIds.has(a.id));
|
||||
|
||||
const result: typeof grouped = [];
|
||||
|
||||
if (starred.length > 0 && !keyword) {
|
||||
result.push({ type: "header", title: "收藏" });
|
||||
result.push({ type: "group", title: "收藏", data: starred });
|
||||
}
|
||||
|
||||
// 无关键词时保持原有分组展示非收藏项
|
||||
if (!keyword) {
|
||||
const groupMap = new Map<string, SubAppItem[]>();
|
||||
unstarred.forEach((a) => {
|
||||
const arr = groupMap.get(a.group) || [];
|
||||
arr.push(a);
|
||||
groupMap.set(a.group, arr);
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
const keys = Array.from(groupMap.keys()).sort();
|
||||
keys.forEach((k) => {
|
||||
result.push({ type: "header", title: k });
|
||||
result.push({ type: "group", title: k, data: groupMap.get(k)! });
|
||||
});
|
||||
} else {
|
||||
// 搜索模式下不分组,直接展示
|
||||
if (unstarred.length > 0) {
|
||||
result.push({ type: "header", title: "结果" });
|
||||
result.push({ type: "group", title: "结果", data: unstarred });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [keyword, favoriteStore.favorites]);
|
||||
|
||||
// 16 是 LayoutView.ScrollView 内置的 paddingHorizontal
|
||||
const contentPadding = 16;
|
||||
const itemWidth = Math.floor(
|
||||
(screenWidth - contentPadding * 2 - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS
|
||||
);
|
||||
|
||||
const handleLetterPress = useCallback(
|
||||
(letter: string) => {
|
||||
const y = headerPositions.current[letter];
|
||||
if (y !== undefined && scrollRef.current) {
|
||||
scrollRef.current.scrollTo({ y, animated: true });
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||
|
||||
<LayoutView.LegendList<AppListItem>
|
||||
ref={listRef}
|
||||
data={data}
|
||||
keyExtractor={(item, i) => String(i)}
|
||||
estimatedItemSize={120}
|
||||
recycleItems
|
||||
initialContainerPoolRatio={20}
|
||||
contentContainerStyle={{
|
||||
paddingTop: safeAreaInsets.top + 60,
|
||||
paddingBottom: safeAreaInsets.bottom + 100,
|
||||
paddingRight: 32,
|
||||
}}
|
||||
ListEmptyComponent={<EmptyState />}
|
||||
renderItem={({ item }: { item: AppListItem }) => {
|
||||
if (item.type === "header") {
|
||||
return <Text className="text-xs text-gray-400 mb-2 px-2">{item.title}</Text>;
|
||||
}
|
||||
<LayoutView.ScrollView
|
||||
ref={scrollRef}
|
||||
showsVerticalScrollIndicator={false}
|
||||
topExtraPadding={70}
|
||||
bottomExtraPadding={100}
|
||||
>
|
||||
{sortedApps.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
sortedApps.map((item, idx) => {
|
||||
if (item.type === "header") {
|
||||
const headerKey = `${item.title}-${keyword || "all"}`;
|
||||
|
||||
return (
|
||||
<View className="mb-6">
|
||||
<ListGroup>
|
||||
{item.data.map((subItem, index) => {
|
||||
const Icon = subItem.icon;
|
||||
return (
|
||||
<View
|
||||
key={`h-${idx}`}
|
||||
onLayout={(e) => {
|
||||
headerPositions.current[headerKey] =
|
||||
e.nativeEvent.layout.y;
|
||||
if (!keyword) {
|
||||
headerPositions.current[item.title] =
|
||||
e.nativeEvent.layout.y;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PressableFeedback
|
||||
key={subItem.id}
|
||||
onPress={() => {
|
||||
router.navigate(subItem.id as any);
|
||||
}}
|
||||
>
|
||||
<ListGroup.Item>
|
||||
<ListGroup.ItemPrefix>
|
||||
<Icon size={22} />
|
||||
</ListGroup.ItemPrefix>
|
||||
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));
|
||||
}
|
||||
|
||||
<ListGroup.ItemContent>
|
||||
<ListGroup.ItemTitle>{subItem.title}</ListGroup.ItemTitle>
|
||||
{subItem.description && (
|
||||
<ListGroup.ItemDescription>
|
||||
{subItem.description}
|
||||
</ListGroup.ItemDescription>
|
||||
)}
|
||||
</ListGroup.ItemContent>
|
||||
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);
|
||||
|
||||
<ListGroup.ItemSuffix />
|
||||
</ListGroup.Item>
|
||||
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}
|
||||
</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{index !== item.data.length - 1 && <Separator className="mx-4" />}
|
||||
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
);
|
||||
})}
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* 星标按钮 — 独立于导航主体,避免事件冲突 */}
|
||||
<PressableFeedback
|
||||
onPress={() =>
|
||||
favoriteStore.toggleFavorite(
|
||||
familyStore.currentFamily.family_id,
|
||||
subItem.id
|
||||
)
|
||||
}
|
||||
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>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</LayoutView.ScrollView>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<View
|
||||
className="absolute top-0 left-0 right-0 px-4"
|
||||
style={{
|
||||
paddingTop: safeAreaInsets.top,
|
||||
paddingRight: 32,
|
||||
}}
|
||||
style={{ paddingTop: safeAreaInsets.top }}
|
||||
>
|
||||
<SearchField value={keyword} onChange={setKeyword}>
|
||||
<SearchField.Group>
|
||||
@@ -118,8 +216,10 @@ export default function Tools() {
|
||||
</SearchField>
|
||||
</View>
|
||||
|
||||
<AZIndex onSelect={scrollToLetter} />
|
||||
{/* 字母导航 */}
|
||||
<AZIndex onSelect={handleLetterPress} />
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<View className="absolute bottom-6 left-0 right-0 items-center">
|
||||
<CloseButton
|
||||
onPress={() => router.back()}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
|
||||
}, [pathname, tabs]);
|
||||
|
||||
function handleClose() {
|
||||
router.replace("/(tabs)/home");
|
||||
router.back();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LoginQqParams, LoginResult, LoginWxParams } from "@/types/auth";
|
||||
import type { LoginQqParams, LoginResult, LoginTestParams, LoginWxParams } from "@/types/auth";
|
||||
import alovaInstance from "./index";
|
||||
|
||||
export function loginQqApi(params: LoginQqParams) {
|
||||
@@ -9,6 +9,10 @@ export function loginWxApi(params: LoginWxParams) {
|
||||
return alovaInstance.Post<ResponseData<LoginResult>>(`/auth/login/wechat`, params);
|
||||
}
|
||||
|
||||
export function loginTestApi(params: LoginTestParams) {
|
||||
return alovaInstance.Post<ResponseData<LoginResult>>(`/auth/login/test`, params);
|
||||
}
|
||||
|
||||
export function bindQqApi(params: LoginQqParams) {
|
||||
return alovaInstance.Post<ResponseData<null>>(`/bind/qq`, params);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ const alovaInstance = createAlova({
|
||||
},
|
||||
|
||||
beforeRequest(method) {
|
||||
console.log(process.env.EXPO_PUBLIC_API_URL)
|
||||
|
||||
// 白名单接口直接放行
|
||||
if (isWhiteListed(method.url)) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
loadDataApi,
|
||||
saveDataApi,
|
||||
getUniqueIdApi,
|
||||
} from "@/request/api";
|
||||
import { useUserStore } from "./user";
|
||||
|
||||
interface FavoriteState {
|
||||
/** 当前家庭已收藏的 app_id 集合 */
|
||||
favorites: Set<string>;
|
||||
/** 是否已加载 */
|
||||
loaded: boolean;
|
||||
/** 操作中的 app_id */
|
||||
loadingIds: Set<string>;
|
||||
|
||||
/** 拉取收藏列表 */
|
||||
fetchFavorites: (familyId: number) => Promise<void>;
|
||||
/** 切换收藏状态 */
|
||||
toggleFavorite: (familyId: number, appId: string) => Promise<boolean>;
|
||||
/** 是否已收藏 */
|
||||
isFavorite: (appId: string) => boolean;
|
||||
/** 清空 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useFavoriteStore = create<FavoriteState>()((set, get) => ({
|
||||
favorites: new Set(),
|
||||
loaded: false,
|
||||
loadingIds: new Set(),
|
||||
|
||||
fetchFavorites: async (familyId: number) => {
|
||||
if (familyId === 0) return;
|
||||
const res = await loadDataApi({
|
||||
view_name: "b_app_favorite",
|
||||
search_condition: "family_id = ?",
|
||||
order_by: "id ASC",
|
||||
search_columns: [],
|
||||
args: [familyId],
|
||||
});
|
||||
if (res.isSuccess) {
|
||||
const ids = new Set(res.data.map((item: any) => item.app_id));
|
||||
set({ favorites: ids, loaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
toggleFavorite: async (familyId: number, appId: string) => {
|
||||
const { loadingIds, favorites } = get();
|
||||
if (loadingIds.has(appId)) return favorites.has(appId);
|
||||
|
||||
const newLoading = new Set(loadingIds);
|
||||
newLoading.add(appId);
|
||||
set({ loadingIds: newLoading });
|
||||
|
||||
const isFav = get().favorites.has(appId);
|
||||
|
||||
if (isFav) {
|
||||
// 已收藏 → 查 id → 删
|
||||
const checkRes = await loadDataApi({
|
||||
view_name: "b_app_favorite",
|
||||
search_condition: "family_id = ? AND app_id = ?",
|
||||
order_by: "",
|
||||
search_columns: [],
|
||||
args: [familyId, appId],
|
||||
});
|
||||
|
||||
const nextLoading = new Set(get().loadingIds);
|
||||
nextLoading.delete(appId);
|
||||
set({ loadingIds: nextLoading });
|
||||
|
||||
if (checkRes.isSuccess && checkRes.data.length > 0) {
|
||||
const row = checkRes.data[0];
|
||||
const delRes = await saveDataApi([
|
||||
{
|
||||
table_name: "b_app_favorite",
|
||||
key_field: "id",
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: [{ id: row.id }],
|
||||
},
|
||||
]);
|
||||
|
||||
if (delRes.isSuccess) {
|
||||
const newFavorites = new Set(get().favorites);
|
||||
newFavorites.delete(appId);
|
||||
set({ favorites: newFavorites });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true; // 失败时保持原状态
|
||||
} else {
|
||||
// 未收藏 → 取 id → 插入
|
||||
const userId = useUserStore.getState().userInfo.id;
|
||||
|
||||
const idRes = await getUniqueIdApi(1);
|
||||
|
||||
const nextLoading = new Set(get().loadingIds);
|
||||
nextLoading.delete(appId);
|
||||
set({ loadingIds: nextLoading });
|
||||
|
||||
if (idRes.isSuccess && idRes.data.length > 0) {
|
||||
const newId = idRes.data[0];
|
||||
const saveRes = await saveDataApi([
|
||||
{
|
||||
table_name: "b_app_favorite",
|
||||
key_field: "id",
|
||||
inserts: [
|
||||
{
|
||||
id: newId,
|
||||
family_id: familyId,
|
||||
user_id: userId,
|
||||
app_id: appId,
|
||||
},
|
||||
],
|
||||
updates: [],
|
||||
deletes: [],
|
||||
},
|
||||
]);
|
||||
|
||||
if (saveRes.isSuccess) {
|
||||
const newFavorites = new Set(get().favorites);
|
||||
newFavorites.add(appId);
|
||||
set({ favorites: newFavorites });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false; // 失败时保持原状态
|
||||
}
|
||||
},
|
||||
|
||||
isFavorite: (appId: string) => get().favorites.has(appId),
|
||||
|
||||
clear: () => set({ favorites: new Set(), loaded: false, loadingIds: new Set() }),
|
||||
}));
|
||||
@@ -2,3 +2,4 @@ export * from "./app";
|
||||
export * from "./user";
|
||||
export * from "./finance";
|
||||
export * from "./family";
|
||||
export * from "./favorite";
|
||||
@@ -4,6 +4,10 @@ export interface LoginQqParams {
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface LoginTestParams {
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
export interface LoginWxParams {
|
||||
code: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user