2025.11.27.22.40

This commit is contained in:
RUI
2025-11-27 22:43:24 +08:00
parent 5dbb30b32c
commit 0d73d0c63b
20 changed files with 1154 additions and 226 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env*
! .env.example

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
# MONGODB数据库
MONGODB_URI
# JWT配置用于生成token
JWT_SECRET=

3
.gitignore vendored
View File

@@ -31,7 +31,8 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* # .env*
.env.local
# vercel # vercel
.vercel .vercel

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# 🚀 优化版 Dockerfile for Next.js with pnpm (目标: <200MB)
# 第一阶段:安装依赖
FROM node:22-alpine AS deps
WORKDIR /app
# 查看 https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine 以了解为什么可能需要 libc6-compat
RUN apk add --no-cache libc6-compat
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm i --frozen-lockfile
# 第二阶段:构建应用
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 在构建期间禁用遥测
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm install -g pnpm && pnpm run build
# 第三阶段:生产镜像,复制所有文件并运行 next
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 在运行时禁用遥测
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 复制 public 文件夹
COPY --from=builder /app/public ./public
# 自动利用输出追踪来减小镜像大小
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# 设置主机名为 0.0.0.0 以便外部访问
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]PORT=3000 \
HOSTNAME="0.0.0.0" \
NODE_ENV=production
# 启动应用 (standalone模式直接运行server.js)
CMD ["node", "server.js"]

View File

@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: 'standalone',
reactStrictMode: true, reactStrictMode: true,
images: { images: {
remotePatterns: [ remotePatterns: [
@@ -13,6 +14,10 @@ const nextConfig: NextConfig = {
protocol: 'https', protocol: 'https',
hostname: 'api.dicebear.com', hostname: 'api.dicebear.com',
}, },
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
], ],
}, },
}; };

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -43,12 +44,14 @@
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.66.1", "react-hook-form": "^7.66.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^3.5.0",
"rehype-pretty-code": "^0.14.1", "rehype-pretty-code": "^0.14.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"sharp": "^0.34.5",
"shiki": "^3.15.0", "shiki": "^3.15.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",

339
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@radix-ui/react-avatar': '@radix-ui/react-avatar':
specifier: ^1.1.11 specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.15 specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -110,6 +113,9 @@ importers:
react-markdown: react-markdown:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.7)(react@19.2.0) version: 10.1.0(@types/react@19.2.7)(react@19.2.0)
recharts:
specifier: ^3.5.0
version: 3.5.0(@types/react@19.2.7)(eslint@9.39.1(jiti@2.6.1))(react-dom@19.2.0(react@19.2.0))(react-is@16.13.1)(react@19.2.0)(redux@5.0.1)
rehype-pretty-code: rehype-pretty-code:
specifier: ^0.14.1 specifier: ^0.14.1
version: 0.14.1(shiki@3.15.0) version: 0.14.1(shiki@3.15.0)
@@ -128,6 +134,9 @@ importers:
remark-rehype: remark-rehype:
specifier: ^11.1.2 specifier: ^11.1.2
version: 11.1.2 version: 11.1.2
sharp:
specifier: ^0.34.5
version: 0.34.5
shiki: shiki:
specifier: ^3.15.0 specifier: ^3.15.0
version: 3.15.0 version: 3.15.0
@@ -596,6 +605,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7': '@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies: peerDependencies:
@@ -986,6 +1008,17 @@ packages:
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@reduxjs/toolkit@2.11.0':
resolution: {integrity: sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@remirror/core-constants@3.0.0': '@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@@ -1013,6 +1046,9 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -1274,6 +1310,33 @@ packages:
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -1726,6 +1789,50 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8: damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -1761,6 +1868,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@@ -1867,6 +1977,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.42.0:
resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1947,6 +2060,12 @@ packages:
peerDependencies: peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
eslint-plugin-react-perf@3.3.3:
resolution: {integrity: sha512-EzPdxsRJg5IllCAH9ny/3nK7sv9251tvKmi/d3Ouv5KzI8TB3zNhzScxL9wnh9Hvv8GYC5LEtzTauynfOEYiAw==}
engines: {node: '>=6.9.1'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
eslint-plugin-react@7.37.5: eslint-plugin-react@7.37.5:
resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1998,6 +2117,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
eventsource-parser@3.0.6: eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -2221,6 +2343,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.0.0:
resolution: {integrity: sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2236,6 +2364,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-alphabetical@2.0.1: is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -3028,6 +3160,18 @@ packages:
'@types/react': '>=18' '@types/react': '>=18'
react: '>=18' react: '>=18'
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-remove-scroll-bar@2.3.8: react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3062,6 +3206,22 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
recharts@3.5.0:
resolution: {integrity: sha512-jWqBtu8L3VICXWa3g/y+bKjL8DDHSRme7DHD/70LQ/Tk0di1h11Y0kKC0nPh6YJ2oaa0k6anIFNhg6SfzHWdEA==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3143,6 +3303,9 @@ packages:
remark-stringify@11.0.0: remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3336,6 +3499,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -3483,6 +3649,9 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
w3c-keyname@2.2.8: w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -3742,8 +3911,7 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.0.0': '@img/colour@1.0.0': {}
optional: true
'@img/sharp-darwin-arm64@0.34.5': '@img/sharp-darwin-arm64@0.34.5':
optionalDependencies: optionalDependencies:
@@ -3939,6 +4107,22 @@ snapshots:
'@types/react': 19.2.7 '@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7) '@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0)
@@ -4326,6 +4510,18 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.1': {}
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1))(react@19.2.0)':
dependencies:
'@standard-schema/spec': 1.0.0
'@standard-schema/utils': 0.3.0
immer: 11.0.0
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.0
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1)
'@remirror/core-constants@3.0.0': {} '@remirror/core-constants@3.0.0': {}
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
@@ -4363,6 +4559,8 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
@@ -4631,6 +4829,30 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/debug@4.1.12': '@types/debug@4.1.12':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
@@ -5107,6 +5329,44 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {} damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2: data-view-buffer@1.0.2:
@@ -5137,6 +5397,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@@ -5307,6 +5569,8 @@ snapshots:
is-date-object: 1.1.0 is-date-object: 1.1.0
is-symbol: 1.1.1 is-symbol: 1.1.1
es-toolkit@1.42.0: {}
escalade@3.2.0: {} escalade@3.2.0: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
@@ -5426,6 +5690,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-react-perf@3.3.3(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)):
dependencies: dependencies:
array-includes: 3.1.9 array-includes: 3.1.9
@@ -5518,6 +5786,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.1: {}
eventsource-parser@3.0.6: {} eventsource-parser@3.0.6: {}
extend@3.0.2: {} extend@3.0.2: {}
@@ -5824,6 +6094,10 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.0.0: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -5839,6 +6113,8 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 side-channel: 1.1.0
internmap@2.0.3: {}
is-alphabetical@2.0.1: {} is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1: is-alphanumerical@2.0.1:
@@ -6887,6 +7163,15 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.0
use-sync-external-store: 1.6.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.7
redux: 5.0.1
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.0): react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.0):
dependencies: dependencies:
react: 19.2.0 react: 19.2.0
@@ -6916,6 +7201,34 @@ snapshots:
react@19.2.0: {} react@19.2.0: {}
recharts@3.5.0(@types/react@19.2.7)(eslint@9.39.1(jiti@2.6.1))(react-dom@19.2.0(react@19.2.0))(react-is@16.13.1)(react@19.2.0)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.0(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1))(react@19.2.0)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.42.0
eslint-plugin-react-perf: 3.3.3(eslint@9.39.1(jiti@2.6.1))
eventemitter3: 5.0.1
immer: 10.2.0
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-is: 16.13.1
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.0)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.0)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- eslint
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@@ -7083,6 +7396,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2 mdast-util-to-markdown: 2.1.2
unified: 11.0.5 unified: 11.0.5
reselect@5.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
@@ -7186,7 +7501,6 @@ snapshots:
'@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5 '@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
@@ -7341,6 +7655,8 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -7549,6 +7865,23 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.0.3 vfile-message: 4.0.3
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
w3c-keyname@2.2.8: {} w3c-keyname@2.2.8: {}
web-namespaces@2.0.1: {} web-namespaces@2.0.1: {}

View File

@@ -12,8 +12,8 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, FileText, DollarSign, Settings } from 'lucide-react'; import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, FileText, DollarSign, Settings, Plus, Trash2 } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller, useFieldArray } from 'react-hook-form';
import TiptapEditor from './TiptapEditor'; import TiptapEditor from './TiptapEditor';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -57,12 +57,20 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
: '', : '',
: '', : '',
: '', : '',
: '' : '',
: '',
: '',
: [] as { 属性名: string; 属性值: string }[]
}, },
: 'published' : 'published'
} }
}); });
const { fields: extendedFields, append: appendExtended, remove: removeExtended } = useFieldArray({
control,
name: "资源属性.扩展属性"
});
useEffect(() => { useEffect(() => {
fetchDependencies(); fetchDependencies();
if (mode === 'edit' && articleId) { if (mode === 'edit' && articleId) {
@@ -296,6 +304,16 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
<Lock className="w-4 h-4" /> <Lock className="w-4 h-4" />
</h3> </h3>
<div className="p-4 bg-gray-50 rounded-lg space-y-4 border border-gray-100"> <div className="p-4 bg-gray-50 rounded-lg space-y-4 border border-gray-100">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.版本号')} className="h-8 text-sm bg-white" placeholder="v1.0.0" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.文件大小')} className="h-8 text-sm bg-white" placeholder="100MB" />
</div>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label> <Label className="text-xs text-muted-foreground"></Label>
<Input {...register('资源属性.下载链接')} className="h-8 text-sm bg-white" /> <Input {...register('资源属性.下载链接')} className="h-8 text-sm bg-white" />
@@ -310,6 +328,29 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
<Input {...register('资源属性.解压密码')} className="h-8 text-sm bg-white" /> <Input {...register('资源属性.解压密码')} className="h-8 text-sm bg-white" />
</div> </div>
</div> </div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> ()</Label>
<Textarea {...register('资源属性.隐藏内容')} className="text-sm bg-white min-h-[60px]" placeholder="仅付费用户可见的内容..." />
</div>
{/* Extended Attributes */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground flex justify-between items-center">
<span></span>
<Button type="button" variant="ghost" size="sm" className="h-5 px-2 text-xs" onClick={() => appendExtended({ : '', : '' })}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</Label>
{extendedFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-center">
<Input {...register(`资源属性.扩展属性.${index}.属性名` as const)} className="h-7 text-xs bg-white flex-1" placeholder="属性名" />
<Input {...register(`资源属性.扩展属性.${index}.属性值` as const)} className="h-7 text-xs bg-white flex-1" placeholder="属性值" />
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => removeExtended(index)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
</div> </div>
</div> </div>
@@ -334,6 +375,7 @@ export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
<SelectContent> <SelectContent>
<SelectItem value="points"></SelectItem> <SelectItem value="points"></SelectItem>
<SelectItem value="cash"> (CNY)</SelectItem> <SelectItem value="cash"> (CNY)</SelectItem>
<SelectItem value="membership_free"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -62,10 +63,14 @@ export default function HeroBanner({ banners }: HeroBannerProps) {
<CarouselItem key={index}> <CarouselItem key={index}>
<div className="relative h-[500px] w-full overflow-hidden"> <div className="relative h-[500px] w-full overflow-hidden">
{/* Background Image */} {/* Background Image */}
<div <div className="absolute inset-0">
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 hover:scale-105" <Image
style={{ backgroundImage: `url(${banner.})` }} src={banner.}
> alt={banner.}
fill
className="object-cover transition-transform duration-700 hover:scale-105"
priority={index === 0}
/>
<div className="absolute inset-0 bg-black/40" /> {/* Overlay */} <div className="absolute inset-0 bg-black/40" /> {/* Overlay */}
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent" /> {/* Gradient Overlay */} <div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent" /> {/* Gradient Overlay */}
</div> </div>

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -2,9 +2,6 @@ import jwt from 'jsonwebtoken';
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('Please define the JWT_SECRET environment variable inside .env.local');
}
export interface DecodedToken { export interface DecodedToken {
userId: string; userId: string;
@@ -15,8 +12,12 @@ export interface DecodedToken {
} }
export function verifyToken(token: string): DecodedToken | null { export function verifyToken(token: string): DecodedToken | null {
if (!JWT_SECRET) {
console.error('JWT_SECRET is not defined');
return null;
}
try { try {
return jwt.verify(token, JWT_SECRET as string) as unknown as DecodedToken; return jwt.verify(token, JWT_SECRET) as unknown as DecodedToken;
} catch (error) { } catch (error) {
return null; return null;
} }

View File

@@ -43,6 +43,10 @@ const UserSchema = new Schema({
* ================================================================== * ==================================================================
* 2. Article (文章/资源模型) * 2. Article (文章/资源模型)
* ================================================================== * ==================================================================
* 核心更新:
* - 支付方式增加了 'membership_free' (会员免费)。
* - 资源属性增加了 版本、大小、扩展属性。
* - 统计数据增加了 销量、最近售出时间。
*/ */
const ArticleSchema = new Schema({ const ArticleSchema = new Schema({
// --- 基础信息 --- // --- 基础信息 ---
@@ -52,7 +56,7 @@ const ArticleSchema = new Schema({
// --- 内容部分 --- // --- 内容部分 ---
: { type: String }, : { type: String },
: { type: String, required: true }, : { type: String, required: true }, // 公开预览内容 或 包含截断标识的完整内容
// --- SEO 专用优化 --- // --- SEO 专用优化 ---
SEO关键词: { type: [String], default: [] }, SEO关键词: { type: [String], default: [] },
@@ -65,14 +69,28 @@ const ArticleSchema = new Schema({
// --- 售卖策略 --- // --- 售卖策略 ---
: { type: Number, default: 0 }, : { type: Number, default: 0 },
: { type: String, enum: ['points', 'cash'], default: 'points' }, // 【升级】新增 membership_free会员免费普通用户需购买
: { type: String, enum: ['points', 'cash', 'membership_free'], default: 'points' },
// --- 资源交付 (付费后可见) --- // --- 资源交付 (付费后可见) ---
: { : {
: { type: String }, : { type: String },
: { type: String }, : { type: String },
: { type: String }, : { type: String },
: { type: String }
// 【升级】标准资源字段
: { type: String }, // e.g., "v2.0.1"
: { type: String }, // e.g., "1.5GB"
// 【升级】扩展属性:支持自定义键值对,如 [{ 属性名: "运行环境", 属性值: "Win11" }]
: [{
: { type: String },
: { type: String }
}],
// 【升级】内容隐藏逻辑
// 直接存在这里,和正文完全分离(推荐,更安全)
: { type: String },
}, },
// --- 数据统计 --- // --- 数据统计 ---
@@ -81,7 +99,10 @@ const ArticleSchema = new Schema({
: { type: Number, default: 0 }, : { type: Number, default: 0 },
: { type: Number, default: 0 }, : { type: Number, default: 0 },
: { type: Number, default: 0 }, : { type: Number, default: 0 },
: { type: Number, default: 0 } : { type: Number, default: 0 },
// 【升级】销售统计
: { type: Number, default: 0 },
: { type: Date }
}, },
// --- 状态控制 --- // --- 状态控制 ---
@@ -91,6 +112,7 @@ const ArticleSchema = new Schema({
// 复合索引优化 // 复合索引优化
ArticleSchema.index({ createdAt: -1 }); ArticleSchema.index({ createdAt: -1 });
ArticleSchema.index({ 分类ID: 1, 发布状态: 1 }); ArticleSchema.index({ 分类ID: 1, 发布状态: 1 });
ArticleSchema.index({ '统计数据.销量': -1 }); // 新增:支持按销量排行
/** /**

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Users, FileText, ShoppingBag, DollarSign } from 'lucide-react'; import { Users, FileText, ShoppingBag, DollarSign } from 'lucide-react';
import { verifyToken } from '@/lib/auth'; import { verifyToken } from '@/lib/auth';
import { User, Article, Order } from '@/models'; import { User, Article, Order } from '@/models';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
// 定义统计数据接口 // 定义统计数据接口
interface DashboardStats { interface DashboardStats {
@@ -15,9 +16,26 @@ interface DashboardStats {
} }
export default function AdminDashboard({ stats }: { stats: DashboardStats }) { export default function AdminDashboard({ stats }: { stats: DashboardStats }) {
// 模拟图表数据 (实际应从 API 获取)
const data = [
{ name: 'Jan', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Feb', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Mar', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'May', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Nov', total: Math.floor(Math.random() * 5000) + 1000 },
{ name: 'Dec', total: Math.floor(Math.random() * 5000) + 1000 },
];
return ( return (
<AdminLayout> <AdminLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* ... (existing header and stats cards) ... */}
<div> <div>
<h2 className="text-3xl font-bold tracking-tight"></h2> <h2 className="text-3xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -82,9 +100,25 @@ export default function AdminDashboard({ stats }: { stats: DashboardStats }) {
<CardTitle></CardTitle> <CardTitle></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pl-2"> <CardContent className="pl-2">
<div className="h-[200px] flex items-center justify-center text-muted-foreground"> <ResponsiveContainer width="100%" height={350}>
(Recharts) <BarChart data={data}>
</div> <XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `¥${value}`}
/>
<Bar dataKey="total" fill="#000000" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-3"> <Card className="col-span-3">

View File

@@ -67,7 +67,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
} }
// If article is free, everyone has access // If article is free (price 0), everyone has access
if (article. === 0) { if (article. === 0) {
hasAccess = true; hasAccess = true;
} }
@@ -75,12 +75,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// If not yet accessible and user is logged in, check purchase/membership // If not yet accessible and user is logged in, check purchase/membership
if (!hasAccess && userId) { if (!hasAccess && userId) {
const user = await User.findById(userId); const user = await User.findById(userId);
const isMember = user?.?. && new Date(user..) > new Date();
// Check Membership // Check Membership
if (user?.?. && new Date(user..) > new Date()) { // If payment method is 'membership_free', members get access for free
if (isMember && article. === 'membership_free') {
hasAccess = true; hasAccess = true;
} }
// If user is a member, they might have general download privileges (depending on your business logic)
// For now, let's assume 'membership_free' explicitly grants access to members.
// If you have a global "members can download everything" policy, you can add `if (isMember) hasAccess = true;` here.
// Check if purchased // Check if purchased
if (!hasAccess) { if (!hasAccess) {
const order = await Order.findOne({ const order = await Order.findOne({
@@ -104,7 +110,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!hasAccess) { if (!hasAccess) {
// Hide sensitive content // Hide sensitive content
delete articleData.; if (articleData.) {
// Keep public attributes visible
const publicAttributes = {
版本号: articleData.资源属性.版本号,
文件大小: articleData.资源属性.文件大小,
扩展属性: articleData.资源属性.扩展属性
};
articleData. = publicAttributes;
}
// Optional: Truncate content for preview // Optional: Truncate content for preview
articleData. = await processMarkdown(article..substring(0, 300) + '...'); articleData. = await processMarkdown(article..substring(0, 300) + '...');
} }

View File

@@ -32,6 +32,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ message: '邮箱或密码错误' }); return res.status(401).json({ message: '邮箱或密码错误' });
} }
// 检查用户是否被封禁
if (user.) {
return res.status(403).json({ message: '该账号已被封禁,请联系管理员' });
}
// 生成 JWT // 生成 JWT
const token = jwt.sign( const token = jwt.sign(
{ userId: user._id, email: user.邮箱, role: user.角色 }, { userId: user._id, email: user.邮箱, role: user.角色 },

View File

@@ -1,6 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import dbConnect from '@/lib/dbConnect'; import dbConnect from '@/lib/dbConnect';
import { Order, User, MembershipPlan } from '@/models'; import { Order, User, MembershipPlan, Article } from '@/models';
import { alipayService } from '@/lib/alipay'; import { alipayService } from '@/lib/alipay';
export const config = { export const config = {
@@ -71,8 +71,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
order. = 'alipay'; order. = 'alipay';
await order.save(); await order.save();
// Update User Membership // Handle different order types
if (order. === 'buy_membership') { if (order. === 'buy_membership') {
// Update User Membership
const plan = await MembershipPlan.findById(order.ID); const plan = await MembershipPlan.findById(order.ID);
if (plan) { if (plan) {
const user = await User.findById(order.ID); const user = await User.findById(order.ID);
@@ -89,6 +90,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await user.save(); await user.save();
} }
} }
} else if (order. === 'buy_resource') {
// Update Article Sales Stats
const article = await Article.findById(order.ID);
if (article) {
article.. = (article.. || 0) + 1;
article.. = new Date();
await article.save();
}
} }
console.log(`Order ${outTradeNo} marked as paid`); console.log(`Order ${outTradeNo} marked as paid`);

View File

@@ -1,12 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Image from 'next/image';
import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import MainLayout from '@/components/layouts/MainLayout'; import MainLayout from '@/components/layouts/MainLayout';
import CommentSection from '@/components/article/CommentSection'; import CommentSection from '@/components/article/CommentSection';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Loader2, Lock, Download, Calendar, User as UserIcon, Tag as TagIcon, Share2, Crown, Sparkles, FileText, Eye, ShoppingCart, Copy, Check, Box, HardDrive } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale'; import { zhCN } from 'date-fns/locale';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -22,22 +26,27 @@ interface Article {
头像: string; 头像: string;
}; };
ID: { ID: {
_id: string;
分类名称: string; 分类名称: string;
}; };
ID列表: { ID列表: {
标签名称: string; 标签名称: string;
}[]; }[];
价格: number; 价格: number;
: 'points' | 'cash'; : 'points' | 'cash' | 'membership_free';
?: { ?: {
下载链接: string; 下载链接: string;
提取码: string; 提取码: string;
解压密码: string; 解压密码: string;
隐藏内容: string; 隐藏内容: string;
版本号?: string;
文件大小?: string;
?: { 属性名: string; 属性值: string }[];
}; };
createdAt: string; createdAt: string;
: { : {
阅读数: number; 阅读数: number;
销量: number;
}; };
} }
@@ -46,13 +55,16 @@ export default function ArticleDetail() {
const { id } = router.query; const { id } = router.query;
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const [article, setArticle] = useState<Article | null>(null); const [article, setArticle] = useState<Article | null>(null);
const [recentArticles, setRecentArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasAccess, setHasAccess] = useState(false); const [hasAccess, setHasAccess] = useState(false);
const [purchasing, setPurchasing] = useState(false); const [purchasing, setPurchasing] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
fetchArticle(); fetchArticle();
fetchRecentArticles();
} }
}, [id]); }, [id]);
@@ -74,6 +86,20 @@ export default function ArticleDetail() {
} }
}; };
const fetchRecentArticles = async () => {
try {
const res = await fetch('/api/articles?limit=5');
if (res.ok) {
const data = await res.json();
// Filter out current article if present
const filtered = data.articles.filter((a: Article) => a._id !== id);
setRecentArticles(filtered.slice(0, 5));
}
} catch (error) {
console.error('Failed to fetch recent articles', error);
}
};
const handlePurchase = async () => { const handlePurchase = async () => {
if (!user) { if (!user) {
router.push(`/auth/login?redirect=${encodeURIComponent(router.asPath)}`); router.push(`/auth/login?redirect=${encodeURIComponent(router.asPath)}`);
@@ -113,22 +139,43 @@ export default function ArticleDetail() {
} }
}; };
const handleShare = () => {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
toast.success('链接已复制到剪贴板');
}).catch(() => {
toast.error('复制失败');
});
};
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
toast.success('复制成功');
setTimeout(() => setCopiedField(null), 2000);
}).catch(() => {
toast.error('复制失败');
});
};
if (loading) { if (loading) {
return ( return (
<MainLayout> <MainLayout>
<div className="container mx-auto px-4 py-8 max-w-4xl"> <div className="container mx-auto px-4 py-8 max-w-6xl">
<div className="space-y-4 mb-8"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<Skeleton className="h-12 w-3/4" /> <div className="lg:col-span-3 space-y-8">
<div className="flex gap-4"> <Skeleton className="h-12 w-3/4" />
<Skeleton className="h-4 w-24" /> <Skeleton className="h-[400px] w-full rounded-xl" />
<Skeleton className="h-4 w-24" /> <div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
</div>
<div className="lg:col-span-1 space-y-6">
<Skeleton className="h-[300px] w-full rounded-xl" />
<Skeleton className="h-[200px] w-full rounded-xl" />
</div> </div>
</div>
<Skeleton className="h-[400px] w-full rounded-xl mb-8" />
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div> </div>
</div> </div>
</MainLayout> </MainLayout>
@@ -145,137 +192,271 @@ export default function ArticleDetail() {
keywords: article.标签ID列表?.map(t => t.).join(',') keywords: article.标签ID列表?.map(t => t.).join(',')
}} }}
> >
<div className="max-w-4xl mx-auto px-4 py-8"> <div className="max-w-7xl mx-auto px-4 py-8">
{/* Article Header */} <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="mb-8"> {/* Main Content - Left Column (75%) */}
<div className="flex items-center gap-2 mb-4"> <div className="lg:col-span-3">
<Badge variant="secondary" className="text-primary bg-primary/10 hover:bg-primary/20"> {/* Article Header */}
{article.ID?. || '未分类'} <div className="mb-8">
</Badge> <div className="flex items-center gap-2 mb-4">
{article.ID列表?.map((tag, index) => ( <Badge variant="secondary" className="text-primary bg-primary/10 hover:bg-primary/20">
<Badge key={index} variant="outline" className="text-gray-500"> {article.ID?. || '未分类'}
{tag.} </Badge>
</Badge> {article.ID列表?.map((tag, index) => (
))} <Badge key={index} variant="outline" className="text-gray-500">
</div> {tag.}
</Badge>
))}
</div>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight"> <h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
{article.} {article.}
</h1> </h1>
<div className="flex items-center gap-6 text-sm text-gray-500 border-b border-gray-100 pb-8"> <div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-8">
<div className="flex items-center gap-2"> <div className="flex items-center gap-6 text-sm text-gray-500">
<UserIcon className="w-4 h-4" /> <div className="flex items-center gap-2">
<span>{article.ID?. || '匿名'}</span> <UserIcon className="w-4 h-4" />
</div> <span>{article.ID?. || '匿名'}</span>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>{format(new Date(article.createdAt), 'yyyy-MM-dd', { locale: zhCN })}</span>
</div>
<div>
{article.?. || 0}
</div>
</div>
</div>
{/* Cover Image */}
{article. && (
<div className="mb-10 rounded-xl overflow-hidden shadow-lg">
<img
src={article.}
alt={article.}
className="w-full h-auto object-cover max-h-[500px]"
/>
</div>
)}
{/* Article Content */}
<article className="prose prose-lg max-w-none mb-12 prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: article.正文内容 }} />
</article>
{/* Resource Download / Paywall Section */}
<div className="bg-gray-50 rounded-xl p-8 mb-12 border border-gray-100">
{hasAccess ? (
<div className="space-y-6">
<h3 className="text-xl font-bold flex items-center gap-2 text-green-700">
<Download className="w-6 h-6" />
</h3>
{article. ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="bg-white p-4 rounded-lg border border-gray-200">
<span className="text-gray-500 text-sm block mb-1"></span>
<a
href={article..}
target="_blank"
rel="noopener noreferrer"
className="text-primary font-medium hover:underline break-all"
>
{article..}
</a>
</div> </div>
{article.. && ( <div className="flex items-center gap-2">
<div className="bg-white p-4 rounded-lg border border-gray-200"> <Calendar className="w-4 h-4" />
<span className="text-gray-500 text-sm block mb-1"></span> <span>{format(new Date(article.createdAt), 'yyyy-MM-dd', { locale: zhCN })}</span>
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono"> </div>
{article..} <div className="flex items-center gap-2">
</code> <Eye className="w-4 h-4" />
<span>{article.?. || 0} </span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleShare} className="text-gray-500 hover:text-primary">
<Share2 className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* Cover Image */}
{article. && (
<div className="mb-10 rounded-xl overflow-hidden shadow-lg relative aspect-video bg-gray-100">
<Image
src={article.}
alt={article.}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 800px"
/>
</div>
)}
{/* Article Content */}
<article className="prose prose-lg max-w-none mb-12 prose-headings:text-gray-900 prose-p:text-gray-700 prose-a:text-primary hover:prose-a:text-primary/80 prose-img:rounded-xl dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: article.正文内容 }} />
</article>
{/* Comments Section */}
<CommentSection articleId={article._id} isLoggedIn={!!user} />
</div>
{/* Sidebar - Right Column (25%) */}
<div className="lg:col-span-1 space-y-6">
{/* Resource Card */}
<Card className="border-primary/20 shadow-md overflow-hidden">
<div className="bg-linear-to-r from-primary/10 to-primary/5 p-4 border-b border-primary/10">
<h3 className="font-bold text-lg flex items-center gap-2 text-gray-900">
<Download className="w-5 h-5 text-primary" />
</h3>
</div>
<CardContent className="p-6 space-y-6">
<div className="flex items-baseline justify-between">
<span className="text-sm text-gray-500"></span>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-primary">
{article. > 0 ? `¥${article.}` : '免费'}
</span>
{article. > 0 && <span className="text-xs text-gray-400">/ </span>}
</div>
</div>
{/* Resource Attributes (Version, Size, etc.) */}
{article. && (
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600 bg-gray-50 p-3 rounded-lg border border-gray-100">
{article.. && (
<div className="flex items-center gap-1.5">
<Box className="w-3.5 h-3.5 text-gray-400" />
<span>: {article..}</span>
</div>
)}
{article.. && (
<div className="flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5 text-gray-400" />
<span>: {article..}</span>
</div>
)}
{article..?.map((attr, idx) => (
<div key={idx} className="flex items-center gap-1.5 col-span-2">
<TagIcon className="w-3.5 h-3.5 text-gray-400" />
<span>{attr.}: {attr.}</span>
</div>
))}
</div>
)}
{hasAccess ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="bg-green-50 text-green-700 px-4 py-3 rounded-lg text-sm flex items-center gap-2 border border-green-100">
<Check className="w-4 h-4" />
</div> </div>
)}
{article.. && ( {article. && (
<div className="bg-white p-4 rounded-lg border border-gray-200"> <div className="space-y-3">
<span className="text-gray-500 text-sm block mb-1"></span> <Button className="w-full" asChild>
<code className="bg-gray-100 px-2 py-1 rounded text-gray-900 font-mono"> <a href={article..} target="_blank" rel="noopener noreferrer">
{article..} <Download className="w-4 h-4 mr-2" />
</code>
</a>
</Button>
{article.. && (
<div className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm border border-gray-100">
<span className="text-gray-500 pl-1">: {article..}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyToClipboard(article.!., 'code')}
>
{copiedField === 'code' ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
)}
{article.. && (
<div className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm border border-gray-100">
<span className="text-gray-500 pl-1">: {article..}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyToClipboard(article.!., 'pwd')}
>
{copiedField === 'pwd' ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
)}
{article.. && (
<div className="bg-amber-50 p-3 rounded text-sm border border-amber-100 text-amber-800">
<div className="font-medium mb-1 flex items-center gap-1">
<Lock className="w-3 h-3" />
</div>
<div className="whitespace-pre-wrap text-xs opacity-90">{article..}</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4">
<Button
className="w-full bg-linear-to-r from-primary to-purple-600 hover:from-primary/90 hover:to-purple-600/90 text-white shadow-lg shadow-primary/20"
onClick={handlePurchase}
disabled={purchasing}
>
{purchasing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<ShoppingCart className="w-4 h-4 mr-2" />
)}
</Button>
{article. === 'membership_free' ? (
<div className="text-center">
<Link href="/membership" className="text-xs text-primary font-medium hover:underline flex items-center justify-center gap-1">
<Crown className="w-3 h-3 text-yellow-500" />
()
</Link>
</div>
) : (
<div className="text-center">
<Link href="/membership" className="text-xs text-gray-500 hover:text-primary flex items-center justify-center gap-1">
<Crown className="w-3 h-3 text-yellow-500" />
</Link>
</div>
)}
</div>
)}
<div className="pt-4 border-t border-gray-100 grid grid-cols-2 gap-4 text-center text-sm text-gray-500">
<div>
<div className="text-gray-900 font-medium">{article.?. || 0}</div>
<div className="text-xs mt-1"></div>
</div>
<div>
<div className="text-gray-900 font-medium">{article.?. || 0}</div>
<div className="text-xs mt-1"></div>
</div>
</div>
</CardContent>
</Card>
{/* Recent Posts Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="px-0 pb-2">
<div className="flex flex-col">
{recentArticles.length > 0 ? (
recentArticles.map((item, index) => (
<Link
key={item._id}
href={`/article/${item._id}`}
className="group flex items-start gap-3 px-6 py-3 hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-0"
>
<div className="relative w-16 h-12 shrink-0 rounded overflow-hidden bg-gray-100">
{item. ? (
<Image
src={item.}
alt={item.}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="64px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">
<FileText className="w-6 h-6" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 line-clamp-2 group-hover:text-primary transition-colors">
{item.}
</h4>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-400">
<span>{format(new Date(item.createdAt), 'MM-dd')}</span>
</div>
</div>
</Link>
))
) : (
<div className="px-6 py-4 text-sm text-gray-500 text-center">
</div> </div>
)} )}
</div> </div>
) : ( </CardContent>
<p className="text-gray-500"></p> </Card>
)} </div>
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-primary" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="lg"
className="px-8"
onClick={handlePurchase}
disabled={purchasing}
>
{purchasing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<span className="mr-2">¥{article.}</span>
)}
</Button>
<Button
size="lg"
variant="outline"
onClick={() => router.push('/membership')}
>
()
</Button>
</div>
</div>
)}
</div> </div>
{/* Comments Section */}
<CommentSection articleId={article._id} isLoggedIn={!!user} />
</div> </div>
</MainLayout> </MainLayout>
); );

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import { Loader2 } from 'lucide-react'; import { Loader2, Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -17,7 +17,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox';
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: "请输入有效的邮箱地址" }), email: z.string().email({ message: "请输入有效的邮箱地址" }),
@@ -69,25 +69,59 @@ export default function LoginPage() {
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> <div className="min-h-screen grid lg:grid-cols-2">
<Card className="w-full max-w-md"> {/* Left Panel - Visual & Branding */}
<CardHeader className="space-y-1"> <div className="hidden lg:flex flex-col justify-between bg-black text-white p-12 relative overflow-hidden">
<CardTitle className="text-2xl font-bold text-center"></CardTitle> {/* Abstract Background */}
<CardDescription className="text-center"> <div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop')] bg-cover bg-center opacity-40"></div>
<div className="absolute inset-0 bg-linear-to-br from-black via-black/80 to-transparent"></div>
</CardDescription>
</CardHeader> {/* Content */}
<CardContent> <div className="relative z-10">
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="w-8 h-8 rounded-lg bg-white text-black flex items-center justify-center">
<Sparkles className="w-5 h-5" />
</div>
AOUN AI
</div>
</div>
<div className="relative z-10 space-y-6 max-w-lg">
<h1 className="text-5xl font-bold leading-tight tracking-tight">
<br />
<span className="text-transparent bg-clip-text bg-linear-to-r from-blue-400 to-purple-400"></span>
</h1>
<p className="text-lg text-gray-300 leading-relaxed">
AI
</p>
</div>
<div className="relative z-10 text-sm text-gray-400">
© 2024 AOUN AI. All rights reserved.
</div>
</div>
{/* Right Panel - Login Form */}
<div className="flex items-center justify-center p-8 bg-white">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold tracking-tight text-gray-900"></h2>
<p className="text-gray-500"></p>
</div>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField <FormField
control={form.control} control={form.control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input placeholder="name@example.com" {...field} /> <div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input placeholder="name@example.com" className="pl-10 h-11" {...field} />
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -100,33 +134,67 @@ export default function LoginPage() {
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="******" {...field} /> <div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox id="remember" />
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-500"
>
</label>
</div>
<Link href="#" className="text-sm font-medium text-primary hover:underline">
</Link>
</div>
{error && ( {error && (
<div className="text-sm text-destructive text-center">{error}</div> <div className="p-3 rounded-md bg-red-50 text-red-500 text-sm text-center font-medium">
{error}
</div>
)} )}
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full h-11 text-base" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
</span>
)}
</Button> </Button>
</form> </form>
</Form> </Form>
</CardContent>
<CardFooter className="flex justify-center"> <div className="relative">
<p className="text-sm text-muted-foreground"> <div className="absolute inset-0 flex items-center">
{' '} <span className="w-full border-t border-gray-100" />
<Link href="/auth/register" className="text-primary hover:underline"> </div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">
</span>
</div>
</div>
<div className="text-center">
<Link href="/auth/register" className="text-primary font-semibold hover:underline">
</Link> </Link>
</p> </div>
</CardFooter> </div>
</Card> </div>
</div> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import { Loader2 } from 'lucide-react'; import { Loader2, User, Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -16,7 +16,6 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
const formSchema = z.object({ const formSchema = z.object({
username: z.string().min(2, { message: "用户名至少需要2个字符" }), username: z.string().min(2, { message: "用户名至少需要2个字符" }),
@@ -76,15 +75,46 @@ export default function RegisterPage() {
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> <div className="min-h-screen grid lg:grid-cols-2">
<Card className="w-full max-w-md"> {/* Left Panel - Visual & Branding */}
<CardHeader className="space-y-1"> <div className="hidden lg:flex flex-col justify-between bg-black text-white p-12 relative overflow-hidden">
<CardTitle className="text-2xl font-bold text-center"></CardTitle> {/* Abstract Background */}
<CardDescription className="text-center"> <div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?q=80&w=2574&auto=format&fit=crop')] bg-cover bg-center opacity-40"></div>
使 <div className="absolute inset-0 bg-linear-to-br from-black via-black/80 to-transparent"></div>
</CardDescription>
</CardHeader> {/* Content */}
<CardContent> <div className="relative z-10">
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="w-8 h-8 rounded-lg bg-white text-black flex items-center justify-center">
<Sparkles className="w-5 h-5" />
</div>
AOUN AI
</div>
</div>
<div className="relative z-10 space-y-6 max-w-lg">
<h1 className="text-5xl font-bold leading-tight tracking-tight">
<br />
<span className="text-transparent bg-clip-text bg-linear-to-r from-purple-400 to-pink-400"></span>
</h1>
<p className="text-lg text-gray-300 leading-relaxed">
AI GPT-4Claude 3
</p>
</div>
<div className="relative z-10 text-sm text-gray-400">
© 2024 AOUN AI. All rights reserved.
</div>
</div>
{/* Right Panel - Register Form */}
<div className="flex items-center justify-center p-8 bg-white">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold tracking-tight text-gray-900"></h2>
<p className="text-gray-500"></p>
</div>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
@@ -94,7 +124,10 @@ export default function RegisterPage() {
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input placeholder="johndoe" {...field} /> <div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input placeholder="johndoe" className="pl-10 h-11" {...field} />
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -105,9 +138,12 @@ export default function RegisterPage() {
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input placeholder="name@example.com" {...field} /> <div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input placeholder="name@example.com" className="pl-10 h-11" {...field} />
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -120,7 +156,10 @@ export default function RegisterPage() {
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="******" {...field} /> <div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -133,7 +172,10 @@ export default function RegisterPage() {
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="******" {...field} /> <div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input type="password" placeholder="******" className="pl-10 h-11" {...field} />
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -141,25 +183,41 @@ export default function RegisterPage() {
/> />
{error && ( {error && (
<div className="text-sm text-destructive text-center">{error}</div> <div className="p-3 rounded-md bg-red-50 text-red-500 text-sm text-center font-medium">
{error}
</div>
)} )}
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full h-11 text-base mt-2" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
</span>
)}
</Button> </Button>
</form> </form>
</Form> </Form>
</CardContent>
<CardFooter className="flex justify-center"> <div className="relative">
<p className="text-sm text-muted-foreground"> <div className="absolute inset-0 flex items-center">
{' '} <span className="w-full border-t border-gray-100" />
<Link href="/auth/login" className="text-primary hover:underline"> </div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">
</span>
</div>
</div>
<div className="text-center">
<Link href="/auth/login" className="text-primary font-semibold hover:underline">
</Link> </Link>
</p> </div>
</CardFooter> </div>
</Card> </div>
</div> </div>
); );
} }

View File

@@ -6,13 +6,56 @@ import Sidebar from '@/components/home/Sidebar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
interface Article {
_id: string;
文章标题: string;
摘要?: string;
封面图?: string;
ID?: {
用户名: string;
头像: string;
};
ID?: {
分类名称: string;
};
ID列表?: {
标签名称: string;
}[];
: {
阅读数: number;
点赞数: number;
评论数: number;
};
价格?: number;
createdAt: string;
}
interface Category {
_id: string;
分类名称: string;
别名: string;
}
interface Tag {
_id: string;
标签名称: string;
}
interface Banner {
标题: string;
描述: string;
图片地址: string;
按钮文本: string;
按钮链接: string;
}
export default function Home() { export default function Home() {
const [articles, setArticles] = useState<any[]>([]); const [articles, setArticles] = useState<Article[]>([]);
const [categories, setCategories] = useState<any[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<any[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState('all'); const [activeCategory, setActiveCategory] = useState('all');
const [banners, setBanners] = useState<any[]>([]); const [banners, setBanners] = useState<Banner[]>([]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -108,7 +151,16 @@ export default function Home() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{articles.map(article => ( {articles.map(article => (
<ArticleCard key={article._id} article={article} /> <ArticleCard
key={article._id}
article={{
...article,
摘要: article.摘要 || '',
封面图: article.封面图 || '',
价格: article.价格 || 0,
分类ID: article.分类ID || { : '未分类' }
}}
/>
))} ))}
</div> </div>
)} )}