修改页面
CI / Server (Go) (push) Has been cancelled
CI / Web (Next.js) (push) Has been cancelled
CI / Mobile (React Native) (push) Has been cancelled
CI / Admin (React) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

This commit is contained in:
zeronline
2026-06-25 16:33:32 +08:00
parent c5cccd346b
commit 545329ac40
63 changed files with 2560 additions and 161 deletions
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock AsyncStorage
const storage: Record<string, string> = {}
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => Promise.resolve(storage[key] || null)),
setItem: vi.fn((key: string, val: string) => { storage[key] = val; return Promise.resolve() }),
removeItem: vi.fn((key: string) => { delete storage[key]; return Promise.resolve() }),
multiRemove: vi.fn((keys: string[]) => { keys.forEach(k => delete storage[k]); return Promise.resolve() }),
},
}))
beforeEach(() => {
Object.keys(storage).forEach(k => delete storage[k])
})
// ========== API Service ==========
describe('Mobile API Service', () => {
it('should export api methods', async () => {
const mod = await import('../services/api')
expect(mod.api).toBeDefined()
expect(typeof mod.api.get).toBe('function')
expect(typeof mod.api.post).toBe('function')
expect(typeof mod.api.put).toBe('function')
expect(typeof mod.api.del).toBe('function')
})
it('should export auth helpers', async () => {
const mod = await import('../services/api')
expect(typeof mod.saveToken).toBe('function')
expect(typeof mod.getStoredUser).toBe('function')
expect(typeof mod.getStoredToken).toBe('function')
expect(typeof mod.clearAuth).toBe('function')
})
it('saveToken + getStoredUser should persist auth', async () => {
const { saveToken, getStoredUser, getStoredToken } = await import('../services/api')
await saveToken('test-token', { id: '1', nickname: 'Tester', points: 100 })
const token = await getStoredToken()
expect(token).toBe('test-token')
const user = await getStoredUser()
expect(user.nickname).toBe('Tester')
expect(user.points).toBe(100)
})
it('clearAuth should remove all auth data', async () => {
const { saveToken, clearAuth, getStoredToken, getStoredUser } = await import('../services/api')
await saveToken('t', { id: '1', nickname: 'T' })
await clearAuth()
expect(await getStoredToken()).toBeNull()
expect(await getStoredUser()).toBeNull()
})
})
// ========== AuthProvider ==========
describe('AuthProvider', () => {
it('should export AuthProvider and useAuth', async () => {
const mod = await import('../components/AuthProvider')
expect(mod.AuthProvider).toBeDefined()
expect(typeof mod.useAuth).toBe('function')
})
})
// ========== CheckIn Module ==========
describe('CheckIn Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/checkin/index')
expect(typeof mod.createItem).toBe('function')
expect(typeof mod.listItems).toBe('function')
expect(typeof mod.getTodayStatus).toBe('function')
expect(typeof mod.getStreak).toBe('function')
expect(typeof mod.getRecords).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/checkin/index')
expect(typeof mod.useCheckInItems).toBe('function')
expect(typeof mod.useCheckInStatus).toBe('function')
expect(typeof mod.useCheckInDetail).toBe('function')
})
it('should export category constants', async () => {
const mod = await import('../modules/checkin/index')
expect(mod.CATEGORY_CONFIG.study.label).toBe('学习成长')
expect(mod.CATEGORY_CONFIG.health.icon).toBe('❤️')
expect(mod.CATEGORY_CONFIG.goal.color).toBe('#FF6B35')
expect(mod.CYCLE_LABEL.daily).toBe('每日')
})
})
// ========== Social Module ==========
describe('Social Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/social/index')
expect(typeof mod.createPost).toBe('function')
expect(typeof mod.getFeed).toBe('function')
expect(typeof mod.likePost).toBe('function')
expect(typeof mod.listFriends).toBe('function')
expect(typeof mod.sendFriendRequest).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/social/index')
expect(typeof mod.useFeed).toBe('function')
expect(typeof mod.useFriends).toBe('function')
})
})
// ========== Profile Module ==========
describe('Profile Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/profile/index')
expect(typeof mod.getAchievements).toBe('function')
expect(typeof mod.updateProfile).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/profile/index')
expect(typeof mod.useProfile).toBe('function')
expect(typeof mod.useAchievements).toBe('function')
})
})
// ========== Discover Module ==========
describe('Discover Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/discover/index')
expect(typeof mod.getLeaderboard).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/discover/index')
expect(typeof mod.useLeaderboard).toBe('function')
expect(typeof mod.useDiscover).toBe('function')
})
})
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock AsyncStorage
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(() => Promise.resolve(null)),
setItem: vi.fn(() => Promise.resolve()),
removeItem: vi.fn(() => Promise.resolve()),
multiRemove: vi.fn(() => Promise.resolve()),
},
}))
describe('Mobile API Service', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('should export api with get/post/put/del methods', async () => {
const mod = await import('../services/api')
expect(mod.api).toBeDefined()
expect(typeof mod.api.get).toBe('function')
expect(typeof mod.api.post).toBe('function')
expect(typeof mod.api.put).toBe('function')
expect(typeof mod.api.del).toBe('function')
})
it('should export auth helper functions', async () => {
const mod = await import('../services/api')
expect(typeof mod.saveToken).toBe('function')
expect(typeof mod.getStoredUser).toBe('function')
expect(typeof mod.clearAuth).toBe('function')
})
it('should export AuthProvider and useAuth', async () => {
const mod = await import('../components/AuthProvider')
expect(mod.AuthProvider).toBeDefined()
expect(typeof mod.useAuth).toBe('function')
})
})
+219
View File
@@ -0,0 +1,219 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
// ========== 类型定义 ==========
export type CheckInCategory = 'study' | 'health' | 'habit' | 'hobby' | 'goal'
export type CycleType = 'daily' | 'weekly' | 'every_other_day' | 'custom'
export type ItemStatus = 'active' | 'paused' | 'archived' | 'deleted'
export interface ICheckInItem {
id: string
userId: string
name: string
icon?: string
category: CheckInCategory
dailyGoal?: string
cycleType: CycleType
privacy: number
status: ItemStatus
createdAt: string
}
export interface ICheckInRecord {
id: string
itemId: string
userId: string
checkDate: string
status: 'normal' | 'makeup'
note?: string
images?: string
}
export interface IStreak {
itemId: string
streak: number
}
export interface IMonthlyStats {
year: number
month: number
total: number
records: ICheckInRecord[]
streaks: Record<string, number>
}
export const CATEGORY_CONFIG: Record<CheckInCategory, { label: string; icon: string; color: string }> = {
study: { label: '学习成长', icon: '📚', color: '#4CC9F0' },
health: { label: '健康生活', icon: '❤️', color: '#06D6A0' },
habit: { label: '习惯自律', icon: '🔄', color: '#7209B7' },
hobby: { label: '兴趣爱好', icon: '⭐', color: '#F72585' },
goal: { label: '目标任务', icon: '🎯', color: '#FF6B35' },
}
export const CYCLE_LABEL: Record<string, string> = {
daily: '每日',
weekly: '每周',
every_other_day: '隔日',
custom: '自定义',
}
// ========== 创建打卡项 ==========
export type CreateItemInput = {
name: string
category: CheckInCategory
icon?: string
dailyGoal?: string
cycleType?: CycleType
privacy?: number
}
export async function createItem(data: CreateItemInput): Promise<ICheckInItem> {
return api.post<ICheckInItem>('/api/checkin/items', data)
}
export async function listItems(): Promise<ICheckInItem[]> {
return api.get<ICheckInItem[]>('/api/checkin/items')
}
export async function getItem(id: string): Promise<ICheckInItem> {
return api.get<ICheckInItem>(`/api/checkin/items/${id}`)
}
export async function updateItem(id: string, data: Partial<ICheckInItem>): Promise<ICheckInItem> {
return api.put<ICheckInItem>(`/api/checkin/items/${id}`, data)
}
export async function deleteItem(id: string): Promise<void> {
return api.del(`/api/checkin/items/${id}`)
}
export async function pauseItem(id: string): Promise<void> {
return api.post(`/api/checkin/items/${id}/pause`)
}
export async function resumeItem(id: string): Promise<void> {
return api.post(`/api/checkin/items/${id}/resume`)
}
// ========== 打卡记录 ==========
export async function createRecord(itemId: string, note?: string): Promise<ICheckInRecord> {
return api.post<ICheckInRecord>('/api/checkin/records', { itemId, note })
}
export async function makeupRecord(itemId: string, checkDate: string): Promise<ICheckInRecord> {
return api.post<ICheckInRecord>('/api/checkin/records/makeup', { itemId, checkDate })
}
export async function getRecords(params: { itemId?: string; start?: string; end?: string }): Promise<ICheckInRecord[]> {
const qs = new URLSearchParams()
if (params.itemId) qs.set('itemId', params.itemId)
if (params.start) qs.set('start', params.start)
if (params.end) qs.set('end', params.end)
return api.get<ICheckInRecord[]>(`/api/checkin/records?${qs}`)
}
// ========== 统计 ==========
export async function getStreak(itemId: string): Promise<IStreak> {
return api.get<IStreak>(`/api/checkin/streak/${itemId}`)
}
export async function getTodayStatus(): Promise<Record<string, boolean>> {
return api.get<Record<string, boolean>>('/api/checkin/today-status')
}
export async function getMonthlyStats(year: number, month: number): Promise<IMonthlyStats> {
return api.get<IMonthlyStats>(`/api/checkin/stats/monthly?year=${year}&month=${month}`)
}
// ========== Hook: 打卡列表 ==========
export function useCheckInItems() {
const [items, setItems] = useState<ICheckInItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await listItems()
setItems(data.filter((i) => i.status !== 'deleted'))
} catch (e: any) {
setError(e.message || '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { items, loading, error, reload: load }
}
// ========== Hook: 今日状态 + 连续天数 ==========
export function useCheckInStatus(items: ICheckInItem[]) {
const [todayStatus, setTodayStatus] = useState<Record<string, boolean>>({})
const [streaks, setStreaks] = useState<Record<string, number>>({})
const load = useCallback(async () => {
try {
const [ts] = await Promise.all([getTodayStatus()])
setTodayStatus(ts || {})
const s: Record<string, number> = {}
await Promise.all(
items
.filter((i) => i.status === 'active')
.map(async (item) => {
try {
const r = await getStreak(item.id)
s[item.id] = r.streak
} catch {
/* ignore */
}
}),
)
setStreaks(s)
} catch {
/* ignore */
}
}, [items])
useEffect(() => {
if (items.length > 0) load()
}, [items, load])
return { todayStatus, streaks, reload: load }
}
// ========== Hook: 打卡详情 ==========
export function useCheckInDetail(itemId: string) {
const [item, setItem] = useState<ICheckInItem | null>(null)
const [streak, setStreak] = useState(0)
const [records, setRecords] = useState<ICheckInRecord[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const [itemData, streakData, recordsData] = await Promise.all([
getItem(itemId),
getStreak(itemId),
getRecords({
itemId,
start: new Date().toISOString().slice(0, 7) + '-01',
end: new Date().toISOString().slice(0, 10),
}),
])
setItem(itemData)
setStreak(streakData.streak)
setRecords(recordsData || [])
} catch {
// handled by caller
} finally {
setLoading(false)
}
}, [itemId])
useEffect(() => { load() }, [load])
return { item, streak, records, loading, reload: load }
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
// ========== 排行榜 ==========
export interface IRankEntry {
rank: number
userId: string
nickname: string
avatar?: string
value: number
}
export type RankType = 'streak' | 'points' | 'checkin_count'
export async function getLeaderboard(type: RankType, limit = 20): Promise<IRankEntry[]> {
// 后端暂只支持 points 排行,type 参数预留
return api.get<IRankEntry[]>(`/api/leaderboard?limit=${limit}`)
}
// ========== 推荐用户 ==========
export async function getRecommendedUsers(): Promise<{ id: string; nickname?: string; avatar?: string; bio?: string }[]> {
return api.get('/api/discover/users')
}
// ========== Hook: 排行榜 ==========
export function useLeaderboard(type: RankType = 'streak') {
const [entries, setEntries] = useState<IRankEntry[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getLeaderboard(type)
setEntries(data || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [type])
useEffect(() => { load() }, [load])
return { entries, loading, reload: load }
}
// ========== Hook: 发现页 ==========
export function useDiscover() {
const [leaderboard, setLeaderboard] = useState<IRankEntry[]>([])
const [recommended, setRecommended] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const [lb, rec] = await Promise.all([
getLeaderboard('streak', 10),
getRecommendedUsers(),
])
setLeaderboard(lb || [])
setRecommended(rec || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { leaderboard, recommended, loading, reload: load }
}
+73
View File
@@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
import { useAuth } from '../../components/AuthProvider'
// ========== 成就 ==========
export interface IAchievement {
id: string
name: string
icon?: string
condition: string
rarity: string
unlocked?: boolean
unlockedAt?: string
}
export async function getAchievements(): Promise<IAchievement[]> {
return api.get<IAchievement[]>('/api/achievements')
}
// ========== 用户资料 API ==========
export async function updateProfile(updates: Record<string, any>): Promise<any> {
return api.put('/api/users/profile', updates)
}
export async function getUserPoints(): Promise<{ points: number }> {
return api.get<{ points: number }>('/api/users/points')
}
// ========== Hook: 个人统计与成就 ==========
export function useProfile() {
const { user, refreshUser } = useAuth()
const [achievements, setAchievements] = useState<IAchievement[]>([])
const [points, setPoints] = useState(0)
const load = useCallback(async () => {
try {
const [ach, pts] = await Promise.all([
getAchievements(),
getUserPoints(),
])
setAchievements(ach || [])
setPoints(pts?.points ?? 0)
} catch {
// features may not be ready yet
}
}, [])
useEffect(() => { load() }, [load])
return { user, achievements, points, updateProfile, refreshUser, reload: load }
}
// ========== Hook: 成就列表 ==========
export function useAchievements() {
const [achievements, setAchievements] = useState<IAchievement[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getAchievements()
setAchievements(data || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { achievements, loading, reload: load }
}
+143
View File
@@ -0,0 +1,143 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
// ========== 类型定义 ==========
export interface IPost {
id: string
userId: string
content?: string
images?: string
privacy: number
likes: number
comments: number
createdAt: string
user?: { nickname?: string; avatar?: string }
}
export interface IUserBrief {
id: string
nickname?: string
avatar?: string
}
export interface IGroup {
id: string
name: string
cover?: string
intro?: string
type: string
maxMember: number
createdBy: string
createdAt: string
}
// ========== 动态 API ==========
export async function createPost(data: { content?: string; images?: string; privacy?: number }): Promise<IPost> {
return api.post<IPost>('/api/social/posts', data)
}
export async function getFeed(page = 1, pageSize = 20): Promise<{ list: IPost[]; total: number; page: number }> {
return api.get(`/api/social/feed?page=${page}&pageSize=${pageSize}`)
}
export async function likePost(postId: string): Promise<void> {
return api.post(`/api/social/posts/${postId}/like`)
}
export async function deletePost(postId: string): Promise<void> {
return api.del(`/api/social/posts/${postId}`)
}
// ========== 好友 API ==========
export async function sendFriendRequest(friendId: string): Promise<void> {
return api.post('/api/social/friends/request', { friendId })
}
export async function acceptFriendRequest(friendId: string): Promise<void> {
return api.post('/api/social/friends/accept', { friendId })
}
export async function removeFriend(userId: string): Promise<void> {
return api.del(`/api/social/friends/${userId}`)
}
export async function listFriends(): Promise<IUserBrief[]> {
return api.get<IUserBrief[]>('/api/social/friends')
}
// ========== 小组 API ==========
export async function createGroup(data: Partial<IGroup>): Promise<IGroup> {
return api.post<IGroup>('/api/social/groups', data)
}
export async function joinGroup(groupId: string): Promise<void> {
return api.post(`/api/social/groups/${groupId}/join`)
}
export async function leaveGroup(groupId: string): Promise<void> {
return api.post(`/api/social/groups/${groupId}/leave`)
}
export async function getGroupMembers(groupId: string): Promise<{ groupId: string; userId: string; role: string; joinedAt: string }[]> {
return api.get(`/api/social/groups/${groupId}/members`)
}
// ========== Hook: 动态 Feed ==========
export function useFeed() {
const [posts, setPosts] = useState<IPost[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getFeed()
setPosts(data.list || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const toggleLike = useCallback(async (postId: string) => {
try {
await likePost(postId)
setPosts((prev) =>
prev.map((p) => (p.id === postId ? { ...p, likes: p.likes + 1 } : p)),
)
} catch {
// silent
}
}, [])
const publishPost = useCallback(async (content: string) => {
await createPost({ content, privacy: 1 })
await load()
}, [load])
return { posts, loading, reload: load, toggleLike, publishPost }
}
// ========== Hook: 好友列表 ==========
export function useFriends() {
const [friends, setFriends] = useState<IUserBrief[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await listFriends()
setFriends(data || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { friends, loading, reload: load }
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { FeedStackParamList } from './types'
import FeedScreen from '../screens/FeedScreen'
const Stack = createNativeStackNavigator<FeedStackParamList>()
export default function FeedStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="FeedMain" component={FeedScreen} />
</Stack.Navigator>
)
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { HomeStackParamList } from './types'
import HomeScreen from '../screens/HomeScreen'
import CheckInDetailScreen from '../screens/CheckInDetailScreen'
import CreateCheckInScreen from '../screens/CreateCheckInScreen'
const Stack = createNativeStackNavigator<HomeStackParamList>()
export default function HomeStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="HomeMain" component={HomeScreen} />
<Stack.Screen name="CheckInDetail" component={CheckInDetailScreen} />
<Stack.Screen name="CreateCheckIn" component={CreateCheckInScreen} />
</Stack.Navigator>
)
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import Ionicons from '@expo/vector-icons/Ionicons'
import type { MainTabParamList } from './types'
import HomeStack from './HomeStack'
import FeedStack from './FeedStack'
import ProfileStack from './ProfileStack'
const Tab = createBottomTabNavigator<MainTabParamList>()
export default function MainTabs() {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#FF6B35',
tabBarInactiveTintColor: '#86868B',
tabBarStyle: {
backgroundColor: 'rgba(245,245,247,0.95)',
borderTopColor: '#E5E5EA',
borderTopWidth: 0.5,
paddingTop: 6,
paddingBottom: 20,
},
tabBarLabelStyle: {
fontSize: 10,
fontWeight: '600' as const,
},
}}
>
<Tab.Screen
name="HomeTab"
component={HomeStack}
options={{
tabBarLabel: '打卡',
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tab.Screen
name="FeedTab"
component={FeedStack}
options={{
tabBarLabel: '广场',
tabBarIcon: ({ color, size }) => <Ionicons name="newspaper" size={size} color={color} />,
}}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileStack}
options={{
tabBarLabel: '我的',
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
}}
/>
</Tab.Navigator>
)
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ProfileStackParamList } from './types'
import ProfileScreen from '../screens/ProfileScreen'
import AchievementsScreen from '../screens/AchievementsScreen'
const Stack = createNativeStackNavigator<ProfileStackParamList>()
export default function ProfileStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="ProfileMain" component={ProfileScreen} />
<Stack.Screen name="Achievements" component={AchievementsScreen} />
</Stack.Navigator>
)
}
+56
View File
@@ -0,0 +1,56 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'
import type { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native'
// ========== Home Stack ==========
export type HomeStackParamList = {
HomeMain: undefined
CheckInDetail: { itemId: string }
CreateCheckIn: undefined
}
// ========== Feed Stack ==========
export type FeedStackParamList = {
FeedMain: undefined
}
// ========== Profile Stack ==========
export type ProfileStackParamList = {
ProfileMain: undefined
Achievements: undefined
}
// ========== Main Tab Navigator ==========
export type MainTabParamList = {
HomeTab: NavigatorScreenParams<HomeStackParamList>
FeedTab: NavigatorScreenParams<FeedStackParamList>
ProfileTab: NavigatorScreenParams<ProfileStackParamList>
}
// ========== Root Navigator ==========
export type RootStackParamList = {
Login: undefined
MainTabs: NavigatorScreenParams<MainTabParamList>
}
// ========== Screen Prop Helpers ==========
export type HomeScreenProps = CompositeScreenProps<
NativeStackScreenProps<HomeStackParamList, 'HomeMain'>,
BottomTabScreenProps<MainTabParamList>
>
export type CheckInDetailScreenProps = NativeStackScreenProps<HomeStackParamList, 'CheckInDetail'>
export type CreateCheckInScreenProps = NativeStackScreenProps<HomeStackParamList, 'CreateCheckIn'>
export type FeedScreenProps = CompositeScreenProps<
NativeStackScreenProps<FeedStackParamList, 'FeedMain'>,
BottomTabScreenProps<MainTabParamList>
>
export type ProfileScreenProps = CompositeScreenProps<
NativeStackScreenProps<ProfileStackParamList, 'ProfileMain'>,
BottomTabScreenProps<MainTabParamList>
>
export type LoginScreenProps = NativeStackScreenProps<RootStackParamList, 'Login'>
+159
View File
@@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'
import { api } from '../services/api'
interface IAchievement {
id: string
name: string
icon?: string
condition: string
rarity: string
unlocked?: boolean
unlockedAt?: string
}
const RARITY_CONFIG: Record<string, { label: string; color: string }> = {
bronze: { label: '青铜', color: '#CD7F32' },
silver: { label: '白银', color: '#C0C0C0' },
gold: { label: '黄金', color: '#FFD700' },
}
export default function AchievementsScreen() {
const [achievements, setAchievements] = useState<IAchievement[]>([])
const [points, setPoints] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get<IAchievement[]>('/api/achievements'),
api.get<{ points: number }>('/api/users/points'),
])
.then(([ach, pts]) => {
setAchievements(ach || [])
setPoints(pts?.points ?? 0)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
const unlocked = achievements.filter((a) => a.unlocked)
const locked = achievements.filter((a) => !a.unlocked)
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
{/* 积分头部 */}
<View style={styles.pointsHeader}>
<Text style={styles.pointsValue}>{points}</Text>
<Text style={styles.pointsLabel}></Text>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statNum}>{unlocked.length}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={[styles.statItem, { borderRightWidth: 0 }]}>
<Text style={styles.statNum}>{achievements.length}</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 24 }}>
{/* 已解锁 */}
{unlocked.length > 0 && (
<>
<Text style={styles.sectionTitle}> ({unlocked.length})</Text>
<View style={styles.grid}>
{unlocked.map((a) => (
<View key={a.id} style={[styles.badge, { borderColor: RARITY_CONFIG[a.rarity]?.color || '#E5E5EA' }]}>
<Text style={styles.badgeIcon}>{a.icon || '🏅'}</Text>
<Text style={styles.badgeName}>{a.name}</Text>
<Text style={[styles.badgeRarity, { color: RARITY_CONFIG[a.rarity]?.color || '#86868B' }]}>
{RARITY_CONFIG[a.rarity]?.label || a.rarity}
</Text>
</View>
))}
</View>
</>
)}
{/* 未解锁 */}
{locked.length > 0 && (
<>
<Text style={styles.sectionTitle}> ({locked.length})</Text>
<View style={styles.grid}>
{locked.map((a) => (
<View key={a.id} style={[styles.badge, styles.lockedBadge]}>
<Text style={[styles.badgeIcon, { opacity: 0.3 }]}>🔒</Text>
<Text style={[styles.badgeName, { color: '#86868B' }]}>{a.name}</Text>
<Text style={{ fontSize: 10, color: '#C7C7CC', textAlign: 'center' }}>{a.condition}</Text>
</View>
))}
</View>
</>
)}
{achievements.length === 0 && (
<View style={{ alignItems: 'center', padding: 60 }}>
<Text style={{ fontSize: 40 }}>🏆</Text>
<Text style={{ fontSize: 15, color: '#86868B', marginTop: 8 }}></Text>
</View>
)}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
pointsHeader: {
backgroundColor: 'white',
alignItems: 'center',
paddingVertical: 28,
marginBottom: 8,
},
pointsValue: { fontSize: 48, fontWeight: '700', color: '#FF6B35' },
pointsLabel: { fontSize: 13, color: '#86868B', marginTop: 2 },
statsRow: {
flexDirection: 'row',
marginTop: 16,
backgroundColor: '#F5F5F7',
borderRadius: 10,
overflow: 'hidden',
},
statItem: {
paddingHorizontal: 24,
paddingVertical: 10,
borderRightWidth: 0.5,
borderRightColor: '#E5E5EA',
alignItems: 'center',
},
statNum: { fontSize: 18, fontWeight: '700', color: '#1D1D1F' },
statLabel: { fontSize: 12, color: '#86868B' },
sectionTitle: { fontSize: 17, fontWeight: '600', margin: 16, marginBottom: 12 },
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
gap: 10,
},
badge: {
width: '30%',
backgroundColor: 'white',
borderRadius: 12,
padding: 12,
alignItems: 'center',
gap: 4,
borderWidth: 2,
},
lockedBadge: { borderColor: '#E5E5EA', opacity: 0.7 },
badgeIcon: { fontSize: 28 },
badgeName: { fontSize: 12, fontWeight: '600', textAlign: 'center' },
badgeRarity: { fontSize: 10, fontWeight: '600' },
})
+10 -3
View File
@@ -1,8 +1,15 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'
import { useNavigation, useRoute } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import type { RouteProp } from '@react-navigation/native'
import { api } from '../services/api'
import type { HomeStackParamList } from '../navigation/types'
export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string; onBack: () => void }) {
export default function CheckInDetailScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList, 'CheckInDetail'>>()
const route = useRoute<RouteProp<HomeStackParamList, 'CheckInDetail'>>()
const { itemId } = route.params
const [item, setItem] = useState<any>(null)
const [streak, setStreak] = useState(0)
const [records, setRecords] = useState<any[]>([])
@@ -25,7 +32,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string
Alert.alert('确认删除', '确定删除这个打卡项?', [
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: async () => {
try { await api.del('/api/checkin/items/' + itemId); onBack() }
try { await api.del('/api/checkin/items/' + itemId); navigation.goBack() }
catch { Alert.alert('错误', '删除失败') }
}},
])
@@ -36,7 +43,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.nav}>
<TouchableOpacity onPress={onBack}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<Text style={styles.navTitle}>{item?.name || '打卡详情'}</Text>
<View style={{ width: 60 }} />
</View>
+8 -4
View File
@@ -1,6 +1,9 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { api } from '../services/api'
import type { HomeStackParamList } from '../navigation/types'
const CATEGORIES = [
{ key: 'study', label: '📚 学习' },
@@ -10,7 +13,8 @@ const CATEGORIES = [
{ key: 'goal', label: '🎯 目标' },
]
export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: () => void; onCreated?: () => void }) {
export default function CreateCheckInScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList, 'CreateCheckIn'>>()
const [name, setName] = useState('')
const [category, setCategory] = useState('study')
const [cycleType, setCycleType] = useState('daily')
@@ -21,8 +25,8 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: ()
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?.() } }])
await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined })
Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { navigation.goBack() } }])
} catch (e: any) { Alert.alert('错误', e.message || '创建失败') }
setSubmitting(false)
}
@@ -30,7 +34,7 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: ()
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.nav}>
<TouchableOpacity onPress={onBack}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<Text style={styles.navTitle}></Text>
<View style={{ width: 60 }} />
</View>
+7 -3
View File
@@ -1,9 +1,13 @@
import React, { useEffect, useState, useCallback } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { api } from '../services/api'
import { useAuth } from '../components/AuthProvider'
import type { HomeStackParamList } from '../navigation/types'
export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectItem?: (id: string) => void; onCreateItem?: () => void }) {
export default function HomeScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList, 'HomeMain'>>()
const { user } = useAuth()
const [items, setItems] = useState<any[]>([])
const [streaks, setStreaks] = useState<Record<string, number>>({})
@@ -42,7 +46,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte
<View style={s.sectionHeader}>
<Text style={s.sectionTitle}></Text>
<TouchableOpacity onPress={onCreateItem}><Text style={s.createBtn}>+ </Text></TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('CreateCheckIn')}><Text style={s.createBtn}>+ </Text></TouchableOpacity>
</View>
{activeItems.length === 0 ? (
@@ -52,7 +56,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte
<Text style={{ fontSize: 13, color: '#86868B', marginTop: 4 }}>"创建"</Text>
</View>
) : activeItems.map((item: any) => (
<TouchableOpacity key={item.id} onPress={() => onSelectItem?.(item.id)}>
<TouchableOpacity key={item.id} onPress={() => navigation.navigate('CheckInDetail', { itemId: item.id })}>
<View style={s.card}>
<View style={s.cardIcon}><Text style={{ fontSize: 22 }}>{item.icon || '📋'}</Text></View>
<View style={{ flex: 1 }}>
+6 -2
View File
@@ -1,9 +1,13 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { api, saveToken } from '../services/api'
import { useAuth } from '../components/AuthProvider'
import type { RootStackParamList } from '../navigation/types'
export default function LoginScreen({ onLogin }: { onLogin: () => void }) {
export default function LoginScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Login'>>()
const [phone, setPhone] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
@@ -14,7 +18,7 @@ export default function LoginScreen({ onLogin }: { onLogin: () => void }) {
try {
const result = await api.post<{ user: any; token: string }>('/api/auth/login', { phone })
await login(result.token, result.user)
onLogin()
navigation.replace('MainTabs')
} catch (e: any) {
Alert.alert('登录失败', e.message || '请稍后重试')
} finally { setLoading(false) }
+5 -1
View File
@@ -1,9 +1,13 @@
import React from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useAuth } from '../components/AuthProvider'
import type { ProfileStackParamList } from '../navigation/types'
export default function ProfileScreen() {
const { user, logout } = useAuth()
const navigation = useNavigation<NativeStackNavigationProp<ProfileStackParamList, 'ProfileMain'>>()
const handleLogout = () => {
Alert.alert('确认退出', '确定要退出登录吗?', [
@@ -27,7 +31,7 @@ export default function ProfileScreen() {
</View>
<View style={styles.menu}>
<TouchableOpacity style={styles.menuItem}><Text></Text><Text style={styles.arrow}></Text></TouchableOpacity>
<TouchableOpacity style={styles.menuItem} onPress={() => navigation.navigate('Achievements')}><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>