提交修改
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-29 17:38:01 +08:00
parent b01a5f91c3
commit 471013938d
18 changed files with 2380 additions and 980 deletions
+180 -98
View File
@@ -1,109 +1,191 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { AuthProvider, useAuth } from '@/components/AuthProvider' import { AuthProvider, useAuth } from '@/components/AuthProvider'
import NavBar from '@/components/NavBar' import NavBar from '@/components/NavBar'
import { getAllAchievements, getUserAchievements, RARITY_CONFIG, type IAchievement } from '@/services/achievements' import { getAllAchievements, getUserAchievements, RARITY_CONFIG, type IAchievement } from '@/services/achievements'
function AchievementsPage() { function AchievementsPage() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const router = useRouter() const router = useRouter()
const [allAchievements, setAllAchievements] = useState<IAchievement[]>([]) const [allAchievements, setAllAchievements] = useState<IAchievement[]>([])
const [unlockedIds, setUnlockedIds] = useState<Set<string>>(new Set()) const [unlockedIds, setUnlockedIds] = useState<Set<string>>(new Set())
const [pageLoading, setPageLoading] = useState(true) const [pageLoading, setPageLoading] = useState(true)
useEffect(() => { useEffect(() => {
if (!loading && !user) { router.push('/login'); return } if (!loading && !user) { router.push('/login'); return }
if (user) loadData() if (user) loadData()
}, [user, loading, router]) }, [user, loading, router])
async function loadData() { async function loadData() {
try { try {
const [all, mine] = await Promise.all([ const [all, mine] = await Promise.all([
getAllAchievements(), getAllAchievements(),
getUserAchievements(), getUserAchievements(),
]) ])
setAllAchievements(all) setAllAchievements(all)
setUnlockedIds(new Set(mine.map(u => u.achievementID))) setUnlockedIds(new Set(mine.map(u => u.achievementID)))
} catch { /* ignore */ } } catch { /* ignore */ }
setPageLoading(false) setPageLoading(false)
} }
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>...</div> if (loading || pageLoading) {
if (!user) return null return (
<>
<NavBar title="成就徽章" showBack />
<div className="loading-container">
<div className="loading-spinner" />
<div className="loading-text"></div>
</div>
</>
)
}
if (!user) return null
const grouped = { const grouped = {
gold: allAchievements.filter(a => a.rarity === 'gold'), gold: allAchievements.filter(a => a.rarity === 'gold'),
silver: allAchievements.filter(a => a.rarity === 'silver'), silver: allAchievements.filter(a => a.rarity === 'silver'),
bronze: allAchievements.filter(a => a.rarity === 'bronze'), bronze: allAchievements.filter(a => a.rarity === 'bronze'),
} }
return ( return (
<> <>
<NavBar title="成就徽章" showBack /> <NavBar title="成就徽章" showBack />
{/* 统计 */} <div className="page-container">
<div className="card" style={{ marginTop: 16, display: 'flex', justifyContent: 'space-around', padding: '20px 16px' }}> {/* Stats */}
<div style={{ textAlign: 'center' }}> <div className="card" style={{
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>{unlockedIds.size}</p> marginTop: 14,
<p style={{ fontSize: 13, color: '#86868B' }}></p> display: 'flex', justifyContent: 'space-around',
</div> padding: '20px 16px',
<div style={{ textAlign: 'center' }}> background: 'linear-gradient(135deg, rgba(255, 179, 71, 0.03), rgba(255, 60, 126, 0.02))',
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>{allAchievements.length}</p> }}>
<p style={{ fontSize: 13, color: '#86868B' }}></p> <div style={{ textAlign: 'center' }}>
</div> <p style={{
<div style={{ textAlign: 'center' }}> fontSize: 26, fontWeight: 700, color: 'var(--color-gold)',
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}> fontFamily: 'var(--font-mono)',
{allAchievements.length > 0 ? Math.round((unlockedIds.size / allAchievements.length) * 100) : 0}% }}>
</p> {unlockedIds.size}
<p style={{ fontSize: 13, color: '#86868B' }}></p> </p>
</div> <p style={{
</div> fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 26, fontWeight: 700, color: 'var(--color-text)',
fontFamily: 'var(--font-mono)',
}}>
{allAchievements.length}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 26, fontWeight: 700, color: 'var(--color-primary)',
fontFamily: 'var(--font-mono)',
}}>
{allAchievements.length > 0 ? Math.round((unlockedIds.size / allAchievements.length) * 100) : 0}%
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
</div>
{(['gold', 'silver', 'bronze'] as const).map(rarity => { {(['gold', 'silver', 'bronze'] as const).map(rarity => {
const config = RARITY_CONFIG[rarity] const config = RARITY_CONFIG[rarity]
const items = grouped[rarity] const items = grouped[rarity]
if (items.length === 0) return null if (items.length === 0) return null
return ( return (
<div key={rarity}> <div key={rarity}>
<h3 style={{ fontSize: 15, color: '#86868B', padding: '20px 16px 8px' }}> <div className="section-title-bar" style={{ marginTop: 8 }}>
{config.label} ({items.length}) <span className="section-title-bar-text">
</h3> {config.label.toUpperCase()} ({items.length})
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10, margin: '0 16px' }}> </span>
{items.map(a => { </div>
const unlocked = unlockedIds.has(a.id) <div style={{
return ( display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)',
<div key={a.id} style={{ gap: 10, margin: '0 16px',
borderRadius: 12, padding: 16, textAlign: 'center', }}>
background: unlocked ? config.bg : '#F5F5F7', {items.map(a => {
border: `1.5px solid ${unlocked ? config.color + '44' : '#E5E5EA'}`, const unlocked = unlockedIds.has(a.id)
opacity: unlocked ? 1 : 0.5, return (
transition: 'all 0.3s', <div key={a.id} style={{
}}> borderRadius: 14, padding: 18, textAlign: 'center',
<div style={{ background: unlocked
width: 44, height: 44, borderRadius: 22, margin: '0 auto 8px', ? `linear-gradient(135deg, ${config.color}11, ${config.color}05)`
background: unlocked ? `linear-gradient(135deg, ${config.color}, ${config.color}88)` : '#E5E5EA', : 'var(--color-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center', border: unlocked
fontSize: 20, ? `1px solid ${config.color}44`
}}> : '1px solid var(--color-border)',
{unlocked ? '🏅' : '🔒'} opacity: unlocked ? 1 : 0.5,
</div> transition: 'all 0.3s',
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>{a.name}</p> position: 'relative',
<p style={{ fontSize: 11, color: '#86868B' }}>{unlocked ? a.condition : '???'}</p> overflow: 'hidden',
</div> }}>
) {unlocked && (
})} <div style={{
</div> position: 'absolute', top: 0, right: 0,
</div> padding: '2px 8px',
) fontSize: 8, fontWeight: 700,
})} background: config.color + '33',
color: config.color,
borderRadius: '0 14px 0 8px',
fontFamily: 'var(--font-display)',
letterSpacing: 1,
}}>
{config.label}
</div>
)}
<div style={{
width: 48, height: 48, borderRadius: 24,
margin: '0 auto 10px',
background: unlocked
? `linear-gradient(135deg, ${config.color}, ${config.color}88)`
: 'var(--color-progress-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22,
boxShadow: unlocked ? `0 0 24px ${config.color}44` : 'none',
}}>
{unlocked ? '◈' : '◆'}
</div>
<p style={{
fontSize: 13, fontWeight: 600, marginBottom: 3,
color: unlocked ? 'var(--color-text)' : 'var(--color-text-muted)',
}}>
{a.name}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}>
{unlocked ? a.condition : '???'}
</p>
</div>
)
})}
</div>
</div>
)
})}
</div>
</>
)
}
<div style={{ height: 80 }} /> export default function AchievementsRoute() {
</> return <AuthProvider><AchievementsPage /></AuthProvider>
) }
}
export default function AchievementsRoute() {
return <AuthProvider><AchievementsPage /></AuthProvider>
}
+221 -93
View File
@@ -25,7 +25,7 @@ function DetailPage() {
const [itemData, streakData, recordsData] = await Promise.all([ const [itemData, streakData, recordsData] = await Promise.all([
getItem(id), getItem(id),
getStreak(id), getStreak(id),
getRecords({ itemId: id, start: year + '-' + String(month).padStart(2,'0') + '-01', end: year + '-' + String(month).padStart(2,'0') + '-31' }), getRecords({ itemId: id, start: year + '-' + String(month).padStart(2, '0') + '-01', end: year + '-' + String(month).padStart(2, '0') + '-31' }),
]) ])
setItem(itemData) setItem(itemData)
setStreak(streakData.streak) setStreak(streakData.streak)
@@ -83,110 +83,238 @@ function DetailPage() {
loadData() loadData()
} }
if (pageLoading || !item) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>...</div> if (pageLoading || !item) {
return (
<>
<NavBar title="任务详情" showBack />
<div className="loading-container">
<div className="loading-spinner" />
<div className="loading-text"></div>
</div>
</>
)
}
const cfg = CATEGORY_CONFIG[item.category] || CATEGORY_CONFIG.habit const cfg = CATEGORY_CONFIG[item.category] || CATEGORY_CONFIG.habit
const daysInMonth = getDaysInMonth(year, month) const daysInMonth = getDaysInMonth(year, month)
const firstDay = getFirstDayOfMonth(year, month) const firstDay = getFirstDayOfMonth(year, month)
return ( return (
<> <>
<NavBar title={item.name} showBack /> <NavBar title="任务详情" showBack />
<div style={{ position: 'relative' }}>
<button onClick={() => setShowActions(!showActions)}
style={{ position: 'absolute', right: 16, top: 12, background: 'none', border: 'none', fontSize: 20, cursor: 'pointer' }}>
...
</button>
{showActions && (
<div className="card" style={{ position: 'absolute', right: 16, top: 44, zIndex: 10, minWidth: 140, padding: 0 }}>
{item.status === 'active' ? (
<div className="ios-list-item" onClick={handleTogglePause}><span></span></div>
) : (
<div className="ios-list-item" onClick={handleTogglePause}><span></span></div>
)}
<div className="ios-list-item" onClick={handleDelete} style={{ color: '#FF3B30' }}><span></span></div>
</div>
)}
</div>
<div className="card" style={{ marginTop: 8 }}> <div className="page-container">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> {/* Action menu */}
<div style={{ <div style={{ padding: '0 16px', display: 'flex', justifyContent: 'flex-end', position: 'relative' }}>
width: 52, height: 52, borderRadius: 14, <button onClick={() => setShowActions(!showActions)}
background: cfg.color + '20', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, className="btn-cyber" style={{ padding: '6px 12px', fontSize: 14 }}>
}}>{item.icon || cfg.icon}</div>
<div> </button>
<p style={{ fontSize: 18, fontWeight: 700 }}>{item.name}</p> {showActions && (
<p style={{ fontSize: 13, color: '#86868B', marginTop: 2 }}> <div className="card" style={{
{cfg.label} · {CYCLE_LABEL[item.cycleType]} · {item.status === 'active' ? '进行中' : item.status === 'paused' ? '已暂停' : item.status} position: 'absolute', right: 16, top: 44, zIndex: 10,
</p> minWidth: 150, padding: 0, margin: 0, overflow: 'hidden',
{item.dailyGoal && <p style={{ fontSize: 13, color: '#86868B', marginTop: 2 }}>: {item.dailyGoal}</p>} }}>
</div> <button onClick={() => { handleTogglePause(); setShowActions(false) }}
</div>
</div>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '8px 0' }}>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>{streak}</p>
<p style={{ fontSize: 13, color: '#86868B' }}></p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>{records.length}</p>
<p style={{ fontSize: 13, color: '#86868B' }}></p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>
{daysInMonth > 0 ? Math.round((records.length / daysInMonth) * 100) : 0}%
</p>
<p style={{ fontSize: 13, color: '#86868B' }}></p>
</div>
</div>
</div>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<button onClick={() => { if (month === 1) { setYear(y => y-1); setMonth(12) } else setMonth(m => m-1) }}
style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', padding: 4 }}>&larr;</button>
<span style={{ fontSize: 16, fontWeight: 600 }}>{year} {month} </span>
<button onClick={() => { if (month === 12) { setYear(y => y+1); setMonth(1) } else setMonth(m => m+1) }}
style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', padding: 4 }}>&rarr;</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, textAlign: 'center' }}>
{['日','一','二','三','四','五','六'].map(d => (
<div key={d} style={{ fontSize: 12, color: '#86868B', padding: '6px 0' }}>{d}</div>
))}
{Array.from({ length: firstDay }, (_, i) => <div key={'empty-' + i} />)}
{Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const dateStr = year + '-' + String(month).padStart(2,'0') + '-' + String(day).padStart(2,'0')
const checked = recordDates.has(dateStr)
const isMakeup = makeupDates.has(dateStr)
const isToday = dateStr === todayStr
const canMakeup = !checked && dateStr < todayStr
return (
<div key={day} onClick={() => canMakeup && handleMakeup(dateStr)}
style={{ style={{
padding: '8px 0', fontSize: 14, borderRadius: 8, cursor: canMakeup ? 'pointer' : 'default', display: 'block', width: '100%', padding: '14px 18px',
background: isToday ? '#FFF0EB' : 'transparent', border: 'none', borderBottom: '1px solid var(--color-border)',
fontWeight: isToday ? 600 : 400, cursor: 'pointer', fontSize: 13, textAlign: 'left',
background: 'transparent', color: 'var(--color-text)',
fontFamily: 'var(--font-mono)',
transition: 'background 0.2s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-card-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{item.status === 'active' ? '■ 暂停' : '► 恢复'}
</button>
<button onClick={() => { handleDelete(); setShowActions(false) }}
style={{
display: 'block', width: '100%', padding: '14px 18px',
border: 'none', cursor: 'pointer', fontSize: 13, textAlign: 'left',
background: 'transparent', color: 'var(--color-error)',
fontFamily: 'var(--font-mono)',
transition: 'background 0.2s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-card-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
</button>
</div>
)}
</div>
{/* Item info */}
<div className="card" style={{
marginTop: 8,
background: 'linear-gradient(135deg, rgba(0, 229, 255, 0.03), transparent)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 52, height: 52, borderRadius: 14,
background: `linear-gradient(135deg, ${cfg.color}22, ${cfg.color}11)`,
border: `1px solid ${cfg.color}33`,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26,
}}>
{item.icon || cfg.icon}
</div>
<div>
<p style={{
fontSize: 17, fontWeight: 700, color: 'var(--color-text)',
}}>
{item.name}
</p>
<p style={{
fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-mono)',
}}>
<span style={{ color: cfg.color }}></span> {cfg.label} / {CYCLE_LABEL[item.cycleType]}
{' · '}
{item.status === 'active' ? '进行中' : item.status === 'paused' ? '已暂停' : item.status}
</p>
{item.dailyGoal && (
<p style={{
fontSize: 12, color: 'var(--color-text-secondary)', marginTop: 2,
fontFamily: 'var(--font-mono)',
}}> }}>
{day} : {item.dailyGoal}
{checked && <div style={{ </p>
width: 6, height: 6, borderRadius: 3, margin: '2px auto 0', )}
background: isMakeup ? '#FF9500' : '#34C759', </div>
}} />} </div>
{!checked && isToday && <div style={{ </div>
width: 6, height: 6, borderRadius: 3, margin: '2px auto 0',
background: '#E5E5EA', {/* Stats */}
}} />} <div className="card" style={{ padding: '16px 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '4px 0' }}>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 26, fontWeight: 700, color: 'var(--color-gold)',
fontFamily: 'var(--font-mono)',
}}>
{streak}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 26, fontWeight: 700, color: 'var(--color-primary)',
fontFamily: 'var(--font-mono)',
}}>
{records.length}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 26, fontWeight: 700, color: 'var(--color-accent)',
fontFamily: 'var(--font-mono)',
}}>
{daysInMonth > 0 ? Math.round((records.length / daysInMonth) * 100) : 0}%
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
</div>
</div>
{/* Calendar */}
<div className="card" style={{ padding: '16px 18px' }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16,
}}>
<button onClick={() => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else setMonth(m => m - 1) }}
className="btn-cyber" style={{ padding: '6px 10px', fontSize: 12 }}>
</button>
<span style={{
fontSize: 14, fontWeight: 600, color: 'var(--color-text)',
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
{year}.{String(month).padStart(2, '0')}
</span>
<button onClick={() => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else setMonth(m => m + 1) }}
className="btn-cyber" style={{ padding: '6px 10px', fontSize: 12 }}>
</button>
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)',
gap: 3, textAlign: 'center',
}}>
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => (
<div key={d} style={{
fontSize: 10, color: 'var(--color-text-muted)',
padding: '6px 0', fontFamily: 'var(--font-display)',
}}>
{d}
</div> </div>
) ))}
})} {Array.from({ length: firstDay }, (_, i) => <div key={'empty-' + i} />)}
{Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1
const dateStr = year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0')
const checked = recordDates.has(dateStr)
const isMakeup = makeupDates.has(dateStr)
const isToday = dateStr === todayStr
const canMakeup = !checked && dateStr < todayStr
return (
<div key={day} onClick={() => canMakeup && handleMakeup(dateStr)}
style={{
padding: '7px 0', fontSize: 13, borderRadius: 8,
cursor: canMakeup ? 'pointer' : 'default',
background: isToday ? 'var(--color-primary-dim)' : 'transparent',
fontWeight: isToday ? 600 : 400,
color: isToday ? 'var(--color-primary)' : 'var(--color-text)',
border: isToday ? '1px solid rgba(0, 229, 255, 0.2)' : '1px solid transparent',
position: 'relative',
fontFamily: 'var(--font-mono)',
transition: 'background 0.2s',
}}
onMouseEnter={e => { if (canMakeup) e.currentTarget.style.background = 'rgba(255, 179, 71, 0.1)' }}
onMouseLeave={e => { if (canMakeup) e.currentTarget.style.background = isToday ? 'var(--color-primary-dim)' : 'transparent' }}
>
{day}
{checked && (
<div style={{
position: 'absolute', bottom: 3, left: '50%',
transform: 'translateX(-50%)',
width: 5, height: 5, borderRadius: '50%',
background: isMakeup ? 'var(--color-warning)' : 'var(--color-accent)',
boxShadow: isMakeup ? '0 0 6px rgba(255, 179, 71, 0.4)' : '0 0 6px rgba(0, 230, 118, 0.4)',
}} />
)}
</div>
)
})}
</div>
</div>
<div style={{ padding: '0 16px', marginTop: 8 }}>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', textAlign: 'center',
fontFamily: 'var(--font-mono)',
}}>
</p>
</div> </div>
</div> </div>
<div style={{ height: 80 }} />
</> </>
) )
} }
+104 -61
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { AuthProvider, useAuth } from '@/components/AuthProvider' import { AuthProvider, useAuth } from '@/components/AuthProvider'
import NavBar from '@/components/NavBar' import NavBar from '@/components/NavBar'
import { createItem, CATEGORY_CONFIG, type CheckInCategory, type CycleType } from '@/services/checkin' import { createItem, CATEGORY_CONFIG, type CheckInCategory, type CycleType } from '@/services/checkin'
@@ -11,7 +11,7 @@ const CYCLE_OPTIONS: { value: CycleType; label: string }[] = [
{ value: 'daily', label: '每日' }, { value: 'daily', label: '每日' },
{ value: 'weekly', label: '每周' }, { value: 'weekly', label: '每周' },
{ value: 'every_other_day', label: '隔日' }, { value: 'every_other_day', label: '隔日' },
{ value: 'custom', label: '自定义间隔' }, { value: 'custom', label: '自定义' },
] ]
function CreateCheckInPage() { function CreateCheckInPage() {
@@ -51,70 +51,113 @@ function CreateCheckInPage() {
return ( return (
<> <>
<NavBar title="创建打卡项" showBack /> <NavBar title="创建任务" showBack />
<form onSubmit={handleSubmit} style={{ padding: 16 }}> <div className="page-container">
{/* 名称 */} <form onSubmit={handleSubmit} style={{ padding: 16 }}>
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}> *</label> {/* Name */}
<input className="input-field" value={name} onChange={e => setName(e.target.value)} <label style={{
placeholder="如「每天背单词」" style={{ marginBottom: 20 }} maxLength={50} /> fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6, display: 'block',
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
*
</label>
<input className="input-cyber" value={name} onChange={e => setName(e.target.value)}
placeholder="如「每天背单词」" style={{ marginBottom: 20 }} maxLength={50} />
{/* 分类 */} {/* Category */}
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 8, display: 'block' }}></label> <label style={{
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, marginBottom: 20 }}> fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block',
{CATEGORIES.map(([key, cfg]) => ( fontFamily: 'var(--font-display)', letterSpacing: 1,
<button type="button" key={key} }}>
onClick={() => setCategory(key)}
style={{ </label>
padding: '12px 8px', borderRadius: 10, border: '2px solid', <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, marginBottom: 20 }}>
borderColor: category === key ? cfg.color : '#E5E5EA', {CATEGORIES.map(([key, cfg]) => (
background: category === key ? cfg.color + '15' : '#f8f8fa', <button type="button" key={key}
cursor: 'pointer', fontSize: 13, textAlign: 'center', transition: 'all 0.2s', onClick={() => setCategory(key)}
}} style={{
> padding: '14px 8px', borderRadius: 12, border: '1px solid',
<span style={{ fontSize: 20 }}>{cfg.icon}</span> borderColor: category === key ? cfg.color + '66' : 'var(--color-border)',
<p style={{ marginTop: 4, fontWeight: category === key ? 600 : 400 }}>{cfg.label}</p> background: category === key ? `${cfg.color}15` : 'transparent',
</button> cursor: 'pointer', fontSize: 12, textAlign: 'center',
))} transition: 'all 0.2s',
</div> color: category === key ? cfg.color : 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}
>
<span style={{ fontSize: 22 }}>{cfg.icon}</span>
<p style={{ marginTop: 4, fontWeight: category === key ? 600 : 400 }}>
{cfg.label}
</p>
</button>
))}
</div>
{/* 打卡周期 */} {/* Cycle */}
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}></label> <label style={{
<div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap' }}> fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6, display: 'block',
{CYCLE_OPTIONS.map(opt => ( fontFamily: 'var(--font-display)', letterSpacing: 1,
<button type="button" key={opt.value} }}>
onClick={() => setCycleType(opt.value)}
style={{ </label>
padding: '8px 16px', borderRadius: 8, border: '1.5px solid', <div style={{
borderColor: cycleType === opt.value ? '#FF6B35' : '#E5E5EA', display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap',
background: cycleType === opt.value ? '#FFF0EB' : '#f8f8fa', }}>
cursor: 'pointer', fontSize: 14, transition: 'all 0.2s', {CYCLE_OPTIONS.map(opt => (
}} <button type="button" key={opt.value}
> onClick={() => setCycleType(opt.value)}
{opt.label} className="btn-cyber"
</button> style={{
))} padding: '8px 16px', fontSize: 11, letterSpacing: 1,
</div> borderColor: cycleType === opt.value ? 'var(--color-primary)' : 'var(--color-border)',
color: cycleType === opt.value ? 'var(--color-primary)' : 'var(--color-text-muted)',
background: cycleType === opt.value ? 'rgba(0, 229, 255, 0.1)' : 'transparent',
}}
>
{opt.label}
</button>
))}
</div>
{/* 每日目标 */} {/* Daily Goal */}
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}></label> <label style={{
<input className="input-field" value={dailyGoal} onChange={e => setDailyGoal(e.target.value)} fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6, display: 'block',
placeholder="如「背 50 个单词」" style={{ marginBottom: 20 }} maxLength={200} /> fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</label>
<input className="input-cyber" value={dailyGoal} onChange={e => setDailyGoal(e.target.value)}
placeholder="如「背 50 个单词」" style={{ marginBottom: 20 }} maxLength={200} />
{/* 隐私 */} {/* Privacy */}
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}></label> <label style={{
<select className="input-field" value={privacy} onChange={e => setPrivacy(Number(e.target.value))} fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6, display: 'block',
style={{ marginBottom: 24 }}> fontFamily: 'var(--font-display)', letterSpacing: 1,
<option value={1}></option> }}>
<option value={2}></option>
<option value={3}></option> </label>
</select> <select className="input-cyber" value={privacy} onChange={e => setPrivacy(Number(e.target.value))}
style={{ marginBottom: 24, cursor: 'pointer', appearance: 'auto' }}>
<option value={1}></option>
<option value={2}></option>
<option value={3}></option>
</select>
{error && <p style={{ fontSize: 13, color: '#FF3B30', marginBottom: 12, textAlign: 'center' }}>{error}</p>} {error && (
<p style={{
fontSize: 12, color: 'var(--color-error)', marginBottom: 12, textAlign: 'center',
fontFamily: 'var(--font-mono)',
}}>
ERROR: {error}
</p>
)}
<button className="btn-primary" type="submit" disabled={submitting}> <button className="btn-cyber btn-cyber--primary" type="submit" disabled={submitting}
{submitting ? '创建中...' : '创建打卡项'} style={{ width: '100%', padding: 14, fontSize: 14, letterSpacing: 2 }}>
</button> {submitting ? '创建中...' : '创建任务'}
</form> </button>
</form>
</div>
</> </>
) )
} }
+573 -115
View File
@@ -1,128 +1,586 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
:root { :root {
--color-primary: #FF6B35; --color-bg: #080C18;
--color-primary-light: #FF8C5A; --color-bg-alt: #0C1124;
--color-primary-gradient: linear-gradient(135deg, #FF6B35, #FF4D6D); --color-card: #0F1529;
--color-accent: #7209B7; --color-card-hover: #161E3A;
--color-bg: #F5F5F7; --color-border: #1A2245;
--color-card: #FFFFFF; --color-border-glow: rgba(0, 229, 255, 0.12);
--color-text: #1D1D1F; --color-primary: #00E5FF;
--color-text-secondary: #86868B; --color-primary-dim: rgba(0, 229, 255, 0.08);
--color-border: #E5E5EA; --color-primary-glow: rgba(0, 229, 255, 0.3);
--color-nav-bg: rgba(245,245,247,0.92); --color-secondary: #3B82F6;
--color-success: #34C759; --color-accent: #00E676;
--color-warning: #FF9500; --color-gold: #FFB347;
--color-error: #FF3B30; --color-social: #FF3C7E;
--radius-card: 12px; --color-text: #E0E8FF;
--radius-button: 10px; --color-text-secondary: #6B7BA3;
--font-base: -apple-system, BlinkMacSystemFont, 'SF Pro', 'PingFang SC', sans-serif; --color-text-muted: #3D4F7A;
--color-error: #FF1744;
--color-success: #00E676;
--color-warning: #FFB347;
--color-progress-bg: rgba(26, 34, 69, 0.6);
--font-display: 'Orbitron', monospace;
--font-body: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow-glow-cyan: 0 0 20px rgba(0, 229, 255, 0.15);
--shadow-glow-green: 0 0 20px rgba(0, 230, 118, 0.2);
--shadow-glow-gold: 0 0 20px rgba(255, 179, 71, 0.15);
}
[data-theme="light"] {
--color-bg: #E8ECF4;
--color-bg-alt: #DEE2EC;
--color-card: #FFFFFF;
--color-card-hover: #F0F4FF;
--color-border: #D0D7E6;
--color-border-glow: rgba(59, 130, 246, 0.12);
--color-primary: #2563EB;
--color-primary-dim: rgba(37, 99, 235, 0.06);
--color-primary-glow: rgba(37, 99, 235, 0.15);
--color-secondary: #3B82F6;
--color-accent: #059669;
--color-gold: #D97706;
--color-social: #DB2777;
--color-text: #1A1F36;
--color-text-secondary: #5B6B8F;
--color-text-muted: #95A5C9;
--color-error: #DC2626;
--color-success: #059669;
--color-warning: #D97706;
--color-progress-bg: #E5E9F0;
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.06);
--shadow-glow-cyan: 0 0 12px rgba(37, 99, 235, 0.1);
--shadow-glow-green: 0 0 12px rgba(5, 150, 103, 0.1);
--shadow-glow-gold: 0 0 12px rgba(217, 119, 6, 0.1);
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
min-height: 100vh;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 229, 255, 0.015) 2px,
rgba(0, 229, 255, 0.015) 4px
);
}
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
linear-gradient(rgba(0, 229, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 229, 255, 0.03) 1px, transparent 1px);
background-size: 60px 60px;
}
#__next, main {
position: relative;
z-index: 1;
}
::selection {
background: rgba(0, 229, 255, 0.25);
color: #fff;
}
[data-theme="light"] ::selection {
background: rgba(37, 99, 235, 0.2);
color: var(--color-text);
}
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-text-muted);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
} }
to {
[data-theme="dark"] { opacity: 1;
--color-bg: #1C1C1E; transform: translateY(0);
--color-card: #2C2C2E;
--color-text: #F5F5F7;
--color-text-secondary: #98989D;
--color-border: #38383A;
--color-nav-bg: rgba(28,28,30,0.92);
--color-accent: #BB86FC;
--color-error: #FF453A;
} }
}
body { @keyframes glowPulse {
font-family: var(--font-base); 0%, 100% {
background: var(--color-bg); box-shadow: 0 0 8px rgba(0, 229, 255, 0.2);
color: var(--color-text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background 0.3s, color 0.3s;
} }
50% {
box-shadow: 0 0 20px rgba(0, 229, 255, 0.4), 0 0 40px rgba(0, 229, 255, 0.1);
}
}
/* iOS 风格列表 */ @keyframes glowPulseGreen {
.ios-list { 0%, 100% {
background: var(--color-card); box-shadow: 0 0 8px rgba(0, 230, 118, 0.2);
border-radius: var(--radius-card); }
overflow: hidden; 50% {
margin: 16px; box-shadow: 0 0 20px rgba(0, 230, 118, 0.4), 0 0 40px rgba(0, 230, 118, 0.1);
box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
} }
.ios-list-item { @keyframes shimmer {
display: flex; 0% {
align-items: center; background-position: -200% center;
justify-content: space-between; }
padding: 14px 16px; 100% {
border-bottom: 0.5px solid var(--color-border); background-position: 200% center;
cursor: pointer; }
transition: background 0.2s; }
}
.ios-list-item:last-child { border-bottom: none; }
.ios-list-item:hover { background: #f8f8fa; }
.ios-list-item-label { @keyframes float {
font-size: 15px; 0%, 100% {
color: var(--color-text); transform: translateY(0) scale(1);
} }
.ios-list-item-value { 50% {
font-size: 15px; transform: translateY(-20px) scale(1.05);
color: var(--color-text-secondary); }
} }
/* 按钮 */ @keyframes scanLine {
.btn-primary { 0% {
display: block; top: -10%;
width: 100%; }
padding: 14px; 100% {
background: var(--color-primary-gradient); top: 110%;
color: white; }
border: none; }
border-radius: var(--radius-button);
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:active { opacity: 0.8; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
/* 输入框 */ @keyframes borderRotate {
.input-field { 0% {
width: 100%; transform: rotate(0deg);
padding: 14px 16px; }
background: #f8f8fa; 100% {
border: 1px solid var(--color-border); transform: rotate(360deg);
border-radius: var(--radius-button); }
font-size: 17px; }
font-family: var(--font-base);
outline: none;
transition: border-color 0.2s;
}
.input-field:focus {
border-color: var(--color-primary);
}
/* 页面标题 */ @keyframes dataFlicker {
.page-title { 0%, 100% {
font-size: 28px; opacity: 1;
font-weight: 600; }
padding: 20px 16px 8px; 50% {
} opacity: 0.85;
.page-subtitle { }
font-size: 15px; }
color: var(--color-text-secondary);
padding: 0 16px 20px;
}
/* 卡片 */ @keyframes orbitPulse {
.card { 0%, 100% {
background: var(--color-card); opacity: 0.5;
border-radius: var(--radius-card); transform: scale(1);
margin: 8px 16px; }
padding: 16px; 50% {
box-shadow: 0 1px 3px rgba(0,0,0,0.04); opacity: 1;
} transform: scale(1.1);
}
}
/* ===== Page Layout ===== */
.page-container {
padding-bottom: 100px;
animation: fadeUp 0.5s ease-out;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 16px 12px;
}
.section-title {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.section-title-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 0 16px;
}
.section-title-bar::before {
content: '';
width: 3px;
height: 14px;
background: var(--color-primary);
border-radius: 2px;
box-shadow: 0 0 8px var(--color-primary-glow);
}
.section-title-bar-text {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 1px;
color: var(--color-primary);
}
/* ===== Cards ===== */
.card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin: 8px 16px;
padding: 16px;
box-shadow: var(--shadow-card);
transition: border-color 0.3s, box-shadow 0.3s, background 0.3s, transform 0.2s;
position: relative;
overflow: hidden;
word-break: break-word;
overflow-wrap: break-word;
}
.card::before {
content: '';
position: absolute;
inset: -1px;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(
135deg,
var(--color-border-glow),
transparent 40%,
transparent 60%,
var(--color-border-glow)
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.6;
transition: opacity 0.3s;
}
.card:hover {
border-color: var(--color-primary-dim);
}
.card:hover::before {
opacity: 1;
}
.card-glow {
box-shadow: var(--shadow-card), var(--shadow-glow-cyan);
}
/* ===== Ambient Orbs ===== */
.ambient-orb {
position: fixed;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
z-index: 0;
opacity: 0.15;
}
.ambient-orb--cyan {
background: #00E5FF;
width: 400px;
height: 400px;
top: -100px;
right: -100px;
animation: float 8s ease-in-out infinite;
}
.ambient-orb--blue {
background: #3B82F6;
width: 300px;
height: 300px;
bottom: 10%;
left: -80px;
animation: float 10s ease-in-out infinite 2s;
}
.ambient-orb--pink {
background: #FF3C7E;
width: 250px;
height: 250px;
bottom: 20%;
right: -50px;
animation: float 12s ease-in-out infinite 1s;
}
[data-theme="light"] .ambient-orb--cyan {
opacity: 0.06;
background: #3B82F6;
}
[data-theme="light"] .ambient-orb--blue {
opacity: 0.05;
}
[data-theme="light"] .ambient-orb--pink {
opacity: 0.04;
}
/* ===== Buttons ===== */
.btn-cyber {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
background: linear-gradient(135deg, rgba(0, 229, 255, 0.1), rgba(59, 130, 246, 0.1));
border: 1px solid rgba(0, 229, 255, 0.25);
border-radius: var(--radius-md);
color: var(--color-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
letter-spacing: 0.5px;
}
.btn-cyber:hover {
background: linear-gradient(135deg, rgba(0, 229, 255, 0.2), rgba(59, 130, 246, 0.2));
border-color: var(--color-primary);
box-shadow: 0 0 20px rgba(0, 229, 255, 0.15);
transform: translateY(-1px);
}
.btn-cyber:active {
transform: translateY(0);
}
.btn-cyber--primary {
background: linear-gradient(135deg, #00E5FF, #3B82F6);
border-color: transparent;
color: #080C18;
font-weight: 700;
}
.btn-cyber--primary:hover {
background: linear-gradient(135deg, #33EAFF, #5B9BF7);
box-shadow: 0 0 30px rgba(0, 229, 255, 0.3);
}
.btn-cyber--primary:active {
transform: translateY(0);
box-shadow: 0 0 15px rgba(0, 229, 255, 0.2);
}
.btn-cyber--danger {
border-color: rgba(255, 23, 68, 0.3);
color: var(--color-error);
background: rgba(255, 23, 68, 0.08);
}
.btn-cyber--danger:hover {
background: rgba(255, 23, 68, 0.15);
border-color: var(--color-error);
box-shadow: 0 0 20px rgba(255, 23, 68, 0.15);
}
.btn-cyber:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* ===== Inputs ===== */
.input-cyber {
width: 100%;
padding: 14px 16px;
background: rgba(15, 21, 41, 0.6);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 16px;
font-family: var(--font-body);
color: var(--color-text);
outline: none;
transition: border-color 0.3s, box-shadow 0.3s;
}
.input-cyber:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(0, 229, 255, 0.08);
}
.input-cyber::placeholder {
color: var(--color-text-muted);
}
[data-theme="light"] .input-cyber {
background: rgba(255, 255, 255, 0.8);
}
/* ===== Typography ===== */
.text-display {
font-family: var(--font-display);
letter-spacing: 1px;
}
.text-mono {
font-family: var(--font-mono);
}
.text-gradient {
background: linear-gradient(135deg, #00E5FF, #3B82F6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-gold {
background: linear-gradient(135deg, #FFB347, #FF8C00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-green {
background: linear-gradient(135deg, #00E676, #00C853);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-pink {
background: linear-gradient(135deg, #FF3C7E, #FF1744);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===== Loading ===== */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 60px 20px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--color-text-muted);
animation: dataFlicker 2s ease-in-out infinite;
}
/* ===== Badge / Tag ===== */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
}
.badge--cyan {
background: rgba(0, 229, 255, 0.1);
color: var(--color-primary);
border: 1px solid rgba(0, 229, 255, 0.2);
}
.badge--green {
background: rgba(0, 230, 118, 0.1);
color: var(--color-accent);
border: 1px solid rgba(0, 230, 118, 0.2);
}
.badge--gold {
background: rgba(255, 179, 71, 0.1);
color: var(--color-gold);
border: 1px solid rgba(255, 179, 71, 0.2);
}
/* ===== iOS compatibility ===== */
.ios-safe-bottom {
padding-bottom: 20px;
}
/* Page title (legacy compat) */
.page-title {
font-size: 28px;
font-weight: 700;
padding: 20px 16px 4px;
color: var(--color-text);
}
.page-subtitle {
font-size: 14px;
color: var(--color-text-secondary);
padding: 0 16px 20px;
}
+18 -1
View File
@@ -1,7 +1,20 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Orbitron, JetBrains_Mono } from 'next/font/google'
import './globals.css' import './globals.css'
import ThemeProvider from '@/components/ThemeProvider' import ThemeProvider from '@/components/ThemeProvider'
const orbitron = Orbitron({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
})
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
})
export const metadata: Metadata = { export const metadata: Metadata = {
title: '寸进 — 日日皆有成长', title: '寸进 — 日日皆有成长',
description: '寸进(InchStep)综合成长打卡平台', description: '寸进(InchStep)综合成长打卡平台',
@@ -9,8 +22,12 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="zh-CN" suppressHydrationWarning> <html lang="zh-CN" suppressHydrationWarning
className={`${orbitron.variable} ${jetbrainsMono.variable}`}>
<body> <body>
<div className="ambient-orb ambient-orb--cyan" />
<div className="ambient-orb ambient-orb--blue" />
<div className="ambient-orb ambient-orb--pink" />
<ThemeProvider>{children}</ThemeProvider> <ThemeProvider>{children}</ThemeProvider>
</body> </body>
</html> </html>
+117 -69
View File
@@ -1,79 +1,127 @@
'use client' 'use client'
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { AuthProvider, useAuth } from '@/components/AuthProvider' import { AuthProvider, useAuth } from '@/components/AuthProvider'
function LoginForm() { function LoginForm() {
const [phone, setPhone] = useState('') const [phone, setPhone] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const { login } = useAuth() const { login } = useAuth()
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
if (phone.length !== 11) { if (phone.length !== 11) {
setError('请输入正确的 11 位手机号') setError('请输入正确的 11 位手机号')
return return
} }
setIsLoading(true) setIsLoading(true)
setError('') setError('')
try { try {
await login(phone) await login(phone)
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : '登录失败' const msg = err instanceof Error ? err.message : '登录失败'
setError(msg) setError(msg)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: 32 }}> <div style={{
{/* Logo */} minHeight: '100vh',
<div style={{ textAlign: 'center', marginBottom: 48 }}> display: 'flex',
<div style={{ flexDirection: 'column',
width: 80, height: 80, borderRadius: 20, justifyContent: 'center',
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', padding: 32,
display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative',
margin: '0 auto 16px', fontSize: 36 zIndex: 1,
}}> }}>
📈 {/* Logo */}
</div> <div style={{ textAlign: 'center', marginBottom: 48 }}>
<h1 style={{ fontSize: 28, fontWeight: 700, marginBottom: 4 }}></h1> <div style={{
<p style={{ fontSize: 15, color: '#86868B' }}></p> width: 80, height: 80, borderRadius: 20,
</div> background: 'linear-gradient(135deg, #00E5FF, #3B82F6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 16px', fontSize: 32,
boxShadow: '0 0 40px rgba(0, 229, 255, 0.2)',
position: 'relative',
}}>
<div style={{
position: 'absolute', inset: -3,
borderRadius: 23,
background: 'linear-gradient(135deg, rgba(0, 229, 255, 0.3), rgba(59, 130, 246, 0.1))',
zIndex: -1,
}} />
</div>
<h1 style={{
fontSize: 28, fontWeight: 700, marginBottom: 4,
background: 'linear-gradient(135deg, #00E5FF, #3B82F6)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontFamily: 'var(--font-display)',
letterSpacing: 4,
}}>
</h1>
<p style={{
fontSize: 13, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-display)',
letterSpacing: 2,
}}>
·
</p>
</div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<input <div style={{ position: 'relative' }}>
className="input-field" <input
type="tel" className="input-cyber"
placeholder="手机号" type="tel"
maxLength={11} placeholder="手机号"
value={phone} maxLength={11}
onChange={e => { setPhone(e.target.value.replace(/\D/g, '')); setError('') }} value={phone}
style={{ marginBottom: 12 }} onChange={e => { setPhone(e.target.value.replace(/\D/g, '')); setError('') }}
/> style={{ marginBottom: 12 }}
/>
</div>
{error && ( {error && (
<p style={{ fontSize: 13, color: '#FF3B30', marginBottom: 12, textAlign: 'center' }}>{error}</p> <p style={{
)} fontSize: 12, color: 'var(--color-error)',
marginBottom: 12, textAlign: 'center',
fontFamily: 'var(--font-mono)',
}}>
ERROR: {error}
</p>
)}
<button className="btn-primary" type="submit" disabled={isLoading}> <button
{isLoading ? '登录中...' : '登录 / 注册'} className="btn-cyber btn-cyber--primary"
</button> type="submit"
disabled={isLoading}
style={{ width: '100%', padding: 14, fontSize: 15, letterSpacing: 2 }}
>
{isLoading ? '连接中...' : '登录 / 注册'}
</button>
<p style={{ fontSize: 13, color: '#86868B', textAlign: 'center', marginTop: 16 }}> <p style={{
fontSize: 11, color: 'var(--color-text-muted)',
</p> textAlign: 'center', marginTop: 16,
</form> fontFamily: 'var(--font-mono)',
</div> }}>
) // 未注册的手机号将自动创建账号
} </p>
</form>
</div>
)
}
export default function LoginPage() { export default function LoginPage() {
return ( return (
<AuthProvider> <AuthProvider>
<LoginForm /> <LoginForm />
</AuthProvider> </AuthProvider>
) )
} }
+138 -53
View File
@@ -43,70 +43,155 @@ function Dashboard() {
const activeItems = items.filter(i => i.status === 'active') const activeItems = items.filter(i => i.status === 'active')
const pausedItems = items.filter(i => i.status === 'paused') const pausedItems = items.filter(i => i.status === 'paused')
const doneCount = activeItems.filter(i => todayStatus[i.id]).length
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>...</div> if (loading || pageLoading) {
return (
<>
<NavBar title="寸进" />
<div className="loading-container">
<div className="loading-spinner" />
<div className="loading-text"></div>
</div>
<TabBar />
</>
)
}
if (!user) return null if (!user) return null
return ( return (
<> <>
<NavBar title="寸进" /> <NavBar title="寸进" />
<div className="card" style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 12 }}> <div className="page-container">
<div style={{ {/* Agent Status Card */}
width: 44, height: 44, borderRadius: 22, <div className="card" style={{
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', marginTop: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex',
fontSize: 18, color: 'white', fontWeight: 600, flexShrink: 0, alignItems: 'center',
gap: 14,
padding: '18px 20px',
background: 'linear-gradient(135deg, rgba(0, 229, 255, 0.04), rgba(59, 130, 246, 0.02))',
border: '1px solid rgba(0, 229, 255, 0.12)',
}}> }}>
{user.nickname?.[0] || user.phone?.slice(-4) || '?'} <div style={{
</div> width: 48, height: 48, borderRadius: 14,
<div style={{ flex: 1 }}> background: 'linear-gradient(135deg, #00E5FF22, #3B82F611)',
<p style={{ fontSize: 16, fontWeight: 600 }}>{user.nickname || '用户'}</p> border: '1px solid rgba(0, 229, 255, 0.2)',
<p style={{ fontSize: 13, color: '#86868B' }}> {user.points} · {activeItems.length} </p> display: 'flex', alignItems: 'center', justifyContent: 'center',
</div> fontSize: 20, color: 'var(--color-primary)', fontWeight: 700, flexShrink: 0,
<button onClick={() => router.push('/profile')}
style={{ background: 'none', border: 'none', fontSize: 14, color: '#FF6B35', cursor: 'pointer' }}>
</button>
</div>
<div style={{ padding: '20px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: 20, fontWeight: 700 }}></h2>
<button onClick={() => router.push('/checkin/create')}
style={{
padding: '6px 14px', borderRadius: 8, border: '1.5px solid #FF6B35',
background: 'white', color: '#FF6B35', fontSize: 14, fontWeight: 600, cursor: 'pointer',
}}> }}>
+ {user.nickname?.[0] || user.phone?.slice(-4) || '?'}
</button> </div>
<div style={{ flex: 1 }}>
<p style={{
fontSize: 16, fontWeight: 600, color: 'var(--color-text)',
letterSpacing: 0.5,
}}>
{user.nickname || '用户'}
</p>
<p style={{
fontSize: 12, color: 'var(--color-text-secondary)',
fontFamily: 'var(--font-mono)',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ color: 'var(--color-gold)' }}></span>
<span>{user.points} </span>
<span style={{ color: 'var(--color-text-muted)' }}>|</span>
<span style={{ color: 'var(--color-primary)' }}> {activeItems.length}</span>
<span></span>
{doneCount > 0 && (
<>
<span style={{ color: 'var(--color-text-muted)' }}>|</span>
<span style={{ color: 'var(--color-accent)' }}> {doneCount}/{activeItems.length}</span>
<span></span>
</>
)}
</p>
</div>
<button
onClick={() => router.push('/profile')}
className="btn-cyber"
style={{ padding: '8px 14px', fontSize: 12, flexShrink: 0 }}
>
</button>
</div>
{/* Today's Missions */}
<div className="section-title-bar" style={{ marginTop: 8 }}>
<span className="section-title-bar-text"></span>
</div>
{activeItems.length === 0 ? (
<div className="card" style={{
textAlign: 'center',
padding: '48px 20px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12,
}}>
<div style={{
width: 56, height: 56, borderRadius: 16,
background: 'rgba(0, 229, 255, 0.08)',
border: '1px solid rgba(0, 229, 255, 0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24,
}}>
</div>
<p style={{ fontSize: 15, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
</p>
<p style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
</p>
<button
onClick={() => router.push('/checkin/create')}
className="btn-cyber btn-cyber--primary"
style={{ marginTop: 8 }}
>
+
</button>
</div>
) : (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 16px 4px' }}>
<button
onClick={() => router.push('/checkin/create')}
className="btn-cyber"
style={{ padding: '6px 14px', fontSize: 11, letterSpacing: 1 }}
>
+
</button>
</div>
{activeItems.map((item, i) => (
<div key={item.id} style={{
animation: 'fadeUp 0.5s ease-out',
animationDelay: `${i * 0.08}s`,
animationFillMode: 'both',
}}>
<CheckInCard key={item.id} item={item} streak={streaks[item.id]}
isDone={todayStatus[item.id]}
onCheckIn={(id) => setTodayStatus(s => ({ ...s, [id]: true }))} />
</div>
))}
</>
)}
{/* Paused Items */}
{pausedItems.length > 0 && (
<>
<div className="section-title-bar" style={{ marginTop: 16 }}>
<span className="section-title-bar-text" style={{ color: 'var(--color-text-muted)' }}>
({pausedItems.length})
</span>
</div>
{pausedItems.map(item => (
<CheckInCard key={item.id} item={item} />
))}
</>
)}
</div> </div>
{activeItems.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}>
<p style={{ fontSize: 40, marginBottom: 12 }}>📋</p>
<p style={{ fontSize: 15, color: '#86868B' }}>
<br />
<span style={{ fontSize: 13 }}>"创建"</span>
</p>
</div>
) : (
activeItems.map(item => (
<CheckInCard key={item.id} item={item} streak={streaks[item.id]}
isDone={todayStatus[item.id]}
onCheckIn={(id) => setTodayStatus(s => ({ ...s, [id]: true }))} />
))
)}
{pausedItems.length > 0 && (
<>
<h3 style={{ fontSize: 15, color: '#86868B', padding: '20px 16px 4px' }}> ({pausedItems.length})</h3>
{pausedItems.map(item => (
<CheckInCard key={item.id} item={item} />
))}
</>
)}
<div style={{ height: 100 }} />
<TabBar /> <TabBar />
</> </>
) )
+172 -70
View File
@@ -42,88 +42,190 @@ function ProfilePage() {
setEditing(true) setEditing(true)
} }
if (loading || !profile) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>...</div> if (loading || !profile) {
return (
<>
<NavBar title="个人主页" />
<div className="loading-container">
<div className="loading-spinner" />
<div className="loading-text"></div>
</div>
</>
)
}
return ( return (
<> <>
<NavBar title="个人主页" showBack /> <NavBar title="个人主页" />
{/* 头像与基本信息 */} <div className="page-container">
<div className="card" style={{ marginTop: 16, textAlign: 'center', padding: '32px 16px' }}> {/* Avatar & Bio */}
<div style={{ <div className="card" style={{
width: 72, height: 72, borderRadius: 36, marginTop: 14,
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', textAlign: 'center',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '36px 20px 28px',
margin: '0 auto 12px', fontSize: 28, color: 'white', fontWeight: 600 background: 'linear-gradient(135deg, rgba(0, 229, 255, 0.03), rgba(59, 130, 246, 0.02))',
}}> }}>
{profile.nickname?.[0] || profile.phone?.slice(-4) || '?'} <div style={{
width: 72, height: 72, borderRadius: 18,
background: 'linear-gradient(135deg, #00E5FF22, #3B82F611)',
border: '2px solid rgba(0, 229, 255, 0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 14px', fontSize: 28, color: 'var(--color-primary)', fontWeight: 600,
boxShadow: '0 0 30px rgba(0, 229, 255, 0.1)',
}}>
{profile.nickname?.[0] || profile.phone?.slice(-4) || '?'}
</div>
<h2 style={{
fontSize: 20, fontWeight: 700, color: 'var(--color-text)',
letterSpacing: 1,
}}>
{profile.nickname || '未设置昵称'}
</h2>
<p style={{
fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6,
fontFamily: 'var(--font-mono)',
}}>
{profile.bio || '没有个性签名'}
</p>
</div> </div>
<h2 style={{ fontSize: 22, fontWeight: 700 }}>{profile.nickname || '未设置昵称'}</h2>
<p style={{ fontSize: 14, color: '#86868B', marginTop: 4 }}>{profile.bio || '还没有个性签名'}</p>
</div>
{/* 统计数据 */} {/* Stats */}
<div className="card"> <div className="card" style={{ padding: '16px 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '8px 0' }}> <div style={{ display: 'flex', justifyContent: 'space-around', padding: '4px 0' }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>{profile.points}</p> <p style={{
<p style={{ fontSize: 13, color: '#86868B' }}></p> fontSize: 22, fontWeight: 700, color: 'var(--color-gold)',
</div> fontFamily: 'var(--font-mono)',
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>--</p>
<p style={{ fontSize: 13, color: '#86868B' }}></p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>--</p>
<p style={{ fontSize: 13, color: '#86868B' }}></p>
</div>
</div>
</div>
{/* 操作列表 */}
<div className="ios-list">
{!editing ? (
<>
<div className="ios-list-item" onClick={startEdit}>
<span className="ios-list-item-label"></span>
<span className="ios-list-item-value"></span>
</div>
<div className="ios-list-item">
<span className="ios-list-item-label"></span>
<span className="ios-list-item-value">
{profile.privacy === 1 ? '公开' : profile.privacy === 2 ? '仅好友可见' : '仅自己可见'}
</span>
</div>
<div className="ios-list-item" onClick={() => router.push('/achievements')}>
<span className="ios-list-item-label"></span>
<span className="ios-list-item-value"></span>
</div>
<div className="ios-list-item" onClick={logout}>
<span className="ios-list-item-label" style={{ color: '#FF3B30' }}>退</span>
</div>
</>
) : (
<div style={{ padding: 16 }}>
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}></label>
<input className="input-field" value={nickname} onChange={e => setNickname(e.target.value)} style={{ marginBottom: 12 }} placeholder="输入昵称" />
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}></label>
<input className="input-field" value={bio} onChange={e => setBio(e.target.value)} style={{ marginBottom: 16 }} placeholder="最多 50 字" maxLength={50} />
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn-primary" onClick={handleSave} disabled={saving} style={{ flex: 1 }}>
{saving ? '保存中...' : '保存'}
</button>
<button onClick={() => setEditing(false)} style={{
flex: 1, padding: 14, background: '#f8f8fa', border: '1px solid #E5E5EA',
borderRadius: 10, fontSize: 17, cursor: 'pointer', color: '#86868B'
}}> }}>
{profile.points}
</button> </p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 22, fontWeight: 700, color: 'var(--color-primary)',
fontFamily: 'var(--font-mono)',
}}>
--
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 22, fontWeight: 700, color: 'var(--color-social)',
fontFamily: 'var(--font-mono)',
}}>
--
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div> </div>
</div> </div>
)} </div>
{/* Actions */}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{!editing ? (
<>
<button onClick={startEdit} className="ios-list-item" style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', width: '100%', border: 'none', cursor: 'pointer',
background: 'transparent', color: 'var(--color-text)', fontSize: 14,
borderBottom: '1px solid var(--color-border)',
transition: 'background 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--color-card-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<span></span>
<span style={{ color: 'var(--color-text-muted)' }}></span>
</button>
<div className="ios-list-item" style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', width: '100%',
borderBottom: '1px solid var(--color-border)',
}}>
<span style={{ fontSize: 14, color: 'var(--color-text)' }}></span>
<span style={{
fontSize: 12, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}>
{profile.privacy === 1 ? '公开' : profile.privacy === 2 ? '好友可见' : '私密'}
</span>
</div>
<button onClick={() => router.push('/achievements')} className="ios-list-item" style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', width: '100%', border: 'none', cursor: 'pointer',
background: 'transparent', color: 'var(--color-text)', fontSize: 14,
borderBottom: '1px solid var(--color-border)',
transition: 'background 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--color-card-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<span></span>
<span style={{ color: 'var(--color-text-muted)' }}></span>
</button>
<button onClick={logout} className="ios-list-item" style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', width: '100%', border: 'none', cursor: 'pointer',
background: 'transparent', fontSize: 14,
transition: 'background 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--color-card-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<span style={{ color: 'var(--color-error)' }}>退</span>
</button>
</>
) : (
<div style={{ padding: 20 }}>
<label style={{
fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6, display: 'block',
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</label>
<input className="input-cyber" value={nickname} onChange={e => setNickname(e.target.value)}
style={{ marginBottom: 14 }} placeholder="输入昵称" />
<label style={{
fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6, display: 'block',
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</label>
<input className="input-cyber" value={bio} onChange={e => setBio(e.target.value)}
style={{ marginBottom: 18 }} placeholder="最多 50 字" maxLength={50} />
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn-cyber btn-cyber--primary" onClick={handleSave} disabled={saving}
style={{ flex: 1, padding: 12, fontSize: 13 }}>
{saving ? '保存中...' : '保存'}
</button>
<button onClick={() => setEditing(false)} className="btn-cyber"
style={{ flex: 1, padding: 12, fontSize: 13, opacity: 0.6 }}>
</button>
</div>
</div>
)}
</div>
</div> </div>
<div style={{ height: 100 }} />
<TabBar /> <TabBar />
</> </>
) )
+106 -60
View File
@@ -1,12 +1,12 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { AuthProvider, useAuth } from '@/components/AuthProvider' import { AuthProvider, useAuth } from '@/components/AuthProvider'
import NavBar from '@/components/NavBar' import NavBar from '@/components/NavBar'
import { listFriends, sendFriendRequest, removeFriend, type IUserBrief } from '@/services/social' import { listFriends, sendFriendRequest, removeFriend, type IUserBrief } from '@/services/social'
function FriendsPage() { function FriendsPage() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const router = useRouter() const router = useRouter()
const [friends, setFriends] = useState<IUserBrief[]>([]) const [friends, setFriends] = useState<IUserBrief[]>([])
@@ -53,72 +53,118 @@
? friends.filter(f => f.nickname?.includes(search)) ? friends.filter(f => f.nickname?.includes(search))
: friends : friends
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>...</div> if (loading || pageLoading) {
return (
<>
<NavBar title="好友" showBack />
<div className="loading-container">
<div className="loading-spinner" />
<div className="loading-text"></div>
</div>
</>
)
}
if (!user) return null if (!user) return null
return ( return (
<> <>
<NavBar title="好友" showBack /> <NavBar title="好友" showBack />
{/* 添加好友 */} <div className="page-container">
<div className="card" style={{ marginTop: 16 }}> {/* Add friend */}
<p style={{ fontSize: 15, fontWeight: 600, marginBottom: 8 }}></p> <div className="card" style={{ marginTop: 14 }}>
<div style={{ display: 'flex', gap: 8 }}> <p style={{
<input className="input-field" value={addPhone} onChange={e => setAddPhone(e.target.value)} fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 10,
placeholder="输入手机号" style={{ flex: 1 }} /> fontFamily: 'var(--font-display)', letterSpacing: 1,
<button onClick={handleAdd} disabled={adding || !addPhone.trim()} }}>
style={{
padding: '0 16px', borderRadius: 10, border: 'none',
background: addPhone.trim() ? '#FF6B35' : '#E5E5EA',
color: 'white', fontSize: 14, cursor: addPhone.trim() ? 'pointer' : 'default',
}}>
</button>
</div>
</div>
{/* 好友列表 */}
<div style={{ padding: '20px 16px 8px', display: 'flex', justifyContent: 'space-between' }}>
<h3 style={{ fontSize: 15, fontWeight: 600 }}> ({friends.length})</h3>
{friends.length > 0 && (
<input className="input-field" value={search} onChange={e => setSearch(e.target.value)}
placeholder="搜索..." style={{ width: 140, fontSize: 13, padding: '6px 10px' }} />
)}
</div>
{filtered.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}>
<p style={{ fontSize: 15, color: '#86868B' }}>
{search ? '未找到匹配的好友' : '还没有好友,输入手机号添加吧'}
</p> </p>
</div> <div style={{ display: 'flex', gap: 8 }}>
) : ( <input className="input-cyber" value={addPhone} onChange={e => setAddPhone(e.target.value)}
filtered.map(f => ( placeholder="输入手机号" style={{ flex: 1, fontSize: 14 }} />
<div key={f.id} className="ios-list-item" style={{ margin: '0 16px', borderRadius: 12, background: 'white', padding: '12px 16px' }}> <button onClick={handleAdd} disabled={adding || !addPhone.trim()}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> className="btn-cyber btn-cyber--primary"
<div style={{ style={{
width: 36, height: 36, borderRadius: 18, padding: '0 18px', fontSize: 12, letterSpacing: 1,
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', opacity: addPhone.trim() ? 1 : 0.4,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: 'white', fontWeight: 600,
}}> }}>
{f.nickname?.[0] || '?'}
</div>
<span style={{ fontSize: 15 }}>{f.nickname || '用户'}</span>
</div>
<button onClick={() => handleRemove(f.id)}
style={{ background: 'none', border: 'none', fontSize: 13, color: '#FF3B30', cursor: 'pointer' }}>
</button> </button>
</div> </div>
)) </div>
)}
<div style={{ height: 80 }} /> {/* Friends list */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '20px 16px 12px',
}}>
<span style={{
fontSize: 11, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
({friends.length})
</span>
{friends.length > 0 && (
<input className="input-cyber" value={search} onChange={e => setSearch(e.target.value)}
placeholder="搜索" style={{
width: 130, fontSize: 11, padding: '6px 10px',
fontFamily: 'var(--font-mono)',
}} />
)}
</div>
{filtered.length === 0 ? (
<div className="card" style={{
textAlign: 'center', padding: '40px 16px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
<p style={{ fontSize: 15, color: 'var(--color-text-secondary)' }}>
{search ? '未找到匹配的好友' : '还没有好友'}
</p>
{!search && (
<p style={{
fontSize: 11, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}>
</p>
)}
</div>
) : (
filtered.map(f => (
<div key={f.id} className="card" style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 18px', margin: '4px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 38, height: 38, borderRadius: 10,
background: 'linear-gradient(135deg, rgba(255, 60, 126, 0.15), rgba(255, 23, 68, 0.08))',
border: '1px solid rgba(255, 60, 126, 0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: 'var(--color-social)', fontWeight: 600,
}}>
{f.nickname?.[0] || '?'}
</div>
<span style={{
fontSize: 14, color: 'var(--color-text)',
}}>
{f.nickname || '用户'}
</span>
</div>
<button onClick={() => handleRemove(f.id)}
className="btn-cyber btn-cyber--danger"
style={{ padding: '6px 14px', fontSize: 11 }}>
</button>
</div>
))
)}
</div>
</> </>
) )
} }
export default function FriendsRoute() { export default function FriendsRoute() {
return <AuthProvider><FriendsPage /></AuthProvider> return <AuthProvider><FriendsPage /></AuthProvider>
} }
+134 -71
View File
@@ -1,14 +1,14 @@
'use client' 'use client'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { AuthProvider, useAuth } from '@/components/AuthProvider' import { AuthProvider, useAuth } from '@/components/AuthProvider'
import NavBar from '@/components/NavBar' import NavBar from '@/components/NavBar'
import TabBar from '@/components/TabBar' import TabBar from '@/components/TabBar'
import FeedCard from '@/components/FeedCard' import FeedCard from '@/components/FeedCard'
import { getFeed, createPost, type IPost } from '@/services/social' import { getFeed, createPost, type IPost } from '@/services/social'
function FeedPage() { function FeedPage() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const router = useRouter() const router = useRouter()
const [posts, setPosts] = useState<IPost[]>([]) const [posts, setPosts] = useState<IPost[]>([])
@@ -55,79 +55,142 @@
<> <>
<NavBar title="广场" /> <NavBar title="广场" />
{/* 发布动态入口 */} <div className="page-container">
<div className="card" style={{ marginTop: 16, cursor: 'pointer' }} onClick={() => setShowCreate(true)}> {/* Create post entry */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div className="card" style={{
<div style={{ marginTop: 14, cursor: 'pointer',
width: 36, height: 36, borderRadius: 18, transition: 'all 0.2s',
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', border: '1px solid rgba(255, 60, 126, 0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center', }}
fontSize: 14, color: 'white', fontWeight: 600, onClick={() => setShowCreate(true)}
}}> onMouseEnter={e => {
{user.nickname?.[0] || user.phone?.slice(-4) || '?'} e.currentTarget.style.borderColor = 'rgba(255, 60, 126, 0.2)'
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'rgba(255, 60, 126, 0.08)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 38, height: 38, borderRadius: 10,
background: 'rgba(255, 60, 126, 0.1)',
border: '1px solid rgba(255, 60, 126, 0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 15, color: 'var(--color-social)', fontWeight: 600,
}}>
{user.nickname?.[0] || user.phone?.slice(-4) || '?'}
</div>
<span style={{
fontSize: 14, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}>
...
</span>
</div> </div>
<span style={{ fontSize: 15, color: '#86868B' }}>...</span>
</div> </div>
</div>
{/* 创建动态弹层 */} {/* Create post modal */}
{showCreate && ( {showCreate && (
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.4)', position: 'fixed', inset: 0, zIndex: 200,
display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(8, 12, 24, 0.7)',
}} onClick={() => setShowCreate(false)}> backdropFilter: 'blur(8px)',
<div className="card" style={{ width: '100%', maxWidth: 500, margin: 0, borderRadius: '16px 16px 0 0' }} display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
onClick={e => e.stopPropagation()}> }} onClick={() => setShowCreate(false)}>
<p style={{ fontSize: 17, fontWeight: 600, textAlign: 'center', marginBottom: 12 }}></p> <div className="card" style={{
<textarea ref={inputRef} value={newContent} onChange={e => setNewContent(e.target.value)} width: '100%', maxWidth: 500, margin: 0,
placeholder="分享你的打卡故事..." rows={4} borderRadius: '20px 20px 0 0',
style={{ width: '100%', border: '1px solid #E5E5EA', borderRadius: 10, padding: 12, fontSize: 15, resize: 'none', outline: 'none', fontFamily: 'inherit' }} padding: '24px 20px 32px',
/> }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}> <p style={{
<button onClick={() => { setShowCreate(false); setNewContent('') }} fontSize: 16, fontWeight: 600, textAlign: 'center', marginBottom: 16,
style={{ flex: 1, padding: 12, background: '#f8f8fa', border: '1px solid #E5E5EA', borderRadius: 10, fontSize: 15, cursor: 'pointer' }}> color: 'var(--color-social)',
fontFamily: 'var(--font-display)', letterSpacing: 1,
</button> }}>
<button onClick={handleCreate} disabled={creating || !newContent.trim()}
className="btn-primary" style={{ flex: 1, padding: 12 }}> </p>
{creating ? '发布中...' : '发布'} <textarea ref={inputRef} value={newContent} onChange={e => setNewContent(e.target.value)}
</button> placeholder="分享你的打卡故事..."
rows={4}
className="input-cyber"
style={{
width: '100%', padding: 14, fontSize: 14, resize: 'none',
fontFamily: 'var(--font-body)',
}}
/>
<div style={{ display: 'flex', gap: 12, marginTop: 14 }}>
<button onClick={() => { setShowCreate(false); setNewContent('') }}
className="btn-cyber" style={{ flex: 1, padding: 12, fontSize: 13 }}>
</button>
<button onClick={handleCreate} disabled={creating || !newContent.trim()}
className="btn-cyber btn-cyber--primary" style={{
flex: 1, padding: 12, fontSize: 13,
opacity: creating || !newContent.trim() ? 0.5 : 1,
}}>
{creating ? '发布中...' : '发布'}
</button>
</div>
</div> </div>
</div> </div>
</div> )}
)}
{/* Feed 列表 */} {/* Feed list */}
{feedLoading ? ( {feedLoading ? (
<div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>...</div> <div className="loading-container">
) : posts.length === 0 ? ( <div className="loading-spinner" />
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}> <div className="loading-text"></div>
<p style={{ fontSize: 40, marginBottom: 12 }}>🌊</p> </div>
<p style={{ fontSize: 15, color: '#86868B' }}></p> ) : posts.length === 0 ? (
</div> <div className="card" style={{
) : ( textAlign: 'center',
posts.map(post => ( padding: '48px 20px',
<FeedCard key={post.id} post={post} onUpdate={loadFeed} /> display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12,
)) }}>
)} <div style={{
width: 56, height: 56, borderRadius: 16,
background: 'rgba(255, 60, 126, 0.08)',
border: '1px solid rgba(255, 60, 126, 0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24,
}}>
</div>
<p style={{ fontSize: 15, color: 'var(--color-text-secondary)' }}>
</p>
<p style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
</p>
</div>
) : (
posts.map((post, i) => (
<div key={post.id} style={{
animation: 'fadeUp 0.4s ease-out',
animationDelay: `${i * 0.04}s`,
animationFillMode: 'both',
}}>
<FeedCard key={post.id} post={post} onUpdate={loadFeed} />
</div>
))
)}
{/* 加载更多 */} {/* Load more */}
{posts.length < total && ( {posts.length < total && (
<button onClick={() => setPage(p => p + 1)} style={{ <button onClick={() => setPage(p => p + 1)} className="btn-cyber" style={{
display: 'block', margin: '16px auto', padding: '10px 24px', display: 'block', margin: '16px auto', padding: '10px 28px',
background: 'white', border: '1px solid #E5E5EA', borderRadius: 8, fontSize: 12, letterSpacing: 1,
fontSize: 14, color: '#86868B', cursor: 'pointer', }}>
}}>
</button>
</button> )}
)} </div>
<div style={{ height: 100 }} />
<TabBar /> <TabBar />
</> </>
) )
} }
export default function SocialRoute() { export default function SocialRoute() {
return <AuthProvider><FeedPage /></AuthProvider> return <AuthProvider><FeedPage /></AuthProvider>
} }
+214 -80
View File
@@ -1,12 +1,12 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { AuthProvider, useAuth } from '@/components/AuthProvider' import { AuthProvider, useAuth } from '@/components/AuthProvider'
import NavBar from '@/components/NavBar' import NavBar from '@/components/NavBar'
import TabBar from '@/components/TabBar' import TabBar from '@/components/TabBar'
import ProgressRing from '@/components/ProgressRing' import ProgressRing from '@/components/ProgressRing'
import { getDashboardStats, type DashboardStats } from '@/services/analytics' import { getDashboardStats, type DashboardStats } from '@/services/analytics'
function StatsPage() { function StatsPage() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
@@ -30,99 +30,233 @@ function StatsPage() {
setPageLoading(false) setPageLoading(false)
} }
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>...</div> if (loading || pageLoading) {
return (
<>
<NavBar title="统计" />
<div className="loading-container">
<div className="loading-spinner" />
<div className="loading-text"></div>
</div>
<TabBar />
</>
)
}
if (!user) return null if (!user) return null
return ( return (
<> <>
<NavBar title="统计" /> <NavBar title="统计" />
{/* 月份切换 */} <div className="page-container">
<div className="card" style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> {/* Month selector */}
<button onClick={() => { if (month === 1) { setYear(y => y-1); setMonth(12) } else setMonth(m => m-1) }} <div className="card" style={{
style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', padding: 8 }}></button> marginTop: 14, display: 'flex', justifyContent: 'space-between',
<span style={{ fontSize: 17, fontWeight: 600 }}>{year} {month} </span> alignItems: 'center', padding: '14px 18px',
<button onClick={() => { if (month === 12) { setYear(y => y+1); setMonth(1) } else setMonth(m => m+1) }} }}>
style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', padding: 8 }}></button> <button onClick={() => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else setMonth(m => m - 1) }}
</div> className="btn-cyber" style={{ padding: '6px 12px', fontSize: 14, border: '1px solid rgba(0, 229, 255, 0.15)' }}>
</button>
<span style={{
fontSize: 15, fontWeight: 600, color: 'var(--color-text)',
fontFamily: 'var(--font-display)', letterSpacing: 2,
}}>
{year}.{String(month).padStart(2, '0')}
</span>
<button onClick={() => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else setMonth(m => m + 1) }}
className="btn-cyber" style={{ padding: '6px 12px', fontSize: 14, border: '1px solid rgba(0, 229, 255, 0.15)' }}>
</button>
</div>
{data && ( {data && (
<> <>
{/* 概览卡片 */} {/* Overview */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, margin: '8px 16px' }}> <div style={{
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}> display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, margin: '8px 16px',
<ProgressRing percent={data.summary.completionRate} size={90} label="完成率" /> }}>
</div> <div className="card card-glow" style={{ margin: 0, padding: '20px 12px', display: 'flex', justifyContent: 'center' }}>
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 12 }}> <ProgressRing percent={data.summary.completionRate} size={96} label="完成率" />
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>{data.summary.totalRecords}</p>
<p style={{ fontSize: 12, color: '#86868B' }}></p>
</div> </div>
<div style={{ textAlign: 'center' }}> <div className="card" style={{
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>{data.summary.totalItems}</p> margin: 0, display: 'flex', flexDirection: 'column',
<p style={{ fontSize: 12, color: '#86868B' }}></p> justifyContent: 'center', gap: 14, padding: '16px 18px',
}}>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 22, fontWeight: 700, color: 'var(--color-primary)',
fontFamily: 'var(--font-mono)',
}}>
{data.summary.totalRecords}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{
fontSize: 22, fontWeight: 700, color: 'var(--color-primary)',
fontFamily: 'var(--font-mono)',
}}>
{data.summary.totalItems}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2,
fontFamily: 'var(--font-display)', letterSpacing: 1,
}}>
</p>
</div>
</div> </div>
</div> </div>
</div>
{/* 最高连续天数 */} {/* Max streak */}
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '16px 20px' }}> <div className="card" style={{
<span style={{ fontSize: 32 }}>🔥</span> display: 'flex', alignItems: 'center', gap: 14, padding: '16px 20px',
<div> background: 'linear-gradient(135deg, rgba(255, 179, 71, 0.04), transparent)',
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>{data.summary.maxStreak} </p> border: '1px solid rgba(255, 179, 71, 0.12)',
<p style={{ fontSize: 13, color: '#86868B' }}></p> }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'rgba(255, 179, 71, 0.1)',
border: '1px solid rgba(255, 179, 71, 0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20,
}}>
</div>
<div>
<p style={{
fontSize: 22, fontWeight: 700, color: 'var(--color-gold)',
fontFamily: 'var(--font-mono)',
}}>
{data.summary.maxStreak}
<span style={{
fontFamily: 'var(--font-body)', fontSize: 14, fontWeight: 400,
color: 'var(--color-text-secondary)', marginLeft: 4,
}}></span>
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-display)', letterSpacing: 1, marginTop: 2,
}}>
</p>
</div>
</div> </div>
</div>
{/* 分类分布 */} {/* Category distribution */}
<h3 style={{ fontSize: 15, color: '#86868B', padding: '20px 16px 8px' }}></h3> <div className="section-title-bar" style={{ marginTop: 8 }}>
<div className="card" style={{ padding: '12px 16px' }}> <span className="section-title-bar-text"></span>
{data.byCategory.map(c => { </div>
const pct = data.summary.totalItems > 0 ? Math.round((c.count / data.summary.totalItems) * 100) : 0 <div className="card" style={{ padding: '14px 18px' }}>
{data.byCategory.map((c) => {
const pct = data.summary.totalItems > 0 ? Math.round((c.count / data.summary.totalItems) * 100) : 0
return (
<div key={c.category} style={{ marginBottom: 12 }}>
<div style={{
display: 'flex', justifyContent: 'space-between', marginBottom: 5,
}}>
<span style={{
fontSize: 13, color: 'var(--color-text)',
fontFamily: 'var(--font-mono)',
}}>
<span style={{ color: c.color }}></span> {c.label}
</span>
<span style={{
fontSize: 12, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}>
{c.count} · {pct}%
</span>
</div>
<div style={{
height: 5, borderRadius: 3,
background: 'var(--color-progress-bg)',
overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 3,
background: c.color,
width: pct + '%',
transition: 'width 0.6s ease',
boxShadow: `0 0 8px ${c.color}66`,
}} />
</div>
</div>
)
})}
</div>
{/* Per-item stats */}
<div className="section-title-bar" style={{ marginTop: 8 }}>
<span className="section-title-bar-text"></span>
</div>
{data.items.map((item: any) => {
const rate = data.daysInMonth > 0 ? Math.round((item.monthlyCount / data.daysInMonth) * 100) : 0
return ( return (
<div key={c.category} style={{ marginBottom: 10 }}> <div key={item.id} className="card"
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> onClick={() => router.push(`/checkin/${item.id}`)}
<span style={{ fontSize: 14 }}>{c.label}</span> style={{
<span style={{ fontSize: 13, color: '#86868B' }}>{c.count} · {pct}%</span> cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12,
transition: 'all 0.2s',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = 'rgba(0, 229, 255, 0.2)'
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = ''
}}
>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'var(--color-primary-dim)',
border: '1px solid rgba(0, 229, 255, 0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18,
}}>
{item.icon || '◈'}
</div> </div>
<div style={{ height: 6, borderRadius: 3, background: '#F0F0F0', overflow: 'hidden' }}> <div style={{ flex: 1 }}>
<div style={{ height: '100%', borderRadius: 3, background: c.color, width: pct + '%', transition: 'width 0.5s' }} /> <p style={{
fontSize: 14, fontWeight: 600, color: 'var(--color-text)',
}}>
{item.name}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}>
{item.monthlyCount}/{data.daysInMonth} · {item.streak}
</p>
<div style={{
height: 3, borderRadius: 2,
background: 'var(--color-progress-bg)', marginTop: 5, overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 2,
background: 'var(--color-primary)',
width: rate + '%',
transition: 'width 0.6s ease',
}} />
</div>
</div> </div>
<span style={{
fontSize: 14, fontWeight: 700, color: 'var(--color-primary)',
fontFamily: 'var(--font-mono)',
}}>
{rate}%
</span>
</div> </div>
) )
})} })}
</div> </>
)}
</div>
{/* 单项详情 */}
<h3 style={{ fontSize: 15, color: '#86868B', padding: '20px 16px 8px' }}></h3>
{data.items.map((item: any) => {
const rate = data.daysInMonth > 0 ? Math.round((item.monthlyCount / data.daysInMonth) * 100) : 0
return (
<div key={item.id} className="card" onClick={() => router.push(`/checkin/${item.id}`)}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: '#FFF0EB', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18,
}}>
{item.icon || '📋'}
</div>
<div style={{ flex: 1 }}>
<p style={{ fontSize: 15, fontWeight: 600 }}>{item.name}</p>
<p style={{ fontSize: 12, color: '#86868B' }}>
{item.monthlyCount}/{data.daysInMonth} · {item.streak}
</p>
<div style={{ height: 4, borderRadius: 2, background: '#F0F0F0', marginTop: 4, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: '#FF6B35', width: rate + '%', transition: 'width 0.5s' }} />
</div>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#FF6B35' }}>{rate}%</span>
</div>
)
})}
</>
)}
<div style={{ height: 100 }} />
<TabBar /> <TabBar />
</> </>
) )
+83 -18
View File
@@ -31,53 +31,118 @@ export default function CheckInCard({ item, onCheckIn, streak, isDone }: CheckIn
} }
} }
const isActive = item.status === 'active'
return ( return (
<div <div
className="card" className="card"
onClick={() => router.push(`/checkin/${item.id}`)} onClick={() => router.push(`/checkin/${item.id}`)}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px' }} style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '16px 18px',
margin: '6px 16px',
animation: 'fadeUp 0.5s ease-out',
opacity: isActive ? 1 : 0.5,
transition: 'all 0.3s',
}}
onMouseEnter={e => {
if (isActive) e.currentTarget.style.borderColor = 'rgba(0, 229, 255, 0.2)'
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = ''
}}
> >
{/* 分类图标 */} {/* Category icon */}
<div style={{ <div style={{
width: 44, height: 44, borderRadius: 12, width: 44, height: 44, borderRadius: 12,
background: cfg.color + '20', background: `linear-gradient(135deg, ${cfg.color}22, ${cfg.color}11)`,
border: `1px solid ${cfg.color}33`,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, flexShrink: 0, fontSize: 20, flexShrink: 0,
position: 'relative',
}}> }}>
{item.icon || cfg.icon} {item.icon || cfg.icon}
</div> </div>
{/* 名称与信息 */} {/* Info */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 600, marginBottom: 2 }}>{item.name}</p> <div style={{
<p style={{ fontSize: 13, color: '#86868B', display: 'flex', gap: 8, alignItems: 'center' }}> display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3,
}}>
<p style={{
fontSize: 15, fontWeight: 600, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</p>
{done && (
<span className="badge badge--green" style={{ flexShrink: 0 }}>
</span>
)}
</div>
<p style={{
fontSize: 12, color: 'var(--color-text-muted)',
display: 'flex', gap: 8, alignItems: 'center',
fontFamily: 'var(--font-mono)',
}}>
<span style={{ color: cfg.color }}></span>
<span>{cfg.label}</span> <span>{cfg.label}</span>
<span>·</span> <span style={{ color: 'var(--color-text-muted)' }}>/</span>
<span>{CYCLE_LABEL[item.cycleType] || item.cycleType}</span> <span>{CYCLE_LABEL[item.cycleType] || item.cycleType}</span>
{streak !== undefined && ( {streak !== undefined && (
<> <>
<span>·</span> <span style={{ color: 'var(--color-text-muted)' }}>/</span>
<span style={{ color: '#FF6B35' }}>🔥 {streak} </span> <span style={{ color: 'var(--color-gold)' }}>
{streak}
<span style={{ fontFamily: 'var(--font-body)', marginLeft: 2 }}></span>
</span>
</> </>
)} )}
</p> </p>
</div> </div>
{/* 打卡按钮 */} {/* Check-in button */}
<button <button
onClick={handleCheckIn} onClick={handleCheckIn}
disabled={checking || done} disabled={checking || done || !isActive}
style={{ style={{
width: 44, height: 44, borderRadius: 22, border: 'none', cursor: done ? 'default' : 'pointer', width: 46, height: 46, borderRadius: 14,
border: done
? '1px solid rgba(0, 230, 118, 0.3)'
: '1px solid rgba(0, 229, 255, 0.2)',
cursor: done || !isActive ? 'default' : 'pointer',
background: done background: done
? 'linear-gradient(135deg, #34C759, #28A745)' ? 'linear-gradient(135deg, #00E676, #00C853)'
: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', : 'linear-gradient(135deg, rgba(0, 229, 255, 0.15), rgba(59, 130, 246, 0.15))',
color: 'white', fontSize: 18, fontWeight: 600, flexShrink: 0, color: done ? '#080C18' : 'var(--color-primary)',
transition: 'transform 0.2s, opacity 0.2s', fontSize: 20, fontWeight: 700, flexShrink: 0,
transition: 'all 0.3s',
opacity: checking ? 0.6 : 1, opacity: checking ? 0.6 : 1,
boxShadow: done ? '0 0 20px rgba(0, 230, 118, 0.25)' : 'none',
animation: done ? 'glowPulseGreen 2s ease-in-out infinite' : 'none',
position: 'relative',
overflow: 'hidden',
}}
onMouseEnter={e => {
if (!done && isActive) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(0, 229, 255, 0.25), rgba(59, 130, 246, 0.25))'
e.currentTarget.style.boxShadow = '0 0 20px rgba(0, 229, 255, 0.15)'
e.currentTarget.style.transform = 'scale(1.05)'
}
}}
onMouseLeave={e => {
if (!done) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(0, 229, 255, 0.15), rgba(59, 130, 246, 0.15))'
e.currentTarget.style.boxShadow = 'none'
e.currentTarget.style.transform = 'scale(1)'
}
}} }}
> >
{done ? '✓' : checking ? '...' : '+'} {done ? '✓' : checking ? '' : '+'}
</button> </button>
</div> </div>
) )
+78 -36
View File
@@ -1,14 +1,14 @@
'use client' 'use client'
import { likePost, commentPost, type IPost } from '@/services/social' import { likePost, commentPost, type IPost } from '@/services/social'
import { useState } from 'react' import { useState } from 'react'
interface FeedCardProps { interface FeedCardProps {
post: IPost post: IPost
onUpdate?: () => void onUpdate?: () => void
} }
export default function FeedCard({ post, onUpdate }: FeedCardProps) { export default function FeedCard({ post, onUpdate }: FeedCardProps) {
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
const [likes, setLikes] = useState(post.likes) const [likes, setLikes] = useState(post.likes)
const [comments, setComments] = useState(post.comments) const [comments, setComments] = useState(post.comments)
@@ -16,7 +16,7 @@
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const createdAt = new Date(post.createdAt) const createdAt = new Date(post.createdAt)
const timeStr = `${createdAt.getMonth()+1}${createdAt.getDate()}` const timeStr = `${createdAt.getMonth() + 1}${createdAt.getDate()}`
async function handleLike() { async function handleLike() {
if (liking) return if (liking) return
@@ -38,43 +38,59 @@
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// 解析图片数组
let images: string[] = [] let images: string[] = []
try { if (post.images) images = JSON.parse(post.images) } catch { images = [] } try { if (post.images) images = JSON.parse(post.images) } catch { images = [] }
return ( return (
<div className="card"> <div className="card" style={{ animation: 'fadeUp 0.5s ease-out', padding: '18px 18px' }}>
{/* 用户信息 */} {/* User info */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div style={{ <div style={{
width: 36, height: 36, borderRadius: 18, width: 38, height: 38, borderRadius: 10,
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', background: 'linear-gradient(135deg, rgba(255, 60, 126, 0.2), rgba(255, 23, 68, 0.1))',
border: '1px solid rgba(255, 60, 126, 0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: 'white', fontWeight: 600, flexShrink: 0, fontSize: 15, color: 'var(--color-social)', fontWeight: 600, flexShrink: 0,
}}> }}>
{post.user?.nickname?.[0] || '?'} {post.user?.nickname?.[0] || '?'}
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<p style={{ fontSize: 14, fontWeight: 600 }}>{post.user?.nickname || '用户'}</p> <p style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
<p style={{ fontSize: 12, color: '#86868B' }}>{timeStr}</p> {post.user?.nickname || '用户'}
</p>
<p style={{
fontSize: 11, color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
}}>
{timeStr}
</p>
</div> </div>
</div> </div>
{/* 内容 */} {/* Content */}
{post.content && ( {post.content && (
<p style={{ fontSize: 15, lineHeight: 1.5, marginBottom: images.length > 0 ? 10 : 0, whiteSpace: 'pre-wrap' }}> <p style={{
fontSize: 14, lineHeight: 1.7, marginBottom: images.length > 0 ? 12 : 0,
whiteSpace: 'pre-wrap', color: 'var(--color-text)',
}}>
{post.content} {post.content}
</p> </p>
)} )}
{/* 图片 */} {/* Images */}
{images.length > 0 && ( {images.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: images.length === 1 ? '1fr' : `repeat(${Math.min(images.length, 3)}, 1fr)`, gap: 4, marginBottom: 10 }}> <div style={{
display: 'grid',
gridTemplateColumns: images.length === 1 ? '1fr' : `repeat(${Math.min(images.length, 3)}, 1fr)`,
gap: 4, marginBottom: 12, borderRadius: 8, overflow: 'hidden',
}}>
{images.slice(0, 3).map((url, i) => ( {images.slice(0, 3).map((url, i) => (
<div key={i} style={{ <div key={i} style={{
aspectRatio: '1', borderRadius: 8, background: '#F0F0F0', aspectRatio: '1', borderRadius: 6,
background: 'var(--color-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, color: '#86868B', overflow: 'hidden', fontSize: 12, color: 'var(--color-text-muted)', overflow: 'hidden',
border: '1px solid var(--color-border)',
}}> }}>
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div> </div>
@@ -82,30 +98,56 @@
</div> </div>
)} )}
{/* 互动按钮 */} {/* Actions */}
<div style={{ display: 'flex', gap: 20, paddingTop: 8, borderTop: '0.5px solid #F0F0F0' }}> <div style={{
display: 'flex', gap: 20, paddingTop: 10,
borderTop: '1px solid var(--color-border)',
}}>
<button onClick={handleLike} disabled={liking} <button onClick={handleLike} disabled={liking}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#86868B', display: 'flex', alignItems: 'center', gap: 4 }}> style={{
{likes > 0 ? '❤️' : '🤍'} {likes} background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, color: likes > 0 ? 'var(--color-social)' : 'var(--color-text-muted)',
display: 'flex', alignItems: 'center', gap: 5,
transition: 'color 0.2s',
fontFamily: 'var(--font-mono)',
}}>
<span style={{ fontSize: 14 }}>{likes > 0 ? '♥' : '♡'}</span>
<span>{likes}</span>
</button> </button>
<button onClick={() => setShowComment(!showComment)} <button onClick={() => setShowComment(!showComment)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#86868B', display: 'flex', alignItems: 'center', gap: 4 }}> style={{
💬 {comments} background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, color: 'var(--color-text-muted)',
display: 'flex', alignItems: 'center', gap: 5,
transition: 'color 0.2s',
fontFamily: 'var(--font-mono)',
}}>
<span style={{ fontSize: 14 }}></span>
<span>{comments}</span>
</button> </button>
</div> </div>
{/* 评论输入 */} {/* Comment input */}
{showComment && ( {showComment && (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}> <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="input-field" value={commentText} onChange={e => setCommentText(e.target.value)} <input
placeholder="说点什么..." style={{ flex: 1, fontSize: 14, padding: '8px 12px' }} className="input-cyber"
onKeyDown={e => e.key === 'Enter' && handleComment()} /> value={commentText}
<button onClick={handleComment} disabled={!commentText.trim()} onChange={e => setCommentText(e.target.value)}
placeholder="说点什么..."
style={{ flex: 1, fontSize: 13, padding: '10px 14px' }}
onKeyDown={e => e.key === 'Enter' && handleComment()}
/>
<button
onClick={handleComment}
disabled={!commentText.trim()}
className="btn-cyber"
style={{ style={{
padding: '8px 16px', borderRadius: 8, border: 'none', padding: '10px 18px',
background: commentText.trim() ? '#FF6B35' : '#E5E5EA', fontSize: 13,
color: 'white', fontSize: 14, cursor: commentText.trim() ? 'pointer' : 'default', opacity: commentText.trim() ? 1 : 0.4,
}}> }}
>
</button> </button>
</div> </div>
+58 -27
View File
@@ -10,18 +10,17 @@ interface NavBarProps {
showLogout?: boolean showLogout?: boolean
} }
export default function NavBar({ title, showBack = false, showLogout = false }: NavBarProps) { export default function NavBar({ title, showBack = false }: NavBarProps) {
const { logout, user } = useAuth()
const router = useRouter() const router = useRouter()
const { mode, toggle } = useTheme() const { mode, toggle } = useTheme()
return ( return (
<nav style={{ <nav style={{
position: 'sticky', top: 0, zIndex: 100, position: 'sticky', top: 0, zIndex: 100,
background: 'var(--color-nav-bg)', background: 'rgba(8, 12, 24, 0.85)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(24px) saturate(1.4)',
WebkitBackdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(24px) saturate(1.4)',
borderBottom: '0.5px solid var(--color-border)', borderBottom: '1px solid rgba(26, 34, 69, 0.6)',
padding: '12px 16px', padding: '12px 16px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}> }}>
@@ -29,42 +28,74 @@ export default function NavBar({ title, showBack = false, showLogout = false }:
{showBack && ( {showBack && (
<button <button
onClick={() => router.back()} onClick={() => router.back()}
style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', padding: 4, color: 'var(--color-text)' }} style={{
background: 'rgba(0, 229, 255, 0.08)',
border: '1px solid rgba(0, 229, 255, 0.15)',
borderRadius: 8,
fontSize: 16,
cursor: 'pointer',
padding: '6px 10px',
color: 'var(--color-primary)',
transition: 'all 0.2s',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(0, 229, 255, 0.15)'
e.currentTarget.style.boxShadow = '0 0 12px rgba(0, 229, 255, 0.1)'
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(0, 229, 255, 0.08)'
e.currentTarget.style.boxShadow = 'none'
}}
> >
</button> </button>
)} )}
{showLogout && user && (
<button
onClick={() => router.push('/profile')}
style={{ background: 'none', border: 'none', fontSize: 15, cursor: 'pointer', color: 'var(--color-primary)', padding: 4 }}
>
</button>
)}
</div> </div>
<span style={{ fontSize: 17, fontWeight: 600, flex: 1, textAlign: 'center', color: 'var(--color-text)' }}>{title}</span> <div style={{
fontSize: 16,
fontWeight: 600,
flex: 1,
textAlign: 'center',
color: 'var(--color-text)',
fontFamily: 'var(--font-body)',
letterSpacing: 1,
}}>
<span style={{
background: 'linear-gradient(135deg, #00E5FF, #3B82F6)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
{title}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, minWidth: 60 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6, minWidth: 60 }}>
<button <button
onClick={toggle} onClick={toggle}
title={mode === 'light' ? '切换暗黑模式' : '切换明亮模式'} title={mode === 'light' ? '切换暗黑模式' : '切换明亮模式'}
style={{ style={{
background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', padding: 4, background: 'rgba(0, 229, 255, 0.08)',
color: 'var(--color-text-secondary)', lineHeight: 1, border: '1px solid rgba(0, 229, 255, 0.15)',
borderRadius: 8,
fontSize: 16,
cursor: 'pointer',
padding: '6px 10px',
color: 'var(--color-primary)',
lineHeight: 1,
transition: 'all 0.2s',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(0, 229, 255, 0.15)'
e.currentTarget.style.boxShadow = '0 0 12px rgba(0, 229, 255, 0.1)'
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(0, 229, 255, 0.08)'
e.currentTarget.style.boxShadow = 'none'
}} }}
> >
{mode === 'light' ? '🌙' : '☀️'} {mode === 'light' ? '🌙' : '☀️'}
</button> </button>
{showLogout && (
<button
onClick={logout}
style={{ background: 'none', border: 'none', fontSize: 15, cursor: 'pointer', color: 'var(--color-text-secondary)', padding: 4 }}
>
退
</button>
)}
</div> </div>
</nav> </nav>
) )
+35 -11
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
interface ProgressRingProps { interface ProgressRingProps {
percent: number percent: number
size?: number size?: number
strokeWidth?: number strokeWidth?: number
@@ -8,33 +8,57 @@
label?: string label?: string
} }
export default function ProgressRing({ percent, size = 80, strokeWidth = 6, color = '#FF6B35', label }: ProgressRingProps) { export default function ProgressRing({ percent, size = 80, strokeWidth = 6, color, label }: ProgressRingProps) {
const radius = (size - strokeWidth) / 2 const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius const circumference = 2 * Math.PI * radius
const offset = circumference - (Math.min(percent, 100) / 100) * circumference const offset = circumference - (Math.min(percent, 100) / 100) * circumference
const center = size / 2 const center = size / 2
const ringColor = color || 'var(--color-primary)'
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<svg width={size} height={size}> <svg width={size} height={size} style={{ filter: `drop-shadow(0 0 6px ${ringColor}44)` }}>
{/* 背景圆环 */} {/* Background ring */}
<circle cx={center} cy={center} r={radius} <circle cx={center} cy={center} r={radius}
fill="none" stroke="#F0F0F0" strokeWidth={strokeWidth} /> fill="none" stroke="var(--color-progress-bg)" strokeWidth={strokeWidth} />
{/* 进度圆环 */} {/* Progress ring */}
<circle cx={center} cy={center} r={radius} <circle cx={center} cy={center} r={radius}
fill="none" stroke={color} strokeWidth={strokeWidth} fill="none" stroke={ringColor} strokeWidth={strokeWidth}
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={offset} strokeDashoffset={offset}
strokeLinecap="round" strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`} transform={`rotate(-90 ${center} ${center})`}
style={{ transition: 'stroke-dashoffset 0.6s ease-out' }} style={{ transition: 'stroke-dashoffset 0.8s ease-out' }}
/> />
{/* Glow dot at the end */}
{percent > 0 && percent < 100 && (
<circle cx={center} cy={center} r={radius}
fill="none" stroke={ringColor} strokeWidth={strokeWidth + 4}
strokeDasharray={`${(percent / 100) * circumference - 2} ${circumference}`}
strokeDashoffset={offset}
strokeLinecap="round"
opacity={0.2}
transform={`rotate(-90 ${center} ${center})`}
style={{ transition: 'stroke-dashoffset 0.8s ease-out' }}
/>
)}
<text x={center} y={center} textAnchor="middle" dominantBaseline="central" <text x={center} y={center} textAnchor="middle" dominantBaseline="central"
fontSize={size * 0.28} fontWeight={700} fill="#1D1D1F"> fontSize={size * 0.26} fontWeight={700}
fill="var(--color-text)"
fontFamily="var(--font-mono)">
{percent}% {percent}%
</text> </text>
</svg> </svg>
{label && <span style={{ fontSize: 12, color: '#86868B' }}>{label}</span>} {label && (
<span style={{
fontSize: 11,
color: 'var(--color-text-secondary)',
fontFamily: 'var(--font-display)',
letterSpacing: 1,
}}>
{label}
</span>
)}
</div> </div>
) )
} }
+45 -15
View File
@@ -3,10 +3,10 @@
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
const TABS = [ const TABS = [
{ path: '/', label: '打卡', icon: '📋' }, { path: '/', label: '打卡', icon: '' },
{ path: '/social', label: '广场', icon: '🌊' }, { path: '/social', label: '广场', icon: '' },
{ path: '/stats', label: '统计', icon: '📊' }, { path: '/stats', label: '统计', icon: '' },
{ path: '/profile', label: '我的', icon: '👤' }, { path: '/profile', label: '我的', icon: '' },
] ]
export default function TabBar() { export default function TabBar() {
@@ -22,13 +22,13 @@ export default function TabBar() {
return ( return (
<nav style={{ <nav style={{
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100,
background: 'var(--color-nav-bg)', background: 'rgba(8, 12, 24, 0.9)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(24px) saturate(1.4)',
WebkitBackdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(24px) saturate(1.4)',
borderTop: '0.5px solid var(--color-border)', borderTop: '1px solid rgba(26, 34, 69, 0.6)',
display: 'flex', justifyContent: 'space-around', display: 'flex', justifyContent: 'space-around',
padding: '6px 0 20px', padding: '6px 0 24px',
}}> }}>
{TABS.map(tab => { {TABS.map(tab => {
const active = isActive(tab.path) const active = isActive(tab.path)
@@ -36,12 +36,42 @@ export default function TabBar() {
<button key={tab.path} onClick={() => router.push(tab.path)} <button key={tab.path} onClick={() => router.push(tab.path)}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
fontSize: 10, color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)', fontWeight: active ? 600 : 400, fontSize: 12,
transition: 'color 0.2s', color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontFamily: 'var(--font-display)',
letterSpacing: 1,
transition: 'color 0.3s',
position: 'relative',
padding: '4px 12px',
}}> }}>
<span style={{ fontSize: 22 }}>{tab.icon}</span> {active && (
<span>{tab.label}</span> <div style={{
position: 'absolute',
top: -7,
left: '50%',
transform: 'translateX(-50%)',
width: 20,
height: 2,
background: 'var(--color-primary)',
borderRadius: 1,
boxShadow: '0 0 8px rgba(0, 229, 255, 0.5)',
}} />
)}
<span style={{
fontSize: 20,
fontFamily: 'var(--font-body)',
filter: active ? 'none' : 'none',
textShadow: active ? '0 0 10px rgba(0, 229, 255, 0.3)' : 'none',
}}>
{tab.icon}
</span>
<span style={{
fontSize: 11,
fontWeight: active ? 600 : 400,
}}>
{tab.label}
</span>
</button> </button>
) )
})} })}
+4 -2
View File
@@ -10,7 +10,7 @@ interface IThemeContext {
} }
const ThemeContext = createContext<IThemeContext>({ const ThemeContext = createContext<IThemeContext>({
mode: 'light', mode: 'dark',
toggle: () => {}, toggle: () => {},
setMode: () => {}, setMode: () => {},
}) })
@@ -20,7 +20,7 @@ export function useTheme() {
} }
export default function ThemeProvider({ children }: { children: React.ReactNode }) { export default function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<ThemeMode>('light') const [mode, setModeState] = useState<ThemeMode>('dark')
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => { useEffect(() => {
@@ -29,6 +29,8 @@ export default function ThemeProvider({ children }: { children: React.ReactNode
setModeState(stored) setModeState(stored)
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setModeState('dark') setModeState('dark')
} else {
setModeState('dark')
} }
setMounted(true) setMounted(true)
}, []) }, [])
+28 -28
View File
@@ -17,34 +17,34 @@ export interface IThemeColors {
} }
export const THEME: Record<ThemeMode, IThemeColors> = { export const THEME: Record<ThemeMode, IThemeColors> = {
light: {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryGradient: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
accent: '#7209B7',
bg: '#F5F5F7',
card: '#FFFFFF',
text: '#1D1D1F',
textSecondary: '#86868B',
border: '#E5E5EA',
navBg: 'rgba(245,245,247,0.92)',
success: '#34C759',
warning: '#FF9500',
error: '#FF3B30',
},
dark: { dark: {
primary: '#FF6B35', primary: '#00E5FF',
primaryLight: '#FF8C5A', primaryLight: '#33EAFF',
primaryGradient: 'linear-gradient(135deg, #FF6B35, #FF4D6D)', primaryGradient: 'linear-gradient(135deg, #00E5FF, #3B82F6)',
accent: '#BB86FC', accent: '#3B82F6',
bg: '#1C1C1E', bg: '#080C18',
card: '#2C2C2E', card: '#0F1529',
text: '#F5F5F7', text: '#E0E8FF',
textSecondary: '#98989D', textSecondary: '#6B7BA3',
border: '#38383A', border: '#1A2245',
navBg: 'rgba(28,28,30,0.92)', navBg: 'rgba(8, 12, 24, 0.85)',
success: '#34C759', success: '#00E676',
warning: '#FF9500', warning: '#FFB347',
error: '#FF453A', error: '#FF1744',
},
light: {
primary: '#2563EB',
primaryLight: '#3B82F6',
primaryGradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
accent: '#3B82F6',
bg: '#E8ECF4',
card: '#FFFFFF',
text: '#1A1F36',
textSecondary: '#5B6B8F',
border: '#D0D7E6',
navBg: 'rgba(232, 236, 244, 0.9)',
success: '#059669',
warning: '#D97706',
error: '#DC2626',
}, },
} }