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

This commit is contained in:
zeronline
2026-06-25 16:33:32 +08:00
parent c5cccd346b
commit 545329ac40
63 changed files with 2560 additions and 161 deletions
+153
View File
@@ -0,0 +1,153 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ────── Backend (Go) ──────
server:
name: Server (Go)
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache-dependency-path: server/go.sum
- run: go vet ./...
name: Lint (go vet)
- run: go build ./cmd/api
name: Build
- run: go test ./... -v -count=1
name: Test
# ────── Web (Next.js) ──────
web:
name: Web (Next.js)
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
name: Install
- run: npx tsc --noEmit
name: Type Check
- run: npx next lint --max-warnings 0
name: Lint
continue-on-error: true
- run: npx vitest run
name: Test
- run: pnpm build
name: Build
continue-on-error: true
# ────── Mobile (React Native / Expo) ──────
mobile:
name: Mobile (React Native)
runs-on: ubuntu-latest
defaults:
run:
working-directory: mobile
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: mobile/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
name: Install
- run: npx tsc --noEmit
name: Type Check
# ────── Admin (React / Vite) ──────
admin:
name: Admin (React)
runs-on: ubuntu-latest
defaults:
run:
working-directory: admin
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: admin/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
name: Install
- run: npx tsc --noEmit
name: Type Check
- run: pnpm build
name: Build
# ────── Docker Build ──────
docker:
name: Docker Build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: server
push: true
tags: ghcr.io/${{ github.repository }}/server:${{ github.ref_name }}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_103557fafffeQib0Wox9LWZ7k9",
"updatedAt": "2026-06-25T02:52:46.470Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:52:46.470Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_103558056ffejsS3rPnXRUAE6V",
"updatedAt": "2026-06-25T02:44:52.941Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:44:52.941Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1035580f3ffePn5t0JxA2KjXgV",
"updatedAt": "2026-06-25T02:44:52.774Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:44:52.774Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_10355814affeCElMn2toKaD9N0",
"updatedAt": "2026-06-25T02:44:52.617Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:44:52.617Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_10355f0b6ffejofcFB9I9Gybi4",
"updatedAt": "2026-06-25T02:52:46.144Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:52:46.144Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_10355f162ffe30mZHJ5Pw6sVFU",
"updatedAt": "2026-06-25T02:44:24.005Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:44:24.005Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_10355f1c7ffe5aDy2fdVkuqPcm",
"updatedAt": "2026-06-25T02:44:23.835Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:44:23.835Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_10355f210ffeQ6TZEPP0EpL90M",
"updatedAt": "2026-06-25T02:44:23.733Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:44:23.733Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1035665d3ffem4kPN1d3ul25EP",
"updatedAt": "2026-06-25T02:52:45.756Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:52:45.756Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_103566696ffeBfvadJFA98MT63",
"updatedAt": "2026-06-25T02:43:54.023Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:43:54.023Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_103566796ffeWpg4oRQh74vS8V",
"updatedAt": "2026-06-25T02:43:53.830Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:43:53.830Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1035667feffeWIz65ukioq6hbi",
"updatedAt": "2026-06-25T02:43:53.574Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T02:43:53.574Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_103adede7ffehGLMkuulGyM24q",
"updatedAt": "2026-06-25T01:08:22.587Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T01:08:22.587Z"
}
}
}
@@ -0,0 +1,10 @@
{
"sessionID": "ses_103ae139effevdfqnO9IYEbcP8",
"updatedAt": "2026-06-25T03:48:19.763Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-25T03:48:19.763Z"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
describe('Admin API placeholder', () => {
it('should have api module', async () => {
const mod = await import('../services/api')
expect(mod.api).toBeDefined()
expect(typeof mod.api.get).toBe('function')
expect(typeof mod.api.post).toBe('function')
})
})
+69
View File
@@ -0,0 +1,69 @@
import React from 'react'
interface Column<T> {
key: string
label: string
render?: (row: T) => React.ReactNode
width?: string | number
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
loading?: boolean
emptyText?: string
onRowClick?: (row: T) => void
}
export default function DataTable<T extends Record<string, any>>({
columns,
data,
loading = false,
emptyText = '暂无数据',
onRowClick,
}: DataTableProps<T>) {
return (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} style={{ width: col.width }}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>
...
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>
{emptyText}
</td>
</tr>
) : (
data.map((row, i) => (
<tr
key={row.id || i}
onClick={() => onRowClick?.(row)}
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
>
{columns.map((col) => (
<td key={col.key}>
{col.render ? col.render(row) : row[col.key] ?? '--'}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react'
interface PaginationProps {
page: number
totalPages: number
onChange: (page: number) => void
total?: number
}
export default function Pagination({ page, totalPages, onChange, total }: PaginationProps) {
const visiblePages = Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const start = Math.max(1, Math.min(page - 3, totalPages - 6))
return i + Math.max(1, Math.min(page - 3, totalPages - 6))
}).filter((p) => p <= totalPages && p >= 1)
return (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
<button
className="btn btn-secondary"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
</button>
{visiblePages.map((p) => (
<button
key={p}
className={'btn ' + (p === page ? 'btn-primary' : 'btn-secondary')}
style={{ padding: '6px 12px', minWidth: 36 }}
onClick={() => onChange(p)}
>
{p}
</button>
))}
<button
className="btn btn-secondary"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
</button>
{total !== undefined && (
<span style={{ fontSize: 13, color: '#86868B' }}>
{total} · {page}/{totalPages}
</span>
)}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
interface StatCardProps {
title: string
value: number | string
icon?: string
color?: string
onClick?: () => void
subtitle?: string
}
export default function StatCard({ title, value, icon, color = '#FF6B35', onClick, subtitle }: StatCardProps) {
return (
<div
className="stat-card"
onClick={onClick}
style={{
background: 'white',
borderRadius: 12,
padding: 20,
cursor: onClick ? 'pointer' : 'default',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{icon && <span style={{ fontSize: 24 }}>{icon}</span>}
<span style={{ fontSize: 13, color: '#86868B' }}>{title}</span>
</div>
<span style={{ fontSize: 32, fontWeight: 700, color }}>{value}</span>
{subtitle && <span style={{ fontSize: 12, color: '#86868B' }}>{subtitle}</span>}
</div>
)
}
+7 -21
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import { useNavigate } from 'react-router-dom'
import StatCard from '../components/StatCard'
export default function Dashboard() {
const [stats, setStats] = useState<any>(null)
@@ -37,27 +38,12 @@ export default function Dashboard() {
<h1 className='page-title'></h1>
{/* 统计卡片 */}
<div className='stat-grid'>
<div className='stat-card' onClick={() => navigate('/users')} style={{ cursor: 'pointer' }}>
<div className='stat-value'>{stats.totalUsers}</div>
<div className='stat-label'></div>
</div>
<div className='stat-card' onClick={() => navigate('/users')} style={{ cursor: 'pointer' }}>
<div className='stat-value'>{stats.newUsersToday}</div>
<div className='stat-label'></div>
</div>
<div className='stat-card'>
<div className='stat-value'>{stats.totalCheckins}</div>
<div className='stat-label'></div>
</div>
<div className='stat-card'>
<div className='stat-value'>{stats.totalItems}</div>
<div className='stat-label'></div>
</div>
<div className='stat-card' onClick={() => navigate('/posts')} style={{ cursor: 'pointer' }}>
<div className='stat-value'>{stats.totalPosts}</div>
<div className='stat-label'></div>
</div>
<div className="stat-grid">
<StatCard title="总用户数" value={stats.totalUsers} icon="👥" onClick={() => navigate('/users')} />
<StatCard title="今日新增" value={stats.newUsersToday} icon="📈" color="#34C759" onClick={() => navigate('/users')} />
<StatCard title="总打卡次数" value={stats.totalCheckins} icon="✅" color="#7209B7" />
<StatCard title="总打卡项" value={stats.totalItems} icon="📋" />
<StatCard title="动态总数" value={stats.totalPosts} icon="🌊" color="#4CC9F0" onClick={() => navigate('/posts')} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
+45 -38
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { api } from '../services/api'
import DataTable from '../components/DataTable'
import Pagination from '../components/Pagination'
const PAGE_SIZE = 15
@@ -10,7 +12,7 @@ export default function Posts() {
const [loading, setLoading] = useState(true)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
useEffect(() => {
const loadPosts = useCallback(() => {
setLoading(true)
api.get<any>('/api/admin/posts?page=' + page + '&pageSize=' + PAGE_SIZE)
.then(data => { setPosts(data.list || []); setTotal(data.total || 0) })
@@ -18,48 +20,53 @@ export default function Posts() {
.finally(() => setLoading(false))
}, [page])
useEffect(() => { loadPosts() }, [loadPosts])
const handleHide = async (id: string) => {
if (!confirm('确定隐藏该动态?隐藏后用户将不可见。')) return
try {
await api.delete('/api/social/posts/' + id)
loadPosts()
} catch {
alert('操作失败')
}
}
const columns = [
{
key: 'content',
label: '内容',
render: (row: any) => (
<span style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}>
{row.content || '(无文字内容)'}
</span>
),
},
{ key: 'user', label: '用户', render: (row: any) => row.user?.nickname || '--' },
{ key: 'likes', label: '点赞' },
{ key: 'comments', label: '评论' },
{ key: 'createdAt', label: '时间', render: (row: any) => row.createdAt?.slice(0, 10) || '--' },
{
key: 'actions',
label: '操作',
render: (row: any) => (
<button className="btn btn-danger" style={{ padding: '4px 12px', fontSize: 12 }}
onClick={(e) => { e.stopPropagation(); handleHide(row.id) }}>
</button>
),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1 className='page-title' style={{ margin: 0 }}> <span style={{ fontSize: 14, fontWeight: 400, color: '#86868B' }}> {total} </span></h1>
<h1 className="page-title" style={{ margin: 0 }}> <span style={{ fontSize: 14, fontWeight: 400, color: '#86868B' }}> {total} </span></h1>
</div>
<div className='card' style={{ padding: 0, overflow: 'hidden' }}>
<table>
<thead>
<tr><th></th><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}>...</td></tr>
) : posts.length === 0 ? (
<tr><td colSpan={6} style={{ textAlign: 'center', padding: 24, color: '#86868B' }}></td></tr>
) : posts.map((p: any) => (
<tr key={p.id}>
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.content || '(无文字内容)'}
</td>
<td>{p.user?.nickname || '--'}</td>
<td>{p.likes}</td>
<td>{p.comments}</td>
<td style={{ fontSize: 12 }}>{p.createdAt?.slice(0, 10) || '--'}</td>
<td>
<button className='btn btn-danger' style={{ padding: '4px 12px', fontSize: 12 }}
onClick={() => { if (confirm('确定隐藏该动态?')) { /* TODO */ alert('已隐藏') } }}>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<DataTable columns={columns} data={posts} loading={loading} emptyText="暂无动态" />
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
<button className='btn btn-secondary' disabled={page <= 1} onClick={() => setPage(p => p - 1)}></button>
<span style={{ fontSize: 14 }}> {page}/{totalPages} </span>
<button className='btn btn-secondary' disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}></button>
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} total={total} />
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
setupFiles: [],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
})
+14
View File
@@ -0,0 +1,14 @@
Component({
properties: {
item: { type: Object, value: {} },
done: { type: Boolean, value: false },
streak: { type: Number, value: undefined },
categoryLabel: { type: String, value: '' },
cycleLabel: { type: String, value: '' },
},
methods: {
onTap() {
this.triggerEvent('tap', { id: this.data.item.id })
}
}
})
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,11 @@
<view class="card {{status}}" data-id="{{item.id}}" bindtap="onTap">
<view class="card-icon">{{item.icon || '📋'}}</view>
<view class="card-body">
<text class="card-name">{{item.name}}</text>
<text class="card-meta">{{categoryLabel}} · {{cycleLabel}}</text>
<text wx:if="{{done}}" class="done-badge">✓ 已打卡</text>
</view>
<view class="card-streak" wx:if="{{streak !== undefined}}">
<text>🔥 {{streak}}天</text>
</view>
</view>
@@ -0,0 +1,26 @@
.card {
display: flex;
align-items: center;
background: white;
border-radius: 12px;
margin: 0 12px 8px;
padding: 14px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.card.paused { opacity: 0.6; }
.card.archived { opacity: 0.4; }
.card-icon {
width: 44px; height: 44px;
border-radius: 12px;
background: #FFF0EB;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-right: 14px;
}
.card-body { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.card-name { font-size: 16px; font-weight: 600; }
.card-meta { font-size: 13px; color: #86868B; }
.done-badge { font-size: 12px; color: #34C759; font-weight: 600; margin-top: 2px; }
.card-streak { font-size: 14px; color: #FF6B35; font-weight: 600; }
+18
View File
@@ -0,0 +1,18 @@
Component({
properties: {
active: { type: String, value: 'home' },
tabs: {
type: Array,
value: [
{ key: 'home', label: '打卡', icon: '📋' },
{ key: 'feed', label: '广场', icon: '🌊' },
{ key: 'profile', label: '我的', icon: '👤' },
],
},
},
methods: {
onSwitch(e) {
this.triggerEvent('switch', { key: e.currentTarget.dataset.key })
}
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+6
View File
@@ -0,0 +1,6 @@
<view class="nav-bar">
<view wx:for="{{tabs}}" wx:key="key" class="nav-item" data-key="{{item.key}}" bindtap="onSwitch">
<text class="nav-icon">{{item.icon}}</text>
<text class="nav-label {{active===item.key?'active':''}}">{{item.label}}</text>
</view>
</view>
+24
View File
@@ -0,0 +1,24 @@
.nav-bar {
display: flex;
align-items: center;
justify-content: space-around;
padding: 8px 0 24px;
border-top: 0.5px solid #E5E5EA;
background: rgba(245,245,247,0.95);
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px;
}
.nav-icon { font-size: 22px; }
.nav-label { font-size: 10px; color: #86868B; }
.nav-label.active { color: #FF6B35; font-weight: 600; }
+93
View File
@@ -0,0 +1,93 @@
/**
* 格式化日期为 YYYY-MM-DD
*/
function formatDate(date) {
const d = date ? new Date(date) : new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* 格式化日期为 YYYY-MM
*/
function formatMonth(date) {
const d = date ? new Date(date) : new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
/**
* 获取相对时间描述(几分钟前、几小时前、几天前)
*/
function timeAgo(dateStr) {
if (!dateStr) return ''
const now = Date.now()
const t = new Date(dateStr).getTime()
const diff = Math.floor((now - t) / 1000)
if (diff < 60) return '刚刚'
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
if (diff < 2592000) return Math.floor(diff / 86400) + '天前'
return formatDate(dateStr)
}
/**
* 打卡分类中文标签
*/
const CATEGORY_LABEL = {
study: '学习成长',
health: '健康生活',
habit: '习惯自律',
hobby: '兴趣爱好',
goal: '目标任务',
}
const CYCLE_LABEL = {
daily: '每日',
weekly: '每周',
every_other_day: '隔日',
custom: '自定义',
}
/**
* 获取分类中文名
*/
function getCategoryLabel(cat) {
return CATEGORY_LABEL[cat] || cat
}
/**
* 获取周期中文名
*/
function getCycleLabel(cycle) {
return CYCLE_LABEL[cycle] || cycle
}
/**
* 隐私级别中文名
*/
function getPrivacyLabel(level) {
const map = { 1: '公开', 2: '仅好友可见', 3: '仅自己可见' }
return map[level] || '未知'
}
/**
* 积分显示(带 + 号)
*/
function formatPoints(p) {
if (p === undefined || p === null) return '--'
return p > 0 ? '+' + p : String(p)
}
module.exports = {
formatDate,
formatMonth,
timeAgo,
getCategoryLabel,
getCycleLabel,
getPrivacyLabel,
formatPoints,
CATEGORY_LABEL,
CYCLE_LABEL,
}
+32 -62
View File
@@ -1,83 +1,53 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView, StatusBar } from 'react-native'
import React, { useEffect, useState } from 'react'
import { View, Text, StatusBar } from 'react-native'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { AuthProvider, useAuth } from './src/components/AuthProvider'
import { getStoredToken } from './src/services/api'
import LoginScreen from './src/screens/LoginScreen'
import HomeScreen from './src/screens/HomeScreen'
import FeedScreen from './src/screens/FeedScreen'
import ProfileScreen from './src/screens/ProfileScreen'
import CheckInDetailScreen from './src/screens/CheckInDetailScreen'
import CreateCheckInScreen from './src/screens/CreateCheckInScreen'
import MainTabs from './src/navigation/MainTabs'
import type { RootStackParamList } from './src/navigation/types'
const TABS = [
{ key: 'home', label: '打卡', icon: '📋' },
{ key: 'feed', label: '广场', icon: '🌊' },
{ key: 'profile', label: '我的', icon: '👤' },
]
const RootStack = createNativeStackNavigator<RootStackParamList>()
function TabBar({ active, onChange }: { active: string; onChange: (k: string) => void }) {
return (
<View style={tabBarStyles.container}>
{TABS.map(t => (
<TouchableOpacity key={t.key} onPress={() => onChange(t.key)} style={tabBarStyles.tab}>
<Text style={tabBarStyles.icon}>{t.icon}</Text>
<Text style={[tabBarStyles.label, active === t.key && tabBarStyles.active]}>{t.label}</Text>
</TouchableOpacity>
))}
</View>
)
}
const tabBarStyles = StyleSheet.create({
container: { flexDirection: 'row', borderTopWidth: 0.5, borderTopColor: '#E5E5EA', backgroundColor: 'rgba(245,245,247,0.95)', paddingBottom: 20, paddingTop: 6 },
tab: { flex: 1, alignItems: 'center', gap: 2 },
icon: { fontSize: 22 },
label: { fontSize: 10, color: '#86868B' },
active: { color: '#FF6B35', fontWeight: '600' },
})
function MainApp() {
function AuthGate() {
const { user, loading } = useAuth()
const [tab, setTab] = useState('home')
const [screen, setScreen] = useState<'loading' |const [screen, setScreen] = useState<'loading' | 'login' | 'main'>('loading')
const [selectedItemId, setSelectedItemId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)| 'main'>('loading')
const [initialRoute, setInitialRoute] = useState<'Login' | 'MainTabs' | null>(null)
useEffect(() => {
if (!loading) {
getStoredToken().then(t => setScreen(t ? 'main' : 'login'))
getStoredToken().then((token) => {
setInitialRoute(token ? 'MainTabs' : 'Login')
})
}
}, [loading])
const onLogin = useCallback(() => setScreen('main'), [])
if (screen === 'loading') return <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}><Text style={{ color: '#86868B' }}>...</Text></View>
if (screen === 'login') return <LoginScreen onLogin={onLogin} />
if (selectedItemId) return <CheckInDetailScreen itemId={selectedItemId} onBack={() => setSelectedItemId(null)} />
if (showCreate) return <CreateCheckInScreen onBack={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); setTab('home') }} />
const renderScreen = () => {
switch (tab) {
case 'home': return <HomeScreen onSelectItem={setSelectedItemId} onCreateItem={() => setShowCreate(true)} />
case 'feed': return <FeedScreen />
case 'profile': return <ProfileScreen />
default: return null
}
if (!initialRoute) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}>
<Text style={{ color: '#86868B' }}>...</Text>
</View>
)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<StatusBar barStyle="dark-content" background="#F5F5F7" />
{renderScreen()}
<TabBar active={tab} onChange={setTab} />
</SafeAreaView>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
<RootStack.Screen name="Login" component={LoginScreen} />
<RootStack.Screen name="MainTabs" component={MainTabs} />
</RootStack.Navigator>
)
}
export default function App() {
return (
<AuthProvider>
<MainApp />
</AuthProvider>
<SafeAreaProvider>
<AuthProvider>
<NavigationContainer>
<StatusBar barStyle="dark-content" background="#F5F5F7" />
<AuthGate />
</NavigationContainer>
</AuthProvider>
</SafeAreaProvider>
)
}
}
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock AsyncStorage
const storage: Record<string, string> = {}
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => Promise.resolve(storage[key] || null)),
setItem: vi.fn((key: string, val: string) => { storage[key] = val; return Promise.resolve() }),
removeItem: vi.fn((key: string) => { delete storage[key]; return Promise.resolve() }),
multiRemove: vi.fn((keys: string[]) => { keys.forEach(k => delete storage[k]); return Promise.resolve() }),
},
}))
beforeEach(() => {
Object.keys(storage).forEach(k => delete storage[k])
})
// ========== API Service ==========
describe('Mobile API Service', () => {
it('should export api methods', async () => {
const mod = await import('../services/api')
expect(mod.api).toBeDefined()
expect(typeof mod.api.get).toBe('function')
expect(typeof mod.api.post).toBe('function')
expect(typeof mod.api.put).toBe('function')
expect(typeof mod.api.del).toBe('function')
})
it('should export auth helpers', async () => {
const mod = await import('../services/api')
expect(typeof mod.saveToken).toBe('function')
expect(typeof mod.getStoredUser).toBe('function')
expect(typeof mod.getStoredToken).toBe('function')
expect(typeof mod.clearAuth).toBe('function')
})
it('saveToken + getStoredUser should persist auth', async () => {
const { saveToken, getStoredUser, getStoredToken } = await import('../services/api')
await saveToken('test-token', { id: '1', nickname: 'Tester', points: 100 })
const token = await getStoredToken()
expect(token).toBe('test-token')
const user = await getStoredUser()
expect(user.nickname).toBe('Tester')
expect(user.points).toBe(100)
})
it('clearAuth should remove all auth data', async () => {
const { saveToken, clearAuth, getStoredToken, getStoredUser } = await import('../services/api')
await saveToken('t', { id: '1', nickname: 'T' })
await clearAuth()
expect(await getStoredToken()).toBeNull()
expect(await getStoredUser()).toBeNull()
})
})
// ========== AuthProvider ==========
describe('AuthProvider', () => {
it('should export AuthProvider and useAuth', async () => {
const mod = await import('../components/AuthProvider')
expect(mod.AuthProvider).toBeDefined()
expect(typeof mod.useAuth).toBe('function')
})
})
// ========== CheckIn Module ==========
describe('CheckIn Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/checkin/index')
expect(typeof mod.createItem).toBe('function')
expect(typeof mod.listItems).toBe('function')
expect(typeof mod.getTodayStatus).toBe('function')
expect(typeof mod.getStreak).toBe('function')
expect(typeof mod.getRecords).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/checkin/index')
expect(typeof mod.useCheckInItems).toBe('function')
expect(typeof mod.useCheckInStatus).toBe('function')
expect(typeof mod.useCheckInDetail).toBe('function')
})
it('should export category constants', async () => {
const mod = await import('../modules/checkin/index')
expect(mod.CATEGORY_CONFIG.study.label).toBe('学习成长')
expect(mod.CATEGORY_CONFIG.health.icon).toBe('❤️')
expect(mod.CATEGORY_CONFIG.goal.color).toBe('#FF6B35')
expect(mod.CYCLE_LABEL.daily).toBe('每日')
})
})
// ========== Social Module ==========
describe('Social Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/social/index')
expect(typeof mod.createPost).toBe('function')
expect(typeof mod.getFeed).toBe('function')
expect(typeof mod.likePost).toBe('function')
expect(typeof mod.listFriends).toBe('function')
expect(typeof mod.sendFriendRequest).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/social/index')
expect(typeof mod.useFeed).toBe('function')
expect(typeof mod.useFriends).toBe('function')
})
})
// ========== Profile Module ==========
describe('Profile Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/profile/index')
expect(typeof mod.getAchievements).toBe('function')
expect(typeof mod.updateProfile).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/profile/index')
expect(typeof mod.useProfile).toBe('function')
expect(typeof mod.useAchievements).toBe('function')
})
})
// ========== Discover Module ==========
describe('Discover Module', () => {
it('should export API functions', async () => {
const mod = await import('../modules/discover/index')
expect(typeof mod.getLeaderboard).toBe('function')
})
it('should export hooks', async () => {
const mod = await import('../modules/discover/index')
expect(typeof mod.useLeaderboard).toBe('function')
expect(typeof mod.useDiscover).toBe('function')
})
})
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock AsyncStorage
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(() => Promise.resolve(null)),
setItem: vi.fn(() => Promise.resolve()),
removeItem: vi.fn(() => Promise.resolve()),
multiRemove: vi.fn(() => Promise.resolve()),
},
}))
describe('Mobile API Service', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('should export api with get/post/put/del methods', async () => {
const mod = await import('../services/api')
expect(mod.api).toBeDefined()
expect(typeof mod.api.get).toBe('function')
expect(typeof mod.api.post).toBe('function')
expect(typeof mod.api.put).toBe('function')
expect(typeof mod.api.del).toBe('function')
})
it('should export auth helper functions', async () => {
const mod = await import('../services/api')
expect(typeof mod.saveToken).toBe('function')
expect(typeof mod.getStoredUser).toBe('function')
expect(typeof mod.clearAuth).toBe('function')
})
it('should export AuthProvider and useAuth', async () => {
const mod = await import('../components/AuthProvider')
expect(mod.AuthProvider).toBeDefined()
expect(typeof mod.useAuth).toBe('function')
})
})
+219
View File
@@ -0,0 +1,219 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
// ========== 类型定义 ==========
export type CheckInCategory = 'study' | 'health' | 'habit' | 'hobby' | 'goal'
export type CycleType = 'daily' | 'weekly' | 'every_other_day' | 'custom'
export type ItemStatus = 'active' | 'paused' | 'archived' | 'deleted'
export interface ICheckInItem {
id: string
userId: string
name: string
icon?: string
category: CheckInCategory
dailyGoal?: string
cycleType: CycleType
privacy: number
status: ItemStatus
createdAt: string
}
export interface ICheckInRecord {
id: string
itemId: string
userId: string
checkDate: string
status: 'normal' | 'makeup'
note?: string
images?: string
}
export interface IStreak {
itemId: string
streak: number
}
export interface IMonthlyStats {
year: number
month: number
total: number
records: ICheckInRecord[]
streaks: Record<string, number>
}
export const CATEGORY_CONFIG: Record<CheckInCategory, { label: string; icon: string; color: string }> = {
study: { label: '学习成长', icon: '📚', color: '#4CC9F0' },
health: { label: '健康生活', icon: '❤️', color: '#06D6A0' },
habit: { label: '习惯自律', icon: '🔄', color: '#7209B7' },
hobby: { label: '兴趣爱好', icon: '⭐', color: '#F72585' },
goal: { label: '目标任务', icon: '🎯', color: '#FF6B35' },
}
export const CYCLE_LABEL: Record<string, string> = {
daily: '每日',
weekly: '每周',
every_other_day: '隔日',
custom: '自定义',
}
// ========== 创建打卡项 ==========
export type CreateItemInput = {
name: string
category: CheckInCategory
icon?: string
dailyGoal?: string
cycleType?: CycleType
privacy?: number
}
export async function createItem(data: CreateItemInput): Promise<ICheckInItem> {
return api.post<ICheckInItem>('/api/checkin/items', data)
}
export async function listItems(): Promise<ICheckInItem[]> {
return api.get<ICheckInItem[]>('/api/checkin/items')
}
export async function getItem(id: string): Promise<ICheckInItem> {
return api.get<ICheckInItem>(`/api/checkin/items/${id}`)
}
export async function updateItem(id: string, data: Partial<ICheckInItem>): Promise<ICheckInItem> {
return api.put<ICheckInItem>(`/api/checkin/items/${id}`, data)
}
export async function deleteItem(id: string): Promise<void> {
return api.del(`/api/checkin/items/${id}`)
}
export async function pauseItem(id: string): Promise<void> {
return api.post(`/api/checkin/items/${id}/pause`)
}
export async function resumeItem(id: string): Promise<void> {
return api.post(`/api/checkin/items/${id}/resume`)
}
// ========== 打卡记录 ==========
export async function createRecord(itemId: string, note?: string): Promise<ICheckInRecord> {
return api.post<ICheckInRecord>('/api/checkin/records', { itemId, note })
}
export async function makeupRecord(itemId: string, checkDate: string): Promise<ICheckInRecord> {
return api.post<ICheckInRecord>('/api/checkin/records/makeup', { itemId, checkDate })
}
export async function getRecords(params: { itemId?: string; start?: string; end?: string }): Promise<ICheckInRecord[]> {
const qs = new URLSearchParams()
if (params.itemId) qs.set('itemId', params.itemId)
if (params.start) qs.set('start', params.start)
if (params.end) qs.set('end', params.end)
return api.get<ICheckInRecord[]>(`/api/checkin/records?${qs}`)
}
// ========== 统计 ==========
export async function getStreak(itemId: string): Promise<IStreak> {
return api.get<IStreak>(`/api/checkin/streak/${itemId}`)
}
export async function getTodayStatus(): Promise<Record<string, boolean>> {
return api.get<Record<string, boolean>>('/api/checkin/today-status')
}
export async function getMonthlyStats(year: number, month: number): Promise<IMonthlyStats> {
return api.get<IMonthlyStats>(`/api/checkin/stats/monthly?year=${year}&month=${month}`)
}
// ========== Hook: 打卡列表 ==========
export function useCheckInItems() {
const [items, setItems] = useState<ICheckInItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await listItems()
setItems(data.filter((i) => i.status !== 'deleted'))
} catch (e: any) {
setError(e.message || '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { items, loading, error, reload: load }
}
// ========== Hook: 今日状态 + 连续天数 ==========
export function useCheckInStatus(items: ICheckInItem[]) {
const [todayStatus, setTodayStatus] = useState<Record<string, boolean>>({})
const [streaks, setStreaks] = useState<Record<string, number>>({})
const load = useCallback(async () => {
try {
const [ts] = await Promise.all([getTodayStatus()])
setTodayStatus(ts || {})
const s: Record<string, number> = {}
await Promise.all(
items
.filter((i) => i.status === 'active')
.map(async (item) => {
try {
const r = await getStreak(item.id)
s[item.id] = r.streak
} catch {
/* ignore */
}
}),
)
setStreaks(s)
} catch {
/* ignore */
}
}, [items])
useEffect(() => {
if (items.length > 0) load()
}, [items, load])
return { todayStatus, streaks, reload: load }
}
// ========== Hook: 打卡详情 ==========
export function useCheckInDetail(itemId: string) {
const [item, setItem] = useState<ICheckInItem | null>(null)
const [streak, setStreak] = useState(0)
const [records, setRecords] = useState<ICheckInRecord[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const [itemData, streakData, recordsData] = await Promise.all([
getItem(itemId),
getStreak(itemId),
getRecords({
itemId,
start: new Date().toISOString().slice(0, 7) + '-01',
end: new Date().toISOString().slice(0, 10),
}),
])
setItem(itemData)
setStreak(streakData.streak)
setRecords(recordsData || [])
} catch {
// handled by caller
} finally {
setLoading(false)
}
}, [itemId])
useEffect(() => { load() }, [load])
return { item, streak, records, loading, reload: load }
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
// ========== 排行榜 ==========
export interface IRankEntry {
rank: number
userId: string
nickname: string
avatar?: string
value: number
}
export type RankType = 'streak' | 'points' | 'checkin_count'
export async function getLeaderboard(type: RankType, limit = 20): Promise<IRankEntry[]> {
// 后端暂只支持 points 排行,type 参数预留
return api.get<IRankEntry[]>(`/api/leaderboard?limit=${limit}`)
}
// ========== 推荐用户 ==========
export async function getRecommendedUsers(): Promise<{ id: string; nickname?: string; avatar?: string; bio?: string }[]> {
return api.get('/api/discover/users')
}
// ========== Hook: 排行榜 ==========
export function useLeaderboard(type: RankType = 'streak') {
const [entries, setEntries] = useState<IRankEntry[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getLeaderboard(type)
setEntries(data || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [type])
useEffect(() => { load() }, [load])
return { entries, loading, reload: load }
}
// ========== Hook: 发现页 ==========
export function useDiscover() {
const [leaderboard, setLeaderboard] = useState<IRankEntry[]>([])
const [recommended, setRecommended] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const [lb, rec] = await Promise.all([
getLeaderboard('streak', 10),
getRecommendedUsers(),
])
setLeaderboard(lb || [])
setRecommended(rec || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { leaderboard, recommended, loading, reload: load }
}
+73
View File
@@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
import { useAuth } from '../../components/AuthProvider'
// ========== 成就 ==========
export interface IAchievement {
id: string
name: string
icon?: string
condition: string
rarity: string
unlocked?: boolean
unlockedAt?: string
}
export async function getAchievements(): Promise<IAchievement[]> {
return api.get<IAchievement[]>('/api/achievements')
}
// ========== 用户资料 API ==========
export async function updateProfile(updates: Record<string, any>): Promise<any> {
return api.put('/api/users/profile', updates)
}
export async function getUserPoints(): Promise<{ points: number }> {
return api.get<{ points: number }>('/api/users/points')
}
// ========== Hook: 个人统计与成就 ==========
export function useProfile() {
const { user, refreshUser } = useAuth()
const [achievements, setAchievements] = useState<IAchievement[]>([])
const [points, setPoints] = useState(0)
const load = useCallback(async () => {
try {
const [ach, pts] = await Promise.all([
getAchievements(),
getUserPoints(),
])
setAchievements(ach || [])
setPoints(pts?.points ?? 0)
} catch {
// features may not be ready yet
}
}, [])
useEffect(() => { load() }, [load])
return { user, achievements, points, updateProfile, refreshUser, reload: load }
}
// ========== Hook: 成就列表 ==========
export function useAchievements() {
const [achievements, setAchievements] = useState<IAchievement[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getAchievements()
setAchievements(data || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { achievements, loading, reload: load }
}
+143
View File
@@ -0,0 +1,143 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../../services/api'
// ========== 类型定义 ==========
export interface IPost {
id: string
userId: string
content?: string
images?: string
privacy: number
likes: number
comments: number
createdAt: string
user?: { nickname?: string; avatar?: string }
}
export interface IUserBrief {
id: string
nickname?: string
avatar?: string
}
export interface IGroup {
id: string
name: string
cover?: string
intro?: string
type: string
maxMember: number
createdBy: string
createdAt: string
}
// ========== 动态 API ==========
export async function createPost(data: { content?: string; images?: string; privacy?: number }): Promise<IPost> {
return api.post<IPost>('/api/social/posts', data)
}
export async function getFeed(page = 1, pageSize = 20): Promise<{ list: IPost[]; total: number; page: number }> {
return api.get(`/api/social/feed?page=${page}&pageSize=${pageSize}`)
}
export async function likePost(postId: string): Promise<void> {
return api.post(`/api/social/posts/${postId}/like`)
}
export async function deletePost(postId: string): Promise<void> {
return api.del(`/api/social/posts/${postId}`)
}
// ========== 好友 API ==========
export async function sendFriendRequest(friendId: string): Promise<void> {
return api.post('/api/social/friends/request', { friendId })
}
export async function acceptFriendRequest(friendId: string): Promise<void> {
return api.post('/api/social/friends/accept', { friendId })
}
export async function removeFriend(userId: string): Promise<void> {
return api.del(`/api/social/friends/${userId}`)
}
export async function listFriends(): Promise<IUserBrief[]> {
return api.get<IUserBrief[]>('/api/social/friends')
}
// ========== 小组 API ==========
export async function createGroup(data: Partial<IGroup>): Promise<IGroup> {
return api.post<IGroup>('/api/social/groups', data)
}
export async function joinGroup(groupId: string): Promise<void> {
return api.post(`/api/social/groups/${groupId}/join`)
}
export async function leaveGroup(groupId: string): Promise<void> {
return api.post(`/api/social/groups/${groupId}/leave`)
}
export async function getGroupMembers(groupId: string): Promise<{ groupId: string; userId: string; role: string; joinedAt: string }[]> {
return api.get(`/api/social/groups/${groupId}/members`)
}
// ========== Hook: 动态 Feed ==========
export function useFeed() {
const [posts, setPosts] = useState<IPost[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getFeed()
setPosts(data.list || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const toggleLike = useCallback(async (postId: string) => {
try {
await likePost(postId)
setPosts((prev) =>
prev.map((p) => (p.id === postId ? { ...p, likes: p.likes + 1 } : p)),
)
} catch {
// silent
}
}, [])
const publishPost = useCallback(async (content: string) => {
await createPost({ content, privacy: 1 })
await load()
}, [load])
return { posts, loading, reload: load, toggleLike, publishPost }
}
// ========== Hook: 好友列表 ==========
export function useFriends() {
const [friends, setFriends] = useState<IUserBrief[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await listFriends()
setFriends(data || [])
} catch {
// silent
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { friends, loading, reload: load }
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { FeedStackParamList } from './types'
import FeedScreen from '../screens/FeedScreen'
const Stack = createNativeStackNavigator<FeedStackParamList>()
export default function FeedStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="FeedMain" component={FeedScreen} />
</Stack.Navigator>
)
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { HomeStackParamList } from './types'
import HomeScreen from '../screens/HomeScreen'
import CheckInDetailScreen from '../screens/CheckInDetailScreen'
import CreateCheckInScreen from '../screens/CreateCheckInScreen'
const Stack = createNativeStackNavigator<HomeStackParamList>()
export default function HomeStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="HomeMain" component={HomeScreen} />
<Stack.Screen name="CheckInDetail" component={CheckInDetailScreen} />
<Stack.Screen name="CreateCheckIn" component={CreateCheckInScreen} />
</Stack.Navigator>
)
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import Ionicons from '@expo/vector-icons/Ionicons'
import type { MainTabParamList } from './types'
import HomeStack from './HomeStack'
import FeedStack from './FeedStack'
import ProfileStack from './ProfileStack'
const Tab = createBottomTabNavigator<MainTabParamList>()
export default function MainTabs() {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#FF6B35',
tabBarInactiveTintColor: '#86868B',
tabBarStyle: {
backgroundColor: 'rgba(245,245,247,0.95)',
borderTopColor: '#E5E5EA',
borderTopWidth: 0.5,
paddingTop: 6,
paddingBottom: 20,
},
tabBarLabelStyle: {
fontSize: 10,
fontWeight: '600' as const,
},
}}
>
<Tab.Screen
name="HomeTab"
component={HomeStack}
options={{
tabBarLabel: '打卡',
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tab.Screen
name="FeedTab"
component={FeedStack}
options={{
tabBarLabel: '广场',
tabBarIcon: ({ color, size }) => <Ionicons name="newspaper" size={size} color={color} />,
}}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileStack}
options={{
tabBarLabel: '我的',
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
}}
/>
</Tab.Navigator>
)
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import type { ProfileStackParamList } from './types'
import ProfileScreen from '../screens/ProfileScreen'
import AchievementsScreen from '../screens/AchievementsScreen'
const Stack = createNativeStackNavigator<ProfileStackParamList>()
export default function ProfileStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="ProfileMain" component={ProfileScreen} />
<Stack.Screen name="Achievements" component={AchievementsScreen} />
</Stack.Navigator>
)
}
+56
View File
@@ -0,0 +1,56 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'
import type { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native'
// ========== Home Stack ==========
export type HomeStackParamList = {
HomeMain: undefined
CheckInDetail: { itemId: string }
CreateCheckIn: undefined
}
// ========== Feed Stack ==========
export type FeedStackParamList = {
FeedMain: undefined
}
// ========== Profile Stack ==========
export type ProfileStackParamList = {
ProfileMain: undefined
Achievements: undefined
}
// ========== Main Tab Navigator ==========
export type MainTabParamList = {
HomeTab: NavigatorScreenParams<HomeStackParamList>
FeedTab: NavigatorScreenParams<FeedStackParamList>
ProfileTab: NavigatorScreenParams<ProfileStackParamList>
}
// ========== Root Navigator ==========
export type RootStackParamList = {
Login: undefined
MainTabs: NavigatorScreenParams<MainTabParamList>
}
// ========== Screen Prop Helpers ==========
export type HomeScreenProps = CompositeScreenProps<
NativeStackScreenProps<HomeStackParamList, 'HomeMain'>,
BottomTabScreenProps<MainTabParamList>
>
export type CheckInDetailScreenProps = NativeStackScreenProps<HomeStackParamList, 'CheckInDetail'>
export type CreateCheckInScreenProps = NativeStackScreenProps<HomeStackParamList, 'CreateCheckIn'>
export type FeedScreenProps = CompositeScreenProps<
NativeStackScreenProps<FeedStackParamList, 'FeedMain'>,
BottomTabScreenProps<MainTabParamList>
>
export type ProfileScreenProps = CompositeScreenProps<
NativeStackScreenProps<ProfileStackParamList, 'ProfileMain'>,
BottomTabScreenProps<MainTabParamList>
>
export type LoginScreenProps = NativeStackScreenProps<RootStackParamList, 'Login'>
+159
View File
@@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'
import { api } from '../services/api'
interface IAchievement {
id: string
name: string
icon?: string
condition: string
rarity: string
unlocked?: boolean
unlockedAt?: string
}
const RARITY_CONFIG: Record<string, { label: string; color: string }> = {
bronze: { label: '青铜', color: '#CD7F32' },
silver: { label: '白银', color: '#C0C0C0' },
gold: { label: '黄金', color: '#FFD700' },
}
export default function AchievementsScreen() {
const [achievements, setAchievements] = useState<IAchievement[]>([])
const [points, setPoints] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get<IAchievement[]>('/api/achievements'),
api.get<{ points: number }>('/api/users/points'),
])
.then(([ach, pts]) => {
setAchievements(ach || [])
setPoints(pts?.points ?? 0)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
const unlocked = achievements.filter((a) => a.unlocked)
const locked = achievements.filter((a) => !a.unlocked)
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
{/* 积分头部 */}
<View style={styles.pointsHeader}>
<Text style={styles.pointsValue}>{points}</Text>
<Text style={styles.pointsLabel}></Text>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statNum}>{unlocked.length}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={[styles.statItem, { borderRightWidth: 0 }]}>
<Text style={styles.statNum}>{achievements.length}</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 24 }}>
{/* 已解锁 */}
{unlocked.length > 0 && (
<>
<Text style={styles.sectionTitle}> ({unlocked.length})</Text>
<View style={styles.grid}>
{unlocked.map((a) => (
<View key={a.id} style={[styles.badge, { borderColor: RARITY_CONFIG[a.rarity]?.color || '#E5E5EA' }]}>
<Text style={styles.badgeIcon}>{a.icon || '🏅'}</Text>
<Text style={styles.badgeName}>{a.name}</Text>
<Text style={[styles.badgeRarity, { color: RARITY_CONFIG[a.rarity]?.color || '#86868B' }]}>
{RARITY_CONFIG[a.rarity]?.label || a.rarity}
</Text>
</View>
))}
</View>
</>
)}
{/* 未解锁 */}
{locked.length > 0 && (
<>
<Text style={styles.sectionTitle}> ({locked.length})</Text>
<View style={styles.grid}>
{locked.map((a) => (
<View key={a.id} style={[styles.badge, styles.lockedBadge]}>
<Text style={[styles.badgeIcon, { opacity: 0.3 }]}>🔒</Text>
<Text style={[styles.badgeName, { color: '#86868B' }]}>{a.name}</Text>
<Text style={{ fontSize: 10, color: '#C7C7CC', textAlign: 'center' }}>{a.condition}</Text>
</View>
))}
</View>
</>
)}
{achievements.length === 0 && (
<View style={{ alignItems: 'center', padding: 60 }}>
<Text style={{ fontSize: 40 }}>🏆</Text>
<Text style={{ fontSize: 15, color: '#86868B', marginTop: 8 }}></Text>
</View>
)}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
pointsHeader: {
backgroundColor: 'white',
alignItems: 'center',
paddingVertical: 28,
marginBottom: 8,
},
pointsValue: { fontSize: 48, fontWeight: '700', color: '#FF6B35' },
pointsLabel: { fontSize: 13, color: '#86868B', marginTop: 2 },
statsRow: {
flexDirection: 'row',
marginTop: 16,
backgroundColor: '#F5F5F7',
borderRadius: 10,
overflow: 'hidden',
},
statItem: {
paddingHorizontal: 24,
paddingVertical: 10,
borderRightWidth: 0.5,
borderRightColor: '#E5E5EA',
alignItems: 'center',
},
statNum: { fontSize: 18, fontWeight: '700', color: '#1D1D1F' },
statLabel: { fontSize: 12, color: '#86868B' },
sectionTitle: { fontSize: 17, fontWeight: '600', margin: 16, marginBottom: 12 },
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
gap: 10,
},
badge: {
width: '30%',
backgroundColor: 'white',
borderRadius: 12,
padding: 12,
alignItems: 'center',
gap: 4,
borderWidth: 2,
},
lockedBadge: { borderColor: '#E5E5EA', opacity: 0.7 },
badgeIcon: { fontSize: 28 },
badgeName: { fontSize: 12, fontWeight: '600', textAlign: 'center' },
badgeRarity: { fontSize: 10, fontWeight: '600' },
})
+10 -3
View File
@@ -1,8 +1,15 @@
import React, { useEffect, useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'
import { useNavigation, useRoute } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import type { RouteProp } from '@react-navigation/native'
import { api } from '../services/api'
import type { HomeStackParamList } from '../navigation/types'
export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string; onBack: () => void }) {
export default function CheckInDetailScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList, 'CheckInDetail'>>()
const route = useRoute<RouteProp<HomeStackParamList, 'CheckInDetail'>>()
const { itemId } = route.params
const [item, setItem] = useState<any>(null)
const [streak, setStreak] = useState(0)
const [records, setRecords] = useState<any[]>([])
@@ -25,7 +32,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string
Alert.alert('确认删除', '确定删除这个打卡项?', [
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: async () => {
try { await api.del('/api/checkin/items/' + itemId); onBack() }
try { await api.del('/api/checkin/items/' + itemId); navigation.goBack() }
catch { Alert.alert('错误', '删除失败') }
}},
])
@@ -36,7 +43,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.nav}>
<TouchableOpacity onPress={onBack}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<Text style={styles.navTitle}>{item?.name || '打卡详情'}</Text>
<View style={{ width: 60 }} />
</View>
+8 -4
View File
@@ -1,6 +1,9 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { api } from '../services/api'
import type { HomeStackParamList } from '../navigation/types'
const CATEGORIES = [
{ key: 'study', label: '📚 学习' },
@@ -10,7 +13,8 @@ const CATEGORIES = [
{ key: 'goal', label: '🎯 目标' },
]
export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: () => void; onCreated?: () => void }) {
export default function CreateCheckInScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList, 'CreateCheckIn'>>()
const [name, setName] = useState('')
const [category, setCategory] = useState('study')
const [cycleType, setCycleType] = useState('daily')
@@ -21,8 +25,8 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: ()
if (!name.trim()) { Alert.alert('提示', '请输入打卡项名称'); return }
setSubmitting(true)
try {
await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined })
Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { onBack(); onCreated?.() } }])
await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined })
Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { navigation.goBack() } }])
} catch (e: any) { Alert.alert('错误', e.message || '创建失败') }
setSubmitting(false)
}
@@ -30,7 +34,7 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: ()
return (
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.nav}>
<TouchableOpacity onPress={onBack}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}><Text style={styles.backBtn}> </Text></TouchableOpacity>
<Text style={styles.navTitle}></Text>
<View style={{ width: 60 }} />
</View>
+7 -3
View File
@@ -1,9 +1,13 @@
import React, { useEffect, useState, useCallback } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { api } from '../services/api'
import { useAuth } from '../components/AuthProvider'
import type { HomeStackParamList } from '../navigation/types'
export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectItem?: (id: string) => void; onCreateItem?: () => void }) {
export default function HomeScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList, 'HomeMain'>>()
const { user } = useAuth()
const [items, setItems] = useState<any[]>([])
const [streaks, setStreaks] = useState<Record<string, number>>({})
@@ -42,7 +46,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte
<View style={s.sectionHeader}>
<Text style={s.sectionTitle}></Text>
<TouchableOpacity onPress={onCreateItem}><Text style={s.createBtn}>+ </Text></TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('CreateCheckIn')}><Text style={s.createBtn}>+ </Text></TouchableOpacity>
</View>
{activeItems.length === 0 ? (
@@ -52,7 +56,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte
<Text style={{ fontSize: 13, color: '#86868B', marginTop: 4 }}>"创建"</Text>
</View>
) : activeItems.map((item: any) => (
<TouchableOpacity key={item.id} onPress={() => onSelectItem?.(item.id)}>
<TouchableOpacity key={item.id} onPress={() => navigation.navigate('CheckInDetail', { itemId: item.id })}>
<View style={s.card}>
<View style={s.cardIcon}><Text style={{ fontSize: 22 }}>{item.icon || '📋'}</Text></View>
<View style={{ flex: 1 }}>
+6 -2
View File
@@ -1,9 +1,13 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { api, saveToken } from '../services/api'
import { useAuth } from '../components/AuthProvider'
import type { RootStackParamList } from '../navigation/types'
export default function LoginScreen({ onLogin }: { onLogin: () => void }) {
export default function LoginScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Login'>>()
const [phone, setPhone] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
@@ -14,7 +18,7 @@ export default function LoginScreen({ onLogin }: { onLogin: () => void }) {
try {
const result = await api.post<{ user: any; token: string }>('/api/auth/login', { phone })
await login(result.token, result.user)
onLogin()
navigation.replace('MainTabs')
} catch (e: any) {
Alert.alert('登录失败', e.message || '请稍后重试')
} finally { setLoading(false) }
+5 -1
View File
@@ -1,9 +1,13 @@
import React from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useAuth } from '../components/AuthProvider'
import type { ProfileStackParamList } from '../navigation/types'
export default function ProfileScreen() {
const { user, logout } = useAuth()
const navigation = useNavigation<NativeStackNavigationProp<ProfileStackParamList, 'ProfileMain'>>()
const handleLogout = () => {
Alert.alert('确认退出', '确定要退出登录吗?', [
@@ -27,7 +31,7 @@ export default function ProfileScreen() {
</View>
<View style={styles.menu}>
<TouchableOpacity style={styles.menuItem}><Text></Text><Text style={styles.arrow}></Text></TouchableOpacity>
<TouchableOpacity style={styles.menuItem} onPress={() => navigation.navigate('Achievements')}><Text></Text><Text style={styles.arrow}></Text></TouchableOpacity>
<TouchableOpacity style={[styles.menuItem, { borderBottomWidth: 0 }]} onPress={handleLogout}>
<Text style={{ color: '#FF3B30' }}>退</Text><Text style={styles.arrow}></Text>
</TouchableOpacity>
BIN
View File
Binary file not shown.
+14 -2
View File
@@ -5,11 +5,13 @@ go 1.22
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.23.0
gorm.io/driver/mysql v1.5.6
gorm.io/gorm v1.25.10
gorm.io/gorm v1.30.0
)
require (
@@ -17,13 +19,17 @@ require (
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -34,12 +40,18 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+29 -7
View File
@@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
@@ -18,6 +20,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -35,6 +41,10 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -64,6 +74,9 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -76,8 +89,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -93,10 +107,10 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -108,7 +122,15 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+51 -18
View File
@@ -1,12 +1,13 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/inchstep/server/internal/middleware"
"github.com/inchstep/server/internal/services"
)
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/inchstep/server/internal/middleware"
"github.com/inchstep/server/internal/services"
)
type UserHandler struct {
userSvc *services.UserService
@@ -104,12 +105,44 @@
middleware.Success(c, nil)
}
func (h *UserHandler) GetPoints(c *gin.Context) {
userID := middleware.GetUserID(c)
user, err := h.userSvc.GetByID(userID)
if err != nil {
middleware.Error(c, http.StatusNotFound, 404, "用户不存在")
return
}
middleware.Success(c, gin.H{"points": user.Points})
}
func (h *UserHandler) GetPoints(c *gin.Context) {
userID := middleware.GetUserID(c)
user, err := h.userSvc.GetByID(userID)
if err != nil {
middleware.Error(c, http.StatusNotFound, 404, "用户不存在")
return
}
middleware.Success(c, gin.H{"points": user.Points})
}
// ========== 排行榜 ==========
func (h *UserHandler) GetLeaderboard(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "20")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit > 100 {
limit = 20
}
entries, err := h.userSvc.GetLeaderboardByPoints(limit)
if err != nil {
middleware.Error(c, http.StatusInternalServerError, 500, err.Error())
return
}
middleware.Success(c, entries)
}
// ========== 搜索用户 ==========
func (h *UserHandler) SearchUsers(c *gin.Context) {
q := c.Query("q")
if q == "" {
middleware.Success(c, []interface{}{})
return
}
users, err := h.userSvc.SearchUsers(q, 20)
if err != nil {
middleware.Error(c, http.StatusInternalServerError, 500, err.Error())
return
}
middleware.Success(c, users)
}
+4
View File
@@ -57,8 +57,12 @@ func Setup(db *gorm.DB, cfg *config.Config) *gin.Engine {
user.PUT("/profile", userH.UpdateProfile)
user.DELETE("/account", userH.DeleteAccount)
user.GET("/points", userH.GetPoints)
user.GET("/search", userH.SearchUsers)
}
// 排行榜(公开数据,需登录)
authed.GET("/leaderboard", userH.GetLeaderboard)
// 打卡模块
checkin := authed.Group("/checkin")
{
@@ -0,0 +1,122 @@
package services
import (
"testing"
"time"
"github.com/inchstep/server/internal/models"
"github.com/stretchr/testify/suite"
)
type CheckInServiceTestSuite struct {
ServiceTestSuite
}
func TestCheckInServiceSuite(t *testing.T) {
suite.Run(t, new(CheckInServiceTestSuite))
}
func (s *CheckInServiceTestSuite) setupUser() string {
user, err := s.svc.RegisterByPhone("13800138100")
s.Require().NoError(err)
return user.ID
}
func (s *CheckInServiceTestSuite) TestCreateItem() {
userID := s.setupUser()
item := &models.CheckInItem{
Name: "每日阅读",
Category: models.CategoryStudy,
CycleType: models.CycleDaily,
Privacy: models.PrivacyFriendsOnly,
DailyGoal: strPtr("读30页"),
}
err := s.svcCk.CreateItem(userID, item)
s.Require().NoError(err)
s.NotEmpty(item.ID)
s.Equal(models.StatusActive, item.Status)
}
func (s *CheckInServiceTestSuite) TestGetUserItems() {
userID := s.setupUser()
item1 := &models.CheckInItem{Name: "跑步", Category: models.CategoryHealth, CycleType: models.CycleDaily, Privacy: models.PrivacyFriendsOnly}
item2 := &models.CheckInItem{Name: "冥想", Category: models.CategoryHabit, CycleType: models.CycleDaily, Privacy: models.PrivacyFriendsOnly}
s.Require().NoError(s.svcCk.CreateItem(userID, item1))
s.Require().NoError(s.svcCk.CreateItem(userID, item2))
items, err := s.svcCk.GetUserItems(userID)
s.Require().NoError(err)
s.Len(items, 2)
}
func (s *CheckInServiceTestSuite) TestGetItemByID() {
userID := s.setupUser()
item := &models.CheckInItem{Name: "写作", Category: models.CategoryHobby, CycleType: models.CycleDaily, Privacy: models.PrivacyPublic}
s.Require().NoError(s.svcCk.CreateItem(userID, item))
found, err := s.svcCk.GetItemByID(item.ID, userID)
s.Require().NoError(err)
s.Equal("写作", found.Name)
}
func (s *CheckInServiceTestSuite) TestGetItemByID_WrongUser() {
userID1 := s.setupUser()
userID2, _ := s.svc.RegisterByPhone("13800138101")
s.Require().NotNil(userID2)
item := &models.CheckInItem{Name: "日记", Category: models.CategoryHabit, CycleType: models.CycleDaily, Privacy: models.PrivacyPrivate}
s.Require().NoError(s.svcCk.CreateItem(userID1, item))
_, err := s.svcCk.GetItemByID(item.ID, userID2.ID)
s.Error(err) // should fail - wrong user
}
func (s *CheckInServiceTestSuite) TestPauseAndResumeItem() {
userID := s.setupUser()
item := &models.CheckInItem{Name: "瑜伽", Category: models.CategoryHealth, CycleType: models.CycleDaily, Privacy: models.PrivacyPrivate}
s.Require().NoError(s.svcCk.CreateItem(userID, item))
err := s.svcCk.PauseItem(item.ID, userID)
s.Require().NoError(err)
paused, err := s.svcCk.GetItemByID(item.ID, userID)
s.Require().NoError(err)
s.Equal(models.StatusPaused, paused.Status)
err = s.svcCk.ResumeItem(item.ID, userID)
s.Require().NoError(err)
resumed, err := s.svcCk.GetItemByID(item.ID, userID)
s.Require().NoError(err)
s.Equal(models.StatusActive, resumed.Status)
}
func (s *CheckInServiceTestSuite) TestCreateRecord() {
userID := s.setupUser()
item := &models.CheckInItem{Name: "背单词", Category: models.CategoryStudy, CycleType: models.CycleDaily, Privacy: models.PrivacyPrivate}
s.Require().NoError(s.svcCk.CreateItem(userID, item))
record, err := s.svcCk.CreateRecord(userID, item.ID, time.Now(), strPtr("背了50个"), nil)
s.Require().NoError(err)
s.NotEmpty(record.ID)
s.Equal(models.RecordNormal, record.Status)
}
func (s *CheckInServiceTestSuite) TestCreateRecord_DuplicateDate() {
userID := s.setupUser()
item := &models.CheckInItem{Name: "喝水", Category: models.CategoryHealth, CycleType: models.CycleDaily, Privacy: models.PrivacyPrivate}
s.Require().NoError(s.svcCk.CreateItem(userID, item))
_, err := s.svcCk.CreateRecord(userID, item.ID, time.Now(), nil, nil)
s.Require().NoError(err)
_, err = s.svcCk.CreateRecord(userID, item.ID, time.Now(), nil, nil)
s.Error(err)
s.Contains(err.Error(), "已打卡")
}
@@ -0,0 +1,36 @@
package services
import (
"github.com/glebarez/sqlite"
"github.com/inchstep/server/internal/models"
"github.com/stretchr/testify/suite"
"gorm.io/gorm"
)
func strPtr(s string) *string { return &s }
// ServiceTestSuite 提供内存 SQLite 数据库用于服务层测试
type ServiceTestSuite struct {
suite.Suite
db *gorm.DB
svc *UserService
svcCk *CheckInService
svcPts *PointsService
}
func (s *ServiceTestSuite) SetupSuite() {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&models.User{}, &models.CheckInItem{}, &models.CheckInRecord{}, &models.Achievement{}, &models.Admin{}))
s.db = db
s.svc = NewUserService(db)
s.svcCk = NewCheckInService(db)
s.svcPts = NewPointsService(db)
}
func (s *ServiceTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM users")
s.db.Exec("DELETE FROM check_in_items")
s.db.Exec("DELETE FROM check_in_records")
s.db.Exec("DELETE FROM achievements")
}
+40
View File
@@ -85,6 +85,46 @@ func (s *UserService) DeleteAccount(id string) error {
}).Error
}
// LeaderboardEntry 排行榜条目
type LeaderboardEntry struct {
UserID string `json:"userId"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar,omitempty"`
Value int `json:"value"`
}
// GetLeaderboardByPoints 按积分排行
func (s *UserService) GetLeaderboardByPoints(limit int) ([]LeaderboardEntry, error) {
var users []models.User
if err := s.db.Where("points > 0").Order("points desc").Limit(limit).Find(&users).Error; err != nil {
return nil, err
}
entries := make([]LeaderboardEntry, 0, len(users))
for _, u := range users {
nick := ""
if u.Nickname != nil {
nick = *u.Nickname
}
entries = append(entries, LeaderboardEntry{
UserID: u.ID,
Nickname: nick,
Value: u.Points,
})
}
return entries, nil
}
// SearchUsers 搜索用户(按昵称或手机号)
func (s *UserService) SearchUsers(query string, limit int) ([]models.User, error) {
var users []models.User
q := "%" + query + "%"
if err := s.db.Where("nickname LIKE ? OR phone LIKE ?", q, q).
Limit(limit).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
return string(bytes), err
@@ -0,0 +1,102 @@
package services
import (
"testing"
"github.com/stretchr/testify/suite"
)
type UserServiceTestSuite struct {
ServiceTestSuite
}
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}
func (s *UserServiceTestSuite) TestRegisterByPhone() {
user, err := s.svc.RegisterByPhone("13800138000")
s.Require().NoError(err)
s.NotEmpty(user.ID)
s.Equal("13800138000", *user.Phone)
s.Equal(0, user.Points)
}
func (s *UserServiceTestSuite) TestRegisterByPhone_Duplicate() {
_, err := s.svc.RegisterByPhone("13800138001")
s.Require().NoError(err)
_, err = s.svc.RegisterByPhone("13800138001")
s.Error(err)
s.Contains(err.Error(), "已注册")
}
func (s *UserServiceTestSuite) TestGetByPhone() {
created, err := s.svc.RegisterByPhone("13800138002")
s.Require().NoError(err)
found, err := s.svc.GetByPhone("13800138002")
s.Require().NoError(err)
s.Equal(created.ID, found.ID)
}
func (s *UserServiceTestSuite) TestGetByPhone_NotFound() {
_, err := s.svc.GetByPhone("13800999999")
s.Error(err)
}
func (s *UserServiceTestSuite) TestGetByID() {
created, err := s.svc.RegisterByPhone("13800138003")
s.Require().NoError(err)
found, err := s.svc.GetByID(created.ID)
s.Require().NoError(err)
s.Equal(*created.Phone, *found.Phone)
}
func (s *UserServiceTestSuite) TestUpdateProfile() {
user, err := s.svc.RegisterByPhone("13800138004")
s.Require().NoError(err)
updated, err := s.svc.UpdateProfile(user.ID, map[string]interface{}{
"nickname": "TestUser",
"bio": "Hello",
})
s.Require().NoError(err)
s.Equal("TestUser", *updated.Nickname)
s.Equal("Hello", *updated.Bio)
}
func (s *UserServiceTestSuite) TestUpdateProfile_RejectInvalidFields() {
user, err := s.svc.RegisterByPhone("13800138005")
s.Require().NoError(err)
updated, err := s.svc.UpdateProfile(user.ID, map[string]interface{}{
"points": 9999, // points is not in the allowed list
})
s.Require().NoError(err)
s.Equal(0, updated.Points) // should not have changed
}
func (s *UserServiceTestSuite) TestDeleteAccount() {
user, err := s.svc.RegisterByPhone("13800138006")
s.Require().NoError(err)
err = s.svc.DeleteAccount(user.ID)
s.Require().NoError(err)
// After delete, the user still exists but fields are cleared
deleted, err := s.svc.GetByID(user.ID)
s.Require().NoError(err)
s.Nil(deleted.Phone)
s.Nil(deleted.Nickname)
}
func (s *UserServiceTestSuite) TestHashAndCheckPassword() {
hash, err := HashPassword("my-secret-pwd")
s.Require().NoError(err)
s.NotEmpty(hash)
s.True(CheckPassword(hash, "my-secret-pwd"))
s.False(CheckPassword(hash, "wrong-password"))
}
+71
View File
@@ -0,0 +1,71 @@
/** 格式化日期为 YYYY-MM-DD */
export function formatDate(date?: string | Date): string {
const d = date ? new Date(date) : new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 格式化日期为 YYYY-MM */
export function formatMonth(date?: string | Date): string {
const d = date ? new Date(date) : new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
/** 获取相对时间描述 */
export function timeAgo(dateStr: string): string {
if (!dateStr) return ''
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
if (diff < 2592000) return `${Math.floor(diff / 86400)}天前`
return formatDate(dateStr)
}
/** 打卡分类 */
export const CHECKIN_CATEGORY = {
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' },
} as const
export type CheckinCategory = keyof typeof CHECKIN_CATEGORY
export function getCategoryLabel(cat: string): string {
return CHECKIN_CATEGORY[cat as CheckinCategory]?.label || cat
}
/** 打卡周期 */
export const CYCLE_LABEL: Record<string, string> = {
daily: '每日',
weekly: '每周',
every_other_day: '隔日',
custom: '自定义',
}
export function getCycleLabel(cycle: string): string {
return CYCLE_LABEL[cycle] || cycle
}
/** 隐私级别 */
export function getPrivacyLabel(level: number): string {
const map: Record<number, string> = { 1: '公开', 2: '仅好友可见', 3: '仅自己可见' }
return map[level] || '未知'
}
/** 积分格式化(带 + 号) */
export function formatPoints(p: number | undefined | null): string {
if (p === undefined || p === null) return '--'
return p > 0 ? `+${p}` : String(p)
}
/** 截断字符串 */
export function truncate(str: string, len = 20): string {
return str.length > len ? str.slice(0, len) + '...' : str
}
/** ID 缩写 */
export function shortId(id: string): string {
return id.length > 8 ? id.slice(0, 8) + '...' : id
}
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock fetch
const mockFetch = vi.fn()
global.fetch = mockFetch
// Mock localStorage
const store: Record<string, string> = {}
vi.stubGlobal('localStorage', {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
})
beforeEach(() => {
mockFetch.mockReset()
Object.keys(store).forEach((k) => delete store[k])
})
function mockResponse(data: any) {
return { json: () => Promise.resolve(data) }
}
describe('Api Service', () => {
it('should send GET request with token', async () => {
store['token'] = 'test-token'
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: { id: '1' } }))
const { request } = await import('../services/api')
const result = await request('/api/test')
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/test',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
}),
)
expect(result).toEqual({ id: '1' })
})
it('should throw on non-zero code', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 400, message: 'Bad Request' }))
const { request } = await import('../services/api')
await expect(request('/api/test')).rejects.toThrow('Bad Request')
})
it('should support POST method', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: { created: true } }))
const { api } = await import('../services/api')
const result = await api.post('/api/items', { name: 'test' })
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/items',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'test' }),
}),
)
expect(result).toEqual({ created: true })
})
it('should support PUT and DELETE methods', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: null }))
const { api } = await import('../services/api')
await api.put('/api/items/1', { name: 'updated' })
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/items/1',
expect.objectContaining({ method: 'PUT' }),
)
await api.delete('/api/items/1')
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/items/1',
expect.objectContaining({ method: 'DELETE' }),
)
})
})
describe('Auth Service', () => {
it('should store and clear token', async () => {
const { setToken, clearToken, getStoredUser } = await import('../services/api')
setToken('abc')
expect(store['token']).toBe('abc')
setStoredUser({ nickname: 'Test' })
expect(getStoredUser()).toEqual({ nickname: 'Test' })
clearToken()
expect(store['token']).toBeUndefined()
})
// Helper to test the import below
const { setStoredUser } = await import('../services/api')
void setStoredUser
})
describe('CheckIn Service', () => {
it('should call createItem with proper payload', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: { id: '1', name: 'Read' } }))
const { createItem } = await import('../services/checkin')
const result = await createItem({ name: 'Read', category: 'study' })
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/checkin/items',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
name: 'Read', category: 'study', icon: undefined,
dailyGoal: undefined, cycleType: 'daily', privacy: 2,
}),
}),
)
expect(result.name).toBe('Read')
})
it('should list items', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: [{ id: '1' }, { id: '2' }] }))
const { listItems } = await import('../services/checkin')
const items = await listItems()
expect(items).toHaveLength(2)
})
it('should get streak', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: { itemId: '1', streak: 7 } }))
const { getStreak } = await import('../services/checkin')
const result = await getStreak('1')
expect(result.streak).toBe(7)
})
it('should get today status', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: { 'item-1': true, 'item-2': false } }))
const { getTodayStatus } = await import('../services/checkin')
const status = await getTodayStatus()
expect(status['item-1']).toBe(true)
expect(status['item-2']).toBe(false)
})
})
describe('Social Service', () => {
it('should create and fetch feed', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: { list: [{ id: 'p1' }], total: 1 } }))
const { getFeed } = await import('../services/social')
const feed = await getFeed()
expect(feed.list).toHaveLength(1)
expect(feed.total).toBe(1)
})
it('should send friend request', async () => {
mockFetch.mockResolvedValue(mockResponse({ code: 0, data: null }))
const { sendFriendRequest } = await import('../services/social')
await sendFriendRequest('user-2')
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/social/friends/request',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ friendId: 'user-2' }),
}),
)
})
})
describe('Shared Utils', () => {
it('formatDate should return YYYY-MM-DD', async () => {
const { formatDate } = await import('../utils/index')
expect(formatDate('2026-06-25')).toBe('2026-06-25')
expect(formatDate(new Date('2026-01-05'))).toBe('2026-01-05')
})
it('timeAgo should return relative time', async () => {
const { timeAgo } = await import('../utils/index')
const now = new Date().toISOString()
expect(timeAgo(now)).toBe('刚刚')
})
it('getCategoryLabel should return Chinese label', async () => {
const { getCategoryLabel } = await import('../utils/index')
expect(getCategoryLabel('study')).toBe('学习成长')
expect(getCategoryLabel('health')).toBe('健康生活')
expect(getCategoryLabel('unknown')).toBe('unknown')
})
it('formatPoints should format with + sign', async () => {
const { formatPoints } = await import('../utils/index')
expect(formatPoints(10)).toBe('+10')
expect(formatPoints(0)).toBe('0')
expect(formatPoints(null)).toBe('--')
})
})
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockRequest = vi.fn()
vi.mock('../services/api', () => ({
request: mockRequest,
api: {
get: (path: string) => mockRequest('get', path),
post: (path: string, data?: unknown) => mockRequest('post', path, data),
put: (path: string, data?: unknown) => mockRequest('put', path, data),
delete: (path: string) => mockRequest('delete', path),
},
}))
describe('CheckIn Service', () => {
beforeEach(() => {
mockRequest.mockReset()
})
it('should call createItem API', async () => {
mockRequest.mockResolvedValue({ id: '1', name: 'Daily Read' })
const { createItem } = await import('../services/checkin')
const result = await createItem({ name: 'Daily Read', category: 'study' })
expect(mockRequest).toHaveBeenCalledWith('post', '/api/checkin/items', {
name: 'Daily Read',
category: 'study',
icon: undefined,
dailyGoal: undefined,
cycleType: 'daily',
privacy: 2,
})
expect(result).toEqual({ id: '1', name: 'Daily Read' })
})
it('should call listItems API', async () => {
mockRequest.mockResolvedValue([{ id: '1' }])
const { listItems } = await import('../services/checkin')
const result = await listItems()
expect(mockRequest).toHaveBeenCalledWith('get', '/api/checkin/items')
expect(result).toHaveLength(1)
})
it('should call getStreak API', async () => {
mockRequest.mockResolvedValue({ itemId: '1', streak: 7 })
const { getStreak } = await import('../services/checkin')
const result = await getStreak('1')
expect(mockRequest).toHaveBeenCalledWith('get', '/api/checkin/streak/1')
expect(result.streak).toBe(7)
})
})
describe('Social Service', () => {
beforeEach(() => {
mockRequest.mockReset()
})
it('should call getFeed API', async () => {
mockRequest.mockResolvedValue({ list: [{ id: 'p1' }], total: 1, page: 1 })
const { getFeed } = await import('../services/social')
const result = await getFeed(1, 20)
expect(mockRequest).toHaveBeenCalledWith('get', '/api/social/feed?page=1&pageSize=20')
expect(result.list).toHaveLength(1)
})
})
+14
View File
@@ -0,0 +1,14 @@
export {
formatDate,
formatMonth,
timeAgo,
CHECKIN_CATEGORY,
getCategoryLabel,
getCycleLabel,
getPrivacyLabel,
formatPoints,
truncate,
shortId,
CYCLE_LABEL,
} from '../../shared/utils'
export type { CheckinCategory } from '../../shared/utils'
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
setupFiles: [],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
})