首次提交

This commit is contained in:
zeronline
2026-06-25 08:39:47 +08:00
parent 20a93c703a
commit c5cccd346b
169 changed files with 24720 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { getStoredToken, getStoredUser, saveToken as storeToken, clearAuth } from '../services/api'
interface User { id: string; phone?: string; nickname?: string; avatar?: string; bio?: string; points: number }
interface AuthContextType {
user: User | null
loading: boolean
login: (token: string, user: User) => Promise<void>
logout: () => Promise<void>
refreshUser: (u: User) => void
}
const AuthContext = createContext<AuthContextType>({ user: null, loading: true, login: async () => {}, logout: async () => {}, refreshUser: () => {} })
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([getStoredToken(), getStoredUser()]).then(([token, storedUser]) => {
if (token && storedUser) setUser(storedUser)
setLoading(false)
})
}, [])
const login = async (token: string, u: User) => {
await storeToken(token, u)
setUser(u)
}
const logout = async () => {
await clearAuth()
setUser(null)
}
const refreshUser = (u: User) => { setUser(u) }
return <AuthContext.Provider value={{ user, loading, login, logout, refreshUser }}>{children}</AuthContext.Provider>
}
export const useAuth = () => useContext(AuthContext)
@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'
import { api } from '../services/api'
export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string; onBack: () => void }) {
const [item, setItem] = useState<any>(null)
const [streak, setStreak] = useState(0)
const [records, setRecords] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get('/api/checkin/items/' + itemId),
api.get('/api/checkin/streak/' + itemId),
api.get('/api/checkin/records?itemId=' + itemId + '&start=' + new Date().toISOString().slice(0,7) + '-01&end=' + new Date().toISOString().slice(0,10)),
]).then(([itemData, streakData, recordsData]) => {
setItem(itemData)
setStreak(streakData.streak)
setRecords(recordsData || [])
}).catch(() => Alert.alert('错误', '加载失败'))
.finally(() => setLoading(false))
}, [itemId])
const handleDelete = () => {
Alert.alert('确认删除', '确定删除这个打卡项?', [
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: async () => {
try { await api.del('/api/checkin/items/' + itemId); onBack() }
catch { Alert.alert('错误', '删除失败') }
}},
])
}
if (loading) return <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}><ActivityIndicator size="large" color="#FF6B35" /></View>
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.nav}>
<TouchableOpacity onPress={onBack}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<Text style={styles.navTitle}>{item?.name || '打卡详情'}</Text>
<View style={{ width: 60 }} />
</View>
<ScrollView>
<View style={styles.card}>
<Text style={styles.itemName}>{item?.name}</Text>
<Text style={styles.itemMeta}>{item?.category} · {item?.cycleType}</Text>
{item?.dailyGoal && <Text style={styles.itemGoal}>: {item.dailyGoal}</Text>}
</View>
<View style={styles.statsRow}>
<View style={styles.stat}><Text style={styles.statNum}>{streak}</Text><Text style={styles.statLbl}></Text></View>
<View style={styles.stat}><Text style={styles.statNum}>{records.length}</Text><Text style={styles.statLbl}></Text></View>
</View>
<TouchableOpacity style={styles.deleteBtn} onPress={handleDelete}>
<Text style={{ color: '#FF3B30', textAlign: 'center' }}></Text>
</TouchableOpacity>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
nav: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderBottomWidth: 0.5, borderBottomColor: '#E5E5EA', backgroundColor: 'rgba(245,245,247,0.92)' },
backBtn: { fontSize: 15, color: '#FF6B35', padding: 4 },
navTitle: { fontSize: 17, fontWeight: '600' },
card: { backgroundColor: 'white', borderRadius: 12, margin: 12, padding: 16 },
itemName: { fontSize: 18, fontWeight: '700' },
itemMeta: { fontSize: 13, color: '#86868B', marginTop: 2 },
itemGoal: { fontSize: 13, color: '#86868B', marginTop: 2 },
statsRow: { flexDirection: 'row', backgroundColor: 'white', borderRadius: 12, margin: 8, padding: 16 },
stat: { flex: 1, alignItems: 'center' },
statNum: { fontSize: 28, fontWeight: '700', color: '#FF6B35' },
statLbl: { fontSize: 13, color: '#86868B' },
deleteBtn: { backgroundColor: 'white', borderRadius: 12, margin: 8, padding: 14, alignItems: 'center' },
})
@@ -0,0 +1,84 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native'
import { api } from '../services/api'
const CATEGORIES = [
{ key: 'study', label: '📚 学习' },
{ key: 'health', label: '❤️ 健康' },
{ key: 'habit', label: '🔄 习惯' },
{ key: 'hobby', label: '⭐ 兴趣' },
{ key: 'goal', label: '🎯 目标' },
]
export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: () => void; onCreated?: () => void }) {
const [name, setName] = useState('')
const [category, setCategory] = useState('study')
const [cycleType, setCycleType] = useState('daily')
const [dailyGoal, setDailyGoal] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleCreate = async () => {
if (!name.trim()) { Alert.alert('提示', '请输入打卡项名称'); return }
setSubmitting(true)
try {
await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined })
Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { onBack(); onCreated?.() } }])
} catch (e: any) { Alert.alert('错误', e.message || '创建失败') }
setSubmitting(false)
}
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.nav}>
<TouchableOpacity onPress={onBack}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<Text style={styles.navTitle}></Text>
<View style={{ width: 60 }} />
</View>
<ScrollView style={{ padding: 16 }}>
<Text style={styles.label}></Text>
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="如「每天背单词」" />
<Text style={[styles.label, { marginTop: 20 }]}></Text>
<View style={styles.grid}>
{CATEGORIES.map(c => (
<TouchableOpacity key={c.key} style={[styles.gridItem, category === c.key && styles.gridActive]} onPress={() => setCategory(c.key)}>
<Text style={[category === c.key && { fontWeight: '600' }]}>{c.label}</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.label, { marginTop: 20 }]}></Text>
<View style={styles.row}>
{[{ k: 'daily', l: '每日' }, { k: 'weekly', l: '每周' }, { k: 'every_other_day', l: '隔日' }].map(c => (
<TouchableOpacity key={c.k} style={[styles.chip, cycleType === c.k && styles.chipActive]} onPress={() => setCycleType(c.k)}>
<Text style={cycleType === c.k ? { fontWeight: '600' } : {}}>{c.l}</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.label, { marginTop: 20 }]}></Text>
<TextInput style={styles.input} value={dailyGoal} onChangeText={setDailyGoal} placeholder="如「背 50 个单词」" />
<TouchableOpacity style={styles.submitBtn} onPress={handleCreate} disabled={submitting}>
<Text style={styles.submitText}>{submitting ? '创建中...' : '创建打卡项'}</Text>
</TouchableOpacity>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
nav: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderBottomWidth: 0.5, borderBottomColor: '#E5E5EA', backgroundColor: 'rgba(245,245,247,0.92)' },
backBtn: { fontSize: 15, color: '#FF6B35', padding: 4 },
navTitle: { fontSize: 17, fontWeight: '600' },
label: { fontSize: 13, color: '#86868B', marginBottom: 6 },
input: { backgroundColor: '#f8f8fa', borderWidth: 1, borderColor: '#E5E5EA', borderRadius: 10, padding: 14, fontSize: 16 },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
gridItem: { padding: 12, borderRadius: 10, borderWidth: 2, borderColor: '#E5E5EA', backgroundColor: '#f8f8fa' },
gridActive: { borderColor: '#FF6B35', backgroundColor: '#FFF0EB' },
row: { flexDirection: 'row', gap: 8 },
chip: { padding: '8px 16px', borderRadius: 8, borderWidth: 1.5, borderColor: '#E5E5EA', backgroundColor: '#f8f8fa' },
chipActive: { borderColor: '#FF6B35', backgroundColor: '#FFF0EB' },
submitBtn: { backgroundColor: '#FF6B35', borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 32 },
submitText: { color: 'white', fontSize: 17, fontWeight: '600' },
})
+80
View File
@@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, TextInput, Modal, StyleSheet, Alert } from 'react-native'
import { api } from '../services/api'
export default function FeedScreen() {
const [posts, setPosts] = useState<any[]>([])
const [showModal, setShowModal] = useState(false)
const [content, setContent] = useState('')
const loadFeed = async () => {
try { const data = await api.get<any>('/api/social/feed?page=1&pageSize=20'); setPosts(data.list || []) } catch {}
}
useEffect(() => { loadFeed() }, [])
const submitPost = async () => {
if (!content.trim()) return
try { await api.post('/api/social/posts', { content: content.trim(), privacy: 1 }); setShowModal(false); setContent(''); loadFeed() }
catch (e: any) { Alert.alert('发布失败', e.message) }
}
const handleLike = async (id: string) => {
try { await api.post('/api/social/posts/' + id + '/like'); loadFeed() }
catch {}
}
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<ScrollView>
<TouchableOpacity style={styles.createBtn} onPress={() => setShowModal(true)}>
<Text style={{ fontSize: 15, color: '#86868B' }}>+ </Text>
</TouchableOpacity>
{posts.map(p => (
<View key={p.id} style={styles.card}>
<View style={styles.postHeader}>
<View style={styles.avatar}><Text>{p.user?.nickname?.[0] || '?'}</Text></View>
<View><Text style={styles.postUser}>{p.user?.nickname || '用户'}</Text><Text style={styles.postTime}>{p.createdAt?.slice(0, 10)}</Text></View>
</View>
{p.content && <Text style={styles.postContent}>{p.content}</Text>}
<View style={styles.actions}>
<TouchableOpacity onPress={() => handleLike(p.id)}><Text>{p.likes > 0 ? '❤️' : '🤍'} {p.likes}</Text></TouchableOpacity>
<Text>💬 {p.comments}</Text>
</View>
</View>
))}
{posts.length === 0 && <View style={{ alignItems: 'center', padding: 60 }}><Text style={{ fontSize: 40 }}>🌊</Text><Text style={{ color: '#86868B', marginTop: 8 }}></Text></View>}
</ScrollView>
<Modal visible={showModal} transparent animationType="slide" onRequestClose={() => setShowModal(false)}>
<TouchableOpacity style={styles.modalMask} activeOpacity={1} onPress={() => setShowModal(false)}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<TextInput style={styles.modalInput} value={content} onChangeText={setContent} placeholder="分享你的打卡故事..." multiline numberOfLines={4} />
<View style={{ flexDirection: 'row', gap: 12, marginTop: 12 }}>
<TouchableOpacity style={[styles.btn, { flex: 1, backgroundColor: '#f8f8fa', borderWidth: 1, borderColor: '#E5E5EA' }]} onPress={() => { setShowModal(false); setContent('') }}><Text></Text></TouchableOpacity>
<TouchableOpacity style={[styles.btn, { flex: 1, backgroundColor: '#FF6B35' }]} onPress={submitPost} disabled={!content.trim()}><Text style={{ color: 'white', fontWeight: '600' }}></Text></TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
</View>
)
}
const styles = StyleSheet.create({
createBtn: { backgroundColor: 'white', borderRadius: 12, margin: 12, padding: 16, shadowOpacity: 0.04, shadowRadius: 3, shadowColor: '#000', elevation: 1 },
card: { backgroundColor: 'white', borderRadius: 12, margin: 8, padding: 14, shadowOpacity: 0.04, shadowRadius: 3, elevation: 1 },
postHeader: { flexDirection: 'row', gap: 10, marginBottom: 10 },
avatar: { width: 36, height: 36, borderRadius: 18, backgroundColor: '#FF6B35', justifyContent: 'center', alignItems: 'center' },
postUser: { fontWeight: '600' },
postTime: { fontSize: 12, color: '#86868B' },
postContent: { fontSize: 15, lineHeight: 22 },
actions: { flexDirection: 'row', gap: 20, paddingTop: 8, borderTopWidth: 0.5, borderTopColor: '#F0F0F0', marginTop: 8 },
modalMask: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.4)' },
modalContent: { backgroundColor: 'white', borderRadius: 16, padding: 20 },
modalTitle: { fontSize: 17, fontWeight: '600', textAlign: 'center', marginBottom: 12 },
modalInput: { borderWidth: 1, borderColor: '#E5E5EA', borderRadius: 10, padding: 12, fontSize: 15, minHeight: 100 },
btn: { padding: 12, borderRadius: 10, alignItems: 'center', fontSize: 15 },
})
+89
View File
@@ -0,0 +1,89 @@
import React, { useEffect, useState, useCallback } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { api } from '../services/api'
import { useAuth } from '../components/AuthProvider'
export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectItem?: (id: string) => void; onCreateItem?: () => void }) {
const { user } = useAuth()
const [items, setItems] = useState<any[]>([])
const [streaks, setStreaks] = useState<Record<string, number>>({})
const [todayStatus, setTodayStatus] = useState<Record<string, boolean>>({})
const [refreshing, setRefreshing] = useState(false)
const loadItems = useCallback(async () => {
try {
const [list, ts] = await Promise.all([
api.get<any[]>('/api/checkin/items'),
api.get<Record<string, boolean>>('/api/checkin/today-status'),
])
setItems(list.filter((i: any) => i.status !== 'deleted'))
setTodayStatus(ts || {})
const s: Record<string, number> = {}
await Promise.all(list.filter((i: any) => i.status === 'active').map(async (item: any) => {
try { const r = await api.get<any>('/api/checkin/streak/' + item.id); s[item.id] = r.streak } catch {}
}))
setStreaks(s)
} catch {}
}, [])
useEffect(() => { loadItems() }, [loadItems])
const activeItems = items.filter(i => i.status === 'active')
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadItems().then(() => setRefreshing(false)) }} />}>
<View style={s.header}>
<View style={s.avatar}><Text style={s.avatarText}>{user?.nickname?.[0] || user?.phone?.slice(-4) || '?'}</Text></View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.headerName}>{user?.nickname || '用户'}</Text>
<Text style={s.headerSub}> {user?.points} · {activeItems.length} </Text>
</View>
</View>
<View style={s.sectionHeader}>
<Text style={s.sectionTitle}></Text>
<TouchableOpacity onPress={onCreateItem}><Text style={s.createBtn}>+ </Text></TouchableOpacity>
</View>
{activeItems.length === 0 ? (
<View style={s.empty}>
<Text style={{ fontSize: 40 }}>📋</Text>
<Text style={s.emptyText}></Text>
<Text style={{ fontSize: 13, color: '#86868B', marginTop: 4 }}>"创建"</Text>
</View>
) : activeItems.map((item: any) => (
<TouchableOpacity key={item.id} onPress={() => onSelectItem?.(item.id)}>
<View style={s.card}>
<View style={s.cardIcon}><Text style={{ fontSize: 22 }}>{item.icon || '📋'}</Text></View>
<View style={{ flex: 1 }}>
<Text style={s.cardName}>{item.name}</Text>
<Text style={s.cardMeta}>{item.category}</Text>
{todayStatus[item.id] && <Text style={s.doneText}> </Text>}
</View>
{streaks[item.id] !== undefined && <Text style={s.streak}>🔥 {streaks[item.id]}</Text>}
</View>
</TouchableOpacity>
))}
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F5F5F7' },
header: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'white', borderRadius: 12, margin: 12, padding: 14, shadowOpacity: 0.04, shadowRadius: 3, elevation: 1 },
avatar: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#FF6B35', justifyContent: 'center', alignItems: 'center' },
avatarText: { fontSize: 18, color: 'white', fontWeight: '600' },
headerName: { fontSize: 16, fontWeight: '600' },
headerSub: { fontSize: 13, color: '#86868B' },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16 },
sectionTitle: { fontSize: 20, fontWeight: '700' },
createBtn: { color: '#FF6B35', fontSize: 14, fontWeight: '600' },
card: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'white', borderRadius: 12, marginHorizontal: 12, marginBottom: 8, padding: 14, shadowOpacity: 0.04, elevation: 1 },
cardIcon: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#FFF0EB', justifyContent: 'center', alignItems: 'center', marginRight: 14 },
cardName: { fontSize: 16, fontWeight: '600' },
cardMeta: { fontSize: 13, color: '#86868B', marginTop: 2 },
doneText: { fontSize: 12, color: '#34C759', fontWeight: '600', marginTop: 2 },
streak: { fontSize: 14, color: '#FF6B35', fontWeight: '600' },
empty: { alignItems: 'center', padding: 60, marginTop: 20 },
emptyText: { fontSize: 15, color: '#86868B', marginTop: 8 },
})
+51
View File
@@ -0,0 +1,51 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform } from 'react-native'
import { api, saveToken } from '../services/api'
import { useAuth } from '../components/AuthProvider'
export default function LoginScreen({ onLogin }: { onLogin: () => void }) {
const [phone, setPhone] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
async function handleLogin() {
if (phone.length !== 11) { Alert.alert('提示', '请输入正确的手机号'); return }
setLoading(true)
try {
const result = await api.post<{ user: any; token: string }>('/api/auth/login', { phone })
await login(result.token, result.user)
onLogin()
} catch (e: any) {
Alert.alert('登录失败', e.message || '请稍后重试')
} finally { setLoading(false) }
}
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.logoArea}>
<View style={styles.logo}><Text style={styles.logoText}>📈</Text></View>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}></Text>
</View>
<TextInput style={styles.input} placeholder="手机号" keyboardType="phone-pad" maxLength={11}
value={phone} onChangeText={t => setPhone(t.replace(/\D/g, ''))} />
<TouchableOpacity style={[styles.button, loading && { opacity: 0.6 }]} onPress={handleLogin} disabled={loading}>
<Text style={styles.buttonText}>{loading ? '登录中...' : '登录 / 注册'}</Text>
</TouchableOpacity>
<Text style={styles.tip}></Text>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32, backgroundColor: 'white' },
logoArea: { alignItems: 'center', marginBottom: 48 },
logo: { width: 80, height: 80, borderRadius: 20, backgroundColor: '#FF6B35', justifyContent: 'center', alignItems: 'center', marginBottom: 16 },
logoText: { fontSize: 36 },
title: { fontSize: 28, fontWeight: '700' },
subtitle: { fontSize: 15, color: '#86868B', marginTop: 4 },
input: { width: '100%', padding: 14, backgroundColor: '#f8f8fa', borderWidth: 1, borderColor: '#E5E5EA', borderRadius: 10, fontSize: 17, marginBottom: 12 },
button: { width: '100%', padding: 14, backgroundColor: '#FF6B35', borderRadius: 10, alignItems: 'center', marginTop: 12 },
buttonText: { color: 'white', fontSize: 17, fontWeight: '600' },
tip: { fontSize: 13, color: '#86868B', marginTop: 16 },
})
+51
View File
@@ -0,0 +1,51 @@
import React from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useAuth } from '../components/AuthProvider'
export default function ProfileScreen() {
const { user, logout } = useAuth()
const handleLogout = () => {
Alert.alert('确认退出', '确定要退出登录吗?', [
{ text: '取消', style: 'cancel' },
{ text: '确定', style: 'destructive', onPress: logout },
])
}
return (
<ScrollView style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.profileCard}>
<View style={styles.avatar}><Text style={{ fontSize: 28, color: 'white', fontWeight: '700' }}>{user?.nickname?.[0] || user?.phone?.slice(-4) || '?'}</Text></View>
<Text style={styles.name}>{user?.nickname || '未设置昵称'}</Text>
<Text style={styles.bio}>{user?.bio || '还没有个性签名'}</Text>
</View>
<View style={styles.statsRow}>
<View style={styles.stat}><Text style={styles.statVal}>{user?.points || 0}</Text><Text style={styles.statLbl}></Text></View>
<View style={styles.stat}><Text style={styles.statVal}>--</Text><Text style={styles.statLbl}></Text></View>
<View style={styles.stat}><Text style={styles.statVal}>--</Text><Text style={styles.statLbl}></Text></View>
</View>
<View style={styles.menu}>
<TouchableOpacity style={styles.menuItem}><Text></Text><Text style={styles.arrow}></Text></TouchableOpacity>
<TouchableOpacity style={[styles.menuItem, { borderBottomWidth: 0 }]} onPress={handleLogout}>
<Text style={{ color: '#FF3B30' }}>退</Text><Text style={styles.arrow}></Text>
</TouchableOpacity>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
profileCard: { backgroundColor: 'white', borderRadius: 12, margin: 12, padding: 32, alignItems: 'center', shadowOpacity: 0.04, elevation: 1 },
avatar: { width: 72, height: 72, borderRadius: 36, backgroundColor: '#FF6B35', justifyContent: 'center', alignItems: 'center', marginBottom: 12 },
name: { fontSize: 22, fontWeight: '700' },
bio: { fontSize: 14, color: '#86868B', marginTop: 4 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', backgroundColor: 'white', borderRadius: 12, margin: 8, padding: 16, shadowOpacity: 0.04, elevation: 1 },
stat: { alignItems: 'center' },
statVal: { fontSize: 24, fontWeight: '700', color: '#FF6B35' },
statLbl: { fontSize: 13, color: '#86868B' },
menu: { backgroundColor: 'white', borderRadius: 12, margin: 8, overflow: 'hidden', shadowOpacity: 0.04, elevation: 1 },
menuItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 14, borderBottomWidth: 0.5, borderBottomColor: '#E5E5EA' },
arrow: { fontSize: 15, color: '#86868B' },
})
+43
View File
@@ -0,0 +1,43 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const API_URL = 'http://localhost:8080'
const TOKEN_KEY = 'auth_token'
const USER_KEY = 'auth_user'
interface ApiResponse<T> { code: number; message: string; data?: T }
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(options.headers as any) }
const token = await AsyncStorage.getItem(TOKEN_KEY)
if (token) headers['Authorization'] = 'Bearer ' + token
const res = await fetch(API_URL + path, { ...options, headers })
const body: ApiResponse<T> = await res.json()
if (body.code !== 0) throw new Error(body.message)
return body.data as T
}
export const api = {
get: <T>(p: string) => request<T>(p),
post: <T>(p: string, d?: unknown) => request<T>(p, { method: 'POST', body: JSON.stringify(d) }),
put: <T>(p: string, d?: unknown) => request<T>(p, { method: 'PUT', body: JSON.stringify(d) }),
del: <T>(p: string) => request<T>(p, { method: 'DELETE' }),
}
export async function saveToken(token: string, user: any) {
await AsyncStorage.setItem(TOKEN_KEY, token)
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user))
}
export async function getStoredUser(): Promise<any | null> {
const raw = await AsyncStorage.getItem(USER_KEY)
return raw ? JSON.parse(raw) : null
}
export async function getStoredToken(): Promise<string | null> {
return AsyncStorage.getItem(TOKEN_KEY)
}
export async function clearAuth() {
await AsyncStorage.multiRemove([TOKEN_KEY, USER_KEY])
}