修改页面
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { api } from '../services/api'
|
import { api } from '../services/api'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import StatCard from '../components/StatCard'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<any>(null)
|
const [stats, setStats] = useState<any>(null)
|
||||||
@@ -37,27 +38,12 @@ export default function Dashboard() {
|
|||||||
<h1 className='page-title'>数据概览</h1>
|
<h1 className='page-title'>数据概览</h1>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
<div className='stat-grid'>
|
<div className="stat-grid">
|
||||||
<div className='stat-card' onClick={() => navigate('/users')} style={{ cursor: 'pointer' }}>
|
<StatCard title="总用户数" value={stats.totalUsers} icon="👥" onClick={() => navigate('/users')} />
|
||||||
<div className='stat-value'>{stats.totalUsers}</div>
|
<StatCard title="今日新增" value={stats.newUsersToday} icon="📈" color="#34C759" onClick={() => navigate('/users')} />
|
||||||
<div className='stat-label'>总用户数</div>
|
<StatCard title="总打卡次数" value={stats.totalCheckins} icon="✅" color="#7209B7" />
|
||||||
</div>
|
<StatCard title="总打卡项" value={stats.totalItems} icon="📋" />
|
||||||
<div className='stat-card' onClick={() => navigate('/users')} style={{ cursor: 'pointer' }}>
|
<StatCard title="动态总数" value={stats.totalPosts} icon="🌊" color="#4CC9F0" onClick={() => navigate('/posts')} />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
|||||||
+45
-38
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { api } from '../services/api'
|
import { api } from '../services/api'
|
||||||
|
import DataTable from '../components/DataTable'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
|
||||||
const PAGE_SIZE = 15
|
const PAGE_SIZE = 15
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ export default function Posts() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
useEffect(() => {
|
const loadPosts = useCallback(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
api.get<any>('/api/admin/posts?page=' + page + '&pageSize=' + PAGE_SIZE)
|
api.get<any>('/api/admin/posts?page=' + page + '&pageSize=' + PAGE_SIZE)
|
||||||
.then(data => { setPosts(data.list || []); setTotal(data.total || 0) })
|
.then(data => { setPosts(data.list || []); setTotal(data.total || 0) })
|
||||||
@@ -18,48 +20,53 @@ export default function Posts() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [page])
|
}, [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
<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>
|
||||||
|
|
||||||
<div className='card' style={{ padding: 0, overflow: 'hidden' }}>
|
<DataTable columns={columns} data={posts} loading={loading} emptyText="暂无动态" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
|
<Pagination page={page} totalPages={totalPages} onChange={setPage} total={total} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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; }
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
+31
-61
@@ -1,83 +1,53 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView, StatusBar } from 'react-native'
|
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 { AuthProvider, useAuth } from './src/components/AuthProvider'
|
||||||
import { getStoredToken } from './src/services/api'
|
import { getStoredToken } from './src/services/api'
|
||||||
import LoginScreen from './src/screens/LoginScreen'
|
import LoginScreen from './src/screens/LoginScreen'
|
||||||
import HomeScreen from './src/screens/HomeScreen'
|
import MainTabs from './src/navigation/MainTabs'
|
||||||
import FeedScreen from './src/screens/FeedScreen'
|
import type { RootStackParamList } from './src/navigation/types'
|
||||||
import ProfileScreen from './src/screens/ProfileScreen'
|
|
||||||
import CheckInDetailScreen from './src/screens/CheckInDetailScreen'
|
|
||||||
import CreateCheckInScreen from './src/screens/CreateCheckInScreen'
|
|
||||||
|
|
||||||
const TABS = [
|
const RootStack = createNativeStackNavigator<RootStackParamList>()
|
||||||
{ key: 'home', label: '打卡', icon: '📋' },
|
|
||||||
{ key: 'feed', label: '广场', icon: '🌊' },
|
|
||||||
{ key: 'profile', label: '我的', icon: '👤' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function TabBar({ active, onChange }: { active: string; onChange: (k: string) => void }) {
|
function AuthGate() {
|
||||||
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() {
|
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const [tab, setTab] = useState('home')
|
const [initialRoute, setInitialRoute] = useState<'Login' | 'MainTabs' | null>(null)
|
||||||
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')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
getStoredToken().then(t => setScreen(t ? 'main' : 'login'))
|
getStoredToken().then((token) => {
|
||||||
|
setInitialRoute(token ? 'MainTabs' : 'Login')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [loading])
|
}, [loading])
|
||||||
|
|
||||||
const onLogin = useCallback(() => setScreen('main'), [])
|
if (!initialRoute) {
|
||||||
|
return (
|
||||||
if (screen === 'loading') return <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}><Text style={{ color: '#86868B' }}>加载中...</Text></View>
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}>
|
||||||
if (screen === 'login') return <LoginScreen onLogin={onLogin} />
|
<Text style={{ color: '#86868B' }}>加载中...</Text>
|
||||||
if (selectedItemId) return <CheckInDetailScreen itemId={selectedItemId} onBack={() => setSelectedItemId(null)} />
|
</View>
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
|
<RootStack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
<StatusBar barStyle="dark-content" background="#F5F5F7" />
|
<RootStack.Screen name="Login" component={LoginScreen} />
|
||||||
{renderScreen()}
|
<RootStack.Screen name="MainTabs" component={MainTabs} />
|
||||||
<TabBar active={tab} onChange={setTab} />
|
</RootStack.Navigator>
|
||||||
</SafeAreaView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<SafeAreaProvider>
|
||||||
<MainApp />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<NavigationContainer>
|
||||||
|
<StatusBar barStyle="dark-content" background="#F5F5F7" />
|
||||||
|
<AuthGate />
|
||||||
|
</NavigationContainer>
|
||||||
|
</AuthProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// Mock AsyncStorage
|
||||||
|
const storage: Record<string, string> = {}
|
||||||
|
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
|
default: {
|
||||||
|
getItem: vi.fn((key: string) => Promise.resolve(storage[key] || null)),
|
||||||
|
setItem: vi.fn((key: string, val: string) => { storage[key] = val; return Promise.resolve() }),
|
||||||
|
removeItem: vi.fn((key: string) => { delete storage[key]; return Promise.resolve() }),
|
||||||
|
multiRemove: vi.fn((keys: string[]) => { keys.forEach(k => delete storage[k]); return Promise.resolve() }),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.keys(storage).forEach(k => delete storage[k])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== API Service ==========
|
||||||
|
describe('Mobile API Service', () => {
|
||||||
|
it('should export api methods', async () => {
|
||||||
|
const mod = await import('../services/api')
|
||||||
|
expect(mod.api).toBeDefined()
|
||||||
|
expect(typeof mod.api.get).toBe('function')
|
||||||
|
expect(typeof mod.api.post).toBe('function')
|
||||||
|
expect(typeof mod.api.put).toBe('function')
|
||||||
|
expect(typeof mod.api.del).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export auth helpers', async () => {
|
||||||
|
const mod = await import('../services/api')
|
||||||
|
expect(typeof mod.saveToken).toBe('function')
|
||||||
|
expect(typeof mod.getStoredUser).toBe('function')
|
||||||
|
expect(typeof mod.getStoredToken).toBe('function')
|
||||||
|
expect(typeof mod.clearAuth).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveToken + getStoredUser should persist auth', async () => {
|
||||||
|
const { saveToken, getStoredUser, getStoredToken } = await import('../services/api')
|
||||||
|
await saveToken('test-token', { id: '1', nickname: 'Tester', points: 100 })
|
||||||
|
|
||||||
|
const token = await getStoredToken()
|
||||||
|
expect(token).toBe('test-token')
|
||||||
|
|
||||||
|
const user = await getStoredUser()
|
||||||
|
expect(user.nickname).toBe('Tester')
|
||||||
|
expect(user.points).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearAuth should remove all auth data', async () => {
|
||||||
|
const { saveToken, clearAuth, getStoredToken, getStoredUser } = await import('../services/api')
|
||||||
|
await saveToken('t', { id: '1', nickname: 'T' })
|
||||||
|
await clearAuth()
|
||||||
|
|
||||||
|
expect(await getStoredToken()).toBeNull()
|
||||||
|
expect(await getStoredUser()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== AuthProvider ==========
|
||||||
|
describe('AuthProvider', () => {
|
||||||
|
it('should export AuthProvider and useAuth', async () => {
|
||||||
|
const mod = await import('../components/AuthProvider')
|
||||||
|
expect(mod.AuthProvider).toBeDefined()
|
||||||
|
expect(typeof mod.useAuth).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== CheckIn Module ==========
|
||||||
|
describe('CheckIn Module', () => {
|
||||||
|
it('should export API functions', async () => {
|
||||||
|
const mod = await import('../modules/checkin/index')
|
||||||
|
expect(typeof mod.createItem).toBe('function')
|
||||||
|
expect(typeof mod.listItems).toBe('function')
|
||||||
|
expect(typeof mod.getTodayStatus).toBe('function')
|
||||||
|
expect(typeof mod.getStreak).toBe('function')
|
||||||
|
expect(typeof mod.getRecords).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export hooks', async () => {
|
||||||
|
const mod = await import('../modules/checkin/index')
|
||||||
|
expect(typeof mod.useCheckInItems).toBe('function')
|
||||||
|
expect(typeof mod.useCheckInStatus).toBe('function')
|
||||||
|
expect(typeof mod.useCheckInDetail).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export category constants', async () => {
|
||||||
|
const mod = await import('../modules/checkin/index')
|
||||||
|
expect(mod.CATEGORY_CONFIG.study.label).toBe('学习成长')
|
||||||
|
expect(mod.CATEGORY_CONFIG.health.icon).toBe('❤️')
|
||||||
|
expect(mod.CATEGORY_CONFIG.goal.color).toBe('#FF6B35')
|
||||||
|
expect(mod.CYCLE_LABEL.daily).toBe('每日')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== Social Module ==========
|
||||||
|
describe('Social Module', () => {
|
||||||
|
it('should export API functions', async () => {
|
||||||
|
const mod = await import('../modules/social/index')
|
||||||
|
expect(typeof mod.createPost).toBe('function')
|
||||||
|
expect(typeof mod.getFeed).toBe('function')
|
||||||
|
expect(typeof mod.likePost).toBe('function')
|
||||||
|
expect(typeof mod.listFriends).toBe('function')
|
||||||
|
expect(typeof mod.sendFriendRequest).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export hooks', async () => {
|
||||||
|
const mod = await import('../modules/social/index')
|
||||||
|
expect(typeof mod.useFeed).toBe('function')
|
||||||
|
expect(typeof mod.useFriends).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== Profile Module ==========
|
||||||
|
describe('Profile Module', () => {
|
||||||
|
it('should export API functions', async () => {
|
||||||
|
const mod = await import('../modules/profile/index')
|
||||||
|
expect(typeof mod.getAchievements).toBe('function')
|
||||||
|
expect(typeof mod.updateProfile).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export hooks', async () => {
|
||||||
|
const mod = await import('../modules/profile/index')
|
||||||
|
expect(typeof mod.useProfile).toBe('function')
|
||||||
|
expect(typeof mod.useAchievements).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== Discover Module ==========
|
||||||
|
describe('Discover Module', () => {
|
||||||
|
it('should export API functions', async () => {
|
||||||
|
const mod = await import('../modules/discover/index')
|
||||||
|
expect(typeof mod.getLeaderboard).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export hooks', async () => {
|
||||||
|
const mod = await import('../modules/discover/index')
|
||||||
|
expect(typeof mod.useLeaderboard).toBe('function')
|
||||||
|
expect(typeof mod.useDiscover).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// Mock AsyncStorage
|
||||||
|
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
|
default: {
|
||||||
|
getItem: vi.fn(() => Promise.resolve(null)),
|
||||||
|
setItem: vi.fn(() => Promise.resolve()),
|
||||||
|
removeItem: vi.fn(() => Promise.resolve()),
|
||||||
|
multiRemove: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Mobile API Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export api with get/post/put/del methods', async () => {
|
||||||
|
const mod = await import('../services/api')
|
||||||
|
expect(mod.api).toBeDefined()
|
||||||
|
expect(typeof mod.api.get).toBe('function')
|
||||||
|
expect(typeof mod.api.post).toBe('function')
|
||||||
|
expect(typeof mod.api.put).toBe('function')
|
||||||
|
expect(typeof mod.api.del).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export auth helper functions', async () => {
|
||||||
|
const mod = await import('../services/api')
|
||||||
|
expect(typeof mod.saveToken).toBe('function')
|
||||||
|
expect(typeof mod.getStoredUser).toBe('function')
|
||||||
|
expect(typeof mod.clearAuth).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export AuthProvider and useAuth', async () => {
|
||||||
|
const mod = await import('../components/AuthProvider')
|
||||||
|
expect(mod.AuthProvider).toBeDefined()
|
||||||
|
expect(typeof mod.useAuth).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../../services/api'
|
||||||
|
|
||||||
|
// ========== 类型定义 ==========
|
||||||
|
export type CheckInCategory = 'study' | 'health' | 'habit' | 'hobby' | 'goal'
|
||||||
|
export type CycleType = 'daily' | 'weekly' | 'every_other_day' | 'custom'
|
||||||
|
export type ItemStatus = 'active' | 'paused' | 'archived' | 'deleted'
|
||||||
|
|
||||||
|
export interface ICheckInItem {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
category: CheckInCategory
|
||||||
|
dailyGoal?: string
|
||||||
|
cycleType: CycleType
|
||||||
|
privacy: number
|
||||||
|
status: ItemStatus
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICheckInRecord {
|
||||||
|
id: string
|
||||||
|
itemId: string
|
||||||
|
userId: string
|
||||||
|
checkDate: string
|
||||||
|
status: 'normal' | 'makeup'
|
||||||
|
note?: string
|
||||||
|
images?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStreak {
|
||||||
|
itemId: string
|
||||||
|
streak: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMonthlyStats {
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
total: number
|
||||||
|
records: ICheckInRecord[]
|
||||||
|
streaks: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_CONFIG: Record<CheckInCategory, { label: string; icon: string; color: string }> = {
|
||||||
|
study: { label: '学习成长', icon: '📚', color: '#4CC9F0' },
|
||||||
|
health: { label: '健康生活', icon: '❤️', color: '#06D6A0' },
|
||||||
|
habit: { label: '习惯自律', icon: '🔄', color: '#7209B7' },
|
||||||
|
hobby: { label: '兴趣爱好', icon: '⭐', color: '#F72585' },
|
||||||
|
goal: { label: '目标任务', icon: '🎯', color: '#FF6B35' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CYCLE_LABEL: Record<string, string> = {
|
||||||
|
daily: '每日',
|
||||||
|
weekly: '每周',
|
||||||
|
every_other_day: '隔日',
|
||||||
|
custom: '自定义',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 创建打卡项 ==========
|
||||||
|
export type CreateItemInput = {
|
||||||
|
name: string
|
||||||
|
category: CheckInCategory
|
||||||
|
icon?: string
|
||||||
|
dailyGoal?: string
|
||||||
|
cycleType?: CycleType
|
||||||
|
privacy?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createItem(data: CreateItemInput): Promise<ICheckInItem> {
|
||||||
|
return api.post<ICheckInItem>('/api/checkin/items', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listItems(): Promise<ICheckInItem[]> {
|
||||||
|
return api.get<ICheckInItem[]>('/api/checkin/items')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItem(id: string): Promise<ICheckInItem> {
|
||||||
|
return api.get<ICheckInItem>(`/api/checkin/items/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItem(id: string, data: Partial<ICheckInItem>): Promise<ICheckInItem> {
|
||||||
|
return api.put<ICheckInItem>(`/api/checkin/items/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItem(id: string): Promise<void> {
|
||||||
|
return api.del(`/api/checkin/items/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pauseItem(id: string): Promise<void> {
|
||||||
|
return api.post(`/api/checkin/items/${id}/pause`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resumeItem(id: string): Promise<void> {
|
||||||
|
return api.post(`/api/checkin/items/${id}/resume`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 打卡记录 ==========
|
||||||
|
export async function createRecord(itemId: string, note?: string): Promise<ICheckInRecord> {
|
||||||
|
return api.post<ICheckInRecord>('/api/checkin/records', { itemId, note })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeupRecord(itemId: string, checkDate: string): Promise<ICheckInRecord> {
|
||||||
|
return api.post<ICheckInRecord>('/api/checkin/records/makeup', { itemId, checkDate })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecords(params: { itemId?: string; start?: string; end?: string }): Promise<ICheckInRecord[]> {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.itemId) qs.set('itemId', params.itemId)
|
||||||
|
if (params.start) qs.set('start', params.start)
|
||||||
|
if (params.end) qs.set('end', params.end)
|
||||||
|
return api.get<ICheckInRecord[]>(`/api/checkin/records?${qs}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 统计 ==========
|
||||||
|
export async function getStreak(itemId: string): Promise<IStreak> {
|
||||||
|
return api.get<IStreak>(`/api/checkin/streak/${itemId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTodayStatus(): Promise<Record<string, boolean>> {
|
||||||
|
return api.get<Record<string, boolean>>('/api/checkin/today-status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMonthlyStats(year: number, month: number): Promise<IMonthlyStats> {
|
||||||
|
return api.get<IMonthlyStats>(`/api/checkin/stats/monthly?year=${year}&month=${month}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 打卡列表 ==========
|
||||||
|
export function useCheckInItems() {
|
||||||
|
const [items, setItems] = useState<ICheckInItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await listItems()
|
||||||
|
setItems(data.filter((i) => i.status !== 'deleted'))
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return { items, loading, error, reload: load }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 今日状态 + 连续天数 ==========
|
||||||
|
export function useCheckInStatus(items: ICheckInItem[]) {
|
||||||
|
const [todayStatus, setTodayStatus] = useState<Record<string, boolean>>({})
|
||||||
|
const [streaks, setStreaks] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [ts] = await Promise.all([getTodayStatus()])
|
||||||
|
setTodayStatus(ts || {})
|
||||||
|
const s: Record<string, number> = {}
|
||||||
|
await Promise.all(
|
||||||
|
items
|
||||||
|
.filter((i) => i.status === 'active')
|
||||||
|
.map(async (item) => {
|
||||||
|
try {
|
||||||
|
const r = await getStreak(item.id)
|
||||||
|
s[item.id] = r.streak
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setStreaks(s)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0) load()
|
||||||
|
}, [items, load])
|
||||||
|
|
||||||
|
return { todayStatus, streaks, reload: load }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 打卡详情 ==========
|
||||||
|
export function useCheckInDetail(itemId: string) {
|
||||||
|
const [item, setItem] = useState<ICheckInItem | null>(null)
|
||||||
|
const [streak, setStreak] = useState(0)
|
||||||
|
const [records, setRecords] = useState<ICheckInRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [itemData, streakData, recordsData] = await Promise.all([
|
||||||
|
getItem(itemId),
|
||||||
|
getStreak(itemId),
|
||||||
|
getRecords({
|
||||||
|
itemId,
|
||||||
|
start: new Date().toISOString().slice(0, 7) + '-01',
|
||||||
|
end: new Date().toISOString().slice(0, 10),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setItem(itemData)
|
||||||
|
setStreak(streakData.streak)
|
||||||
|
setRecords(recordsData || [])
|
||||||
|
} catch {
|
||||||
|
// handled by caller
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [itemId])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return { item, streak, records, loading, reload: load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../../services/api'
|
||||||
|
|
||||||
|
// ========== 排行榜 ==========
|
||||||
|
export interface IRankEntry {
|
||||||
|
rank: number
|
||||||
|
userId: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RankType = 'streak' | 'points' | 'checkin_count'
|
||||||
|
|
||||||
|
export async function getLeaderboard(type: RankType, limit = 20): Promise<IRankEntry[]> {
|
||||||
|
// 后端暂只支持 points 排行,type 参数预留
|
||||||
|
return api.get<IRankEntry[]>(`/api/leaderboard?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 推荐用户 ==========
|
||||||
|
export async function getRecommendedUsers(): Promise<{ id: string; nickname?: string; avatar?: string; bio?: string }[]> {
|
||||||
|
return api.get('/api/discover/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 排行榜 ==========
|
||||||
|
export function useLeaderboard(type: RankType = 'streak') {
|
||||||
|
const [entries, setEntries] = useState<IRankEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await getLeaderboard(type)
|
||||||
|
setEntries(data || [])
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [type])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return { entries, loading, reload: load }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 发现页 ==========
|
||||||
|
export function useDiscover() {
|
||||||
|
const [leaderboard, setLeaderboard] = useState<IRankEntry[]>([])
|
||||||
|
const [recommended, setRecommended] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [lb, rec] = await Promise.all([
|
||||||
|
getLeaderboard('streak', 10),
|
||||||
|
getRecommendedUsers(),
|
||||||
|
])
|
||||||
|
setLeaderboard(lb || [])
|
||||||
|
setRecommended(rec || [])
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return { leaderboard, recommended, loading, reload: load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../../services/api'
|
||||||
|
import { useAuth } from '../../components/AuthProvider'
|
||||||
|
|
||||||
|
// ========== 成就 ==========
|
||||||
|
export interface IAchievement {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
condition: string
|
||||||
|
rarity: string
|
||||||
|
unlocked?: boolean
|
||||||
|
unlockedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAchievements(): Promise<IAchievement[]> {
|
||||||
|
return api.get<IAchievement[]>('/api/achievements')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 用户资料 API ==========
|
||||||
|
export async function updateProfile(updates: Record<string, any>): Promise<any> {
|
||||||
|
return api.put('/api/users/profile', updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserPoints(): Promise<{ points: number }> {
|
||||||
|
return api.get<{ points: number }>('/api/users/points')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 个人统计与成就 ==========
|
||||||
|
export function useProfile() {
|
||||||
|
const { user, refreshUser } = useAuth()
|
||||||
|
const [achievements, setAchievements] = useState<IAchievement[]>([])
|
||||||
|
const [points, setPoints] = useState(0)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [ach, pts] = await Promise.all([
|
||||||
|
getAchievements(),
|
||||||
|
getUserPoints(),
|
||||||
|
])
|
||||||
|
setAchievements(ach || [])
|
||||||
|
setPoints(pts?.points ?? 0)
|
||||||
|
} catch {
|
||||||
|
// features may not be ready yet
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return { user, achievements, points, updateProfile, refreshUser, reload: load }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 成就列表 ==========
|
||||||
|
export function useAchievements() {
|
||||||
|
const [achievements, setAchievements] = useState<IAchievement[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await getAchievements()
|
||||||
|
setAchievements(data || [])
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return { achievements, loading, reload: load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '../../services/api'
|
||||||
|
|
||||||
|
// ========== 类型定义 ==========
|
||||||
|
export interface IPost {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
content?: string
|
||||||
|
images?: string
|
||||||
|
privacy: number
|
||||||
|
likes: number
|
||||||
|
comments: number
|
||||||
|
createdAt: string
|
||||||
|
user?: { nickname?: string; avatar?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUserBrief {
|
||||||
|
id: string
|
||||||
|
nickname?: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGroup {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
cover?: string
|
||||||
|
intro?: string
|
||||||
|
type: string
|
||||||
|
maxMember: number
|
||||||
|
createdBy: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 动态 API ==========
|
||||||
|
export async function createPost(data: { content?: string; images?: string; privacy?: number }): Promise<IPost> {
|
||||||
|
return api.post<IPost>('/api/social/posts', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeed(page = 1, pageSize = 20): Promise<{ list: IPost[]; total: number; page: number }> {
|
||||||
|
return api.get(`/api/social/feed?page=${page}&pageSize=${pageSize}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function likePost(postId: string): Promise<void> {
|
||||||
|
return api.post(`/api/social/posts/${postId}/like`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePost(postId: string): Promise<void> {
|
||||||
|
return api.del(`/api/social/posts/${postId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 好友 API ==========
|
||||||
|
export async function sendFriendRequest(friendId: string): Promise<void> {
|
||||||
|
return api.post('/api/social/friends/request', { friendId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptFriendRequest(friendId: string): Promise<void> {
|
||||||
|
return api.post('/api/social/friends/accept', { friendId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFriend(userId: string): Promise<void> {
|
||||||
|
return api.del(`/api/social/friends/${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFriends(): Promise<IUserBrief[]> {
|
||||||
|
return api.get<IUserBrief[]>('/api/social/friends')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 小组 API ==========
|
||||||
|
export async function createGroup(data: Partial<IGroup>): Promise<IGroup> {
|
||||||
|
return api.post<IGroup>('/api/social/groups', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinGroup(groupId: string): Promise<void> {
|
||||||
|
return api.post(`/api/social/groups/${groupId}/join`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveGroup(groupId: string): Promise<void> {
|
||||||
|
return api.post(`/api/social/groups/${groupId}/leave`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupMembers(groupId: string): Promise<{ groupId: string; userId: string; role: string; joinedAt: string }[]> {
|
||||||
|
return api.get(`/api/social/groups/${groupId}/members`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 动态 Feed ==========
|
||||||
|
export function useFeed() {
|
||||||
|
const [posts, setPosts] = useState<IPost[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await getFeed()
|
||||||
|
setPosts(data.list || [])
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const toggleLike = useCallback(async (postId: string) => {
|
||||||
|
try {
|
||||||
|
await likePost(postId)
|
||||||
|
setPosts((prev) =>
|
||||||
|
prev.map((p) => (p.id === postId ? { ...p, likes: p.likes + 1 } : p)),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const publishPost = useCallback(async (content: string) => {
|
||||||
|
await createPost({ content, privacy: 1 })
|
||||||
|
await load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return { posts, loading, reload: load, toggleLike, publishPost }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook: 好友列表 ==========
|
||||||
|
export function useFriends() {
|
||||||
|
const [friends, setFriends] = useState<IUserBrief[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await listFriends()
|
||||||
|
setFriends(data || [])
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
return { friends, loading, reload: load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||||
|
import type { FeedStackParamList } from './types'
|
||||||
|
import FeedScreen from '../screens/FeedScreen'
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<FeedStackParamList>()
|
||||||
|
|
||||||
|
export default function FeedStack() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="FeedMain" component={FeedScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||||
|
import type { HomeStackParamList } from './types'
|
||||||
|
import HomeScreen from '../screens/HomeScreen'
|
||||||
|
import CheckInDetailScreen from '../screens/CheckInDetailScreen'
|
||||||
|
import CreateCheckInScreen from '../screens/CreateCheckInScreen'
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<HomeStackParamList>()
|
||||||
|
|
||||||
|
export default function HomeStack() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="HomeMain" component={HomeScreen} />
|
||||||
|
<Stack.Screen name="CheckInDetail" component={CheckInDetailScreen} />
|
||||||
|
<Stack.Screen name="CreateCheckIn" component={CreateCheckInScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
|
||||||
|
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||||
|
import type { MainTabParamList } from './types'
|
||||||
|
import HomeStack from './HomeStack'
|
||||||
|
import FeedStack from './FeedStack'
|
||||||
|
import ProfileStack from './ProfileStack'
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator<MainTabParamList>()
|
||||||
|
|
||||||
|
export default function MainTabs() {
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: '#FF6B35',
|
||||||
|
tabBarInactiveTintColor: '#86868B',
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: 'rgba(245,245,247,0.95)',
|
||||||
|
borderTopColor: '#E5E5EA',
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
paddingTop: 6,
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="HomeTab"
|
||||||
|
component={HomeStack}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: '打卡',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="FeedTab"
|
||||||
|
component={FeedStack}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: '广场',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="newspaper" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="ProfileTab"
|
||||||
|
component={ProfileStack}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: '我的',
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||||
|
import type { ProfileStackParamList } from './types'
|
||||||
|
import ProfileScreen from '../screens/ProfileScreen'
|
||||||
|
import AchievementsScreen from '../screens/AchievementsScreen'
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<ProfileStackParamList>()
|
||||||
|
|
||||||
|
export default function ProfileStack() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="ProfileMain" component={ProfileScreen} />
|
||||||
|
<Stack.Screen name="Achievements" component={AchievementsScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'
|
||||||
|
import type { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native'
|
||||||
|
|
||||||
|
// ========== Home Stack ==========
|
||||||
|
export type HomeStackParamList = {
|
||||||
|
HomeMain: undefined
|
||||||
|
CheckInDetail: { itemId: string }
|
||||||
|
CreateCheckIn: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Feed Stack ==========
|
||||||
|
export type FeedStackParamList = {
|
||||||
|
FeedMain: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Profile Stack ==========
|
||||||
|
export type ProfileStackParamList = {
|
||||||
|
ProfileMain: undefined
|
||||||
|
Achievements: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Tab Navigator ==========
|
||||||
|
export type MainTabParamList = {
|
||||||
|
HomeTab: NavigatorScreenParams<HomeStackParamList>
|
||||||
|
FeedTab: NavigatorScreenParams<FeedStackParamList>
|
||||||
|
ProfileTab: NavigatorScreenParams<ProfileStackParamList>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Root Navigator ==========
|
||||||
|
export type RootStackParamList = {
|
||||||
|
Login: undefined
|
||||||
|
MainTabs: NavigatorScreenParams<MainTabParamList>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Screen Prop Helpers ==========
|
||||||
|
export type HomeScreenProps = CompositeScreenProps<
|
||||||
|
NativeStackScreenProps<HomeStackParamList, 'HomeMain'>,
|
||||||
|
BottomTabScreenProps<MainTabParamList>
|
||||||
|
>
|
||||||
|
|
||||||
|
export type CheckInDetailScreenProps = NativeStackScreenProps<HomeStackParamList, 'CheckInDetail'>
|
||||||
|
|
||||||
|
export type CreateCheckInScreenProps = NativeStackScreenProps<HomeStackParamList, 'CreateCheckIn'>
|
||||||
|
|
||||||
|
export type FeedScreenProps = CompositeScreenProps<
|
||||||
|
NativeStackScreenProps<FeedStackParamList, 'FeedMain'>,
|
||||||
|
BottomTabScreenProps<MainTabParamList>
|
||||||
|
>
|
||||||
|
|
||||||
|
export type ProfileScreenProps = CompositeScreenProps<
|
||||||
|
NativeStackScreenProps<ProfileStackParamList, 'ProfileMain'>,
|
||||||
|
BottomTabScreenProps<MainTabParamList>
|
||||||
|
>
|
||||||
|
|
||||||
|
export type LoginScreenProps = NativeStackScreenProps<RootStackParamList, 'Login'>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
|
||||||
|
interface IAchievement {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
condition: string
|
||||||
|
rarity: string
|
||||||
|
unlocked?: boolean
|
||||||
|
unlockedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RARITY_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
bronze: { label: '青铜', color: '#CD7F32' },
|
||||||
|
silver: { label: '白银', color: '#C0C0C0' },
|
||||||
|
gold: { label: '黄金', color: '#FFD700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AchievementsScreen() {
|
||||||
|
const [achievements, setAchievements] = useState<IAchievement[]>([])
|
||||||
|
const [points, setPoints] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get<IAchievement[]>('/api/achievements'),
|
||||||
|
api.get<{ points: number }>('/api/users/points'),
|
||||||
|
])
|
||||||
|
.then(([ach, pts]) => {
|
||||||
|
setAchievements(ach || [])
|
||||||
|
setPoints(pts?.points ?? 0)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F5F7' }}>
|
||||||
|
<ActivityIndicator size="large" color="#FF6B35" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlocked = achievements.filter((a) => a.unlocked)
|
||||||
|
const locked = achievements.filter((a) => !a.unlocked)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
|
||||||
|
{/* 积分头部 */}
|
||||||
|
<View style={styles.pointsHeader}>
|
||||||
|
<Text style={styles.pointsValue}>{points}</Text>
|
||||||
|
<Text style={styles.pointsLabel}>总积分</Text>
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statNum}>{unlocked.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>已解锁</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statItem, { borderRightWidth: 0 }]}>
|
||||||
|
<Text style={styles.statNum}>{achievements.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>全部</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 24 }}>
|
||||||
|
{/* 已解锁 */}
|
||||||
|
{unlocked.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>已解锁成就 ({unlocked.length})</Text>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{unlocked.map((a) => (
|
||||||
|
<View key={a.id} style={[styles.badge, { borderColor: RARITY_CONFIG[a.rarity]?.color || '#E5E5EA' }]}>
|
||||||
|
<Text style={styles.badgeIcon}>{a.icon || '🏅'}</Text>
|
||||||
|
<Text style={styles.badgeName}>{a.name}</Text>
|
||||||
|
<Text style={[styles.badgeRarity, { color: RARITY_CONFIG[a.rarity]?.color || '#86868B' }]}>
|
||||||
|
{RARITY_CONFIG[a.rarity]?.label || a.rarity}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 未解锁 */}
|
||||||
|
{locked.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>待解锁 ({locked.length})</Text>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{locked.map((a) => (
|
||||||
|
<View key={a.id} style={[styles.badge, styles.lockedBadge]}>
|
||||||
|
<Text style={[styles.badgeIcon, { opacity: 0.3 }]}>🔒</Text>
|
||||||
|
<Text style={[styles.badgeName, { color: '#86868B' }]}>{a.name}</Text>
|
||||||
|
<Text style={{ fontSize: 10, color: '#C7C7CC', textAlign: 'center' }}>{a.condition}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{achievements.length === 0 && (
|
||||||
|
<View style={{ alignItems: 'center', padding: 60 }}>
|
||||||
|
<Text style={{ fontSize: 40 }}>🏆</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: '#86868B', marginTop: 8 }}>暂无成就数据</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pointsHeader: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 28,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
pointsValue: { fontSize: 48, fontWeight: '700', color: '#FF6B35' },
|
||||||
|
pointsLabel: { fontSize: 13, color: '#86868B', marginTop: 2 },
|
||||||
|
statsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 16,
|
||||||
|
backgroundColor: '#F5F5F7',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRightWidth: 0.5,
|
||||||
|
borderRightColor: '#E5E5EA',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statNum: { fontSize: 18, fontWeight: '700', color: '#1D1D1F' },
|
||||||
|
statLabel: { fontSize: 12, color: '#86868B' },
|
||||||
|
sectionTitle: { fontSize: 17, fontWeight: '600', margin: 16, marginBottom: 12 },
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
width: '30%',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
lockedBadge: { borderColor: '#E5E5EA', opacity: 0.7 },
|
||||||
|
badgeIcon: { fontSize: 28 },
|
||||||
|
badgeName: { fontSize: 12, fontWeight: '600', textAlign: 'center' },
|
||||||
|
badgeRarity: { fontSize: 10, fontWeight: '600' },
|
||||||
|
})
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'
|
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 { 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 [item, setItem] = useState<any>(null)
|
||||||
const [streak, setStreak] = useState(0)
|
const [streak, setStreak] = useState(0)
|
||||||
const [records, setRecords] = useState<any[]>([])
|
const [records, setRecords] = useState<any[]>([])
|
||||||
@@ -25,7 +32,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string
|
|||||||
Alert.alert('确认删除', '确定删除这个打卡项?', [
|
Alert.alert('确认删除', '确定删除这个打卡项?', [
|
||||||
{ text: '取消', style: 'cancel' },
|
{ text: '取消', style: 'cancel' },
|
||||||
{ text: '删除', style: 'destructive', onPress: async () => {
|
{ 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('错误', '删除失败') }
|
catch { Alert.alert('错误', '删除失败') }
|
||||||
}},
|
}},
|
||||||
])
|
])
|
||||||
@@ -36,7 +43,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string
|
|||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
|
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
|
||||||
<View style={styles.nav}>
|
<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>
|
<Text style={styles.navTitle}>{item?.name || '打卡详情'}</Text>
|
||||||
<View style={{ width: 60 }} />
|
<View style={{ width: 60 }} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native'
|
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 { api } from '../services/api'
|
||||||
|
import type { HomeStackParamList } from '../navigation/types'
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ key: 'study', label: '📚 学习' },
|
{ key: 'study', label: '📚 学习' },
|
||||||
@@ -10,7 +13,8 @@ const CATEGORIES = [
|
|||||||
{ key: 'goal', label: '🎯 目标' },
|
{ 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 [name, setName] = useState('')
|
||||||
const [category, setCategory] = useState('study')
|
const [category, setCategory] = useState('study')
|
||||||
const [cycleType, setCycleType] = useState('daily')
|
const [cycleType, setCycleType] = useState('daily')
|
||||||
@@ -21,8 +25,8 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: ()
|
|||||||
if (!name.trim()) { Alert.alert('提示', '请输入打卡项名称'); return }
|
if (!name.trim()) { Alert.alert('提示', '请输入打卡项名称'); return }
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined })
|
await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined })
|
||||||
Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { onBack(); onCreated?.() } }])
|
Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { navigation.goBack() } }])
|
||||||
} catch (e: any) { Alert.alert('错误', e.message || '创建失败') }
|
} catch (e: any) { Alert.alert('错误', e.message || '创建失败') }
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -30,7 +34,7 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: ()
|
|||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
|
<View style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
|
||||||
<View style={styles.nav}>
|
<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>
|
<Text style={styles.navTitle}>创建打卡项</Text>
|
||||||
<View style={{ width: 60 }} />
|
<View style={{ width: 60 }} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react'
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native'
|
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 { api } from '../services/api'
|
||||||
import { useAuth } from '../components/AuthProvider'
|
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 { user } = useAuth()
|
||||||
const [items, setItems] = useState<any[]>([])
|
const [items, setItems] = useState<any[]>([])
|
||||||
const [streaks, setStreaks] = useState<Record<string, number>>({})
|
const [streaks, setStreaks] = useState<Record<string, number>>({})
|
||||||
@@ -42,7 +46,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte
|
|||||||
|
|
||||||
<View style={s.sectionHeader}>
|
<View style={s.sectionHeader}>
|
||||||
<Text style={s.sectionTitle}>今日打卡</Text>
|
<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>
|
</View>
|
||||||
|
|
||||||
{activeItems.length === 0 ? (
|
{activeItems.length === 0 ? (
|
||||||
@@ -52,7 +56,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte
|
|||||||
<Text style={{ fontSize: 13, color: '#86868B', marginTop: 4 }}>点击上方"创建"开始</Text>
|
<Text style={{ fontSize: 13, color: '#86868B', marginTop: 4 }}>点击上方"创建"开始</Text>
|
||||||
</View>
|
</View>
|
||||||
) : activeItems.map((item: any) => (
|
) : 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.card}>
|
||||||
<View style={s.cardIcon}><Text style={{ fontSize: 22 }}>{item.icon || '📋'}</Text></View>
|
<View style={s.cardIcon}><Text style={{ fontSize: 22 }}>{item.icon || '📋'}</Text></View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform } from 'react-native'
|
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 { api, saveToken } from '../services/api'
|
||||||
import { useAuth } from '../components/AuthProvider'
|
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 [phone, setPhone] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -14,7 +18,7 @@ export default function LoginScreen({ onLogin }: { onLogin: () => void }) {
|
|||||||
try {
|
try {
|
||||||
const result = await api.post<{ user: any; token: string }>('/api/auth/login', { phone })
|
const result = await api.post<{ user: any; token: string }>('/api/auth/login', { phone })
|
||||||
await login(result.token, result.user)
|
await login(result.token, result.user)
|
||||||
onLogin()
|
navigation.replace('MainTabs')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('登录失败', e.message || '请稍后重试')
|
Alert.alert('登录失败', e.message || '请稍后重试')
|
||||||
} finally { setLoading(false) }
|
} finally { setLoading(false) }
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native'
|
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 { useAuth } from '../components/AuthProvider'
|
||||||
|
import type { ProfileStackParamList } from '../navigation/types'
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<ProfileStackParamList, 'ProfileMain'>>()
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
Alert.alert('确认退出', '确定要退出登录吗?', [
|
Alert.alert('确认退出', '确定要退出登录吗?', [
|
||||||
@@ -27,7 +31,7 @@ export default function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.menu}>
|
<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}>
|
<TouchableOpacity style={[styles.menuItem, { borderBottomWidth: 0 }]} onPress={handleLogout}>
|
||||||
<Text style={{ color: '#FF3B30' }}>退出登录</Text><Text style={styles.arrow}>→</Text>
|
<Text style={{ color: '#FF3B30' }}>退出登录</Text><Text style={styles.arrow}>→</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
Binary file not shown.
+14
-2
@@ -5,11 +5,13 @@ go 1.22
|
|||||||
require (
|
require (
|
||||||
github.com/gin-contrib/cors v1.7.2
|
github.com/gin-contrib/cors v1.7.2
|
||||||
github.com/gin-gonic/gin v1.10.0
|
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/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.23.0
|
golang.org/x/crypto v0.23.0
|
||||||
gorm.io/driver/mysql v1.5.6
|
gorm.io/driver/mysql v1.5.6
|
||||||
gorm.io/gorm v1.25.10
|
gorm.io/gorm v1.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -17,13 +19,17 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // 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/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.25.0 // indirect
|
||||||
golang.org/x/sys v0.20.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
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.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
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
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=
|
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-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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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=
|
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 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
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/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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
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=
|
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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
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/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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
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=
|
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 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
|
||||||
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
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.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
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=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/inchstep/server/internal/middleware"
|
"github.com/inchstep/server/internal/middleware"
|
||||||
"github.com/inchstep/server/internal/services"
|
"github.com/inchstep/server/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
userSvc *services.UserService
|
userSvc *services.UserService
|
||||||
@@ -104,12 +105,44 @@
|
|||||||
middleware.Success(c, nil)
|
middleware.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) GetPoints(c *gin.Context) {
|
func (h *UserHandler) GetPoints(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
userID := middleware.GetUserID(c)
|
||||||
user, err := h.userSvc.GetByID(userID)
|
user, err := h.userSvc.GetByID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.Error(c, http.StatusNotFound, 404, "用户不存在")
|
middleware.Error(c, http.StatusNotFound, 404, "用户不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
middleware.Success(c, gin.H{"points": user.Points})
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ func Setup(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
user.PUT("/profile", userH.UpdateProfile)
|
user.PUT("/profile", userH.UpdateProfile)
|
||||||
user.DELETE("/account", userH.DeleteAccount)
|
user.DELETE("/account", userH.DeleteAccount)
|
||||||
user.GET("/points", userH.GetPoints)
|
user.GET("/points", userH.GetPoints)
|
||||||
|
user.GET("/search", userH.SearchUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排行榜(公开数据,需登录)
|
||||||
|
authed.GET("/leaderboard", userH.GetLeaderboard)
|
||||||
|
|
||||||
// 打卡模块
|
// 打卡模块
|
||||||
checkin := authed.Group("/checkin")
|
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")
|
||||||
|
}
|
||||||
@@ -85,6 +85,46 @@ func (s *UserService) DeleteAccount(id string) error {
|
|||||||
}).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) {
|
func HashPassword(password string) (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
return string(bytes), err
|
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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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('--')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user