首次提交
This commit is contained in:
@@ -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' },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user