修改页面
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'>
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user