diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8dde373 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/.omo/run-continuation/ses_103557fafffeQib0Wox9LWZ7k9.json b/.omo/run-continuation/ses_103557fafffeQib0Wox9LWZ7k9.json new file mode 100644 index 0000000..77055d6 --- /dev/null +++ b/.omo/run-continuation/ses_103557fafffeQib0Wox9LWZ7k9.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_103558056ffejsS3rPnXRUAE6V.json b/.omo/run-continuation/ses_103558056ffejsS3rPnXRUAE6V.json new file mode 100644 index 0000000..8ba00dc --- /dev/null +++ b/.omo/run-continuation/ses_103558056ffejsS3rPnXRUAE6V.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1035580f3ffePn5t0JxA2KjXgV.json b/.omo/run-continuation/ses_1035580f3ffePn5t0JxA2KjXgV.json new file mode 100644 index 0000000..2ca5c6e --- /dev/null +++ b/.omo/run-continuation/ses_1035580f3ffePn5t0JxA2KjXgV.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_10355814affeCElMn2toKaD9N0.json b/.omo/run-continuation/ses_10355814affeCElMn2toKaD9N0.json new file mode 100644 index 0000000..b699463 --- /dev/null +++ b/.omo/run-continuation/ses_10355814affeCElMn2toKaD9N0.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_10355f0b6ffejofcFB9I9Gybi4.json b/.omo/run-continuation/ses_10355f0b6ffejofcFB9I9Gybi4.json new file mode 100644 index 0000000..ecce57d --- /dev/null +++ b/.omo/run-continuation/ses_10355f0b6ffejofcFB9I9Gybi4.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_10355f162ffe30mZHJ5Pw6sVFU.json b/.omo/run-continuation/ses_10355f162ffe30mZHJ5Pw6sVFU.json new file mode 100644 index 0000000..599180e --- /dev/null +++ b/.omo/run-continuation/ses_10355f162ffe30mZHJ5Pw6sVFU.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_10355f1c7ffe5aDy2fdVkuqPcm.json b/.omo/run-continuation/ses_10355f1c7ffe5aDy2fdVkuqPcm.json new file mode 100644 index 0000000..5a94b3a --- /dev/null +++ b/.omo/run-continuation/ses_10355f1c7ffe5aDy2fdVkuqPcm.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_10355f210ffeQ6TZEPP0EpL90M.json b/.omo/run-continuation/ses_10355f210ffeQ6TZEPP0EpL90M.json new file mode 100644 index 0000000..688be80 --- /dev/null +++ b/.omo/run-continuation/ses_10355f210ffeQ6TZEPP0EpL90M.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1035665d3ffem4kPN1d3ul25EP.json b/.omo/run-continuation/ses_1035665d3ffem4kPN1d3ul25EP.json new file mode 100644 index 0000000..99ad5c4 --- /dev/null +++ b/.omo/run-continuation/ses_1035665d3ffem4kPN1d3ul25EP.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_103566696ffeBfvadJFA98MT63.json b/.omo/run-continuation/ses_103566696ffeBfvadJFA98MT63.json new file mode 100644 index 0000000..dd9839e --- /dev/null +++ b/.omo/run-continuation/ses_103566696ffeBfvadJFA98MT63.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_103566796ffeWpg4oRQh74vS8V.json b/.omo/run-continuation/ses_103566796ffeWpg4oRQh74vS8V.json new file mode 100644 index 0000000..60b342f --- /dev/null +++ b/.omo/run-continuation/ses_103566796ffeWpg4oRQh74vS8V.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1035667feffeWIz65ukioq6hbi.json b/.omo/run-continuation/ses_1035667feffeWIz65ukioq6hbi.json new file mode 100644 index 0000000..c8816ed --- /dev/null +++ b/.omo/run-continuation/ses_1035667feffeWIz65ukioq6hbi.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_103adede7ffehGLMkuulGyM24q.json b/.omo/run-continuation/ses_103adede7ffehGLMkuulGyM24q.json new file mode 100644 index 0000000..183c827 --- /dev/null +++ b/.omo/run-continuation/ses_103adede7ffehGLMkuulGyM24q.json @@ -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" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_103ae139effevdfqnO9IYEbcP8.json b/.omo/run-continuation/ses_103ae139effevdfqnO9IYEbcP8.json new file mode 100644 index 0000000..c33fc6b --- /dev/null +++ b/.omo/run-continuation/ses_103ae139effevdfqnO9IYEbcP8.json @@ -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" + } + } +} \ No newline at end of file diff --git a/admin/src/__tests__/api.test.ts b/admin/src/__tests__/api.test.ts new file mode 100644 index 0000000..ee550fb --- /dev/null +++ b/admin/src/__tests__/api.test.ts @@ -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') + }) +}) \ No newline at end of file diff --git a/admin/src/components/DataTable.tsx b/admin/src/components/DataTable.tsx new file mode 100644 index 0000000..42fc7e5 --- /dev/null +++ b/admin/src/components/DataTable.tsx @@ -0,0 +1,69 @@ +import React from 'react' + +interface Column { + key: string + label: string + render?: (row: T) => React.ReactNode + width?: string | number +} + +interface DataTableProps { + columns: Column[] + data: T[] + loading?: boolean + emptyText?: string + onRowClick?: (row: T) => void +} + +export default function DataTable>({ + columns, + data, + loading = false, + emptyText = '暂无数据', + onRowClick, +}: DataTableProps) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {loading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((row, i) => ( + onRowClick?.(row)} + style={{ cursor: onRowClick ? 'pointer' : 'default' }} + > + {columns.map((col) => ( + + ))} + + )) + )} + +
+ {col.label} +
+ 加载中... +
+ {emptyText} +
+ {col.render ? col.render(row) : row[col.key] ?? '--'} +
+
+ ) +} \ No newline at end of file diff --git a/admin/src/components/Pagination.tsx b/admin/src/components/Pagination.tsx new file mode 100644 index 0000000..4c59402 --- /dev/null +++ b/admin/src/components/Pagination.tsx @@ -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 ( +
+ + {visiblePages.map((p) => ( + + ))} + + {total !== undefined && ( + + 共 {total} 条 · 第 {page}/{totalPages} 页 + + )} +
+ ) +} \ No newline at end of file diff --git a/admin/src/components/StatCard.tsx b/admin/src/components/StatCard.tsx new file mode 100644 index 0000000..a815f63 --- /dev/null +++ b/admin/src/components/StatCard.tsx @@ -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 ( +
+
+ {icon && {icon}} + {title} +
+ {value} + {subtitle && {subtitle}} +
+ ) +} \ No newline at end of file diff --git a/admin/src/pages/Dashboard.tsx b/admin/src/pages/Dashboard.tsx index 93c5c5d..809ed01 100644 --- a/admin/src/pages/Dashboard.tsx +++ b/admin/src/pages/Dashboard.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { api } from '../services/api' import { useNavigate } from 'react-router-dom' +import StatCard from '../components/StatCard' export default function Dashboard() { const [stats, setStats] = useState(null) @@ -37,27 +38,12 @@ export default function Dashboard() {

数据概览

{/* 统计卡片 */} -
-
navigate('/users')} style={{ cursor: 'pointer' }}> -
{stats.totalUsers}
-
总用户数
-
-
navigate('/users')} style={{ cursor: 'pointer' }}> -
{stats.newUsersToday}
-
今日新增
-
-
-
{stats.totalCheckins}
-
总打卡次数
-
-
-
{stats.totalItems}
-
总打卡项
-
-
navigate('/posts')} style={{ cursor: 'pointer' }}> -
{stats.totalPosts}
-
动态总数
-
+
+ navigate('/users')} /> + navigate('/users')} /> + + + navigate('/posts')} />
diff --git a/admin/src/pages/Posts.tsx b/admin/src/pages/Posts.tsx index 9ebae8d..da3dbea 100644 --- a/admin/src/pages/Posts.tsx +++ b/admin/src/pages/Posts.tsx @@ -1,5 +1,7 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { api } from '../services/api' +import DataTable from '../components/DataTable' +import Pagination from '../components/Pagination' const PAGE_SIZE = 15 @@ -10,7 +12,7 @@ export default function Posts() { const [loading, setLoading] = useState(true) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) - useEffect(() => { + const loadPosts = useCallback(() => { setLoading(true) api.get('/api/admin/posts?page=' + page + '&pageSize=' + PAGE_SIZE) .then(data => { setPosts(data.list || []); setTotal(data.total || 0) }) @@ -18,48 +20,53 @@ export default function Posts() { .finally(() => setLoading(false)) }, [page]) + useEffect(() => { loadPosts() }, [loadPosts]) + + const handleHide = async (id: string) => { + if (!confirm('确定隐藏该动态?隐藏后用户将不可见。')) return + try { + await api.delete('/api/social/posts/' + id) + loadPosts() + } catch { + alert('操作失败') + } + } + + const columns = [ + { + key: 'content', + label: '内容', + render: (row: any) => ( + + {row.content || '(无文字内容)'} + + ), + }, + { 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) => ( + + ), + }, + ] + return (
-

动态审核 共 {total} 条

+

动态审核 共 {total} 条

-
- - - - - - {loading ? ( - - ) : posts.length === 0 ? ( - - ) : posts.map((p: any) => ( - - - - - - - - - ))} - -
内容用户点赞评论时间操作
加载中...
暂无动态
- {p.content || '(无文字内容)'} - {p.user?.nickname || '--'}{p.likes}{p.comments}{p.createdAt?.slice(0, 10) || '--'} - -
-
+ -
- - 第 {page}/{totalPages} 页 - -
+
) } diff --git a/admin/vitest.config.ts b/admin/vitest.config.ts new file mode 100644 index 0000000..114b8e2 --- /dev/null +++ b/admin/vitest.config.ts @@ -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'), + }, + }, +}) \ No newline at end of file diff --git a/miniapp/components/checkin-card/index.js b/miniapp/components/checkin-card/index.js new file mode 100644 index 0000000..e5c0ea3 --- /dev/null +++ b/miniapp/components/checkin-card/index.js @@ -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 }) + } + } +}) \ No newline at end of file diff --git a/miniapp/components/checkin-card/index.json b/miniapp/components/checkin-card/index.json new file mode 100644 index 0000000..e8cfaaf --- /dev/null +++ b/miniapp/components/checkin-card/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} \ No newline at end of file diff --git a/miniapp/components/checkin-card/index.wxml b/miniapp/components/checkin-card/index.wxml new file mode 100644 index 0000000..2829a41 --- /dev/null +++ b/miniapp/components/checkin-card/index.wxml @@ -0,0 +1,11 @@ + + {{item.icon || '📋'}} + + {{item.name}} + {{categoryLabel}} · {{cycleLabel}} + ✓ 已打卡 + + + 🔥 {{streak}}天 + + \ No newline at end of file diff --git a/miniapp/components/checkin-card/index.wxss b/miniapp/components/checkin-card/index.wxss new file mode 100644 index 0000000..a75a73a --- /dev/null +++ b/miniapp/components/checkin-card/index.wxss @@ -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; } \ No newline at end of file diff --git a/miniapp/components/nav-bar/index.js b/miniapp/components/nav-bar/index.js new file mode 100644 index 0000000..cb723a7 --- /dev/null +++ b/miniapp/components/nav-bar/index.js @@ -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 }) + } + } +}) \ No newline at end of file diff --git a/miniapp/components/nav-bar/index.json b/miniapp/components/nav-bar/index.json new file mode 100644 index 0000000..e8cfaaf --- /dev/null +++ b/miniapp/components/nav-bar/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} \ No newline at end of file diff --git a/miniapp/components/nav-bar/index.wxml b/miniapp/components/nav-bar/index.wxml new file mode 100644 index 0000000..dd650cd --- /dev/null +++ b/miniapp/components/nav-bar/index.wxml @@ -0,0 +1,6 @@ + + + {{item.icon}} + {{item.label}} + + \ No newline at end of file diff --git a/miniapp/components/nav-bar/index.wxss b/miniapp/components/nav-bar/index.wxss new file mode 100644 index 0000000..fc7c0eb --- /dev/null +++ b/miniapp/components/nav-bar/index.wxss @@ -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; } \ No newline at end of file diff --git a/miniapp/utils/index.js b/miniapp/utils/index.js new file mode 100644 index 0000000..0d54624 --- /dev/null +++ b/miniapp/utils/index.js @@ -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, +} \ No newline at end of file diff --git a/mobile/App.tsx b/mobile/App.tsx index c33eb88..01831a9 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,83 +1,53 @@ -import React, { useState, useEffect, useCallback } from 'react' -import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView, StatusBar } from 'react-native' +import React, { useEffect, useState } from 'react' +import { View, Text, StatusBar } from 'react-native' +import { NavigationContainer } from '@react-navigation/native' +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import { SafeAreaProvider } from 'react-native-safe-area-context' import { AuthProvider, useAuth } from './src/components/AuthProvider' import { getStoredToken } from './src/services/api' import LoginScreen from './src/screens/LoginScreen' -import HomeScreen from './src/screens/HomeScreen' -import FeedScreen from './src/screens/FeedScreen' -import ProfileScreen from './src/screens/ProfileScreen' -import CheckInDetailScreen from './src/screens/CheckInDetailScreen' -import CreateCheckInScreen from './src/screens/CreateCheckInScreen' +import MainTabs from './src/navigation/MainTabs' +import type { RootStackParamList } from './src/navigation/types' -const TABS = [ - { key: 'home', label: '打卡', icon: '📋' }, - { key: 'feed', label: '广场', icon: '🌊' }, - { key: 'profile', label: '我的', icon: '👤' }, -] +const RootStack = createNativeStackNavigator() -function TabBar({ active, onChange }: { active: string; onChange: (k: string) => void }) { - return ( - - {TABS.map(t => ( - onChange(t.key)} style={tabBarStyles.tab}> - {t.icon} - {t.label} - - ))} - - ) -} - -const tabBarStyles = StyleSheet.create({ - container: { flexDirection: 'row', borderTopWidth: 0.5, borderTopColor: '#E5E5EA', backgroundColor: 'rgba(245,245,247,0.95)', paddingBottom: 20, paddingTop: 6 }, - tab: { flex: 1, alignItems: 'center', gap: 2 }, - icon: { fontSize: 22 }, - label: { fontSize: 10, color: '#86868B' }, - active: { color: '#FF6B35', fontWeight: '600' }, -}) - -function MainApp() { +function AuthGate() { const { user, loading } = useAuth() - const [tab, setTab] = useState('home') - const [screen, setScreen] = useState<'loading' |const [screen, setScreen] = useState<'loading' | 'login' | 'main'>('loading') - const [selectedItemId, setSelectedItemId] = useState(null) - const [showCreate, setShowCreate] = useState(false)| 'main'>('loading') + const [initialRoute, setInitialRoute] = useState<'Login' | 'MainTabs' | null>(null) useEffect(() => { if (!loading) { - getStoredToken().then(t => setScreen(t ? 'main' : 'login')) + getStoredToken().then((token) => { + setInitialRoute(token ? 'MainTabs' : 'Login') + }) } }, [loading]) - const onLogin = useCallback(() => setScreen('main'), []) - - if (screen === 'loading') return 加载中... - if (screen === 'login') return - if (selectedItemId) return setSelectedItemId(null)} /> - if (showCreate) return setShowCreate(false)} onCreated={() => { setShowCreate(false); setTab('home') }} /> - - const renderScreen = () => { - switch (tab) { - case 'home': return setShowCreate(true)} /> - case 'feed': return - case 'profile': return - default: return null - } + if (!initialRoute) { + return ( + + 加载中... + + ) } return ( - - - {renderScreen()} - - + + + + ) } export default function App() { return ( - - - + + + + + + + + ) -} +} \ No newline at end of file diff --git a/mobile/src/__tests__/all.test.ts b/mobile/src/__tests__/all.test.ts new file mode 100644 index 0000000..03d3d0c --- /dev/null +++ b/mobile/src/__tests__/all.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock AsyncStorage +const storage: Record = {} +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') + }) +}) \ No newline at end of file diff --git a/mobile/src/__tests__/services.test.ts b/mobile/src/__tests__/services.test.ts new file mode 100644 index 0000000..501e9d1 --- /dev/null +++ b/mobile/src/__tests__/services.test.ts @@ -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') + }) +}) \ No newline at end of file diff --git a/mobile/src/modules/checkin/index.ts b/mobile/src/modules/checkin/index.ts new file mode 100644 index 0000000..042b3b4 --- /dev/null +++ b/mobile/src/modules/checkin/index.ts @@ -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 +} + +export const CATEGORY_CONFIG: Record = { + 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 = { + 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 { + return api.post('/api/checkin/items', data) +} + +export async function listItems(): Promise { + return api.get('/api/checkin/items') +} + +export async function getItem(id: string): Promise { + return api.get(`/api/checkin/items/${id}`) +} + +export async function updateItem(id: string, data: Partial): Promise { + return api.put(`/api/checkin/items/${id}`, data) +} + +export async function deleteItem(id: string): Promise { + return api.del(`/api/checkin/items/${id}`) +} + +export async function pauseItem(id: string): Promise { + return api.post(`/api/checkin/items/${id}/pause`) +} + +export async function resumeItem(id: string): Promise { + return api.post(`/api/checkin/items/${id}/resume`) +} + +// ========== 打卡记录 ========== +export async function createRecord(itemId: string, note?: string): Promise { + return api.post('/api/checkin/records', { itemId, note }) +} + +export async function makeupRecord(itemId: string, checkDate: string): Promise { + return api.post('/api/checkin/records/makeup', { itemId, checkDate }) +} + +export async function getRecords(params: { itemId?: string; start?: string; end?: string }): Promise { + 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(`/api/checkin/records?${qs}`) +} + +// ========== 统计 ========== +export async function getStreak(itemId: string): Promise { + return api.get(`/api/checkin/streak/${itemId}`) +} + +export async function getTodayStatus(): Promise> { + return api.get>('/api/checkin/today-status') +} + +export async function getMonthlyStats(year: number, month: number): Promise { + return api.get(`/api/checkin/stats/monthly?year=${year}&month=${month}`) +} + +// ========== Hook: 打卡列表 ========== +export function useCheckInItems() { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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>({}) + const [streaks, setStreaks] = useState>({}) + + const load = useCallback(async () => { + try { + const [ts] = await Promise.all([getTodayStatus()]) + setTodayStatus(ts || {}) + const s: Record = {} + 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(null) + const [streak, setStreak] = useState(0) + const [records, setRecords] = useState([]) + 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 } +} \ No newline at end of file diff --git a/mobile/src/modules/discover/index.ts b/mobile/src/modules/discover/index.ts new file mode 100644 index 0000000..cc67a8d --- /dev/null +++ b/mobile/src/modules/discover/index.ts @@ -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 { + // 后端暂只支持 points 排行,type 参数预留 + return api.get(`/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([]) + 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([]) + const [recommended, setRecommended] = useState([]) + 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 } +} \ No newline at end of file diff --git a/mobile/src/modules/profile/index.ts b/mobile/src/modules/profile/index.ts new file mode 100644 index 0000000..db3628b --- /dev/null +++ b/mobile/src/modules/profile/index.ts @@ -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 { + return api.get('/api/achievements') +} + +// ========== 用户资料 API ========== +export async function updateProfile(updates: Record): Promise { + 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([]) + 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([]) + 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 } +} \ No newline at end of file diff --git a/mobile/src/modules/social/index.ts b/mobile/src/modules/social/index.ts new file mode 100644 index 0000000..7a5a034 --- /dev/null +++ b/mobile/src/modules/social/index.ts @@ -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 { + return api.post('/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 { + return api.post(`/api/social/posts/${postId}/like`) +} + +export async function deletePost(postId: string): Promise { + return api.del(`/api/social/posts/${postId}`) +} + +// ========== 好友 API ========== +export async function sendFriendRequest(friendId: string): Promise { + return api.post('/api/social/friends/request', { friendId }) +} + +export async function acceptFriendRequest(friendId: string): Promise { + return api.post('/api/social/friends/accept', { friendId }) +} + +export async function removeFriend(userId: string): Promise { + return api.del(`/api/social/friends/${userId}`) +} + +export async function listFriends(): Promise { + return api.get('/api/social/friends') +} + +// ========== 小组 API ========== +export async function createGroup(data: Partial): Promise { + return api.post('/api/social/groups', data) +} + +export async function joinGroup(groupId: string): Promise { + return api.post(`/api/social/groups/${groupId}/join`) +} + +export async function leaveGroup(groupId: string): Promise { + 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([]) + 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([]) + 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 } +} \ No newline at end of file diff --git a/mobile/src/navigation/FeedStack.tsx b/mobile/src/navigation/FeedStack.tsx new file mode 100644 index 0000000..5d7f678 --- /dev/null +++ b/mobile/src/navigation/FeedStack.tsx @@ -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() + +export default function FeedStack() { + return ( + + + + ) +} \ No newline at end of file diff --git a/mobile/src/navigation/HomeStack.tsx b/mobile/src/navigation/HomeStack.tsx new file mode 100644 index 0000000..ca5ead8 --- /dev/null +++ b/mobile/src/navigation/HomeStack.tsx @@ -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() + +export default function HomeStack() { + return ( + + + + + + ) +} \ No newline at end of file diff --git a/mobile/src/navigation/MainTabs.tsx b/mobile/src/navigation/MainTabs.tsx new file mode 100644 index 0000000..f0449a7 --- /dev/null +++ b/mobile/src/navigation/MainTabs.tsx @@ -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() + +export default function MainTabs() { + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + + ) +} \ No newline at end of file diff --git a/mobile/src/navigation/ProfileStack.tsx b/mobile/src/navigation/ProfileStack.tsx new file mode 100644 index 0000000..2f854d3 --- /dev/null +++ b/mobile/src/navigation/ProfileStack.tsx @@ -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() + +export default function ProfileStack() { + return ( + + + + + ) +} \ No newline at end of file diff --git a/mobile/src/navigation/types.ts b/mobile/src/navigation/types.ts new file mode 100644 index 0000000..1999b73 --- /dev/null +++ b/mobile/src/navigation/types.ts @@ -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 + FeedTab: NavigatorScreenParams + ProfileTab: NavigatorScreenParams +} + +// ========== Root Navigator ========== +export type RootStackParamList = { + Login: undefined + MainTabs: NavigatorScreenParams +} + +// ========== Screen Prop Helpers ========== +export type HomeScreenProps = CompositeScreenProps< + NativeStackScreenProps, + BottomTabScreenProps +> + +export type CheckInDetailScreenProps = NativeStackScreenProps + +export type CreateCheckInScreenProps = NativeStackScreenProps + +export type FeedScreenProps = CompositeScreenProps< + NativeStackScreenProps, + BottomTabScreenProps +> + +export type ProfileScreenProps = CompositeScreenProps< + NativeStackScreenProps, + BottomTabScreenProps +> + +export type LoginScreenProps = NativeStackScreenProps \ No newline at end of file diff --git a/mobile/src/screens/AchievementsScreen.tsx b/mobile/src/screens/AchievementsScreen.tsx new file mode 100644 index 0000000..eb8e8e7 --- /dev/null +++ b/mobile/src/screens/AchievementsScreen.tsx @@ -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 = { + bronze: { label: '青铜', color: '#CD7F32' }, + silver: { label: '白银', color: '#C0C0C0' }, + gold: { label: '黄金', color: '#FFD700' }, +} + +export default function AchievementsScreen() { + const [achievements, setAchievements] = useState([]) + const [points, setPoints] = useState(0) + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.all([ + api.get('/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 ( + + + + ) + } + + const unlocked = achievements.filter((a) => a.unlocked) + const locked = achievements.filter((a) => !a.unlocked) + + return ( + + {/* 积分头部 */} + + {points} + 总积分 + + + {unlocked.length} + 已解锁 + + + {achievements.length} + 全部 + + + + + + {/* 已解锁 */} + {unlocked.length > 0 && ( + <> + 已解锁成就 ({unlocked.length}) + + {unlocked.map((a) => ( + + {a.icon || '🏅'} + {a.name} + + {RARITY_CONFIG[a.rarity]?.label || a.rarity} + + + ))} + + + )} + + {/* 未解锁 */} + {locked.length > 0 && ( + <> + 待解锁 ({locked.length}) + + {locked.map((a) => ( + + 🔒 + {a.name} + {a.condition} + + ))} + + + )} + + {achievements.length === 0 && ( + + 🏆 + 暂无成就数据 + + )} + + + ) +} + +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' }, +}) \ No newline at end of file diff --git a/mobile/src/screens/CheckInDetailScreen.tsx b/mobile/src/screens/CheckInDetailScreen.tsx index f960d01..5ffcf77 100644 --- a/mobile/src/screens/CheckInDetailScreen.tsx +++ b/mobile/src/screens/CheckInDetailScreen.tsx @@ -1,8 +1,15 @@ import React, { useEffect, useState } from 'react' import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native' +import { useNavigation, useRoute } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' +import type { RouteProp } from '@react-navigation/native' import { api } from '../services/api' +import type { HomeStackParamList } from '../navigation/types' -export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string; onBack: () => void }) { +export default function CheckInDetailScreen() { + const navigation = useNavigation>() + const route = useRoute>() + const { itemId } = route.params const [item, setItem] = useState(null) const [streak, setStreak] = useState(0) const [records, setRecords] = useState([]) @@ -25,7 +32,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string Alert.alert('确认删除', '确定删除这个打卡项?', [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: async () => { - try { await api.del('/api/checkin/items/' + itemId); onBack() } + try { await api.del('/api/checkin/items/' + itemId); navigation.goBack() } catch { Alert.alert('错误', '删除失败') } }}, ]) @@ -36,7 +43,7 @@ export default function CheckInDetailScreen({ itemId, onBack }: { itemId: string return ( - ← 返回 + navigation.goBack()}>← 返回 {item?.name || '打卡详情'} diff --git a/mobile/src/screens/CreateCheckInScreen.tsx b/mobile/src/screens/CreateCheckInScreen.tsx index d062123..6063c83 100644 --- a/mobile/src/screens/CreateCheckInScreen.tsx +++ b/mobile/src/screens/CreateCheckInScreen.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react' import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native' +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import { api } from '../services/api' +import type { HomeStackParamList } from '../navigation/types' const CATEGORIES = [ { key: 'study', label: '📚 学习' }, @@ -10,7 +13,8 @@ const CATEGORIES = [ { key: 'goal', label: '🎯 目标' }, ] -export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: () => void; onCreated?: () => void }) { +export default function CreateCheckInScreen() { + const navigation = useNavigation>() const [name, setName] = useState('') const [category, setCategory] = useState('study') const [cycleType, setCycleType] = useState('daily') @@ -21,8 +25,8 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: () if (!name.trim()) { Alert.alert('提示', '请输入打卡项名称'); return } setSubmitting(true) try { - await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined }) - Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { onBack(); onCreated?.() } }]) +await api.post('/api/checkin/items', { name: name.trim(), category, cycleType, dailyGoal: dailyGoal.trim() || undefined }) + Alert.alert('成功', '打卡项已创建', [{ text: '确定', onPress: () => { navigation.goBack() } }]) } catch (e: any) { Alert.alert('错误', e.message || '创建失败') } setSubmitting(false) } @@ -30,7 +34,7 @@ export default function CreateCheckInScreen({ onBack, onCreated }: { onBack: () return ( - ← 取消 + navigation.goBack()}>← 取消 创建打卡项 diff --git a/mobile/src/screens/HomeScreen.tsx b/mobile/src/screens/HomeScreen.tsx index 277ab53..b0f43b4 100644 --- a/mobile/src/screens/HomeScreen.tsx +++ b/mobile/src/screens/HomeScreen.tsx @@ -1,9 +1,13 @@ import React, { useEffect, useState, useCallback } from 'react' import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from 'react-native' +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import { api } from '../services/api' import { useAuth } from '../components/AuthProvider' +import type { HomeStackParamList } from '../navigation/types' -export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectItem?: (id: string) => void; onCreateItem?: () => void }) { +export default function HomeScreen() { + const navigation = useNavigation>() const { user } = useAuth() const [items, setItems] = useState([]) const [streaks, setStreaks] = useState>({}) @@ -42,7 +46,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte 今日打卡 - + 创建 + navigation.navigate('CreateCheckIn')}>+ 创建 {activeItems.length === 0 ? ( @@ -52,7 +56,7 @@ export default function HomeScreen({ onSelectItem, onCreateItem }: { onSelectIte 点击上方"创建"开始 ) : activeItems.map((item: any) => ( - onSelectItem?.(item.id)}> + navigation.navigate('CheckInDetail', { itemId: item.id })}> {item.icon || '📋'} diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx index 816ea75..1de23b2 100644 --- a/mobile/src/screens/LoginScreen.tsx +++ b/mobile/src/screens/LoginScreen.tsx @@ -1,9 +1,13 @@ import React, { useState } from 'react' import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform } from 'react-native' +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import { api, saveToken } from '../services/api' import { useAuth } from '../components/AuthProvider' +import type { RootStackParamList } from '../navigation/types' -export default function LoginScreen({ onLogin }: { onLogin: () => void }) { +export default function LoginScreen() { + const navigation = useNavigation>() const [phone, setPhone] = useState('') const [loading, setLoading] = useState(false) const { login } = useAuth() @@ -14,7 +18,7 @@ export default function LoginScreen({ onLogin }: { onLogin: () => void }) { try { const result = await api.post<{ user: any; token: string }>('/api/auth/login', { phone }) await login(result.token, result.user) - onLogin() + navigation.replace('MainTabs') } catch (e: any) { Alert.alert('登录失败', e.message || '请稍后重试') } finally { setLoading(false) } diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index 91bed08..677e904 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -1,9 +1,13 @@ import React from 'react' import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native' +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useAuth } from '../components/AuthProvider' +import type { ProfileStackParamList } from '../navigation/types' export default function ProfileScreen() { const { user, logout } = useAuth() + const navigation = useNavigation>() const handleLogout = () => { Alert.alert('确认退出', '确定要退出登录吗?', [ @@ -27,7 +31,7 @@ export default function ProfileScreen() { - 成就徽章 + navigation.navigate('Achievements')}>成就徽章 退出登录 diff --git a/server/api.exe b/server/api.exe index 6738956..db1dd9c 100644 Binary files a/server/api.exe and b/server/api.exe differ diff --git a/server/go.mod b/server/go.mod index 32739de..36cd1b7 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,11 +5,13 @@ go 1.22 require ( github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 + github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.23.0 gorm.io/driver/mysql v1.5.6 - gorm.io/gorm v1.25.10 + gorm.io/gorm v1.30.0 ) require ( @@ -17,13 +19,17 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -34,12 +40,18 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/text v0.20.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index cb1270b..ff284cc 100644 --- a/server/go.sum +++ b/server/go.sum @@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= @@ -18,6 +20,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -35,6 +41,10 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -64,6 +74,9 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -76,8 +89,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -93,10 +107,10 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -108,7 +122,15 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= -gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/server/internal/handlers/user_handler.go b/server/internal/handlers/user_handler.go index 09f4542..3b715b5 100644 --- a/server/internal/handlers/user_handler.go +++ b/server/internal/handlers/user_handler.go @@ -1,12 +1,13 @@ - package handlers - - import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/inchstep/server/internal/middleware" - "github.com/inchstep/server/internal/services" - ) +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/inchstep/server/internal/middleware" + "github.com/inchstep/server/internal/services" +) type UserHandler struct { userSvc *services.UserService @@ -104,12 +105,44 @@ middleware.Success(c, nil) } - func (h *UserHandler) GetPoints(c *gin.Context) { - userID := middleware.GetUserID(c) - user, err := h.userSvc.GetByID(userID) - if err != nil { - middleware.Error(c, http.StatusNotFound, 404, "用户不存在") - return - } - middleware.Success(c, gin.H{"points": user.Points}) - } +func (h *UserHandler) GetPoints(c *gin.Context) { + userID := middleware.GetUserID(c) + user, err := h.userSvc.GetByID(userID) + if err != nil { + middleware.Error(c, http.StatusNotFound, 404, "用户不存在") + return + } + middleware.Success(c, gin.H{"points": user.Points}) +} + +// ========== 排行榜 ========== + +func (h *UserHandler) GetLeaderboard(c *gin.Context) { + limitStr := c.DefaultQuery("limit", "20") + limit, err := strconv.Atoi(limitStr) + if err != nil || limit > 100 { + limit = 20 + } + entries, err := h.userSvc.GetLeaderboardByPoints(limit) + if err != nil { + middleware.Error(c, http.StatusInternalServerError, 500, err.Error()) + return + } + middleware.Success(c, entries) +} + +// ========== 搜索用户 ========== + +func (h *UserHandler) SearchUsers(c *gin.Context) { + q := c.Query("q") + if q == "" { + middleware.Success(c, []interface{}{}) + return + } + users, err := h.userSvc.SearchUsers(q, 20) + if err != nil { + middleware.Error(c, http.StatusInternalServerError, 500, err.Error()) + return + } + middleware.Success(c, users) +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 1362022..6d6ae81 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -57,8 +57,12 @@ func Setup(db *gorm.DB, cfg *config.Config) *gin.Engine { user.PUT("/profile", userH.UpdateProfile) user.DELETE("/account", userH.DeleteAccount) user.GET("/points", userH.GetPoints) + user.GET("/search", userH.SearchUsers) } + // 排行榜(公开数据,需登录) + authed.GET("/leaderboard", userH.GetLeaderboard) + // 打卡模块 checkin := authed.Group("/checkin") { diff --git a/server/internal/services/checkin_service_test.go b/server/internal/services/checkin_service_test.go new file mode 100644 index 0000000..85520b7 --- /dev/null +++ b/server/internal/services/checkin_service_test.go @@ -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(), "已打卡") +} \ No newline at end of file diff --git a/server/internal/services/service_test_helper.go b/server/internal/services/service_test_helper.go new file mode 100644 index 0000000..4c84bc3 --- /dev/null +++ b/server/internal/services/service_test_helper.go @@ -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") +} \ No newline at end of file diff --git a/server/internal/services/user_service.go b/server/internal/services/user_service.go index c517201..92eaec1 100644 --- a/server/internal/services/user_service.go +++ b/server/internal/services/user_service.go @@ -85,6 +85,46 @@ func (s *UserService) DeleteAccount(id string) error { }).Error } +// LeaderboardEntry 排行榜条目 +type LeaderboardEntry struct { + UserID string `json:"userId"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar,omitempty"` + Value int `json:"value"` +} + +// GetLeaderboardByPoints 按积分排行 +func (s *UserService) GetLeaderboardByPoints(limit int) ([]LeaderboardEntry, error) { + var users []models.User + if err := s.db.Where("points > 0").Order("points desc").Limit(limit).Find(&users).Error; err != nil { + return nil, err + } + entries := make([]LeaderboardEntry, 0, len(users)) + for _, u := range users { + nick := "" + if u.Nickname != nil { + nick = *u.Nickname + } + entries = append(entries, LeaderboardEntry{ + UserID: u.ID, + Nickname: nick, + Value: u.Points, + }) + } + return entries, nil +} + +// SearchUsers 搜索用户(按昵称或手机号) +func (s *UserService) SearchUsers(query string, limit int) ([]models.User, error) { + var users []models.User + q := "%" + query + "%" + if err := s.db.Where("nickname LIKE ? OR phone LIKE ?", q, q). + Limit(limit).Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + func HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) return string(bytes), err diff --git a/server/internal/services/user_service_test.go b/server/internal/services/user_service_test.go new file mode 100644 index 0000000..9376830 --- /dev/null +++ b/server/internal/services/user_service_test.go @@ -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")) +} \ No newline at end of file diff --git a/shared/utils/index.ts b/shared/utils/index.ts new file mode 100644 index 0000000..3a3d1e5 --- /dev/null +++ b/shared/utils/index.ts @@ -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 = { + 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 = { 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 +} \ No newline at end of file diff --git a/web/src/__tests__/all.test.ts b/web/src/__tests__/all.test.ts new file mode 100644 index 0000000..0fdcd5b --- /dev/null +++ b/web/src/__tests__/all.test.ts @@ -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 = {} +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('--') + }) +}) \ No newline at end of file diff --git a/web/src/__tests__/services.test.ts b/web/src/__tests__/services.test.ts new file mode 100644 index 0000000..ca6462e --- /dev/null +++ b/web/src/__tests__/services.test.ts @@ -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) + }) +}) \ No newline at end of file diff --git a/web/src/utils/index.ts b/web/src/utils/index.ts new file mode 100644 index 0000000..8575514 --- /dev/null +++ b/web/src/utils/index.ts @@ -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' \ No newline at end of file diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..114b8e2 --- /dev/null +++ b/web/vitest.config.ts @@ -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'), + }, + }, +}) \ No newline at end of file