首次提交
This commit is contained in:
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['../shared'],
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "inchstep-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.2.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+1566
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import { getAllAchievements, getUserAchievements, RARITY_CONFIG, type IAchievement } from '@/services/achievements'
|
||||
|
||||
function AchievementsPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [allAchievements, setAllAchievements] = useState<IAchievement[]>([])
|
||||
const [unlockedIds, setUnlockedIds] = useState<Set<string>>(new Set())
|
||||
const [pageLoading, setPageLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) { router.push('/login'); return }
|
||||
if (user) loadData()
|
||||
}, [user, loading, router])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [all, mine] = await Promise.all([
|
||||
getAllAchievements(),
|
||||
getUserAchievements(),
|
||||
])
|
||||
setAllAchievements(all)
|
||||
setUnlockedIds(new Set(mine.map(u => u.achievementID)))
|
||||
} catch { /* ignore */ }
|
||||
setPageLoading(false)
|
||||
}
|
||||
|
||||
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>加载中...</div>
|
||||
if (!user) return null
|
||||
|
||||
const grouped = {
|
||||
gold: allAchievements.filter(a => a.rarity === 'gold'),
|
||||
silver: allAchievements.filter(a => a.rarity === 'silver'),
|
||||
bronze: allAchievements.filter(a => a.rarity === 'bronze'),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title="成就徽章" showBack />
|
||||
|
||||
{/* 统计 */}
|
||||
<div className="card" style={{ marginTop: 16, display: 'flex', justifyContent: 'space-around', padding: '20px 16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>{unlockedIds.size}</p>
|
||||
<p style={{ fontSize: 13, color: '#86868B' }}>已获得</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>{allAchievements.length}</p>
|
||||
<p style={{ fontSize: 13, color: '#86868B' }}>总成就</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 28, fontWeight: 700, color: '#FF6B35' }}>
|
||||
{allAchievements.length > 0 ? Math.round((unlockedIds.size / allAchievements.length) * 100) : 0}%
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: '#86868B' }}>完成度</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(['gold', 'silver', 'bronze'] as const).map(rarity => {
|
||||
const config = RARITY_CONFIG[rarity]
|
||||
const items = grouped[rarity]
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div key={rarity}>
|
||||
<h3 style={{ fontSize: 15, color: '#86868B', padding: '20px 16px 8px' }}>
|
||||
{config.label}质徽章 ({items.length})
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10, margin: '0 16px' }}>
|
||||
{items.map(a => {
|
||||
const unlocked = unlockedIds.has(a.id)
|
||||
return (
|
||||
<div key={a.id} style={{
|
||||
borderRadius: 12, padding: 16, textAlign: 'center',
|
||||
background: unlocked ? config.bg : '#F5F5F7',
|
||||
border: `1.5px solid ${unlocked ? config.color + '44' : '#E5E5EA'}`,
|
||||
opacity: unlocked ? 1 : 0.5,
|
||||
transition: 'all 0.3s',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 22, margin: '0 auto 8px',
|
||||
background: unlocked ? `linear-gradient(135deg, ${config.color}, ${config.color}88)` : '#E5E5EA',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
}}>
|
||||
{unlocked ? '🏅' : '🔒'}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>{a.name}</p>
|
||||
<p style={{ fontSize: 11, color: '#86868B' }}>{unlocked ? a.condition : '???'}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ height: 80 }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AchievementsRoute() {
|
||||
return <AuthProvider><AchievementsPage /></AuthProvider>
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import { getItem, getStreak, getRecords, deleteItem, pauseItem, resumeItem, makeupRecord, CATEGORY_CONFIG, CYCLE_LABEL, type ICheckInItem, type ICheckInRecord } from '@/services/checkin'
|
||||
|
||||
function DetailPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
const [item, setItem] = useState<ICheckInItem | null>(null)
|
||||
const [streak, setStreak] = useState(0)
|
||||
const [records, setRecords] = useState<ICheckInRecord[]>([])
|
||||
const [pageLoading, setPageLoading] = useState(true)
|
||||
const [now] = useState(new Date())
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth() + 1)
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [itemData, streakData, recordsData] = await Promise.all([
|
||||
getItem(id),
|
||||
getStreak(id),
|
||||
getRecords({ itemId: id, start: year + '-' + String(month).padStart(2,'0') + '-01', end: year + '-' + String(month).padStart(2,'0') + '-31' }),
|
||||
])
|
||||
setItem(itemData)
|
||||
setStreak(streakData.streak)
|
||||
setRecords(recordsData)
|
||||
} catch { router.push('/') }
|
||||
setPageLoading(false)
|
||||
}, [id, year, month, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) { router.push('/login'); return }
|
||||
if (user) loadData()
|
||||
}, [user, loading, loadData, router])
|
||||
|
||||
const recordDates = new Set(records.map(r =>
|
||||
new Date(r.checkDate).toISOString().slice(0, 10)
|
||||
))
|
||||
|
||||
const makeupDates = new Set(
|
||||
records.filter(r => r.status === 'makeup').map(r =>
|
||||
new Date(r.checkDate).toISOString().slice(0, 10)
|
||||
)
|
||||
)
|
||||
|
||||
function getDaysInMonth(y: number, m: number) {
|
||||
return new Date(y, m, 0).getDate()
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(y: number, m: number) {
|
||||
return new Date(y, m - 1, 1).getDay()
|
||||
}
|
||||
|
||||
const todayStr = now.toISOString().slice(0, 10)
|
||||
async function handleMakeup(dateStr: string) {
|
||||
try {
|
||||
await makeupRecord(id, dateStr)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '补卡失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('确定删除这个打卡项?历史记录将保留。')) return
|
||||
await deleteItem(id)
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
async function handleTogglePause() {
|
||||
if (!item) return
|
||||
if (item.status === 'active') {
|
||||
await pauseItem(id)
|
||||
} else {
|
||||
await resumeItem(id)
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
|
||||
if (pageLoading || !item) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>加载中...</div>
|
||||
const cfg = CATEGORY_CONFIG[item.category] || CATEGORY_CONFIG.habit
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDay = getFirstDayOfMonth(year, month)
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title={item.name} 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 style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 14,
|
||||
background: cfg.color + '20', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26,
|
||||
}}>{item.icon || cfg.icon}</div>
|
||||
<div>
|
||||
<p style={{ fontSize: 18, fontWeight: 700 }}>{item.name}</p>
|
||||
<p style={{ fontSize: 13, color: '#86868B', marginTop: 2 }}>
|
||||
{cfg.label} · {CYCLE_LABEL[item.cycleType]} · {item.status === 'active' ? '进行中' : item.status === 'paused' ? '已暂停' : item.status}
|
||||
</p>
|
||||
{item.dailyGoal && <p style={{ fontSize: 13, color: '#86868B', marginTop: 2 }}>目标: {item.dailyGoal}</p>}
|
||||
</div>
|
||||
</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 }}>←</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 }}>→</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={{
|
||||
padding: '8px 0', fontSize: 14, borderRadius: 8, cursor: canMakeup ? 'pointer' : 'default',
|
||||
background: isToday ? '#FFF0EB' : 'transparent',
|
||||
fontWeight: isToday ? 600 : 400,
|
||||
}}>
|
||||
{day}
|
||||
{checked && <div style={{
|
||||
width: 6, height: 6, borderRadius: 3, margin: '2px auto 0',
|
||||
background: isMakeup ? '#FF9500' : '#34C759',
|
||||
}} />}
|
||||
{!checked && isToday && <div style={{
|
||||
width: 6, height: 6, borderRadius: 3, margin: '2px auto 0',
|
||||
background: '#E5E5EA',
|
||||
}} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 80 }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CheckInDetailRoute() {
|
||||
return <AuthProvider><DetailPage /></AuthProvider>
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import { createItem, CATEGORY_CONFIG, type CheckInCategory, type CycleType } from '@/services/checkin'
|
||||
|
||||
const CATEGORIES = Object.entries(CATEGORY_CONFIG) as [CheckInCategory, typeof CATEGORY_CONFIG[CheckInCategory]][]
|
||||
const CYCLE_OPTIONS: { value: CycleType; label: string }[] = [
|
||||
{ value: 'daily', label: '每日' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'every_other_day', label: '隔日' },
|
||||
{ value: 'custom', label: '自定义间隔' },
|
||||
]
|
||||
|
||||
function CreateCheckInPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState<CheckInCategory>('study')
|
||||
const [cycleType, setCycleType] = useState<CycleType>('daily')
|
||||
const [dailyGoal, setDailyGoal] = useState('')
|
||||
const [privacy, setPrivacy] = useState(2)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (loading) return null
|
||||
if (!user) { router.push('/login'); return null }
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) { setError('请输入打卡项名称'); return }
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
await createItem({
|
||||
name: name.trim(),
|
||||
category,
|
||||
cycleType,
|
||||
dailyGoal: dailyGoal.trim() || undefined,
|
||||
privacy,
|
||||
})
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title="创建打卡项" showBack />
|
||||
<form onSubmit={handleSubmit} style={{ padding: 16 }}>
|
||||
{/* 名称 */}
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}>名称 *</label>
|
||||
<input className="input-field" value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="如「每天背单词」" style={{ marginBottom: 20 }} maxLength={50} />
|
||||
|
||||
{/* 分类 */}
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 8, display: 'block' }}>分类</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, marginBottom: 20 }}>
|
||||
{CATEGORIES.map(([key, cfg]) => (
|
||||
<button type="button" key={key}
|
||||
onClick={() => setCategory(key)}
|
||||
style={{
|
||||
padding: '12px 8px', borderRadius: 10, border: '2px solid',
|
||||
borderColor: category === key ? cfg.color : '#E5E5EA',
|
||||
background: category === key ? cfg.color + '15' : '#f8f8fa',
|
||||
cursor: 'pointer', fontSize: 13, textAlign: 'center', transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{cfg.icon}</span>
|
||||
<p style={{ marginTop: 4, fontWeight: category === key ? 600 : 400 }}>{cfg.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 打卡周期 */}
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}>打卡周期</label>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{CYCLE_OPTIONS.map(opt => (
|
||||
<button type="button" key={opt.value}
|
||||
onClick={() => setCycleType(opt.value)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: '1.5px solid',
|
||||
borderColor: cycleType === opt.value ? '#FF6B35' : '#E5E5EA',
|
||||
background: cycleType === opt.value ? '#FFF0EB' : '#f8f8fa',
|
||||
cursor: 'pointer', fontSize: 14, transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 每日目标 */}
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}>每日目标(选填)</label>
|
||||
<input className="input-field" value={dailyGoal} onChange={e => setDailyGoal(e.target.value)}
|
||||
placeholder="如「背 50 个单词」" style={{ marginBottom: 20 }} maxLength={200} />
|
||||
|
||||
{/* 隐私 */}
|
||||
<label style={{ fontSize: 13, color: '#86868B', marginBottom: 6, display: 'block' }}>隐私设置</label>
|
||||
<select className="input-field" value={privacy} onChange={e => setPrivacy(Number(e.target.value))}
|
||||
style={{ marginBottom: 24 }}>
|
||||
<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>}
|
||||
|
||||
<button className="btn-primary" type="submit" disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建打卡项'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CreatePage() {
|
||||
return <AuthProvider><CreateCheckInPage /></AuthProvider>
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-primary: #FF6B35;
|
||||
--color-primary-light: #FF8C5A;
|
||||
--color-primary-gradient: linear-gradient(135deg, #FF6B35, #FF4D6D);
|
||||
--color-accent: #7209B7;
|
||||
--color-bg: #F5F5F7;
|
||||
--color-card: #FFFFFF;
|
||||
--color-text: #1D1D1F;
|
||||
--color-text-secondary: #86868B;
|
||||
--color-border: #E5E5EA;
|
||||
--color-success: #34C759;
|
||||
--color-warning: #FF9500;
|
||||
--color-error: #FF3B30;
|
||||
--radius-card: 12px;
|
||||
--radius-button: 10px;
|
||||
--font-base: -apple-system, BlinkMacSystemFont, 'SF Pro', 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-base);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* iOS 风格列表 */
|
||||
.ios-list {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
margin: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.ios-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ios-list-item:last-child { border-bottom: none; }
|
||||
.ios-list-item:hover { background: #f8f8fa; }
|
||||
|
||||
.ios-list-item-label {
|
||||
font-size: 15px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ios-list-item-value {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn-primary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--color-primary-gradient);
|
||||
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; }
|
||||
|
||||
/* 输入框 */
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: #f8f8fa;
|
||||
border: 1px solid var(--color-border);
|
||||
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);
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
padding: 20px 16px 8px;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0 16px 20px;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-card);
|
||||
margin: 8px 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '寸进 — 日日皆有成长',
|
||||
description: '寸进(InchStep)综合成长打卡平台',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
|
||||
function LoginForm() {
|
||||
const [phone, setPhone] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { login } = useAuth()
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (phone.length !== 11) {
|
||||
setError('请输入正确的 11 位手机号')
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await login(phone)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '登录失败'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: 32 }}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<div style={{
|
||||
width: 80, height: 80, borderRadius: 20,
|
||||
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 16px', fontSize: 36
|
||||
}}>
|
||||
📈
|
||||
</div>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, marginBottom: 4 }}>寸进</h1>
|
||||
<p style={{ fontSize: 15, color: '#86868B' }}>日日皆有成长</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
className="input-field"
|
||||
type="tel"
|
||||
placeholder="手机号"
|
||||
maxLength={11}
|
||||
value={phone}
|
||||
onChange={e => { setPhone(e.target.value.replace(/\D/g, '')); setError('') }}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p style={{ fontSize: 13, color: '#FF3B30', marginBottom: 12, textAlign: 'center' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button className="btn-primary" type="submit" disabled={isLoading}>
|
||||
{isLoading ? '登录中...' : '登录 / 注册'}
|
||||
</button>
|
||||
|
||||
<p style={{ fontSize: 13, color: '#86868B', textAlign: 'center', marginTop: 16 }}>
|
||||
未注册的手机号将自动创建账号
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import TabBar from '@/components/TabBar'
|
||||
import CheckInCard from '@/components/CheckInCard'
|
||||
import { listItems, getStreak, getTodayStatus, type ICheckInItem } from '@/services/checkin'
|
||||
|
||||
function Dashboard() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [items, setItems] = useState<ICheckInItem[]>([])
|
||||
const [streaks, setStreaks] = useState<Record<string, number>>({})
|
||||
const [todayStatus, setTodayStatus] = useState<Record<string, boolean>>({})
|
||||
const [pageLoading, setPageLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (user) loadItems()
|
||||
}, [user, loading, router])
|
||||
|
||||
async function loadItems() {
|
||||
try {
|
||||
const [itemList, status] = await Promise.all([listItems(), getTodayStatus()])
|
||||
setItems(itemList)
|
||||
setTodayStatus(status)
|
||||
const s: Record<string, number> = {}
|
||||
await Promise.all(itemList.map(async (item) => {
|
||||
try {
|
||||
const { streak } = await getStreak(item.id)
|
||||
s[item.id] = streak
|
||||
} catch { /* ignore */ }
|
||||
}))
|
||||
setStreaks(s)
|
||||
} catch { /* ignore */ }
|
||||
setPageLoading(false)
|
||||
}
|
||||
|
||||
const activeItems = items.filter(i => i.status === 'active')
|
||||
const pausedItems = items.filter(i => i.status === 'paused')
|
||||
|
||||
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>加载中...</div>
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title="寸进" showLogout />
|
||||
|
||||
<div className="card" style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 22,
|
||||
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: 'white', fontWeight: 600, flexShrink: 0,
|
||||
}}>
|
||||
{user.nickname?.[0] || user.phone?.slice(-4) || '?'}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 600 }}>{user.nickname || '用户'}</p>
|
||||
<p style={{ fontSize: 13, color: '#86868B' }}>积分 {user.points} · {activeItems.length} 个活跃项</p>
|
||||
</div>
|
||||
<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',
|
||||
}}>
|
||||
+ 创建
|
||||
</button>
|
||||
</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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Dashboard />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import TabBar from '@/components/TabBar'
|
||||
import { getProfile, updateProfile, type IUser } from '@/services/auth'
|
||||
|
||||
function ProfilePage() {
|
||||
const { user, loading, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const [profile, setProfile] = useState<IUser | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [nickname, setNickname] = useState('')
|
||||
const [bio, setBio] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (user) {
|
||||
getProfile().then(setProfile).catch(() => logout())
|
||||
}
|
||||
}, [user, loading, router, logout])
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await updateProfile({ nickname, bio } as Partial<IUser>)
|
||||
setProfile(updated)
|
||||
setEditing(false)
|
||||
} catch { /* ignore */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
setNickname(profile?.nickname || '')
|
||||
setBio(profile?.bio || '')
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
if (loading || !profile) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>加载中...</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title="个人主页" showBack />
|
||||
|
||||
{/* 头像与基本信息 */}
|
||||
<div className="card" style={{ marginTop: 16, textAlign: 'center', padding: '32px 16px' }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 36,
|
||||
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 12px', fontSize: 28, color: 'white', fontWeight: 600
|
||||
}}>
|
||||
{profile.nickname?.[0] || profile.phone?.slice(-4) || '?'}
|
||||
</div>
|
||||
<h2 style={{ fontSize: 22, fontWeight: 700 }}>{profile.nickname || '未设置昵称'}</h2>
|
||||
<p style={{ fontSize: 14, color: '#86868B', marginTop: 4 }}>{profile.bio || '还没有个性签名'}</p>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '8px 0' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>{profile.points}</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 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'
|
||||
}}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 100 }} />
|
||||
<TabBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileRoute() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ProfilePage />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import { listFriends, sendFriendRequest, removeFriend, type IUserBrief } from '@/services/social'
|
||||
|
||||
function FriendsPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [friends, setFriends] = useState<IUserBrief[]>([])
|
||||
const [pageLoading, setPageLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [addPhone, setAddPhone] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) { router.push('/login'); return }
|
||||
if (user) loadFriends()
|
||||
}, [user, loading, router])
|
||||
|
||||
async function loadFriends() {
|
||||
try {
|
||||
const list = await listFriends()
|
||||
setFriends(list)
|
||||
} catch { /* ignore */ }
|
||||
setPageLoading(false)
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!addPhone.trim()) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await sendFriendRequest(addPhone.trim())
|
||||
alert('好友请求已发送')
|
||||
setAddPhone('')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '添加失败')
|
||||
}
|
||||
setAdding(false)
|
||||
}
|
||||
|
||||
async function handleRemove(friendId: string) {
|
||||
if (!confirm('确定删除这位好友?')) return
|
||||
try {
|
||||
await removeFriend(friendId)
|
||||
setFriends(f => f.filter(ff => ff.id !== friendId))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? friends.filter(f => f.nickname?.includes(search))
|
||||
: friends
|
||||
|
||||
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>加载中...</div>
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title="好友" showBack />
|
||||
|
||||
{/* 添加好友 */}
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<p style={{ fontSize: 15, fontWeight: 600, marginBottom: 8 }}>添加好友</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input-field" value={addPhone} onChange={e => setAddPhone(e.target.value)}
|
||||
placeholder="输入手机号" style={{ flex: 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>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(f => (
|
||||
<div key={f.id} className="ios-list-item" style={{ margin: '0 16px', borderRadius: 12, background: 'white', padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18,
|
||||
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, color: 'white', fontWeight: 600,
|
||||
}}>
|
||||
{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>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div style={{ height: 80 }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FriendsRoute() {
|
||||
return <AuthProvider><FriendsPage /></AuthProvider>
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import TabBar from '@/components/TabBar'
|
||||
import FeedCard from '@/components/FeedCard'
|
||||
import { getFeed, createPost, type IPost } from '@/services/social'
|
||||
|
||||
function FeedPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [posts, setPosts] = useState<IPost[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [feedLoading, setFeedLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newContent, setNewContent] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) { router.push('/login'); return }
|
||||
if (user) loadFeed()
|
||||
}, [user, loading, page, router])
|
||||
|
||||
async function loadFeed() {
|
||||
try {
|
||||
const data = await getFeed(page, 20)
|
||||
if (page === 1) setPosts(data.list)
|
||||
else setPosts(p => [...p, ...data.list])
|
||||
setTotal(data.total)
|
||||
} catch { /* ignore */ }
|
||||
setFeedLoading(false)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newContent.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await createPost({ content: newContent.trim(), privacy: 1 })
|
||||
setNewContent('')
|
||||
setShowCreate(false)
|
||||
setPage(1)
|
||||
loadFeed()
|
||||
} catch { /* ignore */ }
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
if (loading) return null
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title="广场" />
|
||||
|
||||
{/* 发布动态入口 */}
|
||||
<div className="card" style={{ marginTop: 16, cursor: 'pointer' }} onClick={() => setShowCreate(true)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18,
|
||||
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, color: 'white', fontWeight: 600,
|
||||
}}>
|
||||
{user.nickname?.[0] || user.phone?.slice(-4) || '?'}
|
||||
</div>
|
||||
<span style={{ fontSize: 15, color: '#86868B' }}>分享你的打卡动态...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建动态弹层 */}
|
||||
{showCreate && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
|
||||
}} onClick={() => setShowCreate(false)}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 500, margin: 0, borderRadius: '16px 16px 0 0' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<p style={{ fontSize: 17, fontWeight: 600, textAlign: 'center', marginBottom: 12 }}>发布动态</p>
|
||||
<textarea ref={inputRef} value={newContent} onChange={e => setNewContent(e.target.value)}
|
||||
placeholder="分享你的打卡故事..." rows={4}
|
||||
style={{ width: '100%', border: '1px solid #E5E5EA', borderRadius: 10, padding: 12, fontSize: 15, resize: 'none', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
|
||||
<button onClick={() => { setShowCreate(false); setNewContent('') }}
|
||||
style={{ flex: 1, padding: 12, background: '#f8f8fa', border: '1px solid #E5E5EA', borderRadius: 10, fontSize: 15, cursor: 'pointer' }}>
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleCreate} disabled={creating || !newContent.trim()}
|
||||
className="btn-primary" style={{ flex: 1, padding: 12 }}>
|
||||
{creating ? '发布中...' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed 列表 */}
|
||||
{feedLoading ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>加载中...</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}>
|
||||
<p style={{ fontSize: 40, marginBottom: 12 }}>🌊</p>
|
||||
<p style={{ fontSize: 15, color: '#86868B' }}>还没有动态,快来发布第一条吧</p>
|
||||
</div>
|
||||
) : (
|
||||
posts.map(post => (
|
||||
<FeedCard key={post.id} post={post} onUpdate={loadFeed} />
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 加载更多 */}
|
||||
{posts.length < total && (
|
||||
<button onClick={() => setPage(p => p + 1)} style={{
|
||||
display: 'block', margin: '16px auto', padding: '10px 24px',
|
||||
background: 'white', border: '1px solid #E5E5EA', borderRadius: 8,
|
||||
fontSize: 14, color: '#86868B', cursor: 'pointer',
|
||||
}}>
|
||||
加载更多
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ height: 100 }} />
|
||||
<TabBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SocialRoute() {
|
||||
return <AuthProvider><FeedPage /></AuthProvider>
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AuthProvider, useAuth } from '@/components/AuthProvider'
|
||||
import NavBar from '@/components/NavBar'
|
||||
import TabBar from '@/components/TabBar'
|
||||
import ProgressRing from '@/components/ProgressRing'
|
||||
import { getDashboardStats, type DashboardStats } from '@/services/analytics'
|
||||
|
||||
function StatsPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const now = new Date()
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth() + 1)
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
const [pageLoading, setPageLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) { router.push('/login'); return }
|
||||
if (user) loadData()
|
||||
}, [user, loading, year, month, router])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const stats = await getDashboardStats(year, month)
|
||||
setData(stats)
|
||||
} catch { /* ignore */ }
|
||||
setPageLoading(false)
|
||||
}
|
||||
|
||||
if (loading || pageLoading) return <div style={{ padding: 40, textAlign: 'center', color: '#86868B' }}>加载中...</div>
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar title="统计" />
|
||||
|
||||
{/* 月份切换 */}
|
||||
<div className="card" style={{ marginTop: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<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: 8 }}>←</button>
|
||||
<span style={{ fontSize: 17, 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: 8 }}>→</button>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* 概览卡片 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, margin: '8px 16px' }}>
|
||||
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
|
||||
<ProgressRing percent={data.summary.completionRate} size={90} label="完成率" />
|
||||
</div>
|
||||
<div className="card" style={{ margin: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 12 }}>
|
||||
<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 style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>{data.summary.totalItems}</p>
|
||||
<p style={{ fontSize: 12, color: '#86868B' }}>活跃项</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最高连续天数 */}
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '16px 20px' }}>
|
||||
<span style={{ fontSize: 32 }}>🔥</span>
|
||||
<div>
|
||||
<p style={{ fontSize: 24, fontWeight: 700, color: '#FF6B35' }}>{data.summary.maxStreak} 天</p>
|
||||
<p style={{ fontSize: 13, color: '#86868B' }}>最高连续打卡</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类分布 */}
|
||||
<h3 style={{ fontSize: 15, color: '#86868B', padding: '20px 16px 8px' }}>分类分布</h3>
|
||||
<div className="card" style={{ padding: '12px 16px' }}>
|
||||
{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: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 14 }}>{c.label}</span>
|
||||
<span style={{ fontSize: 13, color: '#86868B' }}>{c.count} 项 · {pct}%</span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 3, background: '#F0F0F0', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 3, background: c.color, width: pct + '%', transition: 'width 0.5s' }} />
|
||||
</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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatsRoute() {
|
||||
return <AuthProvider><StatsPage /></AuthProvider>
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { getStoredUser, clearToken } from '@/services/api'
|
||||
import { login as apiLogin, register as apiRegister, logout as apiLogout, type IUser } from '@/services/auth'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface AuthContextType {
|
||||
user: IUser | null
|
||||
loading: boolean
|
||||
login: (phone: string) => Promise<void>
|
||||
register: (phone: string) => Promise<void>
|
||||
logout: () => void
|
||||
refreshUser: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<IUser | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredUser<IUser>()
|
||||
if (stored) {
|
||||
setUser(stored)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (phone: string) => {
|
||||
const u = await apiLogin(phone)
|
||||
setUser(u)
|
||||
router.push('/')
|
||||
}, [router])
|
||||
|
||||
const register = useCallback(async (phone: string) => {
|
||||
const u = await apiRegister(phone)
|
||||
setUser(u)
|
||||
router.push('/')
|
||||
}, [router])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
apiLogout()
|
||||
setUser(null)
|
||||
router.push('/login')
|
||||
}, [router])
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const { getProfile } = await import('@/services/auth')
|
||||
const u = await getProfile()
|
||||
setUser(u)
|
||||
} catch {
|
||||
clearToken()
|
||||
setUser(null)
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { CATEGORY_CONFIG, CYCLE_LABEL, type ICheckInItem } from '@/services/checkin'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { createRecord } from '@/services/checkin'
|
||||
|
||||
interface CheckInCardProps {
|
||||
isDone?: boolean
|
||||
item: ICheckInItem
|
||||
onCheckIn?: (itemId: string) => void
|
||||
streak?: number
|
||||
}
|
||||
|
||||
export default function CheckInCard({ item, onCheckIn, streak, isDone }: CheckInCardProps) {
|
||||
const router = useRouter()
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [done, setDone] = useState(isDone || false)
|
||||
const cfg = CATEGORY_CONFIG[item.category] || CATEGORY_CONFIG.habit
|
||||
|
||||
async function handleCheckIn(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
if (checking || done) return
|
||||
setChecking(true)
|
||||
try {
|
||||
await createRecord(item.id)
|
||||
setDone(true)
|
||||
onCheckIn?.(item.id)
|
||||
} catch {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
onClick={() => router.push(`/checkin/${item.id}`)}
|
||||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px' }}
|
||||
>
|
||||
{/* 分类图标 */}
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12,
|
||||
background: cfg.color + '20',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, flexShrink: 0,
|
||||
}}>
|
||||
{item.icon || cfg.icon}
|
||||
</div>
|
||||
|
||||
{/* 名称与信息 */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 600, marginBottom: 2 }}>{item.name}</p>
|
||||
<p style={{ fontSize: 13, color: '#86868B', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span>{cfg.label}</span>
|
||||
<span>·</span>
|
||||
<span>{CYCLE_LABEL[item.cycleType] || item.cycleType}</span>
|
||||
{streak !== undefined && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span style={{ color: '#FF6B35' }}>🔥 {streak} 天</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 打卡按钮 */}
|
||||
<button
|
||||
onClick={handleCheckIn}
|
||||
disabled={checking || done}
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: 22, border: 'none', cursor: done ? 'default' : 'pointer',
|
||||
background: done
|
||||
? 'linear-gradient(135deg, #34C759, #28A745)'
|
||||
: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
|
||||
color: 'white', fontSize: 18, fontWeight: 600, flexShrink: 0,
|
||||
transition: 'transform 0.2s, opacity 0.2s',
|
||||
opacity: checking ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{done ? '✓' : checking ? '...' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { likePost, commentPost, type IPost } from '@/services/social'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface FeedCardProps {
|
||||
post: IPost
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export default function FeedCard({ post, onUpdate }: FeedCardProps) {
|
||||
const [liking, setLiking] = useState(false)
|
||||
const [likes, setLikes] = useState(post.likes)
|
||||
const [comments, setComments] = useState(post.comments)
|
||||
const [showComment, setShowComment] = useState(false)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
|
||||
const createdAt = new Date(post.createdAt)
|
||||
const timeStr = `${createdAt.getMonth()+1}月${createdAt.getDate()}日`
|
||||
|
||||
async function handleLike() {
|
||||
if (liking) return
|
||||
setLiking(true)
|
||||
try {
|
||||
await likePost(post.id)
|
||||
setLikes(l => l + 1)
|
||||
} catch { /* ignore */ }
|
||||
setLiking(false)
|
||||
}
|
||||
|
||||
async function handleComment() {
|
||||
if (!commentText.trim()) return
|
||||
try {
|
||||
await commentPost(post.id)
|
||||
setComments(c => c + 1)
|
||||
setCommentText('')
|
||||
setShowComment(false)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 解析图片数组
|
||||
let images: string[] = []
|
||||
try { if (post.images) images = JSON.parse(post.images) } catch { images = [] }
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
{/* 用户信息 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 18,
|
||||
background: 'linear-gradient(135deg, #FF6B35, #FF4D6D)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, color: 'white', fontWeight: 600, flexShrink: 0,
|
||||
}}>
|
||||
{post.user?.nickname?.[0] || '?'}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontSize: 14, fontWeight: 600 }}>{post.user?.nickname || '用户'}</p>
|
||||
<p style={{ fontSize: 12, color: '#86868B' }}>{timeStr}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
{post.content && (
|
||||
<p style={{ fontSize: 15, lineHeight: 1.5, marginBottom: images.length > 0 ? 10 : 0, whiteSpace: 'pre-wrap' }}>
|
||||
{post.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
{images.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: images.length === 1 ? '1fr' : `repeat(${Math.min(images.length, 3)}, 1fr)`, gap: 4, marginBottom: 10 }}>
|
||||
{images.slice(0, 3).map((url, i) => (
|
||||
<div key={i} style={{
|
||||
aspectRatio: '1', borderRadius: 8, background: '#F0F0F0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 12, color: '#86868B', overflow: 'hidden',
|
||||
}}>
|
||||
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 互动按钮 */}
|
||||
<div style={{ display: 'flex', gap: 20, paddingTop: 8, borderTop: '0.5px solid #F0F0F0' }}>
|
||||
<button onClick={handleLike} disabled={liking}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#86868B', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{likes > 0 ? '❤️' : '🤍'} {likes}
|
||||
</button>
|
||||
<button onClick={() => setShowComment(!showComment)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#86868B', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
💬 {comments}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 评论输入 */}
|
||||
{showComment && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<input className="input-field" value={commentText} onChange={e => setCommentText(e.target.value)}
|
||||
placeholder="说点什么..." style={{ flex: 1, fontSize: 14, padding: '8px 12px' }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleComment()} />
|
||||
<button onClick={handleComment} disabled={!commentText.trim()}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: 'none',
|
||||
background: commentText.trim() ? '#FF6B35' : '#E5E5EA',
|
||||
color: 'white', fontSize: 14, cursor: commentText.trim() ? 'pointer' : 'default',
|
||||
}}>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from './AuthProvider'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NavBarProps {
|
||||
title: string
|
||||
showBack?: boolean
|
||||
showLogout?: boolean
|
||||
}
|
||||
|
||||
export default function NavBar({ title, showBack = false, showLogout = false }: NavBarProps) {
|
||||
const { logout, user } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
background: 'rgba(245,245,247,0.92)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
borderBottom: '0.5px solid #E5E5EA',
|
||||
padding: '12px 16px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 60 }}>
|
||||
{showBack && (
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
{showLogout && user && (
|
||||
<button
|
||||
onClick={() => router.push('/profile')}
|
||||
style={{ background: 'none', border: 'none', fontSize: 15, cursor: 'pointer', color: '#FF6B35', padding: 4 }}
|
||||
>
|
||||
我的
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 17, fontWeight: 600, flex: 1, textAlign: 'center' }}>{title}</span>
|
||||
|
||||
<div style={{ minWidth: 60, textAlign: 'right' }}>
|
||||
{showLogout && (
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{ background: 'none', border: 'none', fontSize: 15, cursor: 'pointer', color: '#86868B', padding: 4 }}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
interface ProgressRingProps {
|
||||
percent: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export default function ProgressRing({ percent, size = 80, strokeWidth = 6, color = '#FF6B35', label }: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (Math.min(percent, 100) / 100) * circumference
|
||||
const center = size / 2
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<svg width={size} height={size}>
|
||||
{/* 背景圆环 */}
|
||||
<circle cx={center} cy={center} r={radius}
|
||||
fill="none" stroke="#F0F0F0" strokeWidth={strokeWidth} />
|
||||
{/* 进度圆环 */}
|
||||
<circle cx={center} cy={center} r={radius}
|
||||
fill="none" stroke={color} strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
style={{ transition: 'stroke-dashoffset 0.6s ease-out' }}
|
||||
/>
|
||||
<text x={center} y={center} textAnchor="middle" dominantBaseline="central"
|
||||
fontSize={size * 0.28} fontWeight={700} fill="#1D1D1F">
|
||||
{percent}%
|
||||
</text>
|
||||
</svg>
|
||||
{label && <span style={{ fontSize: 12, color: '#86868B' }}>{label}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
const TABS = [
|
||||
{ path: '/', label: '打卡', icon: '📋' },
|
||||
{ path: '/social', label: '广场', icon: '🌊' },
|
||||
{ path: '/stats', label: '统计', icon: '📊' },
|
||||
{ path: '/profile', label: '我的', icon: '👤' },
|
||||
]
|
||||
|
||||
export default function TabBar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
if (pathname === '/login') return null
|
||||
|
||||
function isActive(tabPath: string) {
|
||||
if (tabPath === '/') return pathname === '/' || pathname.startsWith('/checkin')
|
||||
return pathname.startsWith(tabPath)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100,
|
||||
background: 'rgba(245,245,247,0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
borderTop: '0.5px solid #E5E5EA',
|
||||
display: 'flex', justifyContent: 'space-around',
|
||||
padding: '6px 0 20px',
|
||||
}}>
|
||||
{TABS.map(tab => {
|
||||
const active = isActive(tab.path)
|
||||
return (
|
||||
<button key={tab.path} onClick={() => router.push(tab.path)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
||||
fontSize: 10, color: active ? '#FF6B35' : '#86868B', fontWeight: active ? 600 : 400,
|
||||
transition: 'color 0.2s',
|
||||
}}>
|
||||
<span style={{ fontSize: 22 }}>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { api } from './api'
|
||||
|
||||
export interface IAchievement {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
icon?: string
|
||||
rarity: 'bronze' | 'silver' | 'gold'
|
||||
condition?: string
|
||||
}
|
||||
|
||||
export interface IUserAchievement {
|
||||
userID: string
|
||||
achievementID: string
|
||||
unlockedAt: string
|
||||
Achievement?: IAchievement
|
||||
}
|
||||
|
||||
/** 获取所有成就定义 */
|
||||
export async function getAllAchievements(): Promise<IAchievement[]> {
|
||||
return api.get<IAchievement[]>('/api/achievements')
|
||||
}
|
||||
|
||||
/** 获取用户已解锁的成就 */
|
||||
export async function getUserAchievements(): Promise<IUserAchievement[]> {
|
||||
return api.get<IUserAchievement[]>('/api/achievements/mine')
|
||||
}
|
||||
|
||||
/** 稀有度配置 */
|
||||
export const RARITY_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
bronze: { label: '铜', color: '#CD7F32', bg: '#FFF5ED', border: '#CD7F3233' },
|
||||
silver: { label: '银', color: '#A8A8A8', bg: '#F5F5F7', border: '#A8A8A833' },
|
||||
gold: { label: '金', color: '#FFD700', bg: '#FFFDF0', border: '#FFD70044' },
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { api } from './api'
|
||||
import { listItems, getStreak, getRecords, CATEGORY_CONFIG, type CheckInCategory } from './checkin'
|
||||
|
||||
export interface MonthlyStats {
|
||||
totalItems: number
|
||||
totalRecords: number
|
||||
byCategory: { Category: string; Count: number }[]
|
||||
}
|
||||
|
||||
/** 获取按月统计数据 */
|
||||
export async function getMonthlyStats(year: number, month: number): Promise<MonthlyStats> {
|
||||
return api.get<MonthlyStats>(`/api/checkin/stats/monthly?year=${year}&month=${month}`)
|
||||
}
|
||||
|
||||
/** 获取完整统计看板数据 */
|
||||
export async function getDashboardStats(year: number, month: number) {
|
||||
const [items, monthlyStats] = await Promise.all([
|
||||
listItems(),
|
||||
getMonthlyStats(year, month),
|
||||
])
|
||||
|
||||
const activeItems = items.filter(i => i.status === 'active')
|
||||
|
||||
// 获取每项的连续天数和月度记录
|
||||
const itemStats = await Promise.all(
|
||||
activeItems.map(async (item) => {
|
||||
try {
|
||||
const [streakData, records] = await Promise.all([
|
||||
getStreak(item.id),
|
||||
getRecords({
|
||||
itemId: item.id,
|
||||
start: `${year}-${String(month).padStart(2, '0')}-01`,
|
||||
end: `${year}-${String(month).padStart(2, '0')}-31`,
|
||||
}),
|
||||
])
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
icon: item.icon,
|
||||
category: item.category,
|
||||
streak: streakData.streak,
|
||||
monthlyCount: records.length,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
const totalPossible = activeItems.length * daysInMonth
|
||||
|
||||
// 按分类汇总
|
||||
const categoryMap = new Map<string, number>()
|
||||
for (const item of activeItems) {
|
||||
const count = categoryMap.get(item.category) || 0
|
||||
categoryMap.set(item.category, count + 1)
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
daysInMonth,
|
||||
summary: {
|
||||
totalItems: activeItems.length,
|
||||
totalRecords: monthlyStats.totalRecords,
|
||||
completionRate: totalPossible > 0 ? Math.round((monthlyStats.totalRecords / totalPossible) * 100) : 0,
|
||||
maxStreak: Math.max(...itemStats.filter(Boolean).map(s => s!.streak), 0),
|
||||
},
|
||||
byCategory: monthlyStats.byCategory.map(c => ({
|
||||
category: c.Category as CheckInCategory,
|
||||
count: c.Count,
|
||||
label: CATEGORY_CONFIG[c.Category as CheckInCategory]?.label || c.Category,
|
||||
color: CATEGORY_CONFIG[c.Category as CheckInCategory]?.color || '#86868B',
|
||||
})),
|
||||
items: itemStats.filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
export type DashboardStats = Awaited<ReturnType<typeof getDashboardStats>>
|
||||
@@ -0,0 +1,83 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
code: number
|
||||
constructor(code: number, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem('token')
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('token', token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
export function getStoredUser<T>(): T | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const raw = localStorage.getItem('user')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}
|
||||
|
||||
export function setStoredUser<T>(user: T) {
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
const body: ApiResponse<T> = await res.json()
|
||||
|
||||
if (body.code !== 0) {
|
||||
if (body.code === 401) {
|
||||
clearToken()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
throw new ApiError(body.code, body.message)
|
||||
}
|
||||
|
||||
return body.data as T
|
||||
}
|
||||
|
||||
// HTTP method helpers
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
|
||||
put: <T>(path: string, data?: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { api, setToken, setStoredUser, clearToken } from './api'
|
||||
|
||||
export interface IUser {
|
||||
id: string
|
||||
phone?: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
bio?: string
|
||||
motto?: string
|
||||
privacy: number
|
||||
points: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface AuthResult {
|
||||
user: IUser
|
||||
token: string
|
||||
}
|
||||
|
||||
export async function login(phone: string): Promise<IUser> {
|
||||
const result = await api.post<AuthResult>('/api/auth/login', { phone })
|
||||
setToken(result.token)
|
||||
setStoredUser(result.user)
|
||||
return result.user
|
||||
}
|
||||
|
||||
export async function register(phone: string): Promise<IUser> {
|
||||
const result = await api.post<AuthResult>('/api/auth/register', { phone })
|
||||
setToken(result.token)
|
||||
setStoredUser(result.user)
|
||||
return result.user
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<IUser> {
|
||||
return api.get<IUser>('/api/users/profile')
|
||||
}
|
||||
|
||||
export async function updateProfile(data: Partial<IUser>): Promise<IUser> {
|
||||
return api.put<IUser>('/api/users/profile', data)
|
||||
}
|
||||
|
||||
export async function deleteAccount(): Promise<void> {
|
||||
await api.delete('/api/users/account')
|
||||
clearToken()
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { api } from './api'
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
export type CheckInCategory = 'study' | 'health' | 'habit' | 'hobby' | 'goal'
|
||||
export type CycleType = 'daily' | 'weekly' | 'every_other_day' | 'custom'
|
||||
export type ItemStatus = 'active' | 'paused' | 'archived' | 'deleted'
|
||||
|
||||
export interface ICheckInItem {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
icon?: string
|
||||
category: CheckInCategory
|
||||
dailyGoal?: string
|
||||
cycleType: CycleType
|
||||
privacy: number
|
||||
status: ItemStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ICheckInRecord {
|
||||
id: string
|
||||
itemId: string
|
||||
userId: string
|
||||
checkDate: string
|
||||
status: 'normal' | 'makeup'
|
||||
note?: string
|
||||
images?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ========== 打卡项 API ==========
|
||||
export async function createItem(data: Partial<ICheckInItem>): Promise<ICheckInItem> {
|
||||
return api.post<ICheckInItem>('/api/checkin/items', {
|
||||
name: data.name,
|
||||
icon: data.icon || undefined,
|
||||
category: data.category,
|
||||
dailyGoal: data.dailyGoal || undefined,
|
||||
cycleType: data.cycleType || 'daily',
|
||||
privacy: data.privacy ?? 2,
|
||||
})
|
||||
}
|
||||
|
||||
export async function listItems(): Promise<ICheckInItem[]> {
|
||||
return api.get<ICheckInItem[]>('/api/checkin/items')
|
||||
}
|
||||
|
||||
export async function getItem(id: string): Promise<ICheckInItem> {
|
||||
return api.get<ICheckInItem>(`/api/checkin/items/${id}`)
|
||||
}
|
||||
|
||||
export async function updateItem(id: string, data: Partial<ICheckInItem>): Promise<ICheckInItem> {
|
||||
return api.put<ICheckInItem>(`/api/checkin/items/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteItem(id: string): Promise<void> {
|
||||
return api.delete(`/api/checkin/items/${id}`)
|
||||
}
|
||||
|
||||
export async function pauseItem(id: string): Promise<void> {
|
||||
return api.post(`/api/checkin/items/${id}/pause`)
|
||||
}
|
||||
|
||||
export async function resumeItem(id: string): Promise<void> {
|
||||
return api.post(`/api/checkin/items/${id}/resume`)
|
||||
}
|
||||
|
||||
// ========== 打卡记录 API ==========
|
||||
export async function createRecord(itemId: string, note?: string): Promise<ICheckInRecord> {
|
||||
return api.post<ICheckInRecord>('/api/checkin/records', { itemId, note })
|
||||
}
|
||||
|
||||
export async function makeupRecord(itemId: string, checkDate: string): Promise<ICheckInRecord> {
|
||||
return api.post<ICheckInRecord>('/api/checkin/records/makeup', { itemId, checkDate })
|
||||
}
|
||||
|
||||
export async function getRecords(params: { itemId?: string; start?: string; end?: string }) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.itemId) qs.set('itemId', params.itemId)
|
||||
if (params.start) qs.set('start', params.start)
|
||||
if (params.end) qs.set('end', params.end)
|
||||
return api.get<ICheckInRecord[]>(`/api/checkin/records?${qs}`)
|
||||
}
|
||||
|
||||
// ========== 统计 API ==========
|
||||
export async function getStreak(itemId: string): Promise<{ itemId: string; streak: number }> {
|
||||
return api.get(`/api/checkin/streak/${itemId}`)
|
||||
}
|
||||
|
||||
export async function getMonthlyStats(year: number, month: number) {
|
||||
return api.get(`/api/checkin/stats/monthly?year=${year}&month=${month}`)
|
||||
}
|
||||
|
||||
// ========== 分类配置 ==========
|
||||
export const CATEGORY_CONFIG: Record<CheckInCategory, { label: string; icon: string; color: string }> = {
|
||||
study: { label: '学习成长', icon: '📚', color: '#4CC9F0' },
|
||||
health: { label: '健康生活', icon: '❤️', color: '#06D6A0' },
|
||||
habit: { label: '习惯自律', icon: '🔄', color: '#7209B7' },
|
||||
hobby: { label: '兴趣爱好', icon: '⭐', color: '#F72585' },
|
||||
goal: { label: '目标任务', icon: '🎯', color: '#FF6B35' },
|
||||
}
|
||||
|
||||
export const CYCLE_LABEL: Record<string, string> = {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
every_other_day: '隔日',
|
||||
custom: '自定义',
|
||||
}
|
||||
|
||||
// ========== 今日状态 ==========
|
||||
/** 获取用户今天已打卡的打卡项 ID 列表 */
|
||||
export async function getTodayStatus(): Promise<Record<string, boolean>> {
|
||||
return api.get<Record<string, boolean>>('/api/checkin/today-status')
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { api } from './api'
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
export interface IPost {
|
||||
id: string
|
||||
userId: string
|
||||
content?: string
|
||||
images?: string
|
||||
privacy: number
|
||||
likes: number
|
||||
comments: number
|
||||
createdAt: string
|
||||
user?: { nickname?: string; avatar?: string }
|
||||
}
|
||||
|
||||
export interface IUserBrief {
|
||||
id: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
// ========== 动态 API ==========
|
||||
export async function createPost(data: { content?: string; images?: string; privacy?: number }): Promise<IPost> {
|
||||
return api.post<IPost>('/api/social/posts', data)
|
||||
}
|
||||
|
||||
export async function getFeed(page = 1, pageSize = 20): Promise<{ list: IPost[]; total: number; page: number }> {
|
||||
return api.get(`/api/social/feed?page=${page}&pageSize=${pageSize}`)
|
||||
}
|
||||
|
||||
export async function likePost(postId: string): Promise<void> {
|
||||
return api.post(`/api/social/posts/${postId}/like`)
|
||||
}
|
||||
|
||||
export async function commentPost(postId: string): Promise<void> {
|
||||
return api.post(`/api/social/posts/${postId}/comment`)
|
||||
}
|
||||
|
||||
// ========== 好友 API ==========
|
||||
export async function sendFriendRequest(friendId: string): Promise<void> {
|
||||
return api.post('/api/social/friends/request', { friendId })
|
||||
}
|
||||
|
||||
export async function acceptFriendRequest(friendId: string): Promise<void> {
|
||||
return api.post('/api/social/friends/accept', { friendId })
|
||||
}
|
||||
|
||||
export async function removeFriend(userId: string): Promise<void> {
|
||||
return api.delete(`/api/social/friends/${userId}`)
|
||||
}
|
||||
|
||||
export async function listFriends(): Promise<IUserBrief[]> {
|
||||
return api.get<IUserBrief[]>('/api/social/friends')
|
||||
}
|
||||
|
||||
// ========== 小组 API ==========
|
||||
export interface IGroup {
|
||||
id: string
|
||||
name: string
|
||||
cover?: string
|
||||
intro?: string
|
||||
type: string
|
||||
maxMember: number
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface IGroupMember {
|
||||
groupId: string
|
||||
userId: string
|
||||
role: string
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
export async function createGroup(data: Partial<IGroup>): Promise<IGroup> {
|
||||
return api.post<IGroup>('/api/social/groups', data)
|
||||
}
|
||||
|
||||
export async function joinGroup(groupId: string): Promise<void> {
|
||||
return api.post(`/api/social/groups/${groupId}/join`)
|
||||
}
|
||||
|
||||
export async function leaveGroup(groupId: string): Promise<void> {
|
||||
return api.post(`/api/social/groups/${groupId}/leave`)
|
||||
}
|
||||
|
||||
export async function getGroupMembers(groupId: string): Promise<IGroupMember[]> {
|
||||
return api.get(`/api/social/groups/${groupId}/members`)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user