首次提交
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true)
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+7839
File diff suppressed because it is too large
Load Diff
@@ -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])
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user