This commit is contained in:
oneao committed 2026-01-26 17:29:24 +08:00
1 parent 8f3f6982db
commit 5a79414319
10 files changed
+354 -292

No files matched your search

@@ -1,11 +1,80 @@
import { TabSlot } from 'expo-router/ui' import type { icons } from 'lucide-react-native'
import { Text, View } from 'react-native' import type { SortableGridRenderItem } from 'react-native-sortables'
import { Slot, usePathname, useRouter } from 'expo-router'
import { useCallback, useEffect, useState } from 'react'
import { Pressable, Text, TouchableOpacity, Vibration, View } from 'react-native'
import Animated, { useAnimatedRef } from 'react-native-reanimated'
import Sortable from 'react-native-sortables'
import { ADrawer, LucideIcon } from '@/components/common/ao'
interface NavItem {
label: string
path: string
icon: keyof typeof icons
}
const NAV_MENUS: NavItem[] = [
{ label: '记账', path: '/money', icon: 'CircleDollarSign' },
{ label: '恋爱记', path: '/love', icon: 'Heart' },
{ label: '聊天', path: '/chat', icon: 'MessageCircle' },
{ label: '个人中心', path: '/user', icon: 'CircleUserRound' },
]
export default function TabsLayout() { export default function TabsLayout() {
const scrollableRef = useAnimatedRef<Animated.ScrollView>()
const router = useRouter()
const pathname = usePathname()
const [showNavDrawer, setShowNavDrawer] = useState(false)
const [draggingId, setDraggingId] = useState<string>('')
const [currentNav, setCurrentNav] = useState<NavItem>({
label: '',
path: '',
icon: 'AArrowDown',
})
useEffect(() => {
const current = NAV_MENUS.find(item => item.path === pathname)
if (current) {
setCurrentNav(current)
}
}, [pathname])
const refreshPage = () => {
router.replace(pathname)
}
const renderNavItem = useCallback<SortableGridRenderItem<NavItem>>(
({ item }) => (
<Pressable
className="
flex
flex-col
items-center
justify-center
bg-[white]
rounded-md
shadow-md
p-3
"
onPress={() => {
setShowNavDrawer(false)
router.push(item.path)
}}
>
<LucideIcon icon={item.icon} size={24} strokeWidth={2} clickable={false} />
<Text className="text-sm mt-2">
{item.label}
</Text>
</Pressable>
),
[],
)
return ( return (
<View className="flex-1 bg-gray-100"> <View className="flex-1 bg-gray-100">
<View className="flex-1 px-4 pt-4 pb-24"> <View className="flex-1 bg-white">
<TabSlot /> <Slot />
</View> </View>
<View <View
@@ -18,10 +87,55 @@ export default function TabsLayout() {
gap-4 gap-4
" "
> >
<View className="bg-white rounded-md shadow-lg h-12 w-12"></View> <View className="bg-white rounded-md shadow-lg h-12 w-12">
<View className="flex-1 bg-white rounded-md shadow-lg h-12"></View> <TouchableOpacity className="w-full h-full items-center justify-center">
<View className="bg-white rounded-md shadow-lg h-12 w-12"></View> <LucideIcon icon="Bot" size={26} clickable={false} strokeWidth={2} />
</TouchableOpacity>
</View>
<View className="flex-1 bg-white rounded-md shadow-lg h-12">
<TouchableOpacity
className="w-full h-full items-center justify-center flex flex-row"
onPress={() => {
setShowNavDrawer(true)
}}
>
<LucideIcon icon={currentNav.icon} size={20} strokeWidth={2} />
<Text className="font-semibold text-lg ml-2">{currentNav.label}</Text>
</TouchableOpacity>
</View>
<View className="bg-white rounded-md shadow-lg h-12 w-12">
<TouchableOpacity className="w-full h-full items-center justify-center" onPress={refreshPage}>
<LucideIcon icon="RefreshCcw" size={26} clickable={false} strokeWidth={2} />
</TouchableOpacity>
</View>
</View> </View>
<ADrawer
visible={showNavDrawer}
height="60%"
title="菜单导航"
isCircle
hideModal={() => {
setShowNavDrawer(false)
}}
>
<Animated.ScrollView
ref={scrollableRef}
className="px-4 py-2 flex-1"
>
<Sortable.Grid
columnGap={16}
rowGap={16}
columns={3}
data={NAV_MENUS}
keyExtractor={item => item.path}
renderItem={renderNavItem}
scrollableRef={scrollableRef}
autoScrollActivationOffset={200}
/>
</Animated.ScrollView>
</ADrawer>
</View> </View>
) )
} }
+15 -4
View File
@@ -1,11 +1,22 @@
import { useRouter } from 'expo-router'
import { Text, View } from 'react-native' import { Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { AButton } from '@/components/common/ao'
// 这个页面的作用是作为占位页,避免进入 (tabs) layout 时出现 404 页面。 // 这个页面的作用是作为占位页,避免进入 (tabs) layout 时出现 404 页面。
// 如果想指定默认页面,可以通过 router.replace 或 TabTrigger 指向具体子页面。 // 如果想指定默认页面,可以通过 router.replace 或 TabTrigger 指向具体子页面。
export default function Index() { export default function Index() {
const router = useRouter()
return ( return (
<View> <SafeAreaView>
<Text>Index</Text> <View>
</View> <AButton onPress={() => {
router.push('/money')
}}
>
测试
</AButton>
</View>
</SafeAreaView>
) )
} }
+1 -1
View File
@@ -11,7 +11,7 @@ export default function NotFound() {
title="(´・ω・`) 页面走丢了~" title="(´・ω・`) 页面走丢了~"
description="回首页看看吧!" description="回首页看看吧!"
footer={( footer={(
<AButton onPress={() => router.replace('/')} leftIcon={{ icon: 'ArrowLeft', size: 18, color: '#fff' }}> <AButton onPress={() => router.replace('/')}>
返回应用首页 返回应用首页
</AButton> </AButton>
)} )}
+1 -1
View File
@@ -31,7 +31,7 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
title="(´・ω・`) 出错啦~" title="(´・ω・`) 出错啦~"
description={`发生了一点小问题:\n${error?.message || '请稍后再试'}`} description={`发生了一点小问题:\n${error?.message || '请稍后再试'}`}
footer={( footer={(
<AButton onPress={retry} leftIcon={{ icon: 'RotateCcw', size: 18, color: '#fff' }}> <AButton onPress={retry}>
再试一下 再试一下
</AButton> </AButton>
)} )}
+136 -136
View File
@@ -1,7 +1,7 @@
import { useEvent } from 'expo' import { useEvent } from 'expo'
import ExpoQQ from 'expo-qq' // import ExpoQQ from 'expo-qq'
import { useRouter } from 'expo-router' import { useRouter } from 'expo-router'
import ExpoWeChat from 'expo-wechat' // import ExpoWeChat from 'expo-wechat'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Image, Text, View } from 'react-native' import { Image, Text, View } from 'react-native'
import { AButton, ACheckbox } from '@/components/common/ao' import { AButton, ACheckbox } from '@/components/common/ao'
@@ -11,7 +11,7 @@ import { loginQqApi, loginWxApi } from '@/request/api/auth'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
export default function LoginScreen() { export default function Auth() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [isAgree, setIsAgree] = useState(true) const [isAgree, setIsAgree] = useState(true)
const [form, setForm] = useState({ const [form, setForm] = useState({
@@ -23,160 +23,160 @@ export default function LoginScreen() {
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const onLoginFinished = useEvent(ExpoQQ, 'onLoginFinished') // const onLoginFinished = useEvent(ExpoQQ, 'onLoginFinished')
const onGetUserInfo = useEvent(ExpoQQ, 'onGetUserInfo') // const onGetUserInfo = useEvent(ExpoQQ, 'onGetUserInfo')
const authResult = useEvent(ExpoWeChat, 'onAuthResult') // const authResult = useEvent(ExpoWeChat, 'onAuthResult')
async function onQQLogin() { // async function onQQLogin() {
setLoading(true) // setLoading(true)
setForm(val => ({ // setForm(val => ({
...val, // ...val,
type: 1, // type: 1,
})) // }))
await ExpoQQ.login(['get_user_info']) // await ExpoQQ.login(['get_user_info'])
} // }
async function onWeChatLogin() { // async function onWeChatLogin() {
setLoading(true) // setLoading(true)
// 检查微信是否安装 // // 检查微信是否安装
const isInstalled = await ExpoWeChat.isWXAppInstalled() // const isInstalled = await ExpoWeChat.isWXAppInstalled()
if (!isInstalled) { // if (!isInstalled) {
toast.show({ // toast.show({
title: onLoginFinished?.errorMessage || '请先安装微信', // title: onLoginFinished?.errorMessage || '请先安装微信',
}) // })
setLoading(false) // setLoading(false)
return // return
} // }
setForm(val => ({ // setForm(val => ({
...val, // ...val,
type: 0, // type: 0,
})) // }))
ExpoWeChat.sendAuthRequest('snsapi_userinfo', 'state123') // ExpoWeChat.sendAuthRequest('snsapi_userinfo', 'state123')
} // }
useEffect(() => { // useEffect(() => {
ExpoQQ.init('102826474', '') // ExpoQQ.init('102826474', '')
ExpoWeChat.registerApp('wxdab3e21a1f7e392f', '') // ExpoWeChat.registerApp('wxdab3e21a1f7e392f', '')
}, []) // }, [])
useEffect(() => { // useEffect(() => {
if (!onLoginFinished) { // if (!onLoginFinished) {
return // return
} // }
if (!onLoginFinished.success) { // if (!onLoginFinished.success) {
toast.show({ // toast.show({
title: onLoginFinished?.errorMessage || '授权失败,请检查手机是否安装 QQ 或确认授权', // title: onLoginFinished?.errorMessage || '授权失败,请检查手机是否安装 QQ 或确认授权',
}) // })
setLoading(false) // setLoading(false)
return // return
} // }
const fetchUserInfo = async () => { // const fetchUserInfo = async () => {
try { // try {
const tokenInfo = await ExpoQQ.getLoginTokenInfo() // const tokenInfo = await ExpoQQ.getLoginTokenInfo()
if (!tokenInfo.openId) { // if (!tokenInfo.openId) {
toast.show({ // toast.show({
title: tokenInfo?.errorMessage || '授权失败,请检查手机是否安装 QQ 或确认授权', // title: tokenInfo?.errorMessage || '授权失败,请检查手机是否安装 QQ 或确认授权',
}) // })
setLoading(false) // setLoading(false)
return // return
} // }
setForm(val => ({ // setForm(val => ({
...val, // ...val,
openid: String(tokenInfo.openId), // openid: String(tokenInfo.openId),
})) // }))
await ExpoQQ.sendGetUserInfoReq() // await ExpoQQ.sendGetUserInfoReq()
} // }
catch (e) { // catch (e) {
console.error('QQ 登录未完成', e) // console.error('QQ 登录未完成', e)
setLoading(false) // setLoading(false)
} // }
} // }
fetchUserInfo() // fetchUserInfo()
}, [onLoginFinished]) // }, [onLoginFinished])
// QQ登录 // // QQ登录
useEffect(() => { // useEffect(() => {
const handleLogin = async () => { // const handleLogin = async () => {
if (!onGetUserInfo) // if (!onGetUserInfo)
return // return
try { // try {
const loginRes = await loginQqApi({ // const loginRes = await loginQqApi({
openid: form.openid, // openid: form.openid,
username: onGetUserInfo.nickname || '', // username: onGetUserInfo.nickname || '',
avatar: onGetUserInfo.figureurlQq_2 || onGetUserInfo.figureurl_2, // avatar: onGetUserInfo.figureurlQq_2 || onGetUserInfo.figureurl_2,
}) // })
if (loginRes.isSuccess) { // if (loginRes.isSuccess) {
userStore.setUserInfo({ // userStore.setUserInfo({
token: loginRes.data.token, // token: loginRes.data.token,
id: loginRes.data.userId, // id: loginRes.data.userId,
username: loginRes.data.username, // username: loginRes.data.username,
avatar: loginRes.data.avatar, // avatar: loginRes.data.avatar,
}) // })
console.log(loginRes.data.token) // console.log(loginRes.data.token)
router.push('/') // router.push('/')
} // }
} // }
catch (err) { // catch (err) {
console.error('登录接口出错', err) // console.error('登录接口出错', err)
} // }
finally { // finally {
setLoading(false) // setLoading(false)
} // }
} // }
handleLogin() // handleLogin()
}, [onGetUserInfo]) // }, [onGetUserInfo])
// 微信登录 // // 微信登录
useEffect(() => { // useEffect(() => {
if (!authResult) // if (!authResult)
return // return
const handleLogin = async () => { // const handleLogin = async () => {
if (authResult.errorCode === 0) { // if (authResult.errorCode === 0) {
try { // try {
const loginRes = await loginWxApi({ // const loginRes = await loginWxApi({
code: authResult.code, // code: authResult.code,
}) // })
if (loginRes.isSuccess) { // if (loginRes.isSuccess) {
userStore.setUserInfo({ // userStore.setUserInfo({
token: loginRes.data.token, // token: loginRes.data.token,
id: loginRes.data.userId, // id: loginRes.data.userId,
username: loginRes.data.username, // username: loginRes.data.username,
avatar: loginRes.data.avatar, // avatar: loginRes.data.avatar,
}) // })
router.push('/') // router.push('/')
} // }
} // }
catch (err) { // catch (err) {
console.error('登录接口出错', err) // console.error('登录接口出错', err)
} // }
finally { // finally {
setLoading(false) // setLoading(false)
} // }
} // }
else { // else {
console.log('授权失败:', authResult.errorMessage) // console.log('授权失败:', authResult.errorMessage)
setLoading(false) // setLoading(false)
} // }
} // }
handleLogin() // handleLogin()
}, [authResult]) // }, [authResult])
function testLogin() { function testLogin() {
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo3NjY3ODU5ODIxNjUwNjEsImlhdCI6MTc2OTM0ODI0Nn0.17ZNaHD3HSLM8dVn_JQK-gvsG9MKj47IRYV9D4xZkzs // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo3NjY3ODU5ODIxNjUwNjEsImlhdCI6MTc2OTM0ODI0Nn0.17ZNaHD3HSLM8dVn_JQK-gvsG9MKj47IRYV9D4xZkzs
@@ -208,7 +208,7 @@ export default function LoginScreen() {
</View> </View>
<View className="flex flex-col gap-4"> <View className="flex flex-col gap-4">
<AButton {/* <AButton
leftIcon={ leftIcon={
<IconWeixin color="#fff" /> <IconWeixin color="#fff" />
} }
@@ -227,7 +227,7 @@ export default function LoginScreen() {
onPress={onQQLogin} onPress={onQQLogin}
> >
QQ QQ
</AButton> </AButton> */}
<AButton <AButton
variant="outline" variant="outline"
@@ -149,11 +149,6 @@ export default function MoneyCategorySetting() {
<AButton <AButton
className="rounded-full" className="rounded-full"
size="sm" size="sm"
leftIcon={{
icon: 'Plus',
color: '#fff',
clickable: false,
}}
onPress={(() => onPress={(() =>
setshowEditCategory(true) setshowEditCategory(true)
)} )}
@@ -37,7 +37,7 @@ export default function Drawer({
isCircle, isCircle,
menuPosition = 'bottom', menuPosition = 'bottom',
children, children,
CircleNum, CircleNum = 8,
isShadowHideModal = true, isShadowHideModal = true,
header, header,
title, title,
@@ -48,7 +48,6 @@ export default function Drawer({
const anim = useRef(new Animated.Value(0)).current const anim = useRef(new Animated.Value(0)).current
/* ================== 同步外部 visible ================== */
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
setInnerVisible(true) setInnerVisible(true)
@@ -59,7 +58,6 @@ export default function Drawer({
} }
}, [visible]) }, [visible])
/* ================== 布局样式 ================== */
useEffect(() => { useEffect(() => {
switch (menuPosition) { switch (menuPosition) {
case 'left': case 'left':
@@ -207,8 +205,9 @@ export default function Drawer({
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
{/* Drawer */} {/* Drawer */}
<View style={drawerMenuBox}> <View style={drawerMenuBox} pointerEvents="box-none">
<Animated.View <Animated.View
pointerEvents="box-none"
style={[ style={[
styles.container, styles.container,
{ {
@@ -33,7 +33,6 @@ export default function LucideIcon({
return <IconComponent size={size} color={color} strokeWidth={strokeWidth} style={style} className={className} /> return <IconComponent size={size} color={color} strokeWidth={strokeWidth} style={style} className={className} />
} }
// clickable 为 true,包 Pressable
return ( return (
<Pressable onPress={onPress} style={style} className={className}> <Pressable onPress={onPress} style={style} className={className}>
<IconComponent size={size} color={color} strokeWidth={strokeWidth} /> <IconComponent size={size} color={color} strokeWidth={strokeWidth} />
+2 -2
View File
@@ -30,8 +30,8 @@ function isWhiteListed(url: string) {
} }
const alovaInstance = createAlova({ const alovaInstance = createAlova({
baseURL: 'http://192.168.5.6:8086/app', // baseURL: 'http://192.168.5.6:8086/app',
// baseURL: 'http://192.168.1.137:8086/app', baseURL: 'http://192.168.1.137:8086/app',
requestAdapter: axiosRequestAdapter(), requestAdapter: axiosRequestAdapter(),
timeout: 5000, timeout: 5000,
cacheFor: null, cacheFor: null,
+75 -131
View File
@@ -1,147 +1,91 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bottom Nav Demo</title> <title>全局底部导航 Demo</title>
<link rel="stylesheet" href="style.css" />
<style> <style>
* { body {
box-sizing: border-box; margin: 0;
margin: 0; font-family: sans-serif;
padding: 0; background-color: #f0f2f5;
font-family: system-ui, -apple-system, BlinkMacSystemFont; display: flex;
} justify-content: center;
align-items: flex-end;
height: 100vh;
}
body { /* 弹出面板 */
background: #f6f7f9; .bottom-panel {
height: 100vh; width: 100%;
} max-width: 400px;
background: white;
border-top-left-radius: 20px;
border-top-right-radius: 20px;
padding: 16px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.content { /* 每个导航 item */
padding: 16px; .nav-item {
padding-bottom: 80px; background: #f9f9f9;
color: #555; border-radius: 16px;
} padding: 16px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s, background 0.2s;
text-align: center;
}
/* 底部栏 */ .nav-item:hover {
.bottom-bar { background: #e0e0ff;
position: fixed; transform: translateY(-3px) scale(1.05);
bottom: 0; }
left: 0;
right: 0;
height: 56px;
background: #fff;
border-top: 1px solid #e5e5e5;
display: flex; .nav-icon {
align-items: center; font-size: 28px;
justify-content: space-between; margin-bottom: 6px;
}
padding: 0 12px;
}
/* 左右按钮 */
.nav-btn {
width: 40px;
height: 40px;
border: none;
background: none;
font-size: 18px;
color: #333;
}
/* 中间区域 */
.nav-center {
flex: 1;
text-align: center;
font-size: 14px;
font-weight: 500;
color: #222;
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
}
.nav-center .arrow {
font-size: 12px;
color: #999;
}
/* 扩展菜单 */
.extension {
position: fixed;
bottom: 56px;
left: 12px;
right: 12px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
transform: translateY(20px);
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
}
.extension.show {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.extension-item {
padding: 14px 16px;
font-size: 14px;
color: #333;
border-bottom: 1px solid #eee;
}
.extension-item:last-child {
border-bottom: none;
}
.extension-item.danger {
color: #d93026;
}
.nav-name {
font-size: 14px;
font-weight: 600;
}
</style> </style>
</head> </head>
<body> <body>
<main class="content"> <div class="bottom-panel">
<p>当前工具内容区域</p> <div class="nav-item" onclick="alert('跳转到首页')">
</main> <div class="nav-icon">🏠</div>
<div class="nav-name">首页</div>
<!-- 扩展菜单 --> </div>
<div class="extension" id="extension"> <div class="nav-item" onclick="alert('跳转到记账')">
<div class="extension-item">视图切换</div> <div class="nav-icon">📝</div>
<div class="extension-item">工具设置</div> <div class="nav-name">记账</div>
<div class="extension-item">导出数据</div> </div>
<div class="extension-item danger">清空记录</div> <div class="nav-item" onclick="alert('跳转到分类')">
<div class="nav-icon">📂</div>
<div class="nav-name">分类</div>
</div>
<div class="nav-item" onclick="alert('跳转到分析')">
<div class="nav-icon">📊</div>
<div class="nav-name">分析</div>
</div>
<div class="nav-item" onclick="alert('跳转到设置')">
<div class="nav-icon">⚙️</div>
<div class="nav-name">设置</div>
</div>
</div> </div>
<!-- 底部导航 -->
<nav class="bottom-bar">
<button class="nav-btn">←</button>
<div class="nav-center">
<span class="title">记账 · 本月</span>
<span class="arrow">▾</span>
</div>
<button class="nav-btn" onclick="toggleExt()">⋯</button>
</nav>
<script>
function toggleExt() {
document.getElementById('extension')
.classList.toggle('show')
}
</script>
</body> </body>
</html> </html>