首次提交

This commit is contained in:
zeronline
2026-06-25 08:39:47 +08:00
parent 20a93c703a
commit c5cccd346b
169 changed files with 24720 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import { getStoredUser, clearToken } from '@/services/api'
import { login as apiLogin, register as apiRegister, logout as apiLogout, type IUser } from '@/services/auth'
import { useRouter } from 'next/navigation'
interface AuthContextType {
user: IUser | null
loading: boolean
login: (phone: string) => Promise<void>
register: (phone: string) => Promise<void>
logout: () => void
refreshUser: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<IUser | null>(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
useEffect(() => {
const stored = getStoredUser<IUser>()
if (stored) {
setUser(stored)
}
setLoading(false)
}, [])
const login = useCallback(async (phone: string) => {
const u = await apiLogin(phone)
setUser(u)
router.push('/')
}, [router])
const register = useCallback(async (phone: string) => {
const u = await apiRegister(phone)
setUser(u)
router.push('/')
}, [router])
const logout = useCallback(() => {
apiLogout()
setUser(null)
router.push('/login')
}, [router])
const refreshUser = useCallback(async () => {
try {
const { getProfile } = await import('@/services/auth')
const u = await getProfile()
setUser(u)
} catch {
clearToken()
setUser(null)
router.push('/login')
}
}, [router])
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
+84
View File
@@ -0,0 +1,84 @@
'use client'
import { CATEGORY_CONFIG, CYCLE_LABEL, type ICheckInItem } from '@/services/checkin'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { createRecord } from '@/services/checkin'
interface CheckInCardProps {
isDone?: boolean
item: ICheckInItem
onCheckIn?: (itemId: string) => void
streak?: number
}
export default function CheckInCard({ item, onCheckIn, streak, isDone }: CheckInCardProps) {
const router = useRouter()
const [checking, setChecking] = useState(false)
const [done, setDone] = useState(isDone || false)
const cfg = CATEGORY_CONFIG[item.category] || CATEGORY_CONFIG.habit
async function handleCheckIn(e: React.MouseEvent) {
e.stopPropagation()
if (checking || done) return
setChecking(true)
try {
await createRecord(item.id)
setDone(true)
onCheckIn?.(item.id)
} catch {
setChecking(false)
}
}
return (
<div
className="card"
onClick={() => router.push(`/checkin/${item.id}`)}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px' }}
>
{/* 分类图标 */}
<div style={{
width: 44, height: 44, borderRadius: 12,
background: cfg.color + '20',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, flexShrink: 0,
}}>
{item.icon || cfg.icon}
</div>
{/* 名称与信息 */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 600, marginBottom: 2 }}>{item.name}</p>
<p style={{ fontSize: 13, color: '#86868B', display: 'flex', gap: 8, alignItems: 'center' }}>
<span>{cfg.label}</span>
<span>·</span>
<span>{CYCLE_LABEL[item.cycleType] || item.cycleType}</span>
{streak !== undefined && (
<>
<span>·</span>
<span style={{ color: '#FF6B35' }}>🔥 {streak} </span>
</>
)}
</p>
</div>
{/* 打卡按钮 */}
<button
onClick={handleCheckIn}
disabled={checking || done}
style={{
width: 44, height: 44, borderRadius: 22, border: 'none', cursor: done ? 'default' : 'pointer',
background: done
? 'linear-gradient(135deg, #34C759, #28A745)'
: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
color: 'white', fontSize: 18, fontWeight: 600, flexShrink: 0,
transition: 'transform 0.2s, opacity 0.2s',
opacity: checking ? 0.6 : 1,
}}
>
{done ? '✓' : checking ? '...' : '+'}
</button>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
'use client'
import { likePost, commentPost, type IPost } from '@/services/social'
import { useState } from 'react'
interface FeedCardProps {
post: IPost
onUpdate?: () => void
}
export default function FeedCard({ post, onUpdate }: FeedCardProps) {
const [liking, setLiking] = useState(false)
const [likes, setLikes] = useState(post.likes)
const [comments, setComments] = useState(post.comments)
const [showComment, setShowComment] = useState(false)
const [commentText, setCommentText] = useState('')
const createdAt = new Date(post.createdAt)
const timeStr = `${createdAt.getMonth()+1}${createdAt.getDate()}`
async function handleLike() {
if (liking) return
setLiking(true)
try {
await likePost(post.id)
setLikes(l => l + 1)
} catch { /* ignore */ }
setLiking(false)
}
async function handleComment() {
if (!commentText.trim()) return
try {
await commentPost(post.id)
setComments(c => c + 1)
setCommentText('')
setShowComment(false)
} catch { /* ignore */ }
}
// 解析图片数组
let images: string[] = []
try { if (post.images) images = JSON.parse(post.images) } catch { images = [] }
return (
<div className="card">
{/* 用户信息 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 18,
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: 'white', fontWeight: 600, flexShrink: 0,
}}>
{post.user?.nickname?.[0] || '?'}
</div>
<div style={{ flex: 1 }}>
<p style={{ fontSize: 14, fontWeight: 600 }}>{post.user?.nickname || '用户'}</p>
<p style={{ fontSize: 12, color: '#86868B' }}>{timeStr}</p>
</div>
</div>
{/* 内容 */}
{post.content && (
<p style={{ fontSize: 15, lineHeight: 1.5, marginBottom: images.length > 0 ? 10 : 0, whiteSpace: 'pre-wrap' }}>
{post.content}
</p>
)}
{/* 图片 */}
{images.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: images.length === 1 ? '1fr' : `repeat(${Math.min(images.length, 3)}, 1fr)`, gap: 4, marginBottom: 10 }}>
{images.slice(0, 3).map((url, i) => (
<div key={i} style={{
aspectRatio: '1', borderRadius: 8, background: '#F0F0F0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, color: '#86868B', overflow: 'hidden',
}}>
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
))}
</div>
)}
{/* 互动按钮 */}
<div style={{ display: 'flex', gap: 20, paddingTop: 8, borderTop: '0.5px solid #F0F0F0' }}>
<button onClick={handleLike} disabled={liking}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#86868B', display: 'flex', alignItems: 'center', gap: 4 }}>
{likes > 0 ? '❤️' : '🤍'} {likes}
</button>
<button onClick={() => setShowComment(!showComment)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#86868B', display: 'flex', alignItems: 'center', gap: 4 }}>
💬 {comments}
</button>
</div>
{/* 评论输入 */}
{showComment && (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<input className="input-field" value={commentText} onChange={e => setCommentText(e.target.value)}
placeholder="说点什么..." style={{ flex: 1, fontSize: 14, padding: '8px 12px' }}
onKeyDown={e => e.key === 'Enter' && handleComment()} />
<button onClick={handleComment} disabled={!commentText.trim()}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none',
background: commentText.trim() ? '#FF6B35' : '#E5E5EA',
color: 'white', fontSize: 14, cursor: commentText.trim() ? 'pointer' : 'default',
}}>
</button>
</div>
)}
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useAuth } from './AuthProvider'
import { useRouter } from 'next/navigation'
interface NavBarProps {
title: string
showBack?: boolean
showLogout?: boolean
}
export default function NavBar({ title, showBack = false, showLogout = false }: NavBarProps) {
const { logout, user } = useAuth()
const router = useRouter()
return (
<nav style={{
position: 'sticky', top: 0, zIndex: 100,
background: 'rgba(245,245,247,0.92)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderBottom: '0.5px solid #E5E5EA',
padding: '12px 16px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 60 }}>
{showBack && (
<button
onClick={() => router.back()}
style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', padding: 4 }}
>
</button>
)}
{showLogout && user && (
<button
onClick={() => router.push('/profile')}
style={{ background: 'none', border: 'none', fontSize: 15, cursor: 'pointer', color: '#FF6B35', padding: 4 }}
>
</button>
)}
</div>
<span style={{ fontSize: 17, fontWeight: 600, flex: 1, textAlign: 'center' }}>{title}</span>
<div style={{ minWidth: 60, textAlign: 'right' }}>
{showLogout && (
<button
onClick={logout}
style={{ background: 'none', border: 'none', fontSize: 15, cursor: 'pointer', color: '#86868B', padding: 4 }}
>
退
</button>
)}
</div>
</nav>
)
}
+40
View File
@@ -0,0 +1,40 @@
'use client'
interface ProgressRingProps {
percent: number
size?: number
strokeWidth?: number
color?: string
label?: string
}
export default function ProgressRing({ percent, size = 80, strokeWidth = 6, color = '#FF6B35', label }: ProgressRingProps) {
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const offset = circumference - (Math.min(percent, 100) / 100) * circumference
const center = size / 2
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<svg width={size} height={size}>
{/* 背景圆环 */}
<circle cx={center} cy={center} r={radius}
fill="none" stroke="#F0F0F0" strokeWidth={strokeWidth} />
{/* 进度圆环 */}
<circle cx={center} cy={center} r={radius}
fill="none" stroke={color} strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`}
style={{ transition: 'stroke-dashoffset 0.6s ease-out' }}
/>
<text x={center} y={center} textAnchor="middle" dominantBaseline="central"
fontSize={size * 0.28} fontWeight={700} fill="#1D1D1F">
{percent}%
</text>
</svg>
{label && <span style={{ fontSize: 12, color: '#86868B' }}>{label}</span>}
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
const TABS = [
{ path: '/', label: '打卡', icon: '📋' },
{ path: '/social', label: '广场', icon: '🌊' },
{ path: '/stats', label: '统计', icon: '📊' },
{ path: '/profile', label: '我的', icon: '👤' },
]
export default function TabBar() {
const pathname = usePathname()
const router = useRouter()
if (pathname === '/login') return null
function isActive(tabPath: string) {
if (tabPath === '/') return pathname === '/' || pathname.startsWith('/checkin')
return pathname.startsWith(tabPath)
}
return (
<nav style={{
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100,
background: 'rgba(245,245,247,0.95)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderTop: '0.5px solid #E5E5EA',
display: 'flex', justifyContent: 'space-around',
padding: '6px 0 20px',
}}>
{TABS.map(tab => {
const active = isActive(tab.path)
return (
<button key={tab.path} onClick={() => router.push(tab.path)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
fontSize: 10, color: active ? '#FF6B35' : '#86868B', fontWeight: active ? 600 : 400,
transition: 'color 0.2s',
}}>
<span style={{ fontSize: 22 }}>{tab.icon}</span>
<span>{tab.label}</span>
</button>
)
})}
</nav>
)
}