首次提交

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
+8
View File
@@ -0,0 +1,8 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
+3
View File
@@ -0,0 +1,3 @@
{
"devices": []
}
+83
View File
@@ -0,0 +1,83 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView, StatusBar } from 'react-native'
import { AuthProvider, useAuth } from './src/components/AuthProvider'
import { getStoredToken } from './src/services/api'
import LoginScreen from './src/screens/LoginScreen'
import HomeScreen from './src/screens/HomeScreen'
import FeedScreen from './src/screens/FeedScreen'
import ProfileScreen from './src/screens/ProfileScreen'
import CheckInDetailScreen from './src/screens/CheckInDetailScreen'
import CreateCheckInScreen from './src/screens/CreateCheckInScreen'
const TABS = [
{ key: 'home', label: '打卡', icon: '📋' },
{ key: 'feed', label: '广场', icon: '🌊' },
{ key: 'profile', label: '我的', icon: '👤' },
]
function TabBar({ active, onChange }: { active: string; onChange: (k: string) => void }) {
return (
<View style={tabBarStyles.container}>
{TABS.map(t => (
<TouchableOpacity key={t.key} onPress={() => onChange(t.key)} style={tabBarStyles.tab}>
<Text style={tabBarStyles.icon}>{t.icon}</Text>
<Text style={[tabBarStyles.label, active === t.key && tabBarStyles.active]}>{t.label}</Text>
</TouchableOpacity>
))}
</View>
)
}
const tabBarStyles = StyleSheet.create({
container: { flexDirection: 'row', borderTopWidth: 0.5, borderTopColor: '#E5E5EA', backgroundColor: 'rgba(245,245,247,0.95)', paddingBottom: 20, paddingTop: 6 },
tab: { flex: 1, alignItems: 'center', gap: 2 },
icon: { fontSize: 22 },
label: { fontSize: 10, color: '#86868B' },
active: { color: '#FF6B35', fontWeight: '600' },
})
function MainApp() {
const { user, loading } = useAuth()
const [tab, setTab] = useState('home')
const [screen, setScreen] = useState<'loading' |const [screen, setScreen] = useState<'loading' | 'login' | 'main'>('loading')
const [selectedItemId, setSelectedItemId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)| 'main'>('loading')
useEffect(() => {
if (!loading) {
getStoredToken().then(t => setScreen(t ? 'main' : 'login'))
}
}, [loading])
const onLogin = useCallback(() => setScreen('main'), [])
if (screen === 'loading') return <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}><Text style={{ color: '#86868B' }}>加载中...</Text></View>
if (screen === 'login') return <LoginScreen onLogin={onLogin} />
if (selectedItemId) return <CheckInDetailScreen itemId={selectedItemId} onBack={() => setSelectedItemId(null)} />
if (showCreate) return <CreateCheckInScreen onBack={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); setTab('home') }} />
const renderScreen = () => {
switch (tab) {
case 'home': return <HomeScreen onSelectItem={setSelectedItemId} onCreateItem={() => setShowCreate(true)} />
case 'feed': return <FeedScreen />
case 'profile': return <ProfileScreen />
default: return null
}
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<StatusBar barStyle="dark-content" background="#F5F5F7" />
{renderScreen()}
<TabBar active={tab} onChange={setTab} />
</SafeAreaView>
)
}
export default function App() {
return (
<AuthProvider>
<MainApp />
</AuthProvider>
)
}
+28
View File
@@ -0,0 +1,28 @@
{
"expo": {
"name": "寸进",
"slug": "inchstep",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#FF6B35"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.inchstep.app"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#FF6B35"
},
"package": "com.inchstep.app"
},
"plugins": []
}
}
+1
View File
@@ -0,0 +1 @@
placeholder
+1
View File
@@ -0,0 +1 @@
placeholder
+1
View File
@@ -0,0 +1 @@
placeholder
+1
View File
@@ -0,0 +1 @@
placeholder
+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "inchstep-mobile",
"version": "1.0.0",
"main": "expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint .",
"test": "react-native-testing-library"
},
"dependencies": {
"expo": "~52.0.0",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-native": "0.76.7",
"react-native-safe-area-context": "~5.0.0",
"react-native-screens": "~4.4.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "~2.1.0"
},
"devDependencies": {
"@types/react": "~18.3.0",
"typescript": "~5.3.0"
},
"private": true
}
+7839
View File
File diff suppressed because it is too large Load Diff
+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])
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"strict": true,
"jsx": "react-native",
"moduleResolution": "bundler",
"target": "esnext",
"module": "esnext",
"lib": [
"es2020"
],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
"App.tsx"
],
"exclude": [
"node_modules"
],
"extends": "expo/tsconfig.base"
}