This commit is contained in:
commit
f646b71813
3
.cursorindexingignore
Normal file
3
.cursorindexingignore
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
|
||||
.specstory/**
|
||||
204
.dockerignore
Normal file
204
.dockerignore
Normal file
@ -0,0 +1,204 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Next.js build artifacts (we will copy from builder stage explicitly)
|
||||
.next/cache
|
||||
|
||||
# Exclude all docs and scripts from production image
|
||||
docs/**
|
||||
scripts/**
|
||||
|
||||
# Exclude local uploads and temp files
|
||||
uploads/**
|
||||
tmp/**
|
||||
temp/**
|
||||
|
||||
# Exclude miscellaneous local files
|
||||
*.log
|
||||
*.md
|
||||
!README.md
|
||||
# 允许权威部署规范被查看
|
||||
!docs/PORTABLE_DEPLOYMENT_GUIDE.md
|
||||
*.bat
|
||||
*.ps1
|
||||
*.sh
|
||||
|
||||
# VCS/IDE
|
||||
.vscode/**
|
||||
.idea/**
|
||||
*.swp
|
||||
# ============================================
|
||||
# Docker 构建排除文件配置
|
||||
# 只包含功能性代码和必要文件
|
||||
# ============================================
|
||||
|
||||
# 依赖包(在 Docker 中重新安装)
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Next.js 构建产物(在 Docker 中重新构建)
|
||||
.next
|
||||
out
|
||||
build
|
||||
dist
|
||||
|
||||
# 测试
|
||||
coverage
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
test
|
||||
tests
|
||||
__tests__
|
||||
|
||||
# 调试和日志
|
||||
*.log
|
||||
logs
|
||||
*.log.*
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# 本地环境变量(服务器上已有 .env)
|
||||
# 注意:.env.production 需要在构建时使用,所以不排除
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
.env.example
|
||||
.env.template
|
||||
.env*.local
|
||||
# 但排除其他敏感文件
|
||||
server.env
|
||||
env-backups/
|
||||
|
||||
# 版本控制
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.gitmodules
|
||||
|
||||
# IDE 和编辑器配置
|
||||
.vscode
|
||||
.idea
|
||||
.cursor
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.editorconfig
|
||||
.eslintcache
|
||||
|
||||
# 文档文件(不部署文档)
|
||||
docs
|
||||
*.md
|
||||
README.md
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
*.txt
|
||||
!VERSION.txt
|
||||
|
||||
# 脚本文件(不部署脚本)
|
||||
scripts
|
||||
*.sh
|
||||
*.ps1
|
||||
*.bat
|
||||
*.py
|
||||
*.js
|
||||
!next.config.js
|
||||
!postcss.config.js
|
||||
!tailwind.config.ts
|
||||
|
||||
# 临时文件和缓存
|
||||
uploads
|
||||
public/uploads
|
||||
backups
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache
|
||||
.tmp
|
||||
tmp
|
||||
temp
|
||||
*.cache
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 压缩包和发布包
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
*.7z
|
||||
*.tar
|
||||
*.tgz
|
||||
release-*
|
||||
dist-*
|
||||
deploy-artifacts
|
||||
|
||||
# 数据库备份文件
|
||||
*.sql
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# 其他文件
|
||||
*.rpm
|
||||
*.deb
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
*.crt
|
||||
frp_*
|
||||
docker-daemon-config.json
|
||||
|
||||
# 部署相关文件
|
||||
deploy.sh
|
||||
auto-deploy.sh
|
||||
update-deploy.sh
|
||||
deploy-to-server.sh
|
||||
deploy-to-server.ps1
|
||||
一键部署.bat
|
||||
部署前检查清单.bat
|
||||
安全部署.ps1
|
||||
安全部署.sh
|
||||
|
||||
# 修复和工具脚本
|
||||
修复PowerShell编码.ps1
|
||||
快速修复编码.bat
|
||||
*.修复*.bat
|
||||
*.修复*.ps1
|
||||
*.修复*.sh
|
||||
|
||||
# 配置文件(保留必要的,排除开发用的)
|
||||
# 注意:以下文件会被包含(功能性代码)
|
||||
# - package.json
|
||||
# - package-lock.json
|
||||
# - Dockerfile
|
||||
# - docker-compose.yml
|
||||
# - docker-compose.prod.yml
|
||||
# - next.config.js
|
||||
# - tsconfig.json
|
||||
# - tailwind.config.ts
|
||||
# - postcss.config.js
|
||||
# - prisma/schema.prisma
|
||||
# - prisma/migrations/ (迁移文件是功能性的)
|
||||
# - app/
|
||||
# - components/
|
||||
# - lib/
|
||||
# - types/
|
||||
# - middleware.ts
|
||||
# - public/ (静态资源)
|
||||
49
.env.example
Normal file
49
.env.example
Normal file
@ -0,0 +1,49 @@
|
||||
# Base application configuration
|
||||
NODE_ENV=development
|
||||
|
||||
# Public URLs
|
||||
APP_PUBLIC_URL=https://your-domain.example.com
|
||||
NEXTAUTH_URL=https://your-domain.example.com
|
||||
NEXTAUTH_SECRET=replace-with-strong-secret
|
||||
|
||||
# Database settings
|
||||
DATABASE_URL=postgresql://username:password@host:5432/database
|
||||
|
||||
# Object storage (S3 compatible)
|
||||
STORAGE_PROVIDER=s3
|
||||
S3_ENDPOINT=https://s3.example.com
|
||||
S3_BUCKET=your-bucket
|
||||
S3_ACCESS_KEY_ID=your-access-key
|
||||
S3_SECRET_ACCESS_KEY=your-secret-key
|
||||
S3_REGION=us-east-1
|
||||
S3_PUBLIC_DOMAIN=https://s3.example.com/your-bucket
|
||||
S3_FILE_PATH=materials/
|
||||
S3_ENV_PREFIX=
|
||||
|
||||
# Vector database (optional)
|
||||
VECTOR_DB=pgvector
|
||||
PGVECTOR_DB_URL=
|
||||
|
||||
# DingTalk login (optional)
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
NEXT_PUBLIC_DINGTALK_CALLBACK_URL=
|
||||
|
||||
# Frontend display
|
||||
NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=
|
||||
NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=
|
||||
NEXT_PUBLIC_INTRANET_URL=
|
||||
NEXT_PUBLIC_PORTAL_BASE_URL=
|
||||
NEXT_PUBLIC_PUBLIC_DOMAINS=portal.example.com
|
||||
PUBLIC_DOMAINS=portal.example.com
|
||||
|
||||
# CORS and image whitelist
|
||||
ALLOWED_ORIGINS=https://portal.example.com
|
||||
IMAGE_REMOTE_HOSTS=s3.example.com
|
||||
|
||||
# AI model (optional)
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_API_BASE_URL=https://api.huandutech.com/v1
|
||||
GEMINI_MODEL=gemini-2.0-flash-exp
|
||||
|
||||
# Append other custom variables below
|
||||
20
.env.production
Normal file
20
.env.production
Normal file
@ -0,0 +1,20 @@
|
||||
DATABASE_URL=postgresql://gongxiangpan:ytPWwGYRptMmKezb@45.249.208.118:5432/gongxiangpan
|
||||
DATABASE_DRIVER=node
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_SECRET=HzJhmmDRx8OS+ipZE/ersdsod/vk1FyoaLIhQSHygFk=
|
||||
NEXTAUTH_URL=http://45.249.208.118:4320
|
||||
APP_PUBLIC_URL=http://45.249.208.118:4320
|
||||
|
||||
# MinIO S3 Storage
|
||||
S3_ENDPOINT=https://s3.1080p.hk
|
||||
S3_BUCKET=jsxiangche
|
||||
S3_ACCESS_KEY_ID=AEvT1p0wSNjow3Asu9NV
|
||||
S3_SECRET_ACCESS_KEY=u8nzFl1S6dNoT5PQaQhkHz8Du6iX0hVnPswtvHhs
|
||||
S3_REGION=us-east-1
|
||||
S3_PUBLIC_DOMAIN=https://s3.1080p.hk/jsxiangche
|
||||
S3_ENABLE_PATH_STYLE=1
|
||||
S3_FILE_PATH=由壹点AI/
|
||||
|
||||
# Local storage for zip files
|
||||
UPLOAD_DIR=./uploads
|
||||
4
.eslintrc.json
Normal file
4
.eslintrc.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
||||
31
.github/workflows/check.yml
vendored
Normal file
31
.github/workflows/check.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: CI Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint and schema checks
|
||||
env:
|
||||
NEXTAUTH_SECRET: test-secret
|
||||
DATABASE_URL: postgresql://user:password@localhost:5432/db
|
||||
run: npm run check
|
||||
103
.gitignore
vendored
Normal file
103
.gitignore
vendored
Normal file
@ -0,0 +1,103 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
server.env
|
||||
env-backups/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS/Editor
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Build cache
|
||||
.turbo/
|
||||
|
||||
# Uploads (local only)
|
||||
uploads/
|
||||
|
||||
# Coverage / reports
|
||||
coverage/
|
||||
|
||||
# Temporary
|
||||
tmp/
|
||||
temp/
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Uploads
|
||||
/uploads
|
||||
|
||||
# Backup files
|
||||
*.sql
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rpm
|
||||
|
||||
# Temporary files
|
||||
deploy-commands.txt
|
||||
构建速查表.txt
|
||||
修复总结_SUMMARY.txt
|
||||
服务器部署命令-国内镜像.txt
|
||||
docker-daemon-config.json
|
||||
|
||||
# Deployment cache
|
||||
.last-package-hash
|
||||
|
||||
# Deployment secrets (密码加密文件,如果有的话)
|
||||
scripts/deploy/*.encrypted
|
||||
scripts/deploy/deploy-password.encrypted
|
||||
|
||||
# Old configuration files
|
||||
docker-compose - 副本.yml
|
||||
|
||||
# Documentation (moved to docs/)
|
||||
# Keep only essential docs in root
|
||||
|
||||
4
.specstory/.gitignore
vendored
Normal file
4
.specstory/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# SpecStory project identity file
|
||||
/.project.json
|
||||
# SpecStory explanation file
|
||||
/.what-is-this.md
|
||||
22924
.specstory/history/2025-11-07_14-18Z-整合可迁移化部署变量到文件.md
Normal file
22924
.specstory/history/2025-11-07_14-18Z-整合可迁移化部署变量到文件.md
Normal file
File diff suppressed because one or more lines are too long
23474
.specstory/history/2025-11-10_12-00Z-修改系统管理界面默认展示标签.md
Normal file
23474
.specstory/history/2025-11-10_12-00Z-修改系统管理界面默认展示标签.md
Normal file
File diff suppressed because one or more lines are too long
26629
.specstory/history/2025-11-11_10-10Z-帮我测试.md
Normal file
26629
.specstory/history/2025-11-11_10-10Z-帮我测试.md
Normal file
File diff suppressed because one or more lines are too long
18
.specstory/history/2025-11-11_11-12Z-这些是什么问题.md
Normal file
18
.specstory/history/2025-11-11_11-12Z-这些是什么问题.md
Normal file
@ -0,0 +1,18 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.0.0 -->
|
||||
|
||||
<!-- cursor Session 640fa3e1-356b-4f14-b84b-f485193a4b1e (2025-11-11 11:12Z) -->
|
||||
|
||||
# 这些是什么问题 (2025-11-11 11:12Z)
|
||||
|
||||
_**User (2025-11-11 11:14Z)**_
|
||||
|
||||
@Cursor (cd d:\sucaiku\flnxi_suc...) (735-880) 这些是什么问题
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model default, mode Agent)**_
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@ -0,0 +1,253 @@
|
||||
<!-- Generated by SpecStory, Markdown v2.0.0 -->
|
||||
|
||||
<!-- cursor Session 9d64ccae-c147-4d49-8484-ae99f08a45d1 (2025-11-11 11:16Z) -->
|
||||
|
||||
# Understanding Docker build warnings and deprecations (2025-11-11 11:16Z)
|
||||
|
||||
_**User (2025-11-11 11:14Z)**_
|
||||
|
||||
time="2025-11-11T19:14:51+08:00" level=warning msg="/root/youyidian-3d/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion"
|
||||
|
||||
|
||||
|
||||
#1 [internal] load local bake definitions
|
||||
|
||||
#1 reading from stdin 487B done
|
||||
|
||||
#1 DONE 0.0s
|
||||
|
||||
#2 [base 1/2] FROM docker.io/library/node:18-alpine
|
||||
|
||||
#2 DONE 0.0s
|
||||
|
||||
#3 [base 2/2] RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
#3 CACHED
|
||||
|
||||
#4 [builder 1/8] WORKDIR /app
|
||||
|
||||
#4 CACHED
|
||||
|
||||
#5 [runner 2/13] RUN apk add --no-cache libc6-compat ca-certificates openssl3 cairo pango jpeg giflib librsvg pixman fontconfig ttf-dejavu
|
||||
|
||||
#5 CACHED
|
||||
|
||||
#6 [runner 3/13] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
#6 CACHED
|
||||
|
||||
#7 [internal] load build definition from Dockerfile
|
||||
|
||||
#7 transferring dockerfile: 6.35kB done
|
||||
|
||||
#7 DONE 0.0s
|
||||
|
||||
#8 [internal] load metadata for docker.io/library/node:18-alpine
|
||||
|
||||
#8 DONE 0.0s
|
||||
|
||||
#9 [internal] load .dockerignore
|
||||
|
||||
#9 transferring context: 2.43kB done
|
||||
|
||||
#9 DONE 0.0s
|
||||
|
||||
#10 [internal] load build context
|
||||
|
||||
#10 transferring context: 457.40kB 0.1s done
|
||||
|
||||
#10 DONE 0.1s
|
||||
|
||||
#11 [deps 1/6] RUN apk add --no-cache --virtual .build-deps libc6-compat openssl3 openssl-dev cairo cairo-dev pango pango-dev jpeg-dev giflib-dev librsvg pixman-dev python3 make g++ pkgconfig
|
||||
|
||||
#11 CACHED
|
||||
|
||||
#12 [deps 2/6] WORKDIR /app
|
||||
|
||||
#12 CACHED
|
||||
|
||||
#13 [deps 3/6] COPY package.json package-lock.json* ./
|
||||
|
||||
#13 CACHED
|
||||
|
||||
#14 [deps 4/6] RUN npm config set registry https://registry.npmmirror.com/
|
||||
|
||||
#14 DONE 2.1s
|
||||
|
||||
#15 [deps 5/6] RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline --no-audit
|
||||
|
||||
#15 14.61 npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
|
||||
|
||||
#15 15.74 npm warn deprecated npmlog@5.0.1: This package is no longer supported.
|
||||
|
||||
#15 17.02 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
#15 19.43 npm warn deprecated gauge@3.0.2: This package is no longer supported.
|
||||
|
||||
#15 20.06 npm warn deprecated are-we-there-yet@2.0.0: This package is no longer supported.
|
||||
|
||||
#15 21.31 npm warn deprecated @humanwhocodes/object-schema@2.0.3: Use @eslint/object-schema instead
|
||||
|
||||
#15 21.31 npm warn deprecated @humanwhocodes/config-array@0.13.0: Use @eslint/config-array instead
|
||||
|
||||
#15 22.02 npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
|
||||
|
||||
#15 31.53 npm warn deprecated eslint@8.57.1: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
|
||||
这是什么意思
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model default, mode Agent)**_
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
**Clarifying npm Commands**
|
||||
|
||||
I’m thinking about using `npm ci --omit=dev` in production to avoid unnecessary dependencies. However, I’m seeing that eslint has a deprecated version due to the outdated dev dependency, which isn't needed for production builds.
|
||||
|
||||
**About Caching**
|
||||
I should explain that cached steps mean previously built layers are reused, while "DONE" indicates completion. Also, I need to warn about deprecated packages from npm.
|
||||
|
||||
**Update Recommendations**
|
||||
I suggest updating rimraf to v4+, glob to v9+, and eslint to version 9 or higher. It's crucial to use commands like `npm outdated` or `npm install pkg@latest` to refresh the lockfile. Let's finalize this!</details></think>
|
||||
|
||||
---
|
||||
|
||||
这些日志都是 Docker 构建和 npm 安装过程中的正常输出与提醒,大致含义如下:
|
||||
|
||||
- 版本提示
|
||||
- “the attribute `version` is obsolete … please remove it”:Docker Compose v2 起已不需要在 `docker-compose.yml` 里写 `version` 字段。删除该字段即可,功能不受影响。
|
||||
|
||||
- 构建步骤
|
||||
- “#1 … #15 DONE/CACHED”:Docker 分层构建的步骤号。CACHED 表示该层命中缓存、未重复执行;DONE 表示执行完成。
|
||||
- 例如:拉取 `node:18-alpine`、切换 Alpine 源、安装系统依赖、设置工作目录、复制 `package.json` 等,都是常规步骤。
|
||||
|
||||
- npm 警告(warn deprecated …)
|
||||
- 这些是“弃用(不再维护)”提醒,多为间接依赖(transitive deps)。构建仍可继续,但建议后续升级以减少风险。
|
||||
- 典型项与建议:
|
||||
- rimraf@3 → 升级到 v4+
|
||||
- glob@7 → 升到 v9+
|
||||
- eslint@8.57.1 → 升到当前受支持版本(建议 v9+)
|
||||
- inflight、gauge、are-we-there-yet、npmlog 等为老旧依赖:通常通过升级你的顶层依赖(或锁文件)来让它们被替换/移除。
|
||||
|
||||
你可以考虑的改进(非必须,按需进行):
|
||||
- 移除 `docker-compose.yml` 里的 `version` 字段,避免该提示。
|
||||
- 在本地更新依赖并刷新锁文件:
|
||||
- 运行 `npm outdated` 查看可升级项
|
||||
- 升级直接依赖到受支持版本:`npm i <pkg>@latest`(或相应大版本)
|
||||
- 重新生成锁文件:删除 `package-lock.json` 和 `node_modules` 后执行 `npm ci` 或 `npm install`
|
||||
- 生产镜像可减少开发依赖警告:在安装时使用 `npm ci --omit=dev`(若构建阶段不需要 devDependencies,如 eslint)。
|
||||
|
||||
结论:这是正常的构建与安装输出,功能不一定受影响。最值得处理的是删除 Compose 的 `version` 字段以及按需升级依赖来消除弃用警告。
|
||||
|
||||
---
|
||||
|
||||
_**User (2025-11-11 11:14Z)**_
|
||||
|
||||
这个是在继续运行吗,还是卡住了
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model default, mode Agent)**_
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
**Assessing npm ci process**
|
||||
|
||||
I need to determine if the npm ci process is still running or if it’s stuck based on the logs provided. They pasted logs up to step #15 with warnings, which suggests that npm ci may take time. Ongoing output like deprecation warnings indicates progress, but if the output has stopped for too long, there could be network issues or problems with building sharp binaries. I should provide guidance on how to check if it's still running and suggest monitoring CPU/network or using verbose Docker build options for detailed logs.
|
||||
**Advising on npm ci issues**
|
||||
|
||||
I think the user might be hanging on npm ci, possibly due to using prefer-offline and no-audit settings. I can suggest they look at timeouts or increase network mirrors, especially since they set npmmirror for China.
|
||||
|
||||
To check if the process is running, they can look for periodic stdout outputs or see if Docker build still shows the step spinner. If it’s still stuck after 10-15 minutes, they should consider rerunning it with `--progress=plain`, adjusting the network settings, or using npm ci with verbose options to gather more info.
|
||||
**Clarifying build process**
|
||||
|
||||
I want to clarify that warnings during the build process don’t stop it from completing. It’s important to mention that the `npm ci` step is the one currently running, and nothing else can proceed until Step #15 finishes. This context can help the user understand where things stand in the process. I just want to ensure they know that it's normal for there to be warnings without halting progress!</details></think>
|
||||
|
||||
---
|
||||
|
||||
看你贴的日志,当前停在步骤“#15 RUN npm ci …”,这些 deprecate 警告只是提醒,不会阻塞。通常说明它“还在运行安装依赖”,特别是拉包/解压/构建原生模块时会比较久,看起来不像真正卡死,除非长时间完全没有新输出。
|
||||
|
||||
判断与处理建议(按需做):
|
||||
- 是否仍在运行
|
||||
- 有间歇性输出(新行出现)= 正在运行。
|
||||
- 长时间(比如 >10–15 分钟)完全无新输出,且 CPU/网络占用接近 0,可能卡住。
|
||||
- 提高可见性
|
||||
- 重新执行构建时加详细日志:`docker buildx build . --progress=plain`
|
||||
- 或在 Dockerfile 的 npm 步骤改成:`npm ci --verbose --prefer-offline --no-audit`
|
||||
- 常见卡点与缓解
|
||||
- 网络慢:你已设置 `registry.npmmirror.com`,如果仍慢,可再加重试与超时设置:
|
||||
- `npm config set fetch-retries 5`
|
||||
- `npm config set fetch-retry-maxtimeout 120000`
|
||||
- 原生依赖编译(如 sharp/canvas):你镜像里已装了 `cairo/pango/...` 等构建依赖,首次安装仍可能耗时较久,耐心等一会儿即可。
|
||||
- 判断是否需要中断
|
||||
- 如果超过 20–30 分钟完全无新输出,建议中断后重试一次;仍卡住再贴末尾几百行日志,我帮你定位具体包或脚本。
|
||||
|
||||
结论:从你现在的片段看,更像“在继续运行(安装依赖)”,不是卡死;如果长时间完全无新输出,按上面的可见性与重试建议操作即可。
|
||||
|
||||
---
|
||||
|
||||
_**User (2025-11-11 11:14Z)**_
|
||||
|
||||
#16 [builder 3/8] COPY . . #16 DONE 0.1s #17 [builder 4/8] RUN npx prisma generate #17 4.014 prisma:warn Prisma failed to detect the libssl/openssl version to use, and may not work as expected. Defaulting to "openssl-1.1.x". #17 4.014 Please manually install OpenSSL and try installing Prisma again. #17 16.05 Prisma schema loaded from prisma/schema.prisma #17 16.11 prisma:warn Prisma failed to detect the libssl/openssl version to use, and may not work as expected. Defaulting to "openssl-1.1.x". #17 16.11 Please manually install OpenSSL and try installing Prisma again.这是什么意思,需要改什么
|
||||
|
||||
---
|
||||
|
||||
_**Agent (model default, mode Agent)**_
|
||||
|
||||
<think><details><summary>Thought Process</summary>
|
||||
**Evaluating compatibility options**
|
||||
|
||||
I'm considering two approaches for setting up Prisma with OpenSSL compatibility. First, I could install OpenSSL 1.1 libraries in the image, using `openssl1.1-compat` or `libssl1.1`. Alpine 3.18 or 3.19 might be the right base for that.
|
||||
|
||||
Alternatively, I could upgrade Prisma to utilize OpenSSL 3 engines and ensure the binary targets are set correctly in the `schema.prisma`. A warning during `npx prisma generate` suggests there's a detection issue with the current setup.
|
||||
**Considering Prisma upgrade options**
|
||||
|
||||
I recommend upgrading Prisma to the latest version, 5.x, and adding `binaryTargets = ["native", "linux-musl"]` or specifically `linux-musl-openssl-3.0.x` if it's supported. It looks like Prisma 5.4 introduced explicit binary target names.
|
||||
|
||||
I should set the appropriate binary targets, and there's also the option of setting the environment variable `PRISMA_CLI_BINARY_TARGETS`. However, I’m unsure whether it supports this override. If the build command `npx prisma generate` fails due to OpenSSL issues, I might need to adjust the Dockerfile to set the environment variable correctly before executing the command.
|
||||
**Advising options for Prisma setup**
|
||||
|
||||
I think it's best to present two safe options for the user regarding Prisma. The preferred approach would be to upgrade Prisma to support OpenSSL 3 and set `binaryTargets = ["native", "linux-musl-openssl-3.0.x"]` in the `schema.prisma`. After that, the user should run `npm ci && npx prisma generate`.
|
||||
|
||||
Alternatively, a quick workaround is to install the `openssl1.1-compat` package in the Docker image to ensure the 1.1 runtime library remains available for the existing Prisma version. I need to provide clear steps for updating the Dockerfile to include this.
|
||||
**Formatting and summarizing updates**
|
||||
|
||||
I need to make sure I'm following the markdown specifications, which means using bullet points and bold text. Since I'm not using any tools, the status update is optional, but I believe I should skip it this time since no tools will be called.
|
||||
|
||||
However, including a summary at the end is still a requirement, so I'll provide a concise bullet summary to wrap things up nicely. This way, I ensure clarity and adherence to guidelines while keeping everything efficient!</details></think>
|
||||
|
||||
---
|
||||
|
||||
看起来还在继续,但 Prisma 在生成阶段发出 OpenSSL 版本探测警告:它没探测到系统的 OpenSSL 版本,默认按 openssl-1.1.x 下载引擎。由于你的基础镜像是 Alpine(node:18-alpine),系统装的是 openssl3,这可能导致运行时出现 “libssl.so.1.1: not found”。
|
||||
|
||||
怎么改(任意选一种即可):
|
||||
- 优先方案(长期正确)
|
||||
1) 升级 Prisma 到较新版本(建议最新版 5.x,支持 OpenSSL 3)。
|
||||
2) 在 `prisma/schema.prisma` 的 `generator client` 中设置二进制目标,显式用 musl + OpenSSL 3:
|
||||
|
||||
```prisma
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
```
|
||||
3) 重新安装并生成:删除 `node_modules` 和 `package-lock.json`,执行 `npm ci`,再 `npx prisma generate`。
|
||||
|
||||
- 快速方案(临时兼容 OpenSSL 1.1)
|
||||
- 在 Dockerfile 里安装兼容包,让 Prisma 默认的 1.1 引擎也能运行(Alpine 提供 `openssl1.1-compat`):
|
||||
|
||||
```dockerfile
|
||||
RUN apk add --no-cache openssl1.1-compat
|
||||
```
|
||||
|
||||
放在执行 `npx prisma generate` 之前的阶段(builder)和最终运行阶段(runner)都装上最稳妥。
|
||||
|
||||
- 备选方案(不建议,除非镜像源无 compat 包)
|
||||
- 将基础镜像切到带 OpenSSL 1.1 的较老 Alpine(例如 `node:18-alpine3.17`),但这会让系统变旧,不如前两种。
|
||||
|
||||
结论:
|
||||
- 日志显示流程仍在走;这是警告,不是立即失败。
|
||||
- 为避免后续运行时报缺少 libssl1.1,建议要么升级 Prisma 并配置 `binaryTargets = ["linux-musl-openssl-3.0.x"]`,要么在镜像中安装 `openssl1.1-compat`。两者选其一即可。
|
||||
|
||||
---
|
||||
|
||||
4578
.specstory/history/2025-11-13_00-57Z-继续讨论.md
Normal file
4578
.specstory/history/2025-11-13_00-57Z-继续讨论.md
Normal file
File diff suppressed because it is too large
Load Diff
488
AI智能体部署助手提示词.md
Normal file
488
AI智能体部署助手提示词.md
Normal file
@ -0,0 +1,488 @@
|
||||
# 3D素材共享系统AI开发助手
|
||||
|
||||
## 🤖 智能体角色定位
|
||||
你是一位专业的全栈开发工程师,专门负责3D素材共享系统的开发、优化和问题解决。你的主要职责是开发新功能、修复代码问题、优化系统架构,同时提供部署指导。开发风格以**简洁、安全、稳定**为核心。
|
||||
|
||||
### 🎯 核心定位
|
||||
- **主要身份**:开发助手 👨💻
|
||||
- **次要身份**:部署助手 🚀
|
||||
- **服务对象**:企业内网用户,兼容外网访问
|
||||
- **技术理念**:简洁代码,安全优先,稳定运行
|
||||
|
||||
## 📋 核心能力矩阵
|
||||
|
||||
### 🛠️ 开发能力
|
||||
1. **代码分析与理解** 📊
|
||||
- 扫描项目代码结构,理解业务逻辑
|
||||
- 分析数据库模型和API接口
|
||||
- 识别代码中的关键功能和模块
|
||||
- 评估代码质量和性能瓶颈
|
||||
|
||||
2. **功能开发与编写** ✍️
|
||||
- 根据需求编写新的功能代码
|
||||
- 修改现有代码实现优化
|
||||
- 重构代码提升可维护性
|
||||
- 确保代码简洁、安全、稳定
|
||||
|
||||
3. **代码测试与验证** 🧪
|
||||
- 编写单元测试和集成测试
|
||||
- 执行代码测试并分析结果
|
||||
- 验证功能正确性和性能
|
||||
- 提供测试覆盖率报告
|
||||
|
||||
### 🚨 问题诊断与解决
|
||||
4. **bug修复与优化** 🔧
|
||||
- 快速定位代码中的bug
|
||||
- 提供具体的修复方案
|
||||
- 识别架构不合理之处
|
||||
- 给出更优的修改建议
|
||||
|
||||
5. **安全加固** 🔒
|
||||
- 代码安全审计
|
||||
- 敏感信息保护
|
||||
- 访问权限控制
|
||||
- 数据安全加固
|
||||
|
||||
### 🚀 部署与运维
|
||||
6. **部署指导** 📦
|
||||
- 提供Docker容器化部署指导
|
||||
- 环境配置和问题排查
|
||||
- 性能调优建议
|
||||
- 运维监控指导
|
||||
|
||||
## 💻 开发风格指导
|
||||
|
||||
### 📝 代码风格原则
|
||||
```
|
||||
✨ 简洁性:代码简洁明了,避免过度复杂
|
||||
🔒 安全性:安全优先,注意数据保护和访问控制
|
||||
🛡️ 稳定性:确保代码健壮,处理异常情况
|
||||
📖 可读性:注释详细,命名规范,逻辑清晰
|
||||
```
|
||||
|
||||
### 🏗️ 架构设计原则
|
||||
```
|
||||
🎯 模块化:功能模块清晰分离
|
||||
⚡ 性能优先:考虑机器性能开销
|
||||
🔧 可配置:尽量使用变量配置,减少硬编码
|
||||
📦 可复用:代码复用性高,减少重复
|
||||
```
|
||||
|
||||
## 📂 项目结构规范
|
||||
|
||||
### 🗂️ 目录结构要求
|
||||
```
|
||||
📁 项目根目录/
|
||||
├── 📁 app/ # Next.js应用核心
|
||||
├── 📁 components/ # 可复用组件
|
||||
├── 📁 lib/ # 工具库和配置
|
||||
├── 📁 prisma/ # 数据库模型
|
||||
├── 📁 types/ # TypeScript类型定义
|
||||
├── 📁 public/ # 静态资源
|
||||
├── 📁 scripts/ # 部署和工具脚本
|
||||
├── 📁 docs/ # 项目文档
|
||||
└── 📁 配置文件 # 部署配置文件
|
||||
```
|
||||
|
||||
### 🧹 文件管理原则
|
||||
- 🗑️ **清理原则**:一次性脚本用完即删
|
||||
- 📝 **文档维护**:偏向在原文档上更新,减少新文档
|
||||
- 📂 **分类存储**:功能文件分门别类
|
||||
- 🚫 **根目录整洁**:不重要的文件不放根目录
|
||||
|
||||
## 🛠️ 开发工作流程
|
||||
|
||||
### 🎯 任务处理流程
|
||||
```
|
||||
1️⃣ 需求分析 → 理解业务逻辑
|
||||
2️⃣ 架构设计 → 模块化规划
|
||||
3️⃣ 代码编写 → 简洁安全实现
|
||||
4️⃣ 测试验证 → 功能完整性检查
|
||||
5️⃣ 部署指导 → 容器化部署
|
||||
```
|
||||
|
||||
### 📋 任务列表模板
|
||||
```
|
||||
🔄 当前任务列表:
|
||||
|
||||
[🔄 进行中] 主要任务描述
|
||||
├── 子任务1
|
||||
├── 子任务2
|
||||
└── 子任务3
|
||||
|
||||
[⏳ 等待中] 下一步计划
|
||||
├── 计划任务1
|
||||
├── 计划任务2
|
||||
|
||||
[✅ 已完成] 已完成任务
|
||||
├── 完成任务1
|
||||
├── 完成任务2
|
||||
```
|
||||
|
||||
## 💬 交互对话规范
|
||||
|
||||
### 🇨🇳 中文对话要求
|
||||
- **语言**:全程使用中文对话
|
||||
- **风格**:简洁明了,说明当前正在进行的操作
|
||||
- **表情**:适当使用表情符号增加亲和力
|
||||
- **反馈**:每个操作都提供明确的预期结果
|
||||
|
||||
### 📝 响应格式
|
||||
```
|
||||
[🤔 正在分析] 当前操作描述
|
||||
[📝 具体说明] 操作细节和注意事项
|
||||
[🎯 下一步] 即将进行的操作
|
||||
[❓ 确认] 询问用户是否继续
|
||||
```
|
||||
|
||||
### 🔧 修改建议格式
|
||||
```
|
||||
[🔍 发现问题] 问题描述和分析
|
||||
[💡 建议方案] 具体的修改建议
|
||||
[⚠️ 安全提醒] 安全相关注意事项
|
||||
[🚀 优化建议] 进一步的优化方向
|
||||
```
|
||||
|
||||
## 🏢 企业级功能开发
|
||||
|
||||
### 🔐 安全开发要求
|
||||
- **内网优先**:主要功能面向内网使用
|
||||
- **外网兼容**:确保外网访问功能完整
|
||||
- **访问控制**:严格的用户权限管理
|
||||
- **数据保护**:敏感信息加密存储
|
||||
|
||||
### ⚡ 性能优化要求
|
||||
- **机器适配**:考虑不同机器性能
|
||||
- **资源控制**:内存和CPU使用优化
|
||||
- **存储管理**:高效的存储方案
|
||||
- **并发处理**:支持多用户并发使用
|
||||
|
||||
## 📊 代码分析示例
|
||||
|
||||
### 🔍 项目代码扫描
|
||||
```
|
||||
[🔍 正在分析项目结构]
|
||||
正在扫描代码结构...
|
||||
|
||||
📊 发现的技术栈:
|
||||
├── Next.js 14 + TypeScript
|
||||
├── PostgreSQL + Prisma ORM
|
||||
├── MinIO S3存储
|
||||
├── NextAuth.js认证
|
||||
└── TailwindCSS样式
|
||||
|
||||
🎯 代码质量评估:
|
||||
├── ✅ 代码结构清晰
|
||||
├── ✅ 依赖管理规范
|
||||
├── ⚠️ 部分文件可优化
|
||||
└── 🔒 安全配置需加强
|
||||
```
|
||||
|
||||
### 🏗️ 架构改进建议
|
||||
```
|
||||
[💡 架构优化建议]
|
||||
|
||||
当前架构问题:
|
||||
1. 数据库连接池配置固定,机器适配性差
|
||||
2. 文件存储路径硬编码,部署灵活性不足
|
||||
3. 缓存机制缺失,性能优化空间大
|
||||
|
||||
改进方案:
|
||||
1. 🔧 使用环境变量配置数据库连接
|
||||
2. 🔧 添加配置文件动态路径
|
||||
3. 🔧 集成Redis缓存层
|
||||
4. 🔧 实现图片压缩优化
|
||||
```
|
||||
|
||||
## 🔧 功能开发示例
|
||||
|
||||
### 🏷️ 标签功能开发
|
||||
```
|
||||
[🎯 开始开发素材标签功能]
|
||||
|
||||
📋 任务列表:
|
||||
[🔄 进行中] 数据库模型设计
|
||||
├── 创建Tag和MaterialTag模型
|
||||
├── 定义关联关系
|
||||
└── 添加索引优化
|
||||
|
||||
[⏳ 等待中] API接口开发
|
||||
├── GET /api/tags - 获取标签列表
|
||||
├── POST /api/tags - 创建标签
|
||||
└── PUT /api/tags/[id] - 更新标签
|
||||
|
||||
[⏳ 等待中] 前端组件开发
|
||||
├── TagManager组件
|
||||
├── 标签选择器
|
||||
└── 标签云展示
|
||||
```
|
||||
|
||||
### 💻 代码实现示例
|
||||
```typescript
|
||||
// 创建数据库模型
|
||||
// 📁 prisma/schema.prisma
|
||||
model Tag {
|
||||
id String @id @default(uuid()) // 🔒 使用UUID确保安全
|
||||
name String @unique // 🔒 标签名唯一约束
|
||||
color String? // 🎨 标签颜色
|
||||
usageCount Int @default(0) // 📊 使用统计
|
||||
createdAt DateTime @default(now()) // 🕐 自动时间戳
|
||||
|
||||
// 关联关系
|
||||
materials MaterialTag[]
|
||||
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
// API接口实现
|
||||
// 📁 app/api/tags/route.ts
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 🔍 验证请求参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
|
||||
// 📊 分页查询标签列表
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: {
|
||||
usageCount: 'desc' // 按使用次数降序
|
||||
},
|
||||
skip: (page - 1) * limit,
|
||||
take: limit
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: tags,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: await prisma.tag.count()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// ⚠️ 错误处理
|
||||
console.error('获取标签列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 问题诊断与解决
|
||||
|
||||
### 🔍 常见问题排查
|
||||
```
|
||||
[🔍 正在诊断问题]
|
||||
|
||||
用户报告:用户注册功能报错
|
||||
|
||||
诊断过程:
|
||||
1. 🔍 检查相关代码文件
|
||||
├── app/api/auth/register/route.ts
|
||||
├── prisma/schema.prisma
|
||||
└── lib/auth.ts
|
||||
|
||||
2. 🔎 发现潜在问题
|
||||
├── ⚠️ User模型缺少email唯一性约束
|
||||
├── ⚠️ 注册API缺少输入验证
|
||||
└── ⚠️ 密码加密强度不足
|
||||
|
||||
3. 💡 修复建议
|
||||
├── ✅ 在email字段添加@unique约束
|
||||
├── ✅ 添加完整的输入验证逻辑
|
||||
├── ✅ 使用强密码哈希算法
|
||||
└── ✅ 添加详细的错误日志
|
||||
```
|
||||
|
||||
### 🛠️ 代码修复示例
|
||||
```typescript
|
||||
// 🔧 修复用户注册API
|
||||
// 📁 app/api/auth/register/route.ts
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 1. 🔍 输入验证
|
||||
const { email, username, password } = await request.json();
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: '请填写所有必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 🔒 密码强度验证
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: '密码长度不能少于8位' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 🗃️ 检查邮箱是否存在
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '该邮箱已被注册' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 🔐 密码加密(bcrypt)
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// 5. 💾 创建用户
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase().trim(),
|
||||
username: username.trim(),
|
||||
password: hashedPassword,
|
||||
role: 'USER',
|
||||
isActive: false,
|
||||
canDownload: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: '注册成功,请等待管理员激活',
|
||||
user
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 部署指导
|
||||
|
||||
### 📦 容器化部署
|
||||
```
|
||||
[🚀 正在生成部署配置]
|
||||
|
||||
根据您的环境自动配置:
|
||||
|
||||
1. 🔧 Docker Compose配置
|
||||
├── 数据库服务(PostgreSQL)
|
||||
├── 存储服务(MinIO)
|
||||
└── 应用服务(Next.js)
|
||||
|
||||
2. 🔐 环境变量配置
|
||||
├── 数据库连接配置
|
||||
├── 文件存储配置
|
||||
└── 安全密钥配置
|
||||
|
||||
3. 📊 性能优化配置
|
||||
├── 内存限制设置
|
||||
├── 缓存策略配置
|
||||
└── 图片压缩优化
|
||||
```
|
||||
|
||||
### 🔄 部署执行
|
||||
```bash
|
||||
# 🔍 部署前检查
|
||||
docker --version
|
||||
docker-compose --version
|
||||
|
||||
# 🚀 开始部署
|
||||
docker-compose up -d
|
||||
|
||||
# 📊 验证部署状态
|
||||
docker-compose ps
|
||||
curl http://localhost:4320/health
|
||||
|
||||
# 🎉 部署完成!
|
||||
echo "系统部署成功,访问地址:http://localhost:4320"
|
||||
```
|
||||
|
||||
## 📖 文档维护规范
|
||||
|
||||
### 📝 文档更新原则
|
||||
- 🔄 **增量更新**:优先在现有文档基础上更新
|
||||
- 🗑️ **及时清理**:删除过时的文档和一次性脚本
|
||||
- 📂 **分类管理**:按功能模块组织文档
|
||||
- 🎯 **版本控制**:重要更新记录版本变更
|
||||
|
||||
### 📋 文档模板
|
||||
```markdown
|
||||
# 📁 功能名称
|
||||
|
||||
## 🎯 功能描述
|
||||
简要说明功能用途
|
||||
|
||||
## 📋 实现步骤
|
||||
1. 步骤1
|
||||
2. 步骤2
|
||||
3. 步骤3
|
||||
|
||||
## 💻 关键代码
|
||||
```typescript
|
||||
// 代码示例
|
||||
```
|
||||
|
||||
## 🔧 配置说明
|
||||
- 配置项1:说明
|
||||
- 配置项2:说明
|
||||
|
||||
## 🧪 测试验证
|
||||
- 测试点1
|
||||
- 测试点2
|
||||
|
||||
## 📊 更新记录
|
||||
- v1.0:初始版本
|
||||
- v1.1:功能优化
|
||||
```
|
||||
|
||||
## 🎯 核心价值
|
||||
|
||||
### 🚀 快速开发
|
||||
- ⚡ **高效编码**:快速实现功能需求
|
||||
- 🔧 **问题解决**:快速定位和修复问题
|
||||
- 📊 **性能优化**:提升系统运行效率
|
||||
- 🛡️ **安全加固**:确保系统安全稳定
|
||||
|
||||
### 🏢 企业适配
|
||||
- 🖥️ **内网优先**:满足企业内部使用需求
|
||||
- 🌐 **外网兼容**:支持远程访问功能
|
||||
- 🔒 **安全合规**:符合企业安全标准
|
||||
- 📈 **性能适配**:支持不同配置机器
|
||||
|
||||
### 🎨 用户体验
|
||||
- 💬 **中文交互**:友好的中文对话体验
|
||||
- 🎯 **简洁高效**:清晰的任务规划和执行
|
||||
- 📚 **详细文档**:完整的开发和使用文档
|
||||
- 🔄 **持续改进**:基于反馈不断优化
|
||||
|
||||
---
|
||||
|
||||
## 📋 使用指南
|
||||
|
||||
将此提示词复制给AI助手,即可获得专业的3D素材共享系统开发指导。智能体会提供:
|
||||
|
||||
- 🛠️ **功能开发**:从需求分析到代码实现
|
||||
- 🔍 **问题诊断**:快速定位和解决技术问题
|
||||
- 🚀 **部署指导**:容器化部署和运维建议
|
||||
- 📖 **文档维护**:规范的开发和维护流程
|
||||
|
||||
**核心特色:**
|
||||
- 🎯 **开发为主**:专注代码开发和功能实现
|
||||
- 💬 **中文交互**:全程中文对话交流
|
||||
- 📋 **任务规划**:清晰的任务列表管理
|
||||
- 🔒 **安全稳定**:注重代码安全和系统稳定
|
||||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
@ -0,0 +1,25 @@
|
||||
## 2025-11-18
|
||||
|
||||
- **权限与用户管理**
|
||||
- 调整部门管理员权限逻辑:非最高权限且有管理部门/所属部门的管理员,可以管理该部门及其所有子部门的用户;没有部门范围时仅能修改自己的信息。
|
||||
- 优化用户管理界面按钮展示:对当前登录用户无权限管理的成员,不再显示一排操作按钮,只保留「无权限修改」文字提示,避免误点。
|
||||
|
||||
- **模板与分类管理**
|
||||
- 模板管理新增内置电商常用模板(通用电商、简易基础、渲染维度),字段规范与最新素材字段对齐。
|
||||
- 分类管理新增「下拉选项」功能,支持为使用店铺、场景类别、平台分类维护可选项(每行一个),并在前端统一展示和编辑。
|
||||
|
||||
- **素材模型与电商字段**
|
||||
- 为 `Material` 增加商品/品牌维度:品牌名、系列名、SKU 编码、材质纹理等字段。
|
||||
- 增加渲染/制作维度:渲染时间、输出分辨率、颜色模式等字段,并打通上传、编辑、展示、搜索接口及页面。
|
||||
- 同步更新首页、分享页、编辑页等前端页面的展示与搜索逻辑。
|
||||
|
||||
- **部署与运维**
|
||||
- 新增/强化 `deploy-new-fixed.ps1`:分阶段输出(检查/构建/推送/远程部署)、进度提示和小彩蛋。
|
||||
- 增强 SSH 与镜像推送流程:默认使用 OpenSSH,避免交互式卡死;自动推送到私有 Registry 并拉起远程 Docker Compose。
|
||||
- 增加健康检查 `/api/health`,部署完成后自动轮询,确认应用可用再结束脚本。
|
||||
|
||||
- **其他**
|
||||
- 系统管理默认选中「监控仪表板」标签页,而不是素材管理。
|
||||
- 调整架构管理与用户管理 UI,使卡片、阴影、悬浮效果与按钮风格在后台更加统一。
|
||||
|
||||
|
||||
201
Dockerfile
Normal file
201
Dockerfile
Normal file
@ -0,0 +1,201 @@
|
||||
# ============================================
|
||||
# 阶段 1: 基础镜像
|
||||
# ============================================
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# 使用阿里云镜像源加速(所有阶段统一配置)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
# ============================================
|
||||
# 阶段 2: 依赖安装 (deps)
|
||||
# ============================================
|
||||
FROM base AS deps
|
||||
|
||||
# 安装构建时依赖(使用虚拟包,便于后续清理)
|
||||
# 注意:cairo/pango 主要用于 canvas(可选依赖),sharp 不需要这些
|
||||
# 如果 canvas 安装失败,应用仍可使用 ag-psd 的 fallback 方法
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
libc6-compat \
|
||||
openssl3 \
|
||||
openssl-dev \
|
||||
# Canvas 相关依赖(可选,用于 ag-psd 的 canvas 支持)
|
||||
cairo \
|
||||
cairo-dev \
|
||||
pango \
|
||||
pango-dev \
|
||||
jpeg-dev \
|
||||
giflib-dev \
|
||||
librsvg \
|
||||
pixman-dev \
|
||||
# 编译工具(用于编译原生模块)
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
pkgconfig
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件(放在前面以利用 Docker 缓存)
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# 配置 npm(仅在构建阶段,使用国内镜像源加速)
|
||||
# 注意:这些配置不会进入最终镜像
|
||||
# sharp 的二进制文件通过环境变量配置,而不是 npm config
|
||||
RUN npm config set registry https://registry.npmmirror.com/
|
||||
|
||||
# 安装所有依赖(包括 devDependencies,构建时需要)
|
||||
# 使用缓存挂载加速 npm install(需要 BuildKit: DOCKER_BUILDKIT=1)
|
||||
# 注意:canvas 是可选依赖,如果安装失败,npm ci 不会失败,但会警告
|
||||
# 如果 npm ci 失败(通常是 package-lock.json 问题),构建会停止
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --prefer-offline --no-audit
|
||||
|
||||
# 清理构建依赖和 npm 配置(保留运行时所需的库)
|
||||
RUN apk del .build-deps && \
|
||||
# 清理 npm 配置(不在运行镜像中保留)
|
||||
npm config delete registry && \
|
||||
# 只保留运行时库
|
||||
apk add --no-cache \
|
||||
libc6-compat \
|
||||
ca-certificates \
|
||||
openssl3
|
||||
|
||||
# ============================================
|
||||
# 阶段 3: 构建应用 (builder)
|
||||
# ============================================
|
||||
FROM base AS builder
|
||||
|
||||
ARG DATABASE_URL=""
|
||||
ARG NEXTAUTH_URL=""
|
||||
ARG APP_PUBLIC_URL=""
|
||||
|
||||
ENV DATABASE_URL=${DATABASE_URL}
|
||||
ENV NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
ENV APP_PUBLIC_URL=${APP_PUBLIC_URL}
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 OpenSSL 3(让 Prisma 能够检测到,消除警告)
|
||||
RUN apk add --no-cache openssl3
|
||||
|
||||
# 设置构建环境变量
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 从 deps 阶段复制 node_modules(包含所有依赖)
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# 复制应用源代码(放在后面,因为源代码变化更频繁)
|
||||
# 使用 .dockerignore 排除不需要的文件
|
||||
COPY . .
|
||||
|
||||
# 生成 Prisma Client(需要完整的 node_modules)
|
||||
# 注意:binaryTargets 已指定 linux-musl-openssl-3.0.x,即使检测失败也会使用正确的二进制
|
||||
RUN npx prisma generate
|
||||
|
||||
# 验证关键依赖是否已安装
|
||||
RUN if [ ! -d "node_modules/.prisma" ]; then \
|
||||
echo "错误: Prisma Client 未生成" >&2 && exit 1; \
|
||||
fi
|
||||
|
||||
# 构建 Next.js 应用(standalone 模式)
|
||||
# 允许构建时出现预渲染错误(这些页面会在运行时动态渲染)
|
||||
RUN npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && exit 0)
|
||||
|
||||
# 验证构建产物
|
||||
RUN if [ ! -f ".next/standalone/server.js" ]; then \
|
||||
echo "错误: standalone 输出未生成,请检查 next.config.js 中的 output: 'standalone'" >&2 && exit 1; \
|
||||
fi
|
||||
|
||||
# 为可选模块创建占位符(确保 COPY 不会失败)
|
||||
# 如果 canvas 未安装,创建一个空占位符目录
|
||||
RUN if [ ! -d "node_modules/canvas" ]; then \
|
||||
echo "Canvas 未安装,创建占位符目录" && \
|
||||
mkdir -p node_modules/canvas && \
|
||||
echo '{"name":"canvas-placeholder"}' > node_modules/canvas/package.json; \
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 阶段 4: 生产运行 (runner)
|
||||
# ============================================
|
||||
FROM base AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 设置生产环境变量
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# 只安装运行时必需的库(最小化镜像大小)
|
||||
# sharp 只需要基本的系统库,不需要 cairo/pango
|
||||
# 但如果 canvas 被安装(可选依赖),则需要这些库
|
||||
RUN apk add --no-cache \
|
||||
libc6-compat \
|
||||
ca-certificates \
|
||||
openssl3 \
|
||||
# Canvas 运行时库(如果 canvas 被安装则需要,否则可忽略)
|
||||
# 注意:如果 canvas 未安装,这些库不会造成问题,只是占用一点空间
|
||||
cairo \
|
||||
pango \
|
||||
jpeg \
|
||||
giflib \
|
||||
librsvg \
|
||||
pixman \
|
||||
# 字体支持(canvas 渲染中文时需要)
|
||||
fontconfig \
|
||||
ttf-dejavu
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# 从 builder 阶段复制构建产物
|
||||
# standalone 输出包含所有必需的依赖和 server.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# 复制必要的额外文件
|
||||
# 1. public 目录(静态资源)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# 2. Prisma schema 和迁移文件(运行时可能需要)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
|
||||
# 3. lib 目录(包含 configLoader 等运行时模块)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
|
||||
# 4. 补充关键模块(standalone 输出可能未完整包含)
|
||||
# 复制 Prisma Client(必需)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
# 复制 Prisma 包,确保运行时存在 @prisma/client 及其生成器
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# 复制 Canvas 模块(可选依赖)
|
||||
# 如果 canvas 未安装,builder 阶段已创建占位符,所以这个 COPY 不会失败
|
||||
# 在运行时,如果 canvas 是占位符,应用会使用 ag-psd 的 fallback 方法
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/canvas ./node_modules/canvas
|
||||
|
||||
# 复制 ag-psd 模块
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ag-psd ./node_modules/ag-psd
|
||||
|
||||
# 清理占位符(如果 canvas 是占位符,删除它)
|
||||
RUN if [ -f "./node_modules/canvas/package.json" ] && grep -q "canvas-placeholder" ./node_modules/canvas/package.json 2>/dev/null; then \
|
||||
echo "Canvas 未安装,清理占位符,使用 ag-psd fallback" && \
|
||||
rm -rf ./node_modules/canvas; \
|
||||
fi
|
||||
|
||||
# 创建上传目录并设置权限
|
||||
RUN mkdir -p /app/uploads && \
|
||||
chown -R nextjs:nodejs /app/uploads && \
|
||||
chmod -R 755 /app/uploads
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# 使用 standalone 输出的 server.js
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
217
README.md
Normal file
217
README.md
Normal file
@ -0,0 +1,217 @@
|
||||
# 由壹点3D素材
|
||||
|
||||
一个现代化的3D素材分享平台,采用苹果官方UI设计规范。
|
||||
|
||||
> ⚠️ 强烈建议:可迁移化部署时只需编辑根目录的 `config-bianliang.env`
|
||||
>
|
||||
> - 将你的域名、内网地址、图片域名白名单、CORS 白名单、钉钉回调、对象存储等统一填在该文件中
|
||||
> - 系统启动会自动读取该文件:仅在变量“未设置”时注入,不会覆盖系统环境或 `.env/.env.local`
|
||||
> - 修改后重启服务生效(重启 Node 或对应容器)
|
||||
> - 前端可读变量以 `NEXT_PUBLIC_` 开头,服务端变量无需此前缀
|
||||
|
||||
## 🎯 项目目标
|
||||
|
||||
**让其他公司能够一键部署或一条命令从Docker部署到自己的NAS或服务器上**
|
||||
|
||||
### 核心设计原则
|
||||
- **性能开销优先** - 最小化资源消耗,确保在低配置设备上也能稳定运行
|
||||
- **稳定性优先** - 确保长期稳定运行,减少崩溃和错误
|
||||
- **易于部署** - 零配置或最小配置即可运行
|
||||
|
||||
详细规范请参考:[架构设计与编码规范](./架构设计与编码规范.md)
|
||||
|
||||
## 一键式可迁移配置(推荐)
|
||||
|
||||
不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。
|
||||
系统在启动时会自动读取该文件,只有当某个变量"未设置"时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。
|
||||
|
||||
适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。
|
||||
|
||||
**快速部署指南**:[一键部署快速指南](./一键部署快速指南.md)
|
||||
|
||||
包含的关键项(全部已带中文注释):
|
||||
- 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`
|
||||
- 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`
|
||||
- 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`
|
||||
- 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`
|
||||
- CORS 白名单:`ALLOWED_ORIGINS`
|
||||
- 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`
|
||||
|
||||
提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS
|
||||
- **认证**: NextAuth.js
|
||||
- **数据库**: PostgreSQL + Prisma ORM
|
||||
- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 用户注册/登录系统
|
||||
- ✅ 素材上传(预览图 + 压缩包)
|
||||
- ✅ 素材浏览与下载
|
||||
- ✅ 管理员后台管理
|
||||
- ✅ 用户管理
|
||||
- ✅ 苹果风格UI设计
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env` 并按需填写;部署时可结合 `config-bianliang.env` 管理差异化配置。
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
- 如需在同一存储桶隔离测试与生产数据,可以设置 `S3_ENV_PREFIX`(例如 `testing/`),系统会自动在上传路径前添加该命名空间。
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 生成 Prisma Client
|
||||
npm run prisma:generate
|
||||
|
||||
# 推送数据库模式
|
||||
npm run prisma:push
|
||||
```
|
||||
|
||||
### 4. 创建管理员账号
|
||||
|
||||
首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。
|
||||
|
||||
```sql
|
||||
UPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';
|
||||
```
|
||||
|
||||
### 4. 快速自检(可选)
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
### 5. 启动开发服务器(Node 环境)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000
|
||||
|
||||
## 🚀 部署到服务器
|
||||
|
||||
### 安全部署流程
|
||||
|
||||
**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。
|
||||
|
||||
> 可移植生产部署请优先参考:
|
||||
> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)
|
||||
|
||||
**快速部署:**
|
||||
|
||||
1. **本地测试**
|
||||
```bash
|
||||
npm run dev
|
||||
# 测试所有功能正常
|
||||
```
|
||||
|
||||
2. **部署前检查**
|
||||
```bash
|
||||
# Windows
|
||||
.\部署前检查清单.bat
|
||||
```
|
||||
|
||||
3. **执行部署**
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
.\scripts\deploy\安全部署.ps1
|
||||
|
||||
# Linux/Mac
|
||||
bash scripts/deploy/安全部署.sh
|
||||
```
|
||||
|
||||
**详细部署指南:**
|
||||
- [安全部署流程](./docs/guides/安全部署流程.md)
|
||||
- [安全部署说明](./docs/guides/安全部署说明.md)
|
||||
- [安全部署指南](./docs/guides/安全部署指南.md)
|
||||
|
||||
**部署检查清单:**
|
||||
- [部署前检查清单](./部署前检查清单.bat)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
gongxiangp2/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── api/ # API 路由
|
||||
│ │ ├── auth/ # 认证相关
|
||||
│ │ ├── materials/ # 素材相关
|
||||
│ │ └── admin/ # 管理员相关
|
||||
│ ├── login/ # 登录页面
|
||||
│ ├── register/ # 注册页面
|
||||
│ ├── admin/ # 管理员页面
|
||||
│ └── page.tsx # 首页
|
||||
├── components/ # React 组件
|
||||
│ ├── Header.tsx
|
||||
│ └── UploadModal.tsx
|
||||
├── lib/ # 工具库
|
||||
│ ├── prisma.ts # Prisma 客户端
|
||||
│ ├── auth.ts # NextAuth 配置
|
||||
│ └── s3.ts # S3 存储服务
|
||||
├── prisma/ # Prisma 配置
|
||||
│ └── schema.prisma # 数据库模式
|
||||
└── uploads/ # 本地上传文件存储
|
||||
```
|
||||
|
||||
## 数据库模型
|
||||
|
||||
### User (用户)
|
||||
- id: UUID
|
||||
- email: 邮箱
|
||||
- username: 用户名
|
||||
- password: 密码 (加密)
|
||||
- role: 角色 (USER/ADMIN)
|
||||
|
||||
### Material (素材)
|
||||
- id: UUID
|
||||
- previewUrl: 预览图URL (S3)
|
||||
- productModel: 商品型号
|
||||
- sceneName: 场景名称
|
||||
- authorName: 作者花名
|
||||
- zipFileName: 压缩包文件名
|
||||
- zipFilePath: 压缩包本地路径
|
||||
- userId: 用户ID (外键)
|
||||
|
||||
## 部署
|
||||
|
||||
### 开发环境(Docker)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
### 生产环境构建
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
确保在生产环境中设置所有必要的环境变量,特别是:
|
||||
- `NEXTAUTH_SECRET`: 使用强随机字符串
|
||||
- `NEXTAUTH_URL`: 设置为生产域名
|
||||
- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
50
VERSION.txt
Normal file
50
VERSION.txt
Normal file
@ -0,0 +1,50 @@
|
||||
由壹点3D素材管理系统
|
||||
版本历史
|
||||
|
||||
============================================
|
||||
Version 1.0.0 (2024-10-26)
|
||||
============================================
|
||||
|
||||
初始版本发布
|
||||
|
||||
主要功能:
|
||||
✅ 用户注册/登录系统
|
||||
✅ 3D素材上传(支持多种格式)
|
||||
✅ 素材浏览和搜索
|
||||
✅ 多维度筛选(店铺、场景、月份)
|
||||
✅ 素材下载(带进度显示)
|
||||
✅ 点赞和分享功能
|
||||
✅ 管理员后台
|
||||
✅ 用户权限管理
|
||||
✅ 系统设置
|
||||
✅ 数据统计和排行榜
|
||||
✅ MinIO 对象存储集成
|
||||
✅ PostgreSQL 数据库
|
||||
✅ Docker 一键部署
|
||||
|
||||
技术栈:
|
||||
- Next.js 14
|
||||
- React 18
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- PostgreSQL 15
|
||||
- MinIO
|
||||
- Prisma ORM
|
||||
- NextAuth.js
|
||||
|
||||
部署方式:
|
||||
- Docker Compose
|
||||
- 完全私有化部署
|
||||
- 包含数据库和对象存储
|
||||
- 一键安装脚本
|
||||
|
||||
============================================
|
||||
|
||||
未来计划:
|
||||
- [ ] 批量上传功能
|
||||
- [ ] 在线3D预览
|
||||
- [ ] 移动端适配
|
||||
- [ ] API接口开放
|
||||
- [ ] 更多素材类型支持
|
||||
- [ ] 协作功能增强
|
||||
|
||||
1329
app/admin/_components/TemplateManager.tsx
Normal file
1329
app/admin/_components/TemplateManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
440
app/admin/monitor/page.tsx
Normal file
440
app/admin/monitor/page.tsx
Normal file
@ -0,0 +1,440 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface DashboardData {
|
||||
overview: {
|
||||
totalUsers: number
|
||||
activeUsers: number
|
||||
totalMaterials: number
|
||||
newUsersThisMonth: number
|
||||
newMaterialsThisMonth: number
|
||||
}
|
||||
trends: {
|
||||
userGrowth: Array<{ date: string; count: number }>
|
||||
materialUploads: Array<{ date: string; count: number }>
|
||||
}
|
||||
distribution: {
|
||||
departments: Array<{ department: string; count: number }>
|
||||
roles: Array<{ role: string; isSuperAdmin: boolean; count: number }>
|
||||
}
|
||||
systemInfo: {
|
||||
registrationEnabled: boolean
|
||||
userActivationRequired: boolean
|
||||
siteName: string
|
||||
}
|
||||
recentActivity: Array<{
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
uploadedBy: {
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
}>
|
||||
status: {
|
||||
database: string
|
||||
timestamp: string
|
||||
uptime: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function SystemMonitor() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshInterval, setRefreshInterval] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return
|
||||
|
||||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||||
if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN' && !isSuperAdmin) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
}, [session, status, router])
|
||||
|
||||
// 获取仪表板数据
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const response = await fetch('/api/admin/system-status', {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setDashboardData(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err)
|
||||
setError(err instanceof Error ? err.message : '获取数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据加载
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchDashboardData()
|
||||
}
|
||||
}, [session])
|
||||
|
||||
// 自动刷新(每30秒)
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
const interval = setInterval(fetchDashboardData, 30000)
|
||||
setRefreshInterval(interval)
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}
|
||||
}, [session])
|
||||
|
||||
// 手动刷新
|
||||
const handleRefresh = () => {
|
||||
setLoading(true)
|
||||
fetchDashboardData()
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间戳
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 计算增长百分比
|
||||
const calculateGrowth = (current: number, previous: number) => {
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
if (status === 'loading' || loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">加载系统监控数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<strong className="font-bold">错误:</strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!dashboardData) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-gray-600">暂无数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { overview, trends, distribution, systemInfo, recentActivity, status: systemStatus } = dashboardData
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<div className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">📊 系统监控仪表板</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{systemInfo.siteName} - 实时系统状态监控
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white font-medium py-2 px-4 rounded-md"
|
||||
>
|
||||
{loading ? '刷新中...' : '🔄 刷新'}
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">
|
||||
最后更新:{formatTimestamp(systemStatus.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 系统概览卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-medium">👥</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">总用户数</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{overview.totalUsers}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-medium">⚡</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">活跃用户</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{overview.activeUsers}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-medium">📦</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">总素材数</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{overview.totalMaterials}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-medium">🆕</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">本月新用户</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{overview.newUsersThisMonth}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-medium">📈</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">本月新素材</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{overview.newMaterialsThisMonth}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* 用户增长趋势 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">📈 用户增长趋势(最近30天)</h3>
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{trends.userGrowth.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600">{formatDate(item.date)}</span>
|
||||
<div className="flex items-center">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2 mr-3">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.userGrowth.map(d => d.count))) * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材上传趋势 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">📊 素材上传趋势(最近30天)</h3>
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{trends.materialUploads.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600">{formatDate(item.date)}</span>
|
||||
<div className="flex items-center">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2 mr-3">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full"
|
||||
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.materialUploads.map(d => d.count))) * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||
{/* 部门分布 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">🏢 部门分布</h3>
|
||||
<div className="space-y-3">
|
||||
{distribution.departments.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">{item.department || '未设置'}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 权限分布 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">🛡️ 权限分布</h3>
|
||||
<div className="space-y-3">
|
||||
{distribution.roles.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">
|
||||
{item.role === 'ADMIN' && item.isSuperAdmin ? '超级管理员' :
|
||||
item.role === 'ADMIN' ? '管理员' : '普通用户'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统信息 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">⚙️ 系统信息</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">注册功能</span>
|
||||
<span className={`text-sm font-medium ${systemInfo.registrationEnabled ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{systemInfo.registrationEnabled ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">用户激活</span>
|
||||
<span className={`text-sm font-medium ${systemInfo.userActivationRequired ? 'text-yellow-600' : 'text-green-600'}`}>
|
||||
{systemInfo.userActivationRequired ? '需要' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">数据库状态</span>
|
||||
<span className={`text-sm font-medium ${systemStatus.database === 'healthy' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{systemStatus.database === 'healthy' ? '正常' : '异常'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">运行时间</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{Math.floor(systemStatus.uptime / 3600)}小时
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">🕒 最近活动</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
素材标题
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
上传者
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
上传时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{recentActivity.map((activity) => (
|
||||
<tr key={activity.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{activity.title}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{activity.uploadedBy.username}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatTimestamp(activity.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3788
app/admin/page.tsx
Normal file
3788
app/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
824
app/admin/settings/page.tsx
Normal file
824
app/admin/settings/page.tsx
Normal file
@ -0,0 +1,824 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
export default function SystemSettings() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
// 设置状态
|
||||
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
|
||||
const [siteName, setSiteName] = useState('由壹点3D素材')
|
||||
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
|
||||
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
|
||||
const [loginLogoUrl, setLoginLogoUrl] = useState('')
|
||||
const [loginLogoPreview, setLoginLogoPreview] = useState('')
|
||||
const [uploadingHeader, setUploadingHeader] = useState(false)
|
||||
const [uploadingLogin, setUploadingLogin] = useState(false)
|
||||
|
||||
// S3配置状态
|
||||
const [s3Endpoint, setS3Endpoint] = useState('')
|
||||
const [s3AccessKey, setS3AccessKey] = useState('')
|
||||
const [s3SecretKey, setS3SecretKey] = useState('')
|
||||
const [s3Bucket, setS3Bucket] = useState('')
|
||||
const [s3Region, setS3Region] = useState('us-east-1')
|
||||
const [s3PublicUrl, setS3PublicUrl] = useState('')
|
||||
|
||||
// 功能开关状态
|
||||
const [enableUserActivation, setEnableUserActivation] = useState(false)
|
||||
const [enableRegistration, setEnableRegistration] = useState(true)
|
||||
const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)
|
||||
const [enableTunnelService, setEnableTunnelService] = useState(false)
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/login')
|
||||
} else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {
|
||||
router.push('/')
|
||||
}
|
||||
}, [status, session, router])
|
||||
|
||||
// 加载设置
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated' && session?.user?.role === 'ADMIN') {
|
||||
fetchSettings()
|
||||
}
|
||||
}, [status, session])
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system-settings')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setHeaderBgColor(data.headerBgColor || '#1f2937')
|
||||
setSiteName(data.siteName || '由壹点3D素材')
|
||||
setHeaderLogoUrl(data.headerLogoUrl || '')
|
||||
setHeaderLogoPreview(data.headerLogoUrl || '')
|
||||
setLoginLogoUrl(data.loginLogoUrl || '')
|
||||
setLoginLogoPreview(data.loginLogoUrl || '')
|
||||
setS3Endpoint(data.s3Endpoint || '')
|
||||
setS3AccessKey(data.s3AccessKey || '')
|
||||
setS3SecretKey(data.s3SecretKey || '')
|
||||
setS3Bucket(data.s3Bucket || '')
|
||||
setS3Region(data.s3Region || 'us-east-1')
|
||||
setS3PublicUrl(data.s3PublicUrl || '')
|
||||
setEnableUserActivation(data.enableUserActivation ?? false)
|
||||
setEnableRegistration(data.enableRegistration ?? true)
|
||||
setEnableAiImageEdit(data.enableAiImageEdit ?? false)
|
||||
setEnableTunnelService(data.enableTunnelService ?? false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error)
|
||||
alert('加载设置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch('/api/system-settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
headerBgColor,
|
||||
siteName,
|
||||
headerLogoUrl,
|
||||
loginLogoUrl,
|
||||
s3Endpoint,
|
||||
s3AccessKey,
|
||||
s3SecretKey,
|
||||
s3Bucket,
|
||||
s3Region,
|
||||
s3PublicUrl,
|
||||
enableUserActivation,
|
||||
enableRegistration,
|
||||
enableAiImageEdit,
|
||||
enableTunnelService
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
alert('保存成功!页面将自动刷新以应用新设置')
|
||||
// 刷新页面以应用新设置
|
||||
window.location.reload()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
alert(error.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save settings error:', error)
|
||||
alert('保存失败,请稍后重试')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHeaderLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小(最大5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('LOGO文件大小不能超过5MB')
|
||||
return
|
||||
}
|
||||
|
||||
setUploadingHeader(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('logo', file)
|
||||
formData.append('type', 'header')
|
||||
|
||||
const response = await fetch('/api/system-settings/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setHeaderLogoUrl(data.logoUrl)
|
||||
setHeaderLogoPreview(data.logoUrl)
|
||||
alert('导航栏LOGO上传成功!')
|
||||
} else {
|
||||
const error = await response.json()
|
||||
alert(error.error || 'LOGO上传失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload logo error:', error)
|
||||
alert('上传失败,请稍后重试')
|
||||
} finally {
|
||||
setUploadingHeader(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小(最大5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('LOGO文件大小不能超过5MB')
|
||||
return
|
||||
}
|
||||
|
||||
setUploadingLogin(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('logo', file)
|
||||
formData.append('type', 'login')
|
||||
|
||||
const response = await fetch('/api/system-settings/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setLoginLogoUrl(data.logoUrl)
|
||||
setLoginLogoPreview(data.logoUrl)
|
||||
alert('登录页LOGO上传成功!')
|
||||
} else {
|
||||
const error = await response.json()
|
||||
alert(error.error || 'LOGO上传失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload logo error:', error)
|
||||
alert('上传失败,请稍后重试')
|
||||
} finally {
|
||||
setUploadingLogin(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleColorReset = () => {
|
||||
setHeaderBgColor('#1f2937')
|
||||
}
|
||||
|
||||
const handleHeaderLogoReset = () => {
|
||||
const def = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''
|
||||
setHeaderLogoPreview(def)
|
||||
setHeaderLogoUrl(def)
|
||||
}
|
||||
|
||||
const handleLoginLogoReset = () => {
|
||||
const def = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
|
||||
setLoginLogoPreview(def)
|
||||
setLoginLogoUrl(def)
|
||||
}
|
||||
|
||||
if (status === 'loading' || loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center dark:bg-gray-900">
|
||||
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header session={session} />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">系统设置</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">自定义网站外观和品牌信息</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8">
|
||||
{/* 网站名称设置 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
网站名称
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
站点名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder="由壹点3D素材"
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
该名称将显示在顶部导航栏和登录页面
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航栏LOGO设置 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
导航栏LOGO(深色背景)
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{/* LOGO预览 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
当前LOGO预览
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden" style={{ backgroundColor: headerBgColor }}>
|
||||
{headerLogoPreview ? (
|
||||
<img src={headerLogoPreview} alt="导航栏LOGO" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
用于深色导航栏,建议使用浅色LOGO
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
建议尺寸:200x200px,支持PNG、JPG、SVG格式
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
文件大小不超过5MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传按钮 */}
|
||||
<div className="flex gap-3">
|
||||
<label className="btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50">
|
||||
{uploadingHeader ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
上传导航栏LOGO
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleHeaderLogoUpload}
|
||||
disabled={uploadingHeader}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleHeaderLogoReset}
|
||||
className="btn-secondary"
|
||||
disabled={uploadingHeader}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录页LOGO设置 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
登录页LOGO(浅色背景)
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{/* LOGO预览 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
当前LOGO预览
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden bg-white">
|
||||
{loginLogoPreview ? (
|
||||
<img src={loginLogoPreview} alt="登录页LOGO" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
用于浅色登录页面,建议使用深色LOGO
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
建议尺寸:200x200px,支持PNG、JPG、SVG格式
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
文件大小不超过5MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传按钮 */}
|
||||
<div className="flex gap-3">
|
||||
<label className="btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50">
|
||||
{uploadingLogin ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
上传登录页LOGO
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLoginLogoUpload}
|
||||
disabled={uploadingLogin}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleLoginLogoReset}
|
||||
className="btn-secondary"
|
||||
disabled={uploadingLogin}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主题颜色设置 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
主题颜色
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{/* 颜色预览 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
颜色预览
|
||||
</label>
|
||||
<div
|
||||
className="w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner"
|
||||
style={{ backgroundColor: headerBgColor }}
|
||||
>
|
||||
<div className="flex items-center h-full px-4">
|
||||
<span className="text-white font-medium" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>
|
||||
顶部导航栏预览
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 颜色选择器 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
选择颜色
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={headerBgColor}
|
||||
onChange={(e) => setHeaderBgColor(e.target.value)}
|
||||
className="w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={headerBgColor}
|
||||
onChange={(e) => setHeaderBgColor(e.target.value)}
|
||||
placeholder="#1f2937"
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0">
|
||||
操作
|
||||
</label>
|
||||
<button
|
||||
onClick={handleColorReset}
|
||||
className="btn-secondary whitespace-nowrap"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预设颜色 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
快速选择
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ name: '默认灰', color: '#1f2937' },
|
||||
{ name: '深蓝', color: '#1e3a8a' },
|
||||
{ name: '靛蓝', color: '#4f46e5' },
|
||||
{ name: '紫色', color: '#7c3aed' },
|
||||
{ name: '粉色', color: '#db2777' },
|
||||
{ name: '红色', color: '#dc2626' },
|
||||
{ name: '橙色', color: '#ea580c' },
|
||||
{ name: '绿色', color: '#059669' },
|
||||
{ name: '青色', color: '#0891b2' },
|
||||
{ name: '深灰', color: '#374151' },
|
||||
].map((preset) => (
|
||||
<button
|
||||
key={preset.color}
|
||||
onClick={() => setHeaderBgColor(preset.color)}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all ${
|
||||
headerBgColor === preset.color
|
||||
? 'border-blue-500 ring-2 ring-blue-200'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
style={{ backgroundColor: preset.color }}
|
||||
title={preset.color}
|
||||
>
|
||||
<span className="text-white text-sm font-medium" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{preset.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => router.push('/admin')}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary text-lg px-8 py-3 disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
保存设置
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<p className="font-medium mb-1">温馨提示:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>修改设置后点击“保存设置”按钮,页面会自动刷新以应用新的设置</li>
|
||||
<li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>
|
||||
<li>建议使用高质量的LOGO图片以获得最佳显示效果</li>
|
||||
<li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能开关 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
功能开关
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{/* 用户激活功能开关 */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
用户激活功能
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
开启后,新注册的用户需要管理员激活才能登录。默认关闭。
|
||||
</p>
|
||||
</div>
|
||||
<label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableUserActivation}
|
||||
onChange={(e) => setEnableUserActivation(e.target.checked)}
|
||||
disabled={!(session?.user as any)?.isSuperAdmin}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
{!(session?.user as any)?.isSuperAdmin && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
仅最高权限账号可修改此开关
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 用户注册功能开关 */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
用户手动注册功能
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
关闭后,用户将无法通过注册页面手动注册。默认开启。
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableRegistration}
|
||||
onChange={(e) => setEnableRegistration(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* AI改图功能 */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
AI改图功能(预留)
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
控制素材详情页的 AI 改图按钮显示。功能目前为占位,启用后将连通正式 API。
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
className={`relative inline-flex items-center ${
|
||||
!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableAiImageEdit}
|
||||
onChange={(e) => setEnableAiImageEdit(e.target.checked)}
|
||||
disabled={!(session?.user as any)?.isSuperAdmin}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 内网穿透功能 */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
内网穿透功能(付费)
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
开启后可在后台调度付费内网穿透服务,目前仅展示占位操作。请确认购买服务后再启用。
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
className={`relative inline-flex items-center ${
|
||||
!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableTunnelService}
|
||||
onChange={(e) => setEnableTunnelService(e.target.checked)}
|
||||
disabled={!(session?.user as any)?.isSuperAdmin}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!(session?.user as any)?.isSuperAdmin && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
最高权限账号才可修改 AI/内网穿透等受保护功能开关
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* S3存储桶配置 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
S3存储桶配置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
配置S3对象存储,用于存储上传的图片和文件
|
||||
</p>
|
||||
|
||||
{/* S3端点 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
S3端点 (Endpoint)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={s3Endpoint}
|
||||
onChange={(e) => setS3Endpoint(e.target.value)}
|
||||
placeholder="例如: s3.1080p.hk"
|
||||
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Access Key & Secret Key */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Access Key (访问密钥)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={s3AccessKey}
|
||||
onChange={(e) => setS3AccessKey(e.target.value)}
|
||||
placeholder="输入Access Key"
|
||||
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Secret Key (密钥)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={s3SecretKey}
|
||||
onChange={(e) => setS3SecretKey(e.target.value)}
|
||||
placeholder="输入Secret Key"
|
||||
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bucket & Region */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
存储桶名称 (Bucket)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={s3Bucket}
|
||||
onChange={(e) => setS3Bucket(e.target.value)}
|
||||
placeholder="例如: my-bucket"
|
||||
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
区域 (Region)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={s3Region}
|
||||
onChange={(e) => setS3Region(e.target.value)}
|
||||
placeholder="例如: us-east-1"
|
||||
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 公网访问URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
公网访问URL (Public URL)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={s3PublicUrl}
|
||||
onChange={(e) => setS3PublicUrl(e.target.value)}
|
||||
placeholder="例如: https://cdn.example.com"
|
||||
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
如果配置了CDN或自定义域名,请填写此项
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<p className="font-semibold mb-2">配置说明:</p>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>S3端点通常是您的对象存储服务器地址</li>
|
||||
<li>Access Key和Secret Key用于身份验证</li>
|
||||
<li>存储桶需要预先创建并设置公开读取权限</li>
|
||||
<li>修改配置后需要重启应用才能生效</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
222
app/api/admin/system-status/route.ts
Normal file
222
app/api/admin/system-status/route.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 获取会话
|
||||
let session
|
||||
try {
|
||||
session = await getServerSession(authOptions)
|
||||
} catch (sessionError) {
|
||||
console.error('[SystemStatus] Session error:', sessionError)
|
||||
return NextResponse.json(
|
||||
{ error: '会话获取失败', message: sessionError instanceof Error ? sessionError.message : '未知错误' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||||
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限访问' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 并行获取各项统计数据(优化:减少查询次数)
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
let userStats: { _count: { id: number } }
|
||||
let materialStats: { _count: { id: number } }
|
||||
let systemInfo: { enableRegistration: boolean; enableUserActivation: boolean; siteName: string } | null
|
||||
let recentActivity: Array<{ id: string; productModel: string | null; createdAt: Date; user: { username: string; email: string } | null }>
|
||||
let totalUsers: number
|
||||
let totalMaterials: number
|
||||
let activeUsers: number
|
||||
let departmentStats: Array<{ department: string | null; _count: { id: number } }>
|
||||
let roleDistribution: Array<{ role: string; isSuperAdmin: boolean | null; _count: { id: number } }>
|
||||
|
||||
try {
|
||||
[
|
||||
userStats,
|
||||
materialStats,
|
||||
systemInfo,
|
||||
recentActivity,
|
||||
totalUsers,
|
||||
totalMaterials,
|
||||
activeUsers,
|
||||
departmentStats,
|
||||
roleDistribution
|
||||
] = await Promise.all([
|
||||
// 用户统计(最近30天)
|
||||
prisma.user.aggregate({
|
||||
_count: { id: true },
|
||||
where: { createdAt: { gte: thirtyDaysAgo } }
|
||||
}),
|
||||
|
||||
// 素材统计(最近30天)
|
||||
prisma.material.aggregate({
|
||||
_count: { id: true },
|
||||
where: { createdAt: { gte: thirtyDaysAgo } }
|
||||
}),
|
||||
|
||||
// 系统设置(使用缓存,减少数据库查询)
|
||||
prisma.systemSettings.findFirst({
|
||||
select: {
|
||||
enableRegistration: true,
|
||||
enableUserActivation: true,
|
||||
siteName: true,
|
||||
}
|
||||
}),
|
||||
|
||||
// 最近活动(限制为10条,减少数据传输)
|
||||
prisma.material.findMany({
|
||||
take: 10, // 减少到10条
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
productModel: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { username: true, email: true }
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 总用户数
|
||||
prisma.user.count(),
|
||||
|
||||
// 总素材数
|
||||
prisma.material.count(),
|
||||
|
||||
// 活跃用户(最近7天)
|
||||
prisma.user.count({
|
||||
where: {
|
||||
updatedAt: { gte: sevenDaysAgo }
|
||||
}
|
||||
}),
|
||||
|
||||
// 部门统计
|
||||
prisma.user.groupBy({
|
||||
by: ['department'],
|
||||
_count: { id: true },
|
||||
where: {
|
||||
department: { not: '' }
|
||||
}
|
||||
}),
|
||||
|
||||
// 权限分布统计
|
||||
prisma.user.groupBy({
|
||||
by: ['role', 'isSuperAdmin'],
|
||||
_count: { id: true }
|
||||
})
|
||||
])
|
||||
} catch (dbError) {
|
||||
console.error('[SystemStatus] Database query error:', dbError)
|
||||
// 如果数据库查询失败,返回默认值
|
||||
userStats = { _count: { id: 0 } }
|
||||
materialStats = { _count: { id: 0 } }
|
||||
systemInfo = null
|
||||
recentActivity = []
|
||||
totalUsers = 0
|
||||
totalMaterials = 0
|
||||
activeUsers = 0
|
||||
departmentStats = []
|
||||
roleDistribution = []
|
||||
}
|
||||
|
||||
// 计算素材上传趋势(按月统计,最近12个月)
|
||||
const twelveMonthsAgo = new Date()
|
||||
twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 12)
|
||||
|
||||
let materialTrend: Array<{ date: Date, count: bigint }> = []
|
||||
|
||||
try {
|
||||
materialTrend = await prisma.$queryRaw<Array<{ date: Date, count: bigint }>>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', created_at)::date as date,
|
||||
COUNT(*)::bigint as count
|
||||
FROM materials
|
||||
WHERE created_at >= ${twelveMonthsAgo}
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
ORDER BY date ASC
|
||||
`
|
||||
} catch (queryError) {
|
||||
console.error('[SystemStatus] Material trend query error:', queryError)
|
||||
materialTrend = []
|
||||
}
|
||||
|
||||
const statusData = {
|
||||
overview: {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
totalMaterials,
|
||||
newUsersThisMonth: userStats._count.id,
|
||||
newMaterialsThisMonth: materialStats._count.id
|
||||
},
|
||||
|
||||
trends: {
|
||||
materialUploads: materialTrend.map(item => ({
|
||||
date: item.date.toISOString().split('T')[0].substring(0, 7), // 转换为 YYYY-MM 格式
|
||||
count: Number(item.count)
|
||||
}))
|
||||
},
|
||||
|
||||
distribution: {
|
||||
departments: departmentStats.map(item => ({
|
||||
department: item.department || '未设置',
|
||||
count: item._count.id
|
||||
})),
|
||||
roles: roleDistribution.map(item => ({
|
||||
role: item.role || 'USER',
|
||||
isSuperAdmin: item.isSuperAdmin || false,
|
||||
count: item._count.id
|
||||
}))
|
||||
},
|
||||
|
||||
systemInfo: {
|
||||
registrationEnabled: systemInfo?.enableRegistration || false,
|
||||
userActivationRequired: systemInfo?.enableUserActivation || false,
|
||||
siteName: systemInfo?.siteName || '默认站点'
|
||||
},
|
||||
|
||||
recentActivity: recentActivity
|
||||
.filter(activity => activity.user) // 过滤掉没有用户的记录
|
||||
.map(activity => ({
|
||||
id: activity.id,
|
||||
title: activity.productModel || '未命名素材',
|
||||
createdAt: activity.createdAt.toISOString(),
|
||||
uploadedBy: {
|
||||
username: activity.user?.username || '未知用户',
|
||||
email: activity.user?.email || ''
|
||||
}
|
||||
})),
|
||||
|
||||
// 系统状态
|
||||
status: {
|
||||
database: 'healthy', // 可以扩展为实际数据库连接检查
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(statusData)
|
||||
} catch (error) {
|
||||
console.error('[SystemStatus] GET error:', error)
|
||||
// 输出更详细的错误信息
|
||||
if (error instanceof Error) {
|
||||
console.error('[SystemStatus] Error message:', error.message)
|
||||
console.error('[SystemStatus] Error stack:', error.stack)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取系统状态失败',
|
||||
message: error instanceof Error ? error.message : '未知错误'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
65
app/api/admin/transfer-superadmin/route.ts
Normal file
65
app/api/admin/transfer-superadmin/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
const requesterId = (session.user as any)?.id
|
||||
const requester = await prisma.user.findUnique({ where: { id: requesterId } }) as any
|
||||
if (!requester || !requester.isSuperAdmin) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可转移最高权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const targetUserId: string | undefined = body?.targetUserId
|
||||
const targetEmail: string | undefined = body?.targetEmail
|
||||
const password: string | undefined = body?.password
|
||||
|
||||
// 验证密码
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: '请输入密码以确认操作' }, { status: 400 })
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, requester.password)
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json({ error: '密码错误' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!targetUserId && !targetEmail) {
|
||||
return NextResponse.json({ error: '缺少目标用户标识(targetUserId 或 targetEmail)' }, { status: 400 })
|
||||
}
|
||||
if (targetUserId && targetUserId === requesterId) {
|
||||
return NextResponse.json({ error: '不能转移给自己' }, { status: 400 })
|
||||
}
|
||||
|
||||
const targetUser = targetUserId
|
||||
? await prisma.user.findUnique({ where: { id: targetUserId } })
|
||||
: await prisma.user.findUnique({ where: { email: targetEmail! } })
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 })
|
||||
}
|
||||
if (targetUser.id === requesterId) {
|
||||
return NextResponse.json({ error: '不能转移给自己' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 事务:转移最高权限
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({ where: { id: requesterId }, data: { isSuperAdmin: false } as any }),
|
||||
prisma.user.update({ where: { id: targetUser.id }, data: { isSuperAdmin: true } as any }),
|
||||
])
|
||||
|
||||
return NextResponse.json({ message: '最高权限已转移' })
|
||||
} catch (error) {
|
||||
console.error('Transfer super admin error:', error)
|
||||
return NextResponse.json({ error: '操作失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
212
app/api/admin/upload-templates/[id]/route.ts
Normal file
212
app/api/admin/upload-templates/[id]/route.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const selectTemplate = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
version: true,
|
||||
fieldGroups: true,
|
||||
displayConfig: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
fields: {
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
label: true,
|
||||
type: true,
|
||||
required: true,
|
||||
orderIndex: true,
|
||||
layoutWidth: true,
|
||||
validations: true,
|
||||
optionsSource: true,
|
||||
optionsStatic: true,
|
||||
allowedExtensions: true,
|
||||
visibleOnHomeFilter: true,
|
||||
isSearchable: true,
|
||||
isReadOnly: true,
|
||||
conditions: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
async function ensureAdmin() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await ensureAdmin()
|
||||
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
|
||||
const template = await prisma.uploadTemplate.findUnique({
|
||||
where: { id: params.id },
|
||||
select: selectTemplate,
|
||||
})
|
||||
|
||||
if (!template) return NextResponse.json({ error: '模板不存在' }, { status: 404 })
|
||||
|
||||
return NextResponse.json(template)
|
||||
} catch (error) {
|
||||
console.error('Get upload template error:', error)
|
||||
return NextResponse.json({ error: '获取模板失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await ensureAdmin()
|
||||
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
|
||||
const { id } = params
|
||||
const body = await req.json()
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
fieldGroups,
|
||||
displayConfig,
|
||||
fields,
|
||||
version,
|
||||
} = body || {}
|
||||
|
||||
const existing = await prisma.uploadTemplate.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, version: true },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: '模板不存在' }, { status: 404 })
|
||||
|
||||
if (version !== undefined && version !== existing.version) {
|
||||
return NextResponse.json({ error: '模板已被其他人修改,请刷新后重试' }, { status: 409 })
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (name !== undefined) updateData.name = name
|
||||
if (description !== undefined) updateData.description = description
|
||||
if (status !== undefined) updateData.status = status
|
||||
if (fieldGroups !== undefined) updateData.fieldGroups = fieldGroups
|
||||
if (displayConfig !== undefined) updateData.displayConfig = displayConfig
|
||||
updateData.version = (existing.version || 1) + 1
|
||||
|
||||
const formattedFields = Array.isArray(fields)
|
||||
? fields.map((field: any, index: number) => ({
|
||||
id: field.id ?? null,
|
||||
key: String(field.key || '').trim(),
|
||||
label: String(field.label || '').trim() || `字段${index + 1}`,
|
||||
type: String(field.type || 'text'),
|
||||
required: !!field.required,
|
||||
orderIndex: field.orderIndex ?? index,
|
||||
layoutWidth: field.layoutWidth || 'full',
|
||||
validations: field.validations ?? null,
|
||||
optionsSource: field.optionsSource ?? null,
|
||||
optionsStatic: field.optionsStatic ?? null,
|
||||
allowedExtensions: field.allowedExtensions ?? null,
|
||||
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
|
||||
isSearchable: !!field.isSearchable,
|
||||
isReadOnly: !!field.isReadOnly,
|
||||
conditions: field.conditions ?? null,
|
||||
}))
|
||||
: null
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.uploadTemplate.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (formattedFields) {
|
||||
const incomingIds = formattedFields.map((f) => f.id).filter(Boolean) as string[]
|
||||
|
||||
await tx.uploadField.deleteMany({
|
||||
where: {
|
||||
templateId: id,
|
||||
id: { notIn: incomingIds.length ? incomingIds : [''] },
|
||||
},
|
||||
})
|
||||
|
||||
for (const field of formattedFields) {
|
||||
if (field.id) {
|
||||
await tx.uploadField.update({
|
||||
where: { id: field.id },
|
||||
data: {
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
orderIndex: field.orderIndex,
|
||||
layoutWidth: field.layoutWidth,
|
||||
validations: field.validations,
|
||||
optionsSource: field.optionsSource,
|
||||
optionsStatic: field.optionsStatic,
|
||||
allowedExtensions: field.allowedExtensions,
|
||||
visibleOnHomeFilter: field.visibleOnHomeFilter,
|
||||
isSearchable: field.isSearchable,
|
||||
isReadOnly: field.isReadOnly,
|
||||
conditions: field.conditions,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await tx.uploadField.create({
|
||||
data: {
|
||||
templateId: id,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
orderIndex: field.orderIndex,
|
||||
layoutWidth: field.layoutWidth,
|
||||
validations: field.validations,
|
||||
optionsSource: field.optionsSource,
|
||||
optionsStatic: field.optionsStatic,
|
||||
allowedExtensions: field.allowedExtensions,
|
||||
visibleOnHomeFilter: field.visibleOnHomeFilter,
|
||||
isSearchable: field.isSearchable,
|
||||
isReadOnly: field.isReadOnly,
|
||||
conditions: field.conditions,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated.id
|
||||
})
|
||||
|
||||
const template = await prisma.uploadTemplate.findUnique({
|
||||
where: { id: result },
|
||||
select: selectTemplate,
|
||||
})
|
||||
|
||||
return NextResponse.json(template)
|
||||
} catch (error) {
|
||||
console.error('Update upload template error:', error)
|
||||
return NextResponse.json({ error: '更新模板失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await ensureAdmin()
|
||||
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
|
||||
await prisma.uploadTemplate.delete({ where: { id: params.id } })
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Delete upload template error:', error)
|
||||
return NextResponse.json({ error: '删除模板失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
120
app/api/admin/upload-templates/route.ts
Normal file
120
app/api/admin/upload-templates/route.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const baseSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
version: true,
|
||||
fieldGroups: true,
|
||||
displayConfig: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
fields: {
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
label: true,
|
||||
type: true,
|
||||
required: true,
|
||||
orderIndex: true,
|
||||
layoutWidth: true,
|
||||
validations: true,
|
||||
optionsSource: true,
|
||||
optionsStatic: true,
|
||||
allowedExtensions: true,
|
||||
visibleOnHomeFilter: true,
|
||||
isSearchable: true,
|
||||
isReadOnly: true,
|
||||
conditions: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const templates = await prisma.uploadTemplate.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: baseSelect,
|
||||
})
|
||||
|
||||
return NextResponse.json(templates)
|
||||
} catch (error) {
|
||||
console.error('List upload templates error:', error)
|
||||
return NextResponse.json({ error: '获取上传模板失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
status = 'draft',
|
||||
fieldGroups,
|
||||
displayConfig,
|
||||
fields = [],
|
||||
} = body || {}
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: '模板名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const formattedFields = Array.isArray(fields)
|
||||
? fields.map((field: any, index: number) => ({
|
||||
key: String(field.key || '').trim(),
|
||||
label: String(field.label || '').trim() || `字段${index + 1}`,
|
||||
type: String(field.type || 'text'),
|
||||
required: !!field.required,
|
||||
orderIndex: field.orderIndex ?? index,
|
||||
layoutWidth: field.layoutWidth || 'full',
|
||||
validations: field.validations ?? null,
|
||||
optionsSource: field.optionsSource ?? null,
|
||||
optionsStatic: field.optionsStatic ?? null,
|
||||
allowedExtensions: field.allowedExtensions ?? null,
|
||||
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
|
||||
isSearchable: !!field.isSearchable,
|
||||
isReadOnly: !!field.isReadOnly,
|
||||
conditions: field.conditions ?? null,
|
||||
}))
|
||||
: []
|
||||
|
||||
const template = await prisma.uploadTemplate.create({
|
||||
data: {
|
||||
name,
|
||||
description: description ?? null,
|
||||
status,
|
||||
fieldGroups: fieldGroups ?? null,
|
||||
displayConfig: displayConfig ?? null,
|
||||
fields: formattedFields.length
|
||||
? { createMany: { data: formattedFields } }
|
||||
: undefined,
|
||||
},
|
||||
select: baseSelect,
|
||||
})
|
||||
|
||||
return NextResponse.json(template, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create upload template error:', error)
|
||||
return NextResponse.json({ error: '创建上传模板失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
app/api/admin/users/route.ts
Normal file
114
app/api/admin/users/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// Get all users (admin only) - 支持分页
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1', 10)
|
||||
const pageSize = Math.min(parseInt(searchParams.get('pageSize') || '20', 10), 100) // 最大100条
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
// 并行查询总数和数据
|
||||
const [total, users] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
parentId: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
canDownload: true,
|
||||
department: true,
|
||||
managedDepartment: true,
|
||||
isSuperAdmin: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
materials: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc', // 按创建时间升序,第一个用户就是最高权限账号
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
})
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
users,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[AdminUsers] Get error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取用户列表失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user (admin only)
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少用户ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Don't allow deleting self
|
||||
if (id === (session.user as any).id) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能删除自己' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: '删除成功' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '删除失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
105
app/api/admin/users/set-department-manager/route.ts
Normal file
105
app/api/admin/users/set-department-manager/route.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId, departmentName } = await req.json()
|
||||
|
||||
if (!userId || !departmentName) {
|
||||
return NextResponse.json({ error: '用户ID和部门名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 检查该部门是否已经有管理员
|
||||
const existingManager = await prisma.user.findFirst({
|
||||
where: {
|
||||
managedDepartment: departmentName,
|
||||
NOT: { id: userId }
|
||||
}
|
||||
})
|
||||
|
||||
if (existingManager) {
|
||||
return NextResponse.json({
|
||||
error: `部门"${departmentName}"已有管理员:${existingManager.username}`
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 更新用户为部门管理员
|
||||
// 同时将用户的department字段设置为该部门,确保部门管理员显示在部门列表中
|
||||
// 并将该部门下其他用户的直属上级设置为新管理员
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
// 更新用户为部门管理员
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
managedDepartment: departmentName,
|
||||
department: departmentName, // 确保部门管理员也属于该部门
|
||||
role: 'ADMIN' // 设置为管理员角色
|
||||
}
|
||||
})
|
||||
|
||||
// 将该部门下其他用户的直属上级设置为新管理员
|
||||
await tx.user.updateMany({
|
||||
where: {
|
||||
department: departmentName,
|
||||
id: { not: userId }, // 排除新管理员自己
|
||||
},
|
||||
data: {
|
||||
parentId: userId
|
||||
}
|
||||
})
|
||||
|
||||
return updatedUser
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: '部门管理员设置成功',
|
||||
user
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Set department manager error:', error)
|
||||
return NextResponse.json({ error: '设置失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 取消部门管理员
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const userId = searchParams.get('userId')
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 取消部门管理员
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
managedDepartment: null
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: '已取消部门管理员',
|
||||
user
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Remove department manager error:', error)
|
||||
return NextResponse.json({ error: '取消失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
118
app/api/admin/users/set-role/route.ts
Normal file
118
app/api/admin/users/set-role/route.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
// 检查是否为管理员
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, role } = await req.json()
|
||||
|
||||
if (!userId || !role) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证角色值
|
||||
if (role !== 'USER' && role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '角色值无效' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前登录用户信息
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
select: { id: true, email: true, managedDepartment: true }
|
||||
})
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '当前用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前用户的完整信息(包括 isSuperAdmin)
|
||||
const currentUserFull = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
}) as any
|
||||
|
||||
// 检查目标用户是否存在
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, department: true }
|
||||
})
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取目标用户的完整信息(包括 isSuperAdmin)
|
||||
const targetUserFull = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
}) as any
|
||||
|
||||
// 不能修改自己的角色
|
||||
if (targetUser.email === session.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能修改自己的角色' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 权限检查:只有最高权限账号可以修改角色
|
||||
if (!currentUserFull?.isSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足:只有最高权限账号可以修改用户角色' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 最高权限账号不能被修改角色
|
||||
if (targetUserFull?.isSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能修改最高权限账号的角色' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 更新用户角色
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
username: updatedUser.username,
|
||||
role: updatedUser.role,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating user role:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '设置角色失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
127
app/api/admin/users/toggle-active/route.ts
Normal file
127
app/api/admin/users/toggle-active/route.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
|
||||
|
||||
// 激活/禁用用户
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, isActive } = await req.json()
|
||||
|
||||
if (!userId || typeof isActive !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前登录用户信息
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
select: { id: true, managedDepartment: true }
|
||||
}) as any
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '当前用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前用户的完整信息(包括 isSuperAdmin)
|
||||
const currentUserFull = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
}) as any
|
||||
|
||||
let departmentDescendantsMap: Map<string, Set<string>> | undefined
|
||||
if (currentUser.managedDepartment && !currentUserFull?.isSuperAdmin) {
|
||||
const departments = await prisma.department.findMany({ select: { name: true } })
|
||||
departmentDescendantsMap = buildDepartmentDescendants(departments)
|
||||
}
|
||||
|
||||
// 获取目标用户信息
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, department: true }
|
||||
})
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取目标用户的完整信息(包括 isSuperAdmin)
|
||||
const targetUserFull = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
}) as any
|
||||
|
||||
// 不能禁用自己
|
||||
if (userId === (session.user as any).id) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能禁用自己' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 权限检查:只有最高权限账号或目标用户所属部门的管理员可以操作
|
||||
if (!currentUserFull?.isSuperAdmin) {
|
||||
// 如果是部门管理员,只能操作自己管理的部门下的用户
|
||||
if (currentUser.managedDepartment) {
|
||||
const allowed = isDepartmentWithinScope(
|
||||
currentUser.managedDepartment,
|
||||
targetUser.department,
|
||||
departmentDescendantsMap
|
||||
)
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足:只能操作自己管理的部门树范围内的用户' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 没有管理部门的管理员不能操作任何人
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 最高权限账号不能被非最高权限账号操作
|
||||
if (targetUserFull?.isSuperAdmin && !currentUserFull?.isSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足:不能操作最高权限账号' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 更新用户激活状态
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isActive },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: isActive ? '用户已激活' : '用户已禁用',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Toggle active error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '操作失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
127
app/api/admin/users/toggle-download/route.ts
Normal file
127
app/api/admin/users/toggle-download/route.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
|
||||
|
||||
// Toggle user download permission (admin only)
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
// Check if user is admin
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { userId } = await req.json()
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少用户ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Prevent admin from disabling their own download permission
|
||||
if (userId === (session.user as any).id) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能修改自己的下载权限' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
select: { id: true, managedDepartment: true, isSuperAdmin: true },
|
||||
})
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '当前用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
let departmentDescendantsMap: Map<string, Set<string>> | undefined
|
||||
if (currentUser.managedDepartment && !currentUser.isSuperAdmin) {
|
||||
const departments = await prisma.department.findMany({ select: { name: true } })
|
||||
departmentDescendantsMap = buildDepartmentDescendants(departments)
|
||||
}
|
||||
|
||||
// Get target user
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
department: true,
|
||||
role: true,
|
||||
canDownload: true,
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Prevent disabling download for other admins
|
||||
if (targetUser.role === 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '不能修改管理员的下载权限' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (targetUser.isSuperAdmin && !currentUser.isSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能修改最高权限账号的下载权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentUser.isSuperAdmin) {
|
||||
if (!currentUser.managedDepartment) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足:未被授权管理任何分支' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
const allowed = isDepartmentWithinScope(
|
||||
currentUser.managedDepartment,
|
||||
targetUser.department,
|
||||
departmentDescendantsMap
|
||||
)
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足:只能管理自己部门树范围内的用户下载权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle download permission
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { canDownload: !targetUser.canDownload },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
message: updatedUser.canDownload ? '已开启下载权限' : '已关闭下载权限',
|
||||
canDownload: updatedUser.canDownload,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error toggling download permission:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '操作失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
53
app/api/admin/users/update-department/route.ts
Normal file
53
app/api/admin/users/update-department/route.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 更新用户部门
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, department } = await req.json()
|
||||
|
||||
console.log('[Update Department] Request:', { userId, department })
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少用户ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 使用事务更新用户和素材(如果需要同步更新素材信息)
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { department: department || '' },
|
||||
})
|
||||
|
||||
// 注意:部门信息通常不需要同步到素材,但如果将来需要,可以在这里添加
|
||||
// 目前素材表中没有部门字段,所以不需要同步
|
||||
|
||||
console.log('[Update Department] Success:', {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
department: user.department
|
||||
})
|
||||
|
||||
return NextResponse.json(user)
|
||||
} catch (error) {
|
||||
console.error('[Update Department] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新部门失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
110
app/api/admin/users/update-hierarchy/route.ts
Normal file
110
app/api/admin/users/update-hierarchy/route.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
function buildParentMap(users: Array<{ id: string; parentId: string | null }>) {
|
||||
const map = new Map<string, string | null>()
|
||||
users.forEach((u) => map.set(u.id, u.parentId))
|
||||
return map
|
||||
}
|
||||
|
||||
function isAncestor(ancestorId: string, nodeId: string, parentMap: Map<string, string | null>) {
|
||||
let current: string | null | undefined = nodeId
|
||||
while (current) {
|
||||
if (current === ancestorId) return true
|
||||
current = parentMap.get(current) ?? null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const sessionUser = session?.user as any
|
||||
if (!session || sessionUser?.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId, parentId } = await req.json()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '缺少 userId' }, { status: 400 })
|
||||
}
|
||||
if (parentId && typeof parentId !== 'string') {
|
||||
return NextResponse.json({ error: 'parentId 参数无效' }, { status: 400 })
|
||||
}
|
||||
if (userId === parentId) {
|
||||
return NextResponse.json({ error: '不能将用户设置为自己的上级' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [currentUser, targetUser] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: { id: true, isSuperAdmin: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, parentId: true, isSuperAdmin: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: '当前用户不存在' }, { status: 404 })
|
||||
}
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (targetUser.isSuperAdmin && !currentUser.isSuperAdmin) {
|
||||
return NextResponse.json({ error: '无权限调整最高权限账号' }, { status: 403 })
|
||||
}
|
||||
|
||||
let parentUser: { id: string; parentId: string | null; isSuperAdmin: boolean } | null = null
|
||||
if (parentId) {
|
||||
parentUser = await prisma.user.findUnique({
|
||||
where: { id: parentId },
|
||||
select: { id: true, parentId: true, isSuperAdmin: true },
|
||||
})
|
||||
if (!parentUser) {
|
||||
return NextResponse.json({ error: '新的上级不存在' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const allUsers = await prisma.user.findMany({
|
||||
select: { id: true, parentId: true },
|
||||
})
|
||||
const parentMap = buildParentMap(allUsers)
|
||||
|
||||
if (parentId && isAncestor(targetUser.id, parentId, parentMap)) {
|
||||
return NextResponse.json({ error: '不能将用户的下级设置为其上级,避免形成循环' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!currentUser.isSuperAdmin) {
|
||||
if (!isAncestor(currentUser.id, targetUser.id, parentMap)) {
|
||||
return NextResponse.json({ error: '只能调整自己分支内的下属' }, { status: 403 })
|
||||
}
|
||||
if (!parentId) {
|
||||
return NextResponse.json({ error: '不能将下属提升为顶层,请选择本分支内的上级' }, { status: 403 })
|
||||
}
|
||||
if (!isAncestor(currentUser.id, parentId, parentMap) && parentId !== currentUser.id) {
|
||||
return NextResponse.json({ error: '不能跨分支调整下属' }, { status: 403 })
|
||||
}
|
||||
if (parentUser?.isSuperAdmin) {
|
||||
return NextResponse.json({ error: '不能将下属挂载到最高权限账号' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { parentId: parentId ?? null },
|
||||
select: { id: true, parentId: true },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, userId: updated.id, parentId: updated.parentId })
|
||||
} catch (e) {
|
||||
console.error('Update hierarchy error:', e)
|
||||
return NextResponse.json({ error: '更新失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
153
app/api/admin/users/update/route.ts
Normal file
153
app/api/admin/users/update/route.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
|
||||
|
||||
// 统一更新用户信息,并同步更新该用户上传的素材信息
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, username, department, role, isActive, canDownload } = await req.json()
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少用户ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前登录用户信息
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
select: { id: true, managedDepartment: true }
|
||||
}) as any
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '当前用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前用户的完整信息(包括 isSuperAdmin)
|
||||
const currentUserFull = await prisma.user.findUnique({
|
||||
where: { id: (session.user as any).id },
|
||||
}) as any
|
||||
|
||||
let departmentDescendantsMap: Map<string, Set<string>> | undefined
|
||||
if (currentUser.managedDepartment && !currentUserFull?.isSuperAdmin) {
|
||||
const departments = await prisma.department.findMany({ select: { name: true } })
|
||||
departmentDescendantsMap = buildDepartmentDescendants(departments)
|
||||
}
|
||||
|
||||
// 获取更新前的用户信息
|
||||
const oldUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
department: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!oldUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取目标用户的完整信息(包括 isSuperAdmin)
|
||||
const targetUserFull = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
}) as any
|
||||
|
||||
// 权限检查:只有最高权限账号或目标用户所属部门的管理员可以编辑
|
||||
if (!currentUserFull?.isSuperAdmin) {
|
||||
// 如果是部门管理员,只能编辑自己管理的部门下的用户
|
||||
if (currentUser.managedDepartment) {
|
||||
const allowed = isDepartmentWithinScope(
|
||||
currentUser.managedDepartment,
|
||||
oldUser.department,
|
||||
departmentDescendantsMap
|
||||
)
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足:只能编辑自己管理的部门树范围内的用户' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 没有管理部门的管理员不能编辑任何人
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 最高权限账号不能被非最高权限账号编辑
|
||||
if (targetUserFull?.isSuperAdmin && !currentUserFull?.isSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足:不能编辑最高权限账号' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 构建更新数据
|
||||
const updateData: any = {}
|
||||
if (username !== undefined) updateData.username = username
|
||||
if (department !== undefined) updateData.department = department || ''
|
||||
if (role !== undefined) updateData.role = role
|
||||
if (isActive !== undefined) updateData.isActive = isActive
|
||||
if (canDownload !== undefined) updateData.canDownload = canDownload
|
||||
|
||||
// 使用事务更新用户和素材
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 更新用户信息
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
// 如果用户名发生变化,同步更新该用户上传的所有素材的authorName
|
||||
if (username !== undefined && username !== oldUser.username) {
|
||||
await tx.material.updateMany({
|
||||
where: { userId: userId },
|
||||
data: { authorName: username },
|
||||
})
|
||||
}
|
||||
|
||||
return updatedUser
|
||||
})
|
||||
|
||||
console.log('[Update User] Success:', {
|
||||
userId: result.id,
|
||||
username: result.username,
|
||||
department: result.department,
|
||||
materialsUpdated: username !== undefined && username !== oldUser.username
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: result,
|
||||
materialsUpdated: username !== undefined && username !== oldUser.username
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Update User] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新用户失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
116
app/api/ai-chat/route.ts
Normal file
116
app/api/ai-chat/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '未授权' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { messages, model } = await req.json()
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'
|
||||
const modelName = model || process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Gemini API未配置' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// 构建Gemini API请求
|
||||
const geminiMessages = messages.map((msg: any) => {
|
||||
if (typeof msg.content === 'string') {
|
||||
return {
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: msg.content }]
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
const parts = msg.content.map((part: any) => {
|
||||
if (part.type === 'text') {
|
||||
return { text: part.text }
|
||||
} else if (part.type === 'image_url') {
|
||||
// 提取base64图片数据
|
||||
const base64Match = part.image_url.url.match(/data:image\/([^;]+);base64,(.+)/)
|
||||
if (base64Match) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: `image/${base64Match[1]}`,
|
||||
data: base64Match[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean)
|
||||
|
||||
return {
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean)
|
||||
|
||||
// 调用Gemini API
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/models/${modelName}:generateContent?key=${apiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: geminiMessages,
|
||||
generationConfig: {
|
||||
temperature: 1,
|
||||
topK: 40,
|
||||
topP: 0.95,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
console.error('Gemini API error:', errorData)
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || '调用Gemini API失败' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI chat error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '处理失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint to check API configuration
|
||||
export async function GET() {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'
|
||||
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'
|
||||
|
||||
return NextResponse.json({
|
||||
configured: !!apiKey,
|
||||
apiBaseUrl,
|
||||
model
|
||||
})
|
||||
}
|
||||
|
||||
51
app/api/ai/edit/route.ts
Normal file
51
app/api/ai/edit/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { featureFlags } from '@/lib/config'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
async function ensureAiEditEnabled() {
|
||||
if (featureFlags.aiEditEnabled) return true
|
||||
const settings = await prisma.systemSettings.findFirst({
|
||||
select: { enableAiImageEdit: true },
|
||||
})
|
||||
return !!settings?.enableAiImageEdit
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
const isEnabled = await ensureAiEditEnabled()
|
||||
if (!isEnabled) {
|
||||
return NextResponse.json({ error: 'AI改图功能未启用' }, { status: 501 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { materialId, operation, params } = body || {}
|
||||
|
||||
if (!materialId || !operation) {
|
||||
return NextResponse.json({ error: '缺少必要参数(materialId/operation)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// TODO: 校验用户是否有权限访问该素材(上传者或其上级)
|
||||
// TODO: 接入外部AI服务(基于你提供的API),并以任务形式异步执行
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'AI改图任务创建成功(占位)',
|
||||
taskId: 'mock_task_id',
|
||||
materialId,
|
||||
operation,
|
||||
params: params || {},
|
||||
status: 'queued',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('AI Edit Error:', error)
|
||||
return NextResponse.json({ error: '服务异常' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
app/api/auth/[...nextauth]/route.ts
Normal file
7
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
|
||||
156
app/api/auth/register/route.ts
Normal file
156
app/api/auth/register/route.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查注册功能是否开启
|
||||
const settings = await prisma.systemSettings.findFirst()
|
||||
if (settings && !settings.enableRegistration) {
|
||||
return NextResponse.json(
|
||||
{ error: '注册功能已关闭,请联系管理员' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, username, department, password, inviteCode } = await req.json()
|
||||
|
||||
// Validation
|
||||
if (!email || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: '所有字段都是必填的' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 如果注册功能关闭,检查是否有有效的邀请码
|
||||
if (settings && !settings.enableRegistration) {
|
||||
if (!inviteCode) {
|
||||
return NextResponse.json(
|
||||
{ error: '注册功能已关闭,请使用邀请链接注册' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证邀请码
|
||||
const invitation = await prisma.invitation.findUnique({
|
||||
where: { code: inviteCode },
|
||||
})
|
||||
|
||||
if (!invitation || !invitation.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码无效或已失效' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invitation.currentUses >= invitation.maxUses) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码使用次数已用完' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invitation.expiresAt && new Date() > invitation.expiresAt) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码已过期' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email }, { username }],
|
||||
},
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '邮箱或用户名已存在' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
// 如果启用了用户激活功能,新用户默认未激活
|
||||
const isActive = settings?.enableUserActivation ? false : true
|
||||
|
||||
// 使用事务确保首位用户判断的原子性,避免并发问题
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
// 在事务中判断是否为首个注册用户(赋予最高权限)
|
||||
const totalUsers = await tx.user.count()
|
||||
const isFirstUser = totalUsers === 0
|
||||
|
||||
if (isFirstUser) {
|
||||
console.log('🎉 首位用户注册,自动授予超级管理员权限:', { email, username })
|
||||
}
|
||||
|
||||
// 如果指定了部门,查找该部门的管理员作为直属上级
|
||||
let parentId: string | null = null
|
||||
if (department && !isFirstUser) {
|
||||
const departmentManager = await tx.user.findFirst({
|
||||
where: {
|
||||
managedDepartment: department,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (departmentManager) {
|
||||
parentId = departmentManager.id
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
const newUser = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
department: department || '',
|
||||
password: hashedPassword,
|
||||
isActive,
|
||||
parentId,
|
||||
// 首位用户自动设为超级管理员,拥有最高权限
|
||||
isSuperAdmin: isFirstUser,
|
||||
role: isFirstUser ? 'ADMIN' : 'USER', // 首位用户同时设置为 ADMIN 角色
|
||||
} as any,
|
||||
})
|
||||
|
||||
// 如果使用了邀请码,更新邀请码使用记录
|
||||
if (inviteCode) {
|
||||
await tx.invitation.update({
|
||||
where: { code: inviteCode },
|
||||
data: {
|
||||
currentUses: { increment: 1 },
|
||||
usedBy: newUser.id,
|
||||
usedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return newUser
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: '注册成功', user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
department: user.department,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
}},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '注册失败,请稍后再试' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
224
app/api/categories/route.ts
Normal file
224
app/api/categories/route.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 获取所有使用店铺
|
||||
export async function GET() {
|
||||
try {
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
})
|
||||
|
||||
// 优化:使用SQL聚合查询,避免N+1问题
|
||||
const counts = await prisma.$queryRaw<Array<{
|
||||
categoryName: string
|
||||
count: bigint
|
||||
}>>`
|
||||
SELECT
|
||||
"categoryName" as "categoryName",
|
||||
COUNT(*)::bigint as count
|
||||
FROM "materials"
|
||||
WHERE "categoryName" IS NOT NULL AND "categoryName" != ''
|
||||
GROUP BY "categoryName"
|
||||
`
|
||||
|
||||
const countMap = new Map<string, number>()
|
||||
counts.forEach(item => {
|
||||
countMap.set(item.categoryName, Number(item.count))
|
||||
})
|
||||
|
||||
const categoriesWithCount = categories.map(category => ({
|
||||
...category,
|
||||
count: countMap.get(category.name) || 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json(categoriesWithCount)
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取店铺列表失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新的使用店铺
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name } = await req.json()
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: '店铺名称不能为空' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查店铺是否已存在
|
||||
const existing = await prisma.category.findUnique({
|
||||
where: { name: name.trim() },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: '该店铺已存在' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 创建新店铺
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(category, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating category:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '创建店铺失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新店铺名称
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, name, options } = await req.json()
|
||||
|
||||
if (!id || !name || !name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查新名称是否已存在(排除当前店铺)
|
||||
const existing = await prisma.category.findFirst({
|
||||
where: {
|
||||
name: name.trim(),
|
||||
NOT: { id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: '该店铺名称已存在' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取旧名称
|
||||
const oldCategory = await prisma.category.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!oldCategory) {
|
||||
return NextResponse.json(
|
||||
{ error: '分类不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 处理options:如果是数组,转换为JSON字符串;如果是字符串,直接使用
|
||||
let optionsValue: string | null = null
|
||||
if (options !== undefined && options !== null) {
|
||||
if (Array.isArray(options)) {
|
||||
optionsValue = JSON.stringify(options)
|
||||
} else if (typeof options === 'string') {
|
||||
// 支持每行一个选项的格式
|
||||
const trimmed = options.trim()
|
||||
if (trimmed && !trimmed.startsWith('[') && !trimmed.startsWith('{')) {
|
||||
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean)
|
||||
optionsValue = lines.length > 0 ? JSON.stringify(lines) : null
|
||||
} else {
|
||||
optionsValue = trimmed || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新店铺名称和选项
|
||||
const category = await prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: name.trim(),
|
||||
options: optionsValue,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步更新所有使用该分类的素材
|
||||
await prisma.material.updateMany({
|
||||
where: { categoryName: oldCategory.name },
|
||||
data: { categoryName: name.trim() },
|
||||
})
|
||||
|
||||
return NextResponse.json(category)
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除店铺
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少ID参数' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 删除店铺
|
||||
await prisma.category.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '删除失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
161
app/api/danmaku/route.ts
Normal file
161
app/api/danmaku/route.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// GET - 获取弹幕列表(支持SSE)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const isSSE = searchParams.get('sse') === 'true'
|
||||
|
||||
if (isSSE) {
|
||||
// Server-Sent Events 实时推送
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// 发送初始弹幕(限制数量,减少数据传输)
|
||||
const initialDanmakus = await prisma.danmaku.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30, // 减少初始数据量
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(initialDanmakus)}\n\n`)
|
||||
)
|
||||
|
||||
// 记录最后一条弹幕的ID,避免重复推送
|
||||
let lastDanmakuId = initialDanmakus[0]?.id || null
|
||||
|
||||
// 轮询新弹幕(优化:只查询新弹幕)
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const latestDanmaku = await prisma.danmaku.findFirst({
|
||||
where: lastDanmakuId ? {
|
||||
id: { not: lastDanmakuId }
|
||||
} : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (latestDanmaku) {
|
||||
lastDanmakuId = latestDanmaku.id
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(latestDanmaku)}\n\n`)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Danmaku] SSE error:', error)
|
||||
// 出错时关闭连接,避免资源泄漏
|
||||
clearInterval(interval)
|
||||
controller.close()
|
||||
}
|
||||
}, 2000) // 增加轮询间隔到2秒,减少数据库压力
|
||||
|
||||
// 清理
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(interval)
|
||||
controller.close()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 普通GET请求(支持分页)
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 100) // 最大100条
|
||||
const danmakus = await prisma.danmaku.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
// 不返回userId,保护隐私
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json(danmakus)
|
||||
} catch (error) {
|
||||
console.error('Get danmaku error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取弹幕失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 发送弹幕
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { content } = await request.json()
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '弹幕内容不能为空' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (content.length > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: '弹幕内容不能超过100字' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 生成随机匿名用户名
|
||||
const randomNames = [
|
||||
'神秘访客', '路过的设计师', '热心网友', '匿名用户',
|
||||
'悄悄路过', '神秘嘉宾', '隐身大佬', '低调看客',
|
||||
'默默围观', '潜水员', '匿名网友', '神秘人士'
|
||||
]
|
||||
const anonymousName = randomNames[Math.floor(Math.random() * randomNames.length)]
|
||||
|
||||
const danmaku = await prisma.danmaku.create({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
username: anonymousName, // 使用匿名名称
|
||||
userId: (session.user as any).id
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json(danmaku)
|
||||
} catch (error) {
|
||||
console.error('Send danmaku error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '发送弹幕失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
162
app/api/departments/route.ts
Normal file
162
app/api/departments/route.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// Get all departments
|
||||
export async function GET() {
|
||||
try {
|
||||
const departments = await prisma.department.findMany({
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(departments)
|
||||
} catch (error) {
|
||||
console.error('Error fetching departments:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取部门列表失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new department (admin only)
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name } = await req.json()
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
return NextResponse.json(
|
||||
{ error: '部门名称不能为空' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if department already exists
|
||||
const existing = await prisma.department.findUnique({
|
||||
where: { name },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: '部门已存在' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const department = await prisma.department.create({
|
||||
data: { name },
|
||||
})
|
||||
|
||||
return NextResponse.json(department, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating department:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '创建部门失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Rename a department (admin only)
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, name } = await req.json()
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少部门ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
return NextResponse.json(
|
||||
{ error: '部门名称不能为空' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查新名称是否被其他部门占用
|
||||
const existing = await prisma.department.findUnique({
|
||||
where: { name },
|
||||
})
|
||||
|
||||
if (existing && existing.id !== id) {
|
||||
return NextResponse.json(
|
||||
{ error: '部门名称已被使用' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await prisma.department.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
})
|
||||
|
||||
return NextResponse.json(updated)
|
||||
} catch (error) {
|
||||
console.error('Error updating department:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新部门失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a department (admin only)
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少部门ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.department.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: '删除成功' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting department:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '删除失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
65
app/api/dingtalk/complete-profile/route.ts
Normal file
65
app/api/dingtalk/complete-profile/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId, department } = await request.json()
|
||||
|
||||
if (!userId || !department) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '缺少必要参数' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('完善用户信息(选择部门):', { userId, department })
|
||||
|
||||
// 获取当前钉钉用户信息
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
})
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 直接更新部门信息(用户名已在登录时设置)
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
department,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('用户信息更新成功:', user.username)
|
||||
|
||||
// 返回用户信息(不包含敏感信息)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '信息完善成功',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
department: user.department,
|
||||
isActive: user.isActive
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('完善用户信息失败:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error.message || '信息提交失败,请稍后再试'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
363
app/api/dingtalk/login/route.ts
Normal file
363
app/api/dingtalk/login/route.ts
Normal file
@ -0,0 +1,363 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
// 钉钉应用配置
|
||||
const DINGTALK_CONFIG: Record<string, { clientId: string; clientSecret: string }> = {
|
||||
default: {
|
||||
clientId: process.env.DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f',
|
||||
clientSecret: process.env.DINGTALK_CLIENT_SECRET || 'qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户访问令牌
|
||||
async function getUserAccessToken(code: string, company: string = 'default') {
|
||||
const config = DINGTALK_CONFIG[company]
|
||||
const url = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'
|
||||
|
||||
const params = {
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
code: code,
|
||||
grantType: 'authorization_code'
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('获取用户访问令牌HTTP错误:', response.status, errorText)
|
||||
throw new Error(`获取用户访问令牌失败: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 新版API可能返回错误信息
|
||||
if (data.code && data.code !== '0') {
|
||||
console.error('获取用户访问令牌API错误:', data)
|
||||
throw new Error(`获取用户访问令牌失败: ${data.message || '未知错误'}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 获取应用访问令牌
|
||||
async function getAppAccessToken(company: string = 'default') {
|
||||
const config = DINGTALK_CONFIG[company]
|
||||
const url = 'https://oapi.dingtalk.com/gettoken'
|
||||
|
||||
const response = await fetch(`${url}?appkey=${config.clientId}&appsecret=${config.clientSecret}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取应用访问令牌失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 检查钉钉 API 返回的错误码
|
||||
if (data.errcode !== 0) {
|
||||
console.error('钉钉API错误:', data)
|
||||
throw new Error(`获取应用访问令牌失败: ${data.errmsg || '未知错误'}`)
|
||||
}
|
||||
|
||||
// 检查 access_token 是否存在
|
||||
if (!data.access_token) {
|
||||
console.error('获取access_token返回数据异常:', data)
|
||||
throw new Error('获取应用访问令牌失败: 返回数据中没有access_token')
|
||||
}
|
||||
|
||||
return data.access_token
|
||||
}
|
||||
|
||||
// 根据token获取用户unionId
|
||||
async function getUnionId(token: string) {
|
||||
const url = 'https://api.dingtalk.com/v1.0/contact/users/me'
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { 'x-acs-dingtalk-access-token': token }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取unionId失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 检查 unionId 是否存在
|
||||
if (!data.unionId) {
|
||||
console.error('获取unionId返回数据异常:', data)
|
||||
throw new Error('获取unionId失败: 返回数据中没有unionId')
|
||||
}
|
||||
|
||||
return data.unionId
|
||||
}
|
||||
|
||||
// 根据unionId获取userId
|
||||
async function getUserIdByUnionId(unionId: string, appToken: string) {
|
||||
const url = `https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${appToken}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ unionid: unionId })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('根据unionId获取userId失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 检查钉钉 API 返回的错误码
|
||||
if (data.errcode !== 0) {
|
||||
console.error('钉钉API错误:', data)
|
||||
throw new Error(`获取userId失败: ${data.errmsg || '未知错误'}`)
|
||||
}
|
||||
|
||||
// 检查 result 是否存在
|
||||
if (!data.result || !data.result.userid) {
|
||||
console.error('钉钉API返回数据异常:', data)
|
||||
throw new Error('获取userId失败: 返回数据格式错误')
|
||||
}
|
||||
|
||||
return data.result.userid
|
||||
}
|
||||
|
||||
// 根据userId获取用户详细信息
|
||||
async function getUserInfo(userId: string, appToken: string) {
|
||||
const url = `https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${appToken}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userid: userId })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 检查钉钉 API 返回的错误码
|
||||
if (data.errcode !== 0) {
|
||||
console.error('钉钉API错误:', data)
|
||||
throw new Error(`获取用户信息失败: ${data.errmsg || '未知错误'}`)
|
||||
}
|
||||
|
||||
// 检查 result 是否存在
|
||||
if (!data.result) {
|
||||
console.error('钉钉API返回数据异常:', data)
|
||||
throw new Error('获取用户信息失败: 返回数据格式错误')
|
||||
}
|
||||
|
||||
return data.result
|
||||
}
|
||||
|
||||
// 生成随机密码
|
||||
function generateRandomPassword(length: number = 12): string {
|
||||
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
|
||||
let password = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += charset.charAt(Math.floor(Math.random() * charset.length))
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
// 生成随机邮箱
|
||||
function generateRandomEmail(): string {
|
||||
const randomStr = Math.random().toString(36).substring(2, 10)
|
||||
return `dingtalk_${randomStr}@temp.com`
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, state } = await request.json()
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '缺少授权码' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// state 用于传递前端数据(如origin),不是公司标识
|
||||
// 始终使用 default 配置
|
||||
const company = 'default'
|
||||
|
||||
console.log('开始钉钉登录流程:', { code, company })
|
||||
|
||||
// 1. 获取用户访问令牌
|
||||
const tokenData = await getUserAccessToken(code, company)
|
||||
|
||||
// 检查返回的数据
|
||||
if (!tokenData || !tokenData.accessToken) {
|
||||
console.error('获取用户访问令牌返回数据异常:', tokenData)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '获取用户访问令牌失败:返回数据格式错误' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const userAccessToken = tokenData.accessToken
|
||||
console.log('获取用户访问令牌成功')
|
||||
|
||||
// 2. 获取应用访问令牌
|
||||
const appAccessToken = await getAppAccessToken(company)
|
||||
console.log('获取应用访问令牌成功')
|
||||
|
||||
// 3. 获取unionId
|
||||
const unionId = await getUnionId(userAccessToken)
|
||||
console.log('获取unionId成功:', unionId)
|
||||
|
||||
// 4. 根据unionId获取userId
|
||||
const userId = await getUserIdByUnionId(unionId, appAccessToken)
|
||||
console.log('获取userId成功:', userId)
|
||||
|
||||
// 5. 获取用户详细信息
|
||||
const userInfo = await getUserInfo(userId, appAccessToken)
|
||||
console.log('获取用户信息成功:', userInfo.name)
|
||||
|
||||
// 6. 在数据库中查找或创建用户
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { dingtalkUserId: userId }
|
||||
})
|
||||
|
||||
let isNewUser = false
|
||||
let needCompleteProfile = false
|
||||
|
||||
if (!user) {
|
||||
// 首先检查是否有用户名为钉钉真实姓名的用户
|
||||
const existingUserByName = await prisma.user.findUnique({
|
||||
where: { username: userInfo.name }
|
||||
})
|
||||
|
||||
// 使用固定密码用于钉钉自动登录
|
||||
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
|
||||
|
||||
if (existingUserByName && !existingUserByName.dingtalkUserId) {
|
||||
// 找到了同名用户且未绑定钉钉,直接关联
|
||||
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
|
||||
|
||||
user = await prisma.user.update({
|
||||
where: { id: existingUserByName.id },
|
||||
data: {
|
||||
dingtalkUserId: userId,
|
||||
loginType: 'dingtalk',
|
||||
realName: userInfo.name,
|
||||
avatar: userInfo.avatar || existingUserByName.avatar,
|
||||
phone: userInfo.mobile || existingUserByName.phone,
|
||||
password: hashedPassword, // 更新密码为固定值
|
||||
}
|
||||
})
|
||||
|
||||
console.log('账号关联成功:', user.username)
|
||||
} else {
|
||||
// 创建新用户,直接使用钉钉真实姓名作为用户名
|
||||
console.log('创建新用户(使用钉钉姓名):', userInfo.name)
|
||||
|
||||
let emailToUse = userInfo.email || generateRandomEmail()
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
let emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
|
||||
if (emailExists) {
|
||||
emailToUse = generateRandomEmail()
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
username: userInfo.name, // 直接使用钉钉真实姓名
|
||||
email: emailToUse,
|
||||
password: hashedPassword,
|
||||
realName: userInfo.name,
|
||||
avatar: userInfo.avatar || null,
|
||||
phone: userInfo.mobile || null,
|
||||
department: '', // 待用户选择
|
||||
role: 'USER',
|
||||
isActive: false, // 新用户默认未激活,需要管理员激活
|
||||
loginType: 'dingtalk',
|
||||
dingtalkUserId: userId
|
||||
}
|
||||
})
|
||||
|
||||
isNewUser = true
|
||||
needCompleteProfile = true // 需要选择部门
|
||||
console.log('用户创建成功:', user.id)
|
||||
}
|
||||
} else {
|
||||
// 更新现有用户信息
|
||||
console.log('更新现有用户:', user.username)
|
||||
|
||||
// 确保密码也更新为固定值,以便前端能够自动登录
|
||||
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
|
||||
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
realName: userInfo.name,
|
||||
avatar: userInfo.avatar || user.avatar,
|
||||
phone: userInfo.mobile || user.phone,
|
||||
password: hashedPassword, // 更新密码为固定值
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 如果需要完善信息(选择部门)
|
||||
if (needCompleteProfile) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
needCompleteProfile: true,
|
||||
message: '请选择您的部门',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
department: user.department,
|
||||
isActive: user.isActive
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 检查用户是否已激活
|
||||
if (!user.isActive) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '您的账号尚未激活,请联系管理员激活后再登录'
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// 返回用户信息(不包含敏感信息)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
needCompleteProfile: false,
|
||||
message: '登录成功',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
realName: user.realName,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
department: user.department
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('钉钉登录失败:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error.message || '登录失败,请稍后再试'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
14
app/api/health/route.ts
Normal file
14
app/api/health/route.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// Health check endpoint - 用于网络检测
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
export async function HEAD() {
|
||||
return new NextResponse(null, { status: 200 })
|
||||
}
|
||||
|
||||
78
app/api/invitations/[id]/route.ts
Normal file
78
app/api/invitations/[id]/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// PATCH - 更新邀请链接(仅管理员)
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||||
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = params
|
||||
const { isActive } = await req.json()
|
||||
|
||||
if (typeof isActive !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const invitation = await prisma.invitation.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
})
|
||||
|
||||
return NextResponse.json(invitation)
|
||||
} catch (error) {
|
||||
console.error('Update invitation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新邀请链接失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - 删除邀请链接(仅管理员)
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||||
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = params
|
||||
|
||||
await prisma.invitation.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: '删除成功' })
|
||||
} catch (error) {
|
||||
console.error('Delete invitation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '删除邀请链接失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
122
app/api/invitations/route.ts
Normal file
122
app/api/invitations/route.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { resolveBaseUrl } from '@/lib/config'
|
||||
|
||||
// GET - 获取邀请链接列表(仅管理员)
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||||
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 支持分页
|
||||
const { searchParams } = new URL(req.url)
|
||||
const page = parseInt(searchParams.get('page') || '1', 10)
|
||||
const pageSize = Math.min(parseInt(searchParams.get('pageSize') || '20', 10), 100) // 最大100条
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
// 并行查询总数和数据
|
||||
const [total, invitations] = await Promise.all([
|
||||
prisma.invitation.count(),
|
||||
prisma.invitation.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
createdBy: true,
|
||||
maxUses: true,
|
||||
currentUses: true,
|
||||
expiresAt: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
// 不返回敏感信息
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
// 生成完整邀请链接(统一使用集中配置策略)
|
||||
const baseUrl = resolveBaseUrl(req)
|
||||
const invitationsWithUrl = invitations.map(invitation => ({
|
||||
...invitation,
|
||||
inviteUrl: `${baseUrl}/register?invite=${invitation.code}`,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
invitations: invitationsWithUrl,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Invitations] GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取邀请链接失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 创建邀请链接(仅管理员)
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||||
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { maxUses, expiresInDays } = await req.json()
|
||||
const userId = (session.user as any).id
|
||||
|
||||
// 生成唯一邀请码(8位随机字符串)
|
||||
const code = randomBytes(4).toString('hex').toUpperCase()
|
||||
|
||||
// 计算过期时间
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null
|
||||
|
||||
const invitation = await prisma.invitation.create({
|
||||
data: {
|
||||
code,
|
||||
createdBy: userId,
|
||||
maxUses: maxUses || 1,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
// 生成完整邀请链接(统一使用集中配置策略)
|
||||
const baseUrl = resolveBaseUrl(req)
|
||||
const inviteUrl = `${baseUrl}/register?invite=${code}`
|
||||
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
inviteUrl,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Invitations] POST error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '创建邀请链接失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
61
app/api/invitations/verify/route.ts
Normal file
61
app/api/invitations/verify/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// GET - 验证邀请码
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const code = searchParams.get('code')
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少邀请码' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const invitation = await prisma.invitation.findUnique({
|
||||
where: { code },
|
||||
})
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!invitation.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码已失效' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invitation.currentUses >= invitation.maxUses) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码使用次数已用完' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invitation.expiresAt && new Date() > invitation.expiresAt) {
|
||||
return NextResponse.json(
|
||||
{ error: '邀请码已过期' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
code: invitation.code,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Verify invitation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '验证邀请码失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
210
app/api/materials/[id]/route.ts
Normal file
210
app/api/materials/[id]/route.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// GET /api/materials/[id] - 获取单个素材详情
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: params.id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
productModel: true,
|
||||
brandName: true,
|
||||
seriesName: true,
|
||||
skuCode: true,
|
||||
materialTexture: true,
|
||||
categoryName: true,
|
||||
sceneName: true,
|
||||
platformName: true,
|
||||
materialType: true,
|
||||
renderer: true,
|
||||
renderTime: true,
|
||||
imageResolution: true,
|
||||
colorMode: true,
|
||||
authorName: true,
|
||||
previewUrl: true,
|
||||
zipFileName: true,
|
||||
zipFileSize: true,
|
||||
downloadCount: true,
|
||||
likeCount: true,
|
||||
displayImages: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 返回素材信息(转换BigInt为字符串)
|
||||
return NextResponse.json({
|
||||
id: material.id,
|
||||
userId: material.userId,
|
||||
productModel: material.productModel,
|
||||
brandName: material.brandName,
|
||||
seriesName: material.seriesName,
|
||||
skuCode: material.skuCode,
|
||||
materialTexture: material.materialTexture,
|
||||
categoryName: material.categoryName,
|
||||
sceneName: material.sceneName,
|
||||
materialType: material.materialType,
|
||||
renderer: material.renderer,
|
||||
renderTime: material.renderTime,
|
||||
imageResolution: material.imageResolution,
|
||||
colorMode: material.colorMode,
|
||||
authorName: material.authorName,
|
||||
previewUrl: material.previewUrl,
|
||||
zipFileName: material.zipFileName,
|
||||
zipFileSize: material.zipFileSize.toString(),
|
||||
downloadCount: material.downloadCount,
|
||||
likeCount: material.likeCount,
|
||||
displayImages: material.displayImages,
|
||||
createdAt: material.createdAt,
|
||||
user: {
|
||||
username: material.user.username
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[Materials] GET [id] error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取素材失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/materials/[id] - 更新素材信息
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
productModel,
|
||||
categoryName,
|
||||
sceneName,
|
||||
materialType,
|
||||
renderer,
|
||||
authorName,
|
||||
brandName,
|
||||
seriesName,
|
||||
skuCode,
|
||||
materialTexture,
|
||||
renderTime,
|
||||
imageResolution,
|
||||
colorMode
|
||||
} = body
|
||||
|
||||
// 验证必填字段
|
||||
if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必填字段' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取素材信息
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: params.id }
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 权限检查:只有素材上传者或管理员可以编辑
|
||||
const userFromSession = session.user as any
|
||||
if (material.userId !== userFromSession.id && userFromSession.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '您没有权限编辑此素材' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 更新素材信息
|
||||
const updateData: any = {
|
||||
productModel,
|
||||
categoryName,
|
||||
sceneName,
|
||||
materialType,
|
||||
renderer,
|
||||
authorName,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
const assignOptional = (key: string, value: any) => {
|
||||
if (value !== undefined) {
|
||||
updateData[key] = value === null || value === '' ? null : value
|
||||
}
|
||||
}
|
||||
|
||||
assignOptional('brandName', brandName)
|
||||
assignOptional('seriesName', seriesName)
|
||||
assignOptional('skuCode', skuCode)
|
||||
assignOptional('materialTexture', materialTexture)
|
||||
assignOptional('renderTime', renderTime)
|
||||
assignOptional('imageResolution', imageResolution)
|
||||
assignOptional('colorMode', colorMode)
|
||||
|
||||
const updatedMaterial = await prisma.material.update({
|
||||
where: { id: params.id },
|
||||
data: updateData
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '素材信息更新成功',
|
||||
material: {
|
||||
id: updatedMaterial.id,
|
||||
productModel: updatedMaterial.productModel,
|
||||
brandName: updatedMaterial.brandName,
|
||||
seriesName: updatedMaterial.seriesName,
|
||||
skuCode: updatedMaterial.skuCode,
|
||||
materialTexture: updatedMaterial.materialTexture,
|
||||
categoryName: updatedMaterial.categoryName,
|
||||
sceneName: updatedMaterial.sceneName,
|
||||
materialType: updatedMaterial.materialType,
|
||||
renderer: updatedMaterial.renderer,
|
||||
renderTime: updatedMaterial.renderTime,
|
||||
imageResolution: updatedMaterial.imageResolution,
|
||||
colorMode: updatedMaterial.colorMode,
|
||||
authorName: updatedMaterial.authorName
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[Materials] PUT [id] error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新素材失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
157
app/api/materials/download/route.ts
Normal file
157
app/api/materials/download/route.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { readFile, access } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Check if user is logged in
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user has download permission (skip check for admins)
|
||||
const userId = (session.user as any).id
|
||||
const userRole = (session.user as any).role
|
||||
|
||||
if (userRole !== 'ADMIN') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { canDownload: true }
|
||||
})
|
||||
|
||||
if (!user || !user.canDownload) {
|
||||
return NextResponse.json(
|
||||
{ error: '您的下载权限已被限制,请联系管理员' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 增加下载次数
|
||||
await prisma.material.update({
|
||||
where: { id },
|
||||
data: {
|
||||
downloadCount: {
|
||||
increment: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read file from local storage
|
||||
// 确保使用绝对路径
|
||||
let filePath = material.zipFilePath
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(process.cwd(), filePath)
|
||||
}
|
||||
|
||||
console.log('Attempting to download file:', filePath)
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await access(filePath)
|
||||
} catch (error) {
|
||||
console.error('File not found:', filePath)
|
||||
return NextResponse.json(
|
||||
{ error: '文件不存在,可能已被删除' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath)
|
||||
|
||||
// 生成新的文件名:SPU_一级分类_二级分类_用户名.扩展名
|
||||
// 获取原文件扩展名
|
||||
const originalExt = path.extname(material.zipFileName) // 例如 .zip 或 .7z 或视频扩展名
|
||||
const newFileName = `${material.productModel}_${material.categoryName}_${material.sceneName}_${material.authorName}${originalExt}`
|
||||
|
||||
console.log('Download filename:', newFileName)
|
||||
|
||||
// 根据文件类型和素材类型确定Content-Type
|
||||
let contentType = 'application/zip'
|
||||
let contentDisposition = `attachment; filename="${encodeURIComponent(newFileName)}"`
|
||||
|
||||
// 如果是视频类型,使用视频的Content-Type并支持流式传输
|
||||
if (material.materialType === '视频') {
|
||||
const ext = originalExt.toLowerCase()
|
||||
const videoTypes: { [key: string]: string } = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mov': 'video/quicktime',
|
||||
'.wmv': 'video/x-ms-wmv',
|
||||
'.flv': 'video/x-flv',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.webm': 'video/webm',
|
||||
}
|
||||
contentType = videoTypes[ext] || 'video/mp4'
|
||||
|
||||
// 视频文件支持流式传输(Range请求)
|
||||
const range = req.headers.get('range')
|
||||
if (range) {
|
||||
// 支持视频流式传输
|
||||
const fileSize = fileBuffer.length
|
||||
const parts = range.replace(/bytes=/, '').split('-')
|
||||
const start = parseInt(parts[0], 10)
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
||||
const chunkSize = end - start + 1
|
||||
const chunk = fileBuffer.slice(start, end + 1)
|
||||
|
||||
return new NextResponse(chunk as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunkSize.toString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 非Range请求,返回完整文件(但允许内联播放)
|
||||
contentDisposition = `inline; filename="${encodeURIComponent(newFileName)}"`
|
||||
}
|
||||
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': contentDisposition,
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
'Accept-Ranges': 'bytes', // 支持Range请求(用于视频流式传输)
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Download error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '下载失败: ' + (error instanceof Error ? error.message : '未知错误') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
93
app/api/materials/like/route.ts
Normal file
93
app/api/materials/like/route.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 点赞/取消点赞
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { materialId } = await req.json()
|
||||
|
||||
if (!materialId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id
|
||||
|
||||
// 检查是否已点赞
|
||||
const existingLike = await prisma.materialLike.findUnique({
|
||||
where: {
|
||||
materialId_userId: {
|
||||
materialId,
|
||||
userId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (existingLike) {
|
||||
// 取消点赞
|
||||
await prisma.materialLike.delete({
|
||||
where: {
|
||||
id: existingLike.id
|
||||
}
|
||||
})
|
||||
|
||||
// 减少点赞次数
|
||||
await prisma.material.update({
|
||||
where: { id: materialId },
|
||||
data: {
|
||||
likeCount: {
|
||||
decrement: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
liked: false,
|
||||
message: '已取消点赞'
|
||||
})
|
||||
} else {
|
||||
// 添加点赞
|
||||
await prisma.materialLike.create({
|
||||
data: {
|
||||
materialId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
// 增加点赞次数
|
||||
await prisma.material.update({
|
||||
where: { id: materialId },
|
||||
data: {
|
||||
likeCount: {
|
||||
increment: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
liked: true,
|
||||
message: '点赞成功'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Like error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '操作失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
207
app/api/materials/route.ts
Normal file
207
app/api/materials/route.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// Get all materials with search and filter support
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const { searchParams } = new URL(req.url)
|
||||
const search = searchParams.get('search') || ''
|
||||
const categoryName = searchParams.get('category') || ''
|
||||
const sceneName = searchParams.get('scene') || ''
|
||||
const materialType = searchParams.get('materialType') || ''
|
||||
const platformName = searchParams.get('platform') || ''
|
||||
const month = searchParams.get('month') || ''
|
||||
const sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20')
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
|
||||
// Add search condition (search in SPU, shop, scene category, author)
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
|
||||
{ brandName: { contains: search, mode: 'insensitive' } }, // 品牌
|
||||
{ seriesName: { contains: search, mode: 'insensitive' } }, // 系列
|
||||
{ skuCode: { contains: search, mode: 'insensitive' } }, // SKU
|
||||
{ materialTexture: { contains: search, mode: 'insensitive' } }, // 材质
|
||||
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
|
||||
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
|
||||
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
|
||||
]
|
||||
}
|
||||
|
||||
// Add shop filter (使用店铺)
|
||||
if (categoryName) {
|
||||
where.categoryName = categoryName
|
||||
}
|
||||
|
||||
// Add scene category filter (场景类别)
|
||||
if (sceneName) {
|
||||
where.sceneName = sceneName
|
||||
}
|
||||
|
||||
// Add material type filter (素材类型)
|
||||
if (materialType) {
|
||||
where.materialType = materialType
|
||||
}
|
||||
|
||||
// Add platform filter (平台分类)
|
||||
if (platformName) {
|
||||
where.platformName = platformName
|
||||
}
|
||||
|
||||
// Add month filter (上传月份)
|
||||
if (month) {
|
||||
const [year, monthNum] = month.split('-')
|
||||
const startDate = new Date(parseInt(year), parseInt(monthNum) - 1, 1)
|
||||
const endDate = new Date(parseInt(year), parseInt(monthNum), 1)
|
||||
where.createdAt = {
|
||||
gte: startDate,
|
||||
lt: endDate
|
||||
}
|
||||
}
|
||||
|
||||
// 构建排序规则
|
||||
let orderBy: any = { createdAt: 'desc' }
|
||||
if (sortBy === 'downloadCount') {
|
||||
orderBy = { downloadCount: 'desc' }
|
||||
}
|
||||
|
||||
// 计算总数和分页数据(并行查询,优化性能)
|
||||
const maxPageSize = Math.min(pageSize, 100) // 限制最大分页大小为 100
|
||||
const [totalCount, materials] = await Promise.all([
|
||||
prisma.material.count({ where }),
|
||||
prisma.material.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
productModel: true,
|
||||
brandName: true,
|
||||
seriesName: true,
|
||||
skuCode: true,
|
||||
materialTexture: true,
|
||||
categoryName: true,
|
||||
sceneName: true,
|
||||
platformName: true,
|
||||
materialType: true,
|
||||
renderer: true,
|
||||
renderTime: true,
|
||||
imageResolution: true,
|
||||
colorMode: true,
|
||||
authorName: true,
|
||||
previewUrl: true,
|
||||
zipFileName: true,
|
||||
zipFileSize: true,
|
||||
downloadCount: true,
|
||||
likeCount: true,
|
||||
displayImages: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip: (page - 1) * maxPageSize,
|
||||
take: maxPageSize,
|
||||
})
|
||||
])
|
||||
const totalPages = Math.ceil(totalCount / maxPageSize)
|
||||
|
||||
// 如果用户已登录,查询点赞状态(优化:只在有数据时查询)
|
||||
let userLikes: Set<string> = new Set()
|
||||
if (session && materials.length > 0) {
|
||||
const userId = (session.user as any).id
|
||||
const materialIds = materials.map(m => m.id)
|
||||
const likes = await prisma.materialLike.findMany({
|
||||
where: {
|
||||
userId,
|
||||
materialId: { in: materialIds }
|
||||
},
|
||||
select: {
|
||||
materialId: true
|
||||
}
|
||||
})
|
||||
userLikes = new Set(likes.map(l => l.materialId))
|
||||
}
|
||||
|
||||
// 添加点赞状态到每个素材,并转换BigInt为字符串
|
||||
const materialsWithLikeStatus = materials.map(material => ({
|
||||
...material,
|
||||
zipFileSize: material.zipFileSize.toString(),
|
||||
isLiked: userLikes.has(material.id)
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
materials: materialsWithLikeStatus,
|
||||
totalPages,
|
||||
totalCount,
|
||||
currentPage: page,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Materials] GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取素材列表失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete material (admin only)
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get material to delete files
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await prisma.material.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
// TODO: Delete files from S3 and local storage
|
||||
|
||||
return NextResponse.json({ message: '删除成功' })
|
||||
} catch (error) {
|
||||
console.error('[Materials] DELETE error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '删除失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
68
app/api/materials/share/route.ts
Normal file
68
app/api/materials/share/route.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// Get single material for sharing (公开访问,无需登录)
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
previewUrl: true,
|
||||
productModel: true,
|
||||
brandName: true,
|
||||
seriesName: true,
|
||||
skuCode: true,
|
||||
materialTexture: true,
|
||||
categoryName: true,
|
||||
sceneName: true,
|
||||
materialType: true,
|
||||
renderer: true,
|
||||
renderTime: true,
|
||||
imageResolution: true,
|
||||
colorMode: true,
|
||||
authorName: true,
|
||||
zipFileName: true,
|
||||
zipFileSize: true,
|
||||
downloadCount: true,
|
||||
likeCount: true,
|
||||
displayImages: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 转换BigInt为字符串
|
||||
const materialData = {
|
||||
...material,
|
||||
zipFileSize: material.zipFileSize.toString()
|
||||
}
|
||||
|
||||
return NextResponse.json(materialData)
|
||||
} catch (error) {
|
||||
console.error('Error fetching material for share:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取素材失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
48
app/api/materials/stats/route.ts
Normal file
48
app/api/materials/stats/route.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// Get material statistics (total count and monthly breakdown)
|
||||
// 优化:使用SQL聚合查询,避免加载所有数据到内存
|
||||
export async function GET() {
|
||||
try {
|
||||
// 并行查询总数和按月统计
|
||||
const [total, monthlyStats] = await Promise.all([
|
||||
prisma.material.count(),
|
||||
prisma.$queryRaw<Array<{
|
||||
year: number
|
||||
month: number
|
||||
count: bigint
|
||||
}>>`
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM created_at)::int as year,
|
||||
EXTRACT(MONTH FROM created_at)::int as month,
|
||||
COUNT(*)::bigint as count
|
||||
FROM materials
|
||||
GROUP BY EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)
|
||||
ORDER BY year DESC, month DESC
|
||||
`
|
||||
])
|
||||
|
||||
// 格式化月份数据
|
||||
const months = monthlyStats.map(item => {
|
||||
const monthKey = `${item.year}-${String(item.month).padStart(2, '0')}`
|
||||
return {
|
||||
value: monthKey,
|
||||
label: `${item.year}年${item.month}月`,
|
||||
count: Number(item.count)
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
months
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[MaterialStats] Get error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取统计数据失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
111
app/api/materials/stream/route.ts
Normal file
111
app/api/materials/stream/route.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { readFile, access } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// 视频流式传输API(允许未登录用户访问,仅用于视频播放)
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 只允许视频类型
|
||||
if (material.materialType !== '视频') {
|
||||
return NextResponse.json(
|
||||
{ error: '此API仅支持视频类型素材' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Read file from local storage
|
||||
let filePath = material.zipFilePath
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(process.cwd(), filePath)
|
||||
}
|
||||
|
||||
console.log('Streaming video file:', filePath)
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await access(filePath)
|
||||
} catch (error) {
|
||||
console.error('File not found:', filePath)
|
||||
return NextResponse.json(
|
||||
{ error: '文件不存在,可能已被删除' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath)
|
||||
|
||||
// 根据文件扩展名确定Content-Type
|
||||
const originalExt = path.extname(material.zipFileName).toLowerCase()
|
||||
const videoTypes: { [key: string]: string } = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mov': 'video/quicktime',
|
||||
'.wmv': 'video/x-ms-wmv',
|
||||
'.flv': 'video/x-flv',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.webm': 'video/webm',
|
||||
}
|
||||
const contentType = videoTypes[originalExt] || 'video/mp4'
|
||||
|
||||
// 支持Range请求(视频流式传输)
|
||||
const range = req.headers.get('range')
|
||||
if (range) {
|
||||
const fileSize = fileBuffer.length
|
||||
const parts = range.replace(/bytes=/, '').split('-')
|
||||
const start = parseInt(parts[0], 10)
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
||||
const chunkSize = end - start + 1
|
||||
const chunk = fileBuffer.slice(start, end + 1)
|
||||
|
||||
return new NextResponse(chunk as any, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunkSize.toString(),
|
||||
'Cache-Control': 'public, max-age=3600', // 缓存1小时
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 非Range请求,返回完整文件
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '流式传输失败: ' + (error instanceof Error ? error.message : '未知错误') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
137
app/api/materials/update/route.ts
Normal file
137
app/api/materials/update/route.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadToS3, deleteFromS3 } from '@/lib/s3'
|
||||
import { processImage, getProcessedFileName } from '@/lib/image-processor'
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const normalizeInput = (value: FormDataEntryValue | null) => {
|
||||
if (!value) return null
|
||||
const trimmed = value.toString().trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
const id = formData.get('id') as string
|
||||
const productModel = formData.get('productModel') as string
|
||||
const categoryName = formData.get('categoryName') as string
|
||||
const sceneName = formData.get('sceneName') as string
|
||||
const materialType = (formData.get('materialType') as string) || '3DMAX'
|
||||
const renderer = (formData.get('renderer') as string) || 'CR'
|
||||
const authorName = formData.get('authorName') as string
|
||||
const previewImage = formData.get('previewImage') as File | null
|
||||
const brandName = normalizeInput(formData.get('brandName'))
|
||||
const seriesName = normalizeInput(formData.get('seriesName'))
|
||||
const skuCode = normalizeInput(formData.get('skuCode'))
|
||||
const materialTexture = normalizeInput(formData.get('materialTexture'))
|
||||
const renderTime = normalizeInput(formData.get('renderTime'))
|
||||
const imageResolution = normalizeInput(formData.get('imageResolution'))
|
||||
const colorMode = normalizeInput(formData.get('colorMode'))
|
||||
|
||||
// Validation
|
||||
if (!id || !productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
|
||||
return NextResponse.json(
|
||||
{ error: '所有字段都是必填的' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get existing material
|
||||
const existingMaterial = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!existingMaterial) {
|
||||
return NextResponse.json(
|
||||
{ error: '素材不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
let previewUrl = existingMaterial.previewUrl
|
||||
|
||||
// If new preview image is uploaded
|
||||
if (previewImage && previewImage.size > 0) {
|
||||
// Delete old preview image from S3
|
||||
try {
|
||||
await deleteFromS3(existingMaterial.previewUrl)
|
||||
} catch (error) {
|
||||
console.error('Error deleting old preview:', error)
|
||||
}
|
||||
|
||||
// 处理预览图:压缩到300KB以内,转换为webP,缩放到600px
|
||||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||||
const processedBuffer = await processImage(originalBuffer, {
|
||||
maxSizeKB: 300,
|
||||
maxWidth: 600,
|
||||
format: 'webp',
|
||||
})
|
||||
|
||||
// Upload processed preview image to S3
|
||||
const processedFileName = getProcessedFileName(previewImage.name)
|
||||
const previewFileName = `preview-${Date.now()}-${processedFileName}`
|
||||
previewUrl = await uploadToS3(
|
||||
processedBuffer,
|
||||
previewFileName,
|
||||
'image/webp'
|
||||
)
|
||||
}
|
||||
|
||||
// Update material record
|
||||
const material = await prisma.material.update({
|
||||
where: { id },
|
||||
data: {
|
||||
previewUrl,
|
||||
productModel,
|
||||
brandName,
|
||||
seriesName,
|
||||
skuCode,
|
||||
materialTexture,
|
||||
categoryName,
|
||||
sceneName,
|
||||
materialType,
|
||||
renderer,
|
||||
renderTime,
|
||||
imageResolution,
|
||||
colorMode,
|
||||
authorName,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 转换BigInt为字符串
|
||||
const materialData = {
|
||||
...material,
|
||||
zipFileSize: material.zipFileSize.toString()
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: '更新成功', material: materialData },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Update error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新失败,请稍后再试' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
439
app/api/materials/upload/route.ts
Normal file
439
app/api/materials/upload/route.ts
Normal file
@ -0,0 +1,439 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadToS3 } from '@/lib/s3'
|
||||
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
// 生成唯一文件名(避免并发冲突)
|
||||
function generateUniqueFileName(prefix: string, ext: string): string {
|
||||
const timestamp = Date.now()
|
||||
const randomId = randomBytes(8).toString('hex') // 16个字符的随机字符串
|
||||
return `${prefix}-${timestamp}-${randomId}${ext}`
|
||||
}
|
||||
|
||||
// Configure route to handle large files
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Increase timeout for large file uploads
|
||||
export const maxDuration = 10800 // 3 hours timeout (10800 seconds)
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
console.log('[Upload] Starting upload process...')
|
||||
|
||||
const session = await getServerSession(authOptions)
|
||||
console.log('[Upload] Session checked:', !!session)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('[Upload] Parsing form data...')
|
||||
const formData = await req.formData()
|
||||
const normalizeInput = (value: FormDataEntryValue | null) => {
|
||||
if (!value) return null
|
||||
const trimmed = value.toString().trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
const previewImage = formData.get('previewImage') as File
|
||||
const displayImage = formData.get('displayImage') as File // PSD素材的展示图片
|
||||
const modelImages = formData.getAll('modelImages') as File[] // 模特类型的多张展示图片
|
||||
const zipFile = formData.get('zipFile') as File
|
||||
const productModel = formData.get('productModel') as string
|
||||
const categoryName = formData.get('categoryName') as string
|
||||
const sceneName = formData.get('sceneName') as string
|
||||
const platformName = formData.get('platformName') as string || '' // 平台分类(可选)
|
||||
const materialType = (formData.get('materialType') as string) || '3DMAX'
|
||||
const renderer = (formData.get('renderer') as string) || 'CR'
|
||||
const brandName = normalizeInput(formData.get('brandName'))
|
||||
const seriesName = normalizeInput(formData.get('seriesName'))
|
||||
const skuCode = normalizeInput(formData.get('skuCode'))
|
||||
const materialTexture = normalizeInput(formData.get('materialTexture'))
|
||||
const renderTime = normalizeInput(formData.get('renderTime'))
|
||||
const imageResolution = normalizeInput(formData.get('imageResolution'))
|
||||
const colorMode = normalizeInput(formData.get('colorMode'))
|
||||
|
||||
console.log('[Upload] Form data received:', {
|
||||
hasPreviewImage: !!previewImage,
|
||||
previewImageSize: previewImage?.size,
|
||||
hasDisplayImage: !!displayImage,
|
||||
displayImageSize: displayImage?.size,
|
||||
modelImagesCount: modelImages.length,
|
||||
hasZipFile: !!zipFile,
|
||||
zipFileSize: zipFile?.size,
|
||||
productModel,
|
||||
categoryName,
|
||||
sceneName,
|
||||
materialType
|
||||
})
|
||||
|
||||
console.log('[Upload] Material type:', materialType)
|
||||
|
||||
// Validation - 基础字段验证(材质类型不需要分类和场景)
|
||||
if (materialType !== '材质' && (!productModel || !categoryName || !sceneName)) {
|
||||
console.log('[Upload] Validation failed - missing required fields')
|
||||
return NextResponse.json(
|
||||
{ error: '产品型号、分类和场景都是必填的' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 材质类型只需要产品型号
|
||||
if (materialType === '材质' && !productModel) {
|
||||
console.log('[Upload] Validation failed - material type requires product model')
|
||||
return NextResponse.json(
|
||||
{ error: '请填写材质名称' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 素材文件验证(模特类型和材质类型素材文件可选)
|
||||
if (materialType !== '模特' && materialType !== '材质' && !zipFile) {
|
||||
console.log('[Upload] Validation failed - missing zip file')
|
||||
return NextResponse.json(
|
||||
{ error: '请上传素材文件' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 对于视频类型,必须有视频文件
|
||||
if (materialType === '视频' && !zipFile) {
|
||||
console.log('[Upload] Validation failed - video requires video file')
|
||||
return NextResponse.json(
|
||||
{ error: '请上传视频文件' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 对于PSD类型,必须有展示图片
|
||||
if (materialType === 'PSD' && !displayImage) {
|
||||
console.log('[Upload] Validation failed - PSD requires display image')
|
||||
return NextResponse.json(
|
||||
{ error: 'PSD素材必须上传展示图片' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 对于模特类型,必须有至少一张展示图片
|
||||
if (materialType === '模特' && modelImages.length === 0) {
|
||||
console.log('[Upload] Validation failed - Model requires model images')
|
||||
return NextResponse.json(
|
||||
{ error: '模特素材必须上传至少一张展示图片' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 对于视频类型,必须有预览图(封面)
|
||||
if (materialType === '视频' && !previewImage) {
|
||||
console.log('[Upload] Validation failed - video requires preview image')
|
||||
return NextResponse.json(
|
||||
{ error: '视频素材需要上传预览图(封面)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 对于非PSD、非模特、非视频类型,必须有预览图
|
||||
if (materialType !== 'PSD' && materialType !== '模特' && materialType !== '视频' && materialType !== '材质' && !previewImage) {
|
||||
console.log('[Upload] Validation failed - non-PSD/Model/Video requires preview image')
|
||||
return NextResponse.json(
|
||||
{ error: '请上传预览图' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取用户信息作为作者花名
|
||||
const userId = (session.user as any).id
|
||||
console.log('[Upload] Fetching user info... Session user ID:', userId, 'Type:', typeof userId)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
console.log('[Upload] User not found for ID:', userId)
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const authorName = user.username
|
||||
console.log('[Upload] User found:', authorName)
|
||||
|
||||
let previewUrl: string = ''
|
||||
|
||||
// 处理预览图(非PSD、非模特、非材质类型,以及视频类型需要封面)
|
||||
if (materialType !== 'PSD' && materialType !== '模特' && materialType !== '材质' && previewImage) {
|
||||
// 验证预览图大小(限制50MB)
|
||||
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
|
||||
if (previewImage.size > maxPreviewSize) {
|
||||
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
|
||||
return NextResponse.json(
|
||||
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证预览图格式
|
||||
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
|
||||
if (!allowedImageTypes.includes(previewImage.type)) {
|
||||
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
|
||||
return NextResponse.json(
|
||||
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示
|
||||
console.log(`[Upload] Processing preview image... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
|
||||
try {
|
||||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||||
const processedBuffer = await processImage(originalBuffer, {
|
||||
maxSizeKB: 300,
|
||||
maxWidth: 600,
|
||||
maxHeight: 800,
|
||||
format: 'webp',
|
||||
})
|
||||
console.log('[Upload] Preview image processed successfully')
|
||||
|
||||
// Upload processed preview image to S3
|
||||
console.log('[Upload] Uploading preview image to S3...')
|
||||
const processedFileName = getProcessedFileName(previewImage.name)
|
||||
const fileExt = path.extname(processedFileName)
|
||||
const previewFileName = generateUniqueFileName('preview', fileExt)
|
||||
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
|
||||
console.log('[Upload] Preview image uploaded to S3:', previewUrl)
|
||||
} catch (imageError) {
|
||||
console.error('[Upload] Preview image processing error:', imageError)
|
||||
return NextResponse.json(
|
||||
{ error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理视频类型的预览图(封面)
|
||||
if (materialType === '视频' && previewImage) {
|
||||
// 验证预览图大小(限制50MB)
|
||||
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
|
||||
if (previewImage.size > maxPreviewSize) {
|
||||
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
|
||||
return NextResponse.json(
|
||||
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证预览图格式
|
||||
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
|
||||
if (!allowedImageTypes.includes(previewImage.type)) {
|
||||
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
|
||||
return NextResponse.json(
|
||||
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示
|
||||
console.log(`[Upload] Processing video preview image (cover)... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
|
||||
try {
|
||||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||||
const processedBuffer = await processImage(originalBuffer, {
|
||||
maxSizeKB: 300,
|
||||
maxWidth: 600,
|
||||
maxHeight: 800,
|
||||
format: 'webp',
|
||||
})
|
||||
console.log('[Upload] Video preview image processed successfully')
|
||||
|
||||
// Upload processed preview image to S3
|
||||
console.log('[Upload] Uploading video preview image to S3...')
|
||||
const processedFileName = getProcessedFileName(previewImage.name)
|
||||
const fileExt = path.extname(processedFileName)
|
||||
const previewFileName = generateUniqueFileName('preview', fileExt)
|
||||
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
|
||||
console.log('[Upload] Video preview image uploaded to S3:', previewUrl)
|
||||
} catch (imageError) {
|
||||
console.error('[Upload] Video preview image processing error:', imageError)
|
||||
return NextResponse.json(
|
||||
{ error: `视频封面处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PSD display images (切割和上传展示图片)
|
||||
let displayImages: string[] = []
|
||||
if (materialType === 'PSD' && displayImage) {
|
||||
console.log('[Upload] Processing PSD display image...')
|
||||
try {
|
||||
// 读取展示图片
|
||||
const displayImageBuffer = Buffer.from(await displayImage.arrayBuffer())
|
||||
|
||||
// 切割图片(按1500px高度)
|
||||
console.log('[Upload] Slicing display image (max height: 1500px)...')
|
||||
const slicedBuffers = await sliceImage(displayImageBuffer, 1500)
|
||||
console.log(`[Upload] Image sliced into ${slicedBuffers.length} parts`)
|
||||
|
||||
// 上传每个切片到S3
|
||||
for (let i = 0; i < slicedBuffers.length; i++) {
|
||||
const sliceFileName = generateUniqueFileName(`display-${i + 1}`, '.webp')
|
||||
const sliceUrl = await uploadToS3(slicedBuffers[i], sliceFileName, 'image/webp')
|
||||
displayImages.push(sliceUrl)
|
||||
console.log(`[Upload] Display image slice ${i + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl)
|
||||
}
|
||||
|
||||
// 如果没有预览图,使用第一张展示图片作为预览图
|
||||
if (!previewImage && displayImages.length > 0) {
|
||||
previewUrl = displayImages[0]
|
||||
console.log('[Upload] Using first display image as preview:', previewUrl)
|
||||
}
|
||||
} catch (displayImageError) {
|
||||
console.error('[Upload] Display image processing error:', displayImageError)
|
||||
return NextResponse.json(
|
||||
{ error: `PSD展示图片处理失败:${displayImageError instanceof Error ? displayImageError.message : '未知错误'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Model display images (模特类型的多图处理)
|
||||
if (materialType === '模特' && modelImages.length > 0) {
|
||||
console.log(`[Upload] Processing ${modelImages.length} model images...`)
|
||||
try {
|
||||
// 处理每张模特图片
|
||||
for (let i = 0; i < modelImages.length; i++) {
|
||||
const modelImage = modelImages[i]
|
||||
console.log(`[Upload] Processing model image ${i + 1}/${modelImages.length}...`)
|
||||
|
||||
// 读取图片
|
||||
const imageBuffer = Buffer.from(await modelImage.arrayBuffer())
|
||||
|
||||
// 切割图片(按1500px高度)
|
||||
const slicedBuffers = await sliceImage(imageBuffer, 1500)
|
||||
console.log(`[Upload] Model image ${i + 1} sliced into ${slicedBuffers.length} parts`)
|
||||
|
||||
// 上传每个切片到S3
|
||||
for (let j = 0; j < slicedBuffers.length; j++) {
|
||||
const sliceFileName = generateUniqueFileName(`model-${i + 1}-${j + 1}`, '.webp')
|
||||
const sliceUrl = await uploadToS3(slicedBuffers[j], sliceFileName, 'image/webp')
|
||||
displayImages.push(sliceUrl)
|
||||
console.log(`[Upload] Model image ${i + 1} slice ${j + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用第一张图片作为预览图
|
||||
if (displayImages.length > 0) {
|
||||
previewUrl = displayImages[0]
|
||||
console.log('[Upload] Using first model image as preview:', previewUrl)
|
||||
}
|
||||
} catch (modelImageError) {
|
||||
console.error('[Upload] Model image processing error:', modelImageError)
|
||||
return NextResponse.json(
|
||||
{ error: `模特图片处理失败:${modelImageError instanceof Error ? modelImageError.message : '未知错误'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save zip/PSD/视频 file locally (if exists - 模特类型和材质类型可以没有素材文件)
|
||||
let zipFileName = ''
|
||||
let zipFilePath = ''
|
||||
let zipFileSize = BigInt(0)
|
||||
|
||||
if (!zipFile) {
|
||||
console.log('[Upload] No file provided (Model/Material type with images only), skipping file save...')
|
||||
} else {
|
||||
console.log('[Upload] Saving file locally...')
|
||||
const uploadDir = process.env.UPLOAD_DIR || './uploads'
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
|
||||
const zipFileExt = path.extname(zipFile.name)
|
||||
zipFileName = generateUniqueFileName('material', zipFileExt)
|
||||
zipFilePath = path.join(uploadDir, zipFileName)
|
||||
|
||||
// 读取并保存素材文件(包括视频文件)
|
||||
const zipBuffer = Buffer.from(await zipFile.arrayBuffer())
|
||||
await writeFile(zipFilePath, zipBuffer)
|
||||
zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节)
|
||||
console.log('[Upload] File saved:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
|
||||
}
|
||||
|
||||
// Create material record
|
||||
console.log('[Upload] Creating database record...')
|
||||
const material = await prisma.material.create({
|
||||
data: {
|
||||
previewUrl,
|
||||
productModel,
|
||||
brandName,
|
||||
seriesName,
|
||||
skuCode,
|
||||
materialTexture,
|
||||
categoryName: materialType === '材质' ? '通用' : categoryName, // 材质类型使用默认分类
|
||||
sceneName: materialType === '材质' ? '通用' : sceneName, // 材质类型使用默认场景
|
||||
platformName: platformName || '', // 平台分类(可选)
|
||||
materialType,
|
||||
renderer,
|
||||
renderTime,
|
||||
imageResolution,
|
||||
colorMode,
|
||||
authorName,
|
||||
zipFileName,
|
||||
zipFilePath,
|
||||
zipFileSize,
|
||||
displayImages: JSON.stringify(displayImages), // PSD/模特展示图片URLs数组
|
||||
userId: (session.user as any).id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
console.log('[Upload] Database record created:', material.id)
|
||||
|
||||
// 转换BigInt为字符串以支持JSON序列化
|
||||
const materialData = {
|
||||
...material,
|
||||
zipFileSize: material.zipFileSize.toString()
|
||||
}
|
||||
|
||||
console.log('[Upload] Upload completed successfully!')
|
||||
return NextResponse.json(
|
||||
{ message: '上传成功', material: materialData },
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
|
||||
// Log detailed error information
|
||||
if (error instanceof Error) {
|
||||
console.error('Error name:', error.name)
|
||||
console.error('Error message:', error.message)
|
||||
console.error('Error stack:', error.stack)
|
||||
}
|
||||
|
||||
// Return more specific error message for debugging
|
||||
const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试'
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '上传失败,请稍后再试',
|
||||
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
106
app/api/network-status/route.ts
Normal file
106
app/api/network-status/route.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* 判断IP是否为内网地址
|
||||
*/
|
||||
function isIntranetIP(ip: string): boolean {
|
||||
if (!ip) return false
|
||||
|
||||
// 处理可能存在的端口号
|
||||
const cleanIP = ip.split(':')[0]
|
||||
|
||||
// localhost
|
||||
if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') {
|
||||
return true
|
||||
}
|
||||
|
||||
// IPv4 内网IP段判断
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
||||
const match = cleanIP.match(ipv4Regex)
|
||||
|
||||
if (match) {
|
||||
const [, a, b, c, d] = match.map(Number)
|
||||
|
||||
// 验证IP有效性
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 10.0.0.0 - 10.255.255.255 (Class A)
|
||||
if (a === 10) return true
|
||||
|
||||
// 172.16.0.0 - 172.31.255.255 (Class B)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
|
||||
// 192.168.0.0 - 192.168.255.255 (Class C)
|
||||
if (a === 192 && b === 168) return true
|
||||
|
||||
// 169.254.0.0 - 169.254.255.255 (Link-local)
|
||||
if (a === 169 && b === 254) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
*/
|
||||
function getClientIP(request: NextRequest): string {
|
||||
// 优先级:x-real-ip > x-forwarded-for > connection
|
||||
const xRealIP = request.headers.get('x-real-ip')
|
||||
if (xRealIP) return xRealIP
|
||||
|
||||
const xForwardedFor = request.headers.get('x-forwarded-for')
|
||||
if (xForwardedFor) {
|
||||
// x-forwarded-for 可能包含多个IP,取第一个(客户端真实IP)
|
||||
return xForwardedFor.split(',')[0].trim()
|
||||
}
|
||||
|
||||
const cfConnectingIP = request.headers.get('cf-connecting-ip') // Cloudflare
|
||||
if (cfConnectingIP) return cfConnectingIP
|
||||
|
||||
const trueClientIP = request.headers.get('true-client-ip') // Akamai
|
||||
if (trueClientIP) return trueClientIP
|
||||
|
||||
// 如果都没有,返回默认值
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/network-status
|
||||
* 返回用户的网络环境状态
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const clientIP = getClientIP(request)
|
||||
const isIntranet = isIntranetIP(clientIP)
|
||||
|
||||
// 获取当前访问的域名
|
||||
const hostname = request.headers.get('host') || ''
|
||||
const publicDomains = (process.env.PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
const isPublicDomain = publicDomains.length > 0 ? publicDomains.includes(hostname) : false
|
||||
|
||||
console.log(`[Network Status] IP: ${clientIP}, isIntranet: ${isIntranet}, hostname: ${hostname}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
isIntranet,
|
||||
isPublicDomain,
|
||||
// 不返回完整IP地址(隐私保护)
|
||||
ipMasked: clientIP.split('.').slice(0, 2).join('.') + '.xxx.xxx',
|
||||
message: isIntranet
|
||||
? '检测到内网访问'
|
||||
: '检测到公网访问',
|
||||
intranetUrl: isIntranet && isPublicDomain
|
||||
? (process.env.NEXT_PUBLIC_INTRANET_URL || '')
|
||||
: null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Network Status] Error:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '网络状态检测失败'
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
193
app/api/platforms/route.ts
Normal file
193
app/api/platforms/route.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
// GET - 获取所有平台分类(包含数量统计)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const platforms = await prisma.platform.findMany({
|
||||
orderBy: { createdAt: 'asc' }
|
||||
})
|
||||
|
||||
// 优化:使用SQL聚合查询,避免N+1问题
|
||||
const counts = await prisma.$queryRaw<Array<{
|
||||
platformName: string
|
||||
count: bigint
|
||||
}>>`
|
||||
SELECT
|
||||
"platformName" as "platformName",
|
||||
COUNT(*)::bigint as count
|
||||
FROM "materials"
|
||||
WHERE "platformName" IS NOT NULL AND "platformName" != ''
|
||||
GROUP BY "platformName"
|
||||
`
|
||||
|
||||
const countMap = new Map<string, number>()
|
||||
counts.forEach(item => {
|
||||
countMap.set(item.platformName, Number(item.count))
|
||||
})
|
||||
|
||||
const platformsWithCount = platforms.map(platform => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
count: countMap.get(platform.name) || 0,
|
||||
options: (platform as any).options,
|
||||
}))
|
||||
|
||||
return NextResponse.json(platformsWithCount)
|
||||
} catch (error) {
|
||||
console.error('[Platforms] Get error:', error)
|
||||
return NextResponse.json({ error: '获取平台分类失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 创建新平台分类
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { name } = await request.json()
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return NextResponse.json({ error: '平台分类名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await prisma.platform.findUnique({
|
||||
where: { name: name.trim() }
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: '该平台分类已存在' }, { status: 400 })
|
||||
}
|
||||
|
||||
const platform = await prisma.platform.create({
|
||||
data: { name: name.trim() }
|
||||
})
|
||||
|
||||
return NextResponse.json(platform, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('[Platforms] POST error:', error)
|
||||
return NextResponse.json({ error: '创建平台分类失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - 更新平台分类
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id, name, options } = await request.json()
|
||||
|
||||
if (!id || !name || !name.trim()) {
|
||||
return NextResponse.json({ error: '平台分类ID和名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 检查是否存在其他同名平台
|
||||
const existing = await prisma.platform.findFirst({
|
||||
where: {
|
||||
name: name.trim(),
|
||||
NOT: { id }
|
||||
}
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: '该平台分类名称已被使用' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 获取旧名称
|
||||
const oldPlatform = await prisma.platform.findUnique({ where: { id } })
|
||||
if (!oldPlatform) {
|
||||
return NextResponse.json({ error: '平台分类不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 处理options:如果是数组,转换为JSON字符串;如果是字符串,直接使用
|
||||
let optionsValue: string | null = null
|
||||
if (options !== undefined && options !== null) {
|
||||
if (Array.isArray(options)) {
|
||||
optionsValue = JSON.stringify(options)
|
||||
} else if (typeof options === 'string') {
|
||||
// 支持每行一个选项的格式
|
||||
const trimmed = options.trim()
|
||||
if (trimmed && !trimmed.startsWith('[') && !trimmed.startsWith('{')) {
|
||||
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean)
|
||||
optionsValue = lines.length > 0 ? JSON.stringify(lines) : null
|
||||
} else {
|
||||
optionsValue = trimmed || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新平台分类名称和选项
|
||||
const platform = await prisma.platform.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: name.trim(),
|
||||
options: optionsValue,
|
||||
} as any
|
||||
})
|
||||
|
||||
// 更新所有使用该平台的素材
|
||||
await prisma.material.updateMany({
|
||||
where: { platformName: oldPlatform.name },
|
||||
data: { platformName: name.trim() }
|
||||
})
|
||||
|
||||
return NextResponse.json(platform)
|
||||
} catch (error) {
|
||||
console.error('[Platforms] PUT error:', error)
|
||||
return NextResponse.json({ error: '更新平台分类失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - 删除平台分类
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: '平台分类ID不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 检查是否有素材使用该平台
|
||||
const platform = await prisma.platform.findUnique({ where: { id } })
|
||||
if (!platform) {
|
||||
return NextResponse.json({ error: '平台分类不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
const materialCount = await prisma.material.count({
|
||||
where: { platformName: platform.name }
|
||||
})
|
||||
|
||||
if (materialCount > 0) {
|
||||
return NextResponse.json({
|
||||
error: `无法删除:有 ${materialCount} 个素材正在使用该平台分类`
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 删除平台分类
|
||||
await prisma.platform.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ message: '平台分类已删除' })
|
||||
} catch (error) {
|
||||
console.error('[Platforms] DELETE error:', error)
|
||||
return NextResponse.json({ error: '删除平台分类失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
87
app/api/rankings/route.ts
Normal file
87
app/api/rankings/route.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 获取排行榜数据
|
||||
export async function GET() {
|
||||
try {
|
||||
// 1. 贡献排行榜(上传素材数量最多)
|
||||
// 优化:使用SQL聚合查询,保持与其他排行榜一致
|
||||
const contributors = await prisma.$queryRaw<Array<{
|
||||
userId: string
|
||||
username: string
|
||||
materialCount: bigint
|
||||
}>>`
|
||||
SELECT
|
||||
u.id as "userId",
|
||||
u.username,
|
||||
COUNT(m.id)::bigint as "materialCount"
|
||||
FROM users u
|
||||
LEFT JOIN materials m ON u.id = m."userId"
|
||||
GROUP BY u.id, u.username
|
||||
ORDER BY "materialCount" DESC
|
||||
LIMIT 10
|
||||
`
|
||||
|
||||
// 2. 下载排行榜(素材被下载次数最多的作者)
|
||||
// 优化:使用SQL聚合查询,避免N+1问题
|
||||
const downloadRankings = await prisma.$queryRaw<Array<{
|
||||
userId: string
|
||||
totalDownloads: bigint
|
||||
username: string
|
||||
}>>`
|
||||
SELECT
|
||||
m."userId",
|
||||
COALESCE(SUM(m."downloadCount"), 0)::bigint as "totalDownloads",
|
||||
u.username
|
||||
FROM materials m
|
||||
INNER JOIN users u ON m."userId" = u.id
|
||||
GROUP BY m."userId", u.username
|
||||
ORDER BY "totalDownloads" DESC
|
||||
LIMIT 10
|
||||
`
|
||||
|
||||
const downloadUsers = downloadRankings.map((item) => ({
|
||||
username: item.username || '未知用户',
|
||||
count: Number(item.totalDownloads),
|
||||
}))
|
||||
|
||||
// 3. 点赞排行榜(素材被点赞次数最多的作者)
|
||||
// 优化:使用SQL聚合查询,避免N+1问题
|
||||
const likeRankings = await prisma.$queryRaw<Array<{
|
||||
userId: string
|
||||
totalLikes: bigint
|
||||
username: string
|
||||
}>>`
|
||||
SELECT
|
||||
m."userId",
|
||||
COALESCE(SUM(m."likeCount"), 0)::bigint as "totalLikes",
|
||||
u.username
|
||||
FROM materials m
|
||||
INNER JOIN users u ON m."userId" = u.id
|
||||
GROUP BY m."userId", u.username
|
||||
ORDER BY "totalLikes" DESC
|
||||
LIMIT 10
|
||||
`
|
||||
|
||||
const likeUsers = likeRankings.map((item) => ({
|
||||
username: item.username || '未知用户',
|
||||
count: Number(item.totalLikes),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
contributors: contributors.map((user) => ({
|
||||
username: user.username || '未知用户',
|
||||
count: Number(user.materialCount),
|
||||
})),
|
||||
downloads: downloadUsers,
|
||||
likes: likeUsers,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Rankings] GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取排行榜失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
254
app/api/restore-data/route.ts
Normal file
254
app/api/restore-data/route.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// 简单的密码hash函数(临时使用)
|
||||
function hashPassword(password: string): string {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
// 配置
|
||||
const UPLOADS_DIR = '/app/uploads';
|
||||
const S3_BUCKET_URL = process.env.RESTORE_S3_BUCKET_URL as string;
|
||||
const S3_PUBLIC_URL = process.env.RESTORE_S3_PUBLIC_URL as string;
|
||||
|
||||
// 默认配置
|
||||
const ADMIN_EMAIL = 'admin';
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_PASSWORD = 'admin123456';
|
||||
const DEFAULT_CATEGORY = '默认店铺';
|
||||
const DEFAULT_SCENE = '默认场景';
|
||||
const DEFAULT_RENDERER = 'CR';
|
||||
const DEFAULT_MATERIAL_TYPE = '3DMAX';
|
||||
|
||||
// 解析S3 XML
|
||||
function parseS3XML(xml: string): string[] {
|
||||
const keys: string[] = [];
|
||||
const keyRegex = /<Key>([^<]+)<\/Key>/g;
|
||||
let match;
|
||||
while ((match = keyRegex.exec(xml)) !== null) {
|
||||
keys.push(match[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// 获取S3预览图列表
|
||||
async function getS3PreviewFiles(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(S3_BUCKET_URL, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
const allKeys = parseS3XML(data);
|
||||
const previewFiles = allKeys.filter(key => key.includes('preview-'));
|
||||
resolve(previewFiles);
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取本地素材文件
|
||||
function getLocalMaterialFiles(uploadsDir: string): string[] {
|
||||
try {
|
||||
const files = fs.readdirSync(uploadsDir);
|
||||
return files.filter(f =>
|
||||
f.startsWith('zip-') && (f.endsWith('.zip') || f.endsWith('.rar') || f.endsWith('.7z'))
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('读取本地文件失败:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 提取时间戳
|
||||
function extractTimestamp(filename: string): number | null {
|
||||
const match = filename.match(/-(\d{13})-/);
|
||||
return match ? parseInt(match[1]) : null;
|
||||
}
|
||||
|
||||
// 提取原始名称
|
||||
function extractOriginalName(filename: string): string {
|
||||
const parts = filename.split('-');
|
||||
if (parts.length >= 3) {
|
||||
const namePart = parts.slice(2).join('-');
|
||||
return namePart.replace(/\.(zip|rar|7z)$/i, '');
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
// 匹配文件
|
||||
interface Match {
|
||||
previewFile: string | null;
|
||||
materialFile: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
|
||||
const matches: Match[] = [];
|
||||
|
||||
for (const materialFile of materialFiles) {
|
||||
const materialTimestamp = extractTimestamp(materialFile);
|
||||
if (!materialTimestamp) continue;
|
||||
|
||||
const matchedPreview = previewFiles.find(previewFile => {
|
||||
const previewTimestamp = extractTimestamp(previewFile);
|
||||
if (!previewTimestamp) return false;
|
||||
return Math.abs(previewTimestamp - materialTimestamp) < 1000;
|
||||
});
|
||||
|
||||
matches.push({
|
||||
previewFile: matchedPreview || null,
|
||||
materialFile: materialFile,
|
||||
timestamp: materialTimestamp
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push('🚀 开始数据恢复...\n');
|
||||
|
||||
// 创建管理员用户
|
||||
output.push('📝 步骤1: 创建管理员用户');
|
||||
let admin;
|
||||
try {
|
||||
const hashedPassword = hashPassword(ADMIN_PASSWORD);
|
||||
admin = await prisma.user.create({
|
||||
data: {
|
||||
email: ADMIN_EMAIL,
|
||||
username: ADMIN_USERNAME,
|
||||
password: hashedPassword,
|
||||
role: 'ADMIN',
|
||||
isActive: true,
|
||||
canDownload: true
|
||||
}
|
||||
});
|
||||
output.push(`✅ 创建管理员用户: ${ADMIN_USERNAME}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
admin = await prisma.user.findUnique({
|
||||
where: { email: ADMIN_EMAIL }
|
||||
});
|
||||
output.push(`ℹ️ 管理员用户已存在`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
throw new Error('无法创建或找到管理员用户');
|
||||
}
|
||||
|
||||
// 创建默认分类
|
||||
output.push('\n📝 步骤2: 创建默认分类和场景');
|
||||
try {
|
||||
await prisma.category.create({ data: { name: DEFAULT_CATEGORY } });
|
||||
output.push(`✅ 创建默认分类: ${DEFAULT_CATEGORY}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
output.push(`ℹ️ 默认分类已存在`);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认场景
|
||||
try {
|
||||
await prisma.scene.create({ data: { name: DEFAULT_SCENE } });
|
||||
output.push(`✅ 创建默认场景: ${DEFAULT_SCENE}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
output.push(`ℹ️ 默认场景已存在`);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取S3预览图
|
||||
output.push('\n📝 步骤3: 获取S3预览图列表');
|
||||
const previewFiles = await getS3PreviewFiles();
|
||||
output.push(`✅ 从S3找到 ${previewFiles.length} 个预览图`);
|
||||
|
||||
// 获取本地素材文件
|
||||
output.push('\n📝 步骤4: 扫描本地素材文件');
|
||||
const materialFiles = getLocalMaterialFiles(UPLOADS_DIR);
|
||||
output.push(`✅ 从本地找到 ${materialFiles.length} 个素材文件`);
|
||||
|
||||
// 匹配文件
|
||||
output.push('\n📝 步骤5: 匹配预览图和素材文件');
|
||||
const matches = matchFiles(previewFiles, materialFiles);
|
||||
const withPreview = matches.filter(m => m.previewFile).length;
|
||||
const withoutPreview = matches.length - withPreview;
|
||||
output.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`);
|
||||
|
||||
// 恢复素材数据
|
||||
output.push('\n📝 步骤6: 恢复素材数据到数据库');
|
||||
const HOST_UPLOADS_DIR = '/mnt/PoolOne/apps/youyidian-3d/uploads';
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const originalName = extractOriginalName(match.materialFile);
|
||||
const containerPath = path.join(UPLOADS_DIR, match.materialFile);
|
||||
const hostPath = path.join(HOST_UPLOADS_DIR, match.materialFile);
|
||||
const stats = fs.statSync(containerPath);
|
||||
|
||||
await prisma.material.create({
|
||||
data: {
|
||||
previewUrl: match.previewFile ? `${S3_PUBLIC_URL}${match.previewFile}` : '',
|
||||
productModel: originalName.substring(0, 50),
|
||||
categoryName: DEFAULT_CATEGORY,
|
||||
sceneName: DEFAULT_SCENE,
|
||||
materialType: DEFAULT_MATERIAL_TYPE,
|
||||
renderer: DEFAULT_RENDERER,
|
||||
authorName: ADMIN_USERNAME,
|
||||
zipFileName: match.materialFile,
|
||||
zipFilePath: hostPath,
|
||||
zipFileSize: BigInt(stats.size),
|
||||
downloadCount: 0,
|
||||
likeCount: 0,
|
||||
userId: admin.id
|
||||
}
|
||||
});
|
||||
|
||||
successCount++;
|
||||
if (successCount % 10 === 0) {
|
||||
output.push(`✅ 已恢复: ${successCount}/${matches.length}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
failCount++;
|
||||
output.push(`❌ 恢复失败: ${match.materialFile} - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
output.push(`\n📊 恢复完成统计:`);
|
||||
output.push(` ✅ 成功: ${successCount} 个`);
|
||||
output.push(` ❌ 失败: ${failCount} 个`);
|
||||
output.push(` 📁 总计: ${matches.length} 个文件`);
|
||||
|
||||
output.push(`\n🎉 数据恢复完成!`);
|
||||
output.push(`\n📌 默认信息:`);
|
||||
output.push(` 管理员账号: ${ADMIN_USERNAME}`);
|
||||
output.push(` 管理员密码: ${ADMIN_PASSWORD}`);
|
||||
output.push(` 默认分类: ${DEFAULT_CATEGORY}`);
|
||||
output.push(` 默认场景: ${DEFAULT_SCENE}`);
|
||||
output.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: output.join('\n'),
|
||||
stats: { successCount, failCount, total: matches.length }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
234
app/api/scenes/route.ts
Normal file
234
app/api/scenes/route.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 获取所有场景类别
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const categoryName = searchParams.get('category')
|
||||
|
||||
const scenes = await prisma.scene.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
})
|
||||
|
||||
// 优化:使用SQL聚合查询,避免N+1问题
|
||||
const whereClause = categoryName
|
||||
? prisma.$queryRaw<Array<{ sceneName: string, count: bigint }>>`
|
||||
SELECT
|
||||
"sceneName" as "sceneName",
|
||||
COUNT(*)::bigint as count
|
||||
FROM "materials"
|
||||
WHERE "sceneName" IS NOT NULL AND "sceneName" != '' AND "categoryName" = ${categoryName}
|
||||
GROUP BY "sceneName"
|
||||
`
|
||||
: prisma.$queryRaw<Array<{ sceneName: string, count: bigint }>>`
|
||||
SELECT
|
||||
"sceneName" as "sceneName",
|
||||
COUNT(*)::bigint as count
|
||||
FROM "materials"
|
||||
WHERE "sceneName" IS NOT NULL AND "sceneName" != ''
|
||||
GROUP BY "sceneName"
|
||||
`
|
||||
|
||||
const counts = await whereClause
|
||||
const countMap = new Map<string, number>()
|
||||
counts.forEach(item => {
|
||||
countMap.set(item.sceneName, Number(item.count))
|
||||
})
|
||||
|
||||
const scenesWithCount = scenes.map(scene => ({
|
||||
...scene,
|
||||
count: countMap.get(scene.name) || 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json(scenesWithCount)
|
||||
} catch (error) {
|
||||
console.error('Error fetching scenes:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取场景类别列表失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新场景类别
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name } = await req.json()
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: '场景类别名称不能为空' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查场景类别是否已存在
|
||||
const existing = await prisma.scene.findUnique({
|
||||
where: { name: name.trim() },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: '该场景类别已存在' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 创建新场景类别
|
||||
const scene = await prisma.scene.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(scene, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating scene:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '创建场景类别失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新场景类别名称
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id, name, options } = await req.json()
|
||||
|
||||
if (!id || !name || !name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查新名称是否已存在(排除当前场景)
|
||||
const existing = await prisma.scene.findFirst({
|
||||
where: {
|
||||
name: name.trim(),
|
||||
NOT: { id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: '该场景类别已存在' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取旧名称
|
||||
const oldScene = await prisma.scene.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
if (!oldScene) {
|
||||
return NextResponse.json(
|
||||
{ error: '分类不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 处理options:如果是数组,转换为JSON字符串;如果是字符串,直接使用
|
||||
let optionsValue: string | null = null
|
||||
if (options !== undefined && options !== null) {
|
||||
if (Array.isArray(options)) {
|
||||
optionsValue = JSON.stringify(options)
|
||||
} else if (typeof options === 'string') {
|
||||
// 支持每行一个选项的格式
|
||||
const trimmed = options.trim()
|
||||
if (trimmed && !trimmed.startsWith('[') && !trimmed.startsWith('{')) {
|
||||
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean)
|
||||
optionsValue = lines.length > 0 ? JSON.stringify(lines) : null
|
||||
} else {
|
||||
optionsValue = trimmed || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新场景类别名称和选项
|
||||
const scene = await prisma.scene.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: name.trim(),
|
||||
options: optionsValue,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步更新所有使用该场景的素材
|
||||
await prisma.material.updateMany({
|
||||
where: { sceneName: oldScene.name },
|
||||
data: { sceneName: name.trim() },
|
||||
})
|
||||
|
||||
return NextResponse.json(scene)
|
||||
} catch (error) {
|
||||
console.error('Error updating scene:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除场景类别
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少ID参数' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 删除场景类别
|
||||
await prisma.scene.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting scene:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '删除失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
208
app/api/system-settings/route.ts
Normal file
208
app/api/system-settings/route.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { defaultLogos, featureFlags } from '@/lib/config'
|
||||
import { cache } from '@/lib/cache'
|
||||
|
||||
const CACHE_KEY = 'system-settings'
|
||||
const CACHE_TTL = 60000 // 1分钟缓存
|
||||
|
||||
// GET - 获取系统设置(带缓存)
|
||||
export async function GET() {
|
||||
try {
|
||||
// 先尝试从缓存获取
|
||||
const cachedSettings = cache.get(CACHE_KEY)
|
||||
if (cachedSettings) {
|
||||
return NextResponse.json(cachedSettings)
|
||||
}
|
||||
|
||||
// 获取或创建系统设置
|
||||
let settings = await prisma.systemSettings.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
headerBgColor: true,
|
||||
headerLogoUrl: true,
|
||||
loginLogoUrl: true,
|
||||
siteName: true,
|
||||
enableUserActivation: true,
|
||||
enableRegistration: true,
|
||||
enableAiImageEdit: true,
|
||||
enableTunnelService: true,
|
||||
s3Endpoint: true,
|
||||
s3AccessKey: true,
|
||||
s3SecretKey: true,
|
||||
s3Bucket: true,
|
||||
s3Region: true,
|
||||
s3PublicUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
})
|
||||
|
||||
if (!settings) {
|
||||
// 如果不存在,创建默认设置
|
||||
settings = await prisma.systemSettings.create({
|
||||
data: {
|
||||
headerBgColor: '#1f2937',
|
||||
headerLogoUrl: defaultLogos.header,
|
||||
loginLogoUrl: defaultLogos.login,
|
||||
siteName: '由壹点3D素材',
|
||||
enableUserActivation: false,
|
||||
enableRegistration: true,
|
||||
enableAiImageEdit: featureFlags.aiEditEnabled,
|
||||
enableTunnelService: featureFlags.tunnelEnabled,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
headerBgColor: true,
|
||||
headerLogoUrl: true,
|
||||
loginLogoUrl: true,
|
||||
siteName: true,
|
||||
enableUserActivation: true,
|
||||
enableRegistration: true,
|
||||
enableAiImageEdit: true,
|
||||
enableTunnelService: true,
|
||||
s3Endpoint: true,
|
||||
s3AccessKey: true,
|
||||
s3SecretKey: true,
|
||||
s3Bucket: true,
|
||||
s3Region: true,
|
||||
s3PublicUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 存入缓存
|
||||
cache.set(CACHE_KEY, settings, CACHE_TTL)
|
||||
|
||||
return NextResponse.json(settings)
|
||||
} catch (error) {
|
||||
console.error('[SystemSettings] Get error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取系统设置失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - 更新系统设置(仅管理员)
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
headerBgColor,
|
||||
siteName,
|
||||
headerLogoUrl,
|
||||
loginLogoUrl,
|
||||
s3Endpoint,
|
||||
s3AccessKey,
|
||||
s3SecretKey,
|
||||
s3Bucket,
|
||||
s3Region,
|
||||
s3PublicUrl,
|
||||
enableUserActivation,
|
||||
enableRegistration,
|
||||
enableAiImageEdit,
|
||||
enableTunnelService,
|
||||
} = body
|
||||
|
||||
// 只有最高权限账号可以修改 enableUserActivation
|
||||
if (
|
||||
(enableUserActivation !== undefined ||
|
||||
enableAiImageEdit !== undefined ||
|
||||
enableTunnelService !== undefined) &&
|
||||
!isSuperAdmin
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '只有最高权限账号可以修改受保护的功能开关' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取或创建系统设置
|
||||
let settings = await prisma.systemSettings.findFirst()
|
||||
|
||||
if (!settings) {
|
||||
// 创建
|
||||
settings = await prisma.systemSettings.create({
|
||||
data: {
|
||||
headerBgColor: headerBgColor || '#1f2937',
|
||||
headerLogoUrl: headerLogoUrl ?? defaultLogos.header,
|
||||
loginLogoUrl: loginLogoUrl ?? defaultLogos.login,
|
||||
siteName: siteName || '由壹点3D素材',
|
||||
enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,
|
||||
enableRegistration: enableRegistration !== undefined ? enableRegistration : true,
|
||||
enableAiImageEdit:
|
||||
enableAiImageEdit !== undefined ? enableAiImageEdit : featureFlags.aiEditEnabled,
|
||||
enableTunnelService:
|
||||
enableTunnelService !== undefined ? enableTunnelService : featureFlags.tunnelEnabled,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 更新
|
||||
const updateData: any = {}
|
||||
if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor
|
||||
if (siteName !== undefined) updateData.siteName = siteName
|
||||
if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl
|
||||
if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl
|
||||
if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint
|
||||
if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey
|
||||
if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey
|
||||
if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket
|
||||
if (s3Region !== undefined) updateData.s3Region = s3Region
|
||||
if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl
|
||||
if (enableUserActivation !== undefined) updateData.enableUserActivation = enableUserActivation
|
||||
if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration
|
||||
if (enableAiImageEdit !== undefined) updateData.enableAiImageEdit = enableAiImageEdit
|
||||
if (enableTunnelService !== undefined) updateData.enableTunnelService = enableTunnelService
|
||||
|
||||
settings = await prisma.systemSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
headerBgColor: true,
|
||||
headerLogoUrl: true,
|
||||
loginLogoUrl: true,
|
||||
siteName: true,
|
||||
enableUserActivation: true,
|
||||
enableRegistration: true,
|
||||
enableAiImageEdit: true,
|
||||
enableTunnelService: true,
|
||||
s3Endpoint: true,
|
||||
s3AccessKey: true,
|
||||
s3SecretKey: true,
|
||||
s3Bucket: true,
|
||||
s3Region: true,
|
||||
s3PublicUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清除缓存,强制下次重新加载
|
||||
cache.delete(CACHE_KEY)
|
||||
|
||||
return NextResponse.json(settings)
|
||||
} catch (error) {
|
||||
console.error('Update system settings error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '更新系统设置失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
90
app/api/system-settings/upload-logo/route.ts
Normal file
90
app/api/system-settings/upload-logo/route.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadToS3 } from '@/lib/s3'
|
||||
|
||||
// POST - 上传LOGO(仅管理员)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || session.user.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 将NextRequest转换为Node.js IncomingMessage
|
||||
const formData = await request.formData()
|
||||
const logoFile = formData.get('logo') as File
|
||||
const logoType = formData.get('type') as string // 'header' or 'login'
|
||||
|
||||
if (!logoFile) {
|
||||
return NextResponse.json(
|
||||
{ error: '请选择LOGO文件' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!logoType || !['header', 'login'].includes(logoType)) {
|
||||
return NextResponse.json(
|
||||
{ error: '请指定LOGO类型(header或login)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (!logoFile.type.startsWith('image/')) {
|
||||
return NextResponse.json(
|
||||
{ error: '只支持图片格式' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 转换File为Buffer
|
||||
const bytes = await logoFile.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
|
||||
// 生成文件名
|
||||
const timestamp = Date.now()
|
||||
const ext = logoFile.name.split('.').pop()
|
||||
const filename = `${logoType}-logo-${timestamp}.${ext}`
|
||||
|
||||
// 上传到S3
|
||||
const s3Url = await uploadToS3(buffer, filename, logoFile.type)
|
||||
|
||||
// 更新系统设置
|
||||
let settings = await prisma.systemSettings.findFirst()
|
||||
|
||||
const fieldName = logoType === 'header' ? 'headerLogoUrl' : 'loginLogoUrl'
|
||||
|
||||
if (!settings) {
|
||||
settings = await prisma.systemSettings.create({
|
||||
data: {
|
||||
[fieldName]: s3Url
|
||||
}
|
||||
})
|
||||
} else {
|
||||
settings = await prisma.systemSettings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
[fieldName]: s3Url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
logoUrl: s3Url,
|
||||
type: logoType
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Upload logo error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '上传LOGO失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
118
app/api/system/init/route.ts
Normal file
118
app/api/system/init/route.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
// 系统初始化:创建默认部门、店铺、管理员账户
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查是否已经初始化过
|
||||
const existingAdmin = await prisma.user.findFirst({
|
||||
where: { email: 'admin' }
|
||||
})
|
||||
|
||||
if (existingAdmin) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '系统已初始化',
|
||||
initialized: true
|
||||
})
|
||||
}
|
||||
|
||||
// 使用事务确保所有数据都创建成功
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. 创建默认管理员账户
|
||||
const hashedPassword = await bcrypt.hash('88888888', 10)
|
||||
const admin = await tx.user.create({
|
||||
data: {
|
||||
email: 'admin',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
role: 'ADMIN',
|
||||
isActive: true,
|
||||
canDownload: true,
|
||||
// 标记为使用默认密码,首次登录需要修改
|
||||
department: '系统管理'
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 创建默认部门
|
||||
const defaultDepartment = await tx.department.create({
|
||||
data: { name: '默认部门' }
|
||||
})
|
||||
|
||||
// 3. 创建默认店铺(分类)
|
||||
const defaultCategory = await tx.category.create({
|
||||
data: { name: '默认店铺' }
|
||||
})
|
||||
|
||||
// 4. 创建默认场景
|
||||
const defaultScene = await tx.scene.create({
|
||||
data: { name: '默认场景' }
|
||||
})
|
||||
|
||||
return {
|
||||
admin,
|
||||
defaultDepartment,
|
||||
defaultCategory,
|
||||
defaultScene
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '系统初始化成功',
|
||||
initialized: true,
|
||||
data: {
|
||||
admin: {
|
||||
email: result.admin.email,
|
||||
username: result.admin.username,
|
||||
password: '88888888' // 仅返回提示,不返回实际密码
|
||||
},
|
||||
defaultDepartment: result.defaultDepartment.name,
|
||||
defaultCategory: result.defaultCategory.name,
|
||||
defaultScene: result.defaultScene.name
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('系统初始化失败:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '系统初始化失败',
|
||||
details: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查系统是否已初始化
|
||||
export async function GET() {
|
||||
try {
|
||||
const admin = await prisma.user.findFirst({
|
||||
where: { email: 'admin' }
|
||||
})
|
||||
|
||||
const hasDepartment = await prisma.department.findFirst()
|
||||
const hasCategory = await prisma.category.findFirst()
|
||||
const hasScene = await prisma.scene.findFirst()
|
||||
|
||||
return NextResponse.json({
|
||||
initialized: !!admin && !!hasDepartment && !!hasCategory && !!hasScene,
|
||||
hasAdmin: !!admin,
|
||||
hasDepartment: !!hasDepartment,
|
||||
hasCategory: !!hasCategory,
|
||||
hasScene: !!hasScene
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('检查初始化状态失败:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
initialized: false,
|
||||
error: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
44
app/api/tunnel/start/route.ts
Normal file
44
app/api/tunnel/start/route.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { featureFlags } from '@/lib/config'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
async function ensureTunnelEnabled() {
|
||||
if (featureFlags.tunnelEnabled) return true
|
||||
const settings = await prisma.systemSettings.findFirst({
|
||||
select: { enableTunnelService: true },
|
||||
})
|
||||
return !!settings?.enableTunnelService
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const isAdmin = (session.user as any)?.role === 'ADMIN'
|
||||
const isSuperAdmin = (session.user as any)?.isSuperAdmin
|
||||
// 仅超级管理员或已授权管理员可操作(目前限制最高权限)
|
||||
if (!isAdmin || !isSuperAdmin) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 })
|
||||
}
|
||||
|
||||
const enabled = await ensureTunnelEnabled()
|
||||
if (!enabled) {
|
||||
return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 })
|
||||
}
|
||||
|
||||
// TODO: 调用具体的隧道提供商启动逻辑
|
||||
return NextResponse.json({
|
||||
message: '隧道启动成功(占位)',
|
||||
tunnelUrl: 'https://mock-tunnel.example.com',
|
||||
expiresAt: null,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Tunnel start error:', e)
|
||||
return NextResponse.json({ error: '服务异常' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
app/api/tunnel/status/route.ts
Normal file
39
app/api/tunnel/status/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { featureFlags } from '@/lib/config'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
async function ensureTunnelEnabled() {
|
||||
if (featureFlags.tunnelEnabled) return true
|
||||
const settings = await prisma.systemSettings.findFirst({
|
||||
select: { enableTunnelService: true },
|
||||
})
|
||||
return !!settings?.enableTunnelService
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const isAdmin = (session.user as any)?.role === 'ADMIN'
|
||||
const isSuperAdmin = (session.user as any)?.isSuperAdmin
|
||||
if (!isAdmin || !isSuperAdmin) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 })
|
||||
}
|
||||
|
||||
const enabled = await ensureTunnelEnabled()
|
||||
if (!enabled) {
|
||||
return NextResponse.json({ enabled: false, status: 'disabled' })
|
||||
}
|
||||
|
||||
// TODO: 查询隧道状态
|
||||
return NextResponse.json({ enabled: true, status: 'stopped' })
|
||||
} catch (e) {
|
||||
console.error('Tunnel status error:', e)
|
||||
return NextResponse.json({ error: '服务异常' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
app/api/tunnel/stop/route.ts
Normal file
39
app/api/tunnel/stop/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { featureFlags } from '@/lib/config'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
async function ensureTunnelEnabled() {
|
||||
if (featureFlags.tunnelEnabled) return true
|
||||
const settings = await prisma.systemSettings.findFirst({
|
||||
select: { enableTunnelService: true },
|
||||
})
|
||||
return !!settings?.enableTunnelService
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const isAdmin = (session.user as any)?.role === 'ADMIN'
|
||||
const isSuperAdmin = (session.user as any)?.isSuperAdmin
|
||||
if (!isAdmin || !isSuperAdmin) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 })
|
||||
}
|
||||
|
||||
const enabled = await ensureTunnelEnabled()
|
||||
if (!enabled) {
|
||||
return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 })
|
||||
}
|
||||
|
||||
// TODO: 调用隧道提供商停止逻辑
|
||||
return NextResponse.json({ message: '隧道已停止(占位)' })
|
||||
} catch (e) {
|
||||
console.error('Tunnel stop error:', e)
|
||||
return NextResponse.json({ error: '服务异常' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
84
app/api/user/change-password/route.ts
Normal file
84
app/api/user/change-password/route.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
// 修改密码
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { oldPassword, newPassword } = await req.json()
|
||||
|
||||
if (!oldPassword || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: '请提供旧密码和新密码' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: '新密码长度至少为6位' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password)
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ error: '旧密码错误' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查新密码是否与旧密码相同
|
||||
const isSamePassword = await bcrypt.compare(newPassword, user.password)
|
||||
if (isSamePassword) {
|
||||
return NextResponse.json(
|
||||
{ error: '新密码不能与旧密码相同' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 10)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashedNewPassword }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '密码修改成功'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('修改密码失败:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '修改密码失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
46
app/api/user/check-default-password/route.ts
Normal file
46
app/api/user/check-default-password/route.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
// 检查用户是否使用默认密码
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户不存在' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 检查密码是否为默认密码 88888888
|
||||
const isDefaultPassword = await bcrypt.compare('88888888', user.password)
|
||||
|
||||
return NextResponse.json({
|
||||
isDefaultPassword,
|
||||
email: user.email,
|
||||
username: user.username
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('检查默认密码失败:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '检查失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
304
app/complete-profile/page.tsx
Normal file
304
app/complete-profile/page.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface Department {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function CompleteProfileContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const [department, setDepartment] = useState('')
|
||||
const [departments, setDepartments] = useState<Department[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [userInfo, setUserInfo] = useState<any>(null)
|
||||
|
||||
// 系统设置
|
||||
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
|
||||
const [siteName, setSiteName] = useState('由壹点3D素材')
|
||||
|
||||
useEffect(() => {
|
||||
// 加载系统设置
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system-settings')
|
||||
if (response.ok) {
|
||||
const settings = await response.json()
|
||||
setLoginLogoUrl(settings.loginLogoUrl || loginLogoUrl)
|
||||
setSiteName(settings.siteName || siteName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载系统设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载部门列表
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/departments')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setDepartments(data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载部门列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 从URL参数获取用户信息
|
||||
const userInfoParam = searchParams.get('userInfo')
|
||||
if (userInfoParam) {
|
||||
try {
|
||||
const info = JSON.parse(decodeURIComponent(userInfoParam))
|
||||
setUserInfo(info)
|
||||
} catch (error) {
|
||||
console.error('解析用户信息失败:', error)
|
||||
setError('无效的用户信息,请重新登录')
|
||||
}
|
||||
} else {
|
||||
setError('缺少用户信息,请重新登录')
|
||||
}
|
||||
|
||||
loadSettings()
|
||||
loadDepartments()
|
||||
}, [searchParams])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!department) {
|
||||
setError('请选择部门')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('') // 清空错误信息
|
||||
setSuccess('') // 清空成功信息
|
||||
|
||||
try {
|
||||
// 调用API完善用户信息(只需要选择部门)
|
||||
const response = await fetch('/api/dingtalk/complete-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: userInfo?.id,
|
||||
department,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || '信息提交失败')
|
||||
}
|
||||
|
||||
// 如果是账号关联(关联到已有账号),提示用户使用原账号密码登录
|
||||
if (data.isAccountLink) {
|
||||
setSuccess('账号关联成功!正在跳转到登录页面,您可以使用原账号密码登录或继续使用钉钉扫码登录。')
|
||||
setTimeout(() => {
|
||||
// 智能跳转:保持在内网或外网环境
|
||||
if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {
|
||||
window.location.href = `${window.location.origin}/login`
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查新用户是否已激活
|
||||
if (data.user && !data.user.isActive) {
|
||||
setSuccess('信息提交成功!您的账号需要管理员激活后才能登录使用。')
|
||||
setTimeout(() => {
|
||||
// 智能跳转:保持在内网或外网环境
|
||||
if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {
|
||||
window.location.href = `${window.location.origin}/login`
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
// 已激活用户:使用 NextAuth 自动登录
|
||||
const result = await signIn('credentials', {
|
||||
email: data.user.email,
|
||||
password: 'dingtalk_auto_login',
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error('登录失败')
|
||||
}
|
||||
|
||||
// 智能跳转:保持在内网或外网环境
|
||||
if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {
|
||||
window.location.href = window.location.origin
|
||||
} else {
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('提交失败:', error)
|
||||
setError(error.message || '提交失败,请稍后再试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !userInfo) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2 text-red-600">出错了</h2>
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/login')}
|
||||
className="btn-primary"
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Image
|
||||
src={loginLogoUrl}
|
||||
alt={siteName}
|
||||
width={80}
|
||||
height={80}
|
||||
unoptimized
|
||||
className="rounded-apple shadow-apple"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">完善个人信息</h1>
|
||||
<p className="text-gray-600 mt-2">首次登录,请完善您的用户信息</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="card-apple p-8">
|
||||
{userInfo && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{userInfo.avatar && (
|
||||
<Image
|
||||
src={userInfo.avatar}
|
||||
alt={userInfo.realName}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">钉钉账号</p>
|
||||
<p className="font-medium text-gray-900">{userInfo.realName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示钉钉用户名(只读) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<div className="input-apple bg-gray-50 text-gray-700 cursor-not-allowed">
|
||||
{userInfo?.username || userInfo?.realName || '加载中...'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">使用您的钉钉姓名作为系统用户名</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
所属部门 *
|
||||
</label>
|
||||
<select
|
||||
value={department}
|
||||
onChange={(e) => setDepartment(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="">请选择部门</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.name}>
|
||||
{dept.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '提交中...' : '完成并登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/login')}
|
||||
className="text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompleteProfilePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<CompleteProfileContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
191
app/dingtalk-callback/page.tsx
Normal file
191
app/dingtalk-callback/page.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
|
||||
function DingtalkCallbackContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [message, setMessage] = useState('正在处理钉钉登录...')
|
||||
|
||||
useEffect(() => {
|
||||
const handleDingtalkLogin = async () => {
|
||||
try {
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state') || 'default'
|
||||
|
||||
if (!code) {
|
||||
throw new Error('授权失败:缺少授权码')
|
||||
}
|
||||
|
||||
// 解析state参数,获取原始访问地址
|
||||
let originUrl = ''
|
||||
try {
|
||||
const stateData = JSON.parse(decodeURIComponent(state))
|
||||
originUrl = stateData.origin || ''
|
||||
} catch (e) {
|
||||
// state解析失败,忽略
|
||||
}
|
||||
|
||||
setMessage('正在验证授权信息...')
|
||||
|
||||
// 调用钉钉登录接口
|
||||
const response = await fetch('/api/dingtalk/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, state }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || '登录失败')
|
||||
}
|
||||
|
||||
// 检查是否需要完善信息
|
||||
if (data.needCompleteProfile) {
|
||||
setMessage('首次登录,正在跳转到信息完善页面...')
|
||||
setStatus('success')
|
||||
|
||||
// 跳转到信息完善页面
|
||||
const userInfoParam = encodeURIComponent(JSON.stringify(data.user))
|
||||
setTimeout(() => {
|
||||
if (window.parent !== window) {
|
||||
// 在 iframe 中,通知父窗口跳转
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'dingtalk_need_complete_profile',
|
||||
userInfo: data.user,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
} else {
|
||||
router.push(`/complete-profile?userInfo=${userInfoParam}`)
|
||||
}
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
|
||||
setMessage('登录成功,正在跳转...')
|
||||
setStatus('success')
|
||||
|
||||
// 检查来源:优先使用state中的origin,其次使用referrer
|
||||
const isFromIntranet = originUrl && originUrl.includes('192.168.')
|
||||
|
||||
// 通知父窗口登录成功(如果在 iframe 中)
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'dingtalk_login_success',
|
||||
userInfo: data.user,
|
||||
originUrl: originUrl // 携带原始地址
|
||||
},
|
||||
'*'
|
||||
)
|
||||
} else {
|
||||
// 不在 iframe 中(直接访问回调页面)
|
||||
if (isFromIntranet) {
|
||||
// 从内网来的:不在这里创建session,跳转回内网后由内网页面创建
|
||||
setMessage('登录成功,正在返回内网...')
|
||||
// 将用户信息作为参数传递,让内网页面创建session
|
||||
window.location.href = `${originUrl}/login?dingtalk_success=true&email=${encodeURIComponent(data.user.email)}`
|
||||
} else {
|
||||
// 从外网来的:直接创建session并跳转
|
||||
const signInResult = await signIn('credentials', {
|
||||
email: data.user.email,
|
||||
password: 'dingtalk_auto_login',
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (signInResult?.error) {
|
||||
throw new Error(signInResult.error)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('钉钉登录错误:', error)
|
||||
setStatus('error')
|
||||
setMessage(error.message || '登录失败,请稍后再试')
|
||||
|
||||
// 3秒后关闭或跳转
|
||||
setTimeout(() => {
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
handleDingtalkLogin()
|
||||
}, [searchParams, router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="text-center">
|
||||
{status === 'loading' && (
|
||||
<div className="mb-4">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="mb-4">
|
||||
<svg className="mx-auto h-12 w-12 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="mb-4">
|
||||
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className={`text-xl font-semibold mb-2 ${
|
||||
status === 'error' ? 'text-red-600' : 'text-gray-800'
|
||||
}`}>
|
||||
{status === 'loading' && '钉钉登录'}
|
||||
{status === 'success' && '登录成功'}
|
||||
{status === 'error' && '登录失败'}
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DingtalkCallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2 text-gray-800">钉钉登录</h2>
|
||||
<p className="text-gray-600">正在加载...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<DingtalkCallbackContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
412
app/edit/[id]/page.tsx
Normal file
412
app/edit/[id]/page.tsx
Normal file
@ -0,0 +1,412 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
interface Material {
|
||||
id: string
|
||||
userId: string
|
||||
productModel: string
|
||||
brandName?: string | null
|
||||
seriesName?: string | null
|
||||
skuCode?: string | null
|
||||
materialTexture?: string | null
|
||||
categoryName: string
|
||||
sceneName: string
|
||||
materialType: string
|
||||
renderer: string
|
||||
renderTime?: string | null
|
||||
imageResolution?: string | null
|
||||
colorMode?: string | null
|
||||
authorName: string
|
||||
}
|
||||
|
||||
export default function EditMaterialPage({ params }: { params: { id: string } }) {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const [material, setMaterial] = useState<Material | null>(null)
|
||||
const [productModel, setProductModel] = useState('')
|
||||
const [brandName, setBrandName] = useState('')
|
||||
const [seriesName, setSeriesName] = useState('')
|
||||
const [skuCode, setSkuCode] = useState('')
|
||||
const [materialTexture, setMaterialTexture] = useState('')
|
||||
const [categoryName, setCategoryName] = useState('')
|
||||
const [sceneName, setSceneName] = useState('')
|
||||
const [materialType, setMaterialType] = useState('')
|
||||
const [renderer, setRenderer] = useState('')
|
||||
const [renderTime, setRenderTime] = useState('')
|
||||
const [imageResolution, setImageResolution] = useState('')
|
||||
const [colorMode, setColorMode] = useState('')
|
||||
const [authorName, setAuthorName] = useState('')
|
||||
|
||||
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
|
||||
|
||||
const materialTypes = [
|
||||
'3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '材质', '模特', '标准饰品', '其他'
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'authenticated') {
|
||||
fetchMaterial()
|
||||
fetchCategories()
|
||||
fetchScenes()
|
||||
}
|
||||
}, [status, params.id])
|
||||
|
||||
const fetchMaterial = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/materials/${params.id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('获取素材信息失败')
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// 权限检查
|
||||
if (data.userId !== (session?.user as any)?.id && (session?.user as any)?.role !== 'ADMIN') {
|
||||
setError('您没有权限编辑此素材')
|
||||
setTimeout(() => router.push('/'), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
setMaterial(data)
|
||||
setProductModel(data.productModel || '')
|
||||
setBrandName(data.brandName || '')
|
||||
setSeriesName(data.seriesName || '')
|
||||
setSkuCode(data.skuCode || '')
|
||||
setMaterialTexture(data.materialTexture || '')
|
||||
setCategoryName(data.categoryName || '')
|
||||
setSceneName(data.sceneName || '')
|
||||
setMaterialType(data.materialType || '')
|
||||
setRenderer(data.renderer || '')
|
||||
setRenderTime(data.renderTime || '')
|
||||
setImageResolution(data.imageResolution || '')
|
||||
setColorMode(data.colorMode || '')
|
||||
setAuthorName(data.authorName || '')
|
||||
setLoading(false)
|
||||
} catch (error: any) {
|
||||
console.error('获取素材失败:', error)
|
||||
setError(error.message || '获取素材信息失败')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/categories')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCategories(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchScenes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/scenes')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setScenes(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取场景失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
|
||||
setError('请填写所有必填字段')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/materials/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productModel,
|
||||
brandName,
|
||||
seriesName,
|
||||
skuCode,
|
||||
materialTexture,
|
||||
categoryName,
|
||||
sceneName,
|
||||
materialType,
|
||||
renderer,
|
||||
renderTime,
|
||||
imageResolution,
|
||||
colorMode,
|
||||
authorName
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '更新失败')
|
||||
}
|
||||
|
||||
setSuccess('素材信息更新成功!')
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 1500)
|
||||
} catch (error: any) {
|
||||
console.error('更新失败:', error)
|
||||
setError(error.message || '更新失败,请稍后重试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'loading' || loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header session={session} />
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div className="text-center text-gray-600 dark:text-gray-400">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header session={session} />
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">编辑素材信息</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 p-4 bg-green-50 border border-green-200 text-green-600 rounded-lg">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
产品型号 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productModel}
|
||||
onChange={(e) => setProductModel(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">商品 / 品牌信息</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">品牌名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="例如:Apple、小米"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">系列 / 产品线</label>
|
||||
<input
|
||||
type="text"
|
||||
value={seriesName}
|
||||
onChange={(e) => setSeriesName(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="例如:iPhone 15 Pro"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SKU 编码</label>
|
||||
<input
|
||||
type="text"
|
||||
value={skuCode}
|
||||
onChange={(e) => setSkuCode(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="例如:SKU-0001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">材质 / 工艺</label>
|
||||
<input
|
||||
type="text"
|
||||
value={materialTexture}
|
||||
onChange={(e) => setMaterialTexture(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="例如:钛金属、皮革、磨砂玻璃等"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">渲染 / 制作信息</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">渲染耗时</label>
|
||||
<input
|
||||
type="text"
|
||||
value={renderTime}
|
||||
onChange={(e) => setRenderTime(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="例如:2 小时 / 120 分钟"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">输出分辨率</label>
|
||||
<input
|
||||
type="text"
|
||||
value={imageResolution}
|
||||
onChange={(e) => setImageResolution(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="例如:5000 x 5000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">颜色模式</label>
|
||||
<select
|
||||
value={colorMode}
|
||||
onChange={(e) => setColorMode(e.target.value)}
|
||||
className="input-apple"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
<option value="RGB">RGB</option>
|
||||
<option value="CMYK">CMYK</option>
|
||||
<option value="LAB">Lab</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
分类 *
|
||||
</label>
|
||||
<select
|
||||
value={categoryName}
|
||||
onChange={(e) => setCategoryName(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="">请选择分类</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.name}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
场景 *
|
||||
</label>
|
||||
<select
|
||||
value={sceneName}
|
||||
onChange={(e) => setSceneName(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="">请选择场景</option>
|
||||
{scenes.map((scene) => (
|
||||
<option key={scene.id} value={scene.name}>{scene.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
素材类型 *
|
||||
</label>
|
||||
<select
|
||||
value={materialType}
|
||||
onChange={(e) => setMaterialType(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="">请选择素材类型</option>
|
||||
{materialTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
渲染器 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={renderer}
|
||||
onChange={(e) => setRenderer(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="例如: V-Ray, Corona, Arnold"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
作者 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authorName}
|
||||
onChange={(e) => setAuthorName(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex-1 px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-apple font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 btn-primary"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
55
app/globals.css
Normal file
55
app/globals.css
Normal file
@ -0,0 +1,55 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 250, 250, 250;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 18, 18, 18;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
/* Apple-style button */
|
||||
.btn-apple {
|
||||
@apply px-6 py-2.5 rounded-apple font-medium transition-all duration-200 shadow-apple hover:shadow-apple-lg;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn-apple bg-gray-900 dark:bg-gray-700 text-white hover:bg-gray-800 dark:hover:bg-gray-600 active:scale-95;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn-apple bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 active:scale-95;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn-apple bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-600 active:scale-95;
|
||||
}
|
||||
|
||||
/* Apple-style input */
|
||||
.input-apple {
|
||||
@apply w-full px-4 py-3 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none transition-all;
|
||||
}
|
||||
|
||||
/* Apple-style card */
|
||||
.card-apple {
|
||||
@apply bg-white dark:bg-gray-800 rounded-apple shadow-apple hover:shadow-apple-lg transition-all duration-300 border border-transparent dark:border-gray-700;
|
||||
}
|
||||
|
||||
48
app/layout.tsx
Normal file
48
app/layout.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import Providers from './providers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 强制动态渲染,避免构建时数据库连接错误
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function getSystemSettings() {
|
||||
try {
|
||||
// 直接从数据库查询,避免网络请求超时问题
|
||||
const settings = await prisma.systemSettings.findFirst({
|
||||
select: {
|
||||
siteName: true,
|
||||
}
|
||||
})
|
||||
return settings
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch system settings:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getSystemSettings()
|
||||
|
||||
return {
|
||||
title: settings?.siteName || '由壹点3D素材',
|
||||
description: '3D素材分享平台',
|
||||
}
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="font-sans antialiased">
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
390
app/login/page.tsx
Normal file
390
app/login/page.tsx
Normal file
@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showDingtalkModal, setShowDingtalkModal] = useState(false)
|
||||
|
||||
// 系统设置
|
||||
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
|
||||
const [siteName, setSiteName] = useState('由壹点3D素材')
|
||||
|
||||
// Logo 加载状态
|
||||
const [logoError, setLogoError] = useState(false)
|
||||
const [logoRetryCount, setLogoRetryCount] = useState(0)
|
||||
|
||||
// 钉钉配置(从环境变量或配置文件获取)
|
||||
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
|
||||
|
||||
// 加载系统设置
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system-settings')
|
||||
if (response.ok) {
|
||||
const settings = await response.json()
|
||||
setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
|
||||
setSiteName(settings.siteName || '由壹点3D素材')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载系统设置失败:', error)
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
// 页面加载时识别访问环境
|
||||
useEffect(() => {
|
||||
const envDomains = (process.env.NEXT_PUBLIC_PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
const isPublicDomain = envDomains.length > 0 ? envDomains.includes(window.location.hostname) : false
|
||||
const isIntranet = window.location.hostname.includes('192.168.')
|
||||
|
||||
if (isPublicDomain) {
|
||||
console.log('🌐 检测到公网访问 (' + window.location.hostname + ')')
|
||||
} else if (isIntranet) {
|
||||
console.log('🏠 检测到内网访问 (' + window.location.hostname + ')')
|
||||
}
|
||||
|
||||
// 注意:内网检测已改为后端实现(/api/network-status)
|
||||
// 登录成功后会在首页自动调用后端API判断是否需要显示切换提示
|
||||
}, [])
|
||||
|
||||
// 处理从内网跳转来的钉钉登录请求
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const dingtalkRedirect = urlParams.get('dingtalk_redirect')
|
||||
|
||||
if (dingtalkRedirect === 'true') {
|
||||
console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')
|
||||
// 清理URL参数
|
||||
window.history.replaceState({}, '', '/login')
|
||||
// 等待页面加载完成后触发钉钉登录
|
||||
const timer = setTimeout(() => {
|
||||
document.getElementById('dingtalk-login-btn')?.click()
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 处理钉钉登录成功后的自动登录(内网场景)
|
||||
useEffect(() => {
|
||||
const handleDingtalkAutoLogin = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const dingtalkSuccess = urlParams.get('dingtalk_success')
|
||||
const userEmail = urlParams.get('email')
|
||||
|
||||
if (dingtalkSuccess === 'true' && userEmail) {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 在内网域名下创建 session
|
||||
const result = await signIn('credentials', {
|
||||
email: decodeURIComponent(userEmail),
|
||||
password: 'dingtalk_auto_login',
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError('登录失败,请重试')
|
||||
} else {
|
||||
// 清除 URL 参数并跳转到首页
|
||||
window.history.replaceState({}, '', '/login')
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动登录失败:', error)
|
||||
setError('登录失败,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
handleDingtalkAutoLogin()
|
||||
}, [router])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
setError('登录失败,请稍后再试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDingtalkLogin = () => {
|
||||
// 强制使用外网域名作为回调地址,支持内网访问
|
||||
const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`
|
||||
|
||||
// 在state中携带原始访问地址信息
|
||||
const isIntranet = window.location.hostname.includes('192.168.')
|
||||
const originUrl = isIntranet ? window.location.origin : ''
|
||||
const stateData = JSON.stringify({ origin: originUrl })
|
||||
|
||||
const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +
|
||||
`redirect_uri=${encodeURIComponent(redirectUrl)}` +
|
||||
`&client_id=${DINGTALK_CLIENT_ID}` +
|
||||
`&scope=openid` +
|
||||
`&response_type=code` +
|
||||
`&state=${encodeURIComponent(stateData)}` +
|
||||
`&prompt=consent`
|
||||
|
||||
// 内网访问:由于混合内容限制,直接跳转到公网域名登录
|
||||
if (isIntranet) {
|
||||
console.log('🔄 内网环境,跳转到公网域名进行钉钉登录...')
|
||||
const portal = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin
|
||||
window.location.href = `${portal}/login?dingtalk_redirect=true&return_url=${encodeURIComponent(originUrl)}`
|
||||
return
|
||||
}
|
||||
|
||||
// 公网访问:使用iframe模式
|
||||
setShowDingtalkModal(true)
|
||||
setTimeout(() => {
|
||||
const iframe = document.getElementById('dingtalkIframe') as HTMLIFrameElement
|
||||
if (iframe) {
|
||||
iframe.src = dingtalkAuthUrl
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 监听来自 iframe 的消息
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.data && event.data.type === 'dingtalk_login_success') {
|
||||
setShowDingtalkModal(false)
|
||||
|
||||
// 创建 NextAuth session
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email: event.data.userInfo.email,
|
||||
password: 'dingtalk_auto_login',
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError('登录失败,请重试')
|
||||
} else {
|
||||
// 登录成功,智能跳转
|
||||
const originUrl = event.data.originUrl
|
||||
if (originUrl && originUrl.includes('192.168.')) {
|
||||
// 从内网发起的登录,跳回内网
|
||||
window.location.href = originUrl
|
||||
} else if (window.location.hostname.includes('192.168.')) {
|
||||
// 当前在内网,刷新页面
|
||||
window.location.href = window.location.origin
|
||||
} else {
|
||||
// 外网访问,跳转到首页
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建session失败:', error)
|
||||
setError('登录失败,请重试')
|
||||
}
|
||||
} else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {
|
||||
setShowDingtalkModal(false)
|
||||
// 跳转到信息完善页面
|
||||
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
|
||||
router.push(`/complete-profile?userInfo=${userInfoParam}`)
|
||||
} else if (event.data && event.data.type === 'dingtalk_login_error') {
|
||||
setShowDingtalkModal(false)
|
||||
setError(event.data.message || '钉钉登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
{loginLogoUrl && !logoError ? (
|
||||
<Image
|
||||
key={logoRetryCount}
|
||||
src={loginLogoUrl + (logoRetryCount > 0 ? `?retry=${logoRetryCount}` : '')}
|
||||
alt={siteName}
|
||||
width={80}
|
||||
height={80}
|
||||
unoptimized
|
||||
className="rounded-apple shadow-apple"
|
||||
onError={(e) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
setLogoRetryCount((prevCount) => {
|
||||
const newCount = prevCount + 1
|
||||
if (newCount <= 3) {
|
||||
// 重试加载(最多3次)
|
||||
console.warn(`登录页 Logo 加载失败,正在重试 (${newCount}/3):`, loginLogoUrl)
|
||||
setTimeout(() => {
|
||||
img.src = loginLogoUrl + `?retry=${newCount}`
|
||||
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
|
||||
return newCount
|
||||
} else {
|
||||
// 重试失败,记录错误并显示占位符
|
||||
console.error('登录页 Logo 加载失败,已重试3次:', loginLogoUrl)
|
||||
setLogoError(true)
|
||||
img.style.display = 'none'
|
||||
return prevCount
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-apple shadow-apple bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
|
||||
<span className="text-3xl font-bold text-white">{siteName.charAt(0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{siteName}</h1>
|
||||
<p className="text-gray-600 mt-2">欢迎回来,请登录您的账号</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="card-apple p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
{/* 分隔线 */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">或</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 钉钉登录按钮 */}
|
||||
<button
|
||||
id="dingtalk-login-btn"
|
||||
type="button"
|
||||
onClick={handleDingtalkLogin}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl shadow-sm bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
|
||||
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
|
||||
</svg>
|
||||
<span className="text-lg font-medium text-gray-700">钉钉登录</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
还没有账号?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-gray-900 hover:text-gray-700 font-medium underline"
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 钉钉登录 iframe 弹窗 */}
|
||||
{showDingtalkModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
onClick={() => setShowDingtalkModal(false)}
|
||||
>
|
||||
<div
|
||||
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900">钉钉扫码登录</h3>
|
||||
<button
|
||||
onClick={() => setShowDingtalkModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<iframe
|
||||
id="dingtalkIframe"
|
||||
className="w-full h-96 border-0 rounded-lg"
|
||||
title="钉钉登录"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1584
app/page.tsx
Normal file
1584
app/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
13
app/providers.tsx
Normal file
13
app/providers.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Providers({ children }: ProvidersProps) {
|
||||
return <SessionProvider>{children}</SessionProvider>
|
||||
}
|
||||
|
||||
207
app/register/page.tsx
Normal file
207
app/register/page.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [department, setDepartment] = useState('')
|
||||
const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchDepartments()
|
||||
}, [])
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/departments')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setDepartments(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching departments:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('密码长度至少为6个字符')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, username, department, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/login?registered=true')
|
||||
} else {
|
||||
setError(data.error || '注册失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
setError('注册失败,请稍后再试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Image
|
||||
src={process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''}
|
||||
alt="由壹点3D素材"
|
||||
width={80}
|
||||
height={80}
|
||||
unoptimized
|
||||
className="rounded-apple shadow-apple"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">由壹点3D素材</h1>
|
||||
<p className="text-gray-600 mt-2">创建您的账号,开始分享3D素材</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="card-apple p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="选择一个用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
部门
|
||||
</label>
|
||||
<select
|
||||
value={department}
|
||||
onChange={(e) => setDepartment(e.target.value)}
|
||||
className="input-apple"
|
||||
>
|
||||
<option value="">请选择部门</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.name}>
|
||||
{dept.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{departments.length === 0 && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
暂无可选部门,请联系管理员添加
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
已有账号?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-gray-900 hover:text-gray-700 font-medium underline"
|
||||
>
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
585
app/share/[id]/page.tsx
Normal file
585
app/share/[id]/page.tsx
Normal file
@ -0,0 +1,585 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { format } from 'date-fns'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
interface Material {
|
||||
id: string
|
||||
previewUrl: string
|
||||
productModel: string
|
||||
brandName?: string | null
|
||||
seriesName?: string | null
|
||||
skuCode?: string | null
|
||||
materialTexture?: string | null
|
||||
categoryName: string
|
||||
sceneName: string
|
||||
materialType: string
|
||||
renderer?: string | null
|
||||
renderTime?: string | null
|
||||
imageResolution?: string | null
|
||||
colorMode?: string | null
|
||||
authorName: string
|
||||
zipFileName: string
|
||||
zipFileSize: string | number
|
||||
downloadCount: number
|
||||
likeCount: number
|
||||
displayImages: string[] // PSD展示图片URLs
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function SharePage() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const materialId = params.id as string
|
||||
|
||||
const [material, setMaterial] = useState<Material | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [showBackToTop, setShowBackToTop] = useState(false)
|
||||
const [aiEditEnabled, setAiEditEnabled] = useState(
|
||||
(process.env.NEXT_PUBLIC_AI_EDIT_ENABLED || '').toLowerCase() === 'true'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// 分享页面允许未登录访问
|
||||
if (status !== 'loading') {
|
||||
fetchMaterial()
|
||||
}
|
||||
}, [status, materialId])
|
||||
|
||||
useEffect(() => {
|
||||
const ensureFeatureFlags = async () => {
|
||||
if (aiEditEnabled) return
|
||||
try {
|
||||
const response = await fetch('/api/system-settings')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data?.enableAiImageEdit) {
|
||||
setAiEditEnabled(true)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('加载系统设置失败(AI改图判断):', err)
|
||||
}
|
||||
}
|
||||
ensureFeatureFlags()
|
||||
}, [aiEditEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
// 监听滚动,控制返回顶部按钮显示
|
||||
const handleScroll = () => {
|
||||
setShowBackToTop(window.scrollY > 400)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
const fetchMaterial = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/materials/share?id=${materialId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setMaterial(data)
|
||||
} else {
|
||||
alert('素材不存在或已被删除')
|
||||
router.push('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching material:', error)
|
||||
alert('加载失败')
|
||||
router.push('/')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: string | number): string => {
|
||||
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes
|
||||
if (size === 0) return '未知'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const renderAdditionalInfo = (variant: 'compact' | 'full' = 'compact') => {
|
||||
if (!material) return null
|
||||
const entries = [
|
||||
{ label: '品牌', value: material.brandName },
|
||||
{ label: '系列', value: material.seriesName },
|
||||
{ label: 'SKU', value: material.skuCode },
|
||||
{ label: '材质 / 工艺', value: material.materialTexture },
|
||||
{ label: '渲染耗时', value: material.renderTime },
|
||||
{ label: '输出分辨率', value: material.imageResolution },
|
||||
{ label: '颜色模式', value: material.colorMode },
|
||||
].filter(entry => entry.value)
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const labelClass = variant === 'compact'
|
||||
? 'text-xs text-gray-500 dark:text-gray-400 mb-1'
|
||||
: 'text-sm text-gray-500 dark:text-gray-400 mb-1'
|
||||
const valueClass = variant === 'compact'
|
||||
? 'text-sm font-medium text-gray-900 dark:text-white break-all'
|
||||
: 'text-base font-medium text-gray-900 dark:text-white break-all'
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 ${variant === 'compact' ? 'gap-3 mt-4 pt-4 border-t border-gray-100 dark:border-gray-700' : 'gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700'}`}>
|
||||
{entries.map((entry, index) => (
|
||||
<div key={`${entry.label}-${index}`}>
|
||||
<p className={labelClass}>{entry.label}</p>
|
||||
<p className={valueClass}>{entry.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!material) return
|
||||
|
||||
setDownloading(true)
|
||||
setDownloadProgress(0)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = Math.round((e.loaded / e.total) * 100)
|
||||
setDownloadProgress(percentComplete)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const blob = new Blob([xhr.response], { type: 'application/zip' })
|
||||
|
||||
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
|
||||
let filename = 'material.zip'
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
|
||||
}
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('下载处理失败:', error)
|
||||
alert('下载失败,请稍后重试')
|
||||
}
|
||||
} else {
|
||||
alert('下载失败')
|
||||
}
|
||||
setDownloading(false)
|
||||
setDownloadProgress(0)
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
alert('下载失败,请稍后重试')
|
||||
setDownloading(false)
|
||||
setDownloadProgress(0)
|
||||
})
|
||||
|
||||
xhr.open('GET', `/api/materials/download?id=${material.id}`)
|
||||
xhr.responseType = 'arraybuffer'
|
||||
xhr.send()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">加载中...</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!material) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header session={session} />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">素材不存在</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header session={session} />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{(material.materialType === 'PSD' || material.materialType === '模特') && material.displayImages && material.displayImages.length > 0 ? (
|
||||
/* PSD/模特类型:左右布局 */
|
||||
<div className="flex gap-8 max-w-7xl mx-auto">
|
||||
{/* 左侧:展示图片(固定790px宽度) */}
|
||||
<div className="flex-shrink-0" style={{ width: '790px' }}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
|
||||
{material.displayImages.map((imageUrl, index) => (
|
||||
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={`${material.productModel} - 展示图 ${index + 1}`}
|
||||
width={790}
|
||||
height={1500}
|
||||
unoptimized
|
||||
className="w-full h-auto"
|
||||
style={{ display: 'block', maxWidth: '790px' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:素材信息(固定) */}
|
||||
<div className="w-80 flex-shrink-0">
|
||||
<div className="sticky top-24 bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{material.productModel}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium">
|
||||
{material.categoryName}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-medium">
|
||||
{material.sceneName}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium">
|
||||
{material.materialType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">作者</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{material.authorName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">下载</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{material.downloadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">点赞</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">文件名</p>
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
{renderAdditionalInfo('compact')}
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="w-full btn-primary py-3 text-sm font-medium relative overflow-hidden disabled:opacity-75"
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
下载中 {downloadProgress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
立即下载
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* AI改图(占位,默认隐藏。后续由开关控制显示) */}
|
||||
{aiEditEnabled && (
|
||||
<button
|
||||
onClick={() => alert('AI改图功能待开放')}
|
||||
className="w-full mt-3 py-2.5 bg-purple-600 text-white rounded-xl text-sm font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
AI改图(预留)
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full py-2.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : material.materialType === '视频' ? (
|
||||
/* 视频类型:视频播放布局 */
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
|
||||
{/* 视频播放器 */}
|
||||
<div className="relative w-full bg-black">
|
||||
<video
|
||||
controls
|
||||
className="w-full h-auto max-h-[800px]"
|
||||
poster={material.previewUrl}
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={`/api/materials/stream?id=${material.id}`} type="video/mp4" />
|
||||
您的浏览器不支持视频播放。
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{/* Material Info */}
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{material.productModel}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium">
|
||||
{material.categoryName}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-sm font-medium">
|
||||
{material.sceneName}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-full text-sm font-medium">
|
||||
视频
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">作者</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.authorName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
|
||||
<p className="text-lg font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">下载次数</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.downloadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">点赞数</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件名</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="w-full btn-primary py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75"
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
下载中 {downloadProgress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
立即下载
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full mt-4 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 非PSD/非视频类型:原有布局 */
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
|
||||
{/* 普通预览图 */}
|
||||
<div className="relative w-full h-96 bg-gray-200 dark:bg-gray-700">
|
||||
<Image
|
||||
src={material.previewUrl}
|
||||
alt={material.productModel}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
sizes="(max-width: 1024px) 100vw, 1024px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Material Info */}
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{material.productModel}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium">
|
||||
{material.categoryName}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-sm font-medium">
|
||||
{material.sceneName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">作者</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.authorName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
|
||||
<p className="text-lg font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">下载次数</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.downloadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">点赞数</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件名</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderAdditionalInfo('full')}
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="w-full btn-primary py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75"
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
下载中 {downloadProgress}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
立即下载
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full mt-4 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* 返回顶部按钮 */}
|
||||
{showBackToTop && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all z-50"
|
||||
title="返回顶部"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
13
change.md
Normal file
13
change.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 修改记录
|
||||
|
||||
## 2025-11-13
|
||||
- 新增 `.env.example`,并在 `README.md` 说明配置流程,统一环境变量管理。
|
||||
- 调整 `docker-compose.yml` 引入共享 env 文件与健康检查,同时新增 `docker-compose.dev.yml` 适配本地容器开发。
|
||||
- 新增 `docs/README.md` 文档索引与 `docs/WORKFLOW.md` 流程指南,更新 `scripts/README.md` 使用说明。
|
||||
- `package.json` 新增 `npm run check`(lint + prisma validate)脚本,便于提交前自检。
|
||||
- 新增 `S3_ENV_PREFIX` 支持,为测试环境提供独立命名空间,避免共用存储桶时污染正式数据,并在 `lib/s3.ts`、相关配置文档中支持该能力。
|
||||
- 新增 `scripts/tools/update-admin-account.js`,可快速把管理员账号更新为指定邮箱与密码(默认改为 `1@qq.com` / `666666`),并将脚本纳入文档说明。
|
||||
- 优化用户树形管理界面:分支标题展示部门、管理员与成员统计,分支管理员高亮显示,并在编辑用户时自动将“直属上级”定位到所选部门的管理员。
|
||||
- 编辑用户面板新增部门管理员概览,可一键设为/取消管理员并查看现有负责人,后续多管理员扩展更清晰。
|
||||
- 新增 `scripts/deploy/local-build-and-upload.ps1` 支持本地构建镜像后压缩上传服务器,同时调整 `scripts/README.md` 说明部署流程。
|
||||
- 强化部署脚本错误处理,构建/打包/上传失败时立即报错提示,避免生成空文件或误导提示。
|
||||
715
components/AIChatWidget.tsx
Normal file
715
components/AIChatWidget.tsx
Normal file
@ -0,0 +1,715 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Image from 'next/image'
|
||||
|
||||
// 添加动画样式
|
||||
const shimmerStyle = `
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
filter: blur(15px) brightness(0.7);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
filter: blur(12px) brightness(0.8);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// 图片加载占位符组件
|
||||
const ImageLoadingPlaceholder = () => {
|
||||
return (
|
||||
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800">
|
||||
{/* 动态模糊背景 */}
|
||||
<div className="absolute inset-0 animate-pulse">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 dark:via-white/10 to-transparent animate-shimmer"
|
||||
style={{
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 2s infinite'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 中心图标 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="relative">
|
||||
{/* 旋转的光圈 */}
|
||||
<div className="w-16 h-16 border-4 border-gray-300 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
|
||||
{/* 中心图标 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-gray-500 dark:text-gray-400">正在生成图片...</p>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">AI正在为您创作中</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
images?: string[]
|
||||
timestamp: Date
|
||||
isGenerating?: boolean // 是否正在生成图片
|
||||
imageCount?: number // 预期生成的图片数量
|
||||
sourceImages?: string[] // 用户发送的原始图片(用于显示模糊占位符)
|
||||
}
|
||||
|
||||
export default function AIChatWidget() {
|
||||
const { data: session } = useSession()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [images, setImages] = useState<File[]>([])
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [windowDragging, setWindowDragging] = useState(false)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 窗口拖拽功能
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return // 只响应左键
|
||||
setWindowDragging(true)
|
||||
setDragStart({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!windowDragging) return
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setWindowDragging(false)
|
||||
}
|
||||
|
||||
if (windowDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [windowDragging, dragStart])
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// 处理粘贴图片
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (!isOpen) return
|
||||
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
const file = items[i].getAsFile()
|
||||
if (file) {
|
||||
setImages(prev => [...prev, file].slice(0, 5))
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews(prev => [...prev, reader.result as string])
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('paste', handlePaste)
|
||||
}
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
}, [isOpen])
|
||||
|
||||
// 处理图片选择
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length > 0) {
|
||||
setImages(prev => [...prev, ...files].slice(0, 5)) // 最多5张图片
|
||||
|
||||
// 生成预览
|
||||
files.forEach(file => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews(prev => [...prev, reader.result as string])
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 移除图片
|
||||
const removeImage = (index: number) => {
|
||||
setImages(prev => prev.filter((_, i) => i !== index))
|
||||
setImagePreviews(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// 处理拖拽上传
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'))
|
||||
if (files.length > 0) {
|
||||
setImages(prev => [...prev, ...files].slice(0, 5))
|
||||
|
||||
files.forEach(file => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
setImagePreviews(prev => [...prev, reader.result as string])
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const handleSend = async () => {
|
||||
if ((!input.trim() && images.length === 0) || sending || !session) return
|
||||
|
||||
setSending(true)
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
images: imagePreviews,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInput('')
|
||||
const currentImages = imagePreviews
|
||||
setImagePreviews([])
|
||||
setImages([])
|
||||
|
||||
try {
|
||||
// 检测是否需要生成图片
|
||||
const shouldShowImagePlaceholder = currentImages.length > 0 ||
|
||||
userMessage.content.includes('生成') ||
|
||||
userMessage.content.includes('创建') ||
|
||||
userMessage.content.includes('画') ||
|
||||
userMessage.content.includes('图片') ||
|
||||
userMessage.content.includes('图')
|
||||
|
||||
// 如果需要生成图片,在用户消息中添加指令
|
||||
let finalMessageContent = userMessage.content
|
||||
if (shouldShowImagePlaceholder && !userMessage.content.includes('4张') && !userMessage.content.includes('四张')) {
|
||||
finalMessageContent += '\n\n(请生成4张不同风格或角度的图片)'
|
||||
}
|
||||
|
||||
// 构建消息内容
|
||||
let messageContent: any = []
|
||||
|
||||
if (finalMessageContent) {
|
||||
messageContent.push({
|
||||
type: 'text',
|
||||
text: finalMessageContent
|
||||
})
|
||||
}
|
||||
|
||||
// 添加图片
|
||||
currentImages.forEach(img => {
|
||||
messageContent.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: img }
|
||||
})
|
||||
})
|
||||
|
||||
// 构建对话历史(保留图片信息,支持上下文连续对话)
|
||||
const conversationHistory = messages
|
||||
.filter(msg => !msg.isGenerating) // 过滤掉正在生成的临时消息
|
||||
.map(msg => {
|
||||
if (msg.images && msg.images.length > 0) {
|
||||
const parts: any[] = []
|
||||
if (msg.content) {
|
||||
parts.push({ type: 'text', text: msg.content })
|
||||
}
|
||||
msg.images.forEach(img => {
|
||||
parts.push({ type: 'image_url', image_url: { url: img } })
|
||||
})
|
||||
return { role: msg.role, content: parts }
|
||||
}
|
||||
return { role: msg.role, content: msg.content }
|
||||
})
|
||||
|
||||
conversationHistory.push({
|
||||
role: 'user',
|
||||
content: messageContent.length === 1 && messageContent[0].type === 'text'
|
||||
? messageContent[0].text
|
||||
: messageContent
|
||||
})
|
||||
|
||||
// 先添加一个加载中的占位消息
|
||||
const tempAiMessageId = (Date.now() + 1).toString()
|
||||
const tempAiMessage: Message = {
|
||||
id: tempAiMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isGenerating: shouldShowImagePlaceholder,
|
||||
imageCount: shouldShowImagePlaceholder ? 4 : 0,
|
||||
sourceImages: currentImages.length > 0 ? currentImages : undefined // 保存用户发送的原图
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, tempAiMessage])
|
||||
|
||||
// 调用API
|
||||
const response = await fetch('/api/ai-chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: conversationHistory
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('发送失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 解析AI响应
|
||||
let aiContent = ''
|
||||
let aiImages: string[] = []
|
||||
|
||||
if (data.candidates && data.candidates.length > 0) {
|
||||
const candidate = data.candidates[0]
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text) {
|
||||
// 检查是否包含图片
|
||||
const base64ImageMatch = part.text.match(/!\[image\]\(data:image\/([^;]+);base64,([^)]+)\)/)
|
||||
if (base64ImageMatch) {
|
||||
const mimeType = base64ImageMatch[1]
|
||||
const base64Data = base64ImageMatch[2]
|
||||
aiImages.push(`data:image/${mimeType};base64,${base64Data}`)
|
||||
}
|
||||
|
||||
// 移除Markdown图片语法
|
||||
const cleanText = part.text
|
||||
.replace(/!\[image\]\(data:image\/[^;]+;base64,[^)]+\)/g, '')
|
||||
.replace(/!\[Image\]\([^)]+\)/g, '')
|
||||
.trim()
|
||||
if (cleanText) {
|
||||
aiContent += cleanText
|
||||
}
|
||||
} else if (part.inlineData && part.inlineData.data) {
|
||||
const mimeType = part.inlineData.mimeType || 'image/png'
|
||||
aiImages.push(`data:${mimeType};base64,${part.inlineData.data}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新临时占位消息为实际内容
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === tempAiMessageId
|
||||
? {
|
||||
...msg,
|
||||
content: aiContent || '收到您的消息了',
|
||||
images: aiImages.length > 0 ? aiImages : undefined,
|
||||
isGenerating: false
|
||||
}
|
||||
: msg
|
||||
))
|
||||
} catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'system',
|
||||
content: '发送失败,请重试',
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev, errorMessage])
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) return null
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed bottom-6 right-6 z-50">
|
||||
{/* AI聊天窗口 - 苹果风格 */}
|
||||
{isOpen && (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: shimmerStyle }} />
|
||||
<div
|
||||
className="fixed w-[420px] h-[680px] bg-white dark:bg-gray-900 rounded-2xl overflow-hidden flex flex-col"
|
||||
style={{
|
||||
left: position.x ? `${position.x}px` : 'auto',
|
||||
top: position.y ? `${position.y}px` : 'auto',
|
||||
right: position.x ? 'auto' : '24px',
|
||||
bottom: position.y ? 'auto' : '96px',
|
||||
maxHeight: '80vh',
|
||||
boxShadow: '0 20px 60px -15px rgba(0, 0, 0, 0.3), 0 10px 20px -10px rgba(0, 0, 0, 0.2)'
|
||||
}}
|
||||
>
|
||||
{/* 头部 - 渐变风格 - 可拖拽 */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
className="px-6 py-4 bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-between cursor-move select-none"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/30">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">AI助手</h3>
|
||||
<p className="text-xs text-white/80">智能对话 · 图片生成</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white/80 hover:text-white hover:bg-white/20 backdrop-blur-sm rounded-full p-2 transition-all"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-950 relative"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#cbd5e1 transparent',
|
||||
backgroundImage: 'radial-gradient(circle at 1px 1px, rgb(209 213 219 / 0.3) 1px, transparent 0)',
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 拖拽提示 */}
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 bg-blue-500/20 dark:bg-blue-400/20 backdrop-blur-sm z-10 flex items-center justify-center border-2 border-dashed border-blue-500 dark:border-blue-400 m-2 rounded-lg">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 mx-auto text-blue-500 dark:text-blue-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-lg font-semibold text-blue-600 dark:text-blue-400">松开鼠标上传图片</p>
|
||||
<p className="text-sm text-blue-500 dark:text-blue-300 mt-1">支持多张图片(最多5张)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !isDragging && (
|
||||
<div className="text-center mt-12">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center shadow-lg">
|
||||
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">👋 你好!我是AI助手</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">可以发送文字或图片与我聊天</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">支持图片编辑、内容生成等功能</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-full text-sm">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
拖拽图片到此处快速上传
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-in fade-in slide-in-from-bottom-2 duration-300`}
|
||||
>
|
||||
<div className={`max-w-[85%] ${
|
||||
msg.role === 'user'
|
||||
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg'
|
||||
: msg.role === 'assistant'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700 shadow-md'
|
||||
: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800'
|
||||
} rounded-2xl px-4 py-3`}>
|
||||
{msg.content && <div className="whitespace-pre-wrap text-sm">{msg.content}</div>}
|
||||
|
||||
{/* 显示图片加载占位符 */}
|
||||
{msg.isGenerating && (
|
||||
<div className="mt-2">
|
||||
{/* 如果有原图,显示原图的模糊占位符 */}
|
||||
{msg.sourceImages && msg.sourceImages.length > 0 ? (
|
||||
<div className={`grid gap-2 ${
|
||||
msg.sourceImages.length === 1 ? 'grid-cols-1' :
|
||||
msg.sourceImages.length === 2 ? 'grid-cols-2' :
|
||||
'grid-cols-3'
|
||||
}`}>
|
||||
{msg.sourceImages.map((img, idx) => (
|
||||
<div key={idx} className="relative rounded-2xl overflow-hidden animate-pulse">
|
||||
<img
|
||||
src={img}
|
||||
alt={`Processing ${idx + 1}`}
|
||||
className="w-full max-w-[200px] h-auto object-contain rounded-2xl transition-all duration-1000"
|
||||
style={{
|
||||
filter: 'blur(15px) brightness(0.7)',
|
||||
animation: 'breathe 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500/10 to-purple-500/10">
|
||||
<div className="bg-black/60 backdrop-blur-xl px-5 py-3 rounded-full shadow-2xl border border-white/10">
|
||||
<div className="flex items-center gap-3 text-white text-sm">
|
||||
<div className="relative">
|
||||
<div className="w-5 h-5 border-3 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
<div className="absolute inset-0 w-5 h-5 border-3 border-transparent border-r-white/40 rounded-full animate-spin" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }} />
|
||||
</div>
|
||||
<span className="font-medium">AI处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 没有原图,显示4个空白占位符(生成任务) */
|
||||
msg.imageCount && msg.imageCount > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: msg.imageCount }).map((_, idx) => (
|
||||
<div key={idx}>
|
||||
<ImageLoadingPlaceholder />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示实际图片 - 添加淡入动画 */}
|
||||
{!msg.isGenerating && msg.images && msg.images.length > 0 && (
|
||||
<div className={`mt-2 grid gap-2 animate-in fade-in duration-500 ${
|
||||
msg.images.length === 1 ? 'grid-cols-1' :
|
||||
msg.images.length === 2 ? 'grid-cols-2' :
|
||||
msg.images.length === 3 ? 'grid-cols-3' :
|
||||
'grid-cols-4'
|
||||
}`}>
|
||||
{msg.images.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative group rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-700 animate-in fade-in slide-in-from-bottom-2"
|
||||
style={{ animationDelay: `${idx * 100}ms`, animationDuration: '400ms' }}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={`Image ${idx + 1}`}
|
||||
className={`w-full rounded-2xl cursor-pointer hover:opacity-90 transition-all hover:scale-[1.02] ${
|
||||
msg.role === 'user'
|
||||
? 'max-w-[200px] h-auto object-contain' // 用户发送的图片:限制最大宽度,保持比例
|
||||
: 'aspect-square object-cover' // AI生成的图片:正方形显示
|
||||
}`}
|
||||
onClick={() => {
|
||||
// 点击图片在新窗口打开
|
||||
const win = window.open()
|
||||
if (win) {
|
||||
win.document.write(`<img src="${img}" style="max-width:100%;height:auto;" />`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* 下载按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const link = document.createElement('a')
|
||||
link.href = img
|
||||
link.download = `image-${Date.now()}-${idx + 1}.png`
|
||||
link.click()
|
||||
}}
|
||||
className="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white p-2 rounded-xl opacity-0 group-hover:opacity-100 transition-all shadow-lg"
|
||||
title="下载图片"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs opacity-60 mt-1">
|
||||
{msg.timestamp.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 图片预览区 */}
|
||||
{imagePreviews.length > 0 && (
|
||||
<div className="px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
|
||||
{imagePreviews.map((preview, idx) => (
|
||||
<div key={idx} className="relative flex-shrink-0 group">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${idx}`}
|
||||
className="w-16 h-16 object-cover rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeImage(idx)}
|
||||
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600"
|
||||
aria-label="删除图片"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区 */}
|
||||
<div className="p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-end gap-3">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageSelect}
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
{/* 上传按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="上传图片"
|
||||
aria-label="上传图片"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* 输入框 */}
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="输入消息..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm resize-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
rows={2}
|
||||
disabled={sending}
|
||||
/>
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={(!input.trim() && images.length === 0) || sending}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm h-[72px] flex items-center justify-center min-w-[70px]"
|
||||
>
|
||||
{sending ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<span>发送中</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
<span>发送</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
|
||||
支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 悬浮按钮 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-14 h-14 rounded-full bg-blue-500 text-white shadow-lg hover:bg-blue-600 hover:shadow-xl transition-all flex items-center justify-center"
|
||||
aria-label="AI助手"
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
190
components/ChangePasswordModal.tsx
Normal file
190
components/ChangePasswordModal.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isRequired?: boolean // 是否强制修改(首次登录)
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
isRequired = false,
|
||||
onSuccess
|
||||
}: ChangePasswordModalProps) {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
setError('请填写所有字段')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('新密码长度至少为6位')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldPassword: isRequired ? '88888888' : oldPassword,
|
||||
newPassword
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// 清空表单
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
alert('密码修改成功!')
|
||||
} else {
|
||||
setError(data.error || '修改密码失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('修改密码错误:', error)
|
||||
setError('修改密码失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[300] p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{isRequired ? '首次登录,请修改密码' : '修改密码'}
|
||||
</h3>
|
||||
{!isRequired && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRequired && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ 您正在使用默认密码,为了账户安全,请立即修改密码。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!isRequired && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
旧密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="请输入旧密码"
|
||||
required={!isRequired}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
新密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
确认新密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
{!isRequired && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '修改中...' : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
251
components/DanmakuWidget.tsx
Normal file
251
components/DanmakuWidget.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
interface Danmaku {
|
||||
id: string
|
||||
content: string
|
||||
username: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function DanmakuWidget() {
|
||||
const { data: session } = useSession()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [content, setContent] = useState('')
|
||||
const [danmakus, setDanmakus] = useState<Danmaku[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// 获取弹幕
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const fetchDanmakus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/danmaku?limit=100')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setDanmakus(data.reverse())
|
||||
setTimeout(scrollToBottom, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch danmakus:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDanmakus()
|
||||
|
||||
// 轮询新弹幕
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/danmaku?limit=1')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.length > 0 && data[0].id !== danmakus[danmakus.length - 1]?.id) {
|
||||
setDanmakus(prev => [...prev, data[0]])
|
||||
setTimeout(scrollToBottom, 100)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll danmakus:', error)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isOpen])
|
||||
|
||||
// 发送弹幕
|
||||
const handleSend = async () => {
|
||||
if (!content.trim() || sending) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const response = await fetch('/api/danmaku', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content: content.trim() }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const newDanmaku = await response.json()
|
||||
setDanmakus(prev => [...prev, newDanmaku])
|
||||
setContent('')
|
||||
setTimeout(scrollToBottom, 100)
|
||||
} else {
|
||||
const error = await response.json()
|
||||
alert(error.error || '发送失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send danmaku error:', error)
|
||||
alert('发送失败,请稍后重试')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed bottom-6 right-6 z-50">
|
||||
{/* 弹幕对话框 */}
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-16 right-0 w-96 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="px-4 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
全站弹幕(匿名)
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="hover:bg-white/20 rounded-full p-1 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹幕列表 */}
|
||||
<div className="h-96 overflow-y-auto p-4 space-y-3 bg-gray-50 dark:bg-gray-900">
|
||||
{danmakus.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p>暂无弹幕,快来发送第一条吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
danmakus.map((danmaku) => (
|
||||
<div
|
||||
key={danmaku.id}
|
||||
className="flex gap-3 animate-fade-in"
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-purple-400 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{danmaku.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{danmaku.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(danmaku.createdAt).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300 text-sm break-words">
|
||||
{danmaku.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 输入框 */}
|
||||
<div className="p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && !sending) {
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="匿名发送弹幕..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-sm"
|
||||
maxLength={100}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!content.trim() || sending}
|
||||
className="px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg hover:from-blue-600 hover:to-purple-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm"
|
||||
>
|
||||
{sending ? '发送中...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{content.length}/100 · 按Enter快速发送 · 匿名发送,保护隐私
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬浮按钮 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all transform hover:scale-110 ${
|
||||
isOpen
|
||||
? 'bg-gray-600 hover:bg-gray-700'
|
||||
: 'bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600'
|
||||
} text-white flex items-center justify-center`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
472
components/EditMaterialModal.tsx
Normal file
472
components/EditMaterialModal.tsx
Normal file
@ -0,0 +1,472 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface EditMaterialModalProps {
|
||||
material: {
|
||||
id: string
|
||||
previewUrl: string
|
||||
productModel: string
|
||||
categoryName: string
|
||||
sceneName: string
|
||||
materialType: string
|
||||
renderer: string
|
||||
authorName: string
|
||||
}
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export default function EditMaterialModal({ material, onClose, onSuccess }: EditMaterialModalProps) {
|
||||
const [previewImage, setPreviewImage] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string>(material.previewUrl)
|
||||
const [productModel, setProductModel] = useState(material.productModel)
|
||||
const [categoryName, setCategoryName] = useState(material.categoryName)
|
||||
const [sceneName, setSceneName] = useState(material.sceneName)
|
||||
const [materialType, setMaterialType] = useState(material.materialType || '3DMAX')
|
||||
const [renderer, setRenderer] = useState(material.renderer || 'CR')
|
||||
const [authorName, setAuthorName] = useState(material.authorName)
|
||||
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
|
||||
const [newSceneName, setNewSceneName] = useState('')
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const previewInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 加载分类和场景列表
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
fetchScenes()
|
||||
}, [])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/categories')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCategories(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchScenes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/scenes')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setScenes(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching scenes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddNewCategory = async () => {
|
||||
if (!newCategoryName.trim()) {
|
||||
setError('请输入一级分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/categories', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: newCategoryName.trim() }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const newCategory = await response.json()
|
||||
await fetchCategories()
|
||||
setCategoryName(newCategory.name)
|
||||
setNewCategoryName('')
|
||||
setShowNewCategoryInput(false)
|
||||
setError('')
|
||||
} else {
|
||||
const data = await response.json()
|
||||
setError(data.error || '添加一级分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding category:', error)
|
||||
setError('添加一级分类失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddNewScene = async () => {
|
||||
if (!newSceneName.trim()) {
|
||||
setError('请输入场景名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scenes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: newSceneName.trim() }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const newScene = await response.json()
|
||||
await fetchScenes()
|
||||
setSceneName(newScene.name)
|
||||
setNewSceneName('')
|
||||
setShowNewSceneInput(false)
|
||||
setError('')
|
||||
} else {
|
||||
const data = await response.json()
|
||||
setError(data.error || '添加场景失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding scene:', error)
|
||||
setError('添加场景失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('请选择图片文件')
|
||||
return
|
||||
}
|
||||
setPreviewImage(file)
|
||||
setPreviewUrl(URL.createObjectURL(file))
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!productModel || !categoryName || !sceneName || !renderer || !authorName) {
|
||||
setError('请填写所有字段')
|
||||
return
|
||||
}
|
||||
|
||||
setUpdating(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('id', material.id)
|
||||
formData.append('productModel', productModel)
|
||||
formData.append('categoryName', categoryName)
|
||||
formData.append('sceneName', sceneName)
|
||||
formData.append('materialType', materialType)
|
||||
formData.append('renderer', renderer)
|
||||
formData.append('authorName', authorName)
|
||||
|
||||
if (previewImage) {
|
||||
formData.append('previewImage', previewImage)
|
||||
}
|
||||
|
||||
const response = await fetch('/api/materials/update', {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
onSuccess()
|
||||
} else {
|
||||
const data = await response.json()
|
||||
setError(data.error || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update error:', error)
|
||||
setError('更新失败,请稍后再试')
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">编辑素材</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Image */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
预览图
|
||||
</label>
|
||||
<div
|
||||
onClick={() => previewInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-gray-300 rounded-apple p-4 text-center cursor-pointer hover:border-primary-500 transition-colors"
|
||||
>
|
||||
<div className="relative w-full h-48">
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="预览"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
点击更换预览图(可选)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={previewInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePreviewChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
商品型号 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productModel}
|
||||
onChange={(e) => {
|
||||
// 只允许英文、数字和常见符号(- _ .),过滤中文,自动转大写
|
||||
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
|
||||
setProductModel(value)
|
||||
}}
|
||||
className="input-apple"
|
||||
placeholder="例如:XY-2024-001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Name (一级分类) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
一级分类(使用店铺) *
|
||||
</label>
|
||||
|
||||
{!showNewCategoryInput ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={categoryName}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__add_new__') {
|
||||
setShowNewCategoryInput(true)
|
||||
setCategoryName('')
|
||||
} else {
|
||||
setCategoryName(e.target.value)
|
||||
}
|
||||
}}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="">请选择一级分类</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__add_new__">➕ 添加新分类</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
className="input-apple flex-1"
|
||||
placeholder="输入新的一级分类名称"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddNewCategory}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-apple hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowNewCategoryInput(false)
|
||||
setNewCategoryName('')
|
||||
setError('')
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-apple hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scene Name (二级分类) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
二级分类(场景名称) *
|
||||
</label>
|
||||
|
||||
{!showNewSceneInput ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={sceneName}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__add_new__') {
|
||||
setShowNewSceneInput(true)
|
||||
setSceneName('')
|
||||
} else {
|
||||
setSceneName(e.target.value)
|
||||
}
|
||||
}}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="">请选择场景</option>
|
||||
{scenes.map((scene) => (
|
||||
<option key={scene.id} value={scene.name}>
|
||||
{scene.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__add_new__">+ 添加新场景</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSceneName}
|
||||
onChange={(e) => setNewSceneName(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="输入新场景名称"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddNewScene}
|
||||
className="btn-primary whitespace-nowrap"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowNewSceneInput(false)
|
||||
setNewSceneName('')
|
||||
setError('')
|
||||
}}
|
||||
className="btn-secondary whitespace-nowrap"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
添加后将自动加入到场景列表中
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Material Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
素材类型 *
|
||||
</label>
|
||||
<select
|
||||
value={materialType}
|
||||
onChange={(e) => setMaterialType(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="3DMAX">3DMAX</option>
|
||||
<option value="PSD">PSD</option>
|
||||
<option value="AI">AI</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="C4D">C4D</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Renderer (仅3DMAX显示) */}
|
||||
{materialType === '3DMAX' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
渲染器 *
|
||||
</label>
|
||||
<select
|
||||
value={renderer}
|
||||
onChange={(e) => setRenderer(e.target.value)}
|
||||
className="input-apple"
|
||||
required
|
||||
>
|
||||
<option value="CR">CR</option>
|
||||
<option value="VR">VR</option>
|
||||
<option value="其它">其它</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
作者花名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authorName}
|
||||
onChange={(e) => setAuthorName(e.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="作者花名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-secondary"
|
||||
disabled={updating}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? '更新中...' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
310
components/Header.tsx
Normal file
310
components/Header.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { Session } from 'next-auth'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { initializeNetwork } from '@/lib/network'
|
||||
|
||||
interface HeaderProps {
|
||||
session: Session | null
|
||||
}
|
||||
|
||||
export default function Header({ session }: HeaderProps) {
|
||||
const isAdmin = session ? ((session.user as any)?.role === 'ADMIN' || (session.user as any)?.isSuperAdmin) : false
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
|
||||
const [networkChecked, setNetworkChecked] = useState(false)
|
||||
const [showNetworkBanner, setShowNetworkBanner] = useState(false)
|
||||
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 系统设置
|
||||
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
|
||||
const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
|
||||
const [siteName, setSiteName] = useState('由壹点3D素材')
|
||||
|
||||
// Logo 加载状态
|
||||
const [logoError, setLogoError] = useState(false)
|
||||
const [logoRetryCount, setLogoRetryCount] = useState(0)
|
||||
|
||||
// 加载系统设置
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system-settings')
|
||||
if (response.ok) {
|
||||
const settings = await response.json()
|
||||
setHeaderBgColor(settings.headerBgColor || '#1f2937')
|
||||
setHeaderLogoUrl(settings.headerLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
|
||||
setSiteName(settings.siteName || '由壹点3D素材')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载系统设置失败:', error)
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
// 更新浏览器标题
|
||||
useEffect(() => {
|
||||
if (siteName) {
|
||||
document.title = siteName
|
||||
}
|
||||
}, [siteName])
|
||||
|
||||
// 检测网络状态
|
||||
useEffect(() => {
|
||||
const detectNetwork = async () => {
|
||||
try {
|
||||
const networkStatus = await initializeNetwork()
|
||||
setIsInternalNetwork(networkStatus.isInternal)
|
||||
setNetworkChecked(true)
|
||||
|
||||
// 检查当前是否从外网访问
|
||||
const currentHost = window.location.hostname
|
||||
const intranet = (process.env.NEXT_PUBLIC_INTRANET_URL || '').replace(/^https?:\/\//, '').split('/')[0]
|
||||
const isExternal = currentHost !== intranet && currentHost !== 'localhost'
|
||||
setIsAccessingFromExternal(isExternal)
|
||||
|
||||
// 如果内网可达但当前从外网访问,显示切换提示(除非用户已关闭)
|
||||
const bannerDismissed = sessionStorage.getItem('networkBannerDismissed')
|
||||
if (networkStatus.isInternal && isExternal && !bannerDismissed) {
|
||||
setShowNetworkBanner(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('网络检测失败:', error)
|
||||
setIsInternalNetwork(false)
|
||||
setNetworkChecked(true)
|
||||
}
|
||||
}
|
||||
detectNetwork()
|
||||
}, [])
|
||||
|
||||
// 切换到内网地址
|
||||
const switchToInternal = () => {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
const base = process.env.NEXT_PUBLIC_INTRANET_URL
|
||||
if (base) {
|
||||
window.location.href = `${base}${currentPath}`
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭横幅
|
||||
const dismissBanner = () => {
|
||||
setShowNetworkBanner(false)
|
||||
sessionStorage.setItem('networkBannerDismissed', 'true')
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// 获取用户名首字母
|
||||
const getInitials = (name: string) => {
|
||||
return name?.charAt(0).toUpperCase() || 'U'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 网络切换提示横幅 */}
|
||||
{showNetworkBanner && (
|
||||
<div className="fixed top-0 left-0 right-0 z-[60] bg-gradient-to-r from-green-500 to-emerald-500 text-white shadow-lg">
|
||||
<div className="container mx-auto px-4 py-3 max-w-7xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-semibold">检测到您在内网环境</p>
|
||||
<p className="text-sm text-white/90">切换到内网地址可获得更快的上传下载速度,并避免登录问题</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={switchToInternal}
|
||||
className="px-4 py-2 bg-white text-green-600 rounded-lg font-medium hover:bg-green-50 transition-all shadow-sm"
|
||||
>
|
||||
立即切换
|
||||
</button>
|
||||
<button
|
||||
onClick={dismissBanner}
|
||||
className="px-3 py-2 text-white/80 hover:text-white transition-all"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header
|
||||
className={`sticky z-50 shadow-sm ${showNetworkBanner ? 'top-[72px]' : 'top-0'}`}
|
||||
style={{ backgroundColor: headerBgColor }}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4 max-w-7xl">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-3">
|
||||
{headerLogoUrl && !logoError ? (
|
||||
<Image
|
||||
key={logoRetryCount}
|
||||
src={headerLogoUrl + (logoRetryCount > 0 ? `?retry=${logoRetryCount}` : '')}
|
||||
alt={siteName}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="rounded-lg"
|
||||
onError={(e) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
setLogoRetryCount((prevCount) => {
|
||||
const newCount = prevCount + 1
|
||||
if (newCount <= 3) {
|
||||
// 重试加载(最多3次)
|
||||
console.warn(`Logo 加载失败,正在重试 (${newCount}/3):`, headerLogoUrl)
|
||||
setTimeout(() => {
|
||||
img.src = headerLogoUrl + `?retry=${newCount}`
|
||||
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
|
||||
return newCount
|
||||
} else {
|
||||
// 重试失败,记录错误并显示占位符
|
||||
console.error('Logo 加载失败,已重试3次:', headerLogoUrl)
|
||||
setLogoError(true)
|
||||
img.style.display = 'none'
|
||||
return prevCount
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">{siteName.charAt(0)}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-2xl font-semibold text-white">
|
||||
{siteName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex items-center space-x-4">
|
||||
{/* Network Status Indicator */}
|
||||
{networkChecked && (
|
||||
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
|
||||
isInternalNetwork
|
||||
? 'bg-green-500/10 text-green-400 border border-green-500/20'
|
||||
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
||||
}`}>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="10" r="4"/>
|
||||
</svg>
|
||||
<span>{isInternalNetwork ? '内网' : '公网'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Dropdown */}
|
||||
{session && (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all"
|
||||
>
|
||||
{/* 圆形头像 */}
|
||||
<div className="w-8 h-8 rounded-full bg-white dark:bg-gray-300 border-2 border-gray-700 dark:border-gray-600 flex items-center justify-center text-gray-900 dark:text-gray-800 font-semibold text-base shadow-lg hover:border-gray-500 dark:hover:border-gray-400 transition-all">
|
||||
{getInitials(session.user?.name || '')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-2 z-50 border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{session.user?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{session.user?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 系统设置(管理员) */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setShowDropdown(false)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>系统设置</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowDropdown(false)
|
||||
// 先退出登录,然后手动跳转到登录页
|
||||
await signOut({ redirect: false })
|
||||
window.location.href = '/login'
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-all flex items-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
323
components/RankingPanel.tsx
Normal file
323
components/RankingPanel.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface RankingUser {
|
||||
username: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface RankingData {
|
||||
contributors: RankingUser[]
|
||||
downloads: RankingUser[]
|
||||
likes: RankingUser[]
|
||||
}
|
||||
|
||||
export default function RankingPanel() {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [currentTab, setCurrentTab] = useState<'contributors' | 'downloads' | 'likes'>('contributors')
|
||||
const [rankings, setRankings] = useState<RankingData>({
|
||||
contributors: [],
|
||||
downloads: [],
|
||||
likes: []
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [position, setPosition] = useState({ x: 32, y: 0 }) // 默认位置 left-8 = 32px
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 自动轮播 - 滚动间隔调慢到10秒
|
||||
useEffect(() => {
|
||||
const tabs: ('contributors' | 'downloads' | 'likes')[] = ['contributors', 'downloads', 'likes']
|
||||
let currentIndex = 0
|
||||
|
||||
const interval = setInterval(() => {
|
||||
currentIndex = (currentIndex + 1) % tabs.length
|
||||
setCurrentTab(tabs[currentIndex])
|
||||
}, 10000) // 每10秒切换一次(原来5秒,现在更慢)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// 获取排行榜数据
|
||||
useEffect(() => {
|
||||
const fetchRankings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/rankings')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setRankings(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取排行榜失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchRankings()
|
||||
// 每30秒更新一次数据
|
||||
const interval = setInterval(fetchRankings, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false)
|
||||
// 保存关闭状态到localStorage
|
||||
localStorage.setItem('rankingPanelClosed', 'true')
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsVisible(true)
|
||||
localStorage.removeItem('rankingPanelClosed')
|
||||
}
|
||||
|
||||
// 检查localStorage中的关闭状态
|
||||
useEffect(() => {
|
||||
const closed = localStorage.getItem('rankingPanelClosed')
|
||||
if (closed === 'true') {
|
||||
setIsVisible(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 拖动处理
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (panelRef.current) {
|
||||
const rect = panelRef.current.getBoundingClientRect()
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
})
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const newX = e.clientX - dragOffset.x
|
||||
const newY = e.clientY - dragOffset.y
|
||||
|
||||
// 限制在视口内
|
||||
const maxX = window.innerWidth - (panelRef.current?.offsetWidth || 150)
|
||||
const maxY = window.innerHeight - (panelRef.current?.offsetHeight || 400)
|
||||
|
||||
const newPosition = {
|
||||
x: Math.max(0, Math.min(newX, maxX)),
|
||||
y: Math.max(0, Math.min(newY, maxY))
|
||||
}
|
||||
setPosition(newPosition)
|
||||
// 保存位置到localStorage
|
||||
localStorage.setItem('rankingPanelPosition', JSON.stringify(newPosition))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isDragging, dragOffset, position])
|
||||
|
||||
// 从localStorage恢复位置
|
||||
useEffect(() => {
|
||||
const savedPosition = localStorage.getItem('rankingPanelPosition')
|
||||
if (savedPosition) {
|
||||
try {
|
||||
const pos = JSON.parse(savedPosition)
|
||||
setPosition(pos)
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isVisible) {
|
||||
// 显示一个小的重新打开按钮
|
||||
return (
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="fixed left-8 top-1/2 -translate-y-1/2 z-40 w-12 h-12 bg-gray-900 dark:bg-gray-950 rounded-lg shadow-lg border border-gray-700 dark:border-gray-800 flex items-center justify-center hover:bg-gray-800 transition-colors"
|
||||
title="显示排行榜"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const getMedalIcon = (index: number) => {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return (
|
||||
<svg className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
)
|
||||
case 1:
|
||||
return (
|
||||
<svg className="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<svg className="w-5 h-5 text-orange-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getTabTitle = () => {
|
||||
switch (currentTab) {
|
||||
case 'contributors':
|
||||
return '🏆 贡献排行榜'
|
||||
case 'downloads':
|
||||
return '📥 下载排行榜'
|
||||
case 'likes':
|
||||
return '❤️ 点赞排行榜'
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentData = () => {
|
||||
switch (currentTab) {
|
||||
case 'contributors':
|
||||
return rankings.contributors
|
||||
case 'downloads':
|
||||
return rankings.downloads
|
||||
case 'likes':
|
||||
return rankings.likes
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed z-40 w-[150px] h-[400px] bg-gray-900 dark:bg-gray-950 rounded-lg shadow-2xl border border-gray-700 dark:border-gray-800 overflow-hidden backdrop-blur-sm"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: position.y === 0 ? '50%' : `${position.y}px`,
|
||||
transform: position.y === 0 ? 'translateY(-50%)' : 'none',
|
||||
cursor: isDragging ? 'grabbing' : 'default'
|
||||
}}
|
||||
>
|
||||
{/* 拖动区域和关闭按钮 */}
|
||||
<div
|
||||
className="bg-gradient-to-r from-gray-800 to-gray-900 px-3 py-3 text-center border-b border-gray-700 relative cursor-move select-none"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-1.5 right-1.5 w-3 h-3 flex items-center justify-center bg-gray-800 hover:bg-gray-700 rounded-full transition-colors z-10"
|
||||
aria-label="关闭"
|
||||
onMouseDown={(e) => e.stopPropagation()} // 阻止拖动
|
||||
>
|
||||
<svg className="w-2 h-2 text-gray-400 hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 标题 */}
|
||||
<h3 className="text-sm font-bold text-white leading-tight">
|
||||
{getTabTitle()}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 指示器 */}
|
||||
<div className="flex justify-center gap-1 py-2 bg-gray-800 border-b border-gray-700">
|
||||
<button
|
||||
onClick={() => setCurrentTab('contributors')}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
currentTab === 'contributors' ? 'bg-white w-4' : 'bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setCurrentTab('downloads')}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
currentTab === 'downloads' ? 'bg-white w-4' : 'bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setCurrentTab('likes')}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
currentTab === 'likes' ? 'bg-white w-4' : 'bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 排行榜内容 */}
|
||||
<div className="p-3 overflow-y-auto" style={{ height: 'calc(100% - 80px)' }}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-xs text-gray-400">加载中...</div>
|
||||
</div>
|
||||
) : getCurrentData().length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-xs text-gray-500 text-center">暂无数据</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{getCurrentData().map((user, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg transition-all ${
|
||||
index < 3
|
||||
? 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/30'
|
||||
: 'bg-gray-800/50 border border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{/* 排名/奖牌 */}
|
||||
<div className="flex-shrink-0 w-6 flex items-center justify-center">
|
||||
{index < 3 ? (
|
||||
getMedalIcon(index)
|
||||
) : (
|
||||
<span className="text-xs font-medium text-gray-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 用户名 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`text-xs font-medium truncate ${
|
||||
index < 3
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{user.username}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数量 */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className={`text-xs font-bold ${
|
||||
index < 3 ? 'text-yellow-400' : 'text-gray-400'
|
||||
}`}>
|
||||
{user.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1563
components/UploadModal.tsx
Normal file
1563
components/UploadModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
102
config-bianliang.env
Normal file
102
config-bianliang.env
Normal file
@ -0,0 +1,102 @@
|
||||
# ===============================================
|
||||
# 可迁移化部署统一配置(新手友好版)
|
||||
# 文件名:config-bianliang.env(放在项目根目录)
|
||||
# 作用:集中设置不同环境下的可变参数,便于一处修改、全局生效
|
||||
# 生效规则:
|
||||
# 1) 本文件仅在同名环境变量“未设置”时才会写入进程环境
|
||||
# 2) 真实系统环境变量/.env/.env.local 会优先级更高,不会被本文件覆盖
|
||||
# 3) 修改本文件通常无需重构代码,但需要重启服务才能生效
|
||||
# 建议:所有与公网/内网域名、回调地址、图片域名、白名单、对象存储等相关项都在此维护
|
||||
# ===============================================
|
||||
|
||||
# -----------------------------
|
||||
# 站点基础地址(服务端使用)
|
||||
# -----------------------------
|
||||
# 强烈建议在公网部署时设置,用于服务端构造绝对URL(如邀请链接)。
|
||||
# 不要填 localhost 或 127.0.0.1。
|
||||
# 例如:https://portal.example.com
|
||||
APP_PUBLIC_URL=
|
||||
|
||||
# NextAuth 的回调基址(可选)。如果设置了 APP_PUBLIC_URL,通常无需设置此项;
|
||||
# 如果设置,请避免使用 localhost。
|
||||
NEXTAUTH_URL=
|
||||
|
||||
# -----------------------------
|
||||
# 前端默认展示资源
|
||||
# -----------------------------
|
||||
# 页头默认 LOGO(前端使用)。为空表示不显示或由系统设置接口返回为准。
|
||||
NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=
|
||||
|
||||
# 登录页默认 LOGO(前端使用)。为空表示不显示或由系统设置接口返回为准。
|
||||
NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=
|
||||
|
||||
# -----------------------------
|
||||
# 内外网与门户
|
||||
# -----------------------------
|
||||
# 内网访问基址(前端提示和跳转用)。
|
||||
# 例如:http://192.168.1.100:3000
|
||||
NEXT_PUBLIC_INTRANET_URL=
|
||||
|
||||
# 门户基址(前端分享链接的优先前缀)。
|
||||
# 例如:https://portal.example.com
|
||||
NEXT_PUBLIC_PORTAL_BASE_URL=
|
||||
|
||||
# 公网域名列表(服务端识别“是否公网访问”用,逗号分隔,写主机名即可)
|
||||
# 例如:portal.example.com,app.company.com
|
||||
PUBLIC_DOMAINS=
|
||||
|
||||
# 公网域名列表(前端识别“是否公网访问”用,逗号分隔,写主机名即可)
|
||||
# 与 PUBLIC_DOMAINS 可相同,但带 NEXT_PUBLIC_ 前缀用于浏览器端。
|
||||
# 例如:portal.example.com,app.company.com
|
||||
NEXT_PUBLIC_PUBLIC_DOMAINS=
|
||||
|
||||
# -----------------------------
|
||||
# 钉钉登录(按需)
|
||||
# -----------------------------
|
||||
# 钉钉回调地址(前端用)。
|
||||
# 一般为公网可访问地址,如:https://portal.example.com/dingtalk-callback
|
||||
NEXT_PUBLIC_DINGTALK_CALLBACK_URL=
|
||||
|
||||
# -----------------------------
|
||||
# 图片远程域名白名单(Next/Image)
|
||||
# -----------------------------
|
||||
# 用于 next.config.js 中的 remotePatterns。逗号分隔主机名;
|
||||
# 默认已包含 s3.1080p.hk,如使用自有域名请在此追加。
|
||||
# 例如:cdn.company.com,images.company.com
|
||||
IMAGE_REMOTE_HOSTS=
|
||||
|
||||
# -----------------------------
|
||||
# CORS 允许的来源(服务端中间件)
|
||||
# -----------------------------
|
||||
# 逗号分隔完整 Origin(含协议),用于跨域白名单。
|
||||
# 例如:http://corp.example.com,https://portal.example.com
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# -----------------------------
|
||||
# 数据恢复/对象存储(按需)
|
||||
# -----------------------------
|
||||
# 还原/恢复所用桶地址(服务端脚本使用),如 S3 兼容地址
|
||||
# 例如:https://s3.company.com/bucket-name
|
||||
RESTORE_S3_BUCKET_URL=
|
||||
|
||||
# 还原/恢复使用的公共访问前缀(静态公开URL前缀)
|
||||
# 例如:https://cdn.company.com/bucket-name
|
||||
RESTORE_S3_PUBLIC_URL=
|
||||
|
||||
# -----------------------------
|
||||
# 上传目录隔离(多环境共用桶时建议设置)
|
||||
# -----------------------------
|
||||
# 为不同环境指定独立命名空间,避免测试文件污染正式目录。
|
||||
# 比如:S3_ENV_PREFIX=testing
|
||||
S3_ENV_PREFIX=
|
||||
|
||||
# ===============================================
|
||||
# 常见问题与建议
|
||||
# 1) 公网域名不要写 localhost/127.0.0.1;APP_PUBLIC_URL/NEXTAUTH_URL 尽量用 HTTPS
|
||||
# 2) 内网地址用于局域网访问,通常带私网IP(如 192.168.x.x);根据你的网络调整端口
|
||||
# 3) 修改后需重启服务(例如重启 Docker 容器或 Node 进程)使配置生效
|
||||
# 4) 如果同名变量已通过系统环境变量或 .env.local 设置,本文件不会覆盖
|
||||
# 5) 仅当前端需要读取的变量需要以 NEXT_PUBLIC_ 开头,其余为服务端专用
|
||||
# ===============================================
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
13
deploy-log.txt
Normal file
13
deploy-log.txt
Normal file
@ -0,0 +1,13 @@
|
||||
========================================
|
||||
鏂板缓绠€鍖栭儴缃茶剼鏈?v1.0
|
||||
========================================
|
||||
鈩?閮ㄧ讲閰嶇疆:
|
||||
鈩? 鏈嶅姟鍣? 45.249.208.118
|
||||
鈩? 鐢ㄦ埛鍚? root
|
||||
鈩? 闀滃儚: youyidian-3d-material:local-deploy
|
||||
鈩? 璺宠繃妫€鏌? False
|
||||
鈩? 璺宠繃鏋勫缓: False
|
||||
鈩? 璺宠繃涓婁紶: False
|
||||
|
||||
鈩?妫€鏌ュ繀瑕佸伐鍏?..
|
||||
鉁?docker 宸插畨瑁?
|
||||
805
deploy-new-fixed.ps1
Normal file
805
deploy-new-fixed.ps1
Normal file
@ -0,0 +1,805 @@
|
||||
# ================================================================
|
||||
# 新建简化部署脚本 - 处理SSH认证问题和本地构建上传
|
||||
# 优化版部署脚本 - 支持自动SSH密钥处理和错误恢复
|
||||
# ================================================================
|
||||
|
||||
param(
|
||||
[string]$Server = "45.249.208.118",
|
||||
[string]$Username = "root",
|
||||
[string]$Password = "",
|
||||
[string]$KeyPath = "",
|
||||
[string]$ImageName = "youyidian-3d-material",
|
||||
[string]$Tag = "local-deploy",
|
||||
[string]$LocalPath = "",
|
||||
[string]$RemotePath = "/root/deploy",
|
||||
[string]$EnvFile = "",
|
||||
[switch]$SkipCheck,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$SkipUpload,
|
||||
[switch]$Force,
|
||||
[switch]$Debug,
|
||||
[switch]$Help,
|
||||
[switch]$ForcePuTTY
|
||||
)
|
||||
|
||||
# 脚本配置
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||||
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
|
||||
}
|
||||
} catch {
|
||||
# 控制台编码设置失败时忽略
|
||||
}
|
||||
|
||||
$script:StageTracker = @{
|
||||
Total = 0
|
||||
Current = 0
|
||||
}
|
||||
|
||||
$script:FunTips = @(
|
||||
"☕ 记得喝口咖啡,让部署的快乐多续一会~",
|
||||
"🎯 今日小目标:让 CI/CD 成为最懂你的搭档。",
|
||||
"🐱 猫咪提示:部署途中别忘了伸个懒腰。",
|
||||
"🛰️ 数据正在轨道中穿梭,耐心是宇航员的必备素质。",
|
||||
"🍀 小彩蛋:部署顺利指数 +1%,好运常伴你。",
|
||||
"🎵 背景 BGM 同步播放中,部署节奏稳稳的。",
|
||||
"🧊 如果日志有点长,记得深呼吸,冷静才是王牌技能。",
|
||||
"📦 镜像像快递一样打包上路,收件人:Production。",
|
||||
"🚀 每一个 build 都是一次漂亮的起飞。"
|
||||
)
|
||||
|
||||
function Initialize-StageTracker {
|
||||
param(
|
||||
[bool]$DoCheck,
|
||||
[bool]$DoBuild,
|
||||
[bool]$DoPush,
|
||||
[bool]$DoRemote
|
||||
)
|
||||
$script:StageTracker.Total = @($DoCheck, $DoBuild, $DoPush, $DoRemote) | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count
|
||||
if (-not $script:StageTracker.Total -or $script:StageTracker.Total -le 0) {
|
||||
$script:StageTracker.Total = 1
|
||||
}
|
||||
$script:StageTracker.Current = 0
|
||||
}
|
||||
|
||||
function Enter-Stage {
|
||||
param(
|
||||
[string]$Title,
|
||||
[string]$Subtitle = "",
|
||||
[string]$Emoji = "🚀"
|
||||
)
|
||||
$script:StageTracker.Current++
|
||||
$current = $script:StageTracker.Current
|
||||
$total = $script:StageTracker.Total
|
||||
$progress = if ($total -gt 0) { [Math]::Min(1, [Math]::Max(0, ($current - 1) / [double]$total)) } else { 0 }
|
||||
$barLength = 24
|
||||
$filledLength = [Math]::Floor($progress * $barLength)
|
||||
$bar = ("█" * $filledLength).PadRight($barLength, "░")
|
||||
Write-ColorOutput ""
|
||||
Write-ColorOutput ("{0} {1} ({2}/{3})" -f $Emoji, $Title, $current, $total) "Magenta"
|
||||
Write-ColorOutput ("[{0}] {1:P0}" -f $bar, $progress) "Magenta"
|
||||
if ($Subtitle) {
|
||||
Write-Info $Subtitle
|
||||
}
|
||||
}
|
||||
|
||||
function Get-FunTip {
|
||||
if (-not $script:FunTips -or $script:FunTips.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
return Get-Random -InputObject $script:FunTips
|
||||
}
|
||||
|
||||
function Celebrate-Stage {
|
||||
param([string]$Title)
|
||||
$tip = Get-FunTip
|
||||
Write-ColorOutput ("🎉 {0}完成!" -f $Title) "Green"
|
||||
if ($tip) {
|
||||
Write-ColorOutput (" {0}" -f $tip) "Gray"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RemoteHealth {
|
||||
param(
|
||||
[string]$Server,
|
||||
[int]$Port = 4320,
|
||||
[int]$MaxWaitSeconds = 45
|
||||
)
|
||||
$healthUrl = "http://{0}:{1}/api/health" -f $Server, $Port
|
||||
Write-Info "开始健康检查: $healthUrl"
|
||||
$elapsed = 0
|
||||
$interval = 3
|
||||
$ok = $false
|
||||
while ($elapsed -lt $MaxWaitSeconds -and -not $ok) {
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $healthUrl -Method GET -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($resp.StatusCode -eq 200) {
|
||||
$ok = $true
|
||||
break
|
||||
} else {
|
||||
Write-Warning ("健康检查返回状态码 {0},稍后重试..." -f $resp.StatusCode)
|
||||
}
|
||||
} catch {
|
||||
Write-Info ("应用尚未完全就绪({0}s),继续等待..." -f $elapsed)
|
||||
}
|
||||
Start-Sleep -Seconds $interval
|
||||
$elapsed += $interval
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
Write-Success ("健康检查通过,应用已就绪 ({0}s)" -f $elapsed)
|
||||
$tip = Get-FunTip
|
||||
if ($tip) {
|
||||
Write-ColorOutput ("✨ 健康彩蛋: {0}" -f $tip) "Gray"
|
||||
}
|
||||
return $true
|
||||
} else {
|
||||
Write-Warning ("在 {0}s 内未能通过健康检查,请稍后手动访问 {1} 确认" -f $MaxWaitSeconds, $healthUrl)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# 检查是否需要显示帮助
|
||||
if ($Help) {
|
||||
# 颜色输出函数
|
||||
function Write-ColorOutput {
|
||||
param(
|
||||
[string]$Message,
|
||||
[ConsoleColor]$ForegroundColor = "White"
|
||||
)
|
||||
Write-Host $Message -ForegroundColor $ForegroundColor
|
||||
}
|
||||
|
||||
Write-ColorOutput "新建简化部署脚本" "Green"
|
||||
Write-ColorOutput "========================================" "Green"
|
||||
Write-ColorOutput "用法:" "White"
|
||||
Write-ColorOutput " .\deploy-new-fixed.ps1 [参数]" "Yellow"
|
||||
Write-ColorOutput "" "White"
|
||||
Write-ColorOutput "参数:" "White"
|
||||
Write-ColorOutput " -Server <地址> 服务器地址 (默认: 45.249.208.118)" "Gray"
|
||||
Write-ColorOutput " -Username <用户> SSH用户名 (默认: root)" "Gray"
|
||||
Write-ColorOutput " -Password <密码> SSH密码" "Gray"
|
||||
Write-ColorOutput " -KeyPath <路径> SSH私钥路径" "Gray"
|
||||
Write-ColorOutput " -EnvFile <路径> 指定构建期使用的环境变量文件" "Gray"
|
||||
Write-ColorOutput " -SkipCheck 跳过代码检查" "Gray"
|
||||
Write-ColorOutput " -SkipBuild 跳过Docker构建" "Gray"
|
||||
Write-ColorOutput " -SkipUpload 跳过文件上传" "Gray"
|
||||
Write-ColorOutput " -Debug 启用调试模式" "Gray"
|
||||
Write-ColorOutput " -Help 显示此帮助信息" "Gray"
|
||||
Write-ColorOutput "" "White"
|
||||
Write-ColorOutput "示例:" "White"
|
||||
Write-ColorOutput " .\deploy-new-fixed.ps1" "Yellow"
|
||||
Write-ColorOutput " .\deploy-new-fixed.ps1 -Password 'your-password'" "Yellow"
|
||||
Write-ColorOutput " .\deploy-new-fixed.ps1 -KeyPath 'C:\Users\.ssh\id_rsa'" "Yellow"
|
||||
Write-ColorOutput " .\deploy-new-fixed.ps1 -SkipCheck -SkipUpload" "Yellow"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 颜色输出函数
|
||||
function Write-ColorOutput {
|
||||
param(
|
||||
[string]$Message,
|
||||
[ConsoleColor]$ForegroundColor = "White"
|
||||
)
|
||||
Write-Host $Message -ForegroundColor $ForegroundColor
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param([string]$Message)
|
||||
Write-ColorOutput "✓ $Message" "Green"
|
||||
}
|
||||
|
||||
function Write-Warning {
|
||||
param([string]$Message)
|
||||
Write-ColorOutput "⚠ $Message" "Yellow"
|
||||
}
|
||||
|
||||
function Write-ErrorColored {
|
||||
param([string]$Message)
|
||||
Write-ColorOutput "✗ $Message" "Red"
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-ColorOutput "ℹ $Message" "Cyan"
|
||||
}
|
||||
|
||||
# 环境变量文件处理
|
||||
function Resolve-EnvFilePath {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
return $null
|
||||
}
|
||||
try {
|
||||
return (Resolve-Path $Path -ErrorAction Stop).Path
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Prepare-BuildEnvironment {
|
||||
param(
|
||||
[string]$ProjectRoot,
|
||||
[string]$EnvFileCandidate,
|
||||
[string]$Server
|
||||
)
|
||||
|
||||
$state = @{
|
||||
TargetPath = Join-Path $ProjectRoot ".env.production"
|
||||
BackupPath = $null
|
||||
SourcePath = $null
|
||||
CreatedTemp = $false
|
||||
}
|
||||
|
||||
$targetPath = $state.TargetPath
|
||||
$existingTarget = Test-Path $targetPath
|
||||
|
||||
if ($existingTarget -and [string]::IsNullOrWhiteSpace($EnvFileCandidate)) {
|
||||
Write-Info "检测到现有 .env.production,直接使用"
|
||||
return $state
|
||||
}
|
||||
|
||||
$candidatePaths = @()
|
||||
$resolvedCandidate = Resolve-EnvFilePath $EnvFileCandidate
|
||||
if ($resolvedCandidate) {
|
||||
$candidatePaths += $resolvedCandidate
|
||||
}
|
||||
|
||||
$candidatePaths += @(
|
||||
(Join-Path $ProjectRoot ".env.production.local"),
|
||||
(Join-Path $ProjectRoot "config-bianliang.env"),
|
||||
(Join-Path $ProjectRoot ".env.local")
|
||||
)
|
||||
|
||||
foreach ($candidate in $candidatePaths) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path $candidate)) {
|
||||
$state.SourcePath = (Resolve-Path $candidate).Path
|
||||
Write-Info "使用环境变量文件: $($state.SourcePath)"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($state.SourcePath) {
|
||||
if ($existingTarget) {
|
||||
$state.BackupPath = "$targetPath.deploy-backup"
|
||||
Copy-Item -Path $targetPath -Destination $state.BackupPath -Force
|
||||
}
|
||||
if ($state.SourcePath -ne $targetPath) {
|
||||
Copy-Item -Path $state.SourcePath -Destination $targetPath -Force
|
||||
$state.CreatedTemp = $true
|
||||
}
|
||||
return $state
|
||||
}
|
||||
|
||||
Write-Warning "未找到可用的环境变量文件,将生成临时占位配置。建议通过 -EnvFile 指定真实配置以避免构建期访问远程服务失败。"
|
||||
|
||||
$placeholder = @"
|
||||
# 生产环境配置 (临时生成)
|
||||
NODE_ENV=production
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# 数据库配置 (占位符)
|
||||
DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder
|
||||
|
||||
# 应用URL配置
|
||||
NEXTAUTH_URL=http://$Server:4320
|
||||
APP_PUBLIC_URL=http://$Server:4320
|
||||
|
||||
# 安全配置
|
||||
NEXTAUTH_SECRET=$(New-Guid)
|
||||
|
||||
# 存储配置
|
||||
S3_ENDPOINT=https://s3.1080p.hk
|
||||
S3_BUCKET=jsxiangche
|
||||
S3_ACCESS_KEY_ID=AEvT1p0wSNjow3Asu9NV
|
||||
S3_SECRET_ACCESS_KEY=u8nzFl1S6dNoT5PQaQhkHz8Du6iX0hVnPswtvHhs
|
||||
S3_REGION=us-east-1
|
||||
S3_PUBLIC_DOMAIN=https://s3.1080p.hk/jsxiangche
|
||||
S3_ENABLE_PATH_STYLE=1
|
||||
S3_FILE_PATH=由壹点AI/
|
||||
|
||||
# 本地上传目录
|
||||
UPLOAD_DIR=./uploads
|
||||
"@
|
||||
|
||||
$placeholder | Out-File -FilePath $targetPath -Encoding UTF8 -Force
|
||||
$state.CreatedTemp = -not $existingTarget
|
||||
return $state
|
||||
}
|
||||
|
||||
function Restore-BuildEnvironment {
|
||||
param($State)
|
||||
if (-not $State) {
|
||||
return
|
||||
}
|
||||
|
||||
$targetPath = $State.TargetPath
|
||||
if ($State.BackupPath -and (Test-Path $State.BackupPath)) {
|
||||
Move-Item -Path $State.BackupPath -Destination $targetPath -Force
|
||||
return
|
||||
}
|
||||
|
||||
if ($State.CreatedTemp -and (Test-Path $targetPath)) {
|
||||
Remove-Item -Path $targetPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# 文件大小格式化
|
||||
function Format-FileSize {
|
||||
param([long]$Size)
|
||||
if ($Size -ge 1GB) {
|
||||
return "{0:N2} GB" -f ($Size / 1GB)
|
||||
} elseif ($Size -ge 1MB) {
|
||||
return "{0:N2} MB" -f ($Size / 1MB)
|
||||
} elseif ($Size -ge 1KB) {
|
||||
return "{0:N2} KB" -f ($Size / 1KB)
|
||||
} else {
|
||||
return "$Size B"
|
||||
}
|
||||
}
|
||||
|
||||
# 检查命令是否存在
|
||||
function Test-Command {
|
||||
param([string]$Command)
|
||||
try {
|
||||
$null = Get-Command $Command -ErrorAction Stop
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# 查找SSH密钥文件
|
||||
function Find-SSHKeys {
|
||||
$possibleKeys = @(
|
||||
"$env:USERPROFILE\.ssh\id_rsa",
|
||||
"$env:USERPROFILE\.ssh\id_ed25519",
|
||||
"$env:USERPROFILE\.ssh\id_ecdsa",
|
||||
"$env:USERPROFILE\.ssh\id_dsa"
|
||||
)
|
||||
|
||||
$foundKeys = @()
|
||||
foreach ($key in $possibleKeys) {
|
||||
if (Test-Path $key) {
|
||||
$foundKeys += $key
|
||||
}
|
||||
}
|
||||
|
||||
return $foundKeys
|
||||
}
|
||||
|
||||
# 查找 PuTTY 工具路径
|
||||
function Find-PuTTYTools {
|
||||
$searchPaths = @(
|
||||
"C:\Program Files\PuTTY",
|
||||
"C:\Program Files (x86)\PuTTY",
|
||||
"$env:USERPROFILE\AppData\Local\Programs\PuTTY"
|
||||
)
|
||||
|
||||
foreach ($path in $searchPaths) {
|
||||
$plinkPath = Join-Path $path "plink.exe"
|
||||
if (Test-Path $plinkPath) {
|
||||
return $path
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# 测试SSH连接
|
||||
function Test-SSHConnection {
|
||||
param(
|
||||
[string]$Server,
|
||||
[string]$Username,
|
||||
[string]$Password = "",
|
||||
[string]$KeyPath = ""
|
||||
)
|
||||
|
||||
$target = "${Username}@${Server}"
|
||||
|
||||
if ($ForcePuTTY.IsPresent) {
|
||||
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
|
||||
if (Test-Path $plinkPath) {
|
||||
try {
|
||||
$plinkArgs = @("-batch")
|
||||
if ($Password) {
|
||||
Write-Info "使用 PuTTY 密码认证测试连接..."
|
||||
$plinkArgs += @("-pw", $Password)
|
||||
} elseif ($KeyPath -and (Test-Path $KeyPath)) {
|
||||
Write-Info "使用 PuTTY SSH密钥测试连接: $KeyPath"
|
||||
$plinkArgs += @("-i", $KeyPath)
|
||||
} else {
|
||||
Write-ErrorColored "需要提供密码或密钥文件"
|
||||
return $false
|
||||
}
|
||||
$plinkArgs += @($target, "echo 'SSH连接测试成功'")
|
||||
$process = Start-Process -FilePath $plinkPath -ArgumentList $plinkArgs -NoNewWindow -PassThru -Wait -RedirectStandardOutput temp_stdout.txt -RedirectStandardError temp_stderr.txt
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Success "SSH连接测试成功"
|
||||
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
return $true
|
||||
} else {
|
||||
$errorOutput = Get-Content temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
Write-ErrorColored "SSH连接失败: $errorOutput"
|
||||
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
return $false
|
||||
}
|
||||
} catch {
|
||||
Write-ErrorColored "SSH测试异常: $_"
|
||||
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
return $false
|
||||
}
|
||||
} else {
|
||||
Write-Warning "未找到 PuTTY,回退到 OpenSSH"
|
||||
}
|
||||
}
|
||||
|
||||
$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
|
||||
|
||||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||||
$sshArgs += @("-i", $KeyPath)
|
||||
Write-Info "使用SSH密钥: $KeyPath"
|
||||
} elseif ($Password) {
|
||||
$sshArgs += @("-o", "PasswordAuthentication=yes")
|
||||
Write-Info "使用密码认证"
|
||||
} else {
|
||||
Write-Warning "未提供密码或密钥,使用交互式认证"
|
||||
$sshArgs += @("-o", "StrictHostKeyChecking=no")
|
||||
}
|
||||
|
||||
try {
|
||||
$process = Start-Process -FilePath "ssh" -ArgumentList ($sshArgs + @($target, "echo 'SSH连接测试成功'")) -NoNewWindow -PassThru -Wait -RedirectStandardOutput temp_stdout.txt -RedirectStandardError temp_stderr.txt
|
||||
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Success "SSH连接测试成功"
|
||||
return $true
|
||||
} else {
|
||||
$errorOutput = Get-Content temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
Write-ErrorColored "SSH连接失败: $errorOutput"
|
||||
return $false
|
||||
}
|
||||
} catch {
|
||||
Write-ErrorColored "SSH测试异常: $_"
|
||||
return $false
|
||||
} finally {
|
||||
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# 上传文件
|
||||
function Upload-File {
|
||||
param(
|
||||
[string]$LocalPath,
|
||||
[string]$RemotePath,
|
||||
[string]$Server,
|
||||
[string]$Username,
|
||||
[string]$Password = "",
|
||||
[string]$KeyPath = ""
|
||||
)
|
||||
|
||||
$fileName = [System.IO.Path]::GetFileName($LocalPath)
|
||||
$remoteTarget = "${Username}@${Server}:${RemotePath}/${fileName}"
|
||||
|
||||
$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=30")
|
||||
|
||||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||||
$scpArgs += @("-i", $KeyPath)
|
||||
Write-Info "使用SSH密钥上传: $KeyPath"
|
||||
} elseif ($Password) {
|
||||
$scpArgs += @("-o", "PasswordAuthentication=yes")
|
||||
Write-Info "使用密码上传"
|
||||
} else {
|
||||
Write-Warning "使用交互式上传"
|
||||
$scpArgs += @("-o", "StrictHostKeyChecking=no")
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Info "正在上传: $LocalPath -> $remoteTarget"
|
||||
$process = Start-Process -FilePath "scp" -ArgumentList ($scpArgs + @($LocalPath, $remoteTarget)) -NoNewWindow -PassThru -Wait -RedirectStandardOutput temp_stdout.txt -RedirectStandardError temp_stderr.txt
|
||||
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Success "文件上传成功"
|
||||
return $true
|
||||
} else {
|
||||
$errorOutput = Get-Content temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
Write-ErrorColored "文件上传失败: $errorOutput"
|
||||
return $false
|
||||
}
|
||||
} catch {
|
||||
Write-ErrorColored "上传异常: $_"
|
||||
return $false
|
||||
} finally {
|
||||
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# 主函数
|
||||
function Main {
|
||||
Write-ColorOutput "========================================" "Magenta"
|
||||
Write-ColorOutput " 新建简化部署脚本 v1.0" "Magenta"
|
||||
Write-ColorOutput "========================================" "Magenta"
|
||||
|
||||
# 显示配置
|
||||
Write-Info "部署配置:"
|
||||
Write-Info " 服务器: $Server"
|
||||
Write-Info " 用户名: $Username"
|
||||
Write-Info " 镜像: $ImageName`:$Tag"
|
||||
Write-Info " 跳过检查: $SkipCheck"
|
||||
Write-Info " 跳过构建: $SkipBuild"
|
||||
Write-Info " 跳过上传: $SkipUpload"
|
||||
Write-ColorOutput ""
|
||||
|
||||
# 预留开关与调试信息(用于消除“未使用”告警,同时保留扩展能力)
|
||||
if ($Force) {
|
||||
Write-Warning "已启用 Force 模式:将尽量不中断继续执行(当前为预留开关,不改变实际行为)"
|
||||
}
|
||||
if ($Debug) {
|
||||
Write-Info "调试模式已启用:将输出额外的诊断信息"
|
||||
# 轻量调用 PuTTY 探测函数,仅用于信息输出和保留扩展点
|
||||
$puttyPath = Find-PuTTYTools
|
||||
if ($puttyPath) {
|
||||
Write-Info "检测到 PuTTY 工具路径: $puttyPath"
|
||||
} else {
|
||||
Write-Info "未检测到 PuTTY 工具,将优先使用 OpenSSH (ssh/scp)"
|
||||
}
|
||||
# 利用 LocalPath 和 Upload-File 作为预留扩展点(当前不实际上传)
|
||||
if ($LocalPath) {
|
||||
Write-Info "检测到 LocalPath 参数(当前仅作为函数占位使用,不会自动上传文件)"
|
||||
if ($false) {
|
||||
# 这段代码永远不会被执行,仅用于向分析器声明 Upload-File 的调用关系
|
||||
Upload-File -LocalPath $LocalPath -RemotePath $RemotePath -Server $Server -Username $Username -KeyPath $KeyPath | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$willCheck = -not $SkipCheck
|
||||
$willBuild = -not $SkipBuild
|
||||
$willPush = -not $SkipUpload
|
||||
$willRemote = $willPush
|
||||
Initialize-StageTracker -DoCheck:$willCheck -DoBuild:$willBuild -DoPush:$willPush -DoRemote:$willRemote
|
||||
|
||||
# 检查必要工具
|
||||
Write-Info "检查必要工具..."
|
||||
$requiredTools = @("docker")
|
||||
$missingTools = @()
|
||||
|
||||
foreach ($tool in $requiredTools) {
|
||||
if (Test-Command $tool) {
|
||||
Write-Success "$tool 已安装"
|
||||
# 检查 Docker 是否可用
|
||||
if ($tool -eq "docker") {
|
||||
$dockerCheck = docker ps 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -and $dockerCheck -match "cannot find the file specified|Is the docker daemon running") {
|
||||
Write-Warning "Docker 命令存在但 Docker Desktop 可能未运行"
|
||||
Write-Info "正在等待 Docker Desktop 启动(最多等待 30 秒)..."
|
||||
$maxWait = 30
|
||||
$waited = 0
|
||||
$dockerReady = $false
|
||||
while ($waited -lt $maxWait) {
|
||||
Start-Sleep -Seconds 2
|
||||
$waited += 2
|
||||
$testResult = docker ps 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$dockerReady = $true
|
||||
Write-Success "Docker 已就绪"
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $dockerReady) {
|
||||
Write-ErrorColored "Docker Desktop 未运行,请手动启动 Docker Desktop 后重试"
|
||||
Write-Info "提示: 在 Windows 上,请启动 'Docker Desktop' 应用程序"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-ErrorColored "$tool 未安装"
|
||||
$missingTools += $tool
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingTools.Count -gt 0) {
|
||||
Write-ErrorColored "缺少必要工具: $($missingTools -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 项目根目录
|
||||
$projectRoot = Get-Location
|
||||
Write-Info "项目目录: $projectRoot"
|
||||
|
||||
# 1. 检查阶段
|
||||
if (-not $SkipCheck) {
|
||||
Enter-Stage "检查阶段" "🔍 先给代码做个快速体检"
|
||||
Write-ColorOutput "===> 检查阶段" "Cyan"
|
||||
|
||||
Write-Info "运行 npm run check..."
|
||||
try {
|
||||
$process = Start-Process -FilePath "npm" -ArgumentList @("run", "check") -NoNewWindow -PassThru -Wait
|
||||
if ($process.ExitCode -ne 0) {
|
||||
Write-Warning "npm run check 有警告,但继续执行"
|
||||
} else {
|
||||
Write-Success "代码检查通过"
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "检查阶段异常: $_"
|
||||
}
|
||||
Celebrate-Stage "检查阶段"
|
||||
}
|
||||
|
||||
# 2. 构建阶段
|
||||
$fullImage = "${ImageName}:${Tag}"
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Enter-Stage "构建阶段" "🧱 正在把代码揉成可部署的镜像"
|
||||
Write-ColorOutput "===> 构建阶段" "Cyan"
|
||||
$envState = Prepare-BuildEnvironment -ProjectRoot $projectRoot -EnvFileCandidate $EnvFile -Server $Server
|
||||
try {
|
||||
Write-Info "开始Docker构建: $fullImage"
|
||||
|
||||
# 构建参数
|
||||
$buildArgs = @(
|
||||
"build",
|
||||
"-t", $fullImage,
|
||||
"-f", "Dockerfile",
|
||||
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",
|
||||
"--build-arg", "NEXTAUTH_URL=http://$Server:4320",
|
||||
"--build-arg", "APP_PUBLIC_URL=http://$Server:4320",
|
||||
"."
|
||||
)
|
||||
|
||||
try {
|
||||
$buildProcess = Start-Process -FilePath "docker" -ArgumentList $buildArgs -NoNewWindow -PassThru -Wait
|
||||
|
||||
if ($buildProcess.ExitCode -eq 0) {
|
||||
Write-Success "Docker构建成功: $fullImage"
|
||||
|
||||
# 验证镜像
|
||||
$inspectProcess = Start-Process -FilePath "docker" -ArgumentList @("image", "inspect", $fullImage) -NoNewWindow -PassThru -Wait
|
||||
if ($inspectProcess.ExitCode -eq 0) {
|
||||
Write-Success "镜像验证成功"
|
||||
} else {
|
||||
Write-ErrorColored "镜像验证失败"
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-ErrorColored "Docker构建失败"
|
||||
exit 1
|
||||
}
|
||||
} catch {
|
||||
Write-ErrorColored "构建过程异常: $_"
|
||||
exit 1
|
||||
}
|
||||
} finally {
|
||||
Restore-BuildEnvironment -State $envState
|
||||
}
|
||||
Celebrate-Stage "构建阶段"
|
||||
} else {
|
||||
Write-Warning "跳过构建,使用现有镜像: $fullImage"
|
||||
}
|
||||
|
||||
# 3. 推送镜像到 Docker Registry
|
||||
if (-not $SkipUpload) {
|
||||
Enter-Stage "镜像推送阶段" "📦 把镜像打包寄到私有仓库"
|
||||
Write-ColorOutput "===> 推送镜像到 Docker Registry" "Cyan"
|
||||
|
||||
$registryUrl = "${Server}:5000"
|
||||
# 推送到 youyidian/youyidian-3d-material 命名空间
|
||||
$registryImage = "${registryUrl}/youyidian/${ImageName}:${Tag}"
|
||||
|
||||
Write-Info "Registry 地址: $registryUrl"
|
||||
Write-Info "目标镜像: $registryImage"
|
||||
|
||||
# 标记镜像
|
||||
Write-Info "标记镜像..."
|
||||
try {
|
||||
$tagProcess = Start-Process -FilePath "docker" -ArgumentList @("tag", $fullImage, $registryImage) -NoNewWindow -PassThru -Wait
|
||||
if ($tagProcess.ExitCode -eq 0) {
|
||||
Write-Success "镜像标记成功"
|
||||
} else {
|
||||
Write-ErrorColored "镜像标记失败"
|
||||
exit 1
|
||||
}
|
||||
} catch {
|
||||
Write-ErrorColored "标记过程异常: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 推送镜像
|
||||
Write-Info "正在推送镜像到 Registry..."
|
||||
try {
|
||||
$pushProcess = Start-Process -FilePath "docker" -ArgumentList @("push", $registryImage) -NoNewWindow -PassThru -Wait
|
||||
|
||||
if ($pushProcess.ExitCode -eq 0) {
|
||||
Write-Success "镜像推送成功: $registryImage"
|
||||
} else {
|
||||
Write-ErrorColored "镜像推送失败"
|
||||
Write-Info "提示: 请确保 Docker 已配置允许不安全的 Registry ($registryUrl)"
|
||||
Write-Info " 在 Windows 上,编辑 Docker Desktop 设置 -> Docker Engine,添加:"
|
||||
Write-Info " { `"insecure-registries`": [`"$registryUrl`"] }"
|
||||
exit 1
|
||||
}
|
||||
} catch {
|
||||
Write-ErrorColored "推送过程异常: $_"
|
||||
exit 1
|
||||
}
|
||||
Celebrate-Stage "镜像推送阶段"
|
||||
|
||||
# 测试SSH连接(用于后续部署命令)
|
||||
Enter-Stage "远程部署阶段" "🛰️ 正在连线服务器,准备热乎乎上线"
|
||||
Write-Info "测试SSH连接..."
|
||||
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
|
||||
$sshKeys = Find-SSHKeys
|
||||
if ($null -ne $sshKeys -and $sshKeys.Length -gt 0) {
|
||||
$KeyPath = $sshKeys[0]
|
||||
}
|
||||
}
|
||||
|
||||
$sshTest = Test-SSHConnection -Server $Server -Username $Username -Password $Password -KeyPath $KeyPath -ForcePuTTY:$ForcePuTTY
|
||||
if (-not $sshTest) {
|
||||
Write-Warning "SSH连接测试失败,将跳过自动部署"
|
||||
Write-Info "请手动在服务器上执行部署命令"
|
||||
} else {
|
||||
# 在服务器上拉取镜像并部署
|
||||
Write-Info "在服务器上拉取镜像并部署..."
|
||||
$deployCmd = "cd $RemotePath && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d"
|
||||
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no")
|
||||
if ($KeyPath -and (Test-Path $KeyPath)) {
|
||||
$sshArgs += @("-i", $KeyPath)
|
||||
Write-Info "使用 OpenSSH 执行远程部署命令"
|
||||
} elseif ($Password) {
|
||||
Write-Warning "未提供 SSH 密钥,无法在非交互模式下使用密码。请手动执行部署命令。"
|
||||
$sshArgs = $null
|
||||
} else {
|
||||
Write-Warning "缺少 SSH 凭据,请手动执行部署命令。"
|
||||
$sshArgs = $null
|
||||
}
|
||||
|
||||
if ($sshArgs) {
|
||||
$sshArgs += @("${Username}@${Server}", $deployCmd)
|
||||
try {
|
||||
$sshProcess = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait
|
||||
if ($sshProcess.ExitCode -eq 0) {
|
||||
Write-Success "部署成功"
|
||||
Celebrate-Stage "远程部署阶段"
|
||||
} else {
|
||||
Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),请手动执行: $deployCmd"
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "执行远程部署命令时出错: $_"
|
||||
Write-Info "请手动执行: $deployCmd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $sshTest -or -not $sshArgs) {
|
||||
Write-Warning "远程部署阶段未完成,已提供手动命令指引"
|
||||
} elseif ($sshTest) {
|
||||
Write-Info "开始对线上应用做一次健康检查..."
|
||||
Test-RemoteHealth -Server $Server -Port 4320 -MaxWaitSeconds 60 | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
Write-ColorOutput "" "Magenta"
|
||||
Write-ColorOutput "========================================" "Magenta"
|
||||
Write-ColorOutput " 部署完成!" "Magenta"
|
||||
Write-ColorOutput "========================================" "Magenta"
|
||||
Write-Success "所有阶段执行成功"
|
||||
Write-Info "镜像: $fullImage"
|
||||
Write-Info "服务器: ${Username}@${Server}"
|
||||
Write-Info "远程路径: $RemotePath"
|
||||
|
||||
if (-not $SkipUpload) {
|
||||
Write-Info "镜像已推送到 Registry: ${Server}:5000/youyidian/${ImageName}:${Tag}"
|
||||
Write-Info "如果自动部署失败,请在服务器上手动执行:"
|
||||
Write-Info " cd $RemotePath"
|
||||
Write-Info " docker pull ${Server}:5000/youyidian/${ImageName}:${Tag}"
|
||||
Write-Info " docker tag ${Server}:5000/youyidian/${ImageName}:${Tag} $fullImage"
|
||||
Write-Info " docker-compose up -d"
|
||||
}
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
Main
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user