This commit is contained in:
oneao committed 2026-03-03 17:28:56 +08:00
1 parent 2efeb7cee2
commit c0531c1d0a
35 files changed
+1817 -984

No files matched your search

@@ -100,7 +100,7 @@ export default function TabsLayout() {
}}
>
<TouchableOpacity className="w-12 items-center justify-center">
<LucideIcon icon="Bot" size={24} clickable={false} strokeWidth={2} />
<LucideIcon icon="Earth" size={24} clickable={false} strokeWidth={2} />
</TouchableOpacity>
<Divider orientation="vertical" className="h-8" />
@@ -10,7 +10,7 @@ import ListItem from './components/ListItem'
export default function User() {
const userStore = useUserStore()
console.log(userStore.userInfo)
return (
<SafeAreaView className="flex-1 p-4">
<View className="flex flex-row items-center">
@@ -1,12 +1,12 @@
import type { UserSpace } from '@/types/space'
import { useFocusEffect, useRouter } from 'expo-router'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { ActivityIndicator, Pressable, RefreshControl, Text, View } from 'react-native'
import { KeyboardAvoidingView } from 'react-native-keyboard-controller'
import Animated, { useAnimatedRef } from 'react-native-reanimated'
import Sortable from 'react-native-sortables'
import { toast } from 'sonner-native'
import { AButton, ADivider, AInput, LucideIcon } from '@/components/common/ao'
import { AButton, ADivider, AInput, AModal, LucideIcon } from '@/components/common/ao'
import EmptyState from '@/components/common/EmptyStatus'
import CustomHeader from '@/components/custom/Header'
import {
@@ -17,11 +17,13 @@ import {
ActionsheetDragIndicatorWrapper,
} from '@/components/ui/actionsheet'
import { Badge, BadgeText } from '@/components/ui/badge'
import { createSpaceApi, listUserSpacesApi, updateSpaceSortNumberApi } from '@/request/api/space/index'
import { createSpaceApi, joinSpaceApi, listUserSpacesApi, updateSpaceSortNumberApi } from '@/request/api/space/index'
import { useSpaceStore } from '@/store/space'
import { useUserStore } from '@/store/user'
export default function AuthSpace() {
const userStore = useUserStore()
const spaceStore = useSpaceStore()
const router = useRouter()
// scrollView 相关
@@ -29,6 +31,9 @@ export default function AuthSpace() {
const [scrollHeight, setScrollHeight] = useState(0)
const ITEM_HEIGHT = 64
const [loading, setLoading] = useState(false)
const [refreshLoading, setRefreshLoading] = useState(false)
// 空间列表
const [spaces, setSpaces] = useState<UserSpace[]>([])
@@ -51,11 +56,25 @@ export default function AuthSpace() {
}
}, [createOpen])
// 加入
const [showJoin, setShowJoin] = useState(false)
const [joinLoading, setJoinLoading] = useState(false)
const [joinForm, setJoinForm] = useState({
invite_code: '',
})
// 获取空间列表
function listUserSpaces() {
function listUserSpaces(loading = true) {
if (loading) {
setLoading(true)
}
listUserSpacesApi().then((res) => {
if (res.isSuccess) {
setSpaces(res.data.spaces)
if (loading) {
setLoading(false)
}
}
})
}
@@ -105,6 +124,14 @@ export default function AuthSpace() {
)
}
{
item.space_id === spaceStore?.currentSpace?.space_id && (
<Badge size="sm" className="bg-[#333] rounded-full">
<BadgeText className="text-white">当前空间</BadgeText>
</Badge>
)
}
</View>
<View className="flex-row items-center gap-3 mt-2">
@@ -161,7 +188,7 @@ export default function AuthSpace() {
return (
<View className="flex-1 bg-[#f5f6f8]">
<CustomHeader title="空间列表" />
<CustomHeader title="空间管理" />
<View className="flex-1 p-4">
<View className="">
@@ -182,6 +209,9 @@ export default function AuthSpace() {
className="flex-1 rounded-full"
size="sm"
leftIcon={<LucideIcon icon="HousePlus" />}
onPress={() => {
setShowJoin(true)
}}
>
加入其他空间
</AButton>
@@ -196,52 +226,69 @@ export default function AuthSpace() {
onLayout={(e) => {
setScrollHeight(e.nativeEvent.layout.height)
}}
refreshControl={(
<RefreshControl
refreshing={refreshLoading}
onRefresh={async () => {
setRefreshLoading(true)
await listUserSpaces(false)
setRefreshLoading(false)
}}
/>
)}
>
{spaces.length === 0
? (
<View className="flex-1 items-center justify-center mt-12">
<EmptyState
title="暂无空间"
description="请选择 创建 或 加入 空间"
/>
</View>
)
: (
<Sortable.Grid
scrollableRef={scrollableRef}
keyExtractor={(item: UserSpace) => {
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) => {
const newData = dragData.data
if (JSON.stringify(spaces) !== JSON.stringify(newData)) {
const updateData = newData.map((item, index) => {
return {
space_id: item.space_id,
sort_number: index,
}
})
{
loading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#333" />
</View>
)
: spaces.length === 0
? (
<View className="flex-1 items-center justify-center mt-12">
<EmptyState
title="暂无空间"
description="请选择 创建 或 加入 空间"
/>
</View>
) : (
<Sortable.Grid
scrollableRef={scrollableRef}
keyExtractor={(item: UserSpace) => {
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) => {
const newData = dragData.data
if (JSON.stringify(spaces) !== JSON.stringify(newData)) {
const updateData = newData.map((item, index) => {
return {
space_id: item.space_id,
sort_number: index,
}
})
updateSpaceSortNumberApi({
spaces: updateData,
}).then((res) => {
if (res.isSuccess) {
// 更新成功
updateSpaceSortNumberApi({
spaces: updateData,
}).then((res) => {
if (res.isSuccess) {
// 更新成功
}
})
}
})
}
}}
/>
)}
}}
/>
)
}
</Animated.ScrollView>
</View>
@@ -323,6 +370,84 @@ export default function AuthSpace() {
</ActionsheetContent>
</KeyboardAvoidingView>
</Actionsheet>
<AModal
title="加入空间"
isOpen={showJoin}
onClose={() => {
setShowJoin(false)
setTimeout(() => {
setJoinForm({
invite_code: '',
})
}, 300)
}}
body={(
<View className="px-3">
<AInput
placeholder="请输入空间邀请码"
value={joinForm.invite_code}
onChangeText={(text) => {
setJoinForm({
invite_code: text,
})
}}
/>
</View>
)}
footer={(
<View className="w-full px-3 pb-3">
<AButton
loading={joinLoading}
onPress={async () => {
if (joinForm.invite_code === '') {
toast.warning('邀请码不能为空')
return
}
setJoinLoading(true)
try {
await joinSpaceApi({
invite_code: joinForm.invite_code,
member_name: userStore.userInfo.nickname,
}).then((res) => {
if (res.isSuccess) {
toast.success('加入成功')
setShowJoin(false)
setTimeout(() => {
setJoinForm({
invite_code: '',
})
listUserSpaces()
}, 300)
}
})
}
finally {
setJoinLoading(false)
}
}}
>
加入
</AButton>
<ADivider text="或" />
<AButton
loading={joinLoading}
variant="outline"
onPress={() => {
toast.warning('功能待实现,敬请期待!')
}}
>
扫码加入
</AButton>
</View>
)}
/>
</View>
)
}
@@ -1,7 +1,9 @@
import type { UserSpace } from '@/types/space'
import * as Clipboard from 'expo-clipboard'
import { useLocalSearchParams } from 'expo-router'
import { useEffect, useState } from 'react'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { useCallback, useEffect, useState } from 'react'
import {
ActivityIndicator,
Modal,
Pressable,
Text,
@@ -12,69 +14,159 @@ import QRCode from 'react-native-qrcode-svg'
import { toast } from 'sonner-native'
import { AButton, ADivider, LucideIcon } from '@/components/common/ao'
import CustomHeader from '@/components/custom/Header'
import { getSpaceSettingsApi, refreshInviteCodeApi } from '@/request/api/space'
import { useUserStore } from '@/store/user'
export default function Invite() {
const params = useLocalSearchParams<{ invite_code: string }>()
const { invite_code } = params
const router = useRouter()
const userStore = useUserStore()
const params = useLocalSearchParams<{ space_id: string }>()
const { space_id } = params
const [loading, setLoading] = useState(false)
const [refreshLoading, setRefreshLoading] = useState(false)
const [space, setSpace] = useState<UserSpace | null>(null)
const [qrcode, setQrcode] = useState('space:')
const [previewVisible, setPreviewVisible] = useState(false)
async function copyInviteCode() {
await Clipboard.setStringAsync(invite_code)
toast.success('邀请码已复制')
if (space) {
await Clipboard.setStringAsync(space.invite_code)
toast.success('邀请码已复制')
}
else {
toast.error('邀请码无效')
}
}
async function getSpace() {
setLoading(true)
await getSpaceSettingsApi({
space_id: Number(space_id),
}).then((res) => {
if (res.isSuccess) {
setSpace(res.data)
setLoading(false)
}
})
}
useEffect(() => {
setQrcode(`space:${invite_code}`)
console.log(`space:${invite_code}`)
}, [invite_code])
if (space) {
setQrcode(`space:${space.invite_code}`)
}
}, [space])
useFocusEffect(
useCallback(() => {
getSpace()
}, []),
)
function refreshInviteCode() {
if (!space) {
toast.error('空间无效')
router.back()
return
}
setRefreshLoading(true)
refreshInviteCodeApi({
space_id: space.space_id,
}).then((res) => {
if (res.isSuccess) {
setSpace(prev =>
prev
? {
...prev,
invite_code: res.data.invite_code,
}
: prev,
)
setRefreshLoading(false)
toast.success('刷新成功')
}
})
}
return (
<View className="flex-1 bg-[#f5f6f8]">
<CustomHeader title="分享空间" />
<View className="px-5 pt-6">
{
loading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#333" />
</View>
) : (
<View className="px-5 pt-6">
{/* 邀请码卡片 */}
<View className="bg-white rounded-2xl p-6 items-center">
<Text className="text-gray-500 text-sm mb-3">
复制邀请码邀请家庭成员加入
</Text>
{/* 邀请码卡片 */}
<View className="bg-white rounded-2xl p-6 items-center">
<Text className="text-gray-500 text-sm mb-3">
复制邀请码邀请家庭成员加入
</Text>
<Text className="text-4xl font-bold tracking-[8px] text-gray-900 mb-4">
{invite_code}
</Text>
<Text className="text-4xl font-bold tracking-[8px] text-gray-900 mb-4">
{space?.invite_code}
</Text>
<AButton
onPress={copyInviteCode}
className="rounded-full"
leftIcon={
<LucideIcon icon="Copy" size={16} color="#fff" clickable={false} />
}
>
复制邀请码
</AButton>
</View>
<View className="flex-row gap-3">
<AButton
onPress={copyInviteCode}
className="rounded-full flex-1"
leftIcon={
<LucideIcon icon="Copy" size={16} color="#fff" clickable={false} />
}
>
复制邀请码
</AButton>
<View className="my-8">
<ADivider direction="horizontal" text="或扫描二维码" />
</View>
{
userStore.userInfo.id === space?.owner_id && (
<AButton
variant="outline"
className="rounded-full px-4"
leftIcon={
<LucideIcon icon="RefreshCw" size={16} clickable={false} />
}
loading={refreshLoading}
onPress={refreshInviteCode}
>
刷新
</AButton>
)
}
{/* 二维码卡片 */}
<TouchableOpacity
activeOpacity={0.9}
onPress={() => setPreviewVisible(true)}
className="bg-white rounded-2xl py-8 items-center"
>
<QRCode
size={160}
value={qrcode || ''}
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className="my-8">
<ADivider direction="horizontal" text="或扫描二维码" />
</View>
{/* 二维码卡片 */}
<TouchableOpacity
activeOpacity={0.9}
onPress={() => setPreviewVisible(true)}
className="bg-white rounded-2xl py-8 items-center"
>
<QRCode
size={160}
value={qrcode || ''}
/>
</TouchableOpacity>
</View>
)
}
{/* 全屏预览(无动画) */}
<Modal
@@ -1,10 +0,0 @@
import { View } from 'react-native'
import CustomHeader from '@/components/custom/Header'
export default function () {
return (
<View className="flex-1">
<CustomHeader title="成员管理" />
</View>
)
}
@@ -0,0 +1,379 @@
import type { SpaceMember, UserSpace } from '@/types/space'
import { useFocusEffect, useLocalSearchParams } from 'expo-router'
import { useCallback, useState } from 'react'
import { ActivityIndicator, RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native'
import { toast } from 'sonner-native'
import { ADivider, AModal, LucideIcon } from '@/components/common/ao'
import EmptyState from '@/components/common/EmptyStatus'
import CustomHeader from '@/components/custom/Header'
import {
Actionsheet,
ActionsheetBackdrop,
ActionsheetContent,
ActionsheetDragIndicator,
ActionsheetDragIndicatorWrapper,
ActionsheetItem,
ActionsheetItemText,
} from '@/components/ui/actionsheet'
import {
Avatar,
AvatarFallbackText,
AvatarImage,
} from '@/components/ui/avatar'
import { Badge, BadgeText } from '@/components/ui/badge'
import { getSpaceSettingsApi, kickoutSpaceMemberApi, listSpaceMembersApi, transferSpaceApi } from '@/request/api/space'
import { useUserStore } from '@/store/user'
import { formatRelativeTime } from '@/utils/time'
export default function () {
const userStore = useUserStore()
const params = useLocalSearchParams<{ space_id: string }>()
const { space_id } = params
const [space, setSpace] = useState<UserSpace | null>(null)
const [members, setMembers] = useState<SpaceMember[]>([])
const [loading, setLoading] = useState(true)
const [refreshLoading, setRefreshLoading] = useState(false)
const [showAction, setShowAction] = useState(false)
const [currentMember, setCurrentMember] = useState<SpaceMember | null>(null)
// 踢出空间
const [showKickout, setShowKickout] = useState(false)
// 转让空间
const [showTransfer, setShowTransfer] = useState(false)
async function listSpaceMembers() {
await listSpaceMembersApi({
space_id: Number(space_id),
}).then((res) => {
if (res.isSuccess) {
setMembers(res.data.members)
}
})
}
async function getSpace() {
await getSpaceSettingsApi({
space_id: Number(space_id),
}).then((res) => {
if (res.isSuccess) {
setSpace(res.data)
}
})
}
async function loadData(loading = true) {
if (loading) {
setLoading(true)
}
await Promise.all([
getSpace(),
listSpaceMembers(),
])
if (loading) {
setLoading(false)
}
}
useFocusEffect(
useCallback(() => {
if (space_id) {
loadData()
}
}, []),
)
return (
<View className="flex-1 ">
<CustomHeader
title="空间成员"
/>
<View className="flex-1 bg-[#f5f6f8] relative">
{
loading
? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#333" />
</View>
)
: space === null
? (
<EmptyState title="空间不存在或已解散" />
)
: (
<>
<View className="pt-6 pb-24 px-4 bg-[#333] items-center">
<Text className="text-lg text-white font-semibold">
{space.name || ''}
</Text>
<View className="flex-row gap-2 mt-3">
{space.is_default === 1 && (
<Badge className="rounded-full bg-[#515151] px-3">
<BadgeText className="text-white">默认</BadgeText>
</Badge>
)}
</View>
</View>
<View className="mt-[-60] p-6 bg-white h-full rounded-tl-2xl rounded-tr-2xl">
<View className="flex-row items-center justify-between">
<Text className="text-gray-600 font-semibold">
空间成员
</Text>
<Text>
{members.length}
</Text>
</View>
<ScrollView
className="pt-4"
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
refreshControl={(
<RefreshControl
refreshing={refreshLoading}
onRefresh={async () => {
setRefreshLoading(true)
await loadData(false)
setRefreshLoading(false)
}}
/>
)}
>
{
members.map((item) => {
return (
<TouchableOpacity
className="flex-row justify-between items-center pb-4"
key={item.user_id}
onPress={() => {
setCurrentMember(item)
setShowAction(true)
}}
>
<View className="flex-row items-center">
<Avatar size="md">
<AvatarFallbackText>{item.member_name}</AvatarFallbackText>
<AvatarImage
source={{
uri: item.avatar,
}}
/>
</Avatar>
<View className="ml-3">
<Text className="text-sm">{item.member_name}</Text>
<View className="mt-2 gap-2 flex-row items-center">
<View className="flex-row gap-1 items-center">
<LucideIcon icon="Clock" size={12} color="#6b7280" clickable={false} />
<Text className="text-xs text-gray-500">
{formatRelativeTime(item.last_login_time)}
</Text>
</View>
</View>
</View>
</View>
<View className="flex-row gap-2">
{
item.role === 0 && (
<Badge size="sm" className="rounded-full mt-1">
<BadgeText>创建人</BadgeText>
</Badge>
)
}
{
item.member_status === 1 && (
<Badge size="sm" className="rounded-full mt-1" action="error">
<BadgeText>禁用</BadgeText>
</Badge>
)
}
</View>
</TouchableOpacity>
)
})
}
</ScrollView>
</View>
</>
)
}
</View>
<Actionsheet
isOpen={showAction}
onClose={() => {
setShowAction(false)
setTimeout(() => {
setCurrentMember(null)
}, 300)
}}
>
<ActionsheetBackdrop />
<ActionsheetContent style={{
paddingBottom: 16,
}}
>
<ActionsheetDragIndicatorWrapper>
<ActionsheetDragIndicator />
</ActionsheetDragIndicatorWrapper>
<ActionsheetItem
onPress={() => {
toast.warning('功能待开发,敬请期待!')
}}
>
<LucideIcon icon="SquareUserRound" clickable={false} color="#333" />
<ActionsheetItemText>查看资料</ActionsheetItemText>
</ActionsheetItem>
{
currentMember?.user_id !== userStore.userInfo.id && (
<ActionsheetItem
onPress={() => {
toast.warning('功能待开发,敬请期待!')
}}
>
<LucideIcon icon="UserPen" clickable={false} color="#333" />
<ActionsheetItemText>修改备注</ActionsheetItemText>
</ActionsheetItem>
)
}
{
space?.owner_id === userStore.userInfo.id
&& currentMember?.user_id !== userStore.userInfo.id && (
<>
<ADivider />
<ActionsheetItem
onPress={() => {
setShowTransfer(true)
}}
>
<LucideIcon icon="Rotate3d" clickable={false} color="#333" />
<ActionsheetItemText>转让空间</ActionsheetItemText>
</ActionsheetItem>
<ActionsheetItem
onPress={() => {
toast.warning('功能待开发,敬请期待!')
}}
>
<LucideIcon icon="ShieldAlert" clickable={false} color="#333" />
<ActionsheetItemText>禁用</ActionsheetItemText>
</ActionsheetItem>
<ADivider />
<ActionsheetItem
onPress={() => {
setShowKickout(true)
}}
>
<LucideIcon icon="CircleX" clickable={false} color="#333" />
<ActionsheetItemText>踢出空间</ActionsheetItemText>
</ActionsheetItem>
</>
)
}
</ActionsheetContent>
</Actionsheet>
<AModal
isOpen={showKickout}
title="提示"
body="确定该用户踢出空间?"
onCancel={() => {
setShowKickout(false)
}}
onClose={() => {
setShowKickout(false)
}}
onConfirm={async () => {
if (!currentMember) {
toast.warning('选中成员无效')
return
}
await kickoutSpaceMemberApi({
space_id: currentMember.space_id,
member_id: currentMember.user_id,
}).then((res) => {
if (res.isSuccess) {
toast.success('已将该用户踢出空间')
setShowKickout(false)
setShowAction(false)
setTimeout(() => {
setCurrentMember(null)
loadData()
}, 300)
}
})
}}
/>
<AModal
isOpen={showTransfer}
title="提示"
body={`确定将该空间的所有权转让给 ${currentMember?.member_name || '未知用户'} ?`}
onCancel={() => {
setShowTransfer(false)
}}
onClose={() => {
setShowTransfer(false)
}}
onConfirm={async () => {
if (!currentMember) {
toast.warning('选中成员无效')
return
}
await transferSpaceApi({
space_id: currentMember.space_id,
member_id: currentMember.user_id,
}).then((res) => {
if (res.isSuccess) {
toast.success('已成功转让空间')
setSpace((val) => {
if (!val)
return val
return {
...val,
owner_id: currentMember.user_id,
}
})
setShowTransfer(false)
setShowAction(false)
setTimeout(() => {
setCurrentMember(null)
loadData()
}, 300)
}
})
}}
/>
</View>
)
}
@@ -1,12 +1,12 @@
import type { UserSpace } from '@/types/space'
import { router, useLocalSearchParams } from 'expo-router'
import { useEffect, useRef, useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native'
import { KeyboardAvoidingView } from 'react-native-keyboard-controller'
import { toast } from 'sonner-native'
import { AButton, AInput, AModal, ASwitch, LucideIcon } from '@/components/common/ao'
import EmptyState from '@/components/common/EmptyStatus'
import CustomHeader from '@/components/custom/Header'
import {
Actionsheet,
ActionsheetBackdrop,
@@ -14,7 +14,8 @@ import {
ActionsheetDragIndicator,
ActionsheetDragIndicatorWrapper,
} from '@/components/ui/actionsheet'
import { dissolveSpaceApi, exitSpaceApi, getSpaceSettingsApi, updateSpaceDefaultApi, updateSpaceMemberNameApi, updateSpaceNameApi } from '@/request/api/space'
import { dissolveSpaceApi, enterSpaceApi, exitSpaceApi, getSpaceSettingsApi, updateSpaceDefaultApi, updateSpaceMemberNameApi, updateSpaceNameApi } from '@/request/api/space'
import { useSpaceStore } from '@/store/space'
import { useUserStore } from '@/store/user'
interface ItemProps {
@@ -60,6 +61,7 @@ function Item({
export default function Settings() {
const userStore = useUserStore()
const spaceStore = useSpaceStore()
const params = useLocalSearchParams<{ spaceId: string }>()
const { spaceId } = params
@@ -100,21 +102,19 @@ export default function Settings() {
}
}, [showName])
useEffect(() => {
if (!spaceId) {
toast.warning('空间无效')
router.back()
}
else {
getSpaceSettingsApi({
space_id: Number(spaceId),
}).then((res) => {
if (res.isSuccess) {
setSpace(res.data)
}
})
}
}, [spaceId])
useFocusEffect(
useCallback(() => {
if (spaceId) {
getSpaceSettingsApi({
space_id: Number(spaceId),
}).then((res) => {
if (res.isSuccess) {
setSpace(res.data)
}
})
}
}, []),
)
useEffect(() => {
if (space) {
@@ -131,10 +131,41 @@ export default function Settings() {
{
!space
? (
<EmptyState title="正在加载空间中 . . ." />
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#333" />
</View>
)
: (
<View className="flex-1 px-4 pt-4">
<View className="bg-white rounded-xl overflow-hidden mb-6">
<Item
icon="Earth"
title="进入空间"
onPress={async () => {
const toastId = toast.loading('正在进入中 ...', {
duration: Infinity,
})
try {
enterSpaceApi({
space_id: space.space_id,
}).then((res) => {
if (res.isSuccess) {
toast.success('进入成功')
spaceStore.setCurrentSpace({
space_id: space.space_id,
role: space.role,
})
router.navigate('/money')
}
})
}
finally {
toast.dismiss(toastId)
}
}}
/>
</View>
<Text className="text-sm font-semibold text-gray-400 mb-2 tracking-wider">
常用设置
@@ -175,7 +206,7 @@ export default function Settings() {
<Item
icon="Star"
title="设为默认空间"
title="默认空间"
isLast
showRightIcon={false}
right={(
@@ -229,7 +260,7 @@ export default function Settings() {
router.navigate({
pathname: '/space/manage/sub-page/invite',
params: {
invite_code: space.invite_code,
space_id: space.space_id,
},
})
}}
@@ -245,7 +276,15 @@ export default function Settings() {
isOwner && (
<Item
icon="Users"
title="成员管理"
title="空间成员"
onPress={() => {
router.navigate({
pathname: '/space/manage/sub-page/member',
params: {
space_id: space.space_id,
},
})
}}
/>
)
}
@@ -432,7 +471,7 @@ export default function Settings() {
{/* 解散 */}
<AModal
title="提示"
body={isOwner ? '确认解散该空间么?' : '确定解散空间么?'}
body={isOwner ? '确认解散该空间么?' : '确定退出空间么?'}
isOpen={showDissolve}
onClose={() => {
setShowDissolve(false)
@@ -1,7 +1,6 @@
import { useRouter } from 'expo-router'
import { useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { AModal } from '@/components/common/ao'
import CustomHeader from '@/components/custom/Header'
import { useAppStore } from '@/store/app'
@@ -14,7 +13,7 @@ export default function Share() {
function exitLogin() {
setShowExitModal(false)
appStore.reset()
// appStore.reset()
router.replace('/auth')
}
@@ -1,81 +0,0 @@
import type { AFormRef } from '@/components/common/ao'
import { useRef, useState } from 'react'
import { Text, View } from 'react-native'
import { AButton, ADivider, AForm, AFormItem, AInput } from '@/components/common/ao'
import { Link, LinkText } from '@/components/ui/link'
interface JoinSpaceProps {
onBack?: () => void
onJoin?: (code: string) => void
}
export default function JoinSpace({
onBack,
onJoin,
}: JoinSpaceProps) {
const formRef = useRef<AFormRef>(null)
const [form, setForm] = useState({
code: '',
})
return (
<View className="px-4 items-center">
<AForm ref={formRef} form={form}>
<AFormItem
name="username"
label="邀请码"
>
<AInput
placeholder="请输入邀请码"
value={form.code}
onChangeText={text => setForm({
...form,
code: text,
})}
>
</AInput>
</AFormItem>
</AForm>
<AButton
className="w-full"
onPress={() => {
if (form.code === '') {
//
}
else {
onJoin?.(form.code)
}
}}
>
加入空间
</AButton>
<View
className="flex-row items-center w-full"
style={{
marginTop: 16,
marginBottom: 16,
}}
>
<View className="flex-1">
<ADivider />
</View>
<Text className="mx-3 text-gray-500 text-sm">或</Text>
<View className="flex-1">
<ADivider />
</View>
</View>
<AButton variant="outline" className="w-full">扫码加入</AButton>
<Link
className="mt-4"
onPress={() => {
onBack?.()
}}
>
<LinkText>返回</LinkText>
</Link>
</View>
)
}
@@ -1,37 +0,0 @@
import { Text, View } from 'react-native'
import { AButton, ADivider, LucideIcon } from '@/components/common/ao'
interface SpaceHomeProps {
onCreate?: () => void
onJoin?: () => void
}
export default function SpaceHome({
onCreate,
onJoin,
}: SpaceHomeProps) {
return (
<View className="p-4">
<View className="flex-col gap-4 justify-center items-center">
<LucideIcon icon="Earth" size={48} strokeWidth={1.5} />
<Text className="font-semibold text-xl">创建共享空间</Text>
<Text className="text-base text-center">创建一个专属空间,邀请朋友加入,共享文件、聊天和协作</Text>
<AButton
className="w-full"
onPress={() => onCreate?.()}
>
创建我的空间
</AButton>
</View>
<ADivider text="或" />
<AButton
variant="outline"
onPress={() => onJoin?.()}
>
加入已有空间
</AButton>
</View>
)
}
@@ -1,333 +0,0 @@
import type { SpaceMember } from '@/types/space'
import * as Clipboard from 'expo-clipboard'
import { useState } from 'react'
import { FlatList, TouchableHighlight, TouchableOpacity, View } from 'react-native'
import {
Menu,
MenuOption,
MenuOptions,
MenuTrigger,
renderers,
} from 'react-native-popup-menu'
import QRCode from 'react-native-qrcode-svg'
import { AAvatar, AButton, ADivider, LucideIcon } from '@/components/common/ao'
import { AModal } from '@/components/common/ao/index'
import {
Accordion,
AccordionContent,
AccordionHeader,
AccordionIcon,
AccordionItem,
AccordionTitleText,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Badge, BadgeText } from '@/components/ui/badge'
import { Heading } from '@/components/ui/heading'
import { HStack } from '@/components/ui/hstack'
import { ChevronDownIcon, ChevronUpIcon } from '@/components/ui/icon'
import { Text } from '@/components/ui/text'
import { VStack } from '@/components/ui/vstack'
import { dissolveSpaceApi, exitSpaceApi, removeSpaceMemberApi } from '@/request/api/space/index'
import { useUserStore } from '@/store/user'
import { formatRelativeTime } from '@/utils/time'
import { toast } from '@/utils/toast'
type ExitAction = 'exit' | 'remove' | 'dissolve'
interface ExitParams {
type: ExitAction
userId?: number
}
interface SpaceInviteProps {
userSpace: UserSpace | null
spaceUsers: SpaceMember[]
onExit?: (params: ExitParams) => void
}
export default function SpaceInvite({
userSpace,
spaceUsers,
onExit,
}: SpaceInviteProps) {
const userStore = useUserStore()
// 踢出 退出 解散
const [modal, setModal] = useState<{
open: boolean
title: string
type: ExitAction
userId?: number
}>({
open: false,
title: '',
type: 'dissolve',
})
const [accordion, setAccordion] = useState(['a'])
async function copyInviteCode() {
await Clipboard.setStringAsync('hello world')
toast.show({
title: '复制成功',
})
}
function handleModalConfirm() {
if (userSpace === null) {
return
}
if (modal.type === 'exit') {
// 退出
exitSpaceApi({
space_id: userSpace.id,
}).catch((err) => {
console.log(err)
})
}
else if (modal.type === 'remove') {
if (!modal.userId) {
return
}
// 移除
removeSpaceMemberApi({
space_id: userSpace.id,
user_id: modal.userId,
}).catch((err) => {
console.log(err)
})
}
else {
// 解散
dissolveSpaceApi({
space_id: userSpace.id,
}).catch((err) => {
console.log(err)
})
}
setModal({
...modal,
open: false,
})
onExit?.({
type: modal.type,
userId: modal.userId,
})
}
return (
<View className="px-4 flex-1">
<Accordion
size="md"
variant="unfilled"
type="multiple"
isCollapsible={true}
isDisabled={false}
value={accordion}
onValueChange={(value) => {
setAccordion(value)
}}
>
<AccordionItem value="a">
<AccordionHeader>
<AccordionTrigger
style={{
paddingLeft: 0,
paddingRight: 0,
}}
>
{({ isExpanded }: { isExpanded: boolean }) => {
return (
<>
<AccordionTitleText className="font-semibold text-lg">
邀请码
</AccordionTitleText>
{isExpanded
? (
<AccordionIcon as={ChevronUpIcon} className="ml-3" />
)
: (
<AccordionIcon as={ChevronDownIcon} className="ml-3" />
)}
</>
)
}}
</AccordionTrigger>
</AccordionHeader>
<AccordionContent style={{
marginBottom: 0,
paddingBottom: 0,
}}
>
<TouchableOpacity
className="flex-row items-center justify-center"
onPress={() => {
copyInviteCode()
}}
>
<Text className="font-bold text-3xl tracking-[4px] mr-3">{userSpace?.invite_code}</Text>
<LucideIcon icon="Copy" size={16} color="blue" clickable={false} />
</TouchableOpacity>
<View className="items-center justify-center">
<ADivider direction="horizontal" text="扫描二维码" />
<QRCode
size={120}
value={userSpace?.invite_code || ''}
/>
</View>
</AccordionContent>
</AccordionItem>
</Accordion>
<View className="mt-4 flex-1">
<View className="flex flex-row items-center justify-between">
<Text className="font-semibold text-lg">空间成员</Text>
<Badge size="lg" variant="solid" action="muted">
<BadgeText>
{spaceUsers.length}
{' '}
人
</BadgeText>
</Badge>
</View>
<View>
<FlatList
data={spaceUsers}
keyExtractor={item => item.id.toString()}
renderItem={({ item }) => (
<TouchableOpacity>
<HStack space="md" className="my-2 items-center">
<AAvatar avatar={item.avatar} username={item.username} showBadge={true} />
<VStack>
<HStack space="sm">
<Heading size="sm">{item.username}</Heading>
{item.role === 0 && (
<Badge size="sm" variant="solid" action="muted">
<BadgeText>创建者</BadgeText>
</Badge>
)}
{
userStore.userInfo?.id === item.id && (
<Badge size="sm" variant="solid" action="info">
<BadgeText>
我
</BadgeText>
</Badge>
)
}
</HStack>
<Text size="sm" className="mt-1">{formatRelativeTime(item.last_login_time)}</Text>
</VStack>
{userSpace?.role === 0 && (
<View className="ml-auto">
<Menu
renderer={renderers.Popover}
rendererProps={{
placement: 'bottom',
preferredPlacement: 'bottom',
anchorStyle: { backgroundColor: 'transparent' },
closeAnimationDuration: 0,
}}
>
<MenuTrigger>
<View className="w-12 items-center justify-center h-10">
<LucideIcon icon="Menu" size={20} clickable={false} />
</View>
</MenuTrigger>
<MenuOptions
customStyles={{
optionsContainer: {
backgroundColor: 'white',
minWidth: 160,
},
}}
>
{userSpace.role === 0 && userStore.userInfo?.id !== item.id && (
<MenuOption
customStyles={{
OptionTouchableComponent: TouchableHighlight,
optionTouchable: {
underlayColor: '#f0f0f0',
activeOpacity: 0.7,
},
}}
onSelect={() => {
setModal({
...modal,
open: true,
title: `确定将 ${item.username} 提出空间?`,
type: 'remove',
userId: item.id,
})
}}
>
<View className="flex flex-row items-center h-10 px-2">
<Text className="text-lg">踢出空间</Text>
</View>
</MenuOption>
)}
</MenuOptions>
</Menu>
</View>
)}
</HStack>
</TouchableOpacity>
)}
/>
</View>
<AButton
size="sm"
className="mb-8 mt-4"
onPress={() => {
if (userSpace?.role === 0) {
setModal({
...modal,
open: true,
title: `确定解散该空间么?不可恢复!`,
type: 'dissolve',
})
}
else {
setModal({
...modal,
open: true,
title: `确定退出该空间么?`,
type: 'exit',
})
}
}}
>
{userSpace?.role === 0 ? '解散空间' : '退出空间'}
</AButton>
</View>
<AModal
title={modal.title}
isOpen={modal.open}
onCancel={() => {
setModal({
...modal,
open: false,
})
}}
onClose={() => {
setModal({
...modal,
open: false,
})
}}
onConfirm={() => {
handleModalConfirm()
}}
/>
</View>
)
}
@@ -1,21 +0,0 @@
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
interface StepFadeProps {
visible: boolean
children: React.ReactNode
}
export default function StepFade({ visible, children }: StepFadeProps) {
if (!visible)
return null
return (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={{ flex: 1 }}
>
{children}
</Animated.View>
)
}
@@ -1,155 +0,0 @@
import type { SpaceMember } from '@/types/space'
import { useEffect, useState } from 'react'
import { View } from 'react-native'
import CustomHeader from '@/components/custom/Header'
import { createSpaceApi, joinSpaceApi, listSpaceMembersApi } from '@/request/api/space/index'
import { useSpaceStore } from '@/store/space'
import { useUserStore } from '@/store/user'
import JoinSpace from './components/JoinSpace'
import SpaceHome from './components/SpaceHome'
import SpaceInvite from './components/SpaceInvite'
import StepFade from './components/StepFade'
export default function PagerWithTab() {
const spaceStore = useSpaceStore()
const userStore = useUserStore()
const [headerTitle, setHeaderTitle] = useState('我的空间')
const [step, setStep] = useState<'join' | 'invite' | 'home'>('home')
const [spaceUsers, setSpaceUsers] = useState<SpaceMember[]>([])
// 封装获取空间用户列表并直接更新状态
const fetchSpaceUsers = async (spaceId: number) => {
try {
const res = await listSpaceMembersApi({ space_id: spaceId })
if (res.isSuccess) {
setSpaceUsers(res.data.members)
console.log(res.data.members)
}
else {
console.warn('获取空间用户失败')
setSpaceUsers([])
}
}
catch (err) {
console.error('listSpaceUsersApi error:', err)
setSpaceUsers([])
}
}
// 页面加载时判断并拉取用户列表
useEffect(() => {
const loadSpaceUsers = () => {
if (spaceStore.userSpace !== null) {
setStep('invite')
fetchSpaceUsers(spaceStore.userSpace.id)
}
else {
setStep('home')
}
}
loadSpaceUsers()
}, [])
useEffect(() => {
if (spaceStore.userSpace) {
setHeaderTitle(spaceStore.userSpace.name || '我的空间')
}
else {
setHeaderTitle('我的空间')
}
}, [spaceStore.userSpace])
function getSpaceName() {
const username = userStore.userInfo?.username
return username ? `${username}的空间` : '我的空间'
}
function createSpace() {
createSpaceApi({
name: getSpaceName(),
}).then((res) => {
if (res.isSuccess) {
spaceStore.setUserSpace({
id: res.data.space_id,
name: getSpaceName(),
role: 0,
invite_code: res.data.invite_code,
})
fetchSpaceUsers(res.data.space_id)
setStep('invite')
}
}).catch((err) => {
console.log(err)
})
}
function joinSpace(code: string) {
joinSpaceApi({
invite_code: code,
}).then((res) => {
if (res.isSuccess) {
spaceStore.setUserSpace({
id: res.data.space_id,
name: res.data.space_name,
invite_code: code,
role: 0,
})
setStep('invite')
fetchSpaceUsers(res.data.space_id)
}
}).catch((err) => {
console.log(err)
})
}
return (
<View className="flex-1">
<CustomHeader title={headerTitle} />
<View className="flex-1">
<StepFade visible={step === 'home'}>
<SpaceHome
onCreate={createSpace}
onJoin={() => {
setStep('join')
}}
/>
</StepFade>
<StepFade visible={step === 'join'}>
<JoinSpace
onBack={() => {
setStep('home')
}}
onJoin={(code) => {
if (code !== '') {
joinSpace(code)
}
}}
/>
</StepFade>
<StepFade visible={step === 'invite'}>
<SpaceInvite
userSpace={spaceStore.userSpace}
spaceUsers={spaceUsers}
onExit={(data) => {
if (data.type === 'remove' && data.userId !== undefined) {
setSpaceUsers(prevUsers =>
prevUsers.filter(user => user.id !== data.userId),
)
return
}
setStep('home')
spaceStore.clear()
setSpaceUsers([])
}}
/>
</StepFade>
</View>
</View>
)
}