This commit is contained in:
oneao committed 2026-04-17 17:25:15 +08:00
1 parent e893ef2e48
commit b6415c0d65
25 files changed
+1495 -112

No files matched your search

+6 -6
View File
@@ -20,9 +20,9 @@ const tabs: TabbarItem[] = [
icon: HomeIcon,
},
{
id: "/chat",
title: "聊天",
icon: MessageCircleIcon,
id: "/space",
title: "空间",
icon: EarthIcon,
},
{
id: "/tools",
@@ -31,9 +31,9 @@ const tabs: TabbarItem[] = [
isCenter: true,
},
{
id: "/space",
title: "空间",
icon: EarthIcon,
id: "/chat",
title: "聊天",
icon: MessageCircleIcon,
},
{
id: "/my",
+36 -29
View File
@@ -1,36 +1,43 @@
import { Button, Dialog } from "heroui-native";
import { Button, Toast, useToast, useThemeColor } from "heroui-native";
import { View } from "react-native";
import { useState } from "react";
export default function DialogExample() {
const [isOpen, setIsOpen] = useState(false);
export default function ToastExample() {
const { toast } = useToast();
const themeColorForeground = useThemeColor("foreground");
return (
<>
<Button variant="primary" onPress={() => {
setIsOpen(true)
}}>Open Dialog</Button>
<View className="gap-4 p-4">
<Button
onPress={() =>
toast.show({
variant: "success",
label: "You have upgraded your plan",
description: "You can continue using HeroUI Chat",
actionLabel: "Close",
onActionPress: ({ hide }) => hide(),
})
}
>
Show Success Toast
</Button>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Close variant="ghost" />
<View className="mb-5 gap-1.5">
<Dialog.Title>Confirm Action</Dialog.Title>
<Dialog.Description>
Are you sure you want to proceed with this action? This cannot be undone.
</Dialog.Description>
</View>
<View className="flex-row justify-end gap-3">
<Button variant="ghost" size="sm" onPress={() => setIsOpen(false)}>
Cancel
</Button>
<Button size="sm">Confirm</Button>
</View>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</>
<Button
onPress={() =>
toast.show({
duration: 0,
component: (props) => (
<Toast variant="accent" {...props}>
<Toast.Title>Custom Toast</Toast.Title>
<Toast.Description>This uses a custom component</Toast.Description>
<Toast.Action onPress={() => props.hide()}>Undo</Toast.Action>
<Toast.Close className="absolute top-0 right-0" />
</Toast>
),
})
}
>
Show Custom Toast
</Button>
</View>
);
}
+556 -3
View File
@@ -1,13 +1,566 @@
import React from "react";
import React, { useEffect, useState, useRef } from "react";
import LayoutView from "@/components/layout/view";
import { Text } from "react-native";
import { View, Text } from "react-native";
import {
Button,
Separator,
Spinner,
Chip,
BottomSheet,
Label,
TextField,
Input,
InputOTP,
type InputOTPRef,
PressableFeedback,
useBottomSheetAwareHandlers,
} from "heroui-native";
import { PlusIcon, HousePlusIcon, GripIcon } from "lucide-react-native";
import { useUserStore } from "@/store";
import { getUniqueIdApi, loadDataApi, loadDataBySqlApi, saveDataApi } from "@/request/api";
import EmptyState from "@/components/EmptyStatus";
import Sortable from "react-native-sortables";
import { useSpaceStore } from "@/store";
import { useRouter } from "expo-router";
import { UserRoundIcon, UsersRoundIcon } from "lucide-react-native";
import { toast } from "sonner-native";
import { FadeIn } from "react-native-reanimated";
import { generateCode } from "@/utils/common";
export default function Space() {
const router = useRouter();
const userStore = useUserStore();
const spaceStore = useSpaceStore();
const ITEM_HEIGHT = 64;
const [spaces, setSpaces] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 加入
const [showJoin, setShowJoin] = useState(false);
const [joinLoading, setJoinLoading] = useState(false);
const [joinForm, setJoinForm] = useState({
invite_code: "",
});
const joinInputRef = useRef<InputOTPRef>(null);
// 新建
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
});
const [createLoading, setCreateLoading] = useState(false);
const createInputRef = useRef<any>(null);
const { onFocus, onBlur } = useBottomSheetAwareHandlers();
useEffect(() => {
if (showCreate) {
setTimeout(() => {
createInputRef.current?.focus();
}, 0);
}
}, [showCreate]);
useEffect(() => {
if (showJoin) {
setTimeout(() => {
joinInputRef.current?.focus();
}, 0);
}
}, [showJoin]);
useEffect(() => {
listUserSpaces();
}, []);
function listUserSpaces(loading = true) {
if (loading) {
setLoading(true);
}
loadDataApi({
view_name: "v_user_spaces",
search_condition: `v_user_id = ?`,
search_columns: [],
order_by: "",
args: [userStore.userInfo.id],
}).then((res) => {
if (res.isSuccess) {
setSpaces(res.data);
if (loading) {
setLoading(false);
}
}
});
}
async function handleCreate() {
if (createLoading) return;
if (createForm.name === "") {
toast.warning("空间名称不能为空");
return;
}
setCreateLoading(true);
try {
let inviteCode = "";
let isExist = true;
// 循环生成直到不存在
while (isExist) {
inviteCode = generateCode(6);
const res = await loadDataBySqlApi({
sql: `SELECT 1 FROM space WHERE invite_code = ? LIMIT 1`,
args: [inviteCode],
});
if (!res.isSuccess) {
throw new Error("校验邀请码失败");
}
isExist = res.data.length > 0;
}
// 设置是否是默认空间
let isDefault = false;
const defaultRes = await loadDataApi({
view_name: "v_user_spaces",
search_condition: `v_user_id = ?`,
search_columns: ["v_is_default"],
order_by: "",
args: [userStore.userInfo.id],
});
if (!defaultRes.isSuccess) {
return;
}
if (defaultRes.data.length == 0) {
isDefault = true;
}
// 创建
const autoRes = await getUniqueIdApi(2);
if (!autoRes.isSuccess) {
return;
}
const spaceId = autoRes.data[0];
const spaceMemberId = autoRes.data[1];
// 插入空间
const requestData = [];
requestData.push({
table_name: "space",
key_field: "id",
inserts: [
{
id: spaceId,
name: createForm.name,
invite_code: inviteCode,
owner_id: userStore.userInfo.id,
status: 0,
},
],
updates: [],
deletes: [],
});
// 插入空间用户
requestData.push({
table_name: "space_member",
key_field: "id",
inserts: [
{
id: spaceMemberId,
space_id: spaceId,
user_id: userStore.userInfo.id,
role: 0,
is_default: isDefault ? 1 : 0,
sort_number: defaultRes.data.length,
member_name: userStore.userInfo.nickname,
},
],
updates: [],
deletes: [],
});
const saveRes = await saveDataApi(requestData);
if (saveRes.isSuccess) {
toast.success("创建成功");
setShowCreate(false);
setCreateForm({
name: "",
});
listUserSpaces();
}
} catch (e) {
console.log(e);
} finally {
setCreateLoading(false);
}
}
async function handleJoin() {
if (joinLoading) return;
const code = joinForm.invite_code.trim();
if (!code) {
toast.warning("邀请码不能为空");
return;
}
if (!/^[A-Za-z0-9]{6}$/.test(code)) {
toast.warning("邀请码必须为6位字母或数字");
return;
}
setJoinLoading(true);
try {
const res = await loadDataBySqlApi({
sql: ` SELECT
s.id,
s.name,
EXISTS (
SELECT 1
FROM space_member sm
WHERE sm.space_id = s.id
AND sm.user_id = ?
) AS is_joined
FROM space s
WHERE s.invite_code = ?
LIMIT 1;
`,
args: [userStore.userInfo.id, code],
});
if (!res.isSuccess) return;
const row = res.data?.[0];
if (!row) {
toast.warning("邀请码无效");
return;
}
if (row.is_joined) {
toast.warning("你已经在该空间了");
return;
}
const autoRes = await getUniqueIdApi(1);
if (!autoRes.isSuccess) {
return;
}
// 设置是否是默认空间
let isDefault = false;
const defaultRes = await loadDataApi({
view_name: "v_user_spaces",
search_condition: `v_user_id = ?`,
search_columns: ["v_is_default"],
order_by: "",
args: [userStore.userInfo.id],
});
if (!defaultRes.isSuccess) {
return;
}
if (defaultRes.data.length == 0) {
isDefault = true;
}
// 加入
const requestData = [];
requestData.push({
table_name: "space_member",
key_field: "id",
inserts: [
{
id: autoRes.data[0],
space_id: row.id,
user_id: userStore.userInfo.id,
role: 1,
is_default: isDefault ? 1 : 0,
sort_number: defaultRes.data.length,
member_name: userStore.userInfo.nickname,
},
],
updates: [],
deletes: [],
});
const saveRes = await saveDataApi(requestData);
if (saveRes.isSuccess) {
toast.success("加入成功");
setShowJoin(false);
setJoinForm({
invite_code: "",
});
listUserSpaces();
}
} catch (e) {
console.error(e);
toast.error("加入失败,请稍后重试");
} finally {
setJoinLoading(false);
}
}
const renderItem = ({ item, index }: { item: any; index: number }) => (
<PressableFeedback
key={item.space_id}
onPress={() => {
router.navigate({
pathname: "/tab-space" as any,
params: {
spaceId: item.space_id,
},
});
}}
style={{
height: ITEM_HEIGHT,
}}
className={`rounded-xl flex-row items-center px-4 py-3 bg-white ${
index === spaces.length - 1 ? "mb-4" : ""
}`}
>
<View className="w-full flex-row items-center justify-between">
<View>
<View className="flex-row items-center gap-2">
<Text className="font-semibold text-gray-900">{item.name}</Text>
{item.owner_id === userStore.userInfo.id && (
<Chip size="sm" variant="soft" color="success">
创建者
</Chip>
)}
{item.v_is_default === 1 && (
<Chip size="sm" variant="secondary" color="default">
默认
</Chip>
)}
{item.space_id === spaceStore?.currentSpace?.space_id && (
<Chip size="sm" variant="primary" color="accent">
当前空间
</Chip>
)}
</View>
<View className="flex-row items-center gap-3 mt-3">
<View className="flex-row gap-1 items-center">
<UserRoundIcon size={12} />
<Text className="text-xs">{item.v_member_name}</Text>
</View>
<View className="flex-row gap-1 items-center">
<UsersRoundIcon size={12} />
<Text className={`text-xs `}>{item.v_member_count}</Text>
</View>
</View>
</View>
<Sortable.Handle>
<GripIcon size={20} />
</Sortable.Handle>
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
);
return (
<LayoutView>
<LayoutView.ScrollView>
<Text className="text-base text-black">我的空间</Text>
<View className="flex-row gap-4">
<Button
className="flex-1"
onPress={() => {
setShowCreate(true);
createInputRef.current?.focus();
}}
>
<PlusIcon color="#fff" size={20} />
<Button.Label>创建新空间</Button.Label>
</Button>
<Button
className="flex-1"
variant="outline"
onPress={() => {
setShowJoin(true);
}}
>
<HousePlusIcon size={20} />
<Button.Label>加入其它空间</Button.Label>
</Button>
</View>
<View className="flex-row my-3 items-center gap-3">
<Separator className="flex-1" />
<Text className="text-xs text-gray-400">我的空间</Text>
<Separator className="flex-1" />
</View>
{loading ? (
<View className="flex-1 items-center justify-center">
<Spinner size="lg" />
</View>
) : spaces.length === 0 ? (
<EmptyState />
) : (
<Sortable.Grid
keyExtractor={(item: any) => {
return String(item.space_id);
}}
activeItemScale={1}
columns={1}
data={spaces}
overDrag="vertical"
renderItem={renderItem}
rowGap={10}
highlight-next-line
customHandle
autoScrollActivationOffset={200}
onDragEnd={(dragData: any) => {
const list = dragData.data;
const updates = list.map((item: any, index: number) => ({
id: item.v_space_member_id,
sort_number: index,
}));
saveDataApi([
{
table_name: "space_member",
key_field: "id",
inserts: [],
updates,
deletes: [],
},
]).then((_res) => {
// console.log(res);
});
}}
/>
)}
</LayoutView.ScrollView>
<BottomSheet
isOpen={showCreate}
onOpenChange={(val) => {
if (!val) {
setCreateForm({
name: "",
});
setCreateLoading(false);
}
setShowCreate(val);
}}
>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View>
<TextField>
<Label>空间名称</Label>
<Input
ref={createInputRef}
placeholder="请输入空间名称"
onFocus={onFocus}
onBlur={onBlur}
value={createForm.name}
onChangeText={(val) =>
setCreateForm({
name: val,
})
}
/>
</TextField>
<Button className="mt-3" onPress={handleCreate}>
{createLoading && <Spinner entering={FadeIn.delay(50)} color="white" />}
<Button.Label>创建</Button.Label>
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<BottomSheet
isOpen={showJoin}
onOpenChange={(val) => {
if (!val) {
setJoinForm({
invite_code: "",
});
setJoinLoading(false);
}
setShowJoin(val);
}}
>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View>
<TextField>
<Label>邀请码</Label>
<InputOTP
ref={joinInputRef}
maxLength={6}
onFocus={onFocus}
onBlur={onBlur}
value={joinForm.invite_code}
onChange={(val) => {
setJoinForm({
invite_code: val,
});
}}
>
<InputOTP.Group>
<InputOTP.Slot index={0} />
<InputOTP.Slot index={1} />
<InputOTP.Slot index={2} />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot index={3} />
<InputOTP.Slot index={4} />
<InputOTP.Slot index={5} />
</InputOTP.Group>
</InputOTP>
</TextField>
<Button className="mt-3" onPress={handleJoin}>
{joinLoading && <Spinner entering={FadeIn.delay(50)} color="white" />}
<Button.Label>加入</Button.Label>
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
</LayoutView>
);
}
+2 -2
View File
@@ -18,11 +18,11 @@ export default function NotFound() {
className="mt-4"
onPress={() => {
requestAnimationFrame(() => {
router.replace("/home");
router.back();
});
}}
>
返回首页
返回
</Button>
}
/>
+33 -19
View File
@@ -8,10 +8,15 @@ import "@/global.css";
import { useEffect, useState } from "react";
import { Stack } from "expo-router";
import { Toaster } from "sonner-native";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { useUserStore } from "@/store";
import { useRouter } from "expo-router";
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const router = useRouter();
const userStore = useUserStore();
const isDark = useIsDark();
const isDev = __DEV__;
@@ -29,31 +34,40 @@ export default function RootLayout() {
init();
}, []);
useEffect(() => {
if (userStore.authExpired) {
requestAnimationFrame(() => {
router.replace("/auth/login");
});
}
}, [userStore.authExpired]);
if (!isReady) return null;
return (
<GestureHandlerRootView className="flex-1">
<ThemeProvider value={isDark ? DarkTheme : DefaultTheme}>
<HeroUINativeProvider
config={{
devInfo: {
stylingPrinciples: false,
},
}}
>
<Stack
screenOptions={{
animation: "slide_from_right",
contentStyle: {
backgroundColor: "#fff",
<KeyboardProvider>
<ThemeProvider value={isDark ? DarkTheme : DefaultTheme}>
<HeroUINativeProvider
config={{
devInfo: {
stylingPrinciples: false,
},
headerShown: false,
}}
/>
{!isDev && <StatusBar style={isDark ? "light" : "dark"} />}
</HeroUINativeProvider>
</ThemeProvider>
>
<Stack
screenOptions={{
animation: "slide_from_right",
contentStyle: {
backgroundColor: "#fff",
},
headerShown: false,
}}
/>
{!isDev && <StatusBar style={isDark ? "light" : "dark"} />}
</HeroUINativeProvider>
</ThemeProvider>
</KeyboardProvider>
<Toaster duration={1000} />
</GestureHandlerRootView>
);
+3 -1
View File
@@ -33,12 +33,14 @@ export default function Login() {
setTimeout(() => {
requestAnimationFrame(() => {
userStore.setUserInfo({
id: 1,
id: 779895419121733,
token: "123",
nickname: "Zhang",
avatar: "",
});
userStore.setAuthExpired(false);
router.replace("/(tabs)/home");
});
@@ -0,0 +1,43 @@
import React from "react";
import LayoutView from "@/components/layout/view";
import { View, Text } from "react-native";
import Navbar from "@/components/layout/navbar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { EarthIcon } from "lucide-react-native";
import { useRouter } from "expo-router";
export default function Space() {
const insets = useSafeAreaInsets();
const router = useRouter();
return (
<View style={{ flex: 1 }}>
<View style={{ flex: 1, zIndex: 0 }}>
<LayoutView>
<LayoutView.ScrollView>
<Text className="text-base text-black">Space</Text>
</LayoutView.ScrollView>
</LayoutView>
</View>
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 10,
}}
>
<View style={{ paddingTop: insets.top }}>
<Navbar
title={"空间"}
titleIcon={EarthIcon}
onBack={() => {
router.back();
}}
/>
</View>
</View>
</View>
);
}
+18 -1
View File
@@ -9,6 +9,12 @@ export function loginWxApi(params: LoginWxParams) {
return alovaInstance.Post<ResponseData<LoginResult>>(`/auth/login/wechat`, params);
}
export function getUniqueIdApi(count = 1) {
return alovaInstance.Post<ResponseData<number[]>>(`/data/getUniqueId`, {
count: count,
});
}
interface LoadDataBySqlParams {
sql: string;
args: any[];
@@ -23,10 +29,21 @@ interface LoadDataParams {
search_condition: string;
order_by: string;
search_columns: string[];
args: string[];
args: any[];
}
export function loadDataApi(params: LoadDataParams) {
return alovaInstance.Post<ResponseData<any[]>>(`/data/loadData`, params);
}
interface SaveDataParams {
table_name: string;
key_field: string;
inserts: any[];
updates: any[];
deletes: any[];
}
export function saveDataApi(params: SaveDataParams[]) {
return alovaInstance.Post<ResponseData<null>>(`/data/saveData`, params);
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./app";
export * from "./user";
export * from "./space";
+32
View File
@@ -0,0 +1,32 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface Space {
space_id: number;
}
interface SpaceState {
currentSpace: Space | null;
setCurrentSpace: (space: Space | null) => void;
clear: () => void;
inSpace: () => boolean;
}
export const useSpaceStore = create<SpaceState>()(
persist(
(set, get) => ({
currentSpace: null,
setCurrentSpace: (space) => set({ currentSpace: space }),
clear: () => set({ currentSpace: null }),
inSpace: () => {
const currentSpace = get().currentSpace;
return currentSpace !== null && currentSpace.space_id !== 0;
},
}),
{
name: "space-storage",
storage: createJSONStorage(() => AsyncStorage),
},
),
);
+13
View File
@@ -0,0 +1,13 @@
export function generateCode(
length: number = 6,
chars: string = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789",
): string {
let result = "";
const max = chars.length;
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * max));
}
return result;
}