├── pnpm-workspace.yaml
├── docs
└── screens
│ ├── 1.png
│ ├── 2.png
│ ├── 3.png
│ ├── 4.png
│ ├── 5.png
│ └── 6.png
├── apps
├── web
│ ├── public
│ │ ├── favicon.ico
│ │ └── apple-touch-icon.png
│ ├── src
│ │ ├── app
│ │ │ ├── profile
│ │ │ │ ├── items
│ │ │ │ │ └── page.js
│ │ │ │ ├── shop
│ │ │ │ │ └── page.js
│ │ │ │ ├── rewards
│ │ │ │ │ └── page.js
│ │ │ │ ├── wallet
│ │ │ │ │ └── page.js
│ │ │ │ ├── page.js
│ │ │ │ ├── badges
│ │ │ │ │ └── page.js
│ │ │ │ ├── layout.js
│ │ │ │ ├── topics
│ │ │ │ │ └── page.js
│ │ │ │ └── settings
│ │ │ │ │ └── components
│ │ │ │ │ └── UsernameChangeDialog.js
│ │ │ ├── dashboard
│ │ │ │ ├── shop
│ │ │ │ │ └── page.js
│ │ │ │ ├── badges
│ │ │ │ │ └── page.js
│ │ │ │ ├── ledger
│ │ │ │ │ └── page.js
│ │ │ │ ├── settings
│ │ │ │ │ └── components
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ └── SecuritySettings.js
│ │ │ │ └── layout.js
│ │ │ ├── topic
│ │ │ │ └── [id]
│ │ │ │ │ ├── loading.js
│ │ │ │ │ ├── not-found.js
│ │ │ │ │ └── page.js
│ │ │ ├── reference
│ │ │ │ └── page.js
│ │ │ ├── (home)
│ │ │ │ ├── layout.js
│ │ │ │ ├── trending
│ │ │ │ │ └── page.js
│ │ │ │ ├── featured
│ │ │ │ │ └── page.js
│ │ │ │ ├── page.js
│ │ │ │ └── categories
│ │ │ │ │ └── [id]
│ │ │ │ │ └── page.js
│ │ │ ├── not-found.js
│ │ │ └── create
│ │ │ │ └── page.js
│ │ ├── components
│ │ │ ├── layout
│ │ │ │ ├── Sidebar
│ │ │ │ │ ├── index.js
│ │ │ │ │ └── Sidebar.jsx
│ │ │ │ └── Footer.jsx
│ │ │ ├── topic
│ │ │ │ ├── TopicList
│ │ │ │ │ ├── index.js
│ │ │ │ │ └── TopicListClient.js
│ │ │ │ └── ReplySection.js
│ │ │ ├── user
│ │ │ │ ├── UserActivityTabs
│ │ │ │ │ └── index.js
│ │ │ │ ├── ReportUserButton.jsx
│ │ │ │ ├── UserProfileClient.jsx
│ │ │ │ ├── FollowButton.jsx
│ │ │ │ └── UserContentGuard.jsx
│ │ │ ├── ui
│ │ │ │ ├── spinner.jsx
│ │ │ │ ├── sonner.jsx
│ │ │ │ ├── label.jsx
│ │ │ │ ├── separator.jsx
│ │ │ │ ├── textarea.jsx
│ │ │ │ ├── checkbox.jsx
│ │ │ │ ├── input.jsx
│ │ │ │ ├── avatar.jsx
│ │ │ │ ├── switch.jsx
│ │ │ │ ├── popover.jsx
│ │ │ │ ├── badge.jsx
│ │ │ │ ├── scroll-area.jsx
│ │ │ │ ├── alert.jsx
│ │ │ │ ├── card.jsx
│ │ │ │ ├── tooltip.jsx
│ │ │ │ ├── tabs.jsx
│ │ │ │ ├── accordion.jsx
│ │ │ │ ├── slider.jsx
│ │ │ │ ├── button.jsx
│ │ │ │ └── table.jsx
│ │ │ ├── auth
│ │ │ │ ├── LoginDialog
│ │ │ │ │ ├── FormMessage.js
│ │ │ │ │ ├── OAuthSection.js
│ │ │ │ │ ├── ModeSwitcher.js
│ │ │ │ │ └── LoginForm.js
│ │ │ │ ├── RequireAdmin.jsx
│ │ │ │ └── RequireAuth.jsx
│ │ │ └── common
│ │ │ │ ├── MarkdownRender
│ │ │ │ └── plugins
│ │ │ │ │ └── remark-media.js
│ │ │ │ ├── ScalarAPI
│ │ │ │ └── index.jsx
│ │ │ │ ├── CopyButton.jsx
│ │ │ │ ├── Time.jsx
│ │ │ │ ├── FormDialog.jsx
│ │ │ │ ├── StickySidebar.jsx
│ │ │ │ └── AlertDialog.jsx
│ │ ├── lib
│ │ │ ├── utils.js
│ │ │ ├── server
│ │ │ │ ├── ledger.js
│ │ │ │ └── api.js
│ │ │ └── api-url.js
│ │ ├── extensions
│ │ │ ├── shop
│ │ │ │ ├── hooks
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── useUserItems.js
│ │ │ │ │ ├── useItemActions.js
│ │ │ │ │ └── useShopItems.js
│ │ │ │ ├── components
│ │ │ │ │ ├── user
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ ├── ShopItemGrid.jsx
│ │ │ │ │ │ ├── ItemInventoryGrid.jsx
│ │ │ │ │ │ └── ShopItemCard.jsx
│ │ │ │ │ └── shared
│ │ │ │ │ │ ├── ItemTypeBadge.jsx
│ │ │ │ │ │ ├── ItemTypeIcon.jsx
│ │ │ │ │ │ └── ItemTypeSelector.jsx
│ │ │ │ ├── utils
│ │ │ │ │ └── itemTypes.js
│ │ │ │ ├── api
│ │ │ │ │ └── index.js
│ │ │ │ └── pages
│ │ │ │ │ └── user
│ │ │ │ │ └── UserItemsPage.jsx
│ │ │ ├── rewards
│ │ │ │ ├── pages
│ │ │ │ │ └── index.js
│ │ │ │ ├── api
│ │ │ │ │ └── index.js
│ │ │ │ └── components
│ │ │ │ │ └── AutoCheckIn.jsx
│ │ │ ├── ledger
│ │ │ │ ├── components
│ │ │ │ │ ├── user
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ ├── BalanceCard.jsx
│ │ │ │ │ │ ├── CheckInStatus.jsx
│ │ │ │ │ │ └── BalanceOverview.jsx
│ │ │ │ │ ├── common
│ │ │ │ │ │ ├── TransactionTypeBadge.jsx
│ │ │ │ │ │ └── CreditsBadge.jsx
│ │ │ │ │ └── admin
│ │ │ │ │ │ └── LedgerOverview.jsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── currency.js
│ │ │ │ │ └── formatters.js
│ │ │ │ ├── api
│ │ │ │ │ └── index.js
│ │ │ │ └── pages
│ │ │ │ │ └── admin
│ │ │ │ │ └── LedgerAdminPage.jsx
│ │ │ └── badges
│ │ │ │ ├── api
│ │ │ │ └── index.js
│ │ │ │ └── components
│ │ │ │ ├── admin
│ │ │ │ └── BadgeTable.jsx
│ │ │ │ └── BadgesList.jsx
│ │ ├── config
│ │ │ └── theme.config.js
│ │ ├── proxy.js
│ │ ├── hooks
│ │ │ └── useClipboard.js
│ │ └── contexts
│ │ │ ├── SettingsContext.jsx
│ │ │ └── ThemeContext.jsx
│ ├── jsconfig.json
│ ├── postcss.config.mjs
│ ├── .env.example
│ ├── next.config.mjs
│ ├── components.json
│ ├── .gitignore
│ ├── ecosystem.config.cjs
│ ├── README.md
│ ├── .dockerignore
│ └── package.json
└── api
│ ├── src
│ ├── db
│ │ └── index.js
│ ├── utils
│ │ ├── env.js
│ │ ├── index.js
│ │ └── settings.js
│ ├── plugins
│ │ ├── multipart.js
│ │ ├── formbody.js
│ │ ├── sensible.js
│ │ ├── redis.js
│ │ ├── oauth.js
│ │ ├── event-bus.js
│ │ ├── static.js
│ │ ├── emailVerification.js
│ │ ├── security.js
│ │ ├── rateLimit.js
│ │ └── cleanup.js
│ ├── extensions
│ │ ├── ledger
│ │ │ └── index.js
│ │ ├── shop
│ │ │ └── index.js
│ │ ├── badges
│ │ │ ├── index.js
│ │ │ ├── listeners.js
│ │ │ └── enricher.js
│ │ └── rewards
│ │ │ ├── index.js
│ │ │ └── routes
│ │ │ └── checkin.js
│ ├── scripts
│ │ └── init
│ │ │ └── rewards.js
│ ├── app.js
│ ├── templates
│ │ └── email
│ │ │ └── index.js
│ ├── server.js
│ ├── services
│ │ └── userEnricher.js
│ └── routes
│ │ └── dashboard
│ │ └── index.js
│ ├── drizzle
│ ├── 0003_add_user_items_metadata.sql
│ └── meta
│ │ └── _journal.json
│ ├── drizzle.config.js
│ ├── ecosystem.config.cjs
│ ├── .env.example
│ ├── README.md
│ ├── .gitignore
│ ├── .dockerignore
│ ├── package.json
│ └── Dockerfile
├── scripts
└── init-db.sql
├── turbo.json
├── .gitignore
├── package.json
├── LICENSE
└── docker
├── .env.docker.example
├── docker-compose.lowmem.yml
├── docker-compose.prod.yml
└── .dockerignore
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/docs/screens/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/docs/screens/1.png
--------------------------------------------------------------------------------
/docs/screens/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/docs/screens/2.png
--------------------------------------------------------------------------------
/docs/screens/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/docs/screens/3.png
--------------------------------------------------------------------------------
/docs/screens/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/docs/screens/4.png
--------------------------------------------------------------------------------
/docs/screens/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/docs/screens/5.png
--------------------------------------------------------------------------------
/docs/screens/6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/docs/screens/6.png
--------------------------------------------------------------------------------
/apps/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/apps/web/public/favicon.ico
--------------------------------------------------------------------------------
/apps/web/src/app/profile/items/page.js:
--------------------------------------------------------------------------------
1 | export { default } from '@/extensions/shop/pages/user/UserItemsPage';
2 |
--------------------------------------------------------------------------------
/apps/web/src/app/profile/shop/page.js:
--------------------------------------------------------------------------------
1 | export { default } from '@/extensions/shop/pages/user/UserShopPage';
2 |
--------------------------------------------------------------------------------
/apps/web/src/app/dashboard/shop/page.js:
--------------------------------------------------------------------------------
1 | export { default } from '@/extensions/shop/pages/admin/AdminShopPage';
2 |
--------------------------------------------------------------------------------
/apps/web/src/app/profile/rewards/page.js:
--------------------------------------------------------------------------------
1 | export { default } from '@/extensions/ledger/pages/user/UserWalletPage';
2 |
--------------------------------------------------------------------------------
/apps/web/src/app/profile/wallet/page.js:
--------------------------------------------------------------------------------
1 | export { default } from '@/extensions/ledger/pages/user/UserWalletPage';
2 |
--------------------------------------------------------------------------------
/apps/web/src/app/dashboard/badges/page.js:
--------------------------------------------------------------------------------
1 | export { default } from '@/extensions/badges/pages/admin/AdminBadgesPage';
2 |
--------------------------------------------------------------------------------
/apps/web/src/app/dashboard/ledger/page.js:
--------------------------------------------------------------------------------
1 | export { default } from '@/extensions/ledger/pages/admin/LedgerAdminPage';
2 |
--------------------------------------------------------------------------------
/apps/web/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./src/*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/apps/web/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/apps/web/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aiprojecthub/nodebbs/HEAD/apps/web/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/apps/api/src/db/index.js:
--------------------------------------------------------------------------------
1 | import { drizzle } from 'drizzle-orm/node-postgres';
2 |
3 | const db = drizzle(process.env.DATABASE_URL);
4 |
5 | export default db;
6 |
--------------------------------------------------------------------------------
/apps/api/drizzle/0003_add_user_items_metadata.sql:
--------------------------------------------------------------------------------
1 | DO $$ BEGIN
2 | ALTER TABLE "user_items" ADD COLUMN "metadata" text;
3 | EXCEPTION
4 | WHEN duplicate_column THEN null;
5 | END $$;
6 |
--------------------------------------------------------------------------------
/apps/web/src/components/layout/Sidebar/index.js:
--------------------------------------------------------------------------------
1 | export { default as Sidebar } from './Sidebar';
2 | export { SidebarUI, CategoryList, StatsPanel, formatNumber } from './SidebarUI';
3 |
--------------------------------------------------------------------------------
/apps/web/src/lib/utils.js:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/src/app/profile/page.js:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function ProfilePage() {
4 | // 重定向到我的话题页面
5 | redirect('/profile/topics');
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/.env.example:
--------------------------------------------------------------------------------
1 | # 应用名,用于显示pm2服务等场景
2 | APP_NAME=nodebbs
3 |
4 | # 应用端口
5 | PORT=3100
6 |
7 | # 运行时 API 地址 (关键配置)
8 | # 本地开发时,Next.js 服务器需要知道转发给谁
9 | SERVER_API_URL=http://localhost:7100
10 |
--------------------------------------------------------------------------------
/apps/api/src/utils/env.js:
--------------------------------------------------------------------------------
1 | export const NODE_ENV = process.env.NODE_ENV || 'development';
2 |
3 | console.log('当前环境: ', NODE_ENV);
4 |
5 | export const isDev = NODE_ENV === 'development';
6 | export const isProd = NODE_ENV === 'production';
7 |
--------------------------------------------------------------------------------
/apps/api/src/utils/index.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { fileURLToPath } from 'url';
3 |
4 | export const dirname = (url) => {
5 | const filename = fileURLToPath(url);
6 | const dirname = path.dirname(filename);
7 | return dirname;
8 | };
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/hooks/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Shop extension hooks
3 | */
4 |
5 | export { useShopItems } from './useShopItems';
6 | export { useUserItems } from './useUserItems';
7 | export { useItemActions } from './useItemActions';
8 |
--------------------------------------------------------------------------------
/apps/web/src/components/topic/TopicList/index.js:
--------------------------------------------------------------------------------
1 | export { default as TopicList } from './TopicList';
2 | export { default as TopicListClient } from './TopicListClient';
3 | export { TopicListUI, TopicItem, TopicListHeader, EmptyState } from './TopicListUI';
4 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/rewards/pages/index.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Credits system pages
4 | */
5 |
6 | // Admin pages
7 | export { default as AdminRewardsPage } from './admin/AdminRewardsPage';
8 |
9 | // User pages
10 | // (Currently empty or migrated to ledger)
11 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/components/user/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * User-facing components for ledger system
3 | */
4 |
5 | export { BalanceCard } from './BalanceCard';
6 | export { BalanceOverview } from './BalanceOverview';
7 | export { CheckInStatus } from './CheckInStatus';
8 |
--------------------------------------------------------------------------------
/apps/api/drizzle.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'drizzle-kit';
2 |
3 | export default defineConfig({
4 | out: './drizzle',
5 | schema: './src/db/schema.js',
6 | dialect: 'postgresql',
7 | dbCredentials: {
8 | url: process.env.DATABASE_URL,
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/apps/api/drizzle/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1765547640840,
9 | "tag": "0003_handy_lightspeed",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/apps/api/src/plugins/multipart.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import multipart from '@fastify/multipart';
3 |
4 | export default fp(async function (fastify, opts) {
5 | fastify.register(multipart, {
6 | limits: {
7 | fileSize: 5 * 1024 * 1024, // 5MB
8 | },
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/scripts/init-db.sql:
--------------------------------------------------------------------------------
1 | -- PostgreSQL 初始化脚本
2 | -- 这个脚本会在数据库首次创建时执行
3 |
4 | -- 创建扩展(如果需要)
5 | -- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
6 | -- CREATE EXTENSION IF NOT EXISTS "pg_trgm";
7 |
8 | -- 设置时区
9 | SET timezone = 'Asia/Shanghai';
10 |
11 | -- 数据库已由 POSTGRES_DB 环境变量创建
12 | -- 这里可以添加其他初始化 SQL
13 |
14 | \echo '数据库初始化完成'
15 |
--------------------------------------------------------------------------------
/apps/web/src/components/user/UserActivityTabs/index.js:
--------------------------------------------------------------------------------
1 | export { default as UserActivityTabs } from './UserActivityTabs';
2 | export { default as UserActivityTabsServer } from './UserActivityTabsServer';
3 | export {
4 | TopicsList,
5 | PostsList,
6 | TopicItem,
7 | PostItem,
8 | EmptyState,
9 | } from './UserActivityTabsUI';
10 |
--------------------------------------------------------------------------------
/apps/web/src/app/topic/[id]/loading.js:
--------------------------------------------------------------------------------
1 | import { Loading } from '@/components/common/Loading';
2 |
3 | export default function LoadingPage() {
4 | // 你可以在这里放骨架屏
5 | return (
6 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/apps/web/src/components/layout/Sidebar/Sidebar.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname } from 'next/navigation';
4 | import { SidebarUI } from './SidebarUI';
5 |
6 | export default function Sidebar({ categories, stats }) {
7 | const pathname = usePathname();
8 |
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/formbody.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import formBody from '@fastify/formbody';
3 |
4 | /**
5 | * 此插件增加了对 application/x-www-form-urlencoded 内容类型的支持
6 | * 这对于处理 Apple Sign In 回调 (form_post 模式) 是必需的
7 | */
8 | async function formBodyPlugin(fastify) {
9 | await fastify.register(formBody);
10 | }
11 |
12 | export default fp(formBodyPlugin);
13 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.com/schema.json",
3 | "ui": "stream",
4 | "tasks": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "inputs": ["$TURBO_DEFAULT$", ".env*"],
8 | "outputs": [".next/**", "!.next/cache/**"]
9 | },
10 | "dev": {
11 | "cache": false,
12 | "persistent": true
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/sensible.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import fp from 'fastify-plugin'
4 | import sensible from '@fastify/sensible'
5 |
6 | /**
7 | * This plugins adds some utilities to handle http errors
8 | *
9 | * @see https://github.com/fastify/fastify-sensible
10 | */
11 | export default fp(async function (fastify, opts) {
12 | fastify.register(sensible, {
13 | errorHandler: false
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/spinner.jsx:
--------------------------------------------------------------------------------
1 | import { Loader2Icon } from "lucide-react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Spinner({
6 | className,
7 | ...props
8 | }) {
9 | return (
10 |
15 | );
16 | }
17 |
18 | export { Spinner }
19 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/components/user/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * User-facing components for shop system
3 | */
4 |
5 | export { ShopItemCard } from './ShopItemCard';
6 | export { ShopItemGrid } from './ShopItemGrid';
7 | export { ItemInventoryCard } from './ItemInventoryCard';
8 | export { ItemInventoryGrid } from './ItemInventoryGrid';
9 | export { PurchaseDialog } from './PurchaseDialog';
10 | export { BadgeUnlockDialog } from './BadgeUnlockDialog';
11 |
--------------------------------------------------------------------------------
/apps/web/src/app/reference/page.js:
--------------------------------------------------------------------------------
1 | import ScalarAPI from '@/components/common/ScalarAPI';
2 | export default function ApiReference() {
3 | const url = `/docs/json`;
4 | const config = {
5 | url,
6 | theme: 'alternate',
7 | customCss: `.scalar-app .h-dvh{height: calc(100dvh - 58px); top: 58px}`,
8 | defaultHttpClient: {
9 | targetKey: 'node',
10 | clientKey: 'fetch',
11 | },
12 | };
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/apps/api/ecosystem.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | name: `${process.env.APP_NAME}-api`,
5 | script: './src/app.js',
6 | // instances: 'max', // 使用最大可用CPU核心数
7 | instances: 1, // 使用最大可用CPU核心数
8 | // exec_mode: 'cluster', // 使用集群模式 可以是 “cluster” 或 “fork”,默认 fork
9 | env: {
10 | // NODE_ENV: 'production', // pm2 启动优先使用这个值
11 | LOG_LEVEL: 'info',
12 | // PORT: 7100,
13 | },
14 | },
15 | ],
16 | };
17 |
--------------------------------------------------------------------------------
/apps/web/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "**", // 匹配所有域名
8 | },
9 | {
10 | protocol: "http",
11 | hostname: "**",
12 | },
13 | ],
14 | },
15 | // 启用 standalone 输出模式,用于 Docker 部署
16 | output: 'standalone',
17 | // 代理逻辑已迁移至 src/proxy.js,以支持 Docker 运行时环境变量动态注入
18 | };
19 |
20 | export default nextConfig;
21 |
--------------------------------------------------------------------------------
/apps/api/src/extensions/ledger/index.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import { LedgerService } from './services/ledgerService.js';
3 | import ledgerRoutes from './routes.js';
4 |
5 | async function ledgerPlugin(fastify, options) {
6 | const service = new LedgerService(fastify);
7 | fastify.decorate('ledger', service);
8 |
9 | fastify.register(ledgerRoutes, { prefix: '/api/ledger' });
10 | }
11 |
12 | export default fp(ledgerPlugin, {
13 | name: 'ledger-plugin',
14 | dependencies: []
15 | });
16 |
--------------------------------------------------------------------------------
/apps/api/src/extensions/shop/index.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import shopRoutes from './routes/index.js';
3 | import registerShopEnricher from './enricher.js';
4 |
5 | /**
6 | * 商城插件
7 | * 处理商城系统逻辑和路由。
8 | */
9 | async function shopPlugin(fastify, options) {
10 | // 注册路由
11 | fastify.register(shopRoutes, { prefix: '/api/shop' });
12 |
13 | // 注册增强器
14 | registerShopEnricher(fastify);
15 | }
16 |
17 | export default fp(shopPlugin, {
18 | name: 'shop-plugin',
19 | // dependencies: ['credits-plugin'] // 可选:如果紧密依赖积分系统
20 | });
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # Dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # Local env files
9 | .env*
10 | !.env*.example
11 | /.dockerignore
12 |
13 | # Testing
14 | coverage
15 |
16 | # Turbo
17 | .turbo
18 |
19 | # Vercel
20 | .vercel
21 | .vscode
22 | .agent
23 | .claude
24 |
25 | # Build Outputs
26 | .next/
27 | out/
28 | build
29 | dist
30 |
31 |
32 | # Debug
33 | npm-debug.log*
34 | yarn-debug.log*
35 | yarn-error.log*
36 |
37 | # Misc
38 | .DS_Store
39 | *.pem
40 |
--------------------------------------------------------------------------------
/apps/web/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": false,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "iconLibrary": "lucide",
14 | "aliases": {
15 | "components": "@/components",
16 | "utils": "@/lib/utils",
17 | "ui": "@/components/ui",
18 | "lib": "@/lib",
19 | "hooks": "@/hooks"
20 | },
21 | "registries": {}
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/components/shared/ItemTypeBadge.jsx:
--------------------------------------------------------------------------------
1 | import { Badge } from '@/components/ui/badge';
2 | import { getItemTypeLabel } from '../../utils/itemTypes';
3 |
4 | /**
5 | * Display item type badge
6 | * @param {Object} props
7 | * @param {string} props.type - Item type
8 | * @param {string} props.variant - Badge variant
9 | */
10 | export function ItemTypeBadge({ type, variant = 'default' }) {
11 | const label = getItemTypeLabel(type);
12 |
13 | return (
14 |
15 | {label}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/sonner.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner";
5 |
6 | const Toaster = ({
7 | ...props
8 | }) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
23 | );
24 | }
25 |
26 | export { Toaster }
27 |
--------------------------------------------------------------------------------
/apps/web/src/components/auth/LoginDialog/FormMessage.js:
--------------------------------------------------------------------------------
1 | export function FormMessage({ error, success }) {
2 | if (!error && !success) return null;
3 |
4 | if (error) {
5 | return (
6 |
7 | {error}
8 |
9 | );
10 | }
11 |
12 | if (success) {
13 | return (
14 |
15 | {success}
16 |
17 | );
18 | }
19 |
20 | return null;
21 | }
22 |
--------------------------------------------------------------------------------
/apps/api/src/extensions/badges/index.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import badgeRoutes from './routes/index.js';
3 | import badgeListeners from './listeners.js';
4 | import registerBadgeEnricher from './enricher.js';
5 |
6 | /**
7 | * Badges Feature
8 | * Handles achievement badges and user honors.
9 | */
10 | async function badgesFeature(fastify, options) {
11 | fastify.register(badgeRoutes, { prefix: '/api/badges' });
12 | fastify.register(badgeListeners);
13 |
14 | // Register User Enricher
15 | registerBadgeEnricher(fastify);
16 | }
17 |
18 | export default fp(badgesFeature, {
19 | name: 'badges-feature'
20 | });
21 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/components/common/TransactionTypeBadge.jsx:
--------------------------------------------------------------------------------
1 | import { Badge } from '@/components/ui/badge';
2 | import { getTransactionTypeLabel, getTransactionTypeColor } from '../../utils/transactionTypes';
3 |
4 | /**
5 | * Display transaction type badge
6 | * @param {Object} props
7 | * @param {string} props.type - Transaction type key
8 | */
9 | export function TransactionTypeBadge({ type }) {
10 | const label = getTransactionTypeLabel(type);
11 | const variant = getTransactionTypeColor(type);
12 |
13 | return (
14 |
15 | {label}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/redis.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin'
2 | import fastifyRedis from '@fastify/redis'
3 |
4 | export default fp(async (fastify) => {
5 | fastify.register(fastifyRedis, {
6 | url: process.env.REDIS_URL,
7 | // 可选:配置 ioredis 原生选项
8 | connectTimeout: 5000,
9 | maxRetriesPerRequest: 3,
10 | })
11 |
12 | // 可选:在 ready 阶段进行连接测试
13 | fastify.addHook('onReady', async () => {
14 | try {
15 | await fastify.redis.ping()
16 | fastify.log.info('✅ Redis connected successfully')
17 | } catch (err) {
18 | fastify.log.error('❌ Redis connection failed:', err)
19 | }
20 | })
21 | }, {
22 | name: 'redis'
23 | })
24 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/label.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }) {
12 | return (
13 |
20 | );
21 | }
22 |
23 | export { Label }
24 |
--------------------------------------------------------------------------------
/apps/web/src/app/dashboard/settings/components/index.js:
--------------------------------------------------------------------------------
1 | export { GeneralSettings } from './GeneralSettings';
2 | export { FeatureSettings } from './FeatureSettings';
3 | export { OAuthSettings } from './OAuthProviderCard';
4 | export { EmailSettings } from './EmailProviderCard';
5 | export { RateLimitSettings } from './RateLimitSettings';
6 |
7 | // Feature Settings 子组件
8 | export { RegistrationSettings } from './RegistrationSettings';
9 | export { SecuritySettings } from './SecuritySettings';
10 | export { AuthenticationSettings } from './AuthenticationSettings';
11 | export { UserManagementSettings } from './UserManagementSettings';
12 | export { SpamProtectionSettings } from './SpamProtectionSettings';
13 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/components/shared/ItemTypeIcon.jsx:
--------------------------------------------------------------------------------
1 | import { Package, Award, Sparkles } from 'lucide-react';
2 | import { ITEM_TYPES } from '../../utils/itemTypes';
3 |
4 | /**
5 | * Display appropriate icon for item type
6 | * @param {Object} props
7 | * @param {string} props.type - Item type
8 | * @param {string} props.className - Additional CSS classes
9 | */
10 | export function ItemTypeIcon({ type, className = 'h-5 w-5' }) {
11 | switch (type) {
12 | case ITEM_TYPES.AVATAR_FRAME:
13 | return ;
14 | case ITEM_TYPES.BADGE:
15 | return ;
16 | default:
17 | return ;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 | !.env.example
36 |
37 | # vercel
38 | .vercel
39 | .claude
40 | .kiro
41 | .vscode
42 |
43 | # typescript
44 | *.tsbuildinfo
45 | next-env.d.ts
46 |
--------------------------------------------------------------------------------
/apps/web/ecosystem.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | // name: "next-dash",
5 | name: `${process.env.APP_NAME}-dash`,
6 | script: 'node_modules/next/dist/bin/next',
7 | // script: '.next/standalone/server.js', // 为后续docker部署准备, 需要同步静态文件 cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public
8 | args: `start -p ${process.env.PORT}`, // 指定端口
9 | exec_mode: 'cluster', // 启用集群模式
10 | // instances: "max", // 使用所有 CPU 核心
11 | instances: 1, // 使用所有 CPU 核心
12 | autorestart: true, // 崩溃自动重启
13 | watch: false, // 禁用文件监听(生产环境建议关闭)
14 | env: {
15 | NODE_ENV: 'production',
16 | },
17 | },
18 | ],
19 | };
20 |
--------------------------------------------------------------------------------
/apps/web/src/lib/server/ledger.js:
--------------------------------------------------------------------------------
1 | import { request } from '@/lib/server/api';
2 |
3 | /**
4 | * 检查特定货币是否已启用 (Server Side)
5 | * 使用后端公开接口 /api/ledger/active-currencies
6 | * @param {string} currencyCode - 货币代码 (默认 'credits')
7 | * @returns {Promise}
8 | */
9 | export async function isCurrencyActive(currencyCode = 'credits') {
10 | try {
11 | const activeCurrencies = await request('/ledger/active-currencies');
12 | if (!Array.isArray(activeCurrencies)) {
13 | return false;
14 | }
15 | return activeCurrencies.some(c => c.code === currencyCode);
16 | } catch (error) {
17 | console.error(`[Server] Failed to check status for currency ${currencyCode}`, error);
18 | return false;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/apps/web/src/lib/api-url.js:
--------------------------------------------------------------------------------
1 | function isServer() {
2 | return typeof window === 'undefined';
3 | }
4 |
5 | function normalizeHost(host) {
6 | return host.replace(/\/+$/, '');
7 | }
8 |
9 | export function getApiHost() {
10 | if (isServer()) {
11 | // 服务端:优先使用运行时变量 SERVER_API_URL
12 | const host = process.env.SERVER_API_URL;
13 | if (!host) {
14 | throw new Error('SERVER_API_URL is not defined (server-side)');
15 | }
16 | return normalizeHost(host);
17 | }
18 |
19 | // 客户端:返回空字符串,使请求变为相对路径 (e.g., /api/users)
20 | // 这会触发 Next.js 的 rewrites 规则,将其代理到 SERVER_API_URL
21 | return '';
22 | }
23 |
24 | export function getApiPath() {
25 | return '/api';
26 | }
27 |
28 | export function getApiBaseUrl() {
29 | return `${getApiHost()}${getApiPath()}`;
30 | }
--------------------------------------------------------------------------------
/apps/web/src/components/ui/separator.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Separator({
9 | className,
10 | orientation = "horizontal",
11 | decorative = true,
12 | ...props
13 | }) {
14 | return (
15 |
24 | );
25 | }
26 |
27 | export { Separator }
28 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/utils/currency.js:
--------------------------------------------------------------------------------
1 | import { ledgerApi } from '../api';
2 |
3 | /**
4 | * 检查特定货币是否已启用
5 | * @param {string} currencyCode - 货币代码 (默认 'credits')
6 | * @returns {Promise}
7 | */
8 | export async function isCurrencyActive(currencyCode = 'credits') {
9 | try {
10 | // 使用公开接口检查货币状态
11 | const currencies = await ledgerApi.getActiveCurrencies();
12 |
13 | if (!Array.isArray(currencies)) {
14 | return false;
15 | }
16 |
17 | const currency = currencies.find(c => c.code === currencyCode);
18 | return !!(currency && currency.isActive);
19 | } catch (error) {
20 | console.error(`Failed to check status for currency ${currencyCode}`, error);
21 | return false;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/apps/api/.env.example:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 |
3 | # 应用名,用于显示pm2服务等场景
4 | APP_NAME=nodebbs
5 |
6 | # 主机/端口
7 | HOST=0.0.0.0
8 | PORT=7100
9 |
10 | # 数据库
11 | DATABASE_URL=postgres://postgres@0.0.0.0:5432/nodebbs
12 |
13 | # 缓存配置
14 | REDIS_URL=redis://default:password@redis:6379/0
15 |
16 | # 用户信息缓存配置(秒)
17 | # 用于缓存用户角色、封禁状态等信息,减少数据库查询
18 | # 建议值:开发环境 30-60,生产环境 120-300
19 | USER_CACHE_TTL=30
20 |
21 | # JWT 使用 `openssl rand -base64 32` 生成
22 | JWT_SECRET=change-this-to-a-secure-random-string-in-production
23 | JWT_ACCESS_TOKEN_EXPIRES_IN=1y
24 |
25 | CORS_ORIGIN=*
26 |
27 | # 前端 URL,用于发送验证邮件和构建 Oauth 回调 URL
28 | APP_URL=http://localhost:3100
29 |
30 | # COOKIE 配置(备用,通常不用设置)
31 | # COOKIE_SECRET=change-this-to-a-secure-random-string-in-production
32 | # COOKIE_SECURE=true
33 | # COOKIE_SAMESITE=Lax
34 | # COOKIE_DOMAIN=.example.com
--------------------------------------------------------------------------------
/apps/web/src/app/topic/[id]/not-found.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { Button } from '@/components/ui/button';
3 |
4 | export default function NotFound() {
5 | return (
6 |
7 |
8 |
9 | 话题不存在
10 |
11 |
12 | 该话题可能已被删除或不存在
13 |
14 |
15 |
16 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/textarea.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Textarea({
6 | className,
7 | ...props
8 | }) {
9 | return (
10 |
17 | );
18 | }
19 |
20 | export { Textarea }
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nodebbs-forum",
3 | "private": true,
4 | "scripts": {
5 | "build": "turbo run build",
6 | "dev": "turbo run dev",
7 | "release": "git add . && git commit -m \"chore: release v$(node -p 'require(\"./package.json\").version')\" && git tag v$(node -p 'require(\"./package.json\").version') && git push && git push --tags"
8 | },
9 | "devDependencies": {
10 | "turbo": "^2.6.0"
11 | },
12 | "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c",
13 | "engines": {
14 | "node": ">=22"
15 | },
16 | "author": "wengqianshan",
17 | "license": "MIT",
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/aiprojecthub/nodebbs"
21 | },
22 | "version": "1.3.1-beta.2"
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/src/config/theme.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 主题配置文件 - 单一数据源
3 | * 在 ThemeContext 和 layout 中共享,确保配置一致性
4 | */
5 |
6 | export const THEMES = [
7 | { value: 'default', label: '默认', class: '' },
8 | { value: 'sunrise', label: '晨曦', class: 'sunrise' },
9 | { value: 'iceblue', label: '冰蓝', class: 'iceblue' },
10 | { value: 'nord', label: 'Nord', class: 'nord' },
11 | ];
12 |
13 | export const FONT_SIZES = [
14 | { value: 'compact', label: '紧凑', class: 'font-scale-compact' },
15 | { value: 'normal', label: '正常', class: 'font-scale-normal' },
16 | { value: 'comfortable', label: '宽松', class: 'font-scale-comfortable' },
17 | ];
18 |
19 | // 默认值
20 | export const DEFAULT_THEME = 'default';
21 | export const DEFAULT_FONT_SIZE = 'normal';
22 |
23 | // localStorage 键名
24 | export const STORAGE_KEYS = {
25 | THEME_STYLE: 'theme-style',
26 | FONT_SIZE: 'font-size',
27 | };
28 |
--------------------------------------------------------------------------------
/apps/api/src/scripts/init/rewards.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 奖励系统配置初始化 (已迁移至 Ledger Currency Config)
3 | */
4 |
5 | import { userCheckIns, postRewards } from '../../extensions/rewards/schema.js';
6 | import { eq } from 'drizzle-orm';
7 |
8 | /**
9 | * 初始化奖励系统 (仅保留其他数据初始化,配置已移除)
10 | */
11 | export async function initRewardConfigs(db, reset = false) {
12 | // Configs are now handled in Ledger init for 'credits' currency.
13 | return { total: 0, addedCount: 0, updatedCount: 0, skippedCount: 0 };
14 | }
15 |
16 | /**
17 | * 清空奖励系统数据
18 | * @param {import('drizzle-orm').NodePgDatabase} db
19 | */
20 | export async function cleanRewards(db) {
21 | console.log('正在清空奖励系统数据...');
22 |
23 | await db.delete(postRewards);
24 | console.log('- 已清空帖子打赏 (postRewards)');
25 |
26 | await db.delete(userCheckIns);
27 | console.log('- 已清空用户签到 (userCheckIns)');
28 |
29 |
30 | return { success: true };
31 | }
32 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/badges/api/index.js:
--------------------------------------------------------------------------------
1 | import apiClient from '../../../lib/api';
2 |
3 | export const badgesApi = {
4 | // Get all available badges
5 | async getAll(params = {}) {
6 | return apiClient.get('/badges', params);
7 | },
8 |
9 | // Get user's badges
10 | async getUserBadges(userId) {
11 | return apiClient.get(`/badges/users/${userId}`);
12 | },
13 |
14 | // Admin API
15 | admin: {
16 | getAll(params = {}) {
17 | return apiClient.get('/badges/admin', params);
18 | },
19 | create(data) {
20 | return apiClient.post('/badges/admin', data);
21 | },
22 | update(id, data) {
23 | return apiClient.request(`/badges/admin/${id}`, {
24 | method: 'PATCH',
25 | body: JSON.stringify(data),
26 | });
27 | },
28 | delete(id) {
29 | return apiClient.delete(`/badges/admin/${id}`);
30 | },
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/apps/web/src/app/profile/badges/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import BadgesList from '@/extensions/badges/components/BadgesList';
5 | import { useAuth } from '@/contexts/AuthContext';
6 | import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
7 | import { Medal } from 'lucide-react';
8 |
9 | export default function MyBadgesPage() {
10 | const { user } = useAuth();
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | 我的勋章
18 |
19 |
20 | 查看您获得的所有荣誉勋章
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/apps/api/src/app.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import Fastify from 'fastify';
4 | import server from './server.js';
5 | import { isDev } from './utils/env.js';
6 |
7 | const logger = {
8 | level: isDev ? 'debug' : 'info',
9 | transport: isDev
10 | ? {
11 | target: 'pino-pretty',
12 | options: {
13 | colorize: true,
14 | translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
15 | singleLine: false,
16 | },
17 | }
18 | : undefined,
19 | };
20 |
21 | const app = Fastify({
22 | logger,
23 | disableRequestLogging: true, // 禁用默认的请求日志
24 | trustProxy: true,
25 | });
26 |
27 | app.register(server);
28 |
29 | app.listen(
30 | { port: process.env.PORT || 7100, host: process.env.HOST || '0.0.0.0' },
31 | (err, address) => {
32 | if (err) {
33 | app.log.error(err);
34 | process.exit(1);
35 | }
36 | app.log.info(`服务启动成功,访问地址: ${address}`);
37 | }
38 | );
39 |
--------------------------------------------------------------------------------
/apps/web/src/app/(home)/layout.js:
--------------------------------------------------------------------------------
1 | import { getCategoriesData, getStatsData } from '@/lib/server/topics';
2 | import { Sidebar } from '@/components/layout/Sidebar';
3 | import StickySidebar from '@/components/common/StickySidebar';
4 |
5 | export default async function HomeLayout({ children }) {
6 | // 并行获取分类和统计数据
7 | const [categories, stats] = await Promise.all([
8 | getCategoriesData({ isFeatured: true }),
9 | getStatsData(),
10 | ]);
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
{children}
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/components/auth/RequireAdmin.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { useRouter, usePathname } from 'next/navigation';
5 | import { useAuth } from '@/contexts/AuthContext';
6 | import { Loading } from '../common/Loading';
7 |
8 | /**
9 | * 认证守卫组件
10 | * 用于保护需要管理员才能访问的页面
11 | */
12 | export default function RequireAdmin({ children }) {
13 | const { user, isAuthenticated, loading, openLoginDialog } = useAuth();
14 |
15 | const router = useRouter();
16 |
17 | useEffect(() => {
18 | if (!loading && (!isAuthenticated || user?.role !== 'admin')) {
19 | router.push('/');
20 | }
21 | }, [user, isAuthenticated, loading, router]);
22 |
23 | // 加载状态
24 | if (loading) {
25 | return ;
26 | }
27 |
28 | if (!isAuthenticated || user?.role !== 'admin') {
29 | return null;
30 | }
31 |
32 | // 已登录,显示内容
33 | return <>{children}>;
34 | }
35 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/MarkdownRender/plugins/remark-media.js:
--------------------------------------------------------------------------------
1 | import { visit } from 'unist-util-visit';
2 |
3 | export default function remarkMedia() {
4 | return (tree) => {
5 | visit(tree, (node) => {
6 | if (
7 | node.type === 'textDirective' ||
8 | node.type === 'leafDirective' ||
9 | node.type === 'containerDirective'
10 | ) {
11 | if (node.name !== 'video' && node.name !== 'audio') return;
12 |
13 | const data = node.data || (node.data = {});
14 | const attributes = node.attributes || {};
15 |
16 | // Ensure src exists
17 | if (!attributes.src && !attributes.url) return;
18 |
19 | data.hName = node.name; // 'video' or 'audio'
20 | data.hProperties = {
21 | src: attributes.src || attributes.url,
22 | controls: true,
23 | ...attributes,
24 | };
25 | }
26 | });
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/apps/api/src/templates/email/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 邮件模板索引
3 | * 统一管理所有邮件模板
4 | */
5 | import welcomeTemplate from './welcome.js';
6 | import verificationCodeTemplate from './verification-code.js';
7 |
8 | const templates = {
9 | welcome: welcomeTemplate,
10 | 'verification-code': verificationCodeTemplate,
11 | };
12 |
13 | /**
14 | * 获取邮件模板
15 | * @param {string} templateName - 模板名称
16 | * @param {object} data - 模板数据
17 | * @returns {object} { subject, html, text }
18 | */
19 | export function getEmailTemplate(templateName, data) {
20 | const template = templates[templateName];
21 |
22 | if (!template) {
23 | throw new Error(`邮件模板不存在: ${templateName}`);
24 | }
25 |
26 | return template(data);
27 | }
28 |
29 | /**
30 | * 获取所有可用的模板名称
31 | */
32 | export function getAvailableTemplates() {
33 | return Object.keys(templates);
34 | }
35 |
36 | export default {
37 | getEmailTemplate,
38 | getAvailableTemplates,
39 | };
40 |
--------------------------------------------------------------------------------
/apps/web/src/app/dashboard/layout.js:
--------------------------------------------------------------------------------
1 | import StickySidebar from '@/components/common/StickySidebar';
2 | import DashboardSidebar from '@/components/layout/DashboardSidebar';
3 | import RequireAdmin from '@/components/auth/RequireAdmin';
4 |
5 | export const metadata = {
6 | title: '管理后台',
7 | };
8 |
9 | export default function AdminLayout({ children }) {
10 | return (
11 |
12 |
13 |
14 | {/* sidebar */}
15 |
16 |
17 |
18 |
19 |
20 |
21 | {/* Main content */}
22 |
{children}
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/oauth.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import db from '../db/index.js';
3 | import { oauthProviders } from '../db/schema.js';
4 | import { eq } from 'drizzle-orm';
5 |
6 | /**
7 | * OAuth 2.0 插件配置
8 | * 支持 GitHub, Google, Apple 等提供商
9 | * 从数据库动态读取配置,无需重启即可生效
10 | */
11 | async function oauthPlugin(fastify, opts) {
12 | fastify.log.info('Initializing OAuth plugin with database configuration');
13 |
14 | // 添加一个装饰器方法,用于获取提供商配置
15 | // 这个方法会实时从数据库读取最新配置
16 | fastify.decorate('getOAuthProviderConfig', async (providerName) => {
17 | const result = await db
18 | .select()
19 | .from(oauthProviders)
20 | .where(eq(oauthProviders.provider, providerName))
21 | .limit(1);
22 |
23 | return result[0] || null;
24 | });
25 |
26 | fastify.log.info('OAuth plugin initialized successfully');
27 | }
28 |
29 |
30 |
31 | export default fp(oauthPlugin, {
32 | name: 'oauth-plugin',
33 | });
34 |
--------------------------------------------------------------------------------
/apps/web/src/app/profile/layout.js:
--------------------------------------------------------------------------------
1 | import ProfileSidebar from '@/components/layout/ProfileSidebar';
2 | import RequireAuth from '@/components/auth/RequireAuth';
3 | import StickySidebar from '@/components/common/StickySidebar';
4 |
5 | export const metadata = {
6 | title: '个人中心',
7 | description: '管理你的话题和个人设置',
8 | };
9 |
10 | export default function ProfileLayout({ children }) {
11 | return (
12 |
13 |
14 |
15 | {/* 左侧栏 */}
16 |
21 |
22 | {/* 主内容区 */}
23 |
{children}
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/components/user/BalanceCard.jsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from '@/components/ui/card';
2 | import { Coins } from 'lucide-react';
3 | import { formatCredits } from '../../../ledger/utils/formatters';
4 |
5 | /**
6 | * Compact balance display card for headers
7 | * @param {Object} props
8 | * @param {number} props.balance - Credits balance
9 | */
10 | export function BalanceCard({ balance }) {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
我的余额
18 |
{formatCredits(balance)}
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/event-bus.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import { EventEmitter } from 'node:events';
3 |
4 | /**
5 | * Event Bus Plugin
6 | * Provides a simple event bus for decoupling modules.
7 | */
8 | async function eventBusPlugin(fastify, options) {
9 | const emitter = new EventEmitter();
10 |
11 | // Decorate fastify instance with event bus methods
12 | fastify.decorate('eventBus', {
13 | emit: (event, ...args) => {
14 | fastify.log.debug(`[EventBus] Emitting event: ${event}`);
15 | emitter.emit(event, ...args);
16 | },
17 | on: (event, listener) => {
18 | fastify.log.debug(`[EventBus] Registering listener for: ${event}`);
19 | emitter.on(event, listener);
20 | },
21 | off: (event, listener) => {
22 | emitter.off(event, listener);
23 | },
24 | once: (event, listener) => {
25 | emitter.once(event, listener);
26 | }
27 | });
28 | }
29 |
30 | export default fp(eventBusPlugin, {
31 | name: 'event-bus'
32 | });
33 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/checkbox.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3 | import { Check } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
8 |
16 |
19 |
20 |
21 |
22 | ))
23 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
24 |
25 | export { Checkbox }
26 |
--------------------------------------------------------------------------------
/apps/api/README.md:
--------------------------------------------------------------------------------
1 | # API 后台项目
2 |
3 | ## 项目技术栈
4 |
5 | - **框架**: Fastify 5
6 | - **数据库**: PostgreSQL
7 | - **ORM**: Drizzle ORM
8 | - **缓存**: Redis (ioredis)
9 | - **认证**: JWT (@fastify/jwt)
10 | - **文件上传**: Multipart (@fastify/multipart)
11 | - **图片处理**: IPX
12 | - **文档**: Swagger UI
13 | - **日志**: Pino
14 | - **环境变量**: @dotenvx/dotenvx
15 | - **密码加密**: bcryptjs
16 |
17 | ## 开发
18 |
19 | 1. **安装依赖**
20 | ```bash
21 | pnpm install
22 | ```
23 |
24 | 2. **配置环境变量**
25 | ```bash
26 | # 生成 JWT 密钥
27 | openssl rand -base64 32
28 |
29 | # 在 .env 文件中配置数据库和 Redis 连接信息
30 | ```
31 |
32 | 3. **初始化数据库**
33 | ```bash
34 | pnpm run db:push
35 | ```
36 |
37 | 4. **启动开发服务器**
38 | ```bash
39 | pnpm run dev
40 | ```
41 |
42 | 5. **数据库管理工具**
43 | ```bash
44 | pnpm run db:studio
45 | ```
46 |
47 | ## 部署
48 |
49 | 1. **配置生产环境变量**
50 | ```bash
51 | # 在 .env.production 文件中配置
52 | ```
53 |
54 | 2. **推送数据库结构**
55 | ```bash
56 | pnpm run db:push
57 | ```
58 |
59 | 3. **启动生产环境**
60 | ```bash
61 | pnpm run prod
62 | ```
63 |
64 | 使用 PM2 进行进程管理,配置文件:`ecosystem.config.cjs`
65 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/ScalarAPI/index.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { useEffect } from 'react';
5 | import { Loading } from '../Loading';
6 |
7 | export default function ScalarAPI({ config }) {
8 | const [loading, setLoading] = useState(true);
9 | useEffect(() => {
10 | // 加载 CDN 脚本
11 | const script = document.createElement('script');
12 | // script.src = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference';
13 | script.src =
14 | 'https://unpkg.com/@scalar/api-reference@1.40.0/dist/browser/standalone.js';
15 | script.async = true;
16 |
17 | script.onload = () => {
18 | if (window.Scalar) {
19 | setLoading(false);
20 | window.Scalar.createApiReference('#ScalarAPP', config);
21 | }
22 | };
23 |
24 | document.body.appendChild(script);
25 |
26 | return () => script.remove();
27 | }, []);
28 |
29 | return (
30 | <>
31 | {loading && }
32 |
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/input.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({
6 | className,
7 | type,
8 | ...props
9 | }) {
10 | return (
11 |
21 | );
22 | }
23 |
24 | export { Input }
25 |
--------------------------------------------------------------------------------
/apps/web/src/components/auth/RequireAuth.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useAuth } from '@/contexts/AuthContext';
4 | import { Button } from '@/components/ui/button';
5 | import { Lock, Loader2 } from 'lucide-react';
6 | import { Loading } from '../common/Loading';
7 |
8 | /**
9 | * 认证守卫组件
10 | * 用于保护需要登录才能访问的页面
11 | */
12 | export default function RequireAuth({ children }) {
13 | const { user, isAuthenticated, loading, openLoginDialog } = useAuth();
14 |
15 | // 加载状态
16 | if (loading) {
17 | return ;
18 | }
19 |
20 | // 未登录状态
21 | if (!isAuthenticated || !user) {
22 | return (
23 |
24 |
25 |
需要登录
26 |
27 | 请先登录以访问此页面
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | // 已登录,显示内容
35 | return <>{children}>;
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/rewards/api/index.js:
--------------------------------------------------------------------------------
1 | import apiClient from '../../../lib/api';
2 |
3 | // ============= 奖励系统 API =============
4 | export const rewardsApi = {
5 | // 每日签到 (New Endpoint)
6 | async checkIn() {
7 | return apiClient.post('/check-in');
8 | },
9 |
10 | // 获取签到状态
11 | async getCheckInStatus() {
12 | return apiClient.get('/check-in');
13 | },
14 |
15 | // 打赏帖子 (Feature specific, remains)
16 | async reward(postId, amount, message) {
17 | return apiClient.post('/rewards/reward', { postId, amount, message });
18 | },
19 |
20 | // 获取帖子的打赏列表
21 | async getPostRewards(postId, params = {}) {
22 | return apiClient.get(`/rewards/posts/${postId}`, params);
23 | },
24 |
25 | // 批量获取多个帖子的打赏统计
26 | async getBatchPostRewards(postIds) {
27 | if (!postIds || postIds.length === 0) {
28 | return {};
29 | }
30 | return apiClient.post('/rewards/posts/batch', { postIds });
31 | },
32 |
33 | // 获取积分排行榜 (Feature specific, remains)
34 | async getRanking(params = {}) {
35 | return apiClient.get('/rewards/rank', params);
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/apps/web/README.md:
--------------------------------------------------------------------------------
1 | # 技术论坛 - GitHub Issue 风格的讨论社区
2 |
3 | ## 项目技术栈
4 |
5 | - **框架**: Next.js 16 (App Router + Turbopack)
6 | - **样式**: Tailwind CSS 4
7 | - **组件库**: shadcn/ui (基于 Radix UI)
8 | - **图标**: Lucide React
9 | - **表单**: React Hook Form
10 | - **Markdown**: React Markdown + Remark GFM
11 | - **主题**: Next Themes
12 | - **提示**: Sonner
13 | - **UI 工具**: class-variance-authority, clsx, tailwind-merge
14 | - **日期处理**: Day.js
15 |
16 | ## 开发
17 |
18 | 1. **安装依赖**
19 | ```bash
20 | pnpm install
21 | ```
22 |
23 | 2. **配置环境变量**
24 | ```bash
25 | # 在 .env.local 文件中配置 API 地址等信息
26 | ```
27 |
28 | 3. **启动开发服务器**
29 | ```bash
30 | pnpm run dev
31 | ```
32 |
33 | 4. **访问应用**
34 | 打开浏览器访问 [http://localhost:3100](http://localhost:3100)
35 |
36 | ## 部署
37 |
38 | ### Vercel 部署(推荐)
39 | 1. 将代码推送到 GitHub
40 | 2. 在 Vercel 中导入项目
41 | 3. 配置环境变量
42 | 4. 自动部署完成
43 |
44 | ### PM2 部署
45 | 1. **构建生产版本**
46 | ```bash
47 | pnpm run build
48 | ```
49 |
50 | 2. **配置生产环境变量**
51 | ```bash
52 | # 在 .env 文件中配置
53 | ```
54 |
55 | 3. **启动生产环境**
56 | ```bash
57 | pnpm run prod
58 | ```
59 |
60 | 使用 PM2 进行进程管理,配置文件:`ecosystem.config.cjs`
61 |
--------------------------------------------------------------------------------
/apps/api/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 |
39 | # 0x
40 | profile-*
41 |
42 | # mac files
43 | .DS_Store
44 |
45 | # vim swap files
46 | *.swp
47 |
48 | # webstorm
49 | .idea
50 |
51 | # vscode
52 | .vscode
53 | *code-workspace
54 | .claude
55 |
56 | # clinic
57 | profile*
58 | *clinic*
59 | *flamegraph*
60 |
61 | uploads/
62 |
63 | # env files (can opt-in for committing if needed)
64 | .env*
65 | !.env.example
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 qianshan w
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/avatar.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Avatar({
9 | className,
10 | ...props
11 | }) {
12 | return (
13 |
17 | );
18 | }
19 |
20 | function AvatarImage({
21 | className,
22 | ...props
23 | }) {
24 | return (
25 |
29 | );
30 | }
31 |
32 | function AvatarFallback({
33 | className,
34 | ...props
35 | }) {
36 | return (
37 |
44 | );
45 | }
46 |
47 | export { Avatar, AvatarImage, AvatarFallback }
48 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/utils/formatters.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Formatting utilities for credits system
3 | */
4 |
5 | /**
6 | * Format credits amount with thousands separator
7 | * @param {number} amount - Credits amount
8 | * @returns {string} Formatted string
9 | */
10 | export function formatCredits(amount) {
11 | if (typeof amount !== 'number') return '0';
12 | return amount.toLocaleString();
13 | }
14 |
15 | /**
16 | * Format credits amount with sign (+ or -)
17 | * @param {number} amount - Credits amount
18 | * @returns {string} Formatted string with sign
19 | */
20 | export function formatCreditsWithSign(amount) {
21 | if (typeof amount !== 'number') return '0';
22 | const sign = amount > 0 ? '+' : '';
23 | return `${sign}${amount.toLocaleString()}`;
24 | }
25 |
26 | /**
27 | * Get CSS class for credits amount based on positive/negative
28 | * @param {number} amount - Credits amount
29 | * @returns {string} CSS class name
30 | */
31 | export function getCreditsColorClass(amount) {
32 | if (amount > 0) return 'text-green-600';
33 | if (amount < 0) return 'text-red-600';
34 | return 'text-muted-foreground';
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/components/auth/LoginDialog/OAuthSection.js:
--------------------------------------------------------------------------------
1 | import { OAuthButton } from './OAuthButton';
2 |
3 | export function OAuthSection({ providers, isLogin, isLoading, setIsLoading, setError }) {
4 | if (!providers || providers.length === 0) {
5 | return null;
6 | }
7 |
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
16 | 或使用以下方式{isLogin ? '登录' : '注册'}
17 |
18 |
19 |
20 |
21 |
22 | {providers.map((provider) => (
23 |
31 | ))}
32 |
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/proxy.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | // Next.js 16 uses proxy.ts instead of middleware.ts
4 | export function proxy(request) {
5 | const { pathname } = request.nextUrl;
6 |
7 | // 获取运行时环境变量
8 | // 在 Docker 环境中,这会读取 docker-compose 传入的 SERVER_API_URL (e.g., http://api:7100)
9 | const apiUrl = process.env.SERVER_API_URL || 'http://localhost:7100';
10 |
11 | // 处理 API 代理
12 | if (
13 | pathname.startsWith('/api') ||
14 | pathname.startsWith('/uploads') ||
15 | pathname.startsWith('/docs/json')
16 | ) {
17 | // 构建目标 URL
18 | const destinationUrl = new URL(pathname, apiUrl);
19 | destinationUrl.search = request.nextUrl.search;
20 |
21 | // console.log(`[Proxy] ${pathname} -> ${destinationUrl.toString()}`);
22 | // console.log(`[Proxy] DEBUG: SERVER_API_URL=${apiUrl}, Proxy Target=${destinationUrl.toString()}`);
23 |
24 | return NextResponse.rewrite(destinationUrl);
25 | }
26 |
27 | return NextResponse.next();
28 | }
29 |
30 | export const config = {
31 | // 匹配需要代理的路径
32 | matcher: [
33 | '/api/:path*',
34 | '/uploads/:path*',
35 | '/docs/json',
36 | ],
37 | };
38 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/components/common/CreditsBadge.jsx:
--------------------------------------------------------------------------------
1 | import { Coins } from 'lucide-react';
2 | import { formatCredits } from '../../utils/formatters';
3 |
4 | /**
5 | * 显示带图标的积分金额
6 | * @param {Object} props
7 | * @param {number} props.amount - 积分数量
8 | * @param {'default'|'large'} props.variant - 显示变体
9 | * @param {string} props.className - 额外的 CSS 类
10 | */
11 | export function CreditsBadge({ amount, currencyCode = 'credits', variant = 'default', className = '' }) {
12 | const isLarge = variant === 'large';
13 |
14 | const getSymbol = (code) => {
15 | switch (code) {
16 | case 'credits': return ;
17 | case 'gold': return 💰;
18 | default: return $;
19 | }
20 | };
21 |
22 | return (
23 |
24 | {getSymbol(currencyCode)}
25 |
26 | {formatCredits(amount)}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/switch.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitive from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Switch({
9 | className,
10 | ...props
11 | }) {
12 | return (
13 |
20 |
25 |
26 | );
27 | }
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/hooks/useUserItems.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { shopApi } from '@/lib/api';
3 | import { toast } from 'sonner';
4 |
5 | /**
6 | * 获取用户已购买物品的 Hook
7 | * @param {Object} options - { type }
8 | * @returns {Object} { items, loading, error, refetch }
9 | */
10 | export function useUserItems(options = {}) {
11 | const { type } = options;
12 |
13 | const [items, setItems] = useState([]);
14 | const [loading, setLoading] = useState(true);
15 | const [error, setError] = useState(null);
16 |
17 | const fetchItems = useCallback(async () => {
18 | setLoading(true);
19 | setError(null);
20 | try {
21 | const params = {};
22 | if (type && type !== 'all') params.type = type;
23 |
24 | const data = await shopApi.getMyItems(params);
25 | setItems(data.items || []);
26 | } catch (err) {
27 | console.error('获取我的道具失败:', err);
28 | setError(err);
29 | toast.error('获取我的道具失败');
30 | } finally {
31 | setLoading(false);
32 | }
33 | }, [type]);
34 |
35 | useEffect(() => {
36 | fetchItems();
37 | }, [fetchItems]);
38 |
39 | return {
40 | items,
41 | loading,
42 | error,
43 | refetch: fetchItems,
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/utils/itemTypes.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 商品类型映射和工具函数
3 | */
4 |
5 | export const ITEM_TYPES = {
6 | AVATAR_FRAME: 'avatar_frame',
7 | BADGE: 'badge',
8 | CUSTOM: 'custom',
9 | };
10 |
11 | export const ITEM_TYPE_LABELS = {
12 | [ITEM_TYPES.AVATAR_FRAME]: '头像框',
13 | [ITEM_TYPES.BADGE]: '勋章',
14 | [ITEM_TYPES.CUSTOM]: '自定义',
15 | };
16 |
17 | /**
18 | * 获取商品类型的标签
19 | * @param {string} type - 商品类型键值
20 | * @returns {string} 可读标签
21 | */
22 | export function getItemTypeLabel(type) {
23 | return ITEM_TYPE_LABELS[type] || type;
24 | }
25 |
26 | /**
27 | * 获取所有可筛选的商品类型选项
28 | * @param {boolean} includeAll - 是否包含“全部”选项
29 | * @returns {Array} {value, label} 对象数组
30 | */
31 | export function getItemTypeOptions(includeAll = true) {
32 | const options = Object.entries(ITEM_TYPE_LABELS).map(([value, label]) => ({
33 | value,
34 | label,
35 | }));
36 |
37 | if (includeAll) {
38 | return [{ value: 'all', label: '全部' }, ...options];
39 | }
40 |
41 | return options;
42 | }
43 |
44 | /**
45 | * 检查商品是否过期
46 | * @param {string|null} expiresAt - ISO 日期字符串或 null
47 | * @returns {boolean}
48 | */
49 | export function isItemExpired(expiresAt) {
50 | if (!expiresAt) return false;
51 | return new Date(expiresAt) < new Date();
52 | }
53 |
--------------------------------------------------------------------------------
/apps/api/.dockerignore:
--------------------------------------------------------------------------------
1 | # ============================================
2 | # apps/api/.dockerignore
3 | # 作用:优化 Docker 构建上下文,只把源码和必要文件打包进镜像
4 | # ============================================
5 |
6 | # ========================
7 | # Node / pnpm 依赖
8 | # ========================
9 | node_modules # Node 依赖目录,在镜像内重新安装
10 | .pnpm-store # pnpm 全局存储目录
11 |
12 | # ========================
13 | # 构建输出
14 | # ========================
15 | dist # TypeScript / Fastify 构建产物
16 | .turbo # Turborepo 缓存目录
17 |
18 | # ========================
19 | # 环境变量文件(运行时通过 docker-compose 注入)
20 | # ========================
21 | .env # 本地环境文件
22 | .env.* # 所有 .env 扩展文件
23 | !.env.example # 保留示例文件
24 |
25 | # ========================
26 | # 日志文件
27 | # ========================
28 | *.log # 所有日志文件
29 |
30 | # ========================
31 | # 测试相关文件
32 | # ========================
33 | __tests__ # 单元测试目录
34 | *.test.* # 单元测试文件
35 | *.spec.* # 单元测试文件
36 |
37 | # ========================
38 | # 编辑器 / 操作系统临时文件
39 | # ========================
40 | .vscode
41 | .idea
42 | .DS_Store
43 |
44 | # ========================
45 | # 运行时数据(使用 Docker 卷挂载)
46 | # ========================
47 | uploads/ # 用户上传文件
48 |
--------------------------------------------------------------------------------
/apps/api/src/server.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import path from 'node:path'
4 | import AutoLoad from '@fastify/autoload'
5 | import { dirname } from './utils/index.js'
6 |
7 | const __dirname = dirname(import.meta.url)
8 |
9 | // Pass --options via CLI arguments in command to enable these options.
10 | const options = {}
11 |
12 | export default async function (fastify, opts) {
13 | // Place here your custom code!
14 |
15 | // Do not touch the following lines
16 |
17 | // This loads all plugins defined in plugins
18 | // those should be support plugins that are reused
19 | // through your application
20 | fastify.register(AutoLoad, {
21 | dir: path.join(__dirname, 'plugins'),
22 | options: Object.assign({}, opts),
23 | maxDepth: 1,
24 | })
25 |
26 | // This loads all features defined in extensions
27 | fastify.register(AutoLoad, {
28 | dir: path.join(__dirname, 'extensions'),
29 | options: Object.assign({}, opts),
30 | maxDepth: 1,
31 | })
32 |
33 | // This loads all plugins defined in routes
34 | // define your routes in one of these
35 | fastify.register(AutoLoad, {
36 | dir: path.join(__dirname, 'routes'),
37 | options: {
38 | prefix: '/api',
39 | // encapsulate: false,
40 | }
41 | })
42 | }
43 |
44 | export { options }
45 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/rewards/components/AutoCheckIn.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useRef } from 'react';
4 | import { useAuth } from '@/contexts/AuthContext';
5 | import { rewardsApi } from '@/lib/api';
6 |
7 | import { toast } from 'sonner';
8 |
9 | export default function AutoCheckIn() {
10 | const { isAuthenticated, user } = useAuth();
11 | const hasCheckedIn = useRef(false);
12 |
13 | useEffect(() => {
14 | const checkIn = async () => {
15 | // 如果未登录或本次会话已尝试签到,则跳过
16 | if (!isAuthenticated || !user || hasCheckedIn.current) {
17 | return;
18 | }
19 |
20 | // 标记为已尝试签到,避免重复请求
21 | hasCheckedIn.current = true;
22 |
23 | try {
24 | // 尝试签到
25 | const result = await rewardsApi.checkIn();
26 | if (result.amount) {
27 | // 签到成功,显示提示
28 | toast.success(result.message || '每日签到成功!', {
29 | description: `获得 ${result.amount} 积分,当前连续签到 ${result.checkInStreak} 天`,
30 | duration: 5000,
31 | });
32 | }
33 |
34 | } catch (error) {
35 | // 如果是"今天已经签到过了",则静默失败,不打扰用户
36 | // 其他错误也不需要特别提示,避免影响用户体验
37 | console.error('自动签到失败:', error);
38 | }
39 | };
40 |
41 | checkIn();
42 | }, [isAuthenticated, user]);
43 |
44 | return null;
45 | }
46 |
--------------------------------------------------------------------------------
/apps/web/src/app/(home)/trending/page.js:
--------------------------------------------------------------------------------
1 | import { getTopicsData } from '@/lib/server/topics';
2 | import { TopicListClient } from '@/components/topic/TopicList';
3 |
4 | // 生成页面元数据
5 | export const metadata = {
6 | title: '热门话题',
7 | description: '最受关注的讨论话题',
8 | openGraph: {
9 | title: '热门话题',
10 | description: '最受关注的讨论话题',
11 | type: 'website',
12 | },
13 | };
14 |
15 | export default async function TrendingPage({ searchParams }) {
16 | const resolvedParams = await searchParams;
17 | const page = parseInt(resolvedParams.p) || 1;
18 | const LIMIT = 50;
19 |
20 | // 服务端获取数据
21 | const data = await getTopicsData({
22 | page,
23 | sort: 'trending',
24 | limit: LIMIT,
25 | });
26 |
27 | const totalPages = Math.ceil(data.total / LIMIT);
28 |
29 | return (
30 | <>
31 | {/* 页面标题 */}
32 |
33 |
热门话题
34 |
最受关注的讨论话题
35 |
36 |
37 | {/* 话题列表 */}
38 |
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/apps/web/src/app/(home)/featured/page.js:
--------------------------------------------------------------------------------
1 | import { getTopicsData } from '@/lib/server/topics';
2 | import { TopicListClient } from '@/components/topic/TopicList';
3 |
4 | // 生成页面元数据
5 | export const metadata = {
6 | title: '精华话题',
7 | description: '高质量的讨论和置顶话题',
8 | openGraph: {
9 | title: '精华话题',
10 | description: '高质量的讨论和置顶话题',
11 | type: 'website',
12 | },
13 | };
14 |
15 | export default async function FeaturedPage({ searchParams }) {
16 | const resolvedParams = await searchParams;
17 | const page = parseInt(resolvedParams.p) || 1;
18 | const LIMIT = 50;
19 |
20 | // 服务端获取数据
21 | const data = await getTopicsData({
22 | page,
23 | sort: 'popular',
24 | limit: LIMIT,
25 | });
26 |
27 | const totalPages = Math.ceil(data.total / LIMIT);
28 |
29 | return (
30 | <>
31 | {/* 页面标题 */}
32 |
33 |
精华话题
34 |
高质量的讨论和置顶话题
35 |
36 |
37 | {/* 话题列表 */}
38 |
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/apps/web/src/app/not-found.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { Button } from '@/components/ui/button';
5 | import { Card, CardContent } from '@/components/ui/card';
6 | import { Home, ArrowLeft } from 'lucide-react';
7 |
8 | export default function NotFound() {
9 | return (
10 |
11 |
12 |
13 | 404
14 | 页面未找到
15 |
16 | 抱歉,您访问的页面不存在或已被删除。
17 |
18 |
19 |
20 |
21 |
25 |
26 |
27 |
35 |
36 |
37 |
38 |
39 | );
40 | }
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/hooks/useItemActions.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import { shopApi } from '@/lib/api';
3 | import { toast } from 'sonner';
4 |
5 | /**
6 | * 处理物品装备/卸下操作的 Hook
7 | * @returns {Object} { equip, unequip, actioning, actioningItemId }
8 | */
9 | export function useItemActions() {
10 | const [actioningItemId, setActioningItemId] = useState(null);
11 |
12 | const equip = useCallback(async (userItemId, onSuccess) => {
13 | setActioningItemId(userItemId);
14 | try {
15 | await shopApi.equipItem(userItemId);
16 | toast.success('装备成功');
17 | if (onSuccess) await onSuccess();
18 | } catch (err) {
19 | console.error('装备失败:', err);
20 | toast.error(err.message || '装备失败');
21 | } finally {
22 | setActioningItemId(null);
23 | }
24 | }, []);
25 |
26 | const unequip = useCallback(async (userItemId, onSuccess) => {
27 | setActioningItemId(userItemId);
28 | try {
29 | await shopApi.unequipItem(userItemId);
30 | toast.success('卸下成功');
31 | if (onSuccess) await onSuccess();
32 | } catch (err) {
33 | console.error('卸下失败:', err);
34 | toast.error(err.message || '卸下失败');
35 | } finally {
36 | setActioningItemId(null);
37 | }
38 | }, []);
39 |
40 | return {
41 | equip,
42 | unequip,
43 | actioning: actioningItemId !== null,
44 | actioningItemId,
45 | };
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/components/shared/ItemTypeSelector.jsx:
--------------------------------------------------------------------------------
1 | import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
2 | import { ItemTypeIcon } from './ItemTypeIcon';
3 | import { getItemTypeOptions } from '../../utils/itemTypes';
4 |
5 | /**
6 | * Tabs for filtering by item type
7 | * @param {Object} props
8 | * @param {string} props.value - Current selected type
9 | * @param {Function} props.onChange - Callback when type changes
10 | * @param {boolean} props.showAll - Whether to show "all" tab
11 | * @param {React.ReactNode} props.children - Content to render in TabsContent
12 | */
13 | export function ItemTypeSelector({ value, onChange, showAll = true, excludedTypes = [], children }) {
14 | const options = getItemTypeOptions(showAll).filter(
15 | option => !excludedTypes.includes(option.value)
16 | );
17 |
18 | return (
19 |
20 |
21 | {options.map((option) => (
22 |
23 | {option.value !== 'all' && }
24 | {option.label}
25 |
26 | ))}
27 |
28 |
29 | {children}
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/api/index.js:
--------------------------------------------------------------------------------
1 | import apiClient from '../../../lib/api';
2 |
3 | export const ledgerApi = {
4 | // 获取当前用户的所有账户余额(钱包视图)
5 | async getAccounts() {
6 | return apiClient.get('/ledger/accounts');
7 | },
8 |
9 | // 获取所有活跃货币(公开)
10 | async getActiveCurrencies() {
11 | return apiClient.get('/ledger/active-currencies');
12 | },
13 |
14 | // 获取统计信息(管理员全局/用户个人)
15 | async getStats(params = {}) {
16 | // params: { currency, userId }
17 | return apiClient.get('/ledger/stats', params);
18 | },
19 |
20 | // 获取用户交易历史(统一接口)
21 | async getTransactions(params = {}) {
22 | // params: { page, limit, currency, userId }
23 | return apiClient.get('/ledger/transactions', params);
24 | },
25 |
26 | // 获取单个货币余额
27 | async getBalance(currency = 'credits', userId) {
28 | const params = { currency };
29 | if (userId) {
30 | params.userId = userId;
31 | }
32 | return apiClient.get('/ledger/balance', params);
33 | },
34 |
35 | admin: {
36 | async getCurrencies() {
37 | return apiClient.get('/ledger/currencies');
38 | },
39 | async upsertCurrency(data) {
40 | return apiClient.post('/ledger/currencies', data);
41 | },
42 | async operation(data) {
43 | return apiClient.post('/ledger/admin/operation', data);
44 | }
45 | // getTransactions 已移除,请使用带 userId 的主 getTransactions 方法
46 | }
47 | };
48 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/static.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import fastifyStatic from '@fastify/static';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 | import { createIPX, ipxFSStorage, createIPXNodeServer } from 'ipx';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 |
10 | // 创建 IPX 实例,指定 uploads 目录为存储源 https://github.com/unjs/ipx
11 | const ipx = createIPX({
12 | storage: ipxFSStorage({
13 | dir: path.join(__dirname, '..', '..', 'uploads'), // 与原 root 路径一致
14 | }),
15 | });
16 |
17 | // IPX 处理函数(基于 Express 风格,但兼容 Fastify)
18 | const ipxHandler = createIPXNodeServer(ipx);
19 |
20 | export default fp(async function (fastify, opts) {
21 | // 仅处理头像
22 | fastify.get(
23 | '/uploads/:modifiers/avatars/*',
24 | {
25 | schema: {
26 | hide: true,
27 | },
28 | },
29 | async (request, reply) => {
30 | // 关键:剥离 /uploads 前缀
31 | const originalUrl = request.raw.url;
32 | const cleanPath = originalUrl.replace(/^\/uploads/, '');
33 |
34 | // 修改请求的原始 URL,让 IPX 看到正确的路径
35 | request.raw.url = cleanPath;
36 |
37 | ipxHandler(request.raw, reply.raw);
38 | return reply.hijack();
39 | }
40 | );
41 |
42 | // 保留原始静态文件服务(可选)
43 | fastify.register(fastifyStatic, {
44 | root: path.join(__dirname, '..', '..', 'uploads'),
45 | prefix: '/uploads/',
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/apps/web/src/app/(home)/page.js:
--------------------------------------------------------------------------------
1 | import { getTopicsData } from '@/lib/server/topics';
2 | import { TopicListClient } from '@/components/topic/TopicList';
3 |
4 | // 生成页面元数据(SEO优化)
5 | export async function generateMetadata({ searchParams }) {
6 | const description = `浏览社区中的最新话题,发现精彩讨论`;
7 |
8 | return {
9 | description,
10 | openGraph: {
11 | description,
12 | type: 'website',
13 | },
14 | };
15 | }
16 |
17 | // 主页面组件(服务端组件)
18 | export default async function HomePage({ searchParams }) {
19 | const resolvedParams = await searchParams;
20 | const page = parseInt(resolvedParams.p) || 1;
21 | const sort = resolvedParams.sort || 'latest';
22 | const LIMIT = 50;
23 |
24 | // 服务端获取数据
25 | const data = await getTopicsData({
26 | page,
27 | sort,
28 | limit: LIMIT,
29 | });
30 |
31 | const totalPages = Math.ceil(data.total / LIMIT);
32 |
33 | return (
34 | <>
35 | {/* 页面标题 */}
36 |
37 |
全部话题
38 |
发现社区中的精彩讨论
39 |
40 |
41 | {/* 话题列表(客户端组件) */}
42 |
51 | >
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/CopyButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Check, Copy } from 'lucide-react';
3 | import { Button } from '@/components/ui/button';
4 | import { cn } from '@/lib/utils';
5 | import { useClipboard } from '@/hooks/useClipboard';
6 |
7 | const CopyButton = ({
8 | value,
9 | className,
10 | variant = 'ghost',
11 | size = 'icon',
12 | children,
13 | onCopy,
14 | timeout = 2000,
15 | iconSize = 'h-4 w-4',
16 | ...props
17 | }) => {
18 | const { copy, copied } = useClipboard({ timeout });
19 |
20 | const handleCopy = async (e) => {
21 | e.stopPropagation();
22 | // preventDefault might interfere if inside a form, but for a button type='button' it's fine.
23 | // Safer to just stopPropagation.
24 | const success = await copy(value);
25 | if (success && onCopy) {
26 | onCopy();
27 | }
28 | };
29 |
30 | return (
31 |
47 | );
48 | };
49 |
50 | export default CopyButton;
51 |
--------------------------------------------------------------------------------
/apps/web/.dockerignore:
--------------------------------------------------------------------------------
1 | # ============================================
2 | # apps/web/.dockerignore
3 | # 作用:优化 Docker 构建上下文,只打包源码和必要文件
4 | # ============================================
5 |
6 | # ========================
7 | # Node / pnpm 依赖
8 | # ========================
9 | node_modules # Node 依赖目录,在镜像内重新安装
10 | .pnpm-store # pnpm 全局存储目录
11 |
12 | # ========================
13 | # Next.js 构建产物
14 | # ========================
15 | .next # Next.js 构建目录(SSR / SSG)
16 | out # Next.js 静态导出目录(如果使用 export)
17 |
18 | # ========================
19 | # Turborepo 缓存
20 | # ========================
21 | .turbo # Turborepo 缓存目录
22 |
23 | # ========================
24 | # 环境变量文件(运行时通过 docker-compose 注入)
25 | # ========================
26 | .env # 本地环境文件
27 | .env.* # 所有 .env 扩展文件
28 | !.env.example # 保留示例文件
29 |
30 | # ========================
31 | # 日志文件
32 | # ========================
33 | *.log # 所有日志文件
34 |
35 | # ========================
36 | # 测试相关文件
37 | # ========================
38 | __tests__ # 单元测试目录
39 | *.test.* # 单元测试文件
40 | *.spec.* # 单元测试文件
41 |
42 | # ========================
43 | # 编辑器 / 操作系统临时文件
44 | # ========================
45 | .vscode
46 | .idea
47 | .DS_Store
48 |
49 | # ========================
50 | # 运行时生成的用户数据(使用 Docker 卷挂载)
51 | # ========================
52 | public/uploads/ # 如果前端有上传文件目录,需要挂载卷
53 | .next/cache/ # Next.js 构建缓存,不打包
54 |
--------------------------------------------------------------------------------
/apps/web/src/components/topic/TopicList/TopicListClient.js:
--------------------------------------------------------------------------------
1 | // 专为SSR优化
2 | 'use client';
3 |
4 | import { useState, useEffect } from 'react';
5 | import { useRouter, useSearchParams } from 'next/navigation';
6 | import { TopicListUI } from './TopicListUI';
7 |
8 | export default function TopicListClient({
9 | initialTopics,
10 | totalTopics,
11 | currentPage,
12 | totalPages,
13 | limit = 20,
14 | showPagination = true,
15 | showHeader = true,
16 | }) {
17 | const router = useRouter();
18 | const searchParams = useSearchParams();
19 | const [topics, setTopics] = useState(initialTopics);
20 |
21 | // 监听服务端数据变化,更新本地状态
22 | useEffect(() => {
23 | setTopics(initialTopics);
24 | }, [initialTopics]);
25 |
26 | const handlePageChange = (newPage) => {
27 | const params = new URLSearchParams(searchParams.toString());
28 |
29 | if (newPage === 1) {
30 | params.delete('p');
31 | } else {
32 | params.set('p', newPage.toString());
33 | }
34 |
35 | const newUrl = params.toString() ? `?${params}` : '?';
36 | router.push(newUrl);
37 | window.scrollTo({ top: 0, behavior: 'smooth' });
38 | };
39 |
40 | return (
41 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/docker/.env.docker.example:
--------------------------------------------------------------------------------
1 | # ========================================
2 | # NodeBBS Docker Compose 环境变量配置
3 | # ========================================
4 |
5 | # 应用名称
6 | APP_NAME=nodebbs
7 |
8 | # ========================================
9 | # 数据库配置
10 | # ========================================
11 | POSTGRES_PASSWORD=your_secure_postgres_password_here
12 | POSTGRES_DB=nodebbs
13 | POSTGRES_PORT=5432
14 |
15 | # ========================================
16 | # Redis 配置
17 | # ========================================
18 | REDIS_PASSWORD=your_secure_redis_password_here
19 | REDIS_PORT=6379
20 |
21 | # ========================================
22 | # API 服务配置
23 | # ========================================
24 | API_PORT=7100
25 |
26 | # 用户缓存 TTL(秒)
27 | # 开发环境: 30-60, 生产环境: 120-300
28 | USER_CACHE_TTL=120
29 |
30 | # JWT 配置
31 | # 使用 `openssl rand -base64 32` 生成安全的密钥
32 | JWT_SECRET=change-this-to-a-secure-random-string-in-production
33 | JWT_ACCESS_TOKEN_EXPIRES_IN=1y
34 |
35 | # CORS 配置
36 | # 生产环境建议设置为具体的域名,例如: https://yourdomain.com
37 | CORS_ORIGIN=*
38 |
39 | # 应用 URL(OAuth 回调使用)
40 | APP_URL=http://localhost:3100
41 |
42 | # ========================================
43 | # Web 前端配置
44 | # ========================================
45 | WEB_PORT=3100
46 |
47 | # Web 服务内部访问 API 的地址
48 | # 通常保持默认 http://api:7100 即可,走 Docker 内部网络
49 | SERVER_API_URL=http://api:7100
50 |
51 | # ========================================
52 | # 时区配置
53 | # ========================================
54 | TZ=Asia/Shanghai
55 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/emailVerification.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import { getSetting } from '../utils/settings.js';
3 |
4 | /**
5 | * 中间件:检查是否需要邮箱验证
6 | * 如果系统设置要求邮箱验证,且用户邮箱未验证,则拒绝请求
7 | */
8 | export async function requireEmailVerification(request, reply) {
9 | // 检查是否启用邮箱验证要求
10 | const emailVerificationRequired = await getSetting(
11 | 'email_verification_required',
12 | false
13 | );
14 |
15 | // 如果未启用,直接通过
16 | if (!emailVerificationRequired) {
17 | return;
18 | }
19 |
20 | // 检查用户是否已验证邮箱
21 | if (!request.user.isEmailVerified) {
22 | return reply.code(403).send({
23 | error: '需要验证邮箱',
24 | message: '请先验证您的邮箱后再进行此操作',
25 | code: 'EMAIL_VERIFICATION_REQUIRED',
26 | });
27 | }
28 | }
29 |
30 | /**
31 | * 检查用户是否需要验证邮箱(工具函数)
32 | * @param {Object} user - 用户对象
33 | * @returns {Promise<{required: boolean, verified: boolean, canProceed: boolean}>}
34 | */
35 | export async function checkEmailVerification(user) {
36 | const emailVerificationRequired = await getSetting(
37 | 'email_verification_required',
38 | false
39 | );
40 |
41 | return {
42 | required: emailVerificationRequired,
43 | verified: user.isEmailVerified || false,
44 | canProceed: !emailVerificationRequired || user.isEmailVerified,
45 | };
46 | }
47 |
48 | /**
49 | * Fastify 插件:注册邮箱验证中间件
50 | */
51 |
52 | export default fp(async function (fastify, opts) {
53 | fastify.decorate('requireEmailVerification', requireEmailVerification);
54 | });
55 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/hooks/useShopItems.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { shopApi } from '@/lib/api';
3 | import { toast } from 'sonner';
4 |
5 | /**
6 | * 带有筛选功能的获取商品列表 Hook
7 | * @param {Object} options - { type, page, limit, isAdmin }
8 | * @returns {Object} { items, total, loading, error, refetch }
9 | */
10 | export function useShopItems(options = {}) {
11 | const { type, page = 1, limit = 20, isAdmin = false } = options;
12 |
13 | const [items, setItems] = useState([]);
14 | const [total, setTotal] = useState(0);
15 | const [loading, setLoading] = useState(true);
16 | const [error, setError] = useState(null);
17 |
18 | const fetchItems = useCallback(async () => {
19 | setLoading(true);
20 | setError(null);
21 | try {
22 | const params = { page, limit };
23 | if (type && type !== 'all') params.type = type;
24 |
25 | const api = isAdmin ? shopApi.admin : shopApi;
26 | const data = await api.getItems(params);
27 | setItems(data.items || []);
28 | setTotal(data.total || 0);
29 | } catch (err) {
30 | console.error('获取商品列表失败:', err);
31 | setError(err);
32 | toast.error('获取商品列表失败');
33 | } finally {
34 | setLoading(false);
35 | }
36 | }, [type, page, limit, isAdmin]);
37 |
38 | useEffect(() => {
39 | fetchItems();
40 | }, [fetchItems]);
41 |
42 | return {
43 | items,
44 | total,
45 | loading,
46 | error,
47 | refetch: fetchItems,
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/apps/web/src/components/user/ReportUserButton.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { Button } from '@/components/ui/button';
5 | import { Flag } from 'lucide-react';
6 | import ReportDialog from '@/components/common/ReportDialog';
7 | import { useAuth } from '@/contexts/AuthContext';
8 | import { toast } from 'sonner';
9 |
10 | export default function ReportUserButton({ userId, username }) {
11 | const [reportDialogOpen, setReportDialogOpen] = useState(false);
12 | const { isAuthenticated, user, openLoginDialog } = useAuth();
13 |
14 | const handleClick = () => {
15 | if (!isAuthenticated) {
16 | openLoginDialog();
17 | return;
18 | }
19 |
20 | // 不能举报自己
21 | if (user && user.id === userId) {
22 | toast.error('不能举报自己');
23 | return;
24 | }
25 |
26 | setReportDialogOpen(true);
27 | };
28 |
29 | // 如果是自己,不显示按钮(不能举报自己)
30 | if (user && user.id === userId) {
31 | return null;
32 | }
33 |
34 | return (
35 | <>
36 |
44 |
45 |
52 | >
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/apps/web/src/components/topic/ReplySection.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRef } from 'react';
4 | import ReplyList from './ReplyList';
5 | import ReplyForm from './ReplyForm';
6 |
7 | export default function ReplySection({
8 | topicId,
9 | initialPosts,
10 | totalPosts,
11 | totalPages,
12 | currentPage,
13 | limit,
14 | isClosed,
15 | isDeleted,
16 | onTopicUpdate,
17 | isRewardEnabled,
18 | rewardStatsMap = {}, // 新增:打赏统计 Map
19 | onPostsChange, // 新增:posts 变化回调
20 | onRewardSuccess, // 新增:打赏成功回调(局部更新)
21 | }) {
22 | const replyListRef = useRef(null);
23 |
24 | // 处理新回复添加
25 | const handleReplyAdded = (newPost) => {
26 | if (replyListRef.current) {
27 | replyListRef.current.addPost(newPost);
28 | }
29 | };
30 |
31 | return (
32 | <>
33 | {/* 回复列表 */}
34 |
47 |
48 | {/* 回复表单 */}
49 |
56 | >
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/useClipboard.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import { toast } from 'sonner';
3 |
4 | export function useClipboard({ timeout = 2000 } = {}) {
5 | const [copied, setCopied] = useState(false);
6 |
7 | const copy = useCallback(async (text) => {
8 | if (!text) return false;
9 |
10 | try {
11 | if (navigator?.clipboard?.writeText) {
12 | await navigator.clipboard.writeText(text);
13 | } else {
14 | // Fallback for non-secure contexts
15 | const textArea = document.createElement('textarea');
16 | textArea.value = text;
17 | textArea.style.position = 'fixed';
18 | textArea.style.left = '-9999px';
19 | textArea.style.top = '0';
20 | document.body.appendChild(textArea);
21 | textArea.focus();
22 | textArea.select();
23 | try {
24 | const successful = document.execCommand('copy');
25 | if (!successful) throw new Error('Copy command failed');
26 | } catch (err) {
27 | console.error('Fallback copy failed', err);
28 | throw new Error('复制失败');
29 | } finally {
30 | document.body.removeChild(textArea);
31 | }
32 | }
33 |
34 | setCopied(true);
35 | setTimeout(() => setCopied(false), timeout);
36 | return true;
37 | } catch (error) {
38 | console.error('Copy failed', error);
39 | setCopied(false);
40 | return false;
41 | }
42 | }, [timeout]);
43 |
44 | return { copy, copied };
45 | }
46 |
--------------------------------------------------------------------------------
/apps/api/src/extensions/rewards/index.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import rewardsRoutes from './routes/index.js';
3 | import checkInRoutes from './routes/checkin.js'; // Added here
4 |
5 | import { registerRewardListeners } from './listeners.js';
6 | // ... rest of imports
7 |
8 |
9 | import db from '../../db/index.js';
10 | import { eq, and, inArray } from 'drizzle-orm';
11 | import { users } from '../../db/schema.js';
12 | // userItems removed from schema export to break cycle? No, I commented it out in db/schema.js but it SHOULD indicate I need to import it manually or from where it is defined.
13 | // Wait, userItems is defined in 'extensions/shop/schema.js'.
14 | // db/schema.js does export * from extensions/shop/schema.js.
15 | // So importing from db/schema.js IS correct for userItems IF I removed the *relation* from users, not the export.
16 | // I only removed `userItems: many(userItems)` from `usersRelations`.
17 | // So `userItems` table object IS exported from `db/schema.js`.
18 | // So `import { userItems } ...` is valid.
19 |
20 |
21 | /**
22 | * 奖励插件
23 | * 处理奖励系统逻辑、路由和事件监听器。
24 | */
25 | async function rewardsPlugin(fastify, options) {
26 |
27 |
28 |
29 | // 注册路由
30 | fastify.register(rewardsRoutes, { prefix: '/api/rewards' });
31 | fastify.register(checkInRoutes, { prefix: '/api' });
32 |
33 | // 注册事件监听器
34 | await registerRewardListeners(fastify);
35 |
36 | }
37 |
38 | export default fp(rewardsPlugin, {
39 | name: 'rewards-plugin',
40 | dependencies: ['event-bus', 'ledger-plugin'] // Added ledger-plugin dependency
41 | });
42 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/components/user/CheckInStatus.jsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
2 | import { Calendar } from 'lucide-react';
3 | import Time from '@/components/common/Time';
4 |
5 | /**
6 | * Display check-in streak and last check-in
7 | * @param {Object} props
8 | * @param {number} props.checkInStreak - Current check-in streak
9 | * @param {string} props.lastCheckInDate - Last check-in date (ISO string)
10 | */
11 | export function CheckInStatus({ checkInStreak, lastCheckInDate }) {
12 | return (
13 |
14 |
15 |
16 |
17 | 每日签到
18 |
19 |
20 | 每日首次访问自动签到,连续签到可获得额外奖励
21 |
22 |
23 |
24 |
25 |
26 | 当前连续签到
27 | {checkInStreak || 0} 天
28 |
29 | {lastCheckInDate && (
30 |
31 | 上次签到
32 |
33 |
34 |
35 |
36 | )}
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/popover.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Popover({
9 | ...props
10 | }) {
11 | return ;
12 | }
13 |
14 | function PopoverTrigger({
15 | ...props
16 | }) {
17 | return ;
18 | }
19 |
20 | function PopoverContent({
21 | className,
22 | align = "center",
23 | sideOffset = 4,
24 | ...props
25 | }) {
26 | return (
27 |
28 |
37 |
38 | );
39 | }
40 |
41 | function PopoverAnchor({
42 | ...props
43 | }) {
44 | return ;
45 | }
46 |
47 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
48 |
--------------------------------------------------------------------------------
/apps/web/src/lib/server/api.js:
--------------------------------------------------------------------------------
1 | // SSR请求专用
2 | import { cookies } from 'next/headers';
3 | import { getApiBaseUrl } from '../api-url';
4 |
5 | export const request = async (endpoint, options = {}) => {
6 | const baseURL = getApiBaseUrl();
7 | const url = `${baseURL}${endpoint}`;
8 |
9 | const headers = {
10 | 'Content-Type': 'application/json',
11 | ...options.headers,
12 | };
13 |
14 | const cks = await cookies();
15 | const token = cks.get('auth_token')?.value;
16 | if (token) {
17 | // headers['Authorization'] = `Bearer ${token}`;
18 | headers['Cookie'] = `auth_token=${token}`;
19 | }
20 |
21 | try {
22 | const defaultCache = options.method && options.method !== 'GET' ? 'no-store' : undefined;
23 |
24 | const response = await fetch(url, {
25 | ...options,
26 | // 对于 GET 请求使用默认缓存策略,让 Next.js 自动去重同一渲染周期内的相同请求
27 | // 对于其他请求(POST/PUT/DELETE)使用 no-store
28 | cache: options.cache ?? defaultCache,
29 | headers,
30 | });
31 |
32 | if (!response.ok) {
33 | if (response.status === 404) {
34 | return null;
35 | }
36 | throw new Error('Failed to fetch');
37 | }
38 |
39 | return await response.json();
40 | } catch (error) {
41 | console.error(`Error fetching ${url}:`, error);
42 | return null;
43 | }
44 | };
45 |
46 | // 获取当前登录用户 (SSR专用)
47 | // 优化:只有在存在 auth_token cookie 时才发请求
48 | export const getCurrentUser = async () => {
49 | const cookieStore = await cookies();
50 | const hasToken = cookieStore.has('auth_token');
51 |
52 | if (!hasToken) {
53 | return null;
54 | }
55 |
56 | return request('/auth/me');
57 | };
58 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/security.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import cors from '@fastify/cors';
3 | import { isDev } from '../utils/env.js';
4 |
5 | async function securityPlugin(fastify, opts) {
6 | // Register CORS https://github.com/fastify/fastify-cors?tab=readme-ov-file#options
7 | await fastify.register(cors, {
8 | origin: (origin, cb) => {
9 | // 允许没有 Origin 头的请求(如服务器间通信、Postman)
10 | if (!origin) {
11 | cb(null, true);
12 | return;
13 | }
14 |
15 | // 1. 优先检查 CORS_ORIGIN 环境变量
16 | if (process.env.CORS_ORIGIN) {
17 | if (process.env.CORS_ORIGIN === 'false') {
18 | cb(new Error('Not allowed by CORS'), false);
19 | return;
20 | }
21 |
22 | const allowedOrigins = process.env.CORS_ORIGIN.split(',').map((s) => s.trim());
23 | if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) {
24 | cb(null, true);
25 | return;
26 | }
27 | }
28 |
29 | // 2. 开发环境下,自动允许 localhost 和 127.0.0.1
30 | if (isDev) {
31 | const url = new URL(origin);
32 | if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
33 | cb(null, true);
34 | return;
35 | }
36 | }
37 |
38 | // 3. 生产环境默认只允许 APP_URL
39 | if (process.env.APP_URL && origin === process.env.APP_URL) {
40 | cb(null, true);
41 | return;
42 | }
43 |
44 | // 拒绝其他来源
45 | cb(new Error('Not allowed by CORS'), false);
46 | },
47 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
48 | credentials: true,
49 | });
50 | }
51 |
52 | export default fp(securityPlugin);
53 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/badge.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const badgeVariants = cva(
8 | "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14 | secondary:
15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16 | destructive:
17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18 | outline:
19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | },
25 | }
26 | )
27 |
28 | function Badge({
29 | className,
30 | variant,
31 | asChild = false,
32 | ...props
33 | }) {
34 | const Comp = asChild ? Slot : "span"
35 |
36 | return (
37 |
41 | );
42 | }
43 |
44 | export { Badge, badgeVariants }
45 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/scroll-area.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function ScrollArea({
9 | className,
10 | children,
11 | ...props
12 | }) {
13 | return (
14 |
15 |
18 | {children}
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | function ScrollBar({
27 | className,
28 | orientation = "vertical",
29 | ...props
30 | }) {
31 | return (
32 |
44 |
47 |
48 | );
49 | }
50 |
51 | export { ScrollArea, ScrollBar }
52 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/alert.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-card text-card-foreground",
12 | destructive:
13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | function Alert({
23 | className,
24 | variant,
25 | ...props
26 | }) {
27 | return (
28 |
33 | );
34 | }
35 |
36 | function AlertTitle({
37 | className,
38 | ...props
39 | }) {
40 | return (
41 |
45 | );
46 | }
47 |
48 | function AlertDescription({
49 | className,
50 | ...props
51 | }) {
52 | return (
53 |
60 | );
61 | }
62 |
63 | export { Alert, AlertTitle, AlertDescription }
64 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/card.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cn } from "@/lib/utils"
3 |
4 | const Card = React.forwardRef(({ className, ...props }, ref) => (
5 |
13 | ))
14 | Card.displayName = "Card"
15 |
16 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
17 |
22 | ))
23 | CardHeader.displayName = "CardHeader"
24 |
25 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
26 |
34 | ))
35 | CardTitle.displayName = "CardTitle"
36 |
37 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
38 |
43 | ))
44 | CardDescription.displayName = "CardDescription"
45 |
46 | const CardContent = React.forwardRef(({ className, ...props }, ref) => (
47 |
48 | ))
49 | CardContent.displayName = "CardContent"
50 |
51 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
52 |
57 | ))
58 | CardFooter.displayName = "CardFooter"
59 |
60 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/api/index.js:
--------------------------------------------------------------------------------
1 | import apiClient from '../../../lib/api';
2 |
3 | // ============= 商城系统 API =============
4 | export const shopApi = {
5 | // 获取商城商品列表
6 | async getItems(params = {}) {
7 | return apiClient.get('/shop/items', params);
8 | },
9 |
10 | // 购买商品
11 | async buyItem(itemId) {
12 | return apiClient.post(`/shop/items/${itemId}/buy`);
13 | },
14 |
15 | // 赠送商品
16 | async giftItem(itemId, receiverId, message) {
17 | return apiClient.post(`/shop/items/${itemId}/gift`, { receiverId, message });
18 | },
19 |
20 | // 获取我的商品列表
21 | async getMyItems(params = {}) {
22 | return apiClient.get('/shop/my-items', params);
23 | },
24 |
25 | // 获取指定用户装备的物品
26 | async getUserEquippedItems(userId) {
27 | return apiClient.get(`/shop/users/${userId}/equipped-items`);
28 | },
29 |
30 | // 装备商品
31 | async equipItem(userItemId) {
32 | return apiClient.post(`/shop/my-items/${userItemId}/equip`);
33 | },
34 |
35 | // 卸下商品
36 | async unequipItem(userItemId) {
37 | return apiClient.post(`/shop/my-items/${userItemId}/unequip`);
38 | },
39 |
40 | // 管理员 API
41 | admin: {
42 | // 获取所有商品(含下架)
43 | async getItems(params = {}) {
44 | return apiClient.get('/shop/admin/items', params);
45 | },
46 |
47 | // 创建商品
48 | async createItem(data) {
49 | return apiClient.post('/shop/admin/items', data);
50 | },
51 |
52 | // 更新商品
53 | async updateItem(itemId, data) {
54 | return apiClient.request(`/shop/admin/items/${itemId}`, {
55 | method: 'PATCH',
56 | body: JSON.stringify(data),
57 | });
58 | },
59 |
60 | // 删除商品
61 | async deleteItem(itemId) {
62 | return apiClient.delete(`/shop/admin/items/${itemId}`);
63 | },
64 | },
65 | };
66 |
--------------------------------------------------------------------------------
/apps/web/src/components/user/UserProfileClient.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import FollowButton from '@/components/user/FollowButton';
5 | import { Users } from 'lucide-react';
6 | import Link from 'next/link';
7 |
8 | export default function UserProfileClient({
9 | username,
10 | initialFollowerCount,
11 | initialFollowingCount,
12 | initialIsFollowing,
13 | }) {
14 | const [followerCount, setFollowerCount] = useState(initialFollowerCount || 0);
15 | const [followingCount, setFollowingCount] = useState(initialFollowingCount || 0);
16 | const [isFollowing, setIsFollowing] = useState(initialIsFollowing || false);
17 |
18 | const handleFollowChange = (newIsFollowing) => {
19 | setIsFollowing(newIsFollowing);
20 | // 更新粉丝数
21 | setFollowerCount((prev) => (newIsFollowing ? prev + 1 : prev - 1));
22 | };
23 |
24 | return (
25 | <>
26 | {/* 操作按钮 */}
27 |
32 |
33 | {/* 关注者和粉丝 */}
34 |
35 |
39 |
40 | {followerCount}
41 | 粉丝
42 |
43 |
47 | {followingCount}
48 | 关注
49 |
50 |
51 | >
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/pages/user/UserItemsPage.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { Package } from 'lucide-react';
5 | import { useUserItems } from '@/extensions/shop/hooks/useUserItems';
6 | import { useItemActions } from '@/extensions/shop/hooks/useItemActions';
7 | import { ItemTypeSelector } from '@/extensions/shop/components/shared/ItemTypeSelector';
8 | import { ItemInventoryGrid } from '../../components/user/ItemInventoryGrid';
9 |
10 | export default function UserItemsPage() {
11 | const [itemType, setItemType] = useState('all');
12 |
13 | const { items, loading, refetch } = useUserItems({ type: itemType });
14 |
15 | // Show all items including badges
16 | const displayedItems = items;
17 |
18 | const { equip, unequip, actioningItemId } = useItemActions();
19 |
20 | const handleEquip = async (userItemId) => {
21 | await equip(userItemId, refetch);
22 | };
23 |
24 | const handleUnequip = async (userItemId) => {
25 | await unequip(userItemId, refetch);
26 | };
27 |
28 | return (
29 |
30 | {/* Page Header */}
31 |
32 |
33 |
34 | 我的道具
35 |
36 |
管理你的专属装扮
37 |
38 |
39 | {/* Item Type Selector & Grid */}
40 |
41 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/apps/web/src/app/profile/topics/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter, useSearchParams } from 'next/navigation';
4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
5 | import { CreatedTopics } from './components/CreatedTopics';
6 | import { FavoriteTopics } from './components/FavoriteTopics';
7 |
8 | export default function MyTopicsPage() {
9 | const router = useRouter();
10 | const searchParams = useSearchParams();
11 |
12 | // 从 URL 获取当前 Tab,默认为 'created'
13 | const currentTab = searchParams.get('tab') || 'created';
14 |
15 | const handleTabChange = (value) => {
16 | const params = new URLSearchParams(searchParams);
17 | if (value === 'created') {
18 | params.delete('tab');
19 | } else {
20 | params.set('tab', value);
21 | }
22 | router.push(`/profile/topics?${params.toString()}`);
23 | };
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | 我的话题
31 |
32 |
查看发布和收藏的话题
33 |
34 |
35 |
40 |
41 | 我的发布
42 | 我的收藏
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/Time.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useMemo, useEffect, useState } from 'react';
4 | import dayjs from 'dayjs';
5 | import relativeTime from 'dayjs/plugin/relativeTime';
6 | import localizedFormat from 'dayjs/plugin/localizedFormat';
7 | import 'dayjs/locale/zh-cn';
8 |
9 | dayjs.extend(relativeTime);
10 | dayjs.extend(localizedFormat);
11 | dayjs.locale('zh-cn');
12 |
13 | /**
14 | * Time 组件 - 显示格式化或相对时间(可自动刷新)
15 | * @param {string|number|Date} date - 日期
16 | * @param {string} [className] - 自定义 className
17 | * @param {boolean} [showTooltip=true] - 悬停显示完整时间
18 | * @param {string} [format='YYYY-MM-DD HH:mm:ss'] - 格式化模板
19 | * @param {boolean} [fromNow=false] - 是否显示“几分钟前”
20 | * @param {number} [refreshInterval=60000] - 自动刷新间隔(毫秒),默认 1 分钟
21 | */
22 | export default function Time({
23 | date,
24 | className = '',
25 | showTooltip = true,
26 | format = 'YYYY-MM-DD HH:mm:ss',
27 | fromNow = false,
28 | refreshInterval = 60000,
29 | }) {
30 | const [tick, setTick] = useState(0);
31 |
32 | // 自动刷新(仅在 fromNow 模式下生效)
33 | useEffect(() => {
34 | if (!fromNow) return;
35 | const timer = setInterval(() => setTick((v) => v + 1), refreshInterval);
36 | return () => clearInterval(timer);
37 | }, [fromNow, refreshInterval]);
38 |
39 | const obj = useMemo(() => {
40 | if (!date) return null;
41 | const d = dayjs(date);
42 | if (!d.isValid()) return null;
43 |
44 | return {
45 | title: d.format('YYYY-MM-DD HH:mm:ss'),
46 | value: fromNow ? d.fromNow() : d.format(format),
47 | };
48 | }, [date, format, fromNow, tick]);
49 |
50 | if (!obj) return null;
51 |
52 | return (
53 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/tooltip.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function TooltipProvider({
9 | delayDuration = 0,
10 | ...props
11 | }) {
12 | return ();
13 | }
14 |
15 | function Tooltip({
16 | ...props
17 | }) {
18 | return (
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | function TooltipTrigger({
26 | ...props
27 | }) {
28 | return ;
29 | }
30 |
31 | function TooltipContent({
32 | className,
33 | sideOffset = 0,
34 | children,
35 | ...props
36 | }) {
37 | return (
38 |
39 |
47 | {children}
48 |
50 |
51 |
52 | );
53 | }
54 |
55 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
56 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/FormDialog.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogDescription,
5 | DialogFooter,
6 | DialogHeader,
7 | DialogTitle,
8 | } from '@/components/ui/dialog';
9 | import { Button } from '@/components/ui/button';
10 | import { Loader2 } from 'lucide-react';
11 |
12 | export function FormDialog({
13 | open,
14 | onOpenChange,
15 | title,
16 | description,
17 | children,
18 | submitText = '保存',
19 | cancelText = '取消',
20 | onSubmit,
21 | loading = false,
22 | maxWidth = 'sm:max-w-[425px]',
23 | disabled = false,
24 | submitClassName = '',
25 | footer = undefined, // Custom footer content. Pass null to hide footer.
26 | onInteractOutside,
27 | }) {
28 | return (
29 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/apps/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nodebbs-api",
3 | "version": "1.3.1-beta.2",
4 | "description": "This project was bootstrapped with Fastify-CLI.",
5 | "type": "module",
6 | "main": "app.js",
7 | "directories": {
8 | "test": "test"
9 | },
10 | "scripts": {
11 | "dev": "dotenvx run -- node --watch src/app.js",
12 | "prod": "dotenvx run -- pm2 start ecosystem.config.cjs",
13 | "db:push": "drizzle-kit push",
14 | "db:generate": "drizzle-kit generate",
15 | "db:migrate": "drizzle-kit migrate",
16 | "db:studio": "drizzle-kit studio",
17 | "seed": "dotenvx run -- node src/scripts/init/index.js",
18 | "seed:reset": "dotenvx run -- node src/scripts/init/index.js --reset",
19 | "seed:list": "dotenvx run -- node src/scripts/init/index.js --list",
20 | "seed:clean": "dotenvx run -- node src/scripts/init/index.js --clean"
21 | },
22 | "keywords": [],
23 | "dependencies": {
24 | "@dotenvx/dotenvx": "^1.51.0",
25 | "@fastify/autoload": "^6.0.0",
26 | "@fastify/cookie": "^11.0.2",
27 | "@fastify/cors": "^11.1.0",
28 | "@fastify/formbody": "^8.0.2",
29 | "@fastify/jwt": "^10.0.0",
30 | "@fastify/multipart": "^9.2.1",
31 | "@fastify/oauth2": "^8.1.2",
32 | "@fastify/rate-limit": "^10.3.0",
33 | "@fastify/redis": "^7.1.0",
34 | "@fastify/sensible": "^6.0.0",
35 | "@fastify/static": "^8.3.0",
36 | "@fastify/swagger": "^9.5.2",
37 | "@fastify/swagger-ui": "^5.2.3",
38 | "bcryptjs": "^3.0.2",
39 | "drizzle-orm": "^0.43.1",
40 | "fastify": "^5.0.0",
41 | "fastify-cli": "^7.4.0",
42 | "fastify-plugin": "^5.0.0",
43 | "ioredis": "^5.8.2",
44 | "ipx": "^3.1.1",
45 | "jsonwebtoken": "^9.0.3",
46 | "ms": "^2.1.3",
47 | "nodemailer": "^7.0.10",
48 | "pg": "^8.16.0",
49 | "pino": "^10.1.0",
50 | "pino-pretty": "^13.1.2",
51 | "slug": "^11.0.1"
52 | },
53 | "devDependencies": {
54 | "drizzle-kit": "^0.31.1"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/components/user/ShopItemGrid.jsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from '@/components/ui/card';
2 | import { ShoppingCart } from 'lucide-react';
3 | import { Loading } from '@/components/common/Loading';
4 | import { ShopItemCard } from './ShopItemCard';
5 |
6 | /**
7 | * Grid layout of shop items
8 | * @param {Object} props
9 | * @param {Array} props.items - Array of shop items
10 | * @param {Array} props.items - Array of shop items
11 | * @param {Array} props.accounts - User's accounts list
12 | * @param {Function} props.onPurchase - Callback when purchase button clicked
13 | * @param {boolean} props.isAuthenticated - Whether user is authenticated
14 | * @param {boolean} props.loading - Loading state
15 | */
16 | export function ShopItemGrid({ items, accounts = [], onPurchase, isAuthenticated, loading }) {
17 | if (loading) {
18 | return ;
19 | }
20 |
21 | if (items.length === 0) {
22 | return (
23 |
24 |
25 |
26 |
27 | 暂无商品
28 |
29 |
30 | 该分类下暂时没有商品
31 |
32 |
33 |
34 | );
35 | }
36 |
37 | return (
38 |
39 | {items.map((item) => {
40 | const balance = accounts.find(a => a.currency.code === item.currencyCode)?.balance || 0;
41 | return (
42 |
49 | );
50 | })}
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/pages/admin/LedgerAdminPage.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Wallet, List as ListIcon, Coins } from 'lucide-react';
4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
5 | import { LedgerOverview } from '../../components/admin/LedgerOverview';
6 | import { LedgerTransactions } from '../../components/admin/LedgerTransactions';
7 | import { LedgerCurrencies } from '../../components/admin/LedgerCurrencies';
8 |
9 | export default function LedgerAdminPage() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | 货币管理
17 |
18 |
管理系统货币类型及相关金融设置
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | 概览
27 |
28 |
29 |
30 | 交易记录
31 |
32 |
33 |
34 | 货币管理
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nodebbs-web",
3 | "version": "1.3.1-beta.2",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack -p 3100",
7 | "build": "next build --turbopack",
8 | "prod": "dotenvx run -- pm2 start ecosystem.config.cjs"
9 | },
10 | "dependencies": {
11 | "@radix-ui/react-accordion": "^1.2.12",
12 | "@radix-ui/react-alert-dialog": "^1.1.15",
13 | "@radix-ui/react-avatar": "^1.1.10",
14 | "@radix-ui/react-checkbox": "^1.3.3",
15 | "@radix-ui/react-dialog": "^1.1.15",
16 | "@radix-ui/react-dropdown-menu": "^2.1.16",
17 | "@radix-ui/react-label": "^2.1.7",
18 | "@radix-ui/react-popover": "^1.1.15",
19 | "@radix-ui/react-scroll-area": "^1.2.10",
20 | "@radix-ui/react-select": "^2.2.6",
21 | "@radix-ui/react-separator": "^1.1.7",
22 | "@radix-ui/react-slider": "^1.3.6",
23 | "@radix-ui/react-slot": "^1.2.3",
24 | "@radix-ui/react-switch": "^1.2.6",
25 | "@radix-ui/react-tabs": "^1.1.13",
26 | "@radix-ui/react-tooltip": "^1.2.8",
27 | "@uidotdev/usehooks": "^2.4.1",
28 | "canvas-confetti": "^1.9.4",
29 | "class-variance-authority": "^0.7.1",
30 | "clsx": "^2.1.1",
31 | "dayjs": "^1.11.18",
32 | "framer-motion": "^12.23.25",
33 | "lucide-react": "^0.545.0",
34 | "next": "16.0.7",
35 | "next-themes": "^0.4.6",
36 | "qrcode.react": "^4.2.0",
37 | "react": "19.2.1",
38 | "react-dom": "19.2.1",
39 | "react-hook-form": "^7.65.0",
40 | "react-markdown": "^10.1.0",
41 | "react-syntax-highlighter": "^16.1.0",
42 | "remark-directive": "^4.0.0",
43 | "remark-gfm": "^4.0.1",
44 | "sonner": "^2.0.7",
45 | "tailwind-merge": "^3.3.1",
46 | "unist-util-visit": "^5.0.0",
47 | "vaul": "^1.1.2"
48 | },
49 | "devDependencies": {
50 | "@dotenvx/dotenvx": "^1.51.0",
51 | "@tailwindcss/postcss": "^4",
52 | "@tailwindcss/typography": "^0.5.19",
53 | "tailwindcss": "^4",
54 | "tw-animate-css": "^1.4.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/src/app/create/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { useRouter } from 'next/navigation';
5 | import { topicApi } from '@/lib/api';
6 | import { toast } from 'sonner';
7 | import TopicForm from '@/components/topic/TopicForm';
8 | import RequireAuth from '@/components/auth/RequireAuth';
9 |
10 | export default function CreateTopic() {
11 | const router = useRouter();
12 | const [submitting, setSubmitting] = useState(false);
13 |
14 | const handleSubmit = async (formData) => {
15 | setSubmitting(true);
16 |
17 | try {
18 | const response = await topicApi.create({
19 | title: formData.title,
20 | content: formData.content,
21 | categoryId: formData.categoryId,
22 | tags: formData.tags,
23 | });
24 |
25 | if (response.requiresApproval) {
26 | toast.success(
27 | response.message || '您的话题已提交,等待审核后将公开显示'
28 | );
29 | router.push('/profile/topics');
30 | } else {
31 | toast.success(response.message || '主题发布成功!');
32 | router.push(`/topic/${response.topic?.id}`);
33 | }
34 | } catch (err) {
35 | console.error('发布主题失败:', err);
36 | toast.error('发布主题失败:' + err.message);
37 | } finally {
38 | setSubmitting(false);
39 | }
40 | };
41 |
42 | const handleCancel = () => {
43 | router.push('/');
44 | };
45 |
46 | return (
47 |
48 |
49 | {/* 页面标题 */}
50 |
51 |
发布新话题
52 |
53 | 分享你的想法,开启一场精彩的讨论
54 |
55 |
56 |
57 |
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/tabs.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Tabs({
9 | className,
10 | ...props
11 | }) {
12 | return (
13 |
17 | );
18 | }
19 |
20 | function TabsList({
21 | className,
22 | ...props
23 | }) {
24 | return (
25 |
32 | );
33 | }
34 |
35 | function TabsTrigger({
36 | className,
37 | ...props
38 | }) {
39 | return (
40 |
47 | );
48 | }
49 |
50 | function TabsContent({
51 | className,
52 | ...props
53 | }) {
54 | return (
55 |
59 | );
60 | }
61 |
62 | export { Tabs, TabsList, TabsTrigger, TabsContent }
63 |
--------------------------------------------------------------------------------
/apps/web/src/components/auth/LoginDialog/ModeSwitcher.js:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 |
3 | export function ModeSwitcher({
4 | mode,
5 | registrationMode,
6 | isLoading,
7 | loadingSettings,
8 | onModeChange
9 | }) {
10 | if (loadingSettings) return null;
11 |
12 | const isLogin = mode === 'login';
13 | const isForgotPassword = mode === 'forgot-password';
14 |
15 | if (isForgotPassword) {
16 | return (
17 |
18 |
26 |
27 | );
28 | }
29 |
30 | if (registrationMode === 'closed') {
31 | if (isLogin) {
32 | return (
33 |
34 |
35 | 系统当前已关闭用户注册
36 |
37 |
38 | );
39 | } else {
40 | return (
41 |
42 |
50 |
51 | );
52 | }
53 | }
54 |
55 | return (
56 |
57 |
65 | {!isLogin && registrationMode === 'invitation' && (
66 |
67 | 当前为邀请码注册模式,需要邀请码才能注册
68 |
69 | )}
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/apps/web/src/contexts/SettingsContext.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { createContext, useContext, useState, useEffect } from 'react';
4 | import { settingsApi } from '@/lib/api';
5 |
6 | const SettingsContext = createContext(null);
7 |
8 | export function SettingsProvider({ children }) {
9 | const [settings, setSettings] = useState({});
10 | const [loading, setLoading] = useState(true);
11 | const [error, setError] = useState(null);
12 |
13 | // 加载设置
14 | const loadSettings = async () => {
15 | try {
16 | setLoading(true);
17 | const data = await settingsApi.getAll();
18 | setSettings(data);
19 | setError(null);
20 | } catch (err) {
21 | console.error('Failed to load settings:', err);
22 | setError(err.message);
23 | } finally {
24 | setLoading(false);
25 | }
26 | };
27 |
28 | // 初始化时加载设置
29 | useEffect(() => {
30 | loadSettings();
31 | }, []);
32 |
33 | // 获取单个设置的值
34 | const getSetting = (key, defaultValue = null) => {
35 | return settings[key]?.value ?? defaultValue;
36 | };
37 |
38 | // 更新单个设置
39 | const updateSetting = async (key, value) => {
40 | try {
41 | await settingsApi.update(key, value);
42 | setSettings(prev => ({
43 | ...prev,
44 | [key]: { ...prev[key], value }
45 | }));
46 | return { success: true };
47 | } catch (err) {
48 | console.error('Failed to update setting:', err);
49 | return { success: false, error: err.message };
50 | }
51 | };
52 |
53 | const value = {
54 | settings,
55 | loading,
56 | error,
57 | getSetting,
58 | updateSetting,
59 | refreshSettings: loadSettings,
60 | };
61 |
62 | return (
63 |
64 | {children}
65 |
66 | );
67 | }
68 |
69 | // Hook 来使用设置上下文
70 | export function useSettings() {
71 | const context = useContext(SettingsContext);
72 | if (!context) {
73 | throw new Error('useSettings must be used within SettingsProvider');
74 | }
75 | return context;
76 | }
77 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/shop/components/user/ItemInventoryGrid.jsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from '@/components/ui/card';
2 | import { Button } from '@/components/ui/button';
3 | import { Package } from 'lucide-react';
4 | import { Loading } from '@/components/common/Loading';
5 | import { ItemInventoryCard } from './ItemInventoryCard';
6 | import { getItemTypeLabel } from '@/extensions/shop/utils/itemTypes';
7 |
8 | /**
9 | * 用户物品网格
10 | * @param {Object} props
11 | * @param {Array} props.items - 用户物品数组
12 | * @param {Function} props.onEquip - 点击装备按钮时的回调
13 | * @param {Function} props.onUnequip - 点击卸下按钮时的回调
14 | * @param {number} props.actioningItemId - 当前正在操作的物品 ID
15 | * @param {boolean} props.loading - 加载状态
16 | * @param {string} props.itemType - 当前物品类型筛选
17 | */
18 | export function ItemInventoryGrid({ items, onEquip, onUnequip, actioningItemId, loading, itemType }) {
19 | if (loading) {
20 | return ;
21 | }
22 |
23 | if (items.length === 0) {
24 | return (
25 |
26 |
27 |
28 |
29 | 暂无道具
30 |
31 |
32 | {itemType === 'all'
33 | ? '你还没有购买任何道具'
34 | : `你还没有购买${getItemTypeLabel(itemType)}`}
35 |
36 |
39 |
40 |
41 | );
42 | }
43 |
44 | return (
45 |
46 | {items.map((item) => (
47 |
54 | ))}
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/apps/web/src/components/auth/LoginDialog/LoginForm.js:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { Input } from '@/components/ui/input';
3 | import { Label } from '@/components/ui/label';
4 | import { DialogFooter } from '@/components/ui/dialog';
5 | import { FormMessage } from './FormMessage';
6 |
7 | export function LoginForm({
8 | formData,
9 | error,
10 | isLoading,
11 | onSubmit,
12 | onChange,
13 | onForgotPassword
14 | }) {
15 | return (
16 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/apps/api/src/extensions/badges/listeners.js:
--------------------------------------------------------------------------------
1 | import { checkBadgeConditions } from './services/badgeService.js';
2 |
3 | export default async function badgeListeners(fastify) {
4 | if (!fastify.eventBus) return;
5 |
6 | const handleActivity = async (payload) => {
7 | try {
8 | const userId = payload.userId || payload.user?.id;
9 | if (userId) {
10 | fastify.log.info(`[Badges] Checking conditions for user ${userId}`);
11 | const newBadges = await checkBadgeConditions(userId);
12 |
13 | if (newBadges && newBadges.length > 0) {
14 | fastify.log.info(`[Badges] User ${userId} unlocked ${newBadges.length} new badges`);
15 |
16 | const { notifications } = await import('../../db/schema.js');
17 | const db = (await import('../../db/index.js')).default;
18 |
19 | // 为每个新徽章创建通知
20 | for (const badge of newBadges) {
21 | await db.insert(notifications).values({
22 | userId,
23 | type: 'badge_earned',
24 | message: `恭喜!你获得了一枚新勋章:${badge.name}`,
25 | isRead: false,
26 | createdAt: new Date(),
27 | metadata: JSON.stringify({
28 | badgeId: badge.id,
29 | badgeName: badge.name,
30 | iconUrl: badge.iconUrl,
31 | slug: badge.slug
32 | })
33 | });
34 | }
35 | }
36 | }
37 | } catch (err) {
38 | fastify.log.error(`[Badges] Error checking conditions: ${err.message}`);
39 | }
40 | };
41 |
42 | // 监听可能触发徽章的事件
43 | fastify.eventBus.on('post.created', handleActivity);
44 | fastify.eventBus.on('topic.created', handleActivity);
45 | fastify.eventBus.on('post.liked', (payload) => {
46 | // 对于 'like_received_count',payload 可能包含帖子的 ownerId
47 | if (payload.postOwnerId) {
48 | handleActivity({ userId: payload.postOwnerId });
49 | }
50 | });
51 | fastify.eventBus.on('user.login', handleActivity); // 用于签到连胜或登录天数
52 | fastify.eventBus.on('user.checkin', handleActivity); // 监听签到事件
53 | }
54 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/accordion.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDownIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Accordion({
10 | ...props
11 | }) {
12 | return ;
13 | }
14 |
15 | function AccordionItem({
16 | className,
17 | ...props
18 | }) {
19 | return (
20 |
24 | );
25 | }
26 |
27 | function AccordionTrigger({
28 | className,
29 | children,
30 | ...props
31 | }) {
32 | return (
33 |
34 | svg]:rotate-180",
38 | className
39 | )}
40 | {...props}>
41 | {children}
42 |
44 |
45 |
46 | );
47 | }
48 |
49 | function AccordionContent({
50 | className,
51 | children,
52 | ...props
53 | }) {
54 | return (
55 |
59 | {children}
60 |
61 | );
62 | }
63 |
64 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
65 |
--------------------------------------------------------------------------------
/apps/api/src/extensions/badges/enricher.js:
--------------------------------------------------------------------------------
1 | import { userEnricher } from '../../services/userEnricher.js';
2 | import { getUserBadges, getUsersBadges } from './services/badgeService.js';
3 |
4 | /**
5 | * 为单个用户补充勋章信息
6 | */
7 | export default function registerBadgeEnricher(fastify) {
8 | /**
9 | * 为单个用户补充勋章信息
10 | */
11 | const enrichUser = async (user) => {
12 | if (!user || !user.id) return;
13 |
14 | // 检查积分货币是否启用 (Assuming badges rely on credits system)
15 | if (fastify && fastify.ledger) {
16 | const isCreditsActive = await fastify.ledger.isCurrencyActive('credits');
17 | if (!isCreditsActive) return;
18 | }
19 |
20 | try {
21 | const badges = await getUserBadges(user.id);
22 | user.badges = badges;
23 | } catch (err) {
24 | console.error(`[BadgeEnricher] Failed to enrich user ${user.id}:`, err);
25 | user.badges = [];
26 | }
27 | };
28 |
29 | /**
30 | * 为多个用户批量补充勋章信息
31 | */
32 | const enrichUsers = async (users) => {
33 | if (!users || users.length === 0) return;
34 |
35 | // 检查积分货币是否启用
36 | if (fastify && fastify.ledger) {
37 | const isCreditsActive = await fastify.ledger.isCurrencyActive('credits');
38 | if (!isCreditsActive) return;
39 | }
40 |
41 | // 提取 ID,过滤掉已有勋章数据以避免不必要的重复获取(可选优化)
42 | const userIds = users.filter((u) => u.id).map((u) => u.id);
43 | const uniqueIds = [...new Set(userIds)];
44 |
45 | if (uniqueIds.length === 0) return;
46 |
47 | try {
48 | const badgesMap = await getUsersBadges(uniqueIds);
49 |
50 | users.forEach((user) => {
51 | if (user.id && badgesMap[user.id]) {
52 | user.badges = badgesMap[user.id];
53 | } else {
54 | user.badges = [];
55 | }
56 | });
57 | } catch (err) {
58 | console.error('[BadgeEnricher] Failed to enrich users:', err);
59 | // 降级处理:设置为空数组
60 | users.forEach((user) => {
61 | if (!user.badges) user.badges = [];
62 | });
63 | }
64 | };
65 |
66 | userEnricher.register('badges', enrichUser);
67 | userEnricher.registerBatch('badges', enrichUsers);
68 | }
69 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/slider.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SliderPrimitive from "@radix-ui/react-slider"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Slider({
9 | className,
10 | defaultValue,
11 | value,
12 | min = 0,
13 | max = 100,
14 | ...props
15 | }) {
16 | const _values = React.useMemo(() =>
17 | Array.isArray(value)
18 | ? value
19 | : Array.isArray(defaultValue)
20 | ? defaultValue
21 | : [min, max], [value, defaultValue, min, max])
22 |
23 | return (
24 |
35 |
40 |
45 |
46 | {Array.from({ length: _values.length }, (_, index) => (
47 |
51 | ))}
52 |
53 | );
54 | }
55 |
56 | export { Slider }
57 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/rateLimit.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import rateLimit from '@fastify/rate-limit';
3 | import { getSetting } from '../utils/settings.js';
4 |
5 | async function rateLimitPlugin(fastify, opts) {
6 | // 先尝试可选认证,以便限速器可以识别用户
7 | fastify.addHook('onRequest', async (request, reply) => {
8 | try {
9 | await request.jwtVerify();
10 | } catch (err) {
11 | // 忽略错误,用户未登录
12 | request.user = null;
13 | }
14 | });
15 |
16 | // 注册 @fastify/rate-limit 插件
17 | await fastify.register(rateLimit, {
18 | global: true,
19 | max: async (request, key) => {
20 | // 检查是否启用限速
21 | const enabled = await getSetting('rate_limit_enabled', true);
22 | if (!enabled) {
23 | return 999999; // 禁用时返回极大值
24 | }
25 |
26 | // 获取基础限制
27 | const maxRequests = await getSetting('rate_limit_max_requests', 100);
28 |
29 | // 如果是已登录用户,应用倍数
30 | if (request.user?.id) {
31 | const multiplier = await getSetting('rate_limit_auth_multiplier', 2);
32 | return Math.floor(maxRequests * multiplier);
33 | }
34 |
35 | return maxRequests;
36 | },
37 | timeWindow: async (request, key) => {
38 | const windowMs = await getSetting('rate_limit_window_ms', 60000);
39 | return windowMs;
40 | },
41 | keyGenerator: (request) => {
42 | // 使用用户 ID 或 IP 作为限速键
43 | return request.user?.id ? `user:${request.user.id}` : `ip:${request.ip}`;
44 | },
45 | errorResponseBuilder: (request, context) => {
46 | return {
47 | error: '请求过于频繁,请稍后再试',
48 | statusCode: 429,
49 | retryAfter: context.after,
50 | resetTime: new Date(Date.now() + context.ttl).toISOString(),
51 | };
52 | },
53 | addHeadersOnExceeding: {
54 | 'x-ratelimit-limit': true,
55 | 'x-ratelimit-remaining': true,
56 | 'x-ratelimit-reset': true,
57 | },
58 | addHeaders: {
59 | 'x-ratelimit-limit': true,
60 | 'x-ratelimit-remaining': true,
61 | 'x-ratelimit-reset': true,
62 | 'retry-after': true,
63 | },
64 | });
65 | }
66 |
67 | export default fp(rateLimitPlugin);
68 |
--------------------------------------------------------------------------------
/apps/api/src/services/userEnricher.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * 允许不同功能向用户对象补充额外数据(如勋章、积分、设置等)的服务。
4 | */
5 | class UserEnricher {
6 | constructor() {
7 | this.enrichers = [];
8 | this.batchEnrichers = [];
9 | }
10 |
11 | /**
12 | * 注册一个新的增强器。
13 | * @param {string} name - 增强器的唯一名称。
14 | * @param {function} callback - 异步函数 (user) => Promise。直接修改用户对象。
15 | */
16 | register(name, callback) {
17 | console.log(`[UserEnricher] Registered: ${name}`);
18 | this.enrichers.push({ name, callback });
19 | }
20 |
21 | /**
22 | * 注册一个新的批量增强器。
23 | * @param {string} name - 增强器的唯一名称。
24 | * @param {function} callback - 异步函数 (users[]) => Promise。直接修改数组中的用户对象。
25 | */
26 | registerBatch(name, callback) {
27 | console.log(`[UserEnricher] Registered Batch: ${name}`);
28 | this.batchEnrichers.push({ name, callback });
29 | }
30 |
31 | /**
32 | * 在用户对象上运行所有注册的增强器。
33 | * @param {object} user - 要增强的用户对象。
34 | * @param {object} context - 可选上下文(例如 request 对象)。
35 | */
36 | async enrich(user, context = {}) {
37 | if (!user) return;
38 |
39 | // 并行运行所有增强器以提高性能
40 | await Promise.all(
41 | this.enrichers.map(async ({ name, callback }) => {
42 | try {
43 | await callback(user, context);
44 | } catch (err) {
45 | console.error(`[UserEnricher] Error in ${name}:`, err);
46 | // 如果一个增强器失败,不要导致整个请求失败
47 | }
48 | })
49 | );
50 |
51 | return user;
52 | }
53 |
54 | /**
55 | * 在用户列表上运行所有注册的批量增强器。
56 | * @param {object[]} users - 要增强的用户对象列表。
57 | * @param {object} context - 可选上下文。
58 | */
59 | async enrichMany(users, context = {}) {
60 | if (!users || users.length === 0) return users;
61 |
62 | // 并行运行所有批量增强器
63 | await Promise.all(
64 | this.batchEnrichers.map(async ({ name, callback }) => {
65 | try {
66 | await callback(users, context);
67 | } catch (err) {
68 | console.error(`[UserEnricher] Error in batch ${name}:`, err);
69 | }
70 | })
71 | );
72 |
73 | return users;
74 | }
75 | }
76 |
77 | export const userEnricher = new UserEnricher();
78 |
--------------------------------------------------------------------------------
/apps/web/src/app/topic/[id]/page.js:
--------------------------------------------------------------------------------
1 | import { notFound } from 'next/navigation';
2 | import TopicPageClient from '@/components/topic/TopicPageClient';
3 | import { getTopicData, getPostsData, getTopicRewardData } from '@/lib/server/topics';
4 |
5 | // 生成页面元数据(SEO优化)
6 | export async function generateMetadata({ params }) {
7 | const { id } = await params;
8 | const topic = await getTopicData(id);
9 |
10 | if (!topic) {
11 | return {
12 | title: '话题不存在',
13 | };
14 | }
15 |
16 | // 提取纯文本内容作为描述(去除Markdown标记)
17 | const description =
18 | topic.content?.replace(/[#*`\[\]]/g, '').substring(0, 160) || '';
19 |
20 | return {
21 | title: `${topic.title} - 话题详情`,
22 | description,
23 | openGraph: {
24 | title: topic.title,
25 | description,
26 | type: 'article',
27 | publishedTime: topic.createdAt,
28 | modifiedTime: topic.updatedAt,
29 | authors: [topic.userName || topic.username],
30 | },
31 | };
32 | }
33 |
34 | // 主页面组件(服务端组件)
35 | export default async function TopicDetailPage({ params, searchParams }) {
36 | const { id } = await params;
37 | const resolvedSearchParams = await searchParams;
38 | const currentPage = parseInt(resolvedSearchParams.p) || 1;
39 | const LIMIT = 20;
40 |
41 | // 优化:先获取话题数据(利用 Next.js 自动去重与 generateMetadata 的请求)
42 | const topic = await getTopicData(id);
43 |
44 | // 话题不存在,立即返回 404,避免浪费 posts 请求
45 | if (!topic) {
46 | notFound();
47 | }
48 |
49 | // 话题存在后,再获取回复数据
50 | const postsData = await getPostsData(id, currentPage, LIMIT);
51 |
52 | const posts = postsData.items || [];
53 | const totalPosts = postsData.total || 0;
54 | const totalPages = Math.ceil(totalPosts / LIMIT);
55 |
56 | // 获取积分系统状态和打赏数据 (服务端封装)
57 | const { isRewardEnabled, initialRewardStats } = await getTopicRewardData(topic, posts);
58 |
59 | return (
60 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/apps/api/src/plugins/cleanup.js:
--------------------------------------------------------------------------------
1 | import fp from 'fastify-plugin';
2 | import db from '../db/index.js';
3 | import { qrLoginRequests } from '../db/schema.js';
4 | import { lt } from 'drizzle-orm';
5 |
6 | /**
7 | * 数据清理插件
8 | * 负责定期清理过期的临时数据
9 | */
10 | export default fp(async function (fastify, opts) {
11 | const tasks = new Map();
12 |
13 | /**
14 | * 注册清理任务
15 | * @param {string} name 任务名称
16 | * @param {Function} taskFn 任务函数,需返回清理的记录数
17 | */
18 | function registerCleanupTask(name, taskFn) {
19 | if (tasks.has(name)) {
20 | fastify.log.warn(`Cleanup task ${name} already registered, overwriting.`);
21 | }
22 | tasks.set(name, taskFn);
23 | fastify.log.debug(`Registered cleanup task: ${name}`);
24 | }
25 |
26 | /**
27 | * 执行所有清理任务
28 | */
29 | async function runAllTasks() {
30 | fastify.log.info(`Starting cleanup tasks (${tasks.size} tasks)...`);
31 | let totalCleaned = 0;
32 |
33 | for (const [name, taskFn] of tasks) {
34 | try {
35 | const count = await taskFn();
36 | if (count > 0) {
37 | fastify.log.info(`Task [${name}] cleaned ${count} items.`);
38 | totalCleaned += count;
39 | }
40 | } catch (err) {
41 | fastify.log.error(`Error in cleanup task [${name}]:`, err);
42 | }
43 | }
44 |
45 | return totalCleaned;
46 | }
47 |
48 | // 1. 注册核心 API (使用命名空间)
49 | fastify.decorate('cleanup', {
50 | registerTask: registerCleanupTask,
51 | run: runAllTasks
52 | });
53 |
54 | // 2. 注册默认的 QR 清理任务
55 | registerCleanupTask('qr-login-requests', async () => {
56 | try {
57 | const result = await db
58 | .delete(qrLoginRequests)
59 | .where(lt(qrLoginRequests.expiresAt, new Date()));
60 | return result.rowCount;
61 | } catch (err) {
62 | throw err; // 让运行器捕获错误
63 | }
64 | });
65 |
66 |
67 |
68 | // 启动定时任务 (每5分钟)
69 | const interval = setInterval(runAllTasks, 5 * 60 * 1000);
70 |
71 | // 关闭时清除定时器
72 | fastify.addHook('onClose', async () => {
73 | clearInterval(interval);
74 | });
75 |
76 | fastify.log.info('Cleanup plugin registered with task runner');
77 | }, {
78 | name: 'cleanup-plugin'
79 | });
80 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/components/admin/LedgerOverview.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { CurrencyStats } from './CurrencyStats';
5 | import { ledgerApi } from '../../api';
6 | import { toast } from 'sonner';
7 |
8 | export function LedgerOverview() {
9 | const [stats, setStats] = useState([]);
10 |
11 | const [loading, setLoading] = useState(true);
12 |
13 | const fetchData = async () => {
14 | try {
15 | const statsData = await ledgerApi.getStats();
16 | setStats(Array.isArray(statsData) ? statsData : (statsData.items || [statsData]));
17 | } catch (error) {
18 | console.error(error);
19 | toast.error('获取概览数据失败');
20 | } finally {
21 | setLoading(false);
22 | }
23 | };
24 |
25 | useEffect(() => {
26 | fetchData();
27 | }, []);
28 |
29 | return (
30 |
31 |
32 | {stats.map(stat => {
33 | const currency = stat.info;
34 | if (!currency) return null;
35 |
36 | return (
37 |
38 |
39 | {currency.name} ({currency.code})
40 |
41 | {currency.isActive ? '已激活' : '未激活'}
42 |
43 |
44 |
49 |
50 |
51 | );
52 | })}
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/button.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
15 | outline:
16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost:
20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 | icon: "size-9",
28 | "icon-sm": "size-8",
29 | "icon-lg": "size-10",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | }
37 | )
38 |
39 | function Button({
40 | className,
41 | variant,
42 | size,
43 | asChild = false,
44 | ...props
45 | }) {
46 | const Comp = asChild ? Slot : "button"
47 |
48 | return (
49 |
53 | );
54 | }
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/docker/docker-compose.lowmem.yml:
--------------------------------------------------------------------------------
1 | # 低内存环境 Docker Compose 覆盖配置
2 | # 适用于 1C1G 或 1C2G 的低配服务器
3 | # 使用方式: docker compose -f docker-compose.yml -f docker-compose.lowmem.yml up -d
4 |
5 | services:
6 | # PostgreSQL - 低内存优化
7 | postgres:
8 | restart: always
9 | ports: []
10 | deploy:
11 | resources:
12 | limits:
13 | cpus: '0.5'
14 | memory: 256M
15 | reservations:
16 | cpus: '0.1'
17 | memory: 128M
18 | logging:
19 | driver: "json-file"
20 | options:
21 | max-size: "5m"
22 | max-file: "2"
23 |
24 | # Redis - 低内存优化
25 | redis:
26 | restart: always
27 | ports: []
28 | command: >
29 | redis-server
30 | --requirepass ${REDIS_PASSWORD:-redis_password}
31 | --appendonly yes
32 | --appendfsync everysec
33 | --maxmemory 128mb
34 | --maxmemory-policy allkeys-lru
35 | --save 900 1
36 | --save 300 10
37 | deploy:
38 | resources:
39 | limits:
40 | cpus: '0.3'
41 | memory: 128M
42 | reservations:
43 | cpus: '0.1'
44 | memory: 64M
45 | logging:
46 | driver: "json-file"
47 | options:
48 | max-size: "5m"
49 | max-file: "2"
50 |
51 | # API - 低内存优化
52 | api:
53 | restart: always
54 | environment:
55 | # 严格限制 Node.js 内存
56 | NODE_OPTIONS: "--max-old-space-size=384"
57 | volumes:
58 | - api_uploads:/app/apps/api/uploads
59 | deploy:
60 | resources:
61 | limits:
62 | cpus: '0.7'
63 | memory: 512M
64 | reservations:
65 | cpus: '0.2'
66 | memory: 256M
67 | logging:
68 | driver: "json-file"
69 | options:
70 | max-size: "10m"
71 | max-file: "3"
72 |
73 | # Web - 低内存优化
74 | web:
75 | restart: always
76 | environment:
77 | # 严格限制 Node.js 内存
78 | NODE_OPTIONS: "--max-old-space-size=384"
79 | deploy:
80 | resources:
81 | limits:
82 | cpus: '0.5'
83 | memory: 512M
84 | reservations:
85 | cpus: '0.2'
86 | memory: 256M
87 | logging:
88 | driver: "json-file"
89 | options:
90 | max-size: "10m"
91 | max-file: "3"
92 |
--------------------------------------------------------------------------------
/apps/web/src/components/user/FollowButton.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { Button } from '@/components/ui/button';
5 | import { userApi } from '@/lib/api';
6 | import { toast } from 'sonner';
7 | import { Loader2, UserPlus, UserMinus } from 'lucide-react';
8 | import { useAuth } from '@/contexts/AuthContext';
9 |
10 | export default function FollowButton({ username, initialIsFollowing = false, onFollowChange }) {
11 | const { user, isAuthenticated, openLoginDialog } = useAuth();
12 | const [isFollowing, setIsFollowing] = useState(initialIsFollowing);
13 | const [loading, setLoading] = useState(false);
14 |
15 | // 当initialIsFollowing变化时更新状态
16 | useEffect(() => {
17 | setIsFollowing(initialIsFollowing);
18 | }, [initialIsFollowing]);
19 |
20 | const handleToggleFollow = async () => {
21 | if (!isAuthenticated) {
22 | openLoginDialog();
23 | return;
24 | }
25 |
26 | setLoading(true);
27 |
28 | try {
29 | if (isFollowing) {
30 | await userApi.unfollowUser(username);
31 | setIsFollowing(false);
32 | toast.success('已取消关注');
33 | onFollowChange?.(false);
34 | } else {
35 | await userApi.followUser(username);
36 | setIsFollowing(true);
37 | toast.success('关注成功');
38 | onFollowChange?.(true);
39 | }
40 | } catch (error) {
41 | console.error('关注操作失败:', error);
42 | toast.error(error.message || '操作失败');
43 | } finally {
44 | setLoading(false);
45 | }
46 | };
47 |
48 | // 不显示关注自己的按钮
49 | if (user && user.username === username) {
50 | return null;
51 | }
52 |
53 | if (!isAuthenticated) {
54 | return null;
55 | }
56 |
57 | return (
58 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/StickySidebar.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState, useEffect } from 'react';
4 |
5 | import {
6 | Drawer,
7 | DrawerClose,
8 | DrawerContent,
9 | DrawerDescription,
10 | DrawerFooter,
11 | DrawerHeader,
12 | DrawerTitle,
13 | DrawerTrigger,
14 | } from '@/components/ui/drawer';
15 | import { Button } from '../ui/button';
16 | import { cn } from '@/lib/utils';
17 | import { ChevronRight, X } from 'lucide-react';
18 |
19 | export default function StickySidebar({ children, className, enabled = true }) {
20 | const [mounted, setMounted] = useState(false);
21 | const [open, setOpen] = useState(false);
22 | const [isDesktop, setIsDesktop] = useState(true);
23 |
24 | useEffect(() => {
25 | setMounted(true);
26 |
27 | // 客户端检查屏幕尺寸
28 | const checkDesktop = () => {
29 | setIsDesktop(window.innerWidth >= 768);
30 | };
31 |
32 | checkDesktop();
33 | window.addEventListener('resize', checkDesktop);
34 |
35 | return () => window.removeEventListener('resize', checkDesktop);
36 | }, []);
37 |
38 | // 在服务器端和客户端首次渲染时,始终渲染为桌面版本
39 | if (!mounted) {
40 | return ;
41 | }
42 |
43 | if (isDesktop || !enabled) {
44 | return ;
45 | }
46 |
47 | return (
48 |
49 |
50 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | {/* 移动端覆盖样式 */}
63 | {
66 | const link = e.target.closest('a');
67 | if (link) {
68 | setOpen(false);
69 | }
70 | }}
71 | >
72 | {children}
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/apps/api/src/routes/dashboard/index.js:
--------------------------------------------------------------------------------
1 | import db from '../../db/index.js';
2 | import { users, categories, topics, posts } from '../../db/schema.js';
3 | import { sql, eq, and, ne } from 'drizzle-orm';
4 |
5 | export default async function dashboardRoutes(fastify, options) {
6 | // 获取统计数据(仅管理员可访问)
7 | fastify.get('/stats', {
8 | preHandler: [fastify.requireAdmin],
9 | schema: {
10 | tags: ['dashboard'],
11 | description: '获取管理后台统计数据(仅管理员)',
12 | security: [{ bearerAuth: [] }],
13 | response: {
14 | 200: {
15 | type: 'object',
16 | properties: {
17 | totalUsers: { type: 'number' },
18 | totalCategories: { type: 'number' },
19 | totalTopics: { type: 'number' },
20 | totalPosts: { type: 'number' }
21 | }
22 | }
23 | }
24 | }
25 | }, async (request, reply) => {
26 | try {
27 | // 并行查询所有统计数据
28 | const [
29 | usersCount,
30 | categoriesCount,
31 | topicsCount,
32 | postsCount
33 | ] = await Promise.all([
34 | // 总用户数(排除已删除用户)
35 | db.select({ count: sql`count(*)` })
36 | .from(users)
37 | .where(eq(users.isDeleted, false))
38 | .then(result => result[0]),
39 |
40 | // 分类数量
41 | db.select({ count: sql`count(*)` })
42 | .from(categories)
43 | .then(result => result[0]),
44 |
45 | // 话题数量(排除已删除话题)
46 | db.select({ count: sql`count(*)` })
47 | .from(topics)
48 | .where(eq(topics.isDeleted, false))
49 | .then(result => result[0]),
50 |
51 | // 回复数量(排除第一条回复,因为第一条回复是话题内容本身)
52 | db.select({ count: sql`count(*)` })
53 | .from(posts)
54 | .where(and(
55 | eq(posts.isDeleted, false),
56 | ne(posts.postNumber, 1)
57 | ))
58 | .then(result => result[0])
59 | ]);
60 |
61 | return {
62 | totalUsers: Number(usersCount.count),
63 | totalCategories: Number(categoriesCount.count),
64 | totalTopics: Number(topicsCount.count),
65 | totalPosts: Number(postsCount.count)
66 | };
67 | } catch (error) {
68 | fastify.log.error('获取统计数据失败:', error);
69 | return reply.code(500).send({ error: '获取统计数据失败' });
70 | }
71 | });
72 | }
--------------------------------------------------------------------------------
/apps/web/src/app/(home)/categories/[id]/page.js:
--------------------------------------------------------------------------------
1 | import { notFound } from 'next/navigation';
2 | import { getCategoryBySlug, getTopicsData } from '@/lib/server/topics';
3 | import { TopicListClient } from '@/components/topic/TopicList';
4 |
5 | // 生成页面元数据
6 | export async function generateMetadata({ params }) {
7 | const { id: slug } = await params;
8 | const category = await getCategoryBySlug(slug);
9 |
10 | if (!category) {
11 | return {
12 | title: '分类不存在',
13 | };
14 | }
15 |
16 | return {
17 | title: `${category.name}`,
18 | description: category.description || `浏览${category.name}分类下的所有话题`,
19 | openGraph: {
20 | title: category.name,
21 | description: category.description || `浏览${category.name}分类下的所有话题`,
22 | type: 'website',
23 | },
24 | };
25 | }
26 |
27 | export default async function CategoryPage({ params, searchParams }) {
28 | const { id: slug } = await params;
29 | const resolvedParams = await searchParams;
30 | const page = parseInt(resolvedParams.p) || 1;
31 | const sort = resolvedParams.sort || 'latest';
32 | const LIMIT = 20;
33 |
34 | // 服务端获取分类信息
35 | const category = await getCategoryBySlug(slug);
36 |
37 | // 分类不存在
38 | if (!category) {
39 | notFound();
40 | }
41 |
42 | // 服务端获取话题数据
43 | const data = await getTopicsData({
44 | page,
45 | sort,
46 | categoryId: category.id,
47 | limit: LIMIT,
48 | });
49 |
50 | const totalPages = Math.ceil(data.total / LIMIT);
51 |
52 | return (
53 | <>
54 | {/* 分类标题 */}
55 |
56 |
57 |
61 |
{category.name}
62 |
63 | {category.description && (
64 |
65 | {category.description}
66 |
67 | )}
68 |
69 |
70 | {/* 话题列表 */}
71 |
80 | >
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/apps/web/src/extensions/ledger/components/user/BalanceOverview.jsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2 | import { Coins, TrendingUp, TrendingDown } from 'lucide-react';
3 | import { formatCredits } from '../../utils/formatters';
4 |
5 | /**
6 | * Detailed balance overview with earned/spent stats
7 | * @param {Object} props
8 | * @param {Object} props.balance - Balance object with balance, totalEarned, totalSpent
9 | */
10 | export function BalanceOverview({ balance }) {
11 | if (!balance) return null;
12 |
13 | return (
14 |
15 | {/* Current Balance */}
16 |
17 |
18 | 当前余额
19 |
20 |
21 |
22 | {formatCredits(balance.balance || 0)}
23 |
24 | 可用于打赏和商城消费
25 |
26 |
27 |
28 |
29 | {/* Total Earned */}
30 |
31 |
32 | 累计获得
33 |
34 |
35 |
36 | {formatCredits(balance.totalEarned || 0)}
37 |
38 | 通过各种活动获得
39 |
40 |
41 |
42 |
43 | {/* Total Spent */}
44 |
45 |
46 | 累计消费
47 |
48 |
49 |
50 | {formatCredits(balance.totalSpent || 0)}
51 |
52 | 用于打赏和购物
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/apps/api/src/utils/settings.js:
--------------------------------------------------------------------------------
1 | import db from '../db/index.js';
2 | import { systemSettings } from '../db/schema.js';
3 | import { eq } from 'drizzle-orm';
4 |
5 | // 缓存系统配置 - 使用 Map 存储每个 key 的独立缓存
6 | // Map 结构: key -> { value: any, timestamp: number }
7 | const settingsCache = new Map();
8 | const CACHE_TTL = 60000; // 1分钟缓存
9 |
10 | /**
11 | * 获取系统配置值
12 | * @param {string} key - 配置键名
13 | * @param {any} defaultValue - 默认值
14 | * @returns {Promise} 配置值
15 | */
16 | export async function getSetting(key, defaultValue = null) {
17 | // 检查缓存
18 | const now = Date.now();
19 | const cached = settingsCache.get(key);
20 |
21 | if (cached && now - cached.timestamp < CACHE_TTL) {
22 | return cached.value;
23 | }
24 |
25 | try {
26 | const [setting] = await db
27 | .select()
28 | .from(systemSettings)
29 | .where(eq(systemSettings.key, key))
30 | .limit(1);
31 |
32 | if (!setting) {
33 | return defaultValue;
34 | }
35 |
36 | let value = setting.value;
37 | if (setting.valueType === 'boolean') {
38 | value = setting.value === 'true';
39 | } else if (setting.valueType === 'number') {
40 | value = parseFloat(setting.value);
41 | }
42 |
43 | // 更新缓存 - 每个 key 有独立的时间戳
44 | settingsCache.set(key, { value, timestamp: now });
45 |
46 | return value;
47 | } catch (error) {
48 | console.error('Error fetching setting:', error);
49 | return defaultValue;
50 | }
51 | }
52 |
53 | /**
54 | * 清除配置缓存
55 | */
56 | export function clearSettingsCache() {
57 | settingsCache.clear();
58 | }
59 |
60 | /**
61 | * 获取系统配置值(getSetting 的别名)
62 | */
63 | export const getSettingValue = getSetting;
64 |
65 | /**
66 | * 获取所有系统配置
67 | * @returns {Promise