From d1c6efc79dd12155faed14db3555ea4649fa0f7b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 3 Dec 2025 14:39:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20NAS=20=E9=83=A8=E7=BD=B2=E4=B8=8E=20Har?= =?UTF-8?q?bor=20=E6=8E=A8=E9=80=81=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...11-07_14-18Z-整合可迁移化部署变量到文件.md | 316 +- ...025-11-10_12-00Z-部署脚本偶尔失败的原因.md | 624 ++-- .../history/2025-11-11_10-10Z-帮我测试.md | 780 ++--- .../history/2025-11-13_00-57Z-继续讨论.md | 33 +- ...025-11-19_08-42Z-统一代码构建和部署流程.md | 24 +- .trae/documents/项目优化分析与建议.md | 11 + .trae/rules/project_rules.md | 0 Dockerfile | 14 +- README.md | 4 +- VERSION.txt | 3 +- .../_components/AppearanceSettingsPanel.tsx | 113 +- app/admin/_components/CategoriesTab.tsx | 469 ++- app/admin/_components/InvitationsTab.tsx | 126 +- app/admin/_components/MaterialsTab.tsx | 787 ++++- app/admin/_components/MonitorTab.tsx | 939 +++++- app/admin/_components/TemplateManager.tsx | 32 +- app/admin/_components/UsersTab.tsx | 113 +- .../editor/TemplateBasicsForm.tsx | 65 +- .../editor/TemplateFieldsEditor.tsx | 20 +- .../_components/template-manager/types.ts | 5 + app/admin/_components/types.ts | 4 +- .../users/TransferSuperAdminModal.tsx | 77 +- app/admin/_components/users/UserEditModal.tsx | 148 +- app/admin/monitor/page.tsx | 483 +-- app/admin/page.tsx | 217 +- app/admin/settings/page.tsx | 5 +- app/api/admin/cleanup-database/route.ts | 128 + app/api/admin/material-logs/route.ts | 185 ++ .../admin/materials/transfer-owner/route.ts | 171 + .../admin/migrate-material-fields/route.ts | 86 + .../admin/reset-material-rankings/route.ts | 77 + app/api/admin/system-status/route.ts | 137 +- app/api/admin/upload-templates/[id]/route.ts | 10 + app/api/admin/upload-templates/route.ts | 9 + app/api/admin/users/route.ts | 15 +- app/api/admin/video-transcode-stats/route.ts | 112 + app/api/category-fields/route.ts | 136 +- app/api/home-filter-fields/route.ts | 65 + app/api/material-rankings/route.ts | 65 + app/api/materials/[id]/route.ts | 94 +- app/api/materials/cleanup/route.ts | 126 + app/api/materials/download/route.ts | 226 +- app/api/materials/favorite/route.ts | 45 + app/api/materials/like/route.ts | 19 + app/api/materials/premium/route.ts | 160 + app/api/materials/restore/route.ts | 44 + app/api/materials/route.ts | 253 +- app/api/materials/share/route.ts | 19 +- app/api/materials/stats/route.ts | 8 +- app/api/materials/stream/route.ts | 85 +- app/api/materials/transcode/route.ts | 261 ++ app/api/materials/update/route.ts | 39 + app/api/materials/upload/route.ts | 820 +++-- app/api/system-settings/route.ts | 69 +- app/api/system/init/route.ts | 136 +- .../template-categories/[fieldKey]/route.ts | 372 +-- app/api/template-categories/route.ts | 255 +- app/api/upload-templates/route.ts | 32 +- app/api/user/avatar/route.ts | 86 + app/api/user/favorites/route.ts | 70 + app/api/user/likes/route.ts | 70 + app/api/user/materials/route.ts | 102 + app/api/user/profile/route.ts | 55 + app/complete-profile/page.tsx | 27 +- app/dingtalk-callback/page.tsx | 8 +- app/edit/[id]/page.tsx | 414 +-- app/globals.css | 45 +- app/layout.tsx | 3 +- app/login/page.tsx | 31 +- app/page.tsx | 2882 ++++++++++------- app/profile/page.tsx | 669 ++++ app/register/page.tsx | 23 +- app/share/[id]/page.tsx | 120 +- components/AIChatWidget.tsx | 8 +- components/DanmakuWidget.tsx | 2 +- components/EditMaterialModal.tsx | 836 ++--- components/Header.tsx | 542 +++- components/MaterialActionButtons.tsx | 247 ++ components/MaterialPreviewModal.tsx | 550 +++- components/RankingPanel.tsx | 309 +- components/UploadModal.tsx | 620 ++-- components/ui/Modal.tsx | 34 +- components/ui/Tag.tsx | 5 +- deploy-fixed.ps1 | 1154 +++++-- deploy-new-fixed.ps1 | 324 +- deploy-new.ps1 | 4 +- deploy-simple.ps1 | 62 + docker-compose.prod.yml | 38 +- docker-compose.registry.yml | 5 +- docker-compose.standard.yml | 78 +- docker-compose.yml | 4 +- docker-images/README.md | 4 +- docs/FRESH_INSTALL_GUIDE.md | 90 + docs/USER_ONBOARDING_GUIDE.md | 57 + docs/archive/部署成功报告.md | 5 +- docs/chats/聊天10.md | 348 +- docs/chats/聊天7.md | 324 +- docs/chats/聊天记录.md | 46 +- docs/chats/聊天记录14.md | 62 +- docs/chats/聊天记录2.md | 44 +- docs/chats/聊天记录7.md | 30 +- docs/chats/聊天记录9.md | 324 +- docs/guides/CUSTOMER_ONE_COMMAND_DEPLOY.md | 10 +- docs/guides/CUSTOMER_README.md | 20 +- docs/guides/DEPLOYMENT_GUIDE.md | 5 +- docs/guides/DEPLOYMENT_OPTIMIZATION.md | 7 +- docs/guides/DOCKER_OPTIMIZATION_GUIDE.md | 11 +- docs/guides/DOCKER_REGISTRY_SETUP.md | 5 +- docs/guides/FEATURE_UPDATES.md | 3 +- docs/guides/FRP穿透检查报告-3d.yydd.ai.md | 5 +- docs/guides/HARBOR_ENV_CONFIG.md | 172 + docs/guides/INCREMENTAL_DEPLOYMENT.md | 7 +- docs/guides/NAS_INSTALL_GUIDE.md | 177 + docs/guides/OPTIMIZATION_IMPLEMENTATION.md | 5 +- docs/guides/PACKAGE_SUMMARY.md | 10 +- docs/guides/PROJECT_INFO.md | 3 +- docs/guides/PROJECT_INTRODUCTION.md | 16 +- docs/guides/PUBLIC_ACCESS.md | 3 +- docs/guides/QUICK_BUILD_GUIDE.md | 28 +- docs/guides/README_PACKAGE.md | 20 +- docs/guides/REGISTRY_DEPLOYMENT_GUIDE.md | 5 +- docs/guides/REGISTRY_SETUP_COMPLETE.md | 50 +- docs/guides/SALES_PACKAGE.md | 12 +- docs/guides/UPLOAD_TROUBLESHOOTING.md | 9 +- docs/guides/上传修复说明.md | 3 +- docs/guides/修复完成总结.md | 3 +- docs/guides/内网检测改进实施报告.md | 3 +- docs/guides/安装方式对比.md | 3 +- docs/guides/完整方案说明.md | 12 +- docs/guides/客户安装指南.md | 38 +- docs/guides/开始构建镜像.md | 52 +- docs/guides/快速使用指南.md | 5 +- docs/guides/更新部署完成报告_20251105.md | 5 +- docs/guides/服务器日志诊断报告.md | 11 +- docs/guides/查看上传日志命令.md | 11 +- docs/guides/部署完成报告.md | 9 +- docs/guides/部署记录.md | 10 +- docs/guides/钉钉登录-信息补充功能部署报告.md | 3 +- docs/guides/钉钉登录部署报告.md | 3 +- docs/guides/钉钉登录配置说明.md | 3 +- docs/guides/钉钉登录错误修复报告.md | 5 +- docs/guides/问题修复完成报告.md | 3 +- docs/guides/项目交付说明.md | 9 +- docs/reports/DATABASE_MIGRATION_REPORT.md | 3 +- docs/reports/DEPLOYMENT_REPORT_20251028.md | 5 +- docs/reports/DEPLOYMENT_SUCCESS.md | 7 +- docs/reports/IMAGE_DIMENSIONS_UPDATE.md | 5 +- docs/reports/PSD_AUTO_PREVIEW_DEPLOYMENT.md | 3 +- docs/reports/UPLOAD_FIX_SUMMARY.md | 3 +- docs/scripts/DEPLOY_SCRIPT_OPTIMIZATION.md | 7 +- docs/修复记录-用户和Logo问题.md | 6 +- docs/基础镜像Registry方案说明.md | 184 ++ docs/素材类型模板配置记录.md | 3 +- lib/auth.ts | 40 +- lib/field-pool.ts | 417 +++ lib/material-download-log.ts | 55 + lib/material-log.ts | 101 + lib/s3.ts | 84 +- lib/system-settings-columns.ts | 10 +- lib/ui-styles-base.ts | 8 +- lib/ui-theme-colors-dark.ts | 2 +- lib/ui-theme-colors-light.ts | 2 +- lib/upload-template-presets.ts | 11 + lib/video-processor.ts | 310 ++ lib/video-transcode-queue.ts | 111 + middleware.ts | 4 +- package-lock.json | 5 +- package.json | 6 +- prisma/dev.db | Bin 217088 -> 217442 bytes prisma/schema.prisma | 143 +- push-to-harbor.ps1 | 466 +++ scripts/apply-migration.sh | 3 +- scripts/backfill-material-ids.js | 85 + scripts/backfill-material-ownership.ts | 111 + scripts/backup | 3 +- scripts/check-harbor.ps1 | 23 + scripts/cleanup-category-options.js | 185 ++ scripts/cleanup-category-options.ts | 202 ++ scripts/cleanup-database.js | 67 + scripts/cleanup-harbor.ps1 | 204 ++ scripts/cleanup-materials.js | 121 + scripts/cleanup-server.ps1 | 157 + scripts/deploy/DEPLOYMENT_SUMMARY.md | 3 +- scripts/deploy/README.md | 46 +- scripts/deploy/create-customer-package.ps1 | 11 +- scripts/deploy/local-build-and-upload.ps1 | 11 +- scripts/inspect-category-options.ts | 33 + scripts/inspect-legacy-categories.ts | 28 + scripts/migrate-material-fields.js | 97 + scripts/push-base-images.ps1 | 70 + scripts/run-migration-on-server.sh | 11 +- scripts/tools/apply-upload-fix.sh | 7 +- scripts/tools/backup-database.sh | 5 +- scripts/tools/build-and-push.sh | 18 +- scripts/tools/build-windows.bat | 6 +- scripts/tools/check-logo-config.ps1 | 2 +- scripts/tools/check-logo-simple.ps1 | 2 +- scripts/tools/clear-users-table.ps1 | 2 +- scripts/tools/connect-nas.sh | 3 +- scripts/tools/download-backup.ps1 | 3 +- scripts/tools/export-and-download.ps1 | 3 +- scripts/tools/export-postgresql-data.sh | 3 +- scripts/tools/healthcheck.sh | 5 +- scripts/tools/import-backup.sh | 3 +- scripts/tools/install-auto-windows.bat | 15 +- scripts/tools/install-auto.sh | 73 +- scripts/tools/install.sh | 214 +- scripts/tools/quick-start.sh | 5 +- scripts/tools/start.sh | 3 +- scripts/tools/transfer-to-nas.ps1 | 123 + scripts/tools/uninstall.sh | 136 +- scripts/tools/update-server.sh | 3 +- scripts/tools/一键导出下载.sh | 5 +- scripts/tools/一键部署.bat | 3 +- scripts/tools/快速导出数据库.ps1 | 5 +- scripts/tools/快速导出数据库.sh | 3 +- ssh_stderr_20251127183813_56300_9425.txt | 0 ssh_stderr_20251127183817_56300_5817.txt | 0 ssh_stderr_20251127183821_56300_9585.txt | 0 ssh_stderr_20251127184141_56300_1897.txt | 0 ssh_stderr_20251127184145_56300_9118.txt | 0 ssh_stderr_20251127184149_56300_8370.txt | 0 ssh_stdout_20251127183813_56300_8612.txt | 1 + ssh_stdout_20251127183817_56300_6718.txt | 1 + ssh_stdout_20251127183821_56300_9791.txt | 1 + ssh_stdout_20251127184141_56300_5615.txt | 1 + ssh_stdout_20251127184145_56300_2999.txt | 1 + ssh_stdout_20251127184149_56300_5582.txt | 1 + tailwind.config.ts | 16 + 一键部署完整指南.md | 17 +- 一键部署快速指南.md | 13 +- 一键镜像部署.ps1 | 7 +- 优化计划与优先级.md | 165 +- 优化记录.md | 206 ++ 后续修改.txt | 144 +- 在git.1080p.hk部署镜像仓库指南.md | 29 +- 安装与出厂设置指南.md | 90 + 新手用户使用指引.md | 57 + 更新日志.md | 310 ++ 架构设计与编码规范.md | 2 +- 版本架构设计方案.md | 5 +- 私有镜像仓库部署指南.md | 31 +- 项目优化分析与建议.md | 398 +++ 项目说明-由壹点3D素材.md | 137 + 244 files changed, 19944 insertions(+), 7017 deletions(-) create mode 100644 .trae/documents/项目优化分析与建议.md create mode 100644 .trae/rules/project_rules.md create mode 100644 app/api/admin/cleanup-database/route.ts create mode 100644 app/api/admin/material-logs/route.ts create mode 100644 app/api/admin/materials/transfer-owner/route.ts create mode 100644 app/api/admin/migrate-material-fields/route.ts create mode 100644 app/api/admin/reset-material-rankings/route.ts create mode 100644 app/api/admin/video-transcode-stats/route.ts create mode 100644 app/api/home-filter-fields/route.ts create mode 100644 app/api/material-rankings/route.ts create mode 100644 app/api/materials/cleanup/route.ts create mode 100644 app/api/materials/favorite/route.ts create mode 100644 app/api/materials/premium/route.ts create mode 100644 app/api/materials/restore/route.ts create mode 100644 app/api/materials/transcode/route.ts create mode 100644 app/api/user/avatar/route.ts create mode 100644 app/api/user/favorites/route.ts create mode 100644 app/api/user/likes/route.ts create mode 100644 app/api/user/materials/route.ts create mode 100644 app/api/user/profile/route.ts create mode 100644 app/profile/page.tsx create mode 100644 components/MaterialActionButtons.tsx create mode 100644 deploy-simple.ps1 create mode 100644 docs/FRESH_INSTALL_GUIDE.md create mode 100644 docs/USER_ONBOARDING_GUIDE.md create mode 100644 docs/guides/HARBOR_ENV_CONFIG.md create mode 100644 docs/guides/NAS_INSTALL_GUIDE.md create mode 100644 docs/基础镜像Registry方案说明.md create mode 100644 lib/field-pool.ts create mode 100644 lib/material-download-log.ts create mode 100644 lib/material-log.ts create mode 100644 lib/video-processor.ts create mode 100644 lib/video-transcode-queue.ts create mode 100644 push-to-harbor.ps1 create mode 100644 scripts/backfill-material-ids.js create mode 100644 scripts/backfill-material-ownership.ts create mode 100644 scripts/check-harbor.ps1 create mode 100644 scripts/cleanup-category-options.js create mode 100644 scripts/cleanup-category-options.ts create mode 100644 scripts/cleanup-database.js create mode 100644 scripts/cleanup-harbor.ps1 create mode 100644 scripts/cleanup-materials.js create mode 100644 scripts/cleanup-server.ps1 create mode 100644 scripts/inspect-category-options.ts create mode 100644 scripts/inspect-legacy-categories.ts create mode 100644 scripts/migrate-material-fields.js create mode 100644 scripts/push-base-images.ps1 create mode 100644 scripts/tools/transfer-to-nas.ps1 create mode 100644 ssh_stderr_20251127183813_56300_9425.txt create mode 100644 ssh_stderr_20251127183817_56300_5817.txt create mode 100644 ssh_stderr_20251127183821_56300_9585.txt create mode 100644 ssh_stderr_20251127184141_56300_1897.txt create mode 100644 ssh_stderr_20251127184145_56300_9118.txt create mode 100644 ssh_stderr_20251127184149_56300_8370.txt create mode 100644 ssh_stdout_20251127183813_56300_8612.txt create mode 100644 ssh_stdout_20251127183817_56300_6718.txt create mode 100644 ssh_stdout_20251127183821_56300_9791.txt create mode 100644 ssh_stdout_20251127184141_56300_5615.txt create mode 100644 ssh_stdout_20251127184145_56300_2999.txt create mode 100644 ssh_stdout_20251127184149_56300_5582.txt create mode 100644 安装与出厂设置指南.md create mode 100644 新手用户使用指引.md create mode 100644 项目优化分析与建议.md create mode 100644 项目说明-由壹点3D素材.md diff --git a/.specstory/history/2025-11-07_14-18Z-整合可迁移化部署变量到文件.md b/.specstory/history/2025-11-07_14-18Z-整合可迁移化部署变量到文件.md index 85cb693..c56edf7 100644 --- a/.specstory/history/2025-11-07_14-18Z-整合可迁移化部署变量到文件.md +++ b/.specstory/history/2025-11-07_14-18Z-整合可迁移化部署变量到文件.md @@ -2038,27 +2038,27 @@ Starting containers... time="2025-11-11T10:26:33+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" Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting Waiting for containers to start... - Container youyidian-3d-material Started + Container flnxi-material Started Running database migration... Prisma schema loaded from prisma/schema.prisma Datasource "db": PostgreSQL database "gongxiangpan", schema "public" at "45.249.208.118:5432" Database migration completed or skipped Container status: NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp time="2025-11-11T10:27:34+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" Application logs (last 50 lines): time="2025-11-11T10:27:35+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" -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 249ms +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 249ms [SUCCESS] Deployment completed! @@ -2142,7 +2142,7 @@ Lines added: 53, lines removed: 1 + Write-Host $diskUsage + + Write-Host "`n=== Deployment Summary ===" -ForegroundColor Cyan -+ Write-Host "Container: youyidian-3d-material" -ForegroundColor White ++ Write-Host "Container: flnxi-material" -ForegroundColor White + Write-Host "Port: 4320 (mapped to container port 3000)" -ForegroundColor White + Write-Host "Status: Running" -ForegroundColor Green + Write-Host "`nAccess URLs:" -ForegroundColor Cyan @@ -2177,13 +2177,13 @@ Checking deployment status... [1/5] Checking container status... time="2025-11-11T10:28:25+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" -NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS flnxi-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp [2/5] Checking application health... HTTP Status: 307 [3/5] Checking application logs (last 30 lines)... -time="2025-11-11T10:28:26+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" youyidian-3d-material | ▲ Next.js 14.2.33 youyidian-3d-material | - Local: http://localhost:3000 youyidian-3d-material | - Network: http://0.0.0.0:3000 youyidian-3d-material | youyidian-3d-material | ✓ Starting... youyidian-3d-material | ✓ Ready in 249ms +time="2025-11-11T10:28:26+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" flnxi-material | ▲ Next.js 14.2.33 flnxi-material | - Local: http://localhost:3000 flnxi-material | - Network: http://0.0.0.0:3000 flnxi-material | flnxi-material | ✓ Starting... flnxi-material | ✓ Ready in 249ms [4/5] Checking Docker image info... Repository: youyidian-3d-app, Tag: latest, Created: 2025-11-08 17:35:29 +0800 CST, Size: 303MB @@ -2192,7 +2192,7 @@ Repository: youyidian-3d-app, Tag: latest, Created: 2025-11-08 17:35:29 +0800 CS /dev/vda3 50G 18G 33G 35% / === Deployment Summary === -Container: youyidian-3d-material +Container: flnxi-material Port: 4320 (mapped to container port 3000) Status: Running @@ -2240,7 +2240,7 @@ Lines added: 229, lines removed: 1 + + **部署时间:** 2025-11-11 10:26 + **服务器:** 45.249.208.118:4320 -+ **容器名称:** youyidian-3d-material ++ **容器名称:** flnxi-material + **容器状态:** ✅ 正在运行 + **应用状态:** ✅ Next.js 已就绪 + @@ -2314,7 +2314,7 @@ Lines added: 229, lines removed: 1 + + ### 容器信息 + ``` -+ 容器名称: youyidian-3d-material ++ 容器名称: flnxi-material + 镜像: youyidian-3d-app:latest + 端口映射: 0.0.0.0:4320->3000/tcp + 状态: Up (运行中) @@ -2412,7 +2412,7 @@ Lines added: 229, lines removed: 1 + docker compose logs app --tail=50 -f + + # 查看容器资源使用 -+ docker stats youyidian-3d-material ++ docker stats flnxi-material + ``` + + ### 3. 备份数据 @@ -4175,7 +4175,7 @@ Repository: youyidian-3d-app, Tag: latest, Created: 2025-11-08 17:35:29 +0800 CS /dev/vda3 50G 17G 33G 34% / === Deployment Summary === -Container: youyidian-3d-material +Container: flnxi-material Port: 4320 (mapped to container port 3000) Status: Running @@ -4227,10 +4227,10 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas time="2025-11-11T10:51:30+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" Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started ``` @@ -4464,30 +4464,30 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas ``` NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 5 minutes ago Up 5 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 5 minutes ago Up 5 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp time="2025-11-11T10:56:50+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" --- time="2025-11-11T10:56: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" -youyidian-3d-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT 1 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "orderby_aggregator" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "orderby_1_Material" ON ("public"."users"."id" = "orderby_1_Material"."userId") LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY COALESCE("orderby_1_Material"."orderby_aggregator", $1) DESC LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT SUM("public"."materials"."downloadCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."downloadCount") DESC LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT 1 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "orderby_aggregator" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "orderby_1_Material" ON ("public"."users"."id" = "orderby_1_Material"."userId") LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY COALESCE("orderby_1_Material"."orderby_aggregator", $1) DESC LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT SUM("public"."materials"."downloadCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."downloadCount") DESC LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT 1 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "orderby_aggregator" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "orderby_1_Material" ON ("public"."users"."id" = "orderby_1_Material"."userId") LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY COALESCE("orderby_1_Material"."orderby_aggregator", $1) DESC LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT SUM("public"."materials"."downloadCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."downloadCount") DESC LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 -youyidian-3d-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT 1 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "orderby_aggregator" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "orderby_1_Material" ON ("public"."users"."id" = "orderby_1_Material"."userId") LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY COALESCE("orderby_1_Material"."orderby_aggregator", $1) DESC LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT SUM("public"."materials"."downloadCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."downloadCount") DESC LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT 1 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "orderby_aggregator" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "orderby_1_Material" ON ("public"."users"."id" = "orderby_1_Material"."userId") LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY COALESCE("orderby_1_Material"."orderby_aggregator", $1) DESC LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT SUM("public"."materials"."downloadCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."downloadCount") DESC LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT 1 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "orderby_aggregator" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "orderby_1_Material" ON ("public"."users"."id" = "orderby_1_Material"."userId") LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY COALESCE("orderby_1_Material"."orderby_aggregator", $1) DESC LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT SUM("public"."materials"."downloadCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."downloadCount") DESC LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 +flnxi-material | prisma:query SELECT SUM("public"."materials"."likeCount"), "public"."materials"."userId" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId" ORDER BY SUM("public"."materials"."likeCount") DESC LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."username" FROM "public"."users" WHERE ("public"."users"."id" = $1 AND 1=1) LIMIT $2 OFFSET $3 ``` @@ -4840,14 +4840,14 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas ``` NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 8 minutes ago Up 24 seconds 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 8 minutes ago Up 24 seconds 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp time="2025-11-11T10:59:31+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" --- time="2025-11-11T10:59:31+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" - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed #3 ERROR: failed to do request: Head "https://registry-1.docker.io/v2/library/node/manifests/18-alpine": dial tcp 104.244.43.136:443: i/o timeout @@ -4880,11 +4880,11 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas time="2025-11-11T11:01:11+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" Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started -<!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/d9f40839793e6b5d.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js"/><script src="/_next/static/chunks/fd9d1056-1aae0987937804d3.js" async=""></script><script src="/_next/static/chunks/117-74a99ebe019000cd.js" async=""></script><script src="/_next/static/chunks/main-app-6fca1515cee9a03c.js" async=""></script><script src="/_next/static/chunks/605-7a51c0c3c26fae9e.js" async=""></script><script src="/_next/static/chunks/app/layout-d49c608feac6ab22.js" async=""></script><meta name="robots" content="noindex"/><title>404: This page could not be found.</title><title>由壹点3D素材</title><meta name="description" content="3D素材分享平台"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="font-sans antialiased"><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script src="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/d9f40839793e6b5d.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[4707,[],\"\"]\n5:I[6423,[],\"\"]\n6:I[9294,[\"605\",\"static/chunks/605-7a51c0c3c26fae9e.js\",\"185\",\"static/chunks/app/layout-d49c608feac6ab22.js\"],\"default\"]\nc:I[1060,[],\"\"]\n7:{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"}\n8:{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"}\n9:{\"display\":\"inline-block\"}\na:{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0}\nd:[]\n"])</script><script>self.__next_f.push([1,"0:[\"$\",\"$L2\",null,{\"buildId\":\"p3-L2frN6OXyyREm26kH5\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"api\",\"invitations\"],\"initialTree\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null],null],null]},[null,[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"/_not-found\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\"}]],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/d9f40839793e6b5d.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"className\":\"font-sans antialiased\",\"children\":[\"$\",\"$L6\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":\"$7\",\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":\"$8\",\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":\"$9\",\"children\":[\"$\",\"h2\",null,{\"style\":\"$a\",\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[]}]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],\"$Lb\"],\"globalErrorComponent\":\"$c\",\"missingSlots\":\"$Wd\"}]\n"])</script><script>self.__next_f.push([1,"b:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"由壹点3D素材\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"3D素材分享平台\"}]]\n3:null\n"])</script></body></html> + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started +<!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/d9f40839793e6b5d.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js"/><script src="/_next/static/chunks/fd9d1056-1aae0987937804d3.js" async=""></script><script src="/_next/static/chunks/117-74a99ebe019000cd.js" async=""></script><script src="/_next/static/chunks/main-app-6fca1515cee9a03c.js" async=""></script><script src="/_next/static/chunks/605-7a51c0c3c26fae9e.js" async=""></script><script src="/_next/static/chunks/app/layout-d49c608feac6ab22.js" async=""></script><meta name="robots" content="noindex"/><title>404: This page could not be found.</title><title>FLNXI素材库</title><meta name="description" content="3D素材分享平台"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="font-sans antialiased"><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script src="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/d9f40839793e6b5d.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[4707,[],\"\"]\n5:I[6423,[],\"\"]\n6:I[9294,[\"605\",\"static/chunks/605-7a51c0c3c26fae9e.js\",\"185\",\"static/chunks/app/layout-d49c608feac6ab22.js\"],\"default\"]\nc:I[1060,[],\"\"]\n7:{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"}\n8:{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"}\n9:{\"display\":\"inline-block\"}\na:{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0}\nd:[]\n"])</script><script>self.__next_f.push([1,"0:[\"$\",\"$L2\",null,{\"buildId\":\"p3-L2frN6OXyyREm26kH5\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"api\",\"invitations\"],\"initialTree\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null],null],null]},[null,[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"/_not-found\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\"}]],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/d9f40839793e6b5d.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"className\":\"font-sans antialiased\",\"children\":[\"$\",\"$L6\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":\"$7\",\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":\"$8\",\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":\"$9\",\"children\":[\"$\",\"h2\",null,{\"style\":\"$a\",\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[]}]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],\"$Lb\"],\"globalErrorComponent\":\"$c\",\"missingSlots\":\"$Wd\"}]\n"])</script><script>self.__next_f.push([1,"b:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"FLNXI素材库\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"3D素材分享平台\"}]]\n3:null\n"])</script></body></html> ``` @@ -5177,10 +5177,10 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas ``` time="2025-11-11T11:06:27+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" - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing 寮€濮嬫瀯寤洪暅鍍?.. Network youyidian-3d_default Removed @@ -5727,13 +5727,13 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas time="2025-11-11T11:22:14+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" Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started time="2025-11-11T11:22:15+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" -> youyidian-3d-material@1.0.0 build +> flnxi-material@1.0.0 build > prisma generate && next build sh: prisma: not found @@ -5849,7 +5849,7 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas #13 [9/9] RUN npm run build #13 1.717 -#13 1.717 > youyidian-3d-material@1.0.0 build +#13 1.717 > flnxi-material@1.0.0 build #13 1.717 > prisma generate && next build #13 1.717 #13 1.729 sh: prisma: not found @@ -5857,7 +5857,7 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas ------ > [9/9] RUN npm run build: 1.717 -1.717 > youyidian-3d-material@1.0.0 build +1.717 > flnxi-material@1.0.0 build 1.717 > prisma generate && next build 1.717 1.729 sh: prisma: not found @@ -5916,21 +5916,21 @@ time="2025-11-11T11:28:05+08:00" level=warning msg="/root/youyidi --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Password = "lxvhNYFZ4160"; & $plinkPath -ssh -pw $Password -batch "root@45.249.208.118" "cd /root/youyidian-3d && docker compose down && docker cp app/api/invitations/\[id\]/route.ts youyidian-3d-material:/app/app/api/invitations/[id]/route.ts 2>&1 && docker cp app/api/invitations/route.ts youyidian-3d-material:/app/app/api/invitations/route.ts 2>&1 && docker cp app/page.tsx youyidian-3d-material:/app/app/page.tsx 2>&1 && docker cp app/admin/page.tsx youyidian-3d-material:/app/app/admin/page.tsx 2>&1 && docker cp components/UploadModal.tsx youyidian-3d-material:/app/components/UploadModal.tsx 2>&1 && docker cp components/RankingPanel.tsx youyidian-3d-material:/app/components/RankingPanel.tsx 2>&1 && echo '文件复制完成'" +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Password = "lxvhNYFZ4160"; & $plinkPath -ssh -pw $Password -batch "root@45.249.208.118" "cd /root/youyidian-3d && docker compose down && docker cp app/api/invitations/\[id\]/route.ts flnxi-material:/app/app/api/invitations/[id]/route.ts 2>&1 && docker cp app/api/invitations/route.ts flnxi-material:/app/app/api/invitations/route.ts 2>&1 && docker cp app/page.tsx flnxi-material:/app/app/page.tsx 2>&1 && docker cp app/admin/page.tsx flnxi-material:/app/app/admin/page.tsx 2>&1 && docker cp components/UploadModal.tsx flnxi-material:/app/components/UploadModal.tsx 2>&1 && docker cp components/RankingPanel.tsx flnxi-material:/app/components/RankingPanel.tsx 2>&1 && echo '文件复制完成'" ```bash -cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Password = "lxvhNYFZ4160"; & $plinkPath -ssh -pw $Password -batch "root@45.249.208.118" "cd /root/youyidian-3d && docker compose down && docker cp app/api/invitations/\[id\]/route.ts youyidian-3d-material:/app/app/api/invitations/[id]/route.ts 2>&1 && docker cp app/api/invitations/route.ts youyidian-3d-material:/app/app/api/invitations/route.ts 2>&1 && docker cp app/page.tsx youyidian-3d-material:/app/app/page.tsx 2>&1 && docker cp app/admin/page.tsx youyidian-3d-material:/app/app/admin/page.tsx 2>&1 && docker cp components/UploadModal.tsx youyidian-3d-material:/app/components/UploadModal.tsx 2>&1 && docker cp components/RankingPanel.tsx youyidian-3d-material:/app/components/RankingPanel.tsx 2>&1 && echo '文件复制完成'" +cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Password = "lxvhNYFZ4160"; & $plinkPath -ssh -pw $Password -batch "root@45.249.208.118" "cd /root/youyidian-3d && docker compose down && docker cp app/api/invitations/\[id\]/route.ts flnxi-material:/app/app/api/invitations/[id]/route.ts 2>&1 && docker cp app/api/invitations/route.ts flnxi-material:/app/app/api/invitations/route.ts 2>&1 && docker cp app/page.tsx flnxi-material:/app/app/page.tsx 2>&1 && docker cp app/admin/page.tsx flnxi-material:/app/app/admin/page.tsx 2>&1 && docker cp components/UploadModal.tsx flnxi-material:/app/components/UploadModal.tsx 2>&1 && docker cp components/RankingPanel.tsx flnxi-material:/app/components/RankingPanel.tsx 2>&1 && echo '文件复制完成'" ``` ``` time="2025-11-11T11:28:09+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" - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed -Error response from daemon: No such container: youyidian-3d-material +Error response from daemon: No such container: flnxi-material ```
@@ -5959,13 +5959,13 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas time="2025-11-11T11:28:37+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" Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started time="2025-11-11T11:28:43+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" NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 6 seconds ago Up 5 seconds 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 6 seconds ago Up 5 seconds 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp ```
@@ -6076,10 +6076,10 @@ cd d:\sucaiku\flnxi_sucai; $plinkPath = "C:\Program Files\PuTTY\plink.exe"; $Pas ``` time="2025-11-11T11:45:37+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" - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing 寮€濮嬫瀯寤洪暅鍍?.. Network youyidian-3d_default Removed @@ -10171,7 +10171,7 @@ Lines added: 56, lines removed: 40 - Write-Host $diskUsage - - Write-Host "`n=== Deployment Summary ===" -ForegroundColor Cyan -- Write-Host "Container: youyidian-3d-material" -ForegroundColor White +- Write-Host "Container: flnxi-material" -ForegroundColor White - Write-Host "Port: 4320 (mapped to container port 3000)" -ForegroundColor White - Write-Host "Status: Running" -ForegroundColor Green - Write-Host "`nAccess URLs:" -ForegroundColor Cyan @@ -10467,24 +10467,24 @@ node 18-alpine ee77c6cd7c18 7 months ago 127MB time="2025-11-11T13:50:28+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" Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started 4. Container status after start: time="2025-11-11T13:50:33+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" NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 5 seconds ago Up 4 seconds 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 5 seconds ago Up 4 seconds 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp 5. Application logs: time="2025-11-11T13:50:34+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" -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 277ms +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 277ms Check complete. @@ -10500,7 +10500,7 @@ Check complete. 部署成功。应用已启动: -- 容器:`youyidian-3d-material` 正在运行 +- 容器:`flnxi-material` 正在运行 - 端口:4320(映射到容器内 3000) - Next.js:已就绪(Ready in 277ms) @@ -10587,10 +10587,10 @@ This may take several minutes... 1. Stopping containers... time="2025-11-11T13:51:12+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" - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed @@ -10866,7 +10866,7 @@ time="2025-11-11T13:51:14+08:00" level=warning msg="/root/youyidi #16 [builder 5/5] RUN npm run build #16 1.082 -#16 1.082 > youyidian-3d-material@1.0.0 build +#16 1.082 > flnxi-material@1.0.0 build #16 1.082 > prisma generate && next build #16 1.082 #16 3.040 prisma:warn Prisma failed to detect the libssl/openssl version to use, and may not work as expected. Defaulting to "openssl-1.1.x". @@ -11795,10 +11795,10 @@ time="2025-11-11T13:51:14+08:00" level=warning msg="/root/youyidi youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Error - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started 3. Waiting for containers to start... 4. Checking container status... @@ -11807,12 +11807,12 @@ NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS 5. Application logs (last 30 lines): time="2025-11-11T13:53:53+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" -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 261ms +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 261ms Rebuild complete! @@ -12160,7 +12160,7 @@ Lines added: 104, lines removed: 1 + - **服务器地址**: 45.249.208.118 + - **SSH 用户**: root + - **项目路径**: /root/youyidian-3d -+ - **容器名称**: youyidian-3d-material ++ - **容器名称**: flnxi-material + - **应用端口**: 4320 (映射到容器内 3000) + + ## 后续操作 @@ -12285,7 +12285,7 @@ cd d:\sucaiku\flnxi_sucai; $plink="C:\Program Files\PuTTY\plink.exe"; & $plink - ``` CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES -7f1b5eb4ab07 youyidian-3d-app "docker-entrypoint.s…" 21 minutes ago Up 21 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp youyidian-3d-material +7f1b5eb4ab07 youyidian-3d-app "docker-entrypoint.s…" 21 minutes ago Up 21 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp flnxi-material 422b595682b1 postgres:15-alpine "docker-entrypoint.s…" 3 days ago Exited (128) 38 minutes ago flnxi-postgres 4f51cf252054 flnxi-website-web "docker-entrypoint.s…" 2 weeks ago Up 37 minutes (unhealthy) 0.0.0.0:4782->3000/tcp, [::]:4782->3000/tcp flnxi-website-web-1 297e33bec182 new-api-new-api "/one-api --log-dir …" 2 weeks ago Up 37 minutes (healthy) new-api @@ -14214,7 +14214,7 @@ cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNY ``` ``` -youyidian-3d-material 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp flnxi-website-web-1 0.0.0.0:4782->3000/tcp, [::]:4782->3000/tcp new-api @@ -14285,7 +14285,7 @@ cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNY ``` flnxi-postgres 127.0.0.1:5433->5432/tcp Up 24 seconds (healthy) -youyidian-3d-material 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp Up 26 minutes +flnxi-material 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp Up 26 minutes flnxi-website-web-1 0.0.0.0:4782->3000/tcp, [::]:4782->3000/tcp Up 42 minutes (unhealthy) new-api Up 42 minutes (healthy) @@ -14501,7 +14501,7 @@ cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNY ``` flnxi-postgres 0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp Up 6 seconds (health: starting) -youyidian-3d-material 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp Up 28 minutes +flnxi-material 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp Up 28 minutes flnxi-website-web-1 0.0.0.0:4782->3000/tcp, [::]:4782->3000/tcp Up 44 minutes (unhealthy) new-api Up 44 minutes (healthy) @@ -14611,7 +14611,7 @@ _**Agent (model default, mode Agent)**_ I need to figure out the Prisma schema issue where the column enableUserActivation is missing. It looks like the migrations may not have run on the server database that the app uses. The DATABASE_URL seems to be pointing to a different local DB instead of the flnxi-postgres we intended. The environment file shows it's connecting to a DB named 'gongxiangpan', which suggests the migrations haven’t been applied. To fix this, I should confirm the app’s environment in the container and run prisma migrate deploy. **Executing database commands** -I'm planning to use plink to exec into youyidian-3d-material and run the migration command with npx prisma migrate deploy, ensuring NODE_ENV is set to production. If there aren't any migrations, I can run prisma db push instead, but I think we added migrations. The user's concern about the local address needs consideration; I can confirm that the .env file points to an external address now, not local. I'll execute commands carefully, starting with checking the environment variables and then running the migration. Let's proceed! +I'm planning to use plink to exec into flnxi-material and run the migration command with npx prisma migrate deploy, ensuring NODE_ENV is set to production. If there aren't any migrations, I can run prisma db push instead, but I think we added migrations. The user's concern about the local address needs consideration; I can confirm that the .env file points to an external address now, not local. I'll execute commands carefully, starting with checking the environment variables and then running the migration. Let's proceed! --- @@ -14624,10 +14624,10 @@ Model provided invalid arguments to terminal tool. --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'printenv | egrep \"^(DATABASE_URL|NODE_ENV)\"'" +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'printenv | egrep \"^(DATABASE_URL|NODE_ENV)\"'" ```bash -cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'printenv | egrep \"^(DATABASE_URL|NODE_ENV)\"'" +cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'printenv | egrep \"^(DATABASE_URL|NODE_ENV)\"'" ``` ``` @@ -14646,10 +14646,10 @@ DATABASE_URL : 无法将“DATABASE_URL”项识别为 cmdlet、函数、脚本 --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; $cmd='docker exec youyidian-3d-material sh -lc ''printenv | egrep "^(DATABASE_URL|NODE_ENV)"'''; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 $cmd +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; $cmd='docker exec flnxi-material sh -lc ''printenv | egrep "^(DATABASE_URL|NODE_ENV)"'''; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 $cmd ```bash -cd d:\sucaiku\flnxi_sucai; $cmd='docker exec youyidian-3d-material sh -lc ''printenv | egrep "^(DATABASE_URL|NODE_ENV)"'''; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 $cmd +cd d:\sucaiku\flnxi_sucai; $cmd='docker exec flnxi-material sh -lc ''printenv | egrep "^(DATABASE_URL|NODE_ENV)"'''; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 $cmd ``` ``` @@ -14666,10 +14666,10 @@ sh: syntax error: unexpected "(" --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc \"echo DATABASE_URL=\$DATABASE_URL; echo NODE_ENV=\$NODE_ENV\"" +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc \"echo DATABASE_URL=\$DATABASE_URL; echo NODE_ENV=\$NODE_ENV\"" ```bash -cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc \"echo DATABASE_URL=\$DATABASE_URL; echo NODE_ENV=\$NODE_ENV\"" +cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc \"echo DATABASE_URL=\$DATABASE_URL; echo NODE_ENV=\$NODE_ENV\"" ``` ``` @@ -14688,10 +14688,10 @@ NODE_ENV=\\ --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma migrate deploy || npx prisma db push'" +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma migrate deploy || npx prisma db push'" ```bash -cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma migrate deploy || npx prisma db push'" +cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma migrate deploy || npx prisma db push'" ``` ``` @@ -14738,10 +14738,10 @@ Error: Use the --accept-data-loss flag to ignore the data loss warnings like npx --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma migrate resolve --applied 20251026015433_add_material_type'" +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma migrate resolve --applied 20251026015433_add_material_type'" ```bash -cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma migrate resolve --applied 20251026015433_add_material_type'" +cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma migrate resolve --applied 20251026015433_add_material_type'" ``` ``` @@ -14758,10 +14758,10 @@ Migration 20251026015433_add_material_type marked as applied. --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" ```bash -cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" +cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" ``` ``` @@ -14827,10 +14827,10 @@ DROP INDEX --- -
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" +
Tool use: **command** • Run command: cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" ```bash -cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec youyidian-3d-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" +cd d:\sucaiku\flnxi_sucai; & "C:\Program Files\PuTTY\plink.exe" -ssh -pw "lxvhNYFZ4160" root@45.249.208.118 "docker exec flnxi-material sh -lc 'cd /app && npx prisma db push --accept-data-loss'" ``` ``` @@ -16092,7 +16092,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM docker-compose.yml", - "contentsAfterEdit": "version: '3.8'\r\n\r\nservices:\r\n app:\r\n build:\r\n context: .\r\n dockerfile: Dockerfile\r\n container_name: youyidian-3d-material\r\n restart: unless-stopped\r\n ports:\r\n - \"4320:3000\"\r\n extra_hosts:\r\n - \"host.docker.internal:host-gateway\"\r\n dns:\r\n - 223.5.5.5 # 阿里云DNS\r\n - 8.8.8.8 # Google DNS\r\n - 114.114.114.114 # 114DNS\r\n env_file:\r\n - .env\r\n environment:\r\n - NODE_ENV=production\r\n # 数据库配置从.env文件自动读取\r\n # DATABASE_URL=${DATABASE_URL}\r\n # DATABASE_DRIVER=${DATABASE_DRIVER}\r\n # 站点公共URL(可选,优先用于生成邀请链接等)\r\n # APP_PUBLIC_URL=${APP_PUBLIC_URL}\r\n - KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=\r\n - VECTOR_DB=pgvector\r\n - PGVECTOR_DB_URL=${DATABASE_URL}\r\n - STORAGE_PROVIDER=s3\r\n # S3配置从.env文件自动读取\r\n # S3_ENDPOINT=${S3_ENDPOINT}\r\n # S3_BUCKET=${S3_BUCKET}\r\n # S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}\r\n # S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}\r\n # S3_REGION=${S3_REGION}\r\n # S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}\r\n - S3_ENABLE_PATH_STYLE=1\r\n - S3_SET_ACL=1\r\n - S3_PREVIEW_URL_EXPIRE_IN=7200\r\n - NEXT_PUBLIC_S3_FILE_PATH=${S3_FILE_PATH:-由壹点AI/}\r\n # NextAuth配置从.env文件自动读取\r\n # NEXTAUTH_URL=${NEXTAUTH_URL}\r\n # NEXTAUTH_SECRET=${NEXTAUTH_SECRET}\r\n - GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E\r\n - GEMINI_API_BASE_URL=https://api.huandutech.com/v1\r\n - GEMINI_MODEL=gemini-2.0-flash-exp\r\n - DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f\r\n - DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY\r\n volumes:\r\n - ./uploads:/app/uploads\r\n\r\n" + "contentsAfterEdit": "version: '3.8'\r\n\r\nservices:\r\n app:\r\n build:\r\n context: .\r\n dockerfile: Dockerfile\r\n container_name: flnxi-material\r\n restart: unless-stopped\r\n ports:\r\n - \"4320:3000\"\r\n extra_hosts:\r\n - \"host.docker.internal:host-gateway\"\r\n dns:\r\n - 223.5.5.5 # 阿里云DNS\r\n - 8.8.8.8 # Google DNS\r\n - 114.114.114.114 # 114DNS\r\n env_file:\r\n - .env\r\n environment:\r\n - NODE_ENV=production\r\n # 数据库配置从.env文件自动读取\r\n # DATABASE_URL=${DATABASE_URL}\r\n # DATABASE_DRIVER=${DATABASE_DRIVER}\r\n # 站点公共URL(可选,优先用于生成邀请链接等)\r\n # APP_PUBLIC_URL=${APP_PUBLIC_URL}\r\n - KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=\r\n - VECTOR_DB=pgvector\r\n - PGVECTOR_DB_URL=${DATABASE_URL}\r\n - STORAGE_PROVIDER=s3\r\n # S3配置从.env文件自动读取\r\n # S3_ENDPOINT=${S3_ENDPOINT}\r\n # S3_BUCKET=${S3_BUCKET}\r\n # S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}\r\n # S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}\r\n # S3_REGION=${S3_REGION}\r\n # S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}\r\n - S3_ENABLE_PATH_STYLE=1\r\n - S3_SET_ACL=1\r\n - S3_PREVIEW_URL_EXPIRE_IN=7200\r\n - NEXT_PUBLIC_S3_FILE_PATH=${S3_FILE_PATH:-由壹点AI/}\r\n # NextAuth配置从.env文件自动读取\r\n # NEXTAUTH_URL=${NEXTAUTH_URL}\r\n # NEXTAUTH_SECRET=${NEXTAUTH_SECRET}\r\n - GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E\r\n - GEMINI_API_BASE_URL=https://api.huandutech.com/v1\r\n - GEMINI_MODEL=gemini-2.0-flash-exp\r\n - DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f\r\n - DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY\r\n volumes:\r\n - ./uploads:/app/uploads\r\n\r\n" } ``` User decision: **accepted** @@ -16151,7 +16151,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" + "contentsAfterEdit": "# FLNXI素材库\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" } ``` User decision: **accepted** @@ -16511,7 +16511,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM prisma/schema.prisma", - "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"由壹点3D素材\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" + "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"FLNXI素材库\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -17513,7 +17513,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/api/system-settings/route.ts", - "contentsAfterEdit": "import { NextRequest, NextResponse } from 'next/server'\r\nimport { getServerSession } from 'next-auth'\r\nimport { authOptions } from '@/lib/auth'\r\nimport { prisma } from '@/lib/prisma'\r\n\r\n// GET - 获取系统设置\r\nexport async function GET() {\r\n try {\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n \r\n if (!settings) {\r\n // 如果不存在,创建默认设置\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: '#1f2937',\r\n headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n siteName: '由壹点3D素材',\r\n enableUserActivation: false,\r\n enableRegistration: true\r\n }\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Get system settings error:', error)\r\n return NextResponse.json(\r\n { error: '获取系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n\r\n// PUT - 更新系统设置(仅管理员)\r\nexport async function PUT(request: NextRequest) {\r\n try {\r\n const session = await getServerSession(authOptions)\r\n\r\n const isSuperAdmin = (session?.user as any)?.isSuperAdmin\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return NextResponse.json(\r\n { error: '无权限' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n const body = await request.json()\r\n const { \r\n headerBgColor, \r\n siteName, \r\n headerLogoUrl, \r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n } = body\r\n\r\n // 只有最高权限账号可以修改 enableUserActivation\r\n if (enableUserActivation !== undefined && !isSuperAdmin) {\r\n return NextResponse.json(\r\n { error: '只有最高权限账号可以修改“用户激活功能”开关' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n\r\n if (!settings) {\r\n // 创建\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n siteName: siteName || '由壹点3D素材',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n enableRegistration: enableRegistration !== undefined ? enableRegistration : true\r\n }\r\n })\r\n } else {\r\n // 更新\r\n const updateData: any = {}\r\n if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor\r\n if (siteName !== undefined) updateData.siteName = siteName\r\n if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl\r\n if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl\r\n if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint\r\n if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey\r\n if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey\r\n if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket\r\n if (s3Region !== undefined) updateData.s3Region = s3Region\r\n if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl\r\n if (enableUserActivation !== undefined) updateData.enableUserActivation = enableUserActivation\r\n if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration\r\n \r\n settings = await prisma.systemSettings.update({\r\n where: { id: settings.id },\r\n data: updateData\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Update system settings error:', error)\r\n return NextResponse.json(\r\n { error: '更新系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n" + "contentsAfterEdit": "import { NextRequest, NextResponse } from 'next/server'\r\nimport { getServerSession } from 'next-auth'\r\nimport { authOptions } from '@/lib/auth'\r\nimport { prisma } from '@/lib/prisma'\r\n\r\n// GET - 获取系统设置\r\nexport async function GET() {\r\n try {\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n \r\n if (!settings) {\r\n // 如果不存在,创建默认设置\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: '#1f2937',\r\n headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n siteName: 'FLNXI素材库',\r\n enableUserActivation: false,\r\n enableRegistration: true\r\n }\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Get system settings error:', error)\r\n return NextResponse.json(\r\n { error: '获取系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n\r\n// PUT - 更新系统设置(仅管理员)\r\nexport async function PUT(request: NextRequest) {\r\n try {\r\n const session = await getServerSession(authOptions)\r\n\r\n const isSuperAdmin = (session?.user as any)?.isSuperAdmin\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return NextResponse.json(\r\n { error: '无权限' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n const body = await request.json()\r\n const { \r\n headerBgColor, \r\n siteName, \r\n headerLogoUrl, \r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n } = body\r\n\r\n // 只有最高权限账号可以修改 enableUserActivation\r\n if (enableUserActivation !== undefined && !isSuperAdmin) {\r\n return NextResponse.json(\r\n { error: '只有最高权限账号可以修改“用户激活功能”开关' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n\r\n if (!settings) {\r\n // 创建\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n siteName: siteName || 'FLNXI素材库',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n enableRegistration: enableRegistration !== undefined ? enableRegistration : true\r\n }\r\n })\r\n } else {\r\n // 更新\r\n const updateData: any = {}\r\n if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor\r\n if (siteName !== undefined) updateData.siteName = siteName\r\n if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl\r\n if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl\r\n if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint\r\n if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey\r\n if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey\r\n if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket\r\n if (s3Region !== undefined) updateData.s3Region = s3Region\r\n if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl\r\n if (enableUserActivation !== undefined) updateData.enableUserActivation = enableUserActivation\r\n if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration\r\n \r\n settings = await prisma.systemSettings.update({\r\n where: { id: settings.id },\r\n data: updateData\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Update system settings error:', error)\r\n return NextResponse.json(\r\n { error: '更新系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n" } ``` User decision: **accepted** @@ -17565,7 +17565,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/admin/settings/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState } from 'react'\r\nimport { useSession } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Header from '@/components/Header'\r\n\r\nexport default function SystemSettings() {\r\n const { data: session, status } = useSession()\r\n const router = useRouter()\r\n const [loading, setLoading] = useState(true)\r\n const [saving, setSaving] = useState(false)\r\n const [uploading, setUploading] = useState(false)\r\n\r\n // 设置状态\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState('')\r\n const [headerLogoPreview, setHeaderLogoPreview] = useState('')\r\n const [loginLogoUrl, setLoginLogoUrl] = useState('')\r\n const [loginLogoPreview, setLoginLogoPreview] = useState('')\r\n const [uploadingHeader, setUploadingHeader] = useState(false)\r\n const [uploadingLogin, setUploadingLogin] = useState(false)\r\n \r\n // S3配置状态\r\n const [s3Endpoint, setS3Endpoint] = useState('')\r\n const [s3AccessKey, setS3AccessKey] = useState('')\r\n const [s3SecretKey, setS3SecretKey] = useState('')\r\n const [s3Bucket, setS3Bucket] = useState('')\r\n const [s3Region, setS3Region] = useState('us-east-1')\r\n const [s3PublicUrl, setS3PublicUrl] = useState('')\r\n \r\n // 功能开关状态\r\n const [enableUserActivation, setEnableUserActivation] = useState(false)\r\n const [enableRegistration, setEnableRegistration] = useState(true)\r\n\r\n // 权限检查\r\n useEffect(() => {\r\n if (status === 'unauthenticated') {\r\n router.push('/login')\r\n } else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {\r\n router.push('/')\r\n }\r\n }, [status, session, router])\r\n\r\n // 加载设置\r\n useEffect(() => {\r\n if (status === 'authenticated' && session?.user?.role === 'ADMIN') {\r\n fetchSettings()\r\n }\r\n }, [status, session])\r\n\r\n const fetchSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderBgColor(data.headerBgColor || '#1f2937')\r\n setSiteName(data.siteName || '由壹点3D素材')\r\n setHeaderLogoUrl(data.headerLogoUrl || '')\r\n setHeaderLogoPreview(data.headerLogoUrl || '')\r\n setLoginLogoUrl(data.loginLogoUrl || '')\r\n setLoginLogoPreview(data.loginLogoUrl || '')\r\n setS3Endpoint(data.s3Endpoint || '')\r\n setS3AccessKey(data.s3AccessKey || '')\r\n setS3SecretKey(data.s3SecretKey || '')\r\n setS3Bucket(data.s3Bucket || '')\r\n setS3Region(data.s3Region || 'us-east-1')\r\n setS3PublicUrl(data.s3PublicUrl || '')\r\n setEnableUserActivation(data.enableUserActivation ?? false)\r\n setEnableRegistration(data.enableRegistration ?? true)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching settings:', error)\r\n alert('加载设置失败')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n\r\n const handleSave = async () => {\r\n setSaving(true)\r\n try {\r\n const response = await fetch('/api/system-settings', {\r\n method: 'PUT',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({\r\n headerBgColor,\r\n siteName,\r\n headerLogoUrl,\r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n }),\r\n })\r\n\r\n if (response.ok) {\r\n alert('保存成功!页面将自动刷新以应用新设置')\r\n // 刷新页面以应用新设置\r\n window.location.reload()\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || '保存失败')\r\n }\r\n } catch (error) {\r\n console.error('Save settings error:', error)\r\n alert('保存失败,请稍后重试')\r\n } finally {\r\n setSaving(false)\r\n }\r\n }\r\n\r\n const handleHeaderLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingHeader(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'header')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderLogoUrl(data.logoUrl)\r\n setHeaderLogoPreview(data.logoUrl)\r\n alert('导航栏LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingHeader(false)\r\n }\r\n }\r\n\r\n const handleLoginLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingLogin(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'login')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setLoginLogoUrl(data.logoUrl)\r\n setLoginLogoPreview(data.logoUrl)\r\n alert('登录页LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingLogin(false)\r\n }\r\n }\r\n\r\n const handleColorReset = () => {\r\n setHeaderBgColor('#1f2937')\r\n }\r\n\r\n const handleHeaderLogoReset = () => {\r\n setHeaderLogoPreview('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n setHeaderLogoUrl('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n }\r\n\r\n const handleLoginLogoReset = () => {\r\n setLoginLogoPreview('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n setLoginLogoUrl('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n }\r\n\r\n if (status === 'loading' || loading) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center dark:bg-gray-900\">\r\n <div className=\"text-xl text-gray-600 dark:text-gray-400\">加载中...</div>\r\n </div>\r\n )\r\n }\r\n\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return null\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen bg-gray-50 dark:bg-gray-900\">\r\n <Header session={session} />\r\n\r\n <main className=\"container mx-auto px-4 py-8 max-w-4xl\">\r\n <div className=\"mb-6\">\r\n <h1 className=\"text-3xl font-bold text-gray-900 dark:text-white mb-2\">系统设置</h1>\r\n <p className=\"text-gray-600 dark:text-gray-400\">自定义网站外观和品牌信息</p>\r\n </div>\r\n\r\n <div className=\"bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8\">\r\n {/* 网站名称设置 */}\r\n <div>\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 网站名称\r\n </h2>\r\n <div className=\"space-y-2\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\r\n 站点名称\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={siteName}\r\n onChange={(e) => setSiteName(e.target.value)}\r\n placeholder=\"由壹点3D素材\"\r\n 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\"\r\n />\r\n <p className=\"text-sm text-gray-500 dark:text-gray-400\">\r\n 该名称将显示在顶部导航栏和登录页面\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 导航栏LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 导航栏LOGO(深色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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 }}>\r\n {headerLogoPreview ? (\r\n <img src={headerLogoPreview} alt=\"导航栏LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于深色导航栏,建议使用浅色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingHeader ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传导航栏LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleHeaderLogoUpload}\r\n disabled={uploadingHeader}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleHeaderLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingHeader}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 登录页LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 登录页LOGO(浅色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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\">\r\n {loginLogoPreview ? (\r\n <img src={loginLogoPreview} alt=\"登录页LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于浅色登录页面,建议使用深色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingLogin ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传登录页LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleLoginLogoUpload}\r\n disabled={uploadingLogin}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleLoginLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingLogin}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 主题颜色设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 主题颜色\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* 颜色预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 颜色预览\r\n </label>\r\n <div\r\n className=\"w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner\"\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"flex items-center h-full px-4\">\r\n <span className=\"text-white font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>\r\n 顶部导航栏预览\r\n </span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 颜色选择器 */}\r\n <div className=\"flex items-center gap-4\">\r\n <div className=\"flex-1\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 选择颜色\r\n </label>\r\n <div className=\"flex items-center gap-2\">\r\n <input\r\n type=\"color\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n className=\"w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600\"\r\n />\r\n <input\r\n type=\"text\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n placeholder=\"#1f2937\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0\">\r\n 操作\r\n </label>\r\n <button\r\n onClick={handleColorReset}\r\n className=\"btn-secondary whitespace-nowrap\"\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* 预设颜色 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 快速选择\r\n </label>\r\n <div className=\"flex flex-wrap gap-2\">\r\n {[\r\n { name: '默认灰', color: '#1f2937' },\r\n { name: '深蓝', color: '#1e3a8a' },\r\n { name: '靛蓝', color: '#4f46e5' },\r\n { name: '紫色', color: '#7c3aed' },\r\n { name: '粉色', color: '#db2777' },\r\n { name: '红色', color: '#dc2626' },\r\n { name: '橙色', color: '#ea580c' },\r\n { name: '绿色', color: '#059669' },\r\n { name: '青色', color: '#0891b2' },\r\n { name: '深灰', color: '#374151' },\r\n ].map((preset) => (\r\n <button\r\n key={preset.color}\r\n onClick={() => setHeaderBgColor(preset.color)}\r\n className={`px-4 py-2 rounded-lg border-2 transition-all ${\r\n headerBgColor === preset.color\r\n ? 'border-blue-500 ring-2 ring-blue-200'\r\n : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'\r\n }`}\r\n style={{ backgroundColor: preset.color }}\r\n title={preset.color}\r\n >\r\n <span className=\"text-white text-sm font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>\r\n {preset.name}\r\n </span>\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 保存按钮 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center\">\r\n <button\r\n onClick={() => router.push('/admin')}\r\n className=\"btn-secondary\"\r\n >\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" />\r\n </svg>\r\n 返回\r\n </button>\r\n <button\r\n onClick={handleSave}\r\n disabled={saving}\r\n className=\"btn-primary text-lg px-8 py-3 disabled:opacity-50\"\r\n >\r\n {saving ? (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 保存中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\r\n </svg>\r\n 保存设置\r\n </>\r\n )}\r\n </button>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-medium mb-1\">温馨提示:</p>\r\n <ul className=\"list-disc list-inside space-y-1\">\r\n <li>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</li>\r\n <li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>\r\n <li>建议使用高质量的LOGO图片以获得最佳显示效果</li>\r\n <li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 功能开关 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n </svg>\r\n 功能开关\r\n </h2>\r\n <div className=\"space-y-6\">\r\n {/* 用户激活功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户激活功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后,新注册的用户需要管理员激活才能登录。默认关闭。\r\n </p>\r\n </div>\r\n <label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\r\n <input\r\n type=\"checkbox\"\r\n checked={enableUserActivation}\r\n onChange={(e) => setEnableUserActivation(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 仅最高权限账号可修改此开关\r\n </p>\r\n )}\r\n\r\n {/* 用户注册功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户手动注册功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 关闭后,用户将无法通过注册页面手动注册。默认开启。\r\n </p>\r\n </div>\r\n <label className=\"relative inline-flex items-center cursor-pointer\">\r\n <input\r\n type=\"checkbox\"\r\n checked={enableRegistration}\r\n onChange={(e) => setEnableRegistration(e.target.checked)}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* S3存储桶配置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n S3存储桶配置\r\n </h2>\r\n <div className=\"space-y-4\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 配置S3对象存储,用于存储上传的图片和文件\r\n </p>\r\n\r\n {/* S3端点 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n S3端点 (Endpoint)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Endpoint}\r\n onChange={(e) => setS3Endpoint(e.target.value)}\r\n placeholder=\"例如: s3.1080p.hk\"\r\n 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\"\r\n />\r\n </div>\r\n\r\n {/* Access Key & Secret Key */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Access Key (访问密钥)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3AccessKey}\r\n onChange={(e) => setS3AccessKey(e.target.value)}\r\n placeholder=\"输入Access Key\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Secret Key (密钥)\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={s3SecretKey}\r\n onChange={(e) => setS3SecretKey(e.target.value)}\r\n placeholder=\"输入Secret Key\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* Bucket & Region */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 存储桶名称 (Bucket)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Bucket}\r\n onChange={(e) => setS3Bucket(e.target.value)}\r\n placeholder=\"例如: my-bucket\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 区域 (Region)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Region}\r\n onChange={(e) => setS3Region(e.target.value)}\r\n placeholder=\"例如: us-east-1\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* 公网访问URL */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 公网访问URL (Public URL)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3PublicUrl}\r\n onChange={(e) => setS3PublicUrl(e.target.value)}\r\n placeholder=\"例如: https://cdn.example.com\"\r\n 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\"\r\n />\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\r\n 如果配置了CDN或自定义域名,请填写此项\r\n </p>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-semibold mb-2\">配置说明:</p>\r\n <ul className=\"space-y-1 list-disc list-inside\">\r\n <li>S3端点通常是您的对象存储服务器地址</li>\r\n <li>Access Key和Secret Key用于身份验证</li>\r\n <li>存储桶需要预先创建并设置公开读取权限</li>\r\n <li>修改配置后需要重启应用才能生效</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </main>\r\n </div>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState } from 'react'\r\nimport { useSession } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Header from '@/components/Header'\r\n\r\nexport default function SystemSettings() {\r\n const { data: session, status } = useSession()\r\n const router = useRouter()\r\n const [loading, setLoading] = useState(true)\r\n const [saving, setSaving] = useState(false)\r\n const [uploading, setUploading] = useState(false)\r\n\r\n // 设置状态\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState('')\r\n const [headerLogoPreview, setHeaderLogoPreview] = useState('')\r\n const [loginLogoUrl, setLoginLogoUrl] = useState('')\r\n const [loginLogoPreview, setLoginLogoPreview] = useState('')\r\n const [uploadingHeader, setUploadingHeader] = useState(false)\r\n const [uploadingLogin, setUploadingLogin] = useState(false)\r\n \r\n // S3配置状态\r\n const [s3Endpoint, setS3Endpoint] = useState('')\r\n const [s3AccessKey, setS3AccessKey] = useState('')\r\n const [s3SecretKey, setS3SecretKey] = useState('')\r\n const [s3Bucket, setS3Bucket] = useState('')\r\n const [s3Region, setS3Region] = useState('us-east-1')\r\n const [s3PublicUrl, setS3PublicUrl] = useState('')\r\n \r\n // 功能开关状态\r\n const [enableUserActivation, setEnableUserActivation] = useState(false)\r\n const [enableRegistration, setEnableRegistration] = useState(true)\r\n\r\n // 权限检查\r\n useEffect(() => {\r\n if (status === 'unauthenticated') {\r\n router.push('/login')\r\n } else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {\r\n router.push('/')\r\n }\r\n }, [status, session, router])\r\n\r\n // 加载设置\r\n useEffect(() => {\r\n if (status === 'authenticated' && session?.user?.role === 'ADMIN') {\r\n fetchSettings()\r\n }\r\n }, [status, session])\r\n\r\n const fetchSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderBgColor(data.headerBgColor || '#1f2937')\r\n setSiteName(data.siteName || 'FLNXI素材库')\r\n setHeaderLogoUrl(data.headerLogoUrl || '')\r\n setHeaderLogoPreview(data.headerLogoUrl || '')\r\n setLoginLogoUrl(data.loginLogoUrl || '')\r\n setLoginLogoPreview(data.loginLogoUrl || '')\r\n setS3Endpoint(data.s3Endpoint || '')\r\n setS3AccessKey(data.s3AccessKey || '')\r\n setS3SecretKey(data.s3SecretKey || '')\r\n setS3Bucket(data.s3Bucket || '')\r\n setS3Region(data.s3Region || 'us-east-1')\r\n setS3PublicUrl(data.s3PublicUrl || '')\r\n setEnableUserActivation(data.enableUserActivation ?? false)\r\n setEnableRegistration(data.enableRegistration ?? true)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching settings:', error)\r\n alert('加载设置失败')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n\r\n const handleSave = async () => {\r\n setSaving(true)\r\n try {\r\n const response = await fetch('/api/system-settings', {\r\n method: 'PUT',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({\r\n headerBgColor,\r\n siteName,\r\n headerLogoUrl,\r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n }),\r\n })\r\n\r\n if (response.ok) {\r\n alert('保存成功!页面将自动刷新以应用新设置')\r\n // 刷新页面以应用新设置\r\n window.location.reload()\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || '保存失败')\r\n }\r\n } catch (error) {\r\n console.error('Save settings error:', error)\r\n alert('保存失败,请稍后重试')\r\n } finally {\r\n setSaving(false)\r\n }\r\n }\r\n\r\n const handleHeaderLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingHeader(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'header')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderLogoUrl(data.logoUrl)\r\n setHeaderLogoPreview(data.logoUrl)\r\n alert('导航栏LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingHeader(false)\r\n }\r\n }\r\n\r\n const handleLoginLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingLogin(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'login')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setLoginLogoUrl(data.logoUrl)\r\n setLoginLogoPreview(data.logoUrl)\r\n alert('登录页LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingLogin(false)\r\n }\r\n }\r\n\r\n const handleColorReset = () => {\r\n setHeaderBgColor('#1f2937')\r\n }\r\n\r\n const handleHeaderLogoReset = () => {\r\n setHeaderLogoPreview('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n setHeaderLogoUrl('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n }\r\n\r\n const handleLoginLogoReset = () => {\r\n setLoginLogoPreview('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n setLoginLogoUrl('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n }\r\n\r\n if (status === 'loading' || loading) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center dark:bg-gray-900\">\r\n <div className=\"text-xl text-gray-600 dark:text-gray-400\">加载中...</div>\r\n </div>\r\n )\r\n }\r\n\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return null\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen bg-gray-50 dark:bg-gray-900\">\r\n <Header session={session} />\r\n\r\n <main className=\"container mx-auto px-4 py-8 max-w-4xl\">\r\n <div className=\"mb-6\">\r\n <h1 className=\"text-3xl font-bold text-gray-900 dark:text-white mb-2\">系统设置</h1>\r\n <p className=\"text-gray-600 dark:text-gray-400\">自定义网站外观和品牌信息</p>\r\n </div>\r\n\r\n <div className=\"bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8\">\r\n {/* 网站名称设置 */}\r\n <div>\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 网站名称\r\n </h2>\r\n <div className=\"space-y-2\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\r\n 站点名称\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={siteName}\r\n onChange={(e) => setSiteName(e.target.value)}\r\n placeholder=\"FLNXI素材库\"\r\n 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\"\r\n />\r\n <p className=\"text-sm text-gray-500 dark:text-gray-400\">\r\n 该名称将显示在顶部导航栏和登录页面\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 导航栏LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 导航栏LOGO(深色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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 }}>\r\n {headerLogoPreview ? (\r\n <img src={headerLogoPreview} alt=\"导航栏LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于深色导航栏,建议使用浅色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingHeader ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传导航栏LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleHeaderLogoUpload}\r\n disabled={uploadingHeader}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleHeaderLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingHeader}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 登录页LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 登录页LOGO(浅色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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\">\r\n {loginLogoPreview ? (\r\n <img src={loginLogoPreview} alt=\"登录页LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于浅色登录页面,建议使用深色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingLogin ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传登录页LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleLoginLogoUpload}\r\n disabled={uploadingLogin}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleLoginLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingLogin}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 主题颜色设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 主题颜色\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* 颜色预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 颜色预览\r\n </label>\r\n <div\r\n className=\"w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner\"\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"flex items-center h-full px-4\">\r\n <span className=\"text-white font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>\r\n 顶部导航栏预览\r\n </span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 颜色选择器 */}\r\n <div className=\"flex items-center gap-4\">\r\n <div className=\"flex-1\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 选择颜色\r\n </label>\r\n <div className=\"flex items-center gap-2\">\r\n <input\r\n type=\"color\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n className=\"w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600\"\r\n />\r\n <input\r\n type=\"text\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n placeholder=\"#1f2937\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0\">\r\n 操作\r\n </label>\r\n <button\r\n onClick={handleColorReset}\r\n className=\"btn-secondary whitespace-nowrap\"\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* 预设颜色 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 快速选择\r\n </label>\r\n <div className=\"flex flex-wrap gap-2\">\r\n {[\r\n { name: '默认灰', color: '#1f2937' },\r\n { name: '深蓝', color: '#1e3a8a' },\r\n { name: '靛蓝', color: '#4f46e5' },\r\n { name: '紫色', color: '#7c3aed' },\r\n { name: '粉色', color: '#db2777' },\r\n { name: '红色', color: '#dc2626' },\r\n { name: '橙色', color: '#ea580c' },\r\n { name: '绿色', color: '#059669' },\r\n { name: '青色', color: '#0891b2' },\r\n { name: '深灰', color: '#374151' },\r\n ].map((preset) => (\r\n <button\r\n key={preset.color}\r\n onClick={() => setHeaderBgColor(preset.color)}\r\n className={`px-4 py-2 rounded-lg border-2 transition-all ${\r\n headerBgColor === preset.color\r\n ? 'border-blue-500 ring-2 ring-blue-200'\r\n : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'\r\n }`}\r\n style={{ backgroundColor: preset.color }}\r\n title={preset.color}\r\n >\r\n <span className=\"text-white text-sm font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>\r\n {preset.name}\r\n </span>\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 保存按钮 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center\">\r\n <button\r\n onClick={() => router.push('/admin')}\r\n className=\"btn-secondary\"\r\n >\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" />\r\n </svg>\r\n 返回\r\n </button>\r\n <button\r\n onClick={handleSave}\r\n disabled={saving}\r\n className=\"btn-primary text-lg px-8 py-3 disabled:opacity-50\"\r\n >\r\n {saving ? (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 保存中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\r\n </svg>\r\n 保存设置\r\n </>\r\n )}\r\n </button>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-medium mb-1\">温馨提示:</p>\r\n <ul className=\"list-disc list-inside space-y-1\">\r\n <li>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</li>\r\n <li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>\r\n <li>建议使用高质量的LOGO图片以获得最佳显示效果</li>\r\n <li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 功能开关 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n </svg>\r\n 功能开关\r\n </h2>\r\n <div className=\"space-y-6\">\r\n {/* 用户激活功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户激活功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后,新注册的用户需要管理员激活才能登录。默认关闭。\r\n </p>\r\n </div>\r\n <label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\r\n <input\r\n type=\"checkbox\"\r\n checked={enableUserActivation}\r\n onChange={(e) => setEnableUserActivation(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 仅最高权限账号可修改此开关\r\n </p>\r\n )}\r\n\r\n {/* 用户注册功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户手动注册功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 关闭后,用户将无法通过注册页面手动注册。默认开启。\r\n </p>\r\n </div>\r\n <label className=\"relative inline-flex items-center cursor-pointer\">\r\n <input\r\n type=\"checkbox\"\r\n checked={enableRegistration}\r\n onChange={(e) => setEnableRegistration(e.target.checked)}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* S3存储桶配置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n S3存储桶配置\r\n </h2>\r\n <div className=\"space-y-4\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 配置S3对象存储,用于存储上传的图片和文件\r\n </p>\r\n\r\n {/* S3端点 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n S3端点 (Endpoint)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Endpoint}\r\n onChange={(e) => setS3Endpoint(e.target.value)}\r\n placeholder=\"例如: s3.1080p.hk\"\r\n 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\"\r\n />\r\n </div>\r\n\r\n {/* Access Key & Secret Key */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Access Key (访问密钥)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3AccessKey}\r\n onChange={(e) => setS3AccessKey(e.target.value)}\r\n placeholder=\"输入Access Key\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Secret Key (密钥)\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={s3SecretKey}\r\n onChange={(e) => setS3SecretKey(e.target.value)}\r\n placeholder=\"输入Secret Key\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* Bucket & Region */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 存储桶名称 (Bucket)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Bucket}\r\n onChange={(e) => setS3Bucket(e.target.value)}\r\n placeholder=\"例如: my-bucket\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 区域 (Region)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Region}\r\n onChange={(e) => setS3Region(e.target.value)}\r\n placeholder=\"例如: us-east-1\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* 公网访问URL */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 公网访问URL (Public URL)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3PublicUrl}\r\n onChange={(e) => setS3PublicUrl(e.target.value)}\r\n placeholder=\"例如: https://cdn.example.com\"\r\n 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\"\r\n />\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\r\n 如果配置了CDN或自定义域名,请填写此项\r\n </p>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-semibold mb-2\">配置说明:</p>\r\n <ul className=\"space-y-1 list-disc list-inside\">\r\n <li>S3端点通常是您的对象存储服务器地址</li>\r\n <li>Access Key和Secret Key用于身份验证</li>\r\n <li>存储桶需要预先创建并设置公开读取权限</li>\r\n <li>修改配置后需要重启应用才能生效</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </main>\r\n </div>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -18701,7 +18701,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" + "contentsAfterEdit": "# FLNXI素材库\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" } ``` User decision: **accepted** @@ -19687,7 +19687,7 @@ Result: "diff": { "chunks": [ { - "diffString": "- \n+ # 部署成功报告\n+ \n+ ## ✅ 部署状态\n+ \n+ **部署时间:** 2025-11-11 10:26 \n+ **服务器:** 45.249.208.118:4320 \n+ **容器名称:** youyidian-3d-material \n+ **容器状态:** ✅ 正在运行 \n+ **应用状态:** ✅ Next.js 已就绪\n+ \n+ ---\n+ \n+ ## 📋 本次更新内容\n+ \n+ ### 1. **断点续传功能** ✅\n+ - 上传过程中关闭页面后,重新打开会提示恢复上传\n+ - 上传状态自动保存到 `localStorage`\n+ - 支持恢复表单数据\n+ \n+ ### 2. **视频上传功能** ✅\n+ - 支持多种视频格式(.mp4, .avi, .mov, .wmv, .flv, .mkv, .webm)\n+ - 视频上传需要封面图(预览图)\n+ - 视频流式传输支持(Range 请求)\n+ - 新增视频播放 API:`/api/materials/stream`\n+ \n+ ### 3. **排行榜滚动间隔调整** ✅\n+ - 从 5 秒调整为 10 秒\n+ - 三个标签(贡献、下载、点赞)自动切换间隔更慢\n+ \n+ ### 4. **数据库 Schema 更新** ✅\n+ - 添加\"视频\"类型到 `materialType` 字段\n+ - 数据库迁移已执行\n+ \n+ ---\n+ \n+ ## 🐳 Docker 镜像说明\n+ \n+ ### 当前使用的镜像\n+ - **镜像名称:** `youyidian-3d-app:latest`\n+ - **创建时间:** 2025-11-08 17:35:29\n+ - **镜像大小:** 303 MB\n+ \n+ ### 关于镜像更新\n+ \n+ **重要说明:**\n+ - ✅ 本次部署使用了**现有的 Docker 镜像**\n+ - ❌ 无法从 Docker Hub 拉取新的基础镜像(网络限制)\n+ - ✅ 但是**应用代码已更新**(源代码已上传并部署)\n+ \n+ **为什么可以使用旧镜像?**\n+ 1. Docker 镜像只包含运行环境和依赖\n+ 2. 应用代码是通过 Volume 挂载或重新构建时复制的\n+ 3. 本次更新主要是代码变更,不涉及依赖更新\n+ 4. 旧镜像仍然可以运行新代码\n+ \n+ **何时需要更新镜像?**\n+ - 当 `package.json` 中的依赖发生变化时\n+ - 当 `Dockerfile` 中的系统依赖发生变化时\n+ - 当需要更新 Node.js 版本时\n+ \n+ ---\n+ \n+ ## 🌐 访问地址\n+ \n+ ### 内部访问\n+ ```\n+ http://localhost:4320\n+ ```\n+ \n+ ### 外部访问\n+ ```\n+ http://45.249.208.118:4320\n+ ```\n+ \n+ ---\n+ \n+ ## 🔍 部署详情\n+ \n+ ### 容器信息\n+ ```\n+ 容器名称: youyidian-3d-material\n+ 镜像: youyidian-3d-app:latest\n+ 端口映射: 0.0.0.0:4320->3000/tcp\n+ 状态: Up (运行中)\n+ ```\n+ \n+ ### 应用日志\n+ ```\n+ Next.js 14.2.33\n+ - Local: http://localhost:3000\n+ - Network: http://0.0.0.0:3000\n+ ✓ Starting...\n+ ✓ Ready in 249ms\n+ ```\n+ \n+ ### 磁盘使用\n+ ```\n+ 总容量: 50GB\n+ 已使用: 18GB (35%)\n+ 可用: 33GB\n+ ```\n+ \n+ ---\n+ \n+ ## ✅ 功能验证清单\n+ \n+ …(内容同原文件,保持不变)\n+ \n+ ", + "diffString": "- \n+ # 部署成功报告\n+ \n+ ## ✅ 部署状态\n+ \n+ **部署时间:** 2025-11-11 10:26 \n+ **服务器:** 45.249.208.118:4320 \n+ **容器名称:** flnxi-material \n+ **容器状态:** ✅ 正在运行 \n+ **应用状态:** ✅ Next.js 已就绪\n+ \n+ ---\n+ \n+ ## 📋 本次更新内容\n+ \n+ ### 1. **断点续传功能** ✅\n+ - 上传过程中关闭页面后,重新打开会提示恢复上传\n+ - 上传状态自动保存到 `localStorage`\n+ - 支持恢复表单数据\n+ \n+ ### 2. **视频上传功能** ✅\n+ - 支持多种视频格式(.mp4, .avi, .mov, .wmv, .flv, .mkv, .webm)\n+ - 视频上传需要封面图(预览图)\n+ - 视频流式传输支持(Range 请求)\n+ - 新增视频播放 API:`/api/materials/stream`\n+ \n+ ### 3. **排行榜滚动间隔调整** ✅\n+ - 从 5 秒调整为 10 秒\n+ - 三个标签(贡献、下载、点赞)自动切换间隔更慢\n+ \n+ ### 4. **数据库 Schema 更新** ✅\n+ - 添加\"视频\"类型到 `materialType` 字段\n+ - 数据库迁移已执行\n+ \n+ ---\n+ \n+ ## 🐳 Docker 镜像说明\n+ \n+ ### 当前使用的镜像\n+ - **镜像名称:** `youyidian-3d-app:latest`\n+ - **创建时间:** 2025-11-08 17:35:29\n+ - **镜像大小:** 303 MB\n+ \n+ ### 关于镜像更新\n+ \n+ **重要说明:**\n+ - ✅ 本次部署使用了**现有的 Docker 镜像**\n+ - ❌ 无法从 Docker Hub 拉取新的基础镜像(网络限制)\n+ - ✅ 但是**应用代码已更新**(源代码已上传并部署)\n+ \n+ **为什么可以使用旧镜像?**\n+ 1. Docker 镜像只包含运行环境和依赖\n+ 2. 应用代码是通过 Volume 挂载或重新构建时复制的\n+ 3. 本次更新主要是代码变更,不涉及依赖更新\n+ 4. 旧镜像仍然可以运行新代码\n+ \n+ **何时需要更新镜像?**\n+ - 当 `package.json` 中的依赖发生变化时\n+ - 当 `Dockerfile` 中的系统依赖发生变化时\n+ - 当需要更新 Node.js 版本时\n+ \n+ ---\n+ \n+ ## 🌐 访问地址\n+ \n+ ### 内部访问\n+ ```\n+ http://localhost:4320\n+ ```\n+ \n+ ### 外部访问\n+ ```\n+ http://45.249.208.118:4320\n+ ```\n+ \n+ ---\n+ \n+ ## 🔍 部署详情\n+ \n+ ### 容器信息\n+ ```\n+ 容器名称: flnxi-material\n+ 镜像: youyidian-3d-app:latest\n+ 端口映射: 0.0.0.0:4320->3000/tcp\n+ 状态: Up (运行中)\n+ ```\n+ \n+ ### 应用日志\n+ ```\n+ Next.js 14.2.33\n+ - Local: http://localhost:3000\n+ - Network: http://0.0.0.0:3000\n+ ✓ Starting...\n+ ✓ Ready in 249ms\n+ ```\n+ \n+ ### 磁盘使用\n+ ```\n+ 总容量: 50GB\n+ 已使用: 18GB (35%)\n+ 可用: 33GB\n+ ```\n+ \n+ ---\n+ \n+ ## ✅ 功能验证清单\n+ \n+ …(内容同原文件,保持不变)\n+ \n+ ", "oldStart": 1, "newStart": 1, "oldLines": 1, @@ -19700,7 +19700,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM docs/archive/部署成功报告.md", - "contentsAfterEdit": "# 部署成功报告\r\n\r\n## ✅ 部署状态\r\n\r\n**部署时间:** 2025-11-11 10:26 \r\n**服务器:** 45.249.208.118:4320 \r\n**容器名称:** youyidian-3d-material \r\n**容器状态:** ✅ 正在运行 \r\n**应用状态:** ✅ Next.js 已就绪\r\n\r\n---\r\n\r\n## 📋 本次更新内容\r\n\r\n### 1. **断点续传功能** ✅\r\n- 上传过程中关闭页面后,重新打开会提示恢复上传\r\n- 上传状态自动保存到 `localStorage`\r\n- 支持恢复表单数据\r\n\r\n### 2. **视频上传功能** ✅\r\n- 支持多种视频格式(.mp4, .avi, .mov, .wmv, .flv, .mkv, .webm)\r\n- 视频上传需要封面图(预览图)\r\n- 视频流式传输支持(Range 请求)\r\n- 新增视频播放 API:`/api/materials/stream`\r\n\r\n### 3. **排行榜滚动间隔调整** ✅\r\n- 从 5 秒调整为 10 秒\r\n- 三个标签(贡献、下载、点赞)自动切换间隔更慢\r\n\r\n### 4. **数据库 Schema 更新** ✅\r\n- 添加\"视频\"类型到 `materialType` 字段\r\n- 数据库迁移已执行\r\n\r\n---\r\n\r\n## 🐳 Docker 镜像说明\r\n\r\n### 当前使用的镜像\r\n- **镜像名称:** `youyidian-3d-app:latest`\r\n- **创建时间:** 2025-11-08 17:35:29\r\n- **镜像大小:** 303 MB\r\n\r\n### 关于镜像更新\r\n\r\n**重要说明:**\r\n- ✅ 本次部署使用了**现有的 Docker 镜像**\r\n- ❌ 无法从 Docker Hub 拉取新的基础镜像(网络限制)\r\n- ✅ 但是**应用代码已更新**(源代码已上传并部署)\r\n\r\n**为什么可以使用旧镜像?**\r\n1. Docker 镜像只包含运行环境和依赖\r\n2. 应用代码是通过 Volume 挂载或重新构建时复制的\r\n3. 本次更新主要是代码变更,不涉及依赖更新\r\n4. 旧镜像仍然可以运行新代码\r\n\r\n**何时需要更新镜像?**\r\n- 当 `package.json` 中的依赖发生变化时\r\n- 当 `Dockerfile` 中的系统依赖发生变化时\r\n- 当需要更新 Node.js 版本时\r\n\r\n---\r\n\r\n## 🌐 访问地址\r\n\r\n### 内部访问\r\n```\r\nhttp://localhost:4320\r\n```\r\n\r\n### 外部访问\r\n```\r\nhttp://45.249.208.118:4320\r\n```\r\n\r\n---\r\n\r\n## 🔍 部署详情\r\n\r\n### 容器信息\r\n```\r\n容器名称: youyidian-3d-material\r\n镜像: youyidian-3d-app:latest\r\n端口映射: 0.0.0.0:4320->3000/tcp\r\n状态: Up (运行中)\r\n```\r\n\r\n### 应用日志\r\n```\r\nNext.js 14.2.33\r\n- Local: http://localhost:3000\r\n- Network: http://0.0.0.0:3000\r\n✓ Starting...\r\n✓ Ready in 249ms\r\n```\r\n\r\n### 磁盘使用\r\n```\r\n总容量: 50GB\r\n已使用: 18GB (35%)\r\n可用: 33GB\r\n```\r\n\r\n---\r\n\r\n## ✅ 功能验证清单\r\n\r\n…(内容同原文件,保持不变)\r\n\r\n\r\n" + "contentsAfterEdit": "# 部署成功报告\r\n\r\n## ✅ 部署状态\r\n\r\n**部署时间:** 2025-11-11 10:26 \r\n**服务器:** 45.249.208.118:4320 \r\n**容器名称:** flnxi-material \r\n**容器状态:** ✅ 正在运行 \r\n**应用状态:** ✅ Next.js 已就绪\r\n\r\n---\r\n\r\n## 📋 本次更新内容\r\n\r\n### 1. **断点续传功能** ✅\r\n- 上传过程中关闭页面后,重新打开会提示恢复上传\r\n- 上传状态自动保存到 `localStorage`\r\n- 支持恢复表单数据\r\n\r\n### 2. **视频上传功能** ✅\r\n- 支持多种视频格式(.mp4, .avi, .mov, .wmv, .flv, .mkv, .webm)\r\n- 视频上传需要封面图(预览图)\r\n- 视频流式传输支持(Range 请求)\r\n- 新增视频播放 API:`/api/materials/stream`\r\n\r\n### 3. **排行榜滚动间隔调整** ✅\r\n- 从 5 秒调整为 10 秒\r\n- 三个标签(贡献、下载、点赞)自动切换间隔更慢\r\n\r\n### 4. **数据库 Schema 更新** ✅\r\n- 添加\"视频\"类型到 `materialType` 字段\r\n- 数据库迁移已执行\r\n\r\n---\r\n\r\n## 🐳 Docker 镜像说明\r\n\r\n### 当前使用的镜像\r\n- **镜像名称:** `youyidian-3d-app:latest`\r\n- **创建时间:** 2025-11-08 17:35:29\r\n- **镜像大小:** 303 MB\r\n\r\n### 关于镜像更新\r\n\r\n**重要说明:**\r\n- ✅ 本次部署使用了**现有的 Docker 镜像**\r\n- ❌ 无法从 Docker Hub 拉取新的基础镜像(网络限制)\r\n- ✅ 但是**应用代码已更新**(源代码已上传并部署)\r\n\r\n**为什么可以使用旧镜像?**\r\n1. Docker 镜像只包含运行环境和依赖\r\n2. 应用代码是通过 Volume 挂载或重新构建时复制的\r\n3. 本次更新主要是代码变更,不涉及依赖更新\r\n4. 旧镜像仍然可以运行新代码\r\n\r\n**何时需要更新镜像?**\r\n- 当 `package.json` 中的依赖发生变化时\r\n- 当 `Dockerfile` 中的系统依赖发生变化时\r\n- 当需要更新 Node.js 版本时\r\n\r\n---\r\n\r\n## 🌐 访问地址\r\n\r\n### 内部访问\r\n```\r\nhttp://localhost:4320\r\n```\r\n\r\n### 外部访问\r\n```\r\nhttp://45.249.208.118:4320\r\n```\r\n\r\n---\r\n\r\n## 🔍 部署详情\r\n\r\n### 容器信息\r\n```\r\n容器名称: flnxi-material\r\n镜像: youyidian-3d-app:latest\r\n端口映射: 0.0.0.0:4320->3000/tcp\r\n状态: Up (运行中)\r\n```\r\n\r\n### 应用日志\r\n```\r\nNext.js 14.2.33\r\n- Local: http://localhost:3000\r\n- Network: http://0.0.0.0:3000\r\n✓ Starting...\r\n✓ Ready in 249ms\r\n```\r\n\r\n### 磁盘使用\r\n```\r\n总容量: 50GB\r\n已使用: 18GB (35%)\r\n可用: 33GB\r\n```\r\n\r\n---\r\n\r\n## ✅ 功能验证清单\r\n\r\n…(内容同原文件,保持不变)\r\n\r\n\r\n" } ``` User decision: **accepted** @@ -20546,7 +20546,7 @@ Result: "linesAdded": 1 }, { - "diffString": " data: {\r\n headerBgColor: '#1f2937',\r\n- headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n- loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n+ headerLogoUrl: defaultLogos.header,\r\n+ loginLogoUrl: defaultLogos.login,\r\n siteName: '由壹点3D素材',\r\n enableUserActivation: false,\r", + "diffString": " data: {\r\n headerBgColor: '#1f2937',\r\n- headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n- loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n+ headerLogoUrl: defaultLogos.header,\r\n+ loginLogoUrl: defaultLogos.login,\r\n siteName: 'FLNXI素材库',\r\n enableUserActivation: false,\r", "oldStart": 15, "newStart": 16, "oldLines": 6, @@ -20555,7 +20555,7 @@ Result: "linesAdded": 2 }, { - "diffString": " data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n- headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n- loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n+ headerLogoUrl: headerLogoUrl ?? defaultLogos.header,\r\n+ loginLogoUrl: loginLogoUrl ?? defaultLogos.login,\r\n siteName: siteName || '由壹点3D素材',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r", + "diffString": " data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n- headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n- loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',\r\n+ headerLogoUrl: headerLogoUrl ?? defaultLogos.header,\r\n+ loginLogoUrl: loginLogoUrl ?? defaultLogos.login,\r\n siteName: siteName || 'FLNXI素材库',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r", "oldStart": 79, "newStart": 80, "oldLines": 6, @@ -20568,7 +20568,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/api/system-settings/route.ts", - "contentsAfterEdit": "import { NextRequest, NextResponse } from 'next/server'\r\nimport { getServerSession } from 'next-auth'\r\nimport { authOptions } from '@/lib/auth'\r\nimport { prisma } from '@/lib/prisma'\r\nimport { defaultLogos } from '@/lib/config'\r\n\r\n// GET - 获取系统设置\r\nexport async function GET() {\r\n try {\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n \r\n if (!settings) {\r\n // 如果不存在,创建默认设置\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: '#1f2937',\r\n headerLogoUrl: defaultLogos.header,\r\n loginLogoUrl: defaultLogos.login,\r\n siteName: '由壹点3D素材',\r\n enableUserActivation: false,\r\n enableRegistration: true\r\n }\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Get system settings error:', error)\r\n return NextResponse.json(\r\n { error: '获取系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n\r\n// PUT - 更新系统设置(仅管理员)\r\nexport async function PUT(request: NextRequest) {\r\n try {\r\n const session = await getServerSession(authOptions)\r\n\r\n const isSuperAdmin = (session?.user as any)?.isSuperAdmin\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return NextResponse.json(\r\n { error: '无权限' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n const body = await request.json()\r\n const { \r\n headerBgColor, \r\n siteName, \r\n headerLogoUrl, \r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n } = body\r\n\r\n // 只有最高权限账号可以修改 enableUserActivation\r\n if (enableUserActivation !== undefined && !isSuperAdmin) {\r\n return NextResponse.json(\r\n { error: '只有最高权限账号可以修改“用户激活功能”开关' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n\r\n if (!settings) {\r\n // 创建\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n headerLogoUrl: headerLogoUrl ?? defaultLogos.header,\r\n loginLogoUrl: loginLogoUrl ?? defaultLogos.login,\r\n siteName: siteName || '由壹点3D素材',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n enableRegistration: enableRegistration !== undefined ? enableRegistration : true\r\n }\r\n })\r\n } else {\r\n // 更新\r\n const updateData: any = {}\r\n if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor\r\n if (siteName !== undefined) updateData.siteName = siteName\r\n if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl\r\n if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl\r\n if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint\r\n if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey\r\n if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey\r\n if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket\r\n if (s3Region !== undefined) updateData.s3Region = s3Region\r\n if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl\r\n if (enableUserActivation !== undefined) updateData.enableUserActivation = enableUserActivation\r\n if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration\r\n \r\n settings = await prisma.systemSettings.update({\r\n where: { id: settings.id },\r\n data: updateData\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Update system settings error:', error)\r\n return NextResponse.json(\r\n { error: '更新系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n" + "contentsAfterEdit": "import { NextRequest, NextResponse } from 'next/server'\r\nimport { getServerSession } from 'next-auth'\r\nimport { authOptions } from '@/lib/auth'\r\nimport { prisma } from '@/lib/prisma'\r\nimport { defaultLogos } from '@/lib/config'\r\n\r\n// GET - 获取系统设置\r\nexport async function GET() {\r\n try {\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n \r\n if (!settings) {\r\n // 如果不存在,创建默认设置\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: '#1f2937',\r\n headerLogoUrl: defaultLogos.header,\r\n loginLogoUrl: defaultLogos.login,\r\n siteName: 'FLNXI素材库',\r\n enableUserActivation: false,\r\n enableRegistration: true\r\n }\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Get system settings error:', error)\r\n return NextResponse.json(\r\n { error: '获取系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n\r\n// PUT - 更新系统设置(仅管理员)\r\nexport async function PUT(request: NextRequest) {\r\n try {\r\n const session = await getServerSession(authOptions)\r\n\r\n const isSuperAdmin = (session?.user as any)?.isSuperAdmin\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return NextResponse.json(\r\n { error: '无权限' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n const body = await request.json()\r\n const { \r\n headerBgColor, \r\n siteName, \r\n headerLogoUrl, \r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n } = body\r\n\r\n // 只有最高权限账号可以修改 enableUserActivation\r\n if (enableUserActivation !== undefined && !isSuperAdmin) {\r\n return NextResponse.json(\r\n { error: '只有最高权限账号可以修改“用户激活功能”开关' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n\r\n if (!settings) {\r\n // 创建\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n headerLogoUrl: headerLogoUrl ?? defaultLogos.header,\r\n loginLogoUrl: loginLogoUrl ?? defaultLogos.login,\r\n siteName: siteName || 'FLNXI素材库',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n enableRegistration: enableRegistration !== undefined ? enableRegistration : true\r\n }\r\n })\r\n } else {\r\n // 更新\r\n const updateData: any = {}\r\n if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor\r\n if (siteName !== undefined) updateData.siteName = siteName\r\n if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl\r\n if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl\r\n if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint\r\n if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey\r\n if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey\r\n if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket\r\n if (s3Region !== undefined) updateData.s3Region = s3Region\r\n if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl\r\n if (enableUserActivation !== undefined) updateData.enableUserActivation = enableUserActivation\r\n if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration\r\n \r\n settings = await prisma.systemSettings.update({\r\n where: { id: settings.id },\r\n data: updateData\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Update system settings error:', error)\r\n return NextResponse.json(\r\n { error: '更新系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n" } ``` User decision: **accepted** @@ -20620,7 +20620,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/admin/settings/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState } from 'react'\r\nimport { useSession } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Header from '@/components/Header'\r\n\r\nexport default function SystemSettings() {\r\n const { data: session, status } = useSession()\r\n const router = useRouter()\r\n const [loading, setLoading] = useState(true)\r\n const [saving, setSaving] = useState(false)\r\n const [uploading, setUploading] = useState(false)\r\n\r\n // 设置状态\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState('')\r\n const [headerLogoPreview, setHeaderLogoPreview] = useState('')\r\n const [loginLogoUrl, setLoginLogoUrl] = useState('')\r\n const [loginLogoPreview, setLoginLogoPreview] = useState('')\r\n const [uploadingHeader, setUploadingHeader] = useState(false)\r\n const [uploadingLogin, setUploadingLogin] = useState(false)\r\n \r\n // S3配置状态\r\n const [s3Endpoint, setS3Endpoint] = useState('')\r\n const [s3AccessKey, setS3AccessKey] = useState('')\r\n const [s3SecretKey, setS3SecretKey] = useState('')\r\n const [s3Bucket, setS3Bucket] = useState('')\r\n const [s3Region, setS3Region] = useState('us-east-1')\r\n const [s3PublicUrl, setS3PublicUrl] = useState('')\r\n \r\n // 功能开关状态\r\n const [enableUserActivation, setEnableUserActivation] = useState(false)\r\n const [enableRegistration, setEnableRegistration] = useState(true)\r\n\r\n // 权限检查\r\n useEffect(() => {\r\n if (status === 'unauthenticated') {\r\n router.push('/login')\r\n } else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {\r\n router.push('/')\r\n }\r\n }, [status, session, router])\r\n\r\n // 加载设置\r\n useEffect(() => {\r\n if (status === 'authenticated' && session?.user?.role === 'ADMIN') {\r\n fetchSettings()\r\n }\r\n }, [status, session])\r\n\r\n const fetchSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderBgColor(data.headerBgColor || '#1f2937')\r\n setSiteName(data.siteName || '由壹点3D素材')\r\n setHeaderLogoUrl(data.headerLogoUrl || '')\r\n setHeaderLogoPreview(data.headerLogoUrl || '')\r\n setLoginLogoUrl(data.loginLogoUrl || '')\r\n setLoginLogoPreview(data.loginLogoUrl || '')\r\n setS3Endpoint(data.s3Endpoint || '')\r\n setS3AccessKey(data.s3AccessKey || '')\r\n setS3SecretKey(data.s3SecretKey || '')\r\n setS3Bucket(data.s3Bucket || '')\r\n setS3Region(data.s3Region || 'us-east-1')\r\n setS3PublicUrl(data.s3PublicUrl || '')\r\n setEnableUserActivation(data.enableUserActivation ?? false)\r\n setEnableRegistration(data.enableRegistration ?? true)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching settings:', error)\r\n alert('加载设置失败')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n\r\n const handleSave = async () => {\r\n setSaving(true)\r\n try {\r\n const response = await fetch('/api/system-settings', {\r\n method: 'PUT',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({\r\n headerBgColor,\r\n siteName,\r\n headerLogoUrl,\r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n }),\r\n })\r\n\r\n if (response.ok) {\r\n alert('保存成功!页面将自动刷新以应用新设置')\r\n // 刷新页面以应用新设置\r\n window.location.reload()\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || '保存失败')\r\n }\r\n } catch (error) {\r\n console.error('Save settings error:', error)\r\n alert('保存失败,请稍后重试')\r\n } finally {\r\n setSaving(false)\r\n }\r\n }\r\n\r\n const handleHeaderLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingHeader(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'header')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderLogoUrl(data.logoUrl)\r\n setHeaderLogoPreview(data.logoUrl)\r\n alert('导航栏LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingHeader(false)\r\n }\r\n }\r\n\r\n const handleLoginLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingLogin(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'login')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setLoginLogoUrl(data.logoUrl)\r\n setLoginLogoPreview(data.logoUrl)\r\n alert('登录页LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingLogin(false)\r\n }\r\n }\r\n\r\n const handleColorReset = () => {\r\n setHeaderBgColor('#1f2937')\r\n }\r\n\r\n const handleHeaderLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''\r\n setHeaderLogoPreview(def)\r\n setHeaderLogoUrl(def)\r\n }\r\n\r\n const handleLoginLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n setLoginLogoPreview(def)\r\n setLoginLogoUrl(def)\r\n }\r\n\r\n if (status === 'loading' || loading) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center dark:bg-gray-900\">\r\n <div className=\"text-xl text-gray-600 dark:text-gray-400\">加载中...</div>\r\n </div>\r\n )\r\n }\r\n\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return null\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen bg-gray-50 dark:bg-gray-900\">\r\n <Header session={session} />\r\n\r\n <main className=\"container mx-auto px-4 py-8 max-w-4xl\">\r\n <div className=\"mb-6\">\r\n <h1 className=\"text-3xl font-bold text-gray-900 dark:text-white mb-2\">系统设置</h1>\r\n <p className=\"text-gray-600 dark:text-gray-400\">自定义网站外观和品牌信息</p>\r\n </div>\r\n\r\n <div className=\"bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8\">\r\n {/* 网站名称设置 */}\r\n <div>\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 网站名称\r\n </h2>\r\n <div className=\"space-y-2\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\r\n 站点名称\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={siteName}\r\n onChange={(e) => setSiteName(e.target.value)}\r\n placeholder=\"由壹点3D素材\"\r\n 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\"\r\n />\r\n <p className=\"text-sm text-gray-500 dark:text-gray-400\">\r\n 该名称将显示在顶部导航栏和登录页面\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 导航栏LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 导航栏LOGO(深色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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 }}>\r\n {headerLogoPreview ? (\r\n <img src={headerLogoPreview} alt=\"导航栏LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于深色导航栏,建议使用浅色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingHeader ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传导航栏LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleHeaderLogoUpload}\r\n disabled={uploadingHeader}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleHeaderLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingHeader}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 登录页LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 登录页LOGO(浅色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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\">\r\n {loginLogoPreview ? (\r\n <img src={loginLogoPreview} alt=\"登录页LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于浅色登录页面,建议使用深色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingLogin ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传登录页LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleLoginLogoUpload}\r\n disabled={uploadingLogin}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleLoginLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingLogin}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 主题颜色设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 主题颜色\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* 颜色预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 颜色预览\r\n </label>\r\n <div\r\n className=\"w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner\"\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"flex items-center h-full px-4\">\r\n <span className=\"text-white font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>\r\n 顶部导航栏预览\r\n </span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 颜色选择器 */}\r\n <div className=\"flex items-center gap-4\">\r\n <div className=\"flex-1\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 选择颜色\r\n </label>\r\n <div className=\"flex items-center gap-2\">\r\n <input\r\n type=\"color\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n className=\"w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600\"\r\n />\r\n <input\r\n type=\"text\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n placeholder=\"#1f2937\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0\">\r\n 操作\r\n </label>\r\n <button\r\n onClick={handleColorReset}\r\n className=\"btn-secondary whitespace-nowrap\"\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* 预设颜色 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 快速选择\r\n </label>\r\n <div className=\"flex flex-wrap gap-2\">\r\n {[\r\n { name: '默认灰', color: '#1f2937' },\r\n { name: '深蓝', color: '#1e3a8a' },\r\n { name: '靛蓝', color: '#4f46e5' },\r\n { name: '紫色', color: '#7c3aed' },\r\n { name: '粉色', color: '#db2777' },\r\n { name: '红色', color: '#dc2626' },\r\n { name: '橙色', color: '#ea580c' },\r\n { name: '绿色', color: '#059669' },\r\n { name: '青色', color: '#0891b2' },\r\n { name: '深灰', color: '#374151' },\r\n ].map((preset) => (\r\n <button\r\n key={preset.color}\r\n onClick={() => setHeaderBgColor(preset.color)}\r\n className={`px-4 py-2 rounded-lg border-2 transition-all ${\r\n headerBgColor === preset.color\r\n ? 'border-blue-500 ring-2 ring-blue-200'\r\n : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'\r\n }`}\r\n style={{ backgroundColor: preset.color }}\r\n title={preset.color}\r\n >\r\n <span className=\"text-white text-sm font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>\r\n {preset.name}\r\n </span>\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 保存按钮 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center\">\r\n <button\r\n onClick={() => router.push('/admin')}\r\n className=\"btn-secondary\"\r\n >\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" />\r\n </svg>\r\n 返回\r\n </button>\r\n <button\r\n onClick={handleSave}\r\n disabled={saving}\r\n className=\"btn-primary text-lg px-8 py-3 disabled:opacity-50\"\r\n >\r\n {saving ? (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 保存中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\r\n </svg>\r\n 保存设置\r\n </>\r\n )}\r\n </button>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-medium mb-1\">温馨提示:</p>\r\n <ul className=\"list-disc list-inside space-y-1\">\r\n <li>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</li>\r\n <li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>\r\n <li>建议使用高质量的LOGO图片以获得最佳显示效果</li>\r\n <li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 功能开关 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n </svg>\r\n 功能开关\r\n </h2>\r\n <div className=\"space-y-6\">\r\n {/* 用户激活功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户激活功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后,新注册的用户需要管理员激活才能登录。默认关闭。\r\n </p>\r\n </div>\r\n <label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\r\n <input\r\n type=\"checkbox\"\r\n checked={enableUserActivation}\r\n onChange={(e) => setEnableUserActivation(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 仅最高权限账号可修改此开关\r\n </p>\r\n )}\r\n\r\n {/* 用户注册功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户手动注册功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 关闭后,用户将无法通过注册页面手动注册。默认开启。\r\n </p>\r\n </div>\r\n <label className=\"relative inline-flex items-center cursor-pointer\">\r\n <input\r\n type=\"checkbox\"\r\n checked={enableRegistration}\r\n onChange={(e) => setEnableRegistration(e.target.checked)}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* S3存储桶配置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n S3存储桶配置\r\n </h2>\r\n <div className=\"space-y-4\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 配置S3对象存储,用于存储上传的图片和文件\r\n </p>\r\n\r\n {/* S3端点 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n S3端点 (Endpoint)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Endpoint}\r\n onChange={(e) => setS3Endpoint(e.target.value)}\r\n placeholder=\"例如: s3.1080p.hk\"\r\n 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\"\r\n />\r\n </div>\r\n\r\n {/* Access Key & Secret Key */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Access Key (访问密钥)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3AccessKey}\r\n onChange={(e) => setS3AccessKey(e.target.value)}\r\n placeholder=\"输入Access Key\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Secret Key (密钥)\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={s3SecretKey}\r\n onChange={(e) => setS3SecretKey(e.target.value)}\r\n placeholder=\"输入Secret Key\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* Bucket & Region */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 存储桶名称 (Bucket)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Bucket}\r\n onChange={(e) => setS3Bucket(e.target.value)}\r\n placeholder=\"例如: my-bucket\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 区域 (Region)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Region}\r\n onChange={(e) => setS3Region(e.target.value)}\r\n placeholder=\"例如: us-east-1\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* 公网访问URL */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 公网访问URL (Public URL)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3PublicUrl}\r\n onChange={(e) => setS3PublicUrl(e.target.value)}\r\n placeholder=\"例如: https://cdn.example.com\"\r\n 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\"\r\n />\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\r\n 如果配置了CDN或自定义域名,请填写此项\r\n </p>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-semibold mb-2\">配置说明:</p>\r\n <ul className=\"space-y-1 list-disc list-inside\">\r\n <li>S3端点通常是您的对象存储服务器地址</li>\r\n <li>Access Key和Secret Key用于身份验证</li>\r\n <li>存储桶需要预先创建并设置公开读取权限</li>\r\n <li>修改配置后需要重启应用才能生效</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </main>\r\n </div>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState } from 'react'\r\nimport { useSession } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Header from '@/components/Header'\r\n\r\nexport default function SystemSettings() {\r\n const { data: session, status } = useSession()\r\n const router = useRouter()\r\n const [loading, setLoading] = useState(true)\r\n const [saving, setSaving] = useState(false)\r\n const [uploading, setUploading] = useState(false)\r\n\r\n // 设置状态\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState('')\r\n const [headerLogoPreview, setHeaderLogoPreview] = useState('')\r\n const [loginLogoUrl, setLoginLogoUrl] = useState('')\r\n const [loginLogoPreview, setLoginLogoPreview] = useState('')\r\n const [uploadingHeader, setUploadingHeader] = useState(false)\r\n const [uploadingLogin, setUploadingLogin] = useState(false)\r\n \r\n // S3配置状态\r\n const [s3Endpoint, setS3Endpoint] = useState('')\r\n const [s3AccessKey, setS3AccessKey] = useState('')\r\n const [s3SecretKey, setS3SecretKey] = useState('')\r\n const [s3Bucket, setS3Bucket] = useState('')\r\n const [s3Region, setS3Region] = useState('us-east-1')\r\n const [s3PublicUrl, setS3PublicUrl] = useState('')\r\n \r\n // 功能开关状态\r\n const [enableUserActivation, setEnableUserActivation] = useState(false)\r\n const [enableRegistration, setEnableRegistration] = useState(true)\r\n\r\n // 权限检查\r\n useEffect(() => {\r\n if (status === 'unauthenticated') {\r\n router.push('/login')\r\n } else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {\r\n router.push('/')\r\n }\r\n }, [status, session, router])\r\n\r\n // 加载设置\r\n useEffect(() => {\r\n if (status === 'authenticated' && session?.user?.role === 'ADMIN') {\r\n fetchSettings()\r\n }\r\n }, [status, session])\r\n\r\n const fetchSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderBgColor(data.headerBgColor || '#1f2937')\r\n setSiteName(data.siteName || 'FLNXI素材库')\r\n setHeaderLogoUrl(data.headerLogoUrl || '')\r\n setHeaderLogoPreview(data.headerLogoUrl || '')\r\n setLoginLogoUrl(data.loginLogoUrl || '')\r\n setLoginLogoPreview(data.loginLogoUrl || '')\r\n setS3Endpoint(data.s3Endpoint || '')\r\n setS3AccessKey(data.s3AccessKey || '')\r\n setS3SecretKey(data.s3SecretKey || '')\r\n setS3Bucket(data.s3Bucket || '')\r\n setS3Region(data.s3Region || 'us-east-1')\r\n setS3PublicUrl(data.s3PublicUrl || '')\r\n setEnableUserActivation(data.enableUserActivation ?? false)\r\n setEnableRegistration(data.enableRegistration ?? true)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching settings:', error)\r\n alert('加载设置失败')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n\r\n const handleSave = async () => {\r\n setSaving(true)\r\n try {\r\n const response = await fetch('/api/system-settings', {\r\n method: 'PUT',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({\r\n headerBgColor,\r\n siteName,\r\n headerLogoUrl,\r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration\r\n }),\r\n })\r\n\r\n if (response.ok) {\r\n alert('保存成功!页面将自动刷新以应用新设置')\r\n // 刷新页面以应用新设置\r\n window.location.reload()\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || '保存失败')\r\n }\r\n } catch (error) {\r\n console.error('Save settings error:', error)\r\n alert('保存失败,请稍后重试')\r\n } finally {\r\n setSaving(false)\r\n }\r\n }\r\n\r\n const handleHeaderLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingHeader(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'header')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderLogoUrl(data.logoUrl)\r\n setHeaderLogoPreview(data.logoUrl)\r\n alert('导航栏LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingHeader(false)\r\n }\r\n }\r\n\r\n const handleLoginLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingLogin(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'login')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setLoginLogoUrl(data.logoUrl)\r\n setLoginLogoPreview(data.logoUrl)\r\n alert('登录页LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingLogin(false)\r\n }\r\n }\r\n\r\n const handleColorReset = () => {\r\n setHeaderBgColor('#1f2937')\r\n }\r\n\r\n const handleHeaderLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''\r\n setHeaderLogoPreview(def)\r\n setHeaderLogoUrl(def)\r\n }\r\n\r\n const handleLoginLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n setLoginLogoPreview(def)\r\n setLoginLogoUrl(def)\r\n }\r\n\r\n if (status === 'loading' || loading) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center dark:bg-gray-900\">\r\n <div className=\"text-xl text-gray-600 dark:text-gray-400\">加载中...</div>\r\n </div>\r\n )\r\n }\r\n\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return null\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen bg-gray-50 dark:bg-gray-900\">\r\n <Header session={session} />\r\n\r\n <main className=\"container mx-auto px-4 py-8 max-w-4xl\">\r\n <div className=\"mb-6\">\r\n <h1 className=\"text-3xl font-bold text-gray-900 dark:text-white mb-2\">系统设置</h1>\r\n <p className=\"text-gray-600 dark:text-gray-400\">自定义网站外观和品牌信息</p>\r\n </div>\r\n\r\n <div className=\"bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8\">\r\n {/* 网站名称设置 */}\r\n <div>\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 网站名称\r\n </h2>\r\n <div className=\"space-y-2\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\r\n 站点名称\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={siteName}\r\n onChange={(e) => setSiteName(e.target.value)}\r\n placeholder=\"FLNXI素材库\"\r\n 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\"\r\n />\r\n <p className=\"text-sm text-gray-500 dark:text-gray-400\">\r\n 该名称将显示在顶部导航栏和登录页面\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 导航栏LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 导航栏LOGO(深色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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 }}>\r\n {headerLogoPreview ? (\r\n <img src={headerLogoPreview} alt=\"导航栏LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于深色导航栏,建议使用浅色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingHeader ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传导航栏LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleHeaderLogoUpload}\r\n disabled={uploadingHeader}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleHeaderLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingHeader}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 登录页LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 登录页LOGO(浅色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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\">\r\n {loginLogoPreview ? (\r\n <img src={loginLogoPreview} alt=\"登录页LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于浅色登录页面,建议使用深色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingLogin ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传登录页LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleLoginLogoUpload}\r\n disabled={uploadingLogin}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleLoginLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingLogin}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 主题颜色设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 主题颜色\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* 颜色预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 颜色预览\r\n </label>\r\n <div\r\n className=\"w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner\"\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"flex items-center h-full px-4\">\r\n <span className=\"text-white font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>\r\n 顶部导航栏预览\r\n </span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 颜色选择器 */}\r\n <div className=\"flex items-center gap-4\">\r\n <div className=\"flex-1\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 选择颜色\r\n </label>\r\n <div className=\"flex items-center gap-2\">\r\n <input\r\n type=\"color\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n className=\"w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600\"\r\n />\r\n <input\r\n type=\"text\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n placeholder=\"#1f2937\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0\">\r\n 操作\r\n </label>\r\n <button\r\n onClick={handleColorReset}\r\n className=\"btn-secondary whitespace-nowrap\"\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* 预设颜色 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 快速选择\r\n </label>\r\n <div className=\"flex flex-wrap gap-2\">\r\n {[\r\n { name: '默认灰', color: '#1f2937' },\r\n { name: '深蓝', color: '#1e3a8a' },\r\n { name: '靛蓝', color: '#4f46e5' },\r\n { name: '紫色', color: '#7c3aed' },\r\n { name: '粉色', color: '#db2777' },\r\n { name: '红色', color: '#dc2626' },\r\n { name: '橙色', color: '#ea580c' },\r\n { name: '绿色', color: '#059669' },\r\n { name: '青色', color: '#0891b2' },\r\n { name: '深灰', color: '#374151' },\r\n ].map((preset) => (\r\n <button\r\n key={preset.color}\r\n onClick={() => setHeaderBgColor(preset.color)}\r\n className={`px-4 py-2 rounded-lg border-2 transition-all ${\r\n headerBgColor === preset.color\r\n ? 'border-blue-500 ring-2 ring-blue-200'\r\n : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'\r\n }`}\r\n style={{ backgroundColor: preset.color }}\r\n title={preset.color}\r\n >\r\n <span className=\"text-white text-sm font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>\r\n {preset.name}\r\n </span>\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 保存按钮 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center\">\r\n <button\r\n onClick={() => router.push('/admin')}\r\n className=\"btn-secondary\"\r\n >\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" />\r\n </svg>\r\n 返回\r\n </button>\r\n <button\r\n onClick={handleSave}\r\n disabled={saving}\r\n className=\"btn-primary text-lg px-8 py-3 disabled:opacity-50\"\r\n >\r\n {saving ? (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 保存中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\r\n </svg>\r\n 保存设置\r\n </>\r\n )}\r\n </button>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-medium mb-1\">温馨提示:</p>\r\n <ul className=\"list-disc list-inside space-y-1\">\r\n <li>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</li>\r\n <li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>\r\n <li>建议使用高质量的LOGO图片以获得最佳显示效果</li>\r\n <li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 功能开关 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n </svg>\r\n 功能开关\r\n </h2>\r\n <div className=\"space-y-6\">\r\n {/* 用户激活功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户激活功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后,新注册的用户需要管理员激活才能登录。默认关闭。\r\n </p>\r\n </div>\r\n <label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\r\n <input\r\n type=\"checkbox\"\r\n checked={enableUserActivation}\r\n onChange={(e) => setEnableUserActivation(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 仅最高权限账号可修改此开关\r\n </p>\r\n )}\r\n\r\n {/* 用户注册功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户手动注册功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 关闭后,用户将无法通过注册页面手动注册。默认开启。\r\n </p>\r\n </div>\r\n <label className=\"relative inline-flex items-center cursor-pointer\">\r\n <input\r\n type=\"checkbox\"\r\n checked={enableRegistration}\r\n onChange={(e) => setEnableRegistration(e.target.checked)}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* S3存储桶配置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n S3存储桶配置\r\n </h2>\r\n <div className=\"space-y-4\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 配置S3对象存储,用于存储上传的图片和文件\r\n </p>\r\n\r\n {/* S3端点 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n S3端点 (Endpoint)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Endpoint}\r\n onChange={(e) => setS3Endpoint(e.target.value)}\r\n placeholder=\"例如: s3.1080p.hk\"\r\n 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\"\r\n />\r\n </div>\r\n\r\n {/* Access Key & Secret Key */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Access Key (访问密钥)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3AccessKey}\r\n onChange={(e) => setS3AccessKey(e.target.value)}\r\n placeholder=\"输入Access Key\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Secret Key (密钥)\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={s3SecretKey}\r\n onChange={(e) => setS3SecretKey(e.target.value)}\r\n placeholder=\"输入Secret Key\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* Bucket & Region */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 存储桶名称 (Bucket)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Bucket}\r\n onChange={(e) => setS3Bucket(e.target.value)}\r\n placeholder=\"例如: my-bucket\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 区域 (Region)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Region}\r\n onChange={(e) => setS3Region(e.target.value)}\r\n placeholder=\"例如: us-east-1\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* 公网访问URL */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 公网访问URL (Public URL)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3PublicUrl}\r\n onChange={(e) => setS3PublicUrl(e.target.value)}\r\n placeholder=\"例如: https://cdn.example.com\"\r\n 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\"\r\n />\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\r\n 如果配置了CDN或自定义域名,请填写此项\r\n </p>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-semibold mb-2\">配置说明:</p>\r\n <ul className=\"space-y-1 list-disc list-inside\">\r\n <li>S3端点通常是您的对象存储服务器地址</li>\r\n <li>Access Key和Secret Key用于身份验证</li>\r\n <li>存储桶需要预先创建并设置公开读取权限</li>\r\n <li>修改配置后需要重启应用才能生效</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </main>\r\n </div>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -20944,7 +20944,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" + "contentsAfterEdit": "# FLNXI素材库\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" } ``` User decision: **accepted** @@ -21124,7 +21124,7 @@ Result: "diff": { "chunks": [ { - "diffString": " // 系统设置\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n- const [headerLogoUrl, setHeaderLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n \r", + "diffString": " // 系统设置\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n- const [headerLogoUrl, setHeaderLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n \r", "oldStart": 23, "newStart": 23, "oldLines": 5, @@ -21133,7 +21133,7 @@ Result: "linesAdded": 1 }, { - "diffString": " const settings = await response.json()\r\n setHeaderBgColor(settings.headerBgColor || '#1f2937')\r\n- setHeaderLogoUrl(settings.headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ setHeaderLogoUrl(settings.headerLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n setSiteName(settings.siteName || '由壹点3D素材')\r\n }\r", + "diffString": " const settings = await response.json()\r\n setHeaderBgColor(settings.headerBgColor || '#1f2937')\r\n- setHeaderLogoUrl(settings.headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ setHeaderLogoUrl(settings.headerLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n setSiteName(settings.siteName || 'FLNXI素材库')\r\n }\r", "oldStart": 34, "newStart": 34, "oldLines": 5, @@ -21164,7 +21164,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM components/Header.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\nimport { signOut } from 'next-auth/react'\r\nimport { Session } from 'next-auth'\r\nimport { useState, useRef, useEffect } from 'react'\r\nimport { initializeNetwork } from '@/lib/network'\r\n\r\ninterface HeaderProps {\r\n session: Session | null\r\n}\r\n\r\nexport default function Header({ session }: HeaderProps) {\r\n const isAdmin = session ? (session.user as any)?.role === 'ADMIN' : false\r\n const [showDropdown, setShowDropdown] = useState(false)\r\n const [isInternalNetwork, setIsInternalNetwork] = useState(false)\r\n const [networkChecked, setNetworkChecked] = useState(false)\r\n const [showNetworkBanner, setShowNetworkBanner] = useState(false)\r\n const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)\r\n const dropdownRef = useRef<HTMLDivElement>(null)\r\n \r\n // 系统设置\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n\r\n // 加载系统设置\r\n useEffect(() => {\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setHeaderBgColor(settings.headerBgColor || '#1f2937')\r\n setHeaderLogoUrl(settings.headerLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n setSiteName(settings.siteName || '由壹点3D素材')\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n loadSettings()\r\n }, [])\r\n\r\n // 更新浏览器标题\r\n useEffect(() => {\r\n if (siteName) {\r\n document.title = siteName\r\n }\r\n }, [siteName])\r\n\r\n // 检测网络状态\r\n useEffect(() => {\r\n const detectNetwork = async () => {\r\n try {\r\n const networkStatus = await initializeNetwork()\r\n setIsInternalNetwork(networkStatus.isInternal)\r\n setNetworkChecked(true)\r\n\r\n // 检查当前是否从外网访问\r\n const currentHost = window.location.hostname\r\n const intranet = (process.env.NEXT_PUBLIC_INTRANET_URL || '').replace(/^https?:\\/\\//, '').split('/')[0]\r\n const isExternal = currentHost !== intranet && currentHost !== 'localhost'\r\n setIsAccessingFromExternal(isExternal)\r\n\r\n // 如果内网可达但当前从外网访问,显示切换提示(除非用户已关闭)\r\n const bannerDismissed = sessionStorage.getItem('networkBannerDismissed')\r\n if (networkStatus.isInternal && isExternal && !bannerDismissed) {\r\n setShowNetworkBanner(true)\r\n }\r\n } catch (error) {\r\n console.error('网络检测失败:', error)\r\n setIsInternalNetwork(false)\r\n setNetworkChecked(true)\r\n }\r\n }\r\n detectNetwork()\r\n }, [])\r\n\r\n // 切换到内网地址\r\n const switchToInternal = () => {\r\n const currentPath = window.location.pathname + window.location.search\r\n const base = process.env.NEXT_PUBLIC_INTRANET_URL\r\n if (base) {\r\n window.location.href = `${base}${currentPath}`\r\n }\r\n }\r\n\r\n // 关闭横幅\r\n const dismissBanner = () => {\r\n setShowNetworkBanner(false)\r\n sessionStorage.setItem('networkBannerDismissed', 'true')\r\n }\r\n\r\n // 点击外部关闭下拉菜单\r\n useEffect(() => {\r\n const handleClickOutside = (event: MouseEvent) => {\r\n if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\r\n setShowDropdown(false)\r\n }\r\n }\r\n\r\n document.addEventListener('mousedown', handleClickOutside)\r\n return () => document.removeEventListener('mousedown', handleClickOutside)\r\n }, [])\r\n\r\n // 获取用户名首字母\r\n const getInitials = (name: string) => {\r\n return name?.charAt(0).toUpperCase() || 'U'\r\n }\r\n\r\n return (\r\n <>\r\n {/* 网络切换提示横幅 */}\r\n {showNetworkBanner && (\r\n <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\">\r\n <div className=\"container mx-auto px-4 py-3 max-w-7xl\">\r\n <div className=\"flex items-center justify-between\">\r\n <div className=\"flex items-center gap-3\">\r\n <svg className=\"w-6 h-6 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\r\n </svg>\r\n <div>\r\n <p className=\"font-semibold\">检测到您在内网环境</p>\r\n <p className=\"text-sm text-white/90\">切换到内网地址可获得更快的上传下载速度,并避免登录问题</p>\r\n </div>\r\n </div>\r\n <div className=\"flex items-center gap-2\">\r\n <button\r\n onClick={switchToInternal}\r\n className=\"px-4 py-2 bg-white text-green-600 rounded-lg font-medium hover:bg-green-50 transition-all shadow-sm\"\r\n >\r\n 立即切换\r\n </button>\r\n <button\r\n onClick={dismissBanner}\r\n className=\"px-3 py-2 text-white/80 hover:text-white transition-all\"\r\n aria-label=\"关闭\"\r\n >\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n </svg>\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n\r\n <header \r\n className={`sticky z-50 shadow-sm ${showNetworkBanner ? 'top-[72px]' : 'top-0'}`}\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"container mx-auto px-4 py-4 max-w-7xl\">\r\n <div className=\"flex items-center justify-between\">\r\n {/* Logo */}\r\n <Link href=\"/\" className=\"flex items-center space-x-3\">\r\n <Image\r\n src={headerLogoUrl}\r\n alt={siteName}\r\n width={40}\r\n height={40}\r\n unoptimized\r\n className=\"rounded-lg\"\r\n />\r\n <span className=\"text-2xl font-semibold text-white\">\r\n {siteName}\r\n </span>\r\n </Link>\r\n\r\n {/* Navigation */}\r\n <nav className=\"flex items-center space-x-4\">\r\n {/* Network Status Indicator */}\r\n {networkChecked && (\r\n <div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${\r\n isInternalNetwork \r\n ? 'bg-green-500/10 text-green-400 border border-green-500/20' \r\n : 'bg-blue-500/10 text-blue-400 border border-blue-500/20'\r\n }`}>\r\n <svg className=\"w-3 h-3\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\r\n <circle cx=\"10\" cy=\"10\" r=\"4\"/>\r\n </svg>\r\n <span>{isInternalNetwork ? '内网' : '公网'}</span>\r\n </div>\r\n )}\r\n \r\n {/* User Dropdown */}\r\n {session && (\r\n <div className=\"relative\" ref={dropdownRef}>\r\n <button\r\n onClick={() => setShowDropdown(!showDropdown)}\r\n className=\"flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all\"\r\n >\r\n {/* 圆形头像 */}\r\n <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\">\r\n {getInitials(session.user?.name || '')}\r\n </div>\r\n </button>\r\n\r\n {/* 下拉菜单 */}\r\n {showDropdown && (\r\n <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\">\r\n <div className=\"px-4 py-2 border-b border-gray-100 dark:border-gray-700\">\r\n <p className=\"text-sm font-medium text-gray-900 dark:text-white\">\r\n {session.user?.name}\r\n </p>\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\r\n {session.user?.email}\r\n </p>\r\n </div>\r\n \r\n {/* 系统设置(管理员) */}\r\n {isAdmin && (\r\n <Link\r\n href=\"/admin\"\r\n onClick={() => setShowDropdown(false)}\r\n 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\"\r\n >\r\n <svg\r\n className=\"w-4 h-4\"\r\n fill=\"none\"\r\n stroke=\"currentColor\"\r\n viewBox=\"0 0 24 24\"\r\n >\r\n <path\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n strokeWidth={2}\r\n 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\"\r\n />\r\n <path\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n strokeWidth={2}\r\n d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\r\n />\r\n </svg>\r\n <span>系统设置</span>\r\n </Link>\r\n )}\r\n \r\n {/* 退出登录 */}\r\n <button\r\n onClick={() => {\r\n setShowDropdown(false)\r\n // 使用当前域名的登录页面,而不是硬编码的localhost\r\n const loginUrl = `${window.location.origin}/login`\r\n signOut({ callbackUrl: loginUrl, redirect: true })\r\n }}\r\n 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\"\r\n >\r\n <svg\r\n className=\"w-4 h-4\"\r\n fill=\"none\"\r\n stroke=\"currentColor\"\r\n viewBox=\"0 0 24 24\"\r\n >\r\n <path\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n strokeWidth={2}\r\n 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\"\r\n />\r\n </svg>\r\n <span>退出登录</span>\r\n </button>\r\n </div>\r\n )}\r\n </div>\r\n )}\r\n </nav>\r\n </div>\r\n </div>\r\n </header>\r\n </>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\nimport { signOut } from 'next-auth/react'\r\nimport { Session } from 'next-auth'\r\nimport { useState, useRef, useEffect } from 'react'\r\nimport { initializeNetwork } from '@/lib/network'\r\n\r\ninterface HeaderProps {\r\n session: Session | null\r\n}\r\n\r\nexport default function Header({ session }: HeaderProps) {\r\n const isAdmin = session ? (session.user as any)?.role === 'ADMIN' : false\r\n const [showDropdown, setShowDropdown] = useState(false)\r\n const [isInternalNetwork, setIsInternalNetwork] = useState(false)\r\n const [networkChecked, setNetworkChecked] = useState(false)\r\n const [showNetworkBanner, setShowNetworkBanner] = useState(false)\r\n const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)\r\n const dropdownRef = useRef<HTMLDivElement>(null)\r\n \r\n // 系统设置\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n\r\n // 加载系统设置\r\n useEffect(() => {\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setHeaderBgColor(settings.headerBgColor || '#1f2937')\r\n setHeaderLogoUrl(settings.headerLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')\r\n setSiteName(settings.siteName || 'FLNXI素材库')\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n loadSettings()\r\n }, [])\r\n\r\n // 更新浏览器标题\r\n useEffect(() => {\r\n if (siteName) {\r\n document.title = siteName\r\n }\r\n }, [siteName])\r\n\r\n // 检测网络状态\r\n useEffect(() => {\r\n const detectNetwork = async () => {\r\n try {\r\n const networkStatus = await initializeNetwork()\r\n setIsInternalNetwork(networkStatus.isInternal)\r\n setNetworkChecked(true)\r\n\r\n // 检查当前是否从外网访问\r\n const currentHost = window.location.hostname\r\n const intranet = (process.env.NEXT_PUBLIC_INTRANET_URL || '').replace(/^https?:\\/\\//, '').split('/')[0]\r\n const isExternal = currentHost !== intranet && currentHost !== 'localhost'\r\n setIsAccessingFromExternal(isExternal)\r\n\r\n // 如果内网可达但当前从外网访问,显示切换提示(除非用户已关闭)\r\n const bannerDismissed = sessionStorage.getItem('networkBannerDismissed')\r\n if (networkStatus.isInternal && isExternal && !bannerDismissed) {\r\n setShowNetworkBanner(true)\r\n }\r\n } catch (error) {\r\n console.error('网络检测失败:', error)\r\n setIsInternalNetwork(false)\r\n setNetworkChecked(true)\r\n }\r\n }\r\n detectNetwork()\r\n }, [])\r\n\r\n // 切换到内网地址\r\n const switchToInternal = () => {\r\n const currentPath = window.location.pathname + window.location.search\r\n const base = process.env.NEXT_PUBLIC_INTRANET_URL\r\n if (base) {\r\n window.location.href = `${base}${currentPath}`\r\n }\r\n }\r\n\r\n // 关闭横幅\r\n const dismissBanner = () => {\r\n setShowNetworkBanner(false)\r\n sessionStorage.setItem('networkBannerDismissed', 'true')\r\n }\r\n\r\n // 点击外部关闭下拉菜单\r\n useEffect(() => {\r\n const handleClickOutside = (event: MouseEvent) => {\r\n if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\r\n setShowDropdown(false)\r\n }\r\n }\r\n\r\n document.addEventListener('mousedown', handleClickOutside)\r\n return () => document.removeEventListener('mousedown', handleClickOutside)\r\n }, [])\r\n\r\n // 获取用户名首字母\r\n const getInitials = (name: string) => {\r\n return name?.charAt(0).toUpperCase() || 'U'\r\n }\r\n\r\n return (\r\n <>\r\n {/* 网络切换提示横幅 */}\r\n {showNetworkBanner && (\r\n <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\">\r\n <div className=\"container mx-auto px-4 py-3 max-w-7xl\">\r\n <div className=\"flex items-center justify-between\">\r\n <div className=\"flex items-center gap-3\">\r\n <svg className=\"w-6 h-6 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\r\n </svg>\r\n <div>\r\n <p className=\"font-semibold\">检测到您在内网环境</p>\r\n <p className=\"text-sm text-white/90\">切换到内网地址可获得更快的上传下载速度,并避免登录问题</p>\r\n </div>\r\n </div>\r\n <div className=\"flex items-center gap-2\">\r\n <button\r\n onClick={switchToInternal}\r\n className=\"px-4 py-2 bg-white text-green-600 rounded-lg font-medium hover:bg-green-50 transition-all shadow-sm\"\r\n >\r\n 立即切换\r\n </button>\r\n <button\r\n onClick={dismissBanner}\r\n className=\"px-3 py-2 text-white/80 hover:text-white transition-all\"\r\n aria-label=\"关闭\"\r\n >\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n </svg>\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n\r\n <header \r\n className={`sticky z-50 shadow-sm ${showNetworkBanner ? 'top-[72px]' : 'top-0'}`}\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"container mx-auto px-4 py-4 max-w-7xl\">\r\n <div className=\"flex items-center justify-between\">\r\n {/* Logo */}\r\n <Link href=\"/\" className=\"flex items-center space-x-3\">\r\n <Image\r\n src={headerLogoUrl}\r\n alt={siteName}\r\n width={40}\r\n height={40}\r\n unoptimized\r\n className=\"rounded-lg\"\r\n />\r\n <span className=\"text-2xl font-semibold text-white\">\r\n {siteName}\r\n </span>\r\n </Link>\r\n\r\n {/* Navigation */}\r\n <nav className=\"flex items-center space-x-4\">\r\n {/* Network Status Indicator */}\r\n {networkChecked && (\r\n <div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${\r\n isInternalNetwork \r\n ? 'bg-green-500/10 text-green-400 border border-green-500/20' \r\n : 'bg-blue-500/10 text-blue-400 border border-blue-500/20'\r\n }`}>\r\n <svg className=\"w-3 h-3\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\r\n <circle cx=\"10\" cy=\"10\" r=\"4\"/>\r\n </svg>\r\n <span>{isInternalNetwork ? '内网' : '公网'}</span>\r\n </div>\r\n )}\r\n \r\n {/* User Dropdown */}\r\n {session && (\r\n <div className=\"relative\" ref={dropdownRef}>\r\n <button\r\n onClick={() => setShowDropdown(!showDropdown)}\r\n className=\"flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all\"\r\n >\r\n {/* 圆形头像 */}\r\n <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\">\r\n {getInitials(session.user?.name || '')}\r\n </div>\r\n </button>\r\n\r\n {/* 下拉菜单 */}\r\n {showDropdown && (\r\n <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\">\r\n <div className=\"px-4 py-2 border-b border-gray-100 dark:border-gray-700\">\r\n <p className=\"text-sm font-medium text-gray-900 dark:text-white\">\r\n {session.user?.name}\r\n </p>\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\r\n {session.user?.email}\r\n </p>\r\n </div>\r\n \r\n {/* 系统设置(管理员) */}\r\n {isAdmin && (\r\n <Link\r\n href=\"/admin\"\r\n onClick={() => setShowDropdown(false)}\r\n 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\"\r\n >\r\n <svg\r\n className=\"w-4 h-4\"\r\n fill=\"none\"\r\n stroke=\"currentColor\"\r\n viewBox=\"0 0 24 24\"\r\n >\r\n <path\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n strokeWidth={2}\r\n 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\"\r\n />\r\n <path\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n strokeWidth={2}\r\n d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\r\n />\r\n </svg>\r\n <span>系统设置</span>\r\n </Link>\r\n )}\r\n \r\n {/* 退出登录 */}\r\n <button\r\n onClick={() => {\r\n setShowDropdown(false)\r\n // 使用当前域名的登录页面,而不是硬编码的localhost\r\n const loginUrl = `${window.location.origin}/login`\r\n signOut({ callbackUrl: loginUrl, redirect: true })\r\n }}\r\n 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\"\r\n >\r\n <svg\r\n className=\"w-4 h-4\"\r\n fill=\"none\"\r\n stroke=\"currentColor\"\r\n viewBox=\"0 0 24 24\"\r\n >\r\n <path\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n strokeWidth={2}\r\n 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\"\r\n />\r\n </svg>\r\n <span>退出登录</span>\r\n </button>\r\n </div>\r\n )}\r\n </div>\r\n )}\r\n </nav>\r\n </div>\r\n </div>\r\n </header>\r\n </>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -21203,7 +21203,7 @@ Result: "diff": { "chunks": [ { - "diffString": " <div className=\"flex justify-center mb-4\">\r\n <Image\r\n- src=\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\"\r\n+ src={process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''}\r\n alt=\"由壹点3D素材\"\r\n width={80}\r", + "diffString": " <div className=\"flex justify-center mb-4\">\r\n <Image\r\n- src=\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\"\r\n+ src={process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''}\r\n alt=\"FLNXI素材库\"\r\n width={80}\r", "oldStart": 80, "newStart": 80, "oldLines": 5, @@ -21216,7 +21216,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/register/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\n\r\nexport default function RegisterPage() {\r\n const router = useRouter()\r\n const [email, setEmail] = useState('')\r\n const [username, setUsername] = useState('')\r\n const [department, setDepartment] = useState('')\r\n const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([])\r\n const [password, setPassword] = useState('')\r\n const [confirmPassword, setConfirmPassword] = useState('')\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n\r\n useEffect(() => {\r\n fetchDepartments()\r\n }, [])\r\n\r\n const fetchDepartments = async () => {\r\n try {\r\n const response = await fetch('/api/departments')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setDepartments(data)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching departments:', error)\r\n }\r\n }\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n\r\n if (password !== confirmPassword) {\r\n setError('两次输入的密码不一致')\r\n return\r\n }\r\n\r\n if (password.length < 6) {\r\n setError('密码长度至少为6个字符')\r\n return\r\n }\r\n\r\n setLoading(true)\r\n\r\n try {\r\n const response = await fetch('/api/auth/register', {\r\n method: 'POST',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({ email, username, department, password }),\r\n })\r\n\r\n const data = await response.json()\r\n\r\n if (response.ok) {\r\n router.push('/login?registered=true')\r\n } else {\r\n setError(data.error || '注册失败')\r\n }\r\n } catch (error) {\r\n console.error('Registration error:', error)\r\n setError('注册失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''}\r\n alt=\"由壹点3D素材\"\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">由壹点3D素材</h1>\r\n <p className=\"text-gray-600 mt-2\">创建您的账号,开始分享3D素材</p>\r\n </div>\r\n\r\n {/* Register Form */}\r\n <div className=\"card-apple p-8\">\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 邮箱\r\n </label>\r\n <input\r\n type=\"email\"\r\n value={email}\r\n onChange={(e) => setEmail(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"your@email.com\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 用户名\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={username}\r\n onChange={(e) => setUsername(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"选择一个用户名\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 部门\r\n </label>\r\n <select\r\n value={department}\r\n onChange={(e) => setDepartment(e.target.value)}\r\n className=\"input-apple\"\r\n >\r\n <option value=\"\">请选择部门</option>\r\n {departments.map((dept) => (\r\n <option key={dept.id} value={dept.name}>\r\n {dept.name}\r\n </option>\r\n ))}\r\n </select>\r\n {departments.length === 0 && (\r\n <p className=\"text-sm text-gray-500 mt-1\">\r\n 暂无可选部门,请联系管理员添加\r\n </p>\r\n )}\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={password}\r\n onChange={(e) => setPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 确认密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={confirmPassword}\r\n onChange={(e) => setConfirmPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '注册中...' : '注册'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <p className=\"text-sm text-gray-600\">\r\n 已有账号?{' '}\r\n <Link\r\n href=\"/login\"\r\n className=\"text-gray-900 hover:text-gray-700 font-medium underline\"\r\n >\r\n 立即登录\r\n </Link>\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\n\r\nexport default function RegisterPage() {\r\n const router = useRouter()\r\n const [email, setEmail] = useState('')\r\n const [username, setUsername] = useState('')\r\n const [department, setDepartment] = useState('')\r\n const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([])\r\n const [password, setPassword] = useState('')\r\n const [confirmPassword, setConfirmPassword] = useState('')\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n\r\n useEffect(() => {\r\n fetchDepartments()\r\n }, [])\r\n\r\n const fetchDepartments = async () => {\r\n try {\r\n const response = await fetch('/api/departments')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setDepartments(data)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching departments:', error)\r\n }\r\n }\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n\r\n if (password !== confirmPassword) {\r\n setError('两次输入的密码不一致')\r\n return\r\n }\r\n\r\n if (password.length < 6) {\r\n setError('密码长度至少为6个字符')\r\n return\r\n }\r\n\r\n setLoading(true)\r\n\r\n try {\r\n const response = await fetch('/api/auth/register', {\r\n method: 'POST',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({ email, username, department, password }),\r\n })\r\n\r\n const data = await response.json()\r\n\r\n if (response.ok) {\r\n router.push('/login?registered=true')\r\n } else {\r\n setError(data.error || '注册失败')\r\n }\r\n } catch (error) {\r\n console.error('Registration error:', error)\r\n setError('注册失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''}\r\n alt=\"FLNXI素材库\"\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">FLNXI素材库</h1>\r\n <p className=\"text-gray-600 mt-2\">创建您的账号,开始分享3D素材</p>\r\n </div>\r\n\r\n {/* Register Form */}\r\n <div className=\"card-apple p-8\">\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 邮箱\r\n </label>\r\n <input\r\n type=\"email\"\r\n value={email}\r\n onChange={(e) => setEmail(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"your@email.com\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 用户名\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={username}\r\n onChange={(e) => setUsername(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"选择一个用户名\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 部门\r\n </label>\r\n <select\r\n value={department}\r\n onChange={(e) => setDepartment(e.target.value)}\r\n className=\"input-apple\"\r\n >\r\n <option value=\"\">请选择部门</option>\r\n {departments.map((dept) => (\r\n <option key={dept.id} value={dept.name}>\r\n {dept.name}\r\n </option>\r\n ))}\r\n </select>\r\n {departments.length === 0 && (\r\n <p className=\"text-sm text-gray-500 mt-1\">\r\n 暂无可选部门,请联系管理员添加\r\n </p>\r\n )}\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={password}\r\n onChange={(e) => setPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 确认密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={confirmPassword}\r\n onChange={(e) => setConfirmPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '注册中...' : '注册'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <p className=\"text-sm text-gray-600\">\r\n 已有账号?{' '}\r\n <Link\r\n href=\"/login\"\r\n className=\"text-gray-900 hover:text-gray-700 font-medium underline\"\r\n >\r\n 立即登录\r\n </Link>\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -21255,7 +21255,7 @@ Result: "diff": { "chunks": [ { - "diffString": " \r\n // 系统设置\r\n- const [loginLogoUrl, setLoginLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n \r", + "diffString": " \r\n // 系统设置\r\n- const [loginLogoUrl, setLoginLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n \r", "oldStart": 16, "newStart": 16, "oldLines": 5, @@ -21264,7 +21264,7 @@ Result: "linesAdded": 1 }, { - "diffString": " if (response.ok) {\r\n const settings = await response.json()\r\n- setLoginLogoUrl(settings.loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n setSiteName(settings.siteName || '由壹点3D素材')\r\n }\r", + "diffString": " if (response.ok) {\r\n const settings = await response.json()\r\n- setLoginLogoUrl(settings.loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n setSiteName(settings.siteName || 'FLNXI素材库')\r\n }\r", "oldStart": 29, "newStart": 29, "oldLines": 5, @@ -21295,7 +21295,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/login/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { signIn } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\n\r\nexport default function LoginPage() {\r\n const router = useRouter()\r\n const [email, setEmail] = useState('')\r\n const [password, setPassword] = useState('')\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [showDingtalkModal, setShowDingtalkModal] = useState(false)\r\n \r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n \r\n // 钉钉配置(从环境变量或配置文件获取)\r\n const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'\r\n\r\n // 加载系统设置\r\n useEffect(() => {\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n setSiteName(settings.siteName || '由壹点3D素材')\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n loadSettings()\r\n }, [])\r\n\r\n // 页面加载时识别访问环境\r\n useEffect(() => {\r\n const isPublicDomain = window.location.hostname === '3d.yydd.ai' || window.location.hostname.includes('yydd.ai')\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n \r\n if (isPublicDomain) {\r\n console.log('🌐 检测到公网访问 (' + window.location.hostname + ')')\r\n } else if (isIntranet) {\r\n console.log('🏠 检测到内网访问 (' + window.location.hostname + ')')\r\n }\r\n \r\n // 注意:内网检测已改为后端实现(/api/network-status)\r\n // 登录成功后会在首页自动调用后端API判断是否需要显示切换提示\r\n }, [])\r\n \r\n // 处理从内网跳转来的钉钉登录请求\r\n useEffect(() => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkRedirect = urlParams.get('dingtalk_redirect')\r\n \r\n if (dingtalkRedirect === 'true') {\r\n console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')\r\n // 清理URL参数\r\n window.history.replaceState({}, '', '/login')\r\n // 等待页面加载完成后触发钉钉登录\r\n const timer = setTimeout(() => {\r\n document.getElementById('dingtalk-login-btn')?.click()\r\n }, 500)\r\n return () => clearTimeout(timer)\r\n }\r\n }, [])\r\n\r\n // 处理钉钉登录成功后的自动登录(内网场景)\r\n useEffect(() => {\r\n const handleDingtalkAutoLogin = async () => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkSuccess = urlParams.get('dingtalk_success')\r\n const userEmail = urlParams.get('email')\r\n\r\n if (dingtalkSuccess === 'true' && userEmail) {\r\n setLoading(true)\r\n try {\r\n // 在内网域名下创建 session\r\n const result = await signIn('credentials', {\r\n email: decodeURIComponent(userEmail),\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 清除 URL 参数并跳转到首页\r\n window.history.replaceState({}, '', '/login')\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('自动登录失败:', error)\r\n setError('登录失败,请重试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n }\r\n handleDingtalkAutoLogin()\r\n }, [router])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n setLoading(true)\r\n\r\n try {\r\n const result = await signIn('credentials', {\r\n email,\r\n password,\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError(result.error)\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('Login error:', error)\r\n setError('登录失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n \r\n const handleDingtalkLogin = () => {\r\n // 强制使用外网域名作为回调地址,支持内网访问\r\n const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`\r\n \r\n // 在state中携带原始访问地址信息\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n const originUrl = isIntranet ? window.location.origin : ''\r\n const stateData = JSON.stringify({ origin: originUrl })\r\n \r\n const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +\r\n `redirect_uri=${encodeURIComponent(redirectUrl)}` +\r\n `&client_id=${DINGTALK_CLIENT_ID}` +\r\n `&scope=openid` +\r\n `&response_type=code` +\r\n `&state=${encodeURIComponent(stateData)}` +\r\n `&prompt=consent`\r\n \r\n // 内网访问:由于混合内容限制,直接跳转到公网域名登录\r\n if (isIntranet) {\r\n console.log('🔄 内网环境,跳转到公网域名进行钉钉登录...')\r\n const portal = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin\r\n window.location.href = `${portal}/login?dingtalk_redirect=true&return_url=${encodeURIComponent(originUrl)}`\r\n return\r\n }\r\n \r\n // 公网访问:使用iframe模式\r\n setShowDingtalkModal(true)\r\n setTimeout(() => {\r\n const iframe = document.getElementById('dingtalkIframe') as HTMLIFrameElement\r\n if (iframe) {\r\n iframe.src = dingtalkAuthUrl\r\n }\r\n }, 100)\r\n }\r\n\r\n // 监听来自 iframe 的消息\r\n useEffect(() => {\r\n const handleMessage = async (event: MessageEvent) => {\r\n if (event.data && event.data.type === 'dingtalk_login_success') {\r\n setShowDingtalkModal(false)\r\n \r\n // 创建 NextAuth session\r\n try {\r\n const result = await signIn('credentials', {\r\n email: event.data.userInfo.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n \r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 登录成功,智能跳转\r\n const originUrl = event.data.originUrl\r\n if (originUrl && originUrl.includes('192.168.')) {\r\n // 从内网发起的登录,跳回内网\r\n window.location.href = originUrl\r\n } else if (window.location.hostname.includes('192.168.')) {\r\n // 当前在内网,刷新页面\r\n window.location.href = window.location.origin\r\n } else {\r\n // 外网访问,跳转到首页\r\n router.push('/')\r\n router.refresh()\r\n }\r\n }\r\n } catch (error) {\r\n console.error('创建session失败:', error)\r\n setError('登录失败,请重试')\r\n }\r\n } else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {\r\n setShowDingtalkModal(false)\r\n // 跳转到信息完善页面\r\n const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))\r\n router.push(`/complete-profile?userInfo=${userInfoParam}`)\r\n } else if (event.data && event.data.type === 'dingtalk_login_error') {\r\n setShowDingtalkModal(false)\r\n setError(event.data.message || '钉钉登录失败')\r\n }\r\n }\r\n\r\n window.addEventListener('message', handleMessage)\r\n return () => window.removeEventListener('message', handleMessage)\r\n }, [router])\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">{siteName}</h1>\r\n <p className=\"text-gray-600 mt-2\">欢迎回来,请登录您的账号</p>\r\n </div>\r\n\r\n {/* Login Form */}\r\n <div className=\"card-apple p-8\">\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 邮箱\r\n </label>\r\n <input\r\n type=\"email\"\r\n value={email}\r\n onChange={(e) => setEmail(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"your@email.com\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={password}\r\n onChange={(e) => setPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '登录中...' : '登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6\">\r\n {/* 分隔线 */}\r\n <div className=\"relative my-6\">\r\n <div className=\"absolute inset-0 flex items-center\">\r\n <div className=\"w-full border-t border-gray-300\"></div>\r\n </div>\r\n <div className=\"relative flex justify-center text-sm\">\r\n <span className=\"px-2 bg-white text-gray-500\">或</span>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录按钮 */}\r\n <button\r\n id=\"dingtalk-login-btn\"\r\n type=\"button\"\r\n onClick={handleDingtalkLogin}\r\n 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\"\r\n >\r\n <svg style={{ width: '28px', height: '28px' }} viewBox=\"0 0 1024 1024\">\r\n <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>\r\n </svg>\r\n <span className=\"text-lg font-medium text-gray-700\">钉钉登录</span>\r\n </button>\r\n </div>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <p className=\"text-sm text-gray-600\">\r\n 还没有账号?{' '}\r\n <Link\r\n href=\"/register\"\r\n className=\"text-gray-900 hover:text-gray-700 font-medium underline\"\r\n >\r\n 立即注册\r\n </Link>\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录 iframe 弹窗 */}\r\n {showDingtalkModal && (\r\n <div\r\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50\"\r\n onClick={() => setShowDingtalkModal(false)}\r\n >\r\n <div\r\n className=\"relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4\"\r\n onClick={(e) => e.stopPropagation()}\r\n >\r\n <div className=\"flex items-center justify-between p-4 border-b\">\r\n <h3 className=\"text-lg font-semibold text-gray-900\">钉钉扫码登录</h3>\r\n <button\r\n onClick={() => setShowDingtalkModal(false)}\r\n className=\"text-gray-400 hover:text-gray-600 transition-colors\"\r\n >\r\n <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n </svg>\r\n </button>\r\n </div>\r\n <div className=\"p-4\">\r\n <iframe\r\n id=\"dingtalkIframe\"\r\n className=\"w-full h-96 border-0 rounded-lg\"\r\n title=\"钉钉登录\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { signIn } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\n\r\nexport default function LoginPage() {\r\n const router = useRouter()\r\n const [email, setEmail] = useState('')\r\n const [password, setPassword] = useState('')\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [showDingtalkModal, setShowDingtalkModal] = useState(false)\r\n \r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n \r\n // 钉钉配置(从环境变量或配置文件获取)\r\n const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'\r\n\r\n // 加载系统设置\r\n useEffect(() => {\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n setSiteName(settings.siteName || 'FLNXI素材库')\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n loadSettings()\r\n }, [])\r\n\r\n // 页面加载时识别访问环境\r\n useEffect(() => {\r\n const isPublicDomain = window.location.hostname === '3d.yydd.ai' || window.location.hostname.includes('yydd.ai')\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n \r\n if (isPublicDomain) {\r\n console.log('🌐 检测到公网访问 (' + window.location.hostname + ')')\r\n } else if (isIntranet) {\r\n console.log('🏠 检测到内网访问 (' + window.location.hostname + ')')\r\n }\r\n \r\n // 注意:内网检测已改为后端实现(/api/network-status)\r\n // 登录成功后会在首页自动调用后端API判断是否需要显示切换提示\r\n }, [])\r\n \r\n // 处理从内网跳转来的钉钉登录请求\r\n useEffect(() => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkRedirect = urlParams.get('dingtalk_redirect')\r\n \r\n if (dingtalkRedirect === 'true') {\r\n console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')\r\n // 清理URL参数\r\n window.history.replaceState({}, '', '/login')\r\n // 等待页面加载完成后触发钉钉登录\r\n const timer = setTimeout(() => {\r\n document.getElementById('dingtalk-login-btn')?.click()\r\n }, 500)\r\n return () => clearTimeout(timer)\r\n }\r\n }, [])\r\n\r\n // 处理钉钉登录成功后的自动登录(内网场景)\r\n useEffect(() => {\r\n const handleDingtalkAutoLogin = async () => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkSuccess = urlParams.get('dingtalk_success')\r\n const userEmail = urlParams.get('email')\r\n\r\n if (dingtalkSuccess === 'true' && userEmail) {\r\n setLoading(true)\r\n try {\r\n // 在内网域名下创建 session\r\n const result = await signIn('credentials', {\r\n email: decodeURIComponent(userEmail),\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 清除 URL 参数并跳转到首页\r\n window.history.replaceState({}, '', '/login')\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('自动登录失败:', error)\r\n setError('登录失败,请重试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n }\r\n handleDingtalkAutoLogin()\r\n }, [router])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n setLoading(true)\r\n\r\n try {\r\n const result = await signIn('credentials', {\r\n email,\r\n password,\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError(result.error)\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('Login error:', error)\r\n setError('登录失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n \r\n const handleDingtalkLogin = () => {\r\n // 强制使用外网域名作为回调地址,支持内网访问\r\n const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`\r\n \r\n // 在state中携带原始访问地址信息\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n const originUrl = isIntranet ? window.location.origin : ''\r\n const stateData = JSON.stringify({ origin: originUrl })\r\n \r\n const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +\r\n `redirect_uri=${encodeURIComponent(redirectUrl)}` +\r\n `&client_id=${DINGTALK_CLIENT_ID}` +\r\n `&scope=openid` +\r\n `&response_type=code` +\r\n `&state=${encodeURIComponent(stateData)}` +\r\n `&prompt=consent`\r\n \r\n // 内网访问:由于混合内容限制,直接跳转到公网域名登录\r\n if (isIntranet) {\r\n console.log('🔄 内网环境,跳转到公网域名进行钉钉登录...')\r\n const portal = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin\r\n window.location.href = `${portal}/login?dingtalk_redirect=true&return_url=${encodeURIComponent(originUrl)}`\r\n return\r\n }\r\n \r\n // 公网访问:使用iframe模式\r\n setShowDingtalkModal(true)\r\n setTimeout(() => {\r\n const iframe = document.getElementById('dingtalkIframe') as HTMLIFrameElement\r\n if (iframe) {\r\n iframe.src = dingtalkAuthUrl\r\n }\r\n }, 100)\r\n }\r\n\r\n // 监听来自 iframe 的消息\r\n useEffect(() => {\r\n const handleMessage = async (event: MessageEvent) => {\r\n if (event.data && event.data.type === 'dingtalk_login_success') {\r\n setShowDingtalkModal(false)\r\n \r\n // 创建 NextAuth session\r\n try {\r\n const result = await signIn('credentials', {\r\n email: event.data.userInfo.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n \r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 登录成功,智能跳转\r\n const originUrl = event.data.originUrl\r\n if (originUrl && originUrl.includes('192.168.')) {\r\n // 从内网发起的登录,跳回内网\r\n window.location.href = originUrl\r\n } else if (window.location.hostname.includes('192.168.')) {\r\n // 当前在内网,刷新页面\r\n window.location.href = window.location.origin\r\n } else {\r\n // 外网访问,跳转到首页\r\n router.push('/')\r\n router.refresh()\r\n }\r\n }\r\n } catch (error) {\r\n console.error('创建session失败:', error)\r\n setError('登录失败,请重试')\r\n }\r\n } else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {\r\n setShowDingtalkModal(false)\r\n // 跳转到信息完善页面\r\n const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))\r\n router.push(`/complete-profile?userInfo=${userInfoParam}`)\r\n } else if (event.data && event.data.type === 'dingtalk_login_error') {\r\n setShowDingtalkModal(false)\r\n setError(event.data.message || '钉钉登录失败')\r\n }\r\n }\r\n\r\n window.addEventListener('message', handleMessage)\r\n return () => window.removeEventListener('message', handleMessage)\r\n }, [router])\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">{siteName}</h1>\r\n <p className=\"text-gray-600 mt-2\">欢迎回来,请登录您的账号</p>\r\n </div>\r\n\r\n {/* Login Form */}\r\n <div className=\"card-apple p-8\">\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 邮箱\r\n </label>\r\n <input\r\n type=\"email\"\r\n value={email}\r\n onChange={(e) => setEmail(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"your@email.com\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={password}\r\n onChange={(e) => setPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '登录中...' : '登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6\">\r\n {/* 分隔线 */}\r\n <div className=\"relative my-6\">\r\n <div className=\"absolute inset-0 flex items-center\">\r\n <div className=\"w-full border-t border-gray-300\"></div>\r\n </div>\r\n <div className=\"relative flex justify-center text-sm\">\r\n <span className=\"px-2 bg-white text-gray-500\">或</span>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录按钮 */}\r\n <button\r\n id=\"dingtalk-login-btn\"\r\n type=\"button\"\r\n onClick={handleDingtalkLogin}\r\n 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\"\r\n >\r\n <svg style={{ width: '28px', height: '28px' }} viewBox=\"0 0 1024 1024\">\r\n <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>\r\n </svg>\r\n <span className=\"text-lg font-medium text-gray-700\">钉钉登录</span>\r\n </button>\r\n </div>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <p className=\"text-sm text-gray-600\">\r\n 还没有账号?{' '}\r\n <Link\r\n href=\"/register\"\r\n className=\"text-gray-900 hover:text-gray-700 font-medium underline\"\r\n >\r\n 立即注册\r\n </Link>\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录 iframe 弹窗 */}\r\n {showDingtalkModal && (\r\n <div\r\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50\"\r\n onClick={() => setShowDingtalkModal(false)}\r\n >\r\n <div\r\n className=\"relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4\"\r\n onClick={(e) => e.stopPropagation()}\r\n >\r\n <div className=\"flex items-center justify-between p-4 border-b\">\r\n <h3 className=\"text-lg font-semibold text-gray-900\">钉钉扫码登录</h3>\r\n <button\r\n onClick={() => setShowDingtalkModal(false)}\r\n className=\"text-gray-400 hover:text-gray-600 transition-colors\"\r\n >\r\n <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n </svg>\r\n </button>\r\n </div>\r\n <div className=\"p-4\">\r\n <iframe\r\n id=\"dingtalkIframe\"\r\n className=\"w-full h-96 border-0 rounded-lg\"\r\n title=\"钉钉登录\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -21334,7 +21334,7 @@ Result: "diff": { "chunks": [ { - "diffString": " \r\n // 系统设置\r\n- const [loginLogoUrl, setLoginLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n \r", + "diffString": " \r\n // 系统设置\r\n- const [loginLogoUrl, setLoginLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')\r\n+ const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n \r", "oldStart": 23, "newStart": 23, "oldLines": 5, @@ -21347,7 +21347,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/complete-profile/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState, Suspense } from 'react'\r\nimport { useRouter, useSearchParams } from 'next/navigation'\r\nimport { signIn } from 'next-auth/react'\r\nimport Image from 'next/image'\r\n\r\ninterface Department {\r\n id: string\r\n name: string\r\n}\r\n\r\nfunction CompleteProfileContent() {\r\n const router = useRouter()\r\n const searchParams = useSearchParams()\r\n \r\n const [department, setDepartment] = useState('')\r\n const [departments, setDepartments] = useState<Department[]>([])\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [success, setSuccess] = useState('')\r\n const [userInfo, setUserInfo] = useState<any>(null)\r\n\r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n\r\n useEffect(() => {\r\n // 加载系统设置\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || loginLogoUrl)\r\n setSiteName(settings.siteName || siteName)\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n\r\n // 加载部门列表\r\n const loadDepartments = async () => {\r\n try {\r\n const response = await fetch('/api/departments')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setDepartments(data || [])\r\n }\r\n } catch (error) {\r\n console.error('加载部门列表失败:', error)\r\n }\r\n }\r\n\r\n // 从URL参数获取用户信息\r\n const userInfoParam = searchParams.get('userInfo')\r\n if (userInfoParam) {\r\n try {\r\n const info = JSON.parse(decodeURIComponent(userInfoParam))\r\n setUserInfo(info)\r\n } catch (error) {\r\n console.error('解析用户信息失败:', error)\r\n setError('无效的用户信息,请重新登录')\r\n }\r\n } else {\r\n setError('缺少用户信息,请重新登录')\r\n }\r\n\r\n loadSettings()\r\n loadDepartments()\r\n }, [searchParams])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n\r\n if (!department) {\r\n setError('请选择部门')\r\n return\r\n }\r\n\r\n setLoading(true)\r\n setError('') // 清空错误信息\r\n setSuccess('') // 清空成功信息\r\n\r\n try {\r\n // 调用API完善用户信息(只需要选择部门)\r\n const response = await fetch('/api/dingtalk/complete-profile', {\r\n method: 'POST',\r\n headers: { 'Content-Type': 'application/json' },\r\n body: JSON.stringify({\r\n userId: userInfo?.id,\r\n department,\r\n }),\r\n })\r\n\r\n const data = await response.json()\r\n\r\n if (!response.ok || !data.success) {\r\n throw new Error(data.message || '信息提交失败')\r\n }\r\n\r\n // 如果是账号关联(关联到已有账号),提示用户使用原账号密码登录\r\n if (data.isAccountLink) {\r\n setSuccess('账号关联成功!正在跳转到登录页面,您可以使用原账号密码登录或继续使用钉钉扫码登录。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 检查新用户是否已激活\r\n if (data.user && !data.user.isActive) {\r\n setSuccess('信息提交成功!您的账号需要管理员激活后才能登录使用。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 已激活用户:使用 NextAuth 自动登录\r\n const result = await signIn('credentials', {\r\n email: data.user.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n throw new Error('登录失败')\r\n }\r\n\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = window.location.origin\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error: any) {\r\n console.error('提交失败:', error)\r\n setError(error.message || '提交失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n if (error && !userInfo) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full bg-white rounded-2xl shadow-xl p-8\">\r\n <div className=\"text-center\">\r\n <div className=\"mb-4\">\r\n <svg className=\"mx-auto h-12 w-12 text-red-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <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\" />\r\n </svg>\r\n </div>\r\n <h2 className=\"text-xl font-semibold mb-2 text-red-600\">出错了</h2>\r\n <p className=\"text-gray-600 mb-4\">{error}</p>\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"btn-primary\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">完善个人信息</h1>\r\n <p className=\"text-gray-600 mt-2\">首次登录,请完善您的用户信息</p>\r\n </div>\r\n\r\n {/* Form */}\r\n <div className=\"card-apple p-8\">\r\n {userInfo && (\r\n <div className=\"mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200\">\r\n <div className=\"flex items-center gap-3\">\r\n {userInfo.avatar && (\r\n <Image\r\n src={userInfo.avatar}\r\n alt={userInfo.realName}\r\n width={48}\r\n height={48}\r\n className=\"rounded-full\"\r\n unoptimized\r\n />\r\n )}\r\n <div>\r\n <p className=\"text-sm text-gray-600\">钉钉账号</p>\r\n <p className=\"font-medium text-gray-900\">{userInfo.realName}</p>\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n \r\n {success && (\r\n <div className=\"bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm\">\r\n {success}\r\n </div>\r\n )}\r\n\r\n {/* 显示钉钉用户名(只读) */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 用户名\r\n </label>\r\n <div className=\"input-apple bg-gray-50 text-gray-700 cursor-not-allowed\">\r\n {userInfo?.username || userInfo?.realName || '加载中...'}\r\n </div>\r\n <p className=\"mt-1 text-xs text-gray-500\">使用您的钉钉姓名作为系统用户名</p>\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 所属部门 *\r\n </label>\r\n <select\r\n value={department}\r\n onChange={(e) => setDepartment(e.target.value)}\r\n className=\"input-apple\"\r\n required\r\n >\r\n <option value=\"\">请选择部门</option>\r\n {departments.map((dept) => (\r\n <option key={dept.id} value={dept.name}>\r\n {dept.name}\r\n </option>\r\n ))}\r\n </select>\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '提交中...' : '完成并登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"text-sm text-gray-600 hover:text-gray-900\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\nexport default function CompleteProfilePage() {\r\n return (\r\n <Suspense fallback={\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100\">\r\n <div className=\"text-center\">\r\n <div className=\"inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent\"></div>\r\n <p className=\"mt-4 text-gray-600\">加载中...</p>\r\n </div>\r\n </div>\r\n }>\r\n <CompleteProfileContent />\r\n </Suspense>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState, Suspense } from 'react'\r\nimport { useRouter, useSearchParams } from 'next/navigation'\r\nimport { signIn } from 'next-auth/react'\r\nimport Image from 'next/image'\r\n\r\ninterface Department {\r\n id: string\r\n name: string\r\n}\r\n\r\nfunction CompleteProfileContent() {\r\n const router = useRouter()\r\n const searchParams = useSearchParams()\r\n \r\n const [department, setDepartment] = useState('')\r\n const [departments, setDepartments] = useState<Department[]>([])\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [success, setSuccess] = useState('')\r\n const [userInfo, setUserInfo] = useState<any>(null)\r\n\r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n\r\n useEffect(() => {\r\n // 加载系统设置\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || loginLogoUrl)\r\n setSiteName(settings.siteName || siteName)\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n\r\n // 加载部门列表\r\n const loadDepartments = async () => {\r\n try {\r\n const response = await fetch('/api/departments')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setDepartments(data || [])\r\n }\r\n } catch (error) {\r\n console.error('加载部门列表失败:', error)\r\n }\r\n }\r\n\r\n // 从URL参数获取用户信息\r\n const userInfoParam = searchParams.get('userInfo')\r\n if (userInfoParam) {\r\n try {\r\n const info = JSON.parse(decodeURIComponent(userInfoParam))\r\n setUserInfo(info)\r\n } catch (error) {\r\n console.error('解析用户信息失败:', error)\r\n setError('无效的用户信息,请重新登录')\r\n }\r\n } else {\r\n setError('缺少用户信息,请重新登录')\r\n }\r\n\r\n loadSettings()\r\n loadDepartments()\r\n }, [searchParams])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n\r\n if (!department) {\r\n setError('请选择部门')\r\n return\r\n }\r\n\r\n setLoading(true)\r\n setError('') // 清空错误信息\r\n setSuccess('') // 清空成功信息\r\n\r\n try {\r\n // 调用API完善用户信息(只需要选择部门)\r\n const response = await fetch('/api/dingtalk/complete-profile', {\r\n method: 'POST',\r\n headers: { 'Content-Type': 'application/json' },\r\n body: JSON.stringify({\r\n userId: userInfo?.id,\r\n department,\r\n }),\r\n })\r\n\r\n const data = await response.json()\r\n\r\n if (!response.ok || !data.success) {\r\n throw new Error(data.message || '信息提交失败')\r\n }\r\n\r\n // 如果是账号关联(关联到已有账号),提示用户使用原账号密码登录\r\n if (data.isAccountLink) {\r\n setSuccess('账号关联成功!正在跳转到登录页面,您可以使用原账号密码登录或继续使用钉钉扫码登录。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 检查新用户是否已激活\r\n if (data.user && !data.user.isActive) {\r\n setSuccess('信息提交成功!您的账号需要管理员激活后才能登录使用。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 已激活用户:使用 NextAuth 自动登录\r\n const result = await signIn('credentials', {\r\n email: data.user.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n throw new Error('登录失败')\r\n }\r\n\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = window.location.origin\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error: any) {\r\n console.error('提交失败:', error)\r\n setError(error.message || '提交失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n if (error && !userInfo) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full bg-white rounded-2xl shadow-xl p-8\">\r\n <div className=\"text-center\">\r\n <div className=\"mb-4\">\r\n <svg className=\"mx-auto h-12 w-12 text-red-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <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\" />\r\n </svg>\r\n </div>\r\n <h2 className=\"text-xl font-semibold mb-2 text-red-600\">出错了</h2>\r\n <p className=\"text-gray-600 mb-4\">{error}</p>\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"btn-primary\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">完善个人信息</h1>\r\n <p className=\"text-gray-600 mt-2\">首次登录,请完善您的用户信息</p>\r\n </div>\r\n\r\n {/* Form */}\r\n <div className=\"card-apple p-8\">\r\n {userInfo && (\r\n <div className=\"mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200\">\r\n <div className=\"flex items-center gap-3\">\r\n {userInfo.avatar && (\r\n <Image\r\n src={userInfo.avatar}\r\n alt={userInfo.realName}\r\n width={48}\r\n height={48}\r\n className=\"rounded-full\"\r\n unoptimized\r\n />\r\n )}\r\n <div>\r\n <p className=\"text-sm text-gray-600\">钉钉账号</p>\r\n <p className=\"font-medium text-gray-900\">{userInfo.realName}</p>\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n \r\n {success && (\r\n <div className=\"bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm\">\r\n {success}\r\n </div>\r\n )}\r\n\r\n {/* 显示钉钉用户名(只读) */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 用户名\r\n </label>\r\n <div className=\"input-apple bg-gray-50 text-gray-700 cursor-not-allowed\">\r\n {userInfo?.username || userInfo?.realName || '加载中...'}\r\n </div>\r\n <p className=\"mt-1 text-xs text-gray-500\">使用您的钉钉姓名作为系统用户名</p>\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 所属部门 *\r\n </label>\r\n <select\r\n value={department}\r\n onChange={(e) => setDepartment(e.target.value)}\r\n className=\"input-apple\"\r\n required\r\n >\r\n <option value=\"\">请选择部门</option>\r\n {departments.map((dept) => (\r\n <option key={dept.id} value={dept.name}>\r\n {dept.name}\r\n </option>\r\n ))}\r\n </select>\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '提交中...' : '完成并登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"text-sm text-gray-600 hover:text-gray-900\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\nexport default function CompleteProfilePage() {\r\n return (\r\n <Suspense fallback={\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100\">\r\n <div className=\"text-center\">\r\n <div className=\"inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent\"></div>\r\n <p className=\"mt-4 text-gray-600\">加载中...</p>\r\n </div>\r\n </div>\r\n }>\r\n <CompleteProfileContent />\r\n </Suspense>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -21618,7 +21618,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" + "contentsAfterEdit": "# FLNXI素材库\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" } ``` User decision: **accepted** @@ -22211,7 +22211,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/login/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { signIn } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\n\r\nexport default function LoginPage() {\r\n const router = useRouter()\r\n const [email, setEmail] = useState('')\r\n const [password, setPassword] = useState('')\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [showDingtalkModal, setShowDingtalkModal] = useState(false)\r\n \r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n \r\n // 钉钉配置(从环境变量或配置文件获取)\r\n const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'\r\n\r\n // 加载系统设置\r\n useEffect(() => {\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n setSiteName(settings.siteName || '由壹点3D素材')\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n loadSettings()\r\n }, [])\r\n\r\n // 页面加载时识别访问环境\r\n useEffect(() => {\r\n const envDomains = (process.env.NEXT_PUBLIC_PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)\r\n const isPublicDomain = envDomains.length > 0 ? envDomains.includes(window.location.hostname) : false\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n \r\n if (isPublicDomain) {\r\n console.log('🌐 检测到公网访问 (' + window.location.hostname + ')')\r\n } else if (isIntranet) {\r\n console.log('🏠 检测到内网访问 (' + window.location.hostname + ')')\r\n }\r\n \r\n // 注意:内网检测已改为后端实现(/api/network-status)\r\n // 登录成功后会在首页自动调用后端API判断是否需要显示切换提示\r\n }, [])\r\n \r\n // 处理从内网跳转来的钉钉登录请求\r\n useEffect(() => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkRedirect = urlParams.get('dingtalk_redirect')\r\n \r\n if (dingtalkRedirect === 'true') {\r\n console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')\r\n // 清理URL参数\r\n window.history.replaceState({}, '', '/login')\r\n // 等待页面加载完成后触发钉钉登录\r\n const timer = setTimeout(() => {\r\n document.getElementById('dingtalk-login-btn')?.click()\r\n }, 500)\r\n return () => clearTimeout(timer)\r\n }\r\n }, [])\r\n\r\n // 处理钉钉登录成功后的自动登录(内网场景)\r\n useEffect(() => {\r\n const handleDingtalkAutoLogin = async () => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkSuccess = urlParams.get('dingtalk_success')\r\n const userEmail = urlParams.get('email')\r\n\r\n if (dingtalkSuccess === 'true' && userEmail) {\r\n setLoading(true)\r\n try {\r\n // 在内网域名下创建 session\r\n const result = await signIn('credentials', {\r\n email: decodeURIComponent(userEmail),\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 清除 URL 参数并跳转到首页\r\n window.history.replaceState({}, '', '/login')\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('自动登录失败:', error)\r\n setError('登录失败,请重试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n }\r\n handleDingtalkAutoLogin()\r\n }, [router])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n setLoading(true)\r\n\r\n try {\r\n const result = await signIn('credentials', {\r\n email,\r\n password,\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError(result.error)\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('Login error:', error)\r\n setError('登录失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n \r\n const handleDingtalkLogin = () => {\r\n // 强制使用外网域名作为回调地址,支持内网访问\r\n const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`\r\n \r\n // 在state中携带原始访问地址信息\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n const originUrl = isIntranet ? window.location.origin : ''\r\n const stateData = JSON.stringify({ origin: originUrl })\r\n \r\n const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +\r\n `redirect_uri=${encodeURIComponent(redirectUrl)}` +\r\n `&client_id=${DINGTALK_CLIENT_ID}` +\r\n `&scope=openid` +\r\n `&response_type=code` +\r\n `&state=${encodeURIComponent(stateData)}` +\r\n `&prompt=consent`\r\n \r\n // 内网访问:由于混合内容限制,直接跳转到公网域名登录\r\n if (isIntranet) {\r\n console.log('🔄 内网环境,跳转到公网域名进行钉钉登录...')\r\n const portal = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin\r\n window.location.href = `${portal}/login?dingtalk_redirect=true&return_url=${encodeURIComponent(originUrl)}`\r\n return\r\n }\r\n \r\n // 公网访问:使用iframe模式\r\n setShowDingtalkModal(true)\r\n setTimeout(() => {\r\n const iframe = document.getElementById('dingtalkIframe') as HTMLIFrameElement\r\n if (iframe) {\r\n iframe.src = dingtalkAuthUrl\r\n }\r\n }, 100)\r\n }\r\n\r\n // 监听来自 iframe 的消息\r\n useEffect(() => {\r\n const handleMessage = async (event: MessageEvent) => {\r\n if (event.data && event.data.type === 'dingtalk_login_success') {\r\n setShowDingtalkModal(false)\r\n \r\n // 创建 NextAuth session\r\n try {\r\n const result = await signIn('credentials', {\r\n email: event.data.userInfo.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n \r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 登录成功,智能跳转\r\n const originUrl = event.data.originUrl\r\n if (originUrl && originUrl.includes('192.168.')) {\r\n // 从内网发起的登录,跳回内网\r\n window.location.href = originUrl\r\n } else if (window.location.hostname.includes('192.168.')) {\r\n // 当前在内网,刷新页面\r\n window.location.href = window.location.origin\r\n } else {\r\n // 外网访问,跳转到首页\r\n router.push('/')\r\n router.refresh()\r\n }\r\n }\r\n } catch (error) {\r\n console.error('创建session失败:', error)\r\n setError('登录失败,请重试')\r\n }\r\n } else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {\r\n setShowDingtalkModal(false)\r\n // 跳转到信息完善页面\r\n const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))\r\n router.push(`/complete-profile?userInfo=${userInfoParam}`)\r\n } else if (event.data && event.data.type === 'dingtalk_login_error') {\r\n setShowDingtalkModal(false)\r\n setError(event.data.message || '钉钉登录失败')\r\n }\r\n }\r\n\r\n window.addEventListener('message', handleMessage)\r\n return () => window.removeEventListener('message', handleMessage)\r\n }, [router])\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">{siteName}</h1>\r\n <p className=\"text-gray-600 mt-2\">欢迎回来,请登录您的账号</p>\r\n </div>\r\n\r\n {/* Login Form */}\r\n <div className=\"card-apple p-8\">\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 邮箱\r\n </label>\r\n <input\r\n type=\"email\"\r\n value={email}\r\n onChange={(e) => setEmail(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"your@email.com\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={password}\r\n onChange={(e) => setPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '登录中...' : '登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6\">\r\n {/* 分隔线 */}\r\n <div className=\"relative my-6\">\r\n <div className=\"absolute inset-0 flex items-center\">\r\n <div className=\"w-full border-t border-gray-300\"></div>\r\n </div>\r\n <div className=\"relative flex justify-center text-sm\">\r\n <span className=\"px-2 bg-white text-gray-500\">或</span>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录按钮 */}\r\n <button\r\n id=\"dingtalk-login-btn\"\r\n type=\"button\"\r\n onClick={handleDingtalkLogin}\r\n 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\"\r\n >\r\n <svg style={{ width: '28px', height: '28px' }} viewBox=\"0 0 1024 1024\">\r\n <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>\r\n </svg>\r\n <span className=\"text-lg font-medium text-gray-700\">钉钉登录</span>\r\n </button>\r\n </div>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <p className=\"text-sm text-gray-600\">\r\n 还没有账号?{' '}\r\n <Link\r\n href=\"/register\"\r\n className=\"text-gray-900 hover:text-gray-700 font-medium underline\"\r\n >\r\n 立即注册\r\n </Link>\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录 iframe 弹窗 */}\r\n {showDingtalkModal && (\r\n <div\r\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50\"\r\n onClick={() => setShowDingtalkModal(false)}\r\n >\r\n <div\r\n className=\"relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4\"\r\n onClick={(e) => e.stopPropagation()}\r\n >\r\n <div className=\"flex items-center justify-between p-4 border-b\">\r\n <h3 className=\"text-lg font-semibold text-gray-900\">钉钉扫码登录</h3>\r\n <button\r\n onClick={() => setShowDingtalkModal(false)}\r\n className=\"text-gray-400 hover:text-gray-600 transition-colors\"\r\n >\r\n <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n </svg>\r\n </button>\r\n </div>\r\n <div className=\"p-4\">\r\n <iframe\r\n id=\"dingtalkIframe\"\r\n className=\"w-full h-96 border-0 rounded-lg\"\r\n title=\"钉钉登录\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { signIn } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Image from 'next/image'\r\nimport Link from 'next/link'\r\n\r\nexport default function LoginPage() {\r\n const router = useRouter()\r\n const [email, setEmail] = useState('')\r\n const [password, setPassword] = useState('')\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [showDingtalkModal, setShowDingtalkModal] = useState(false)\r\n \r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n \r\n // 钉钉配置(从环境变量或配置文件获取)\r\n const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'\r\n\r\n // 加载系统设置\r\n useEffect(() => {\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n setSiteName(settings.siteName || 'FLNXI素材库')\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n loadSettings()\r\n }, [])\r\n\r\n // 页面加载时识别访问环境\r\n useEffect(() => {\r\n const envDomains = (process.env.NEXT_PUBLIC_PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)\r\n const isPublicDomain = envDomains.length > 0 ? envDomains.includes(window.location.hostname) : false\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n \r\n if (isPublicDomain) {\r\n console.log('🌐 检测到公网访问 (' + window.location.hostname + ')')\r\n } else if (isIntranet) {\r\n console.log('🏠 检测到内网访问 (' + window.location.hostname + ')')\r\n }\r\n \r\n // 注意:内网检测已改为后端实现(/api/network-status)\r\n // 登录成功后会在首页自动调用后端API判断是否需要显示切换提示\r\n }, [])\r\n \r\n // 处理从内网跳转来的钉钉登录请求\r\n useEffect(() => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkRedirect = urlParams.get('dingtalk_redirect')\r\n \r\n if (dingtalkRedirect === 'true') {\r\n console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')\r\n // 清理URL参数\r\n window.history.replaceState({}, '', '/login')\r\n // 等待页面加载完成后触发钉钉登录\r\n const timer = setTimeout(() => {\r\n document.getElementById('dingtalk-login-btn')?.click()\r\n }, 500)\r\n return () => clearTimeout(timer)\r\n }\r\n }, [])\r\n\r\n // 处理钉钉登录成功后的自动登录(内网场景)\r\n useEffect(() => {\r\n const handleDingtalkAutoLogin = async () => {\r\n const urlParams = new URLSearchParams(window.location.search)\r\n const dingtalkSuccess = urlParams.get('dingtalk_success')\r\n const userEmail = urlParams.get('email')\r\n\r\n if (dingtalkSuccess === 'true' && userEmail) {\r\n setLoading(true)\r\n try {\r\n // 在内网域名下创建 session\r\n const result = await signIn('credentials', {\r\n email: decodeURIComponent(userEmail),\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 清除 URL 参数并跳转到首页\r\n window.history.replaceState({}, '', '/login')\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('自动登录失败:', error)\r\n setError('登录失败,请重试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n }\r\n handleDingtalkAutoLogin()\r\n }, [router])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n setLoading(true)\r\n\r\n try {\r\n const result = await signIn('credentials', {\r\n email,\r\n password,\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n setError(result.error)\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error) {\r\n console.error('Login error:', error)\r\n setError('登录失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n \r\n const handleDingtalkLogin = () => {\r\n // 强制使用外网域名作为回调地址,支持内网访问\r\n const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`\r\n \r\n // 在state中携带原始访问地址信息\r\n const isIntranet = window.location.hostname.includes('192.168.')\r\n const originUrl = isIntranet ? window.location.origin : ''\r\n const stateData = JSON.stringify({ origin: originUrl })\r\n \r\n const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +\r\n `redirect_uri=${encodeURIComponent(redirectUrl)}` +\r\n `&client_id=${DINGTALK_CLIENT_ID}` +\r\n `&scope=openid` +\r\n `&response_type=code` +\r\n `&state=${encodeURIComponent(stateData)}` +\r\n `&prompt=consent`\r\n \r\n // 内网访问:由于混合内容限制,直接跳转到公网域名登录\r\n if (isIntranet) {\r\n console.log('🔄 内网环境,跳转到公网域名进行钉钉登录...')\r\n const portal = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin\r\n window.location.href = `${portal}/login?dingtalk_redirect=true&return_url=${encodeURIComponent(originUrl)}`\r\n return\r\n }\r\n \r\n // 公网访问:使用iframe模式\r\n setShowDingtalkModal(true)\r\n setTimeout(() => {\r\n const iframe = document.getElementById('dingtalkIframe') as HTMLIFrameElement\r\n if (iframe) {\r\n iframe.src = dingtalkAuthUrl\r\n }\r\n }, 100)\r\n }\r\n\r\n // 监听来自 iframe 的消息\r\n useEffect(() => {\r\n const handleMessage = async (event: MessageEvent) => {\r\n if (event.data && event.data.type === 'dingtalk_login_success') {\r\n setShowDingtalkModal(false)\r\n \r\n // 创建 NextAuth session\r\n try {\r\n const result = await signIn('credentials', {\r\n email: event.data.userInfo.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n \r\n if (result?.error) {\r\n setError('登录失败,请重试')\r\n } else {\r\n // 登录成功,智能跳转\r\n const originUrl = event.data.originUrl\r\n if (originUrl && originUrl.includes('192.168.')) {\r\n // 从内网发起的登录,跳回内网\r\n window.location.href = originUrl\r\n } else if (window.location.hostname.includes('192.168.')) {\r\n // 当前在内网,刷新页面\r\n window.location.href = window.location.origin\r\n } else {\r\n // 外网访问,跳转到首页\r\n router.push('/')\r\n router.refresh()\r\n }\r\n }\r\n } catch (error) {\r\n console.error('创建session失败:', error)\r\n setError('登录失败,请重试')\r\n }\r\n } else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {\r\n setShowDingtalkModal(false)\r\n // 跳转到信息完善页面\r\n const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))\r\n router.push(`/complete-profile?userInfo=${userInfoParam}`)\r\n } else if (event.data && event.data.type === 'dingtalk_login_error') {\r\n setShowDingtalkModal(false)\r\n setError(event.data.message || '钉钉登录失败')\r\n }\r\n }\r\n\r\n window.addEventListener('message', handleMessage)\r\n return () => window.removeEventListener('message', handleMessage)\r\n }, [router])\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">{siteName}</h1>\r\n <p className=\"text-gray-600 mt-2\">欢迎回来,请登录您的账号</p>\r\n </div>\r\n\r\n {/* Login Form */}\r\n <div className=\"card-apple p-8\">\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 邮箱\r\n </label>\r\n <input\r\n type=\"email\"\r\n value={email}\r\n onChange={(e) => setEmail(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"your@email.com\"\r\n required\r\n />\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 密码\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={password}\r\n onChange={(e) => setPassword(e.target.value)}\r\n className=\"input-apple\"\r\n placeholder=\"••••••••\"\r\n required\r\n />\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '登录中...' : '登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6\">\r\n {/* 分隔线 */}\r\n <div className=\"relative my-6\">\r\n <div className=\"absolute inset-0 flex items-center\">\r\n <div className=\"w-full border-t border-gray-300\"></div>\r\n </div>\r\n <div className=\"relative flex justify-center text-sm\">\r\n <span className=\"px-2 bg-white text-gray-500\">或</span>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录按钮 */}\r\n <button\r\n id=\"dingtalk-login-btn\"\r\n type=\"button\"\r\n onClick={handleDingtalkLogin}\r\n 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\"\r\n >\r\n <svg style={{ width: '28px', height: '28px' }} viewBox=\"0 0 1024 1024\">\r\n <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>\r\n </svg>\r\n <span className=\"text-lg font-medium text-gray-700\">钉钉登录</span>\r\n </button>\r\n </div>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <p className=\"text-sm text-gray-600\">\r\n 还没有账号?{' '}\r\n <Link\r\n href=\"/register\"\r\n className=\"text-gray-900 hover:text-gray-700 font-medium underline\"\r\n >\r\n 立即注册\r\n </Link>\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 钉钉登录 iframe 弹窗 */}\r\n {showDingtalkModal && (\r\n <div\r\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50\"\r\n onClick={() => setShowDingtalkModal(false)}\r\n >\r\n <div\r\n className=\"relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4\"\r\n onClick={(e) => e.stopPropagation()}\r\n >\r\n <div className=\"flex items-center justify-between p-4 border-b\">\r\n <h3 className=\"text-lg font-semibold text-gray-900\">钉钉扫码登录</h3>\r\n <button\r\n onClick={() => setShowDingtalkModal(false)}\r\n className=\"text-gray-400 hover:text-gray-600 transition-colors\"\r\n >\r\n <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n </svg>\r\n </button>\r\n </div>\r\n <div className=\"p-4\">\r\n <iframe\r\n id=\"dingtalkIframe\"\r\n className=\"w-full h-96 border-0 rounded-lg\"\r\n title=\"钉钉登录\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -22386,7 +22386,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nPUBLIC_DOMAINS= # 例如: 3d.yydd.ai,portal.example.com\r\nIMAGE_REMOTE_HOSTS= # 例如: s3.company.com,cdn.company.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" + "contentsAfterEdit": "# FLNXI素材库\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nPUBLIC_DOMAINS= # 例如: 3d.yydd.ai,portal.example.com\r\nIMAGE_REMOTE_HOSTS= # 例如: s3.company.com,cdn.company.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" } ``` User decision: **accepted** @@ -22739,7 +22739,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 一键式可迁移配置(推荐)\r\n\r\n不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。\r\n系统在启动时会自动读取该文件,只有当某个变量“未设置”时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。\r\n\r\n适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。\r\n\r\n包含的关键项(全部已带中文注释):\r\n- 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`\r\n- 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`\r\n- 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`\r\n- 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`\r\n- CORS 白名单:`ALLOWED_ORIGINS`\r\n- 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`\r\n\r\n提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nPUBLIC_DOMAINS= # 例如: 3d.yydd.ai,portal.example.com\r\nIMAGE_REMOTE_HOSTS= # 例如: s3.company.com,cdn.company.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" + "contentsAfterEdit": "# FLNXI素材库\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n## 一键式可迁移配置(推荐)\r\n\r\n不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。\r\n系统在启动时会自动读取该文件,只有当某个变量“未设置”时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。\r\n\r\n适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。\r\n\r\n包含的关键项(全部已带中文注释):\r\n- 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`\r\n- 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`\r\n- 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`\r\n- 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`\r\n- CORS 白名单:`ALLOWED_ORIGINS`\r\n- 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`\r\n\r\n提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nPUBLIC_DOMAINS= # 例如: 3d.yydd.ai,portal.example.com\r\nIMAGE_REMOTE_HOSTS= # 例如: s3.company.com,cdn.company.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" } ``` User decision: **accepted** @@ -22821,7 +22821,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n> ⚠️ 强烈建议:可迁移化部署时只需编辑根目录的 `config-bianliang.env`\r\n>\r\n> - 将你的域名、内网地址、图片域名白名单、CORS 白名单、钉钉回调、对象存储等统一填在该文件中\r\n> - 系统启动会自动读取该文件:仅在变量“未设置”时注入,不会覆盖系统环境或 `.env/.env.local`\r\n> - 修改后重启服务生效(重启 Node 或对应容器)\r\n> - 前端可读变量以 `NEXT_PUBLIC_` 开头,服务端变量无需此前缀\r\n\r\n## 一键式可迁移配置(推荐)\r\n\r\n不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。\r\n系统在启动时会自动读取该文件,只有当某个变量“未设置”时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。\r\n\r\n适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。\r\n\r\n包含的关键项(全部已带中文注释):\r\n- 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`\r\n- 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`\r\n- 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`\r\n- 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`\r\n- CORS 白名单:`ALLOWED_ORIGINS`\r\n- 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`\r\n\r\n提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nPUBLIC_DOMAINS= # 例如: 3d.yydd.ai,portal.example.com\r\nIMAGE_REMOTE_HOSTS= # 例如: s3.company.com,cdn.company.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" + "contentsAfterEdit": "# FLNXI素材库\r\n\r\n一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\r\n\r\n> ⚠️ 强烈建议:可迁移化部署时只需编辑根目录的 `config-bianliang.env`\r\n>\r\n> - 将你的域名、内网地址、图片域名白名单、CORS 白名单、钉钉回调、对象存储等统一填在该文件中\r\n> - 系统启动会自动读取该文件:仅在变量“未设置”时注入,不会覆盖系统环境或 `.env/.env.local`\r\n> - 修改后重启服务生效(重启 Node 或对应容器)\r\n> - 前端可读变量以 `NEXT_PUBLIC_` 开头,服务端变量无需此前缀\r\n\r\n## 一键式可迁移配置(推荐)\r\n\r\n不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。\r\n系统在启动时会自动读取该文件,只有当某个变量“未设置”时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。\r\n\r\n适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。\r\n\r\n包含的关键项(全部已带中文注释):\r\n- 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`\r\n- 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`\r\n- 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`\r\n- 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`\r\n- CORS 白名单:`ALLOWED_ORIGINS`\r\n- 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`\r\n\r\n提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。\r\n\r\n## 技术栈\r\n\r\n- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\r\n- **认证**: NextAuth.js\r\n- **数据库**: PostgreSQL + Prisma ORM\r\n- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\r\n\r\n## 功能特性\r\n\r\n- ✅ 用户注册/登录系统\r\n- ✅ 素材上传(预览图 + 压缩包)\r\n- ✅ 素材浏览与下载\r\n- ✅ 管理员后台管理\r\n- ✅ 用户管理\r\n- ✅ 苹果风格UI设计\r\n\r\n## 快速开始\r\n\r\n### 1. 安装依赖\r\n\r\n```bash\r\nnpm install\r\n```\r\n\r\n### 2. 配置环境变量\r\n\r\n复制 `.env.local` 文件并填入你的配置:\r\n\r\n```bash\r\n# Database\r\nDATABASE_URL=your_postgresql_url\r\n\r\n# NextAuth\r\nNEXTAUTH_SECRET=your_secret_key\r\nNEXTAUTH_URL=http://localhost:3000\r\nAPP_PUBLIC_URL=\r\n# 默认 LOGO(后端使用,非必填)\r\nDEFAULT_HEADER_LOGO_URL=\r\nDEFAULT_LOGIN_LOGO_URL=\r\n\r\n# 前端公开配置(可选)\r\nNEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL=\r\nNEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL=\r\nNEXT_PUBLIC_INTRANET_URL=\r\nNEXT_PUBLIC_DINGTALK_CALLBACK_URL=\r\nNEXT_PUBLIC_PORTAL_BASE_URL=\r\n\r\n# 中间件与工具\r\nALLOWED_ORIGINS= # 例如: http://corp.example.com,https://portal.example.com\r\nPUBLIC_DOMAINS= # 例如: 3d.yydd.ai,portal.example.com\r\nIMAGE_REMOTE_HOSTS= # 例如: s3.company.com,cdn.company.com\r\nRESTORE_S3_BUCKET_URL=\r\nRESTORE_S3_PUBLIC_URL=\r\n\r\n# MinIO S3 Storage\r\nS3_ENDPOINT=your_s3_endpoint\r\nS3_BUCKET=your_bucket_name\r\nS3_ACCESS_KEY_ID=your_access_key\r\nS3_SECRET_ACCESS_KEY=your_secret_key\r\nS3_REGION=us-east-1\r\nS3_PUBLIC_DOMAIN=your_public_domain\r\nS3_FILE_PATH=your_file_path/\r\n\r\n# Local storage\r\nUPLOAD_DIR=./uploads\r\n```\r\n\r\n### 3. 初始化数据库\r\n\r\n```bash\r\n# 生成 Prisma Client\r\nnpm run prisma:generate\r\n\r\n# 推送数据库模式\r\nnpm run prisma:push\r\n```\r\n\r\n### 4. 创建管理员账号\r\n\r\n首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\r\n\r\n```sql\r\nUPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\r\n```\r\n\r\n### 5. 启动开发服务器\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\n访问 http://localhost:3000\r\n\r\n## 🚀 部署到服务器\r\n\r\n### 安全部署流程\r\n\r\n**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\r\n\r\n> 可移植生产部署请优先参考:\r\n> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\r\n\r\n**快速部署:**\r\n\r\n1. **本地测试**\r\n ```bash\r\n npm run dev\r\n # 测试所有功能正常\r\n ```\r\n\r\n2. **部署前检查**\r\n ```bash\r\n # Windows\r\n .\\部署前检查清单.bat\r\n ```\r\n\r\n3. **执行部署**\r\n ```bash\r\n # Windows PowerShell\r\n .\\scripts\\deploy\\安全部署.ps1\r\n \r\n # Linux/Mac\r\n bash scripts/deploy/安全部署.sh\r\n ```\r\n\r\n**详细部署指南:**\r\n- [安全部署流程](./docs/guides/安全部署流程.md)\r\n- [安全部署说明](./docs/guides/安全部署说明.md)\r\n- [安全部署指南](./docs/guides/安全部署指南.md)\r\n\r\n**部署检查清单:**\r\n- [部署前检查清单](./部署前检查清单.bat)\r\n\r\n## 项目结构\r\n\r\n```\r\ngongxiangp2/\r\n├── app/ # Next.js App Router\r\n│ ├── api/ # API 路由\r\n│ │ ├── auth/ # 认证相关\r\n│ │ ├── materials/ # 素材相关\r\n│ │ └── admin/ # 管理员相关\r\n│ ├── login/ # 登录页面\r\n│ ├── register/ # 注册页面\r\n│ ├── admin/ # 管理员页面\r\n│ └── page.tsx # 首页\r\n├── components/ # React 组件\r\n│ ├── Header.tsx\r\n│ └── UploadModal.tsx\r\n├── lib/ # 工具库\r\n│ ├── prisma.ts # Prisma 客户端\r\n│ ├── auth.ts # NextAuth 配置\r\n│ └── s3.ts # S3 存储服务\r\n├── prisma/ # Prisma 配置\r\n│ └── schema.prisma # 数据库模式\r\n└── uploads/ # 本地上传文件存储\r\n```\r\n\r\n## 数据库模型\r\n\r\n### User (用户)\r\n- id: UUID\r\n- email: 邮箱\r\n- username: 用户名\r\n- password: 密码 (加密)\r\n- role: 角色 (USER/ADMIN)\r\n\r\n### Material (素材)\r\n- id: UUID\r\n- previewUrl: 预览图URL (S3)\r\n- productModel: 商品型号\r\n- sceneName: 场景名称\r\n- authorName: 作者花名\r\n- zipFileName: 压缩包文件名\r\n- zipFilePath: 压缩包本地路径\r\n- userId: 用户ID (外键)\r\n\r\n## 部署\r\n\r\n### 生产环境构建\r\n\r\n```bash\r\nnpm run build\r\nnpm start\r\n```\r\n\r\n### 环境变量\r\n\r\n确保在生产环境中设置所有必要的环境变量,特别是:\r\n- `NEXTAUTH_SECRET`: 使用强随机字符串\r\n- `NEXTAUTH_URL`: 设置为生产域名\r\n- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\r\n\r\n## License\r\n\r\nMIT\r\n\r\n" } ``` User decision: **accepted** @@ -22922,3 +22922,5 @@ I’m noticing that I have the `docker-compose.yml` and a production variant alo --- + + diff --git a/.specstory/history/2025-11-10_12-00Z-部署脚本偶尔失败的原因.md b/.specstory/history/2025-11-10_12-00Z-部署脚本偶尔失败的原因.md index 3a7477f..ca7cb0d 100644 --- a/.specstory/history/2025-11-10_12-00Z-部署脚本偶尔失败的原因.md +++ b/.specstory/history/2025-11-10_12-00Z-部署脚本偶尔失败的原因.md @@ -35,7 +35,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -58,7 +58,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -178,7 +178,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #20 0.321 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.321 + npm run build #20 0.468 -#20 0.468 > youyidian-3d-material@1.0.0 build +#20 0.468 > flnxi-material@1.0.0 build #20 0.468 > prisma generate && next build #20 0.468 #20 0.960 Prisma schema loaded from prisma/schema.prisma @@ -560,19 +560,19 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #36 exporting config sha256:7586ec52131acb77ba501c8fed3e381881b2d4252c58b883dd4c2cc156c53978 done #36 exporting attestation manifest sha256:df7437fa36f560813da444954d0d9e37a0fe409d5b6d6fd16e7fc71ebc396eab 0.0s done #36 exporting manifest list sha256:d9a1572377d60eb666b22c43b5be2c07353cee48bbffd0e261f18bd351ce7e9f done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.8s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:d9a1572377d60eb666b22c43b5be2c07353cee48bbffd0e261f18bd351ce7e9f", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:d9a1572377d60eb666b22c43b5be2c07353cee48bbffd0e261f18bd351ce7e9f" + "flnxi-material@sha256:d9a1572377d60eb666b22c43b5be2c07353cee48bbffd0e261f18bd351ce7e9f" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -656,11 +656,11 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 084c2f5ff15d: Waiting 39cb179a3fb6: Waiting 2838e4ffb568: Waiting @@ -693,7 +693,7 @@ b5253b02fe9f: Pushed c3343906ca18: Pushed 39cb179a3fb6: Pushed local-deploy: digest: sha256:d9a1572377d60eb666b22c43b5be2c07353cee48bbffd0e261f18bd351ce7e9f size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🚀 每一个 build 都是一次漂亮的起飞。 @@ -705,7 +705,7 @@ local-deploy: digest: sha256:d9a1572377d60eb666b22c43b5be2c07353cee48bbffd0e261f ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -767,14 +767,14 @@ c3343906ca18: Pull complete 4f4fb700ef54: Pull complete e193697d7364: Pull complete Digest: sha256:d9a1572377d60eb666b22c43b5be2c07353cee48bbffd0e261f18bd351ce7e9f -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T09:55:29+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T09:55:29+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-minio Running Container youyidian-postgres Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -785,8 +785,8 @@ time="2025-11-19T09:55:29+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! 🚀 每一个 build 都是一次漂亮的起飞。 @@ -799,14 +799,14 @@ time="2025-11-19T09:55:29+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -1689,7 +1689,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -1712,7 +1712,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -1828,7 +1828,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #20 0.316 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.316 + npm run build #20 0.473 -#20 0.473 > youyidian-3d-material@1.0.0 build +#20 0.473 > flnxi-material@1.0.0 build #20 0.473 > prisma generate && next build #20 0.473 #20 0.992 Prisma schema loaded from prisma/schema.prisma @@ -2210,19 +2210,19 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #36 exporting config sha256:a8519b63bbcc69b693221ec4e10e91c059211f52ea3f813e6bb8faa35c5e43b8 done #36 exporting attestation manifest sha256:b3eebdbe4df4fbfcb621daab620d6255c480e12698db643dbf1837e524d63c2b 0.0s done #36 exporting manifest list sha256:1c08fc60d77a13404618298526f9b2502cb4ab303c908947c0afc144f1bac8b5 done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.7s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:1c08fc60d77a13404618298526f9b2502cb4ab303c908947c0afc144f1bac8b5", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:1c08fc60d77a13404618298526f9b2502cb4ab303c908947c0afc144f1bac8b5" + "flnxi-material@sha256:1c08fc60d77a13404618298526f9b2502cb4ab303c908947c0afc144f1bac8b5" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -2306,11 +2306,11 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] a97ea4521554: Layer already exists 3696426c5aa2: Layer already exists 1fcf98a77588: Layer already exists @@ -2339,7 +2339,7 @@ bae19b1e845d: Pushed 6565521f4a2c: Pushed 1e1ef29a41c3: Pushed local-deploy: digest: sha256:1c08fc60d77a13404618298526f9b2502cb4ab303c908947c0afc144f1bac8b5 size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🧊 如果日志有点长,记得深呼吸,冷静才是王牌技能。 @@ -2351,7 +2351,7 @@ local-deploy: digest: sha256:1c08fc60d77a13404618298526f9b2502cb4ab303c908947c0a ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -2409,14 +2409,14 @@ b31b69b2d3fe: Pull complete 4f4fb700ef54: Pull complete f67cd418a5bb: Pull complete Digest: sha256:1c08fc60d77a13404618298526f9b2502cb4ab303c908947c0afc144f1bac8b5 -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T10:07:24+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T10:07:24+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-postgres Running Container youyidian-minio Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -2427,8 +2427,8 @@ time="2025-11-19T10:07:24+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! 🎯 今日小目标:让 CI/CD 成为最懂你的搭档。 @@ -2441,14 +2441,14 @@ time="2025-11-19T10:07:24+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -2697,7 +2697,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -2720,7 +2720,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -2836,7 +2836,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #20 0.247 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.247 + npm run build #20 0.393 -#20 0.393 > youyidian-3d-material@1.0.0 build +#20 0.393 > flnxi-material@1.0.0 build #20 0.393 > prisma generate && next build #20 0.393 #20 0.848 Prisma schema loaded from prisma/schema.prisma @@ -3218,19 +3218,19 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #36 exporting config sha256:01951945d24da584555bc663422093f7e5a5cab895779f15aad76b3e6fd4eebb done #36 exporting attestation manifest sha256:3aa08a150c931450043cb5b67d30dbac1e79c0a61247bb316acc557859b510e0 0.0s done #36 exporting manifest list sha256:3519bbde733e0c0c0be3953df91dc7aebcecb9b18f81b46107bcbadddac9dee2 done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.6s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:3519bbde733e0c0c0be3953df91dc7aebcecb9b18f81b46107bcbadddac9dee2", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:3519bbde733e0c0c0be3953df91dc7aebcecb9b18f81b46107bcbadddac9dee2" + "flnxi-material@sha256:3519bbde733e0c0c0be3953df91dc7aebcecb9b18f81b46107bcbadddac9dee2" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -3314,11 +3314,11 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 2f1de3431c3a: Layer already exists a97ea4521554: Layer already exists 09e0709e75fb: Layer already exists @@ -3350,7 +3350,7 @@ f55cff36a27c: Pushed 0860407b68ed: Pushed 82ed587ff952: Pushed local-deploy: digest: sha256:3519bbde733e0c0c0be3953df91dc7aebcecb9b18f81b46107bcbadddac9dee2 size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🐱 猫咪提示:部署途中别忘了伸个懒腰。 @@ -3362,7 +3362,7 @@ local-deploy: digest: sha256:3519bbde733e0c0c0be3953df91dc7aebcecb9b18f81b46107b ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -3422,14 +3422,14 @@ ecfc36d1b9e2: Pull complete 4f4fb700ef54: Pull complete 97102e0f32ae: Pull complete Digest: sha256:3519bbde733e0c0c0be3953df91dc7aebcecb9b18f81b46107bcbadddac9dee2 -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T10:25:53+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T10:25:53+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-minio Running Container youyidian-postgres Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -3440,8 +3440,8 @@ time="2025-11-19T10:25:53+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! 🍀 小彩蛋:部署顺利指数 +1%,好运常伴你。 @@ -3454,14 +3454,14 @@ time="2025-11-19T10:25:53+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -3769,7 +3769,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -3792,7 +3792,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -3908,7 +3908,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #20 0.283 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.283 + npm run build #20 0.430 -#20 0.430 > youyidian-3d-material@1.0.0 build +#20 0.430 > flnxi-material@1.0.0 build #20 0.430 > prisma generate && next build #20 0.430 #20 0.911 Prisma schema loaded from prisma/schema.prisma @@ -4290,19 +4290,19 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #36 exporting config sha256:1e956750be6d8ee7e7224c4ba8565260884c630e33f667e70f76959bba4ae921 0.0s done #36 exporting attestation manifest sha256:7f5a183e0cfe5a43cffe357c02613951e151ad77de57b54d4963c350fb09bd84 0.0s done #36 exporting manifest list sha256:f0c4e9bc82f1d693c107806332760d14632bd2e12309819f320304685b7fbf8e 0.0s done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.7s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:f0c4e9bc82f1d693c107806332760d14632bd2e12309819f320304685b7fbf8e", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:f0c4e9bc82f1d693c107806332760d14632bd2e12309819f320304685b7fbf8e" + "flnxi-material@sha256:f0c4e9bc82f1d693c107806332760d14632bd2e12309819f320304685b7fbf8e" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -4386,11 +4386,11 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] ad2e5e7e295b: Waiting 1fcf98a77588: Layer already exists cdccf44a82db: Layer already exists @@ -4420,7 +4420,7 @@ e6314c85c3c5: Pushed ad2e5e7e295b: Pushed 4582f5c141f0: Pushed local-deploy: digest: sha256:f0c4e9bc82f1d693c107806332760d14632bd2e12309819f320304685b7fbf8e size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🧊 如果日志有点长,记得深呼吸,冷静才是王牌技能。 @@ -4432,7 +4432,7 @@ local-deploy: digest: sha256:f0c4e9bc82f1d693c107806332760d14632bd2e12309819f320 ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -4494,14 +4494,14 @@ be7fd8b77349: Pull complete 4f4fb700ef54: Pull complete c09f892143c5: Pull complete Digest: sha256:f0c4e9bc82f1d693c107806332760d14632bd2e12309819f320304685b7fbf8e -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T10:41:11+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T10:41:11+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-minio Running Container youyidian-postgres Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -4512,8 +4512,8 @@ time="2025-11-19T10:41:11+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! 🛰️ 数据正在轨道中穿梭,耐心是宇航员的必备素质。 @@ -4526,14 +4526,14 @@ time="2025-11-19T10:41:11+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -4802,7 +4802,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -4825,7 +4825,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -4941,7 +4941,7 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #20 0.277 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.277 + npm run build #20 0.424 -#20 0.424 > youyidian-3d-material@1.0.0 build +#20 0.424 > flnxi-material@1.0.0 build #20 0.424 > prisma generate && next build #20 0.424 #20 0.943 Prisma schema loaded from prisma/schema.prisma @@ -5323,19 +5323,19 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER #36 exporting config sha256:46f0de827b4550af169dfb4864b088b990b5ad1bcb71368250e83049597a36f4 done #36 exporting attestation manifest sha256:72e6f0ea08df8434d2bde53494f6f8dc95e5cbaf7d422d3c812df3577ee8d2c2 0.0s done #36 exporting manifest list sha256:5266f3a1a81ce15c282d09baf3c14fb4335df7af7f8dfe103c81c1ddfe5e5d27 done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.5s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.5s done #36 DONE 3.9s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:5266f3a1a81ce15c282d09baf3c14fb4335df7af7f8dfe103c81c1ddfe5e5d27", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:5266f3a1a81ce15c282d09baf3c14fb4335df7af7f8dfe103c81c1ddfe5e5d27" + "flnxi-material@sha256:5266f3a1a81ce15c282d09baf3c14fb4335df7af7f8dfe103c81c1ddfe5e5d27" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -5419,11 +5419,11 @@ cd D:\sucaiku\flnxi_sucai; pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USER ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 09e0709e75fb: Layer already exists 3696426c5aa2: Layer already exists f7710be2e1d3: Waiting @@ -5454,7 +5454,7 @@ fd8bd28d1139: Pushed a6e6706527e3: Pushed 9a50b9454a39: Pushed local-deploy: digest: sha256:5266f3a1a81ce15c282d09baf3c14fb4335df7af7f8dfe103c81c1ddfe5e5d27 size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🐱 猫咪提示:部署途中别忘了伸个懒腰。 @@ -5466,7 +5466,7 @@ local-deploy: digest: sha256:5266f3a1a81ce15c282d09baf3c14fb4335df7af7f8dfe103c8 ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -5525,14 +5525,14 @@ cd594d4e7a50: Pull complete 4f4fb700ef54: Pull complete 1cd0162c530b: Pull complete Digest: sha256:5266f3a1a81ce15c282d09baf3c14fb4335df7af7f8dfe103c81c1ddfe5e5d27 -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T10:48:46+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T10:48:47+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-postgres Running Container youyidian-minio Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -5543,8 +5543,8 @@ time="2025-11-19T10:48:47+08:00" level=warning msg="Found orphan Container youyidian-minio Healthy Container youyidian-postgres Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! 🧊 如果日志有点长,记得深呼吸,冷静才是王牌技能。 @@ -5557,14 +5557,14 @@ time="2025-11-19T10:48:47+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -6346,7 +6346,7 @@ pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USERPROFILE\.ssh\youyidian_prod ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -6369,7 +6369,7 @@ pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USERPROFILE\.ssh\youyidian_prod ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -6494,7 +6494,7 @@ pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USERPROFILE\.ssh\youyidian_prod #20 0.281 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.281 + npm run build #20 0.416 -#20 0.416 > youyidian-3d-material@1.0.0 build +#20 0.416 > flnxi-material@1.0.0 build #20 0.416 > prisma generate && next build #20 0.416 #20 0.921 Prisma schema loaded from prisma/schema.prisma @@ -6876,19 +6876,19 @@ pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USERPROFILE\.ssh\youyidian_prod #36 exporting config sha256:3bff50fb66cfdf9061ae76e3554e4bf6c8d5ccc819d225f5c54bf5682052b94d done #36 exporting attestation manifest sha256:39977bd410e7ae33fb660eb7b69d8150a93161a6692ad6b20596557ed90a724a 0.0s done #36 exporting manifest list sha256:674eaf3113b7908b7d0371393ee41ab59cc52a67659ea0816deb5372093fd153 done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.7s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:674eaf3113b7908b7d0371393ee41ab59cc52a67659ea0816deb5372093fd153", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:674eaf3113b7908b7d0371393ee41ab59cc52a67659ea0816deb5372093fd153" + "flnxi-material@sha256:674eaf3113b7908b7d0371393ee41ab59cc52a67659ea0816deb5372093fd153" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -6972,11 +6972,11 @@ pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:USERPROFILE\.ssh\youyidian_prod ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 1fcf98a77588: Layer already exists cdccf44a82db: Layer already exists 09e0709e75fb: Layer already exists @@ -7007,7 +7007,7 @@ e83a63fcec99: Pushed c99620ff6cfe: Pushed 9f3a1406c157: Pushed local-deploy: digest: sha256:674eaf3113b7908b7d0371393ee41ab59cc52a67659ea0816deb5372093fd153 size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🎵 背景 BGM 同步播放中,部署节奏稳稳的。 @@ -7019,7 +7019,7 @@ local-deploy: digest: sha256:674eaf3113b7908b7d0371393ee41ab59cc52a67659ea0816de ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -7076,14 +7076,14 @@ b6bf932cca3b: Pull complete 4f4fb700ef54: Pull complete c65e80d130fb: Pull complete Digest: sha256:674eaf3113b7908b7d0371393ee41ab59cc52a67659ea0816deb5372093fd153 -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T11:00:16+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T11:00:16+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-minio Running Container youyidian-postgres Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -7094,8 +7094,8 @@ time="2025-11-19T11:00:16+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! ☕ 记得喝口咖啡,让部署的快乐多续一会~ @@ -7108,14 +7108,14 @@ time="2025-11-19T11:00:16+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -7741,7 +7741,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -7764,7 +7764,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -7885,7 +7885,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #20 0.273 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.273 + npm run build #20 0.413 -#20 0.413 > youyidian-3d-material@1.0.0 build +#20 0.413 > flnxi-material@1.0.0 build #20 0.413 > prisma generate && next build #20 0.413 #20 0.883 Prisma schema loaded from prisma/schema.prisma @@ -8267,19 +8267,19 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #36 exporting config sha256:89af5e0b29f966e19f8c52a4acb09739a1eb1dac2e318a5da1027123e3ac19bb done #36 exporting attestation manifest sha256:e2c7ec7c8f3293f51a01590ee1600b3b64e4fd4e5311a30f786563a9d13a2246 0.0s done #36 exporting manifest list sha256:9f1924d3a515760dbea1798788756a26a006c91074faf5269436ee2b8bdd15d6 done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.5s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:9f1924d3a515760dbea1798788756a26a006c91074faf5269436ee2b8bdd15d6", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:9f1924d3a515760dbea1798788756a26a006c91074faf5269436ee2b8bdd15d6" + "flnxi-material@sha256:9f1924d3a515760dbea1798788756a26a006c91074faf5269436ee2b8bdd15d6" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -8363,11 +8363,11 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] cdccf44a82db: Layer already exists 09e0709e75fb: Layer already exists 97670a7c41e0: Waiting @@ -8393,7 +8393,7 @@ f5e3986b5f5e: Pushed 8f525a56e344: Pushed 45d6cdb4010e: Pushed local-deploy: digest: sha256:9f1924d3a515760dbea1798788756a26a006c91074faf5269436ee2b8bdd15d6 size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 📦 镜像像快递一样打包上路,收件人:Production。 @@ -8405,7 +8405,7 @@ local-deploy: digest: sha256:9f1924d3a515760dbea1798788756a26a006c91074faf526943 ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -8465,14 +8465,14 @@ f5e3986b5f5e: Pull complete 4f4fb700ef54: Pull complete 0223324f7552: Pull complete Digest: sha256:9f1924d3a515760dbea1798788756a26a006c91074faf5269436ee2b8bdd15d6 -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T11:04:59+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T11:04:59+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-minio Running Container youyidian-postgres Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -8488,8 +8488,8 @@ time="2025-11-19T11:04:59+08:00" level=warning msg="Found orphan ℹ 开始对线上应用做一次健康检查... ℹ 开始健康检查: http://45.249.208.118:4320/api/health Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 健康检查通过,应用已就绪 (0s) ✨ 健康彩蛋: 🎯 今日小目标:让 CI/CD 成为最懂你的搭档。 @@ -8497,14 +8497,14 @@ time="2025-11-19T11:04:59+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -9025,7 +9025,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -9048,7 +9048,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -9164,7 +9164,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #20 0.245 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.245 + npm run build #20 0.383 -#20 0.383 > youyidian-3d-material@1.0.0 build +#20 0.383 > flnxi-material@1.0.0 build #20 0.383 > prisma generate && next build #20 0.383 #20 0.876 Prisma schema loaded from prisma/schema.prisma @@ -9546,19 +9546,19 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #36 exporting config sha256:d4590581b81fe83c839218f6f952bb66087ce89efe62049d89a11d7f0bcedffc done #36 exporting attestation manifest sha256:09aea26c213c29f0e61aa6ef4c90608736de40c0fc3380b3c4a29be4a33a1f18 0.0s done #36 exporting manifest list sha256:db14c2fc37025fac866b272049deb12bc2abf9d4e4136e08f8b49e5c5d55d627 done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.7s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:db14c2fc37025fac866b272049deb12bc2abf9d4e4136e08f8b49e5c5d55d627", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:db14c2fc37025fac866b272049deb12bc2abf9d4e4136e08f8b49e5c5d55d627" + "flnxi-material@sha256:db14c2fc37025fac866b272049deb12bc2abf9d4e4136e08f8b49e5c5d55d627" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -9642,11 +9642,11 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] b8c290b8f306: Waiting 9e4c595a940b: Waiting cdccf44a82db: Layer already exists @@ -9673,7 +9673,7 @@ b8c290b8f306: Pushed cc5d56ca247b: Pushed 85e2d1db86f1: Pushed local-deploy: digest: sha256:db14c2fc37025fac866b272049deb12bc2abf9d4e4136e08f8b49e5c5d55d627 size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 📦 镜像像快递一样打包上路,收件人:Production。 @@ -9685,7 +9685,7 @@ local-deploy: digest: sha256:db14c2fc37025fac866b272049deb12bc2abf9d4e4136e08f8b ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -9744,14 +9744,14 @@ b8c290b8f306: Pull complete 4f4fb700ef54: Pull complete f77b4f8ed555: Pull complete Digest: sha256:db14c2fc37025fac866b272049deb12bc2abf9d4e4136e08f8b49e5c5d55d627 -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T11:19:29+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T11:19:29+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-postgres Running Container youyidian-minio Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -9767,8 +9767,8 @@ time="2025-11-19T11:19:29+08:00" level=warning msg="Found orphan ℹ 开始对线上应用做一次健康检查... ℹ 开始健康检查: http://45.249.208.118:4320/api/health Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 健康检查通过,应用已就绪 (0s) ✨ 健康彩蛋: 🎵 背景 BGM 同步播放中,部署节奏稳稳的。 @@ -9776,14 +9776,14 @@ time="2025-11-19T11:19:29+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -10184,7 +10184,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -10207,7 +10207,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -10323,7 +10323,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #20 0.298 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.298 + npm run build #20 0.437 -#20 0.437 > youyidian-3d-material@1.0.0 build +#20 0.437 > flnxi-material@1.0.0 build #20 0.437 > prisma generate && next build #20 0.437 #20 0.912 Prisma schema loaded from prisma/schema.prisma @@ -10705,19 +10705,19 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #36 exporting config sha256:00b7c5c164493d2a4193fab251bfe33c764ebbd86799ccfa7ed08c699f225ad4 done #36 exporting attestation manifest sha256:6f9622472f62a821d6eebfceffca032e25c0b646c2f5e12fc9c91329c0d6956f 0.0s done #36 exporting manifest list sha256:a059c0c4d1c9b26238e3512cbc26f58c1647fb3a2dd9f6150a0fc321adcbf10c done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 3.6s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:a059c0c4d1c9b26238e3512cbc26f58c1647fb3a2dd9f6150a0fc321adcbf10c", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:a059c0c4d1c9b26238e3512cbc26f58c1647fb3a2dd9f6150a0fc321adcbf10c" + "flnxi-material@sha256:a059c0c4d1c9b26238e3512cbc26f58c1647fb3a2dd9f6150a0fc321adcbf10c" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -10801,11 +10801,11 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 6c0420b3b35a: Waiting cc860332befe: Waiting 4f4fb700ef54: Layer already exists @@ -10834,7 +10834,7 @@ af0fba9634fc: Pushed cc860332befe: Pushed 6f319426ad84: Pushed local-deploy: digest: sha256:a059c0c4d1c9b26238e3512cbc26f58c1647fb3a2dd9f6150a0fc321adcbf10c size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🐱 猫咪提示:部署途中别忘了伸个懒腰。 @@ -10846,7 +10846,7 @@ local-deploy: digest: sha256:a059c0c4d1c9b26238e3512cbc26f58c1647fb3a2dd9f6150a0 ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -10906,14 +10906,14 @@ af0fba9634fc: Pull complete 4f4fb700ef54: Pull complete 9463cb66fceb: Pull complete Digest: sha256:a059c0c4d1c9b26238e3512cbc26f58c1647fb3a2dd9f6150a0fc321adcbf10c -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T11:29:57+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T11:29:57+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-minio Running Container youyidian-postgres Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -10924,8 +10924,8 @@ time="2025-11-19T11:29:57+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! 🛰️ 数据正在轨道中穿梭,耐心是宇航员的必备素质。 @@ -10938,14 +10938,14 @@ time="2025-11-19T11:29:57+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -11690,7 +11690,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -11713,7 +11713,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -11832,7 +11832,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #20 0.303 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.303 + npm run build #20 0.461 -#20 0.461 > youyidian-3d-material@1.0.0 build +#20 0.461 > flnxi-material@1.0.0 build #20 0.461 > prisma generate && next build #20 0.461 #20 0.965 Prisma schema loaded from prisma/schema.prisma @@ -12214,19 +12214,19 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #36 exporting config sha256:7dd28e5d3599259d8f9e2f601ff7c127a56397bb57ee97f1316350f759de9859 0.0s done #36 exporting attestation manifest sha256:75f3d4d699559a06dc73e03eea4b2c7cd83aad4c5c7b3318d2c3694ebcb4b78b 0.0s done #36 exporting manifest list sha256:938806d3a4443f7ffa25197e4e882e2054d2eab202103dedd2ebcfa326add48e done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.5s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.5s done #36 DONE 7.2s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:938806d3a4443f7ffa25197e4e882e2054d2eab202103dedd2ebcfa326add48e", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:938806d3a4443f7ffa25197e4e882e2054d2eab202103dedd2ebcfa326add48e" + "flnxi-material@sha256:938806d3a4443f7ffa25197e4e882e2054d2eab202103dedd2ebcfa326add48e" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -12310,11 +12310,11 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 6fe9ebf4ef30: Waiting c38ddae522e5: Waiting feeeabcd47f8: Waiting @@ -12347,7 +12347,7 @@ da64a8e470a1: Pushed 1806e07f62b9: Pushed 8406cbb0832f: Pushed local-deploy: digest: sha256:938806d3a4443f7ffa25197e4e882e2054d2eab202103dedd2ebcfa326add48e size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🧊 如果日志有点长,记得深呼吸,冷静才是王牌技能。 @@ -12359,7 +12359,7 @@ local-deploy: digest: sha256:938806d3a4443f7ffa25197e4e882e2054d2eab202103dedd2e ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -12418,14 +12418,14 @@ da64a8e470a1: Pull complete 4f4fb700ef54: Pull complete 6e5ab5c12807: Pull complete Digest: sha256:938806d3a4443f7ffa25197e4e882e2054d2eab202103dedd2ebcfa326add48e -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T11:35:25+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T11:35:25+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-minio Running Container youyidian-postgres Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -12447,18 +12447,18 @@ time="2025-11-19T11:35:25+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ```
@@ -13023,7 +13023,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -13046,7 +13046,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -13167,7 +13167,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #20 0.319 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.319 + npm run build #20 0.463 -#20 0.463 > youyidian-3d-material@1.0.0 build +#20 0.463 > flnxi-material@1.0.0 build #20 0.463 > prisma generate && next build #20 0.463 #20 0.952 Prisma schema loaded from prisma/schema.prisma @@ -13549,19 +13549,19 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US #36 exporting config sha256:448e1192b3d6f1c32e78aa68bdc170a6cc4842b28c540d9009afcd6e6ee757a1 done #36 exporting attestation manifest sha256:bfc35fafa6e37170a7565c12eef8118848956c5621173a1e231eb7f47fee136f 0.0s done #36 exporting manifest list sha256:ae581aeedd73b6725b7079a5939d728b12656dc35569116cb85c77924e51e6e8 done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.4s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.4s done #36 DONE 7.1s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:ae581aeedd73b6725b7079a5939d728b12656dc35569116cb85c77924e51e6e8", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:ae581aeedd73b6725b7079a5939d728b12656dc35569116cb85c77924e51e6e8" + "flnxi-material@sha256:ae581aeedd73b6725b7079a5939d728b12656dc35569116cb85c77924e51e6e8" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -13645,11 +13645,11 @@ cd D:\sucaiku\flnxi_sucai && pwsh -File .\deploy-new-fixed.ps1 -KeyPath "$env:US ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 3696426c5aa2: Layer already exists 2f1de3431c3a: Layer already exists f985d0f0007b: Waiting @@ -13681,7 +13681,7 @@ f985d0f0007b: Pushed aa3771dd2cf3: Pushed 9b75a369b9d2: Pushed local-deploy: digest: sha256:ae581aeedd73b6725b7079a5939d728b12656dc35569116cb85c77924e51e6e8 size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🚀 每一个 build 都是一次漂亮的起飞。 @@ -13693,7 +13693,7 @@ local-deploy: digest: sha256:ae581aeedd73b6725b7079a5939d728b12656dc35569116cb85 ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -13752,14 +13752,14 @@ f985d0f0007b: Pull complete 4f4fb700ef54: Pull complete 3a0a904ba006: Pull complete Digest: sha256:ae581aeedd73b6725b7079a5939d728b12656dc35569116cb85c77924e51e6e8 -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T11:44:52+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T11:44:52+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-postgres Running Container youyidian-minio Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -13781,18 +13781,18 @@ time="2025-11-19T11:44:52+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ```
@@ -17148,7 +17148,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File "D:\sucaiku\flnx ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -17171,7 +17171,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File "D:\sucaiku\flnx ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -17287,7 +17287,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File "D:\sucaiku\flnx #20 0.325 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.325 + npm run build #20 0.468 -#20 0.468 > youyidian-3d-material@1.0.0 build +#20 0.468 > flnxi-material@1.0.0 build #20 0.468 > prisma generate && next build #20 0.468 #20 1.015 Prisma schema loaded from prisma/schema.prisma @@ -17669,19 +17669,19 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File "D:\sucaiku\flnx #36 exporting config sha256:c7377a21898e2274bd045be06b22532a623a1297b4876c634c651ba1005fb7ac 0.0s done #36 exporting attestation manifest sha256:dda2d1ed046e57942f38b1749802f3d48e8006ff0f1e341807c217ec234c57b3 0.0s done #36 exporting manifest list sha256:4b00447a32e7bf5e2af42807f4d8178c1e1fed72a5620e090f705c985762b53f done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.5s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.5s done #36 DONE 7.2s -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:4b00447a32e7bf5e2af42807f4d8178c1e1fed72a5620e090f705c985762b53f", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:4b00447a32e7bf5e2af42807f4d8178c1e1fed72a5620e090f705c985762b53f" + "flnxi-material@sha256:4b00447a32e7bf5e2af42807f4d8178c1e1fed72a5620e090f705c985762b53f" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -17765,11 +17765,11 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File "D:\sucaiku\flnx ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] 3696426c5aa2: Layer already exists 21f39b9ddd19: Layer already exists 79aad81e1cb7: Waiting @@ -17802,7 +17802,7 @@ a314a69092e8: Pushed 564bf9defaec: Pushed 52125fec2523: Pushed local-deploy: digest: sha256:4b00447a32e7bf5e2af42807f4d8178c1e1fed72a5620e090f705c985762b53f size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! 🎯 今日小目标:让 CI/CD 成为最懂你的搭档。 @@ -17814,7 +17814,7 @@ local-deploy: digest: sha256:4b00447a32e7bf5e2af42807f4d8178c1e1fed72a5620e090f7 ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -17875,14 +17875,14 @@ f62f067432bd: Pull complete 4f4fb700ef54: Pull complete f0d0c6ff6ec7: Pull complete Digest: sha256:4b00447a32e7bf5e2af42807f4d8178c1e1fed72a5620e090f705c985762b53f -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T13:13:08+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T13:13:08+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-postgres Running Container youyidian-minio Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -17893,8 +17893,8 @@ time="2025-11-19T13:13:08+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! 🎵 背景 BGM 同步播放中,部署节奏稳稳的。 @@ -17907,14 +17907,14 @@ time="2025-11-19T13:13:08+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -17932,7 +17932,7 @@ time="2025-11-19T13:13:08+08:00" level=warning msg="Found orphan ## 部署摘要 1. **构建阶段**:Docker 镜像构建成功 -2. **推送阶段**:镜像已推送到私有仓库 `45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy` +2. **推送阶段**:镜像已推送到私有仓库 `45.249.208.118:5000/youyidian/flnxi-material:local-deploy` 3. **远程部署**:服务器已拉取并部署新镜像 4. **健康检查**:应用已就绪,健康检查通过 @@ -20673,7 +20673,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -20696,7 +20696,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -20812,7 +20812,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi #20 0.291 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.291 + npm run build #20 0.467 -#20 0.467 > youyidian-3d-material@1.0.0 build +#20 0.467 > flnxi-material@1.0.0 build #20 0.467 > prisma generate && next build #20 0.467 #20 1.063 Prisma schema loaded from prisma/schema.prisma @@ -20971,7 +20971,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -20994,7 +20994,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -21113,7 +21113,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi #20 0.300 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.300 + npm run build #20 0.466 -#20 0.466 > youyidian-3d-material@1.0.0 build +#20 0.466 > flnxi-material@1.0.0 build #20 0.466 > prisma generate && next build #20 0.466 #20 1.036 Prisma schema loaded from prisma/schema.prisma @@ -21280,7 +21280,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -21303,7 +21303,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -21419,7 +21419,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi #20 0.358 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.358 + npm run build #20 0.521 -#20 0.521 > youyidian-3d-material@1.0.0 build +#20 0.521 > flnxi-material@1.0.0 build #20 0.521 > prisma generate && next build #20 0.521 #20 1.149 Prisma schema loaded from prisma/schema.prisma @@ -21636,7 +21636,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 部署配置: ℹ 服务器: 45.249.208.118 ℹ 用户名: root -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 跳过检查: False ℹ 跳过构建: False ℹ 跳过上传: False @@ -21659,7 +21659,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 🧱 正在把代码揉成可部署的镜像 ===> 构建阶段 ℹ 检测到现有 .env.production,直接使用 -ℹ 开始Docker构建: youyidian-3d-material:local-deploy +ℹ 开始Docker构建: flnxi-material:local-deploy #0 building with "desktop-linux" instance using docker driver #1 [internal] load build definition from Dockerfile @@ -21775,7 +21775,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi #20 0.352 + export 'PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x' #20 0.352 + npm run build #20 0.527 -#20 0.527 > youyidian-3d-material@1.0.0 build +#20 0.527 > flnxi-material@1.0.0 build #20 0.527 > prisma generate && next build #20 0.527 #20 1.143 Prisma schema loaded from prisma/schema.prisma @@ -22152,15 +22152,15 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi #36 exporting to image #36 exporting layers -✓ Docker构建成功: youyidian-3d-material:local-deploy +✓ Docker构建成功: flnxi-material:local-deploy [ { "Id": "sha256:807953d62f505f773adff916deaa95816a6411a8f591129351ea2e3d88f13c5e", "RepoTags": [ - "youyidian-3d-material:local-deploy" + "flnxi-material:local-deploy" ], "RepoDigests": [ - "youyidian-3d-material@sha256:807953d62f505f773adff916deaa95816a6411a8f591129351ea2e3d88f13c5e" + "flnxi-material@sha256:807953d62f505f773adff916deaa95816a6411a8f591129351ea2e3d88f13c5e" ], "Parent": "", "Comment": "buildkit.dockerfile.v0", @@ -22244,7 +22244,7 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi ℹ 📦 把镜像打包寄到私有仓库 ===> 推送镜像到 Docker Registry ℹ Registry 地址: 45.249.208.118:5000 -ℹ 目标镜像: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 目标镜像: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 标记镜像... ✓ 镜像标记成功 ℹ 正在推送镜像到 Registry... @@ -22253,11 +22253,11 @@ cd D:\sucaiku\flnxi_sucai && pwsh -ExecutionPolicy Bypass -File ".\deploy-new-fi #36 exporting config sha256:cd4f2b1a8712c5d8d62edfcbd10c4cf06a1688e33a252db81f942983de8870bc 0.0s done #36 exporting attestation manifest sha256:efced91ae3dce3655d9b835f3c6d1826c11826ee6a05fb23a9b7d15fd9567b39 0.0s done #36 exporting manifest list sha256:807953d62f505f773adff916deaa95816a6411a8f591129351ea2e3d88f13c5e done -#36 naming to docker.io/library/youyidian-3d-material:local-deploy done -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy -#36 unpacking to docker.io/library/youyidian-3d-material:local-deploy 1.7s done +#36 naming to docker.io/library/flnxi-material:local-deploy done +#36 unpacking to docker.io/library/flnxi-material:local-deploy +#36 unpacking to docker.io/library/flnxi-material:local-deploy 1.7s done #36 DONE 4.3s -The push refers to repository [45.249.208.118:5000/youyidian/youyidian-3d-material] +The push refers to repository [45.249.208.118:5000/youyidian/flnxi-material] d8b03ee17fcc: Waiting 43c4264eed91: Waiting 64316f917af8: Waiting @@ -22294,7 +22294,7 @@ d8b03ee17fcc: Pushed 50c3e3375c09: Pushed 3790b5c46790: Pushed local-deploy: digest: sha256:807953d62f505f773adff916deaa95816a6411a8f591129351ea2e3d88f13c5e size: 856 -✓ 镜像推送成功: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +✓ 镜像推送成功: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy 🎉 镜像推送阶段完成! ☕ 记得喝口咖啡,让部署的快乐多续一会~ @@ -22306,7 +22306,7 @@ local-deploy: digest: sha256:807953d62f505f773adff916deaa95816a6411a8f591129351e ✓ SSH连接测试成功 ℹ 在服务器上拉取镜像并部署... ℹ 使用 OpenSSH 执行远程部署命令 -local-deploy: Pulling from youyidian/youyidian-3d-material +local-deploy: Pulling from youyidian/flnxi-material 43c4264eed91: Already exists 3696426c5aa2: Already exists cdccf44a82db: Already exists @@ -22367,14 +22367,14 @@ db88481739b2: Pull complete 4f4fb700ef54: Pull complete 72c9aabd7d57: Pull complete Digest: sha256:807953d62f505f773adff916deaa95816a6411a8f591129351ea2e3d88f13c5e -Status: Downloaded newer image for 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +Status: Downloaded newer image for 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +45.249.208.118:5000/youyidian/flnxi-material:local-deploy time="2025-11-19T17:09:13+08:00" level=warning msg="/root/deploy/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" time="2025-11-19T17:09:13+08:00" level=warning msg="Found orphan containers ([docker-registry docker-registry-ui]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." Container youyidian-postgres Running Container youyidian-minio Running - Container youyidian-3d-material Recreate - Container youyidian-3d-material Recreated + Container flnxi-material Recreate + Container flnxi-material Recreated Container youyidian-minio Waiting Container youyidian-minio Healthy Container youyidian-minio-init Starting @@ -22385,8 +22385,8 @@ time="2025-11-19T17:09:13+08:00" level=warning msg="Found orphan Container youyidian-postgres Healthy Container youyidian-minio Healthy Container youyidian-minio-init Exited - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Starting + Container flnxi-material Started ✓ 部署成功 🎉 远程部署阶段完成! ☕ 记得喝口咖啡,让部署的快乐多续一会~ @@ -22399,14 +22399,14 @@ time="2025-11-19T17:09:13+08:00" level=warning msg="Found orphan 部署完成! ======================================== ✓ 所有阶段执行成功 -ℹ 镜像: youyidian-3d-material:local-deploy +ℹ 镜像: flnxi-material:local-deploy ℹ 服务器: root@45.249.208.118 ℹ 远程路径: /root/deploy -ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy +ℹ 镜像已推送到 Registry: 45.249.208.118:5000/youyidian/flnxi-material:local-deploy ℹ 如果自动部署失败,请在服务器上手动执行: ℹ cd /root/deploy -ℹ docker pull 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy -ℹ docker tag 45.249.208.118:5000/youyidian/youyidian-3d-material:local-deploy youyidian-3d-material:local-deploy +ℹ docker pull 45.249.208.118:5000/youyidian/flnxi-material:local-deploy +ℹ docker tag 45.249.208.118:5000/youyidian/flnxi-material:local-deploy flnxi-material:local-deploy ℹ docker-compose up -d ``` @@ -22878,7 +22878,7 @@ Result: "diff": { "chunks": [ { - "diffString": " \r\n useEffect(() => {\r\n+ const defaultLogo = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n+ const defaultSiteName = '由壹点3D素材'\r\n+ \r\n // 加载系统设置\r\n const loadSettings = async () => {\r", + "diffString": " \r\n useEffect(() => {\r\n+ const defaultLogo = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n+ const defaultSiteName = 'FLNXI素材库'\r\n+ \r\n // 加载系统设置\r\n const loadSettings = async () => {\r", "oldStart": 27, "newStart": 27, "oldLines": 4, @@ -22899,7 +22899,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/complete-profile/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState, Suspense } from 'react'\r\nimport { useRouter, useSearchParams } from 'next/navigation'\r\nimport { signIn } from 'next-auth/react'\r\nimport Image from 'next/image'\r\n\r\ninterface Department {\r\n id: string\r\n name: string\r\n}\r\n\r\nfunction CompleteProfileContent() {\r\n const router = useRouter()\r\n const searchParams = useSearchParams()\r\n \r\n const [department, setDepartment] = useState('')\r\n const [departments, setDepartments] = useState<Department[]>([])\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [success, setSuccess] = useState('')\r\n const [userInfo, setUserInfo] = useState<any>(null)\r\n\r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n\r\n useEffect(() => {\r\n const defaultLogo = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n const defaultSiteName = '由壹点3D素材'\r\n\r\n // 加载系统设置\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || defaultLogo)\r\n setSiteName(settings.siteName || defaultSiteName)\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n\r\n // 加载部门列表\r\n const loadDepartments = async () => {\r\n try {\r\n const response = await fetch('/api/departments')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setDepartments(data || [])\r\n }\r\n } catch (error) {\r\n console.error('加载部门列表失败:', error)\r\n }\r\n }\r\n\r\n // 从URL参数获取用户信息\r\n const userInfoParam = searchParams.get('userInfo')\r\n if (userInfoParam) {\r\n try {\r\n const info = JSON.parse(decodeURIComponent(userInfoParam))\r\n setUserInfo(info)\r\n } catch (error) {\r\n console.error('解析用户信息失败:', error)\r\n setError('无效的用户信息,请重新登录')\r\n }\r\n } else {\r\n setError('缺少用户信息,请重新登录')\r\n }\r\n\r\n loadSettings()\r\n loadDepartments()\r\n }, [searchParams])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n\r\n if (!department) {\r\n setError('请选择部门')\r\n return\r\n }\r\n\r\n setLoading(true)\r\n setError('') // 清空错误信息\r\n setSuccess('') // 清空成功信息\r\n\r\n try {\r\n // 调用API完善用户信息(只需要选择部门)\r\n const response = await fetch('/api/dingtalk/complete-profile', {\r\n method: 'POST',\r\n headers: { 'Content-Type': 'application/json' },\r\n body: JSON.stringify({\r\n userId: userInfo?.id,\r\n department,\r\n }),\r\n })\r\n\r\n const data = await response.json()\r\n\r\n if (!response.ok || !data.success) {\r\n throw new Error(data.message || '信息提交失败')\r\n }\r\n\r\n // 如果是账号关联(关联到已有账号),提示用户使用原账号密码登录\r\n if (data.isAccountLink) {\r\n setSuccess('账号关联成功!正在跳转到登录页面,您可以使用原账号密码登录或继续使用钉钉扫码登录。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 检查新用户是否已激活\r\n if (data.user && !data.user.isActive) {\r\n setSuccess('信息提交成功!您的账号需要管理员激活后才能登录使用。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 已激活用户:使用 NextAuth 自动登录\r\n const result = await signIn('credentials', {\r\n email: data.user.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n throw new Error('登录失败')\r\n }\r\n\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = window.location.origin\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error: any) {\r\n console.error('提交失败:', error)\r\n setError(error.message || '提交失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n if (error && !userInfo) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full bg-white rounded-2xl shadow-xl p-8\">\r\n <div className=\"text-center\">\r\n <div className=\"mb-4\">\r\n <svg className=\"mx-auto h-12 w-12 text-red-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <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\" />\r\n </svg>\r\n </div>\r\n <h2 className=\"text-xl font-semibold mb-2 text-red-600\">出错了</h2>\r\n <p className=\"text-gray-600 mb-4\">{error}</p>\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"btn-primary\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">完善个人信息</h1>\r\n <p className=\"text-gray-600 mt-2\">首次登录,请完善您的用户信息</p>\r\n </div>\r\n\r\n {/* Form */}\r\n <div className=\"card-apple p-8\">\r\n {userInfo && (\r\n <div className=\"mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200\">\r\n <div className=\"flex items-center gap-3\">\r\n {userInfo.avatar && (\r\n <Image\r\n src={userInfo.avatar}\r\n alt={userInfo.realName}\r\n width={48}\r\n height={48}\r\n className=\"rounded-full\"\r\n unoptimized\r\n />\r\n )}\r\n <div>\r\n <p className=\"text-sm text-gray-600\">钉钉账号</p>\r\n <p className=\"font-medium text-gray-900\">{userInfo.realName}</p>\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n \r\n {success && (\r\n <div className=\"bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm\">\r\n {success}\r\n </div>\r\n )}\r\n\r\n {/* 显示钉钉用户名(只读) */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 用户名\r\n </label>\r\n <div className=\"input-apple bg-gray-50 text-gray-700 cursor-not-allowed\">\r\n {userInfo?.username || userInfo?.realName || '加载中...'}\r\n </div>\r\n <p className=\"mt-1 text-xs text-gray-500\">使用您的钉钉姓名作为系统用户名</p>\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 所属部门 *\r\n </label>\r\n <select\r\n value={department}\r\n onChange={(e) => setDepartment(e.target.value)}\r\n className=\"input-apple\"\r\n required\r\n >\r\n <option value=\"\">请选择部门</option>\r\n {departments.map((dept) => (\r\n <option key={dept.id} value={dept.name}>\r\n {dept.name}\r\n </option>\r\n ))}\r\n </select>\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '提交中...' : '完成并登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"text-sm text-gray-600 hover:text-gray-900\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\nexport default function CompleteProfilePage() {\r\n return (\r\n <Suspense fallback={\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100\">\r\n <div className=\"text-center\">\r\n <div className=\"inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent\"></div>\r\n <p className=\"mt-4 text-gray-600\">加载中...</p>\r\n </div>\r\n </div>\r\n }>\r\n <CompleteProfileContent />\r\n </Suspense>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState, Suspense } from 'react'\r\nimport { useRouter, useSearchParams } from 'next/navigation'\r\nimport { signIn } from 'next-auth/react'\r\nimport Image from 'next/image'\r\n\r\ninterface Department {\r\n id: string\r\n name: string\r\n}\r\n\r\nfunction CompleteProfileContent() {\r\n const router = useRouter()\r\n const searchParams = useSearchParams()\r\n \r\n const [department, setDepartment] = useState('')\r\n const [departments, setDepartments] = useState<Department[]>([])\r\n const [loading, setLoading] = useState(false)\r\n const [error, setError] = useState('')\r\n const [success, setSuccess] = useState('')\r\n const [userInfo, setUserInfo] = useState<any>(null)\r\n\r\n // 系统设置\r\n const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n\r\n useEffect(() => {\r\n const defaultLogo = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n const defaultSiteName = 'FLNXI素材库'\r\n\r\n // 加载系统设置\r\n const loadSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const settings = await response.json()\r\n setLoginLogoUrl(settings.loginLogoUrl || defaultLogo)\r\n setSiteName(settings.siteName || defaultSiteName)\r\n }\r\n } catch (error) {\r\n console.error('加载系统设置失败:', error)\r\n }\r\n }\r\n\r\n // 加载部门列表\r\n const loadDepartments = async () => {\r\n try {\r\n const response = await fetch('/api/departments')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setDepartments(data || [])\r\n }\r\n } catch (error) {\r\n console.error('加载部门列表失败:', error)\r\n }\r\n }\r\n\r\n // 从URL参数获取用户信息\r\n const userInfoParam = searchParams.get('userInfo')\r\n if (userInfoParam) {\r\n try {\r\n const info = JSON.parse(decodeURIComponent(userInfoParam))\r\n setUserInfo(info)\r\n } catch (error) {\r\n console.error('解析用户信息失败:', error)\r\n setError('无效的用户信息,请重新登录')\r\n }\r\n } else {\r\n setError('缺少用户信息,请重新登录')\r\n }\r\n\r\n loadSettings()\r\n loadDepartments()\r\n }, [searchParams])\r\n\r\n const handleSubmit = async (e: React.FormEvent) => {\r\n e.preventDefault()\r\n setError('')\r\n\r\n if (!department) {\r\n setError('请选择部门')\r\n return\r\n }\r\n\r\n setLoading(true)\r\n setError('') // 清空错误信息\r\n setSuccess('') // 清空成功信息\r\n\r\n try {\r\n // 调用API完善用户信息(只需要选择部门)\r\n const response = await fetch('/api/dingtalk/complete-profile', {\r\n method: 'POST',\r\n headers: { 'Content-Type': 'application/json' },\r\n body: JSON.stringify({\r\n userId: userInfo?.id,\r\n department,\r\n }),\r\n })\r\n\r\n const data = await response.json()\r\n\r\n if (!response.ok || !data.success) {\r\n throw new Error(data.message || '信息提交失败')\r\n }\r\n\r\n // 如果是账号关联(关联到已有账号),提示用户使用原账号密码登录\r\n if (data.isAccountLink) {\r\n setSuccess('账号关联成功!正在跳转到登录页面,您可以使用原账号密码登录或继续使用钉钉扫码登录。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 检查新用户是否已激活\r\n if (data.user && !data.user.isActive) {\r\n setSuccess('信息提交成功!您的账号需要管理员激活后才能登录使用。')\r\n setTimeout(() => {\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = `${window.location.origin}/login`\r\n } else {\r\n router.push('/login')\r\n }\r\n }, 3000)\r\n return\r\n }\r\n\r\n // 已激活用户:使用 NextAuth 自动登录\r\n const result = await signIn('credentials', {\r\n email: data.user.email,\r\n password: 'dingtalk_auto_login',\r\n redirect: false,\r\n })\r\n\r\n if (result?.error) {\r\n throw new Error('登录失败')\r\n }\r\n\r\n // 智能跳转:保持在内网或外网环境\r\n if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {\r\n window.location.href = window.location.origin\r\n } else {\r\n router.push('/')\r\n router.refresh()\r\n }\r\n } catch (error: any) {\r\n console.error('提交失败:', error)\r\n setError(error.message || '提交失败,请稍后再试')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n if (error && !userInfo) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full bg-white rounded-2xl shadow-xl p-8\">\r\n <div className=\"text-center\">\r\n <div className=\"mb-4\">\r\n <svg className=\"mx-auto h-12 w-12 text-red-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <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\" />\r\n </svg>\r\n </div>\r\n <h2 className=\"text-xl font-semibold mb-2 text-red-600\">出错了</h2>\r\n <p className=\"text-gray-600 mb-4\">{error}</p>\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"btn-primary\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4\">\r\n <div className=\"max-w-md w-full\">\r\n {/* Logo */}\r\n <div className=\"text-center mb-8\">\r\n <div className=\"flex justify-center mb-4\">\r\n <Image\r\n src={loginLogoUrl}\r\n alt={siteName}\r\n width={80}\r\n height={80}\r\n unoptimized\r\n className=\"rounded-apple shadow-apple\"\r\n />\r\n </div>\r\n <h1 className=\"text-3xl font-bold text-gray-900\">完善个人信息</h1>\r\n <p className=\"text-gray-600 mt-2\">首次登录,请完善您的用户信息</p>\r\n </div>\r\n\r\n {/* Form */}\r\n <div className=\"card-apple p-8\">\r\n {userInfo && (\r\n <div className=\"mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200\">\r\n <div className=\"flex items-center gap-3\">\r\n {userInfo.avatar && (\r\n <Image\r\n src={userInfo.avatar}\r\n alt={userInfo.realName}\r\n width={48}\r\n height={48}\r\n className=\"rounded-full\"\r\n unoptimized\r\n />\r\n )}\r\n <div>\r\n <p className=\"text-sm text-gray-600\">钉钉账号</p>\r\n <p className=\"font-medium text-gray-900\">{userInfo.realName}</p>\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n\r\n <form onSubmit={handleSubmit} className=\"space-y-6\">\r\n {error && (\r\n <div className=\"bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm\">\r\n {error}\r\n </div>\r\n )}\r\n \r\n {success && (\r\n <div className=\"bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm\">\r\n {success}\r\n </div>\r\n )}\r\n\r\n {/* 显示钉钉用户名(只读) */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 用户名\r\n </label>\r\n <div className=\"input-apple bg-gray-50 text-gray-700 cursor-not-allowed\">\r\n {userInfo?.username || userInfo?.realName || '加载中...'}\r\n </div>\r\n <p className=\"mt-1 text-xs text-gray-500\">使用您的钉钉姓名作为系统用户名</p>\r\n </div>\r\n\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 mb-2\">\r\n 所属部门 *\r\n </label>\r\n <select\r\n value={department}\r\n onChange={(e) => setDepartment(e.target.value)}\r\n className=\"input-apple\"\r\n required\r\n >\r\n <option value=\"\">请选择部门</option>\r\n {departments.map((dept) => (\r\n <option key={dept.id} value={dept.name}>\r\n {dept.name}\r\n </option>\r\n ))}\r\n </select>\r\n </div>\r\n\r\n <button\r\n type=\"submit\"\r\n className=\"w-full btn-primary\"\r\n disabled={loading}\r\n >\r\n {loading ? '提交中...' : '完成并登录'}\r\n </button>\r\n </form>\r\n\r\n <div className=\"mt-6 text-center\">\r\n <button\r\n onClick={() => router.push('/login')}\r\n className=\"text-sm text-gray-600 hover:text-gray-900\"\r\n >\r\n 返回登录\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n )\r\n}\r\n\r\nexport default function CompleteProfilePage() {\r\n return (\r\n <Suspense fallback={\r\n <div className=\"min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100\">\r\n <div className=\"text-center\">\r\n <div className=\"inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent\"></div>\r\n <p className=\"mt-4 text-gray-600\">加载中...</p>\r\n </div>\r\n </div>\r\n }>\r\n <CompleteProfileContent />\r\n </Suspense>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -23417,3 +23417,5 @@ _**Agent (model default, mode Agent)**_ --- + + diff --git a/.specstory/history/2025-11-11_10-10Z-帮我测试.md b/.specstory/history/2025-11-11_10-10Z-帮我测试.md index bcbb046..bf2ba00 100644 --- a/.specstory/history/2025-11-11_10-10Z-帮我测试.md +++ b/.specstory/history/2025-11-11_10-10Z-帮我测试.md @@ -242,8 +242,8 @@ Node.js v18.20.8 ``` 查找用户... - youyidian-3d-material copy update_user_permissions.js to youyidian-3d-material:/app/update_user_permissions.js Copying - youyidian-3d-material copy update_user_permissions.js to youyidian-3d-material:/app/update_user_permissions.js Copied + flnxi-material copy update_user_permissions.js to flnxi-material:/app/update_user_permissions.js Copying + flnxi-material copy update_user_permissions.js to flnxi-material:/app/update_user_permissions.js Copied 目标用户: { "id": "34fa977a-5754-414d-9a20-0ca7ea1671fc", "email": "549727955@qq.com", @@ -669,18 +669,18 @@ Output mode: count ``` ``` - Container youyidian-3d-material Restarting - Container youyidian-3d-material Started -youyidian-3d-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."email", "public"."users"."username", "public"."users"."role"::text, "public"."users"."isActive", "public"."users"."canDownload", "public"."users"."department", "public"."users"."managedDepartment", "public"."users"."isSuperAdmin", "public"."users"."createdAt", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY "public"."users"."createdAt" ASC OFFSET $1 -youyidian-3d-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."departments"."id", "public"."departments"."name", "public"."departments"."createdAt" FROM "public"."departments" WHERE 1=1 ORDER BY "public"."departments"."createdAt" ASC OFFSET $1 -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 338ms + Container flnxi-material Restarting + Container flnxi-material Started +flnxi-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."email", "public"."users"."username", "public"."users"."role"::text, "public"."users"."isActive", "public"."users"."canDownload", "public"."users"."department", "public"."users"."managedDepartment", "public"."users"."isSuperAdmin", "public"."users"."createdAt", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY "public"."users"."createdAt" ASC OFFSET $1 +flnxi-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."departments"."id", "public"."departments"."name", "public"."departments"."createdAt" FROM "public"."departments" WHERE 1=1 ORDER BY "public"."departments"."createdAt" ASC OFFSET $1 +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 338ms ```
@@ -1207,18 +1207,18 @@ Lint errors for `app/admin/page.tsx` ``` ``` - Container youyidian-3d-material Restarting - Container youyidian-3d-material Started -youyidian-3d-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."users"."id", "public"."users"."email", "public"."users"."username", "public"."users"."role"::text, "public"."users"."isActive", "public"."users"."canDownload", "public"."users"."department", "public"."users"."managedDepartment", "public"."users"."isSuperAdmin", "public"."users"."createdAt", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY "public"."users"."createdAt" ASC OFFSET $1 -youyidian-3d-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 -youyidian-3d-material | prisma:query SELECT "public"."departments"."id", "public"."departments"."name", "public"."departments"."createdAt" FROM "public"."departments" WHERE 1=1 ORDER BY "public"."departments"."createdAt" ASC OFFSET $1 -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 304ms + Container flnxi-material Restarting + Container flnxi-material Started +flnxi-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."users"."id", "public"."users"."email", "public"."users"."username", "public"."users"."role"::text, "public"."users"."isActive", "public"."users"."canDownload", "public"."users"."department", "public"."users"."managedDepartment", "public"."users"."isSuperAdmin", "public"."users"."createdAt", COALESCE("aggr_selection_0_Material"."_aggr_count_materials", 0) AS "_aggr_count_materials" FROM "public"."users" LEFT JOIN (SELECT "public"."materials"."userId", COUNT(*) AS "_aggr_count_materials" FROM "public"."materials" WHERE 1=1 GROUP BY "public"."materials"."userId") AS "aggr_selection_0_Material" ON ("public"."users"."id" = "aggr_selection_0_Material"."userId") WHERE 1=1 ORDER BY "public"."users"."createdAt" ASC OFFSET $1 +flnxi-material | prisma:query SELECT "public"."system_settings"."id", "public"."system_settings"."headerBgColor", "public"."system_settings"."headerLogoUrl", "public"."system_settings"."loginLogoUrl", "public"."system_settings"."siteName", "public"."system_settings"."s3Endpoint", "public"."system_settings"."s3AccessKey", "public"."system_settings"."s3SecretKey", "public"."system_settings"."s3Bucket", "public"."system_settings"."s3Region", "public"."system_settings"."s3PublicUrl", "public"."system_settings"."enableUserActivation", "public"."system_settings"."enableRegistration", "public"."system_settings"."updatedAt", "public"."system_settings"."createdAt" FROM "public"."system_settings" WHERE 1=1 LIMIT $1 OFFSET $2 +flnxi-material | prisma:query SELECT "public"."departments"."id", "public"."departments"."name", "public"."departments"."createdAt" FROM "public"."departments" WHERE 1=1 ORDER BY "public"."departments"."createdAt" ASC OFFSET $1 +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 304ms ```
@@ -1290,7 +1290,7 @@ _**Agent (model default, mode Agent)**_ ``` NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 42 minutes ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 42 minutes ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp ```
@@ -1347,10 +1347,10 @@ ls: /app/app/admin/page.tsx: No such file or directory ``` ``` - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed #27 [runner 6/13] COPY --from=builder --chown=nextjs:nodejs /app/public ./public @@ -1389,10 +1389,10 @@ ls: /app/app/admin/page.tsx: No such file or directory youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started ```
@@ -1418,13 +1418,13 @@ ls: /app/app/admin/page.tsx: No such file or directory ``` NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 3 minutes ago Up 3 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 278ms +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 3 minutes ago Up 3 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 278ms ```
@@ -2690,10 +2690,10 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 ``` ``` - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed @@ -2787,7 +2787,7 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 #20 [builder 7/9] RUN npm run build #20 1.102 -#20 1.102 > youyidian-3d-material@1.0.0 build +#20 1.102 > flnxi-material@1.0.0 build #20 1.102 > prisma generate && next build #20 1.102 #20 3.583 Prisma schema loaded from prisma/schema.prisma @@ -3688,10 +3688,10 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started ```
@@ -3718,13 +3718,13 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 ``` NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 7 minutes ago Up 7 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 310ms +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 7 minutes ago Up 7 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 310ms ```
@@ -4154,10 +4154,10 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 ``` ``` - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed #1 [internal] load local bake definitions @@ -4236,7 +4236,7 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 #20 [builder 7/9] RUN npm run build #20 1.121 -#20 1.121 > youyidian-3d-material@1.0.0 build +#20 1.121 > flnxi-material@1.0.0 build #20 1.121 > prisma generate && next build #20 1.121 #20 3.436 Prisma schema loaded from prisma/schema.prisma @@ -5137,10 +5137,10 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started ```
@@ -7366,7 +7366,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM prisma/schema.prisma", - "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"由壹点3D素材\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" + "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"FLNXI素材库\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -8110,7 +8110,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM prisma/schema.prisma", - "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n templateId String?\r\n template UploadTemplate? @relation(fields: [templateId], references: [id])\r\n metadata Json?\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"由壹点3D素材\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" + "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n templateId String?\r\n template UploadTemplate? @relation(fields: [templateId], references: [id])\r\n metadata Json?\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"FLNXI素材库\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -8166,7 +8166,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM prisma/schema.prisma", - "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n materials Material[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n templateId String?\r\n template UploadTemplate? @relation(fields: [templateId], references: [id])\r\n metadata Json?\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"由壹点3D素材\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" + "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n materials Material[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n templateId String?\r\n template UploadTemplate? @relation(fields: [templateId], references: [id])\r\n metadata Json?\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"FLNXI素材库\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -8222,7 +8222,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM prisma/schema.prisma", - "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n materials Material[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n templateId String?\r\n template UploadTemplate? @relation(fields: [templateId], references: [id])\r\n metadata Json?\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"由壹点3D素材\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n enableAiImageEdit Boolean @default(false) // AI改图功能\r\n enableTunnelService Boolean @default(false) // 内网穿透功能\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" + "contentsAfterEdit": "// This is your Prisma schema file,\r\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\r\n\r\ngenerator client {\r\n provider = \"prisma-client-js\"\r\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\r\n}\r\n\r\ndatasource db {\r\n provider = \"postgresql\"\r\n url = env(\"DATABASE_URL\")\r\n}\r\n\r\nmodel User {\r\n id String @id @default(uuid())\r\n email String @unique\r\n username String @unique\r\n password String\r\n // 组织层级:直属上级(可为空表示顶层)\r\n parentId String?\r\n parent User? @relation(\"UserHierarchy\", fields: [parentId], references: [id])\r\n children User[] @relation(\"UserHierarchy\")\r\n department String @default(\"\") // 用户部门\r\n managedDepartment String? // 管理员负责的部门(如果是部门管理员)\r\n role Role @default(USER)\r\n isSuperAdmin Boolean @default(false) // 是否为最高权限账号(仅一个)\r\n isActive Boolean @default(false) // 是否激活,默认未激活\r\n canDownload Boolean @default(true) // 是否允许下载,默认允许\r\n \r\n // 钉钉登录相关字段\r\n dingtalkUserId String? @unique // 钉钉userId\r\n loginType String @default(\"normal\") // 登录类型:normal/dingtalk\r\n realName String? // 真实姓名\r\n phone String? @unique // 手机号\r\n avatar String? // 头像URL\r\n \r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n materials Material[]\r\n likes MaterialLike[]\r\n\r\n @@map(\"users\")\r\n}\r\n\r\n// ==========================\r\n// 可视化上传模板(占位最小实现)\r\n// ==========================\r\nmodel UploadTemplate {\r\n id String @id @default(uuid())\r\n name String\r\n description String? \r\n status String @default(\"draft\") // draft/active/archived\r\n version Int @default(1)\r\n // UI 分组与展示配置(JSON 占位,前端动态表单使用)\r\n fieldGroups Json?\r\n displayConfig Json?\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n fields UploadField[]\r\n materials Material[]\r\n}\r\n\r\nmodel UploadField {\r\n id String @id @default(uuid())\r\n templateId String\r\n template UploadTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)\r\n\r\n key String // 存储字段键,如 modelNumber/shopName\r\n label String\r\n type String // text/number/dropdown/image/file...\r\n required Boolean @default(false)\r\n orderIndex Int @default(0)\r\n layoutWidth String? // full/half/third\r\n\r\n // 校验与选项\r\n validations Json?\r\n optionsSource String? // static/dynamic/userGenerated\r\n optionsStatic Json?\r\n allowedExtensions Json?\r\n\r\n // 首页筛选与可见性\r\n visibleOnHomeFilter Boolean @default(false)\r\n isSearchable Boolean @default(false)\r\n isReadOnly Boolean @default(false)\r\n conditions Json?\r\n\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\n// 首页筛选项配置(占位)\r\nmodel FilterItem {\r\n id String @id @default(uuid())\r\n fieldKey String\r\n label String\r\n group String?\r\n orderIndex Int @default(0)\r\n multiSelect Boolean @default(false)\r\n showOnHome Boolean @default(true)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n}\r\n\r\nmodel Material {\r\n id String @id @default(uuid())\r\n previewUrl String // S3 URL for preview image\r\n productModel String // SPU\r\n categoryName String // 使用店铺\r\n sceneName String // 场景类别\r\n platformName String @default(\"\") // 平台分类(可选)\r\n materialType String @default(\"3DMAX\") // 素材类型:3DMAX/PSD/AI/CAD/C4D/视频/材质/模特/标准饰品/其他\r\n renderer String @default(\"CR\") // 渲染器:CR/VR/其它(仅3DMAX使用)\r\n authorName String // 作者花名\r\n zipFileName String @default(\"\") // 压缩包文件名(模特类型可为空)\r\n zipFilePath String @default(\"\") // 本地压缩包路径(模特类型可为空)\r\n zipFileSize BigInt @default(0) // 压缩包文件大小(字节)\r\n downloadCount Int @default(0) // 下载次数\r\n likeCount Int @default(0) // 点赞次数\r\n displayImages String[] @default([]) // PSD/模特展示图片URLs数组(切割后的图片按顺序存储)\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n \r\n userId String\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n templateId String?\r\n template UploadTemplate? @relation(fields: [templateId], references: [id])\r\n metadata Json?\r\n likes MaterialLike[]\r\n\r\n @@map(\"materials\")\r\n @@index([userId])\r\n @@index([createdAt])\r\n @@index([categoryName])\r\n @@index([sceneName])\r\n @@index([platformName])\r\n @@index([materialType])\r\n @@index([downloadCount])\r\n @@index([likeCount])\r\n}\r\n\r\nmodel MaterialLike {\r\n id String @id @default(uuid())\r\n materialId String\r\n userId String\r\n createdAt DateTime @default(now())\r\n \r\n material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)\r\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\r\n\r\n @@unique([materialId, userId]) // 确保一个用户只能点赞一次\r\n @@map(\"material_likes\")\r\n @@index([materialId])\r\n @@index([userId])\r\n}\r\n\r\nmodel Category {\r\n id String @id @default(uuid())\r\n name String @unique // 使用店铺名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"categories\")\r\n}\r\n\r\nmodel Scene {\r\n id String @id @default(uuid())\r\n name String @unique // 场景类别名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"scenes\")\r\n}\r\n\r\nmodel Platform {\r\n id String @id @default(uuid())\r\n name String @unique // 平台分类名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"platforms\")\r\n}\r\n\r\nmodel SystemSettings {\r\n id String @id @default(uuid())\r\n headerBgColor String @default(\"#1f2937\") // 顶部导航栏背景色\r\n headerLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 导航栏LOGO URL(深色背景用)\r\n loginLogoUrl String @default(\"http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png\") // 登录页面LOGO URL(浅色背景用)\r\n siteName String @default(\"FLNXI素材库\") // 网站名称\r\n \r\n // S3存储桶配置\r\n s3Endpoint String @default(\"\") // S3端点\r\n s3AccessKey String @default(\"\") // S3访问密钥\r\n s3SecretKey String @default(\"\") // S3密钥\r\n s3Bucket String @default(\"\") // 存储桶名称\r\n s3Region String @default(\"us-east-1\") // 区域\r\n s3PublicUrl String @default(\"\") // 公网访问URL\r\n \r\n // 功能开关\r\n enableUserActivation Boolean @default(false) // 用户激活功能开关(默认关闭)\r\n enableRegistration Boolean @default(true) // 用户手动注册功能开关(默认开启)\r\n enableAiImageEdit Boolean @default(false) // AI改图功能\r\n enableTunnelService Boolean @default(false) // 内网穿透功能\r\n \r\n updatedAt DateTime @updatedAt\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"system_settings\")\r\n}\r\n\r\nmodel Department {\r\n id String @id @default(uuid())\r\n name String @unique // 部门名称\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"departments\")\r\n}\r\n\r\nmodel Danmaku {\r\n id String @id @default(uuid())\r\n content String // 弹幕内容\r\n username String // 发送者用户名\r\n userId String // 发送者ID\r\n createdAt DateTime @default(now())\r\n\r\n @@map(\"danmakus\")\r\n @@index([createdAt])\r\n}\r\n\r\nmodel Invitation {\r\n id String @id @default(uuid())\r\n code String @unique // 邀请码\r\n createdBy String // 创建者ID(管理员)\r\n usedBy String? // 使用者ID(注册时使用)\r\n usedAt DateTime? // 使用时间\r\n expiresAt DateTime? // 过期时间(可选)\r\n maxUses Int @default(1) // 最大使用次数\r\n currentUses Int @default(0) // 当前使用次数\r\n isActive Boolean @default(true) // 是否激活\r\n createdAt DateTime @default(now())\r\n updatedAt DateTime @updatedAt\r\n\r\n @@map(\"invitations\")\r\n @@index([code])\r\n @@index([createdBy])\r\n}\r\n\r\nenum Role {\r\n USER\r\n ADMIN\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -8691,7 +8691,7 @@ Result: "linesAdded": 1 }, { - "diffString": " siteName: '由壹点3D素材',\r\n enableUserActivation: false,\r\n- enableRegistration: true\r\n+ enableRegistration: true,\r\n+ enableAiImageEdit: featureFlags.aiEditEnabled,\r\n+ enableTunnelService: featureFlags.tunnelEnabled,\r\n }\r\n })\r", + "diffString": " siteName: 'FLNXI素材库',\r\n enableUserActivation: false,\r\n- enableRegistration: true\r\n+ enableRegistration: true,\r\n+ enableAiImageEdit: featureFlags.aiEditEnabled,\r\n+ enableTunnelService: featureFlags.tunnelEnabled,\r\n }\r\n })\r", "oldStart": 20, "newStart": 20, "oldLines": 5, @@ -8709,7 +8709,7 @@ Result: "linesAdded": 10 }, { - "diffString": " siteName: siteName || '由壹点3D素材',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n- enableRegistration: enableRegistration !== undefined ? enableRegistration : true\r\n+ enableRegistration: enableRegistration !== undefined ? enableRegistration : true,\r\n+ enableAiImageEdit:\r\n+ enableAiImageEdit !== undefined ? enableAiImageEdit : featureFlags.aiEditEnabled,\r\n+ enableTunnelService:\r\n+ enableTunnelService !== undefined ? enableTunnelService : featureFlags.tunnelEnabled,\r\n }\r\n })\r", + "diffString": " siteName: siteName || 'FLNXI素材库',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n- enableRegistration: enableRegistration !== undefined ? enableRegistration : true\r\n+ enableRegistration: enableRegistration !== undefined ? enableRegistration : true,\r\n+ enableAiImageEdit:\r\n+ enableAiImageEdit !== undefined ? enableAiImageEdit : featureFlags.aiEditEnabled,\r\n+ enableTunnelService:\r\n+ enableTunnelService !== undefined ? enableTunnelService : featureFlags.tunnelEnabled,\r\n }\r\n })\r", "oldStart": 84, "newStart": 93, "oldLines": 5, @@ -8730,7 +8730,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/api/system-settings/route.ts", - "contentsAfterEdit": "import { NextRequest, NextResponse } from 'next/server'\r\nimport { getServerSession } from 'next-auth'\r\nimport { authOptions } from '@/lib/auth'\r\nimport { prisma } from '@/lib/prisma'\r\nimport { defaultLogos, featureFlags } from '@/lib/config'\r\n\r\n// GET - 获取系统设置\r\nexport async function GET() {\r\n try {\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n \r\n if (!settings) {\r\n // 如果不存在,创建默认设置\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: '#1f2937',\r\n headerLogoUrl: defaultLogos.header,\r\n loginLogoUrl: defaultLogos.login,\r\n siteName: '由壹点3D素材',\r\n enableUserActivation: false,\r\n enableRegistration: true,\r\n enableAiImageEdit: featureFlags.aiEditEnabled,\r\n enableTunnelService: featureFlags.tunnelEnabled,\r\n }\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Get system settings error:', error)\r\n return NextResponse.json(\r\n { error: '获取系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n\r\n// PUT - 更新系统设置(仅管理员)\r\nexport async function PUT(request: NextRequest) {\r\n try {\r\n const session = await getServerSession(authOptions)\r\n\r\n const isSuperAdmin = (session?.user as any)?.isSuperAdmin\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return NextResponse.json(\r\n { error: '无权限' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n const body = await request.json()\r\n const { \r\n headerBgColor, \r\n siteName, \r\n headerLogoUrl, \r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration,\r\n enableAiImageEdit,\r\n enableTunnelService,\r\n } = body\r\n\r\n // 只有最高权限账号可以修改 enableUserActivation\r\n if (\r\n (enableUserActivation !== undefined ||\r\n enableAiImageEdit !== undefined ||\r\n enableTunnelService !== undefined) &&\r\n !isSuperAdmin\r\n ) {\r\n return NextResponse.json(\r\n { error: '只有最高权限账号可以修改受保护的功能开关' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n\r\n if (!settings) {\r\n // 创建\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n headerLogoUrl: headerLogoUrl ?? defaultLogos.header,\r\n loginLogoUrl: loginLogoUrl ?? defaultLogos.login,\r\n siteName: siteName || '由壹点3D素材',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n enableRegistration: enableRegistration !== undefined ? enableRegistration : true,\r\n enableAiImageEdit:\r\n enableAiImageEdit !== undefined ? enableAiImageEdit : featureFlags.aiEditEnabled,\r\n enableTunnelService:\r\n enableTunnelService !== undefined ? enableTunnelService : featureFlags.tunnelEnabled,\r\n }\r\n })\r\n } else {\r\n // 更新\r\n const updateData: any = {}\r\n if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor\r\n if (siteName !== undefined) updateData.siteName = siteName\r\n if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl\r\n if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl\r\n if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint\r\n if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey\r\n if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey\r\n if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket\r\n if (s3Region !== undefined) updateData.s3Region = s3Region\r\n if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl\r\n if (enableUserActivation !== undefined) updateData.enableUserActivation = enableUserActivation\r\n if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration\r\n if (enableAiImageEdit !== undefined) updateData.enableAiImageEdit = enableAiImageEdit\r\n if (enableTunnelService !== undefined) updateData.enableTunnelService = enableTunnelService\r\n \r\n settings = await prisma.systemSettings.update({\r\n where: { id: settings.id },\r\n data: updateData\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Update system settings error:', error)\r\n return NextResponse.json(\r\n { error: '更新系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n" + "contentsAfterEdit": "import { NextRequest, NextResponse } from 'next/server'\r\nimport { getServerSession } from 'next-auth'\r\nimport { authOptions } from '@/lib/auth'\r\nimport { prisma } from '@/lib/prisma'\r\nimport { defaultLogos, featureFlags } from '@/lib/config'\r\n\r\n// GET - 获取系统设置\r\nexport async function GET() {\r\n try {\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n \r\n if (!settings) {\r\n // 如果不存在,创建默认设置\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: '#1f2937',\r\n headerLogoUrl: defaultLogos.header,\r\n loginLogoUrl: defaultLogos.login,\r\n siteName: 'FLNXI素材库',\r\n enableUserActivation: false,\r\n enableRegistration: true,\r\n enableAiImageEdit: featureFlags.aiEditEnabled,\r\n enableTunnelService: featureFlags.tunnelEnabled,\r\n }\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Get system settings error:', error)\r\n return NextResponse.json(\r\n { error: '获取系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n\r\n// PUT - 更新系统设置(仅管理员)\r\nexport async function PUT(request: NextRequest) {\r\n try {\r\n const session = await getServerSession(authOptions)\r\n\r\n const isSuperAdmin = (session?.user as any)?.isSuperAdmin\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return NextResponse.json(\r\n { error: '无权限' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n const body = await request.json()\r\n const { \r\n headerBgColor, \r\n siteName, \r\n headerLogoUrl, \r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration,\r\n enableAiImageEdit,\r\n enableTunnelService,\r\n } = body\r\n\r\n // 只有最高权限账号可以修改 enableUserActivation\r\n if (\r\n (enableUserActivation !== undefined ||\r\n enableAiImageEdit !== undefined ||\r\n enableTunnelService !== undefined) &&\r\n !isSuperAdmin\r\n ) {\r\n return NextResponse.json(\r\n { error: '只有最高权限账号可以修改受保护的功能开关' },\r\n { status: 403 }\r\n )\r\n }\r\n\r\n // 获取或创建系统设置\r\n let settings = await prisma.systemSettings.findFirst()\r\n\r\n if (!settings) {\r\n // 创建\r\n settings = await prisma.systemSettings.create({\r\n data: {\r\n headerBgColor: headerBgColor || '#1f2937',\r\n headerLogoUrl: headerLogoUrl ?? defaultLogos.header,\r\n loginLogoUrl: loginLogoUrl ?? defaultLogos.login,\r\n siteName: siteName || 'FLNXI素材库',\r\n enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false,\r\n enableRegistration: enableRegistration !== undefined ? enableRegistration : true,\r\n enableAiImageEdit:\r\n enableAiImageEdit !== undefined ? enableAiImageEdit : featureFlags.aiEditEnabled,\r\n enableTunnelService:\r\n enableTunnelService !== undefined ? enableTunnelService : featureFlags.tunnelEnabled,\r\n }\r\n })\r\n } else {\r\n // 更新\r\n const updateData: any = {}\r\n if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor\r\n if (siteName !== undefined) updateData.siteName = siteName\r\n if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl\r\n if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl\r\n if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint\r\n if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey\r\n if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey\r\n if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket\r\n if (s3Region !== undefined) updateData.s3Region = s3Region\r\n if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl\r\n if (enableUserActivation !== undefined) updateData.enableUserActivation = enableUserActivation\r\n if (enableRegistration !== undefined) updateData.enableRegistration = enableRegistration\r\n if (enableAiImageEdit !== undefined) updateData.enableAiImageEdit = enableAiImageEdit\r\n if (enableTunnelService !== undefined) updateData.enableTunnelService = enableTunnelService\r\n \r\n settings = await prisma.systemSettings.update({\r\n where: { id: settings.id },\r\n data: updateData\r\n })\r\n }\r\n\r\n return NextResponse.json(settings)\r\n } catch (error) {\r\n console.error('Update system settings error:', error)\r\n return NextResponse.json(\r\n { error: '更新系统设置失败' },\r\n { status: 500 }\r\n )\r\n }\r\n}\r\n" } ``` User decision: **accepted** @@ -8811,7 +8811,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM app/admin/settings/page.tsx", - "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState } from 'react'\r\nimport { useSession } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Header from '@/components/Header'\r\n\r\nexport default function SystemSettings() {\r\n const { data: session, status } = useSession()\r\n const router = useRouter()\r\n const [loading, setLoading] = useState(true)\r\n const [saving, setSaving] = useState(false)\r\n const [uploading, setUploading] = useState(false)\r\n\r\n // 设置状态\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [siteName, setSiteName] = useState('由壹点3D素材')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState('')\r\n const [headerLogoPreview, setHeaderLogoPreview] = useState('')\r\n const [loginLogoUrl, setLoginLogoUrl] = useState('')\r\n const [loginLogoPreview, setLoginLogoPreview] = useState('')\r\n const [uploadingHeader, setUploadingHeader] = useState(false)\r\n const [uploadingLogin, setUploadingLogin] = useState(false)\r\n \r\n // S3配置状态\r\n const [s3Endpoint, setS3Endpoint] = useState('')\r\n const [s3AccessKey, setS3AccessKey] = useState('')\r\n const [s3SecretKey, setS3SecretKey] = useState('')\r\n const [s3Bucket, setS3Bucket] = useState('')\r\n const [s3Region, setS3Region] = useState('us-east-1')\r\n const [s3PublicUrl, setS3PublicUrl] = useState('')\r\n \r\n // 功能开关状态\r\n const [enableUserActivation, setEnableUserActivation] = useState(false)\r\n const [enableRegistration, setEnableRegistration] = useState(true)\r\n const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)\r\n const [enableTunnelService, setEnableTunnelService] = useState(false)\r\n\r\n // 权限检查\r\n useEffect(() => {\r\n if (status === 'unauthenticated') {\r\n router.push('/login')\r\n } else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {\r\n router.push('/')\r\n }\r\n }, [status, session, router])\r\n\r\n // 加载设置\r\n useEffect(() => {\r\n if (status === 'authenticated' && session?.user?.role === 'ADMIN') {\r\n fetchSettings()\r\n }\r\n }, [status, session])\r\n\r\n const fetchSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderBgColor(data.headerBgColor || '#1f2937')\r\n setSiteName(data.siteName || '由壹点3D素材')\r\n setHeaderLogoUrl(data.headerLogoUrl || '')\r\n setHeaderLogoPreview(data.headerLogoUrl || '')\r\n setLoginLogoUrl(data.loginLogoUrl || '')\r\n setLoginLogoPreview(data.loginLogoUrl || '')\r\n setS3Endpoint(data.s3Endpoint || '')\r\n setS3AccessKey(data.s3AccessKey || '')\r\n setS3SecretKey(data.s3SecretKey || '')\r\n setS3Bucket(data.s3Bucket || '')\r\n setS3Region(data.s3Region || 'us-east-1')\r\n setS3PublicUrl(data.s3PublicUrl || '')\r\n setEnableUserActivation(data.enableUserActivation ?? false)\r\n setEnableRegistration(data.enableRegistration ?? true)\r\n setEnableAiImageEdit(data.enableAiImageEdit ?? false)\r\n setEnableTunnelService(data.enableTunnelService ?? false)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching settings:', error)\r\n alert('加载设置失败')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n\r\n const handleSave = async () => {\r\n setSaving(true)\r\n try {\r\n const response = await fetch('/api/system-settings', {\r\n method: 'PUT',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({\r\n headerBgColor,\r\n siteName,\r\n headerLogoUrl,\r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration,\r\n enableAiImageEdit,\r\n enableTunnelService\r\n }),\r\n })\r\n\r\n if (response.ok) {\r\n alert('保存成功!页面将自动刷新以应用新设置')\r\n // 刷新页面以应用新设置\r\n window.location.reload()\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || '保存失败')\r\n }\r\n } catch (error) {\r\n console.error('Save settings error:', error)\r\n alert('保存失败,请稍后重试')\r\n } finally {\r\n setSaving(false)\r\n }\r\n }\r\n\r\n const handleHeaderLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingHeader(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'header')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderLogoUrl(data.logoUrl)\r\n setHeaderLogoPreview(data.logoUrl)\r\n alert('导航栏LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingHeader(false)\r\n }\r\n }\r\n\r\n const handleLoginLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingLogin(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'login')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setLoginLogoUrl(data.logoUrl)\r\n setLoginLogoPreview(data.logoUrl)\r\n alert('登录页LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingLogin(false)\r\n }\r\n }\r\n\r\n const handleColorReset = () => {\r\n setHeaderBgColor('#1f2937')\r\n }\r\n\r\n const handleHeaderLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''\r\n setHeaderLogoPreview(def)\r\n setHeaderLogoUrl(def)\r\n }\r\n\r\n const handleLoginLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n setLoginLogoPreview(def)\r\n setLoginLogoUrl(def)\r\n }\r\n\r\n if (status === 'loading' || loading) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center dark:bg-gray-900\">\r\n <div className=\"text-xl text-gray-600 dark:text-gray-400\">加载中...</div>\r\n </div>\r\n )\r\n }\r\n\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return null\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen bg-gray-50 dark:bg-gray-900\">\r\n <Header session={session} />\r\n\r\n <main className=\"container mx-auto px-4 py-8 max-w-4xl\">\r\n <div className=\"mb-6\">\r\n <h1 className=\"text-3xl font-bold text-gray-900 dark:text-white mb-2\">系统设置</h1>\r\n <p className=\"text-gray-600 dark:text-gray-400\">自定义网站外观和品牌信息</p>\r\n </div>\r\n\r\n <div className=\"bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8\">\r\n {/* 网站名称设置 */}\r\n <div>\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 网站名称\r\n </h2>\r\n <div className=\"space-y-2\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\r\n 站点名称\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={siteName}\r\n onChange={(e) => setSiteName(e.target.value)}\r\n placeholder=\"由壹点3D素材\"\r\n 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\"\r\n />\r\n <p className=\"text-sm text-gray-500 dark:text-gray-400\">\r\n 该名称将显示在顶部导航栏和登录页面\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 导航栏LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 导航栏LOGO(深色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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 }}>\r\n {headerLogoPreview ? (\r\n <img src={headerLogoPreview} alt=\"导航栏LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于深色导航栏,建议使用浅色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingHeader ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传导航栏LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleHeaderLogoUpload}\r\n disabled={uploadingHeader}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleHeaderLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingHeader}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 登录页LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 登录页LOGO(浅色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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\">\r\n {loginLogoPreview ? (\r\n <img src={loginLogoPreview} alt=\"登录页LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于浅色登录页面,建议使用深色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingLogin ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传登录页LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleLoginLogoUpload}\r\n disabled={uploadingLogin}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleLoginLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingLogin}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 主题颜色设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 主题颜色\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* 颜色预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 颜色预览\r\n </label>\r\n <div\r\n className=\"w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner\"\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"flex items-center h-full px-4\">\r\n <span className=\"text-white font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>\r\n 顶部导航栏预览\r\n </span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 颜色选择器 */}\r\n <div className=\"flex items-center gap-4\">\r\n <div className=\"flex-1\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 选择颜色\r\n </label>\r\n <div className=\"flex items-center gap-2\">\r\n <input\r\n type=\"color\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n className=\"w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600\"\r\n />\r\n <input\r\n type=\"text\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n placeholder=\"#1f2937\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0\">\r\n 操作\r\n </label>\r\n <button\r\n onClick={handleColorReset}\r\n className=\"btn-secondary whitespace-nowrap\"\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* 预设颜色 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 快速选择\r\n </label>\r\n <div className=\"flex flex-wrap gap-2\">\r\n {[\r\n { name: '默认灰', color: '#1f2937' },\r\n { name: '深蓝', color: '#1e3a8a' },\r\n { name: '靛蓝', color: '#4f46e5' },\r\n { name: '紫色', color: '#7c3aed' },\r\n { name: '粉色', color: '#db2777' },\r\n { name: '红色', color: '#dc2626' },\r\n { name: '橙色', color: '#ea580c' },\r\n { name: '绿色', color: '#059669' },\r\n { name: '青色', color: '#0891b2' },\r\n { name: '深灰', color: '#374151' },\r\n ].map((preset) => (\r\n <button\r\n key={preset.color}\r\n onClick={() => setHeaderBgColor(preset.color)}\r\n className={`px-4 py-2 rounded-lg border-2 transition-all ${\r\n headerBgColor === preset.color\r\n ? 'border-blue-500 ring-2 ring-blue-200'\r\n : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'\r\n }`}\r\n style={{ backgroundColor: preset.color }}\r\n title={preset.color}\r\n >\r\n <span className=\"text-white text-sm font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>\r\n {preset.name}\r\n </span>\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 保存按钮 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center\">\r\n <button\r\n onClick={() => router.push('/admin')}\r\n className=\"btn-secondary\"\r\n >\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" />\r\n </svg>\r\n 返回\r\n </button>\r\n <button\r\n onClick={handleSave}\r\n disabled={saving}\r\n className=\"btn-primary text-lg px-8 py-3 disabled:opacity-50\"\r\n >\r\n {saving ? (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 保存中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\r\n </svg>\r\n 保存设置\r\n </>\r\n )}\r\n </button>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-medium mb-1\">温馨提示:</p>\r\n <ul className=\"list-disc list-inside space-y-1\">\r\n <li>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</li>\r\n <li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>\r\n <li>建议使用高质量的LOGO图片以获得最佳显示效果</li>\r\n <li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 功能开关 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n </svg>\r\n 功能开关\r\n </h2>\r\n <div className=\"space-y-6\">\r\n {/* 用户激活功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户激活功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后,新注册的用户需要管理员激活才能登录。默认关闭。\r\n </p>\r\n </div>\r\n <label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\r\n <input\r\n type=\"checkbox\"\r\n checked={enableUserActivation}\r\n onChange={(e) => setEnableUserActivation(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 仅最高权限账号可修改此开关\r\n </p>\r\n )}\r\n\r\n {/* 用户注册功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户手动注册功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 关闭后,用户将无法通过注册页面手动注册。默认开启。\r\n </p>\r\n </div>\r\n <label className=\"relative inline-flex items-center cursor-pointer\">\r\n <input\r\n type=\"checkbox\"\r\n checked={enableRegistration}\r\n onChange={(e) => setEnableRegistration(e.target.checked)}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n\r\n {/* AI改图功能 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n AI改图功能(预留)\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 控制素材详情页的 AI 改图按钮显示。功能目前为占位,启用后将连通正式 API。\r\n </p>\r\n </div>\r\n <label\r\n className={`relative inline-flex items-center ${\r\n !(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'\r\n }`}\r\n >\r\n <input\r\n type=\"checkbox\"\r\n checked={enableAiImageEdit}\r\n onChange={(e) => setEnableAiImageEdit(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n\r\n {/* 内网穿透功能 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 内网穿透功能(付费)\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后可在后台调度付费内网穿透服务,目前仅展示占位操作。请确认购买服务后再启用。\r\n </p>\r\n </div>\r\n <label\r\n className={`relative inline-flex items-center ${\r\n !(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'\r\n }`}\r\n >\r\n <input\r\n type=\"checkbox\"\r\n checked={enableTunnelService}\r\n onChange={(e) => setEnableTunnelService(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 最高权限账号才可修改 AI/内网穿透等受保护功能开关\r\n </p>\r\n )}\r\n </div>\r\n </div>\r\n\r\n {/* S3存储桶配置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n S3存储桶配置\r\n </h2>\r\n <div className=\"space-y-4\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 配置S3对象存储,用于存储上传的图片和文件\r\n </p>\r\n\r\n {/* S3端点 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n S3端点 (Endpoint)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Endpoint}\r\n onChange={(e) => setS3Endpoint(e.target.value)}\r\n placeholder=\"例如: s3.1080p.hk\"\r\n 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\"\r\n />\r\n </div>\r\n\r\n {/* Access Key & Secret Key */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Access Key (访问密钥)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3AccessKey}\r\n onChange={(e) => setS3AccessKey(e.target.value)}\r\n placeholder=\"输入Access Key\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Secret Key (密钥)\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={s3SecretKey}\r\n onChange={(e) => setS3SecretKey(e.target.value)}\r\n placeholder=\"输入Secret Key\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* Bucket & Region */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 存储桶名称 (Bucket)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Bucket}\r\n onChange={(e) => setS3Bucket(e.target.value)}\r\n placeholder=\"例如: my-bucket\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 区域 (Region)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Region}\r\n onChange={(e) => setS3Region(e.target.value)}\r\n placeholder=\"例如: us-east-1\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* 公网访问URL */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 公网访问URL (Public URL)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3PublicUrl}\r\n onChange={(e) => setS3PublicUrl(e.target.value)}\r\n placeholder=\"例如: https://cdn.example.com\"\r\n 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\"\r\n />\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\r\n 如果配置了CDN或自定义域名,请填写此项\r\n </p>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-semibold mb-2\">配置说明:</p>\r\n <ul className=\"space-y-1 list-disc list-inside\">\r\n <li>S3端点通常是您的对象存储服务器地址</li>\r\n <li>Access Key和Secret Key用于身份验证</li>\r\n <li>存储桶需要预先创建并设置公开读取权限</li>\r\n <li>修改配置后需要重启应用才能生效</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </main>\r\n </div>\r\n )\r\n}\r\n\r\n" + "contentsAfterEdit": "'use client'\r\n\r\nimport { useEffect, useState } from 'react'\r\nimport { useSession } from 'next-auth/react'\r\nimport { useRouter } from 'next/navigation'\r\nimport Header from '@/components/Header'\r\n\r\nexport default function SystemSettings() {\r\n const { data: session, status } = useSession()\r\n const router = useRouter()\r\n const [loading, setLoading] = useState(true)\r\n const [saving, setSaving] = useState(false)\r\n const [uploading, setUploading] = useState(false)\r\n\r\n // 设置状态\r\n const [headerBgColor, setHeaderBgColor] = useState('#1f2937')\r\n const [siteName, setSiteName] = useState('FLNXI素材库')\r\n const [headerLogoUrl, setHeaderLogoUrl] = useState('')\r\n const [headerLogoPreview, setHeaderLogoPreview] = useState('')\r\n const [loginLogoUrl, setLoginLogoUrl] = useState('')\r\n const [loginLogoPreview, setLoginLogoPreview] = useState('')\r\n const [uploadingHeader, setUploadingHeader] = useState(false)\r\n const [uploadingLogin, setUploadingLogin] = useState(false)\r\n \r\n // S3配置状态\r\n const [s3Endpoint, setS3Endpoint] = useState('')\r\n const [s3AccessKey, setS3AccessKey] = useState('')\r\n const [s3SecretKey, setS3SecretKey] = useState('')\r\n const [s3Bucket, setS3Bucket] = useState('')\r\n const [s3Region, setS3Region] = useState('us-east-1')\r\n const [s3PublicUrl, setS3PublicUrl] = useState('')\r\n \r\n // 功能开关状态\r\n const [enableUserActivation, setEnableUserActivation] = useState(false)\r\n const [enableRegistration, setEnableRegistration] = useState(true)\r\n const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)\r\n const [enableTunnelService, setEnableTunnelService] = useState(false)\r\n\r\n // 权限检查\r\n useEffect(() => {\r\n if (status === 'unauthenticated') {\r\n router.push('/login')\r\n } else if (status === 'authenticated' && session?.user?.role !== 'ADMIN') {\r\n router.push('/')\r\n }\r\n }, [status, session, router])\r\n\r\n // 加载设置\r\n useEffect(() => {\r\n if (status === 'authenticated' && session?.user?.role === 'ADMIN') {\r\n fetchSettings()\r\n }\r\n }, [status, session])\r\n\r\n const fetchSettings = async () => {\r\n try {\r\n const response = await fetch('/api/system-settings')\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderBgColor(data.headerBgColor || '#1f2937')\r\n setSiteName(data.siteName || 'FLNXI素材库')\r\n setHeaderLogoUrl(data.headerLogoUrl || '')\r\n setHeaderLogoPreview(data.headerLogoUrl || '')\r\n setLoginLogoUrl(data.loginLogoUrl || '')\r\n setLoginLogoPreview(data.loginLogoUrl || '')\r\n setS3Endpoint(data.s3Endpoint || '')\r\n setS3AccessKey(data.s3AccessKey || '')\r\n setS3SecretKey(data.s3SecretKey || '')\r\n setS3Bucket(data.s3Bucket || '')\r\n setS3Region(data.s3Region || 'us-east-1')\r\n setS3PublicUrl(data.s3PublicUrl || '')\r\n setEnableUserActivation(data.enableUserActivation ?? false)\r\n setEnableRegistration(data.enableRegistration ?? true)\r\n setEnableAiImageEdit(data.enableAiImageEdit ?? false)\r\n setEnableTunnelService(data.enableTunnelService ?? false)\r\n }\r\n } catch (error) {\r\n console.error('Error fetching settings:', error)\r\n alert('加载设置失败')\r\n } finally {\r\n setLoading(false)\r\n }\r\n }\r\n\r\n\r\n const handleSave = async () => {\r\n setSaving(true)\r\n try {\r\n const response = await fetch('/api/system-settings', {\r\n method: 'PUT',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n },\r\n body: JSON.stringify({\r\n headerBgColor,\r\n siteName,\r\n headerLogoUrl,\r\n loginLogoUrl,\r\n s3Endpoint,\r\n s3AccessKey,\r\n s3SecretKey,\r\n s3Bucket,\r\n s3Region,\r\n s3PublicUrl,\r\n enableUserActivation,\r\n enableRegistration,\r\n enableAiImageEdit,\r\n enableTunnelService\r\n }),\r\n })\r\n\r\n if (response.ok) {\r\n alert('保存成功!页面将自动刷新以应用新设置')\r\n // 刷新页面以应用新设置\r\n window.location.reload()\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || '保存失败')\r\n }\r\n } catch (error) {\r\n console.error('Save settings error:', error)\r\n alert('保存失败,请稍后重试')\r\n } finally {\r\n setSaving(false)\r\n }\r\n }\r\n\r\n const handleHeaderLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingHeader(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'header')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setHeaderLogoUrl(data.logoUrl)\r\n setHeaderLogoPreview(data.logoUrl)\r\n alert('导航栏LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingHeader(false)\r\n }\r\n }\r\n\r\n const handleLoginLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\r\n const file = e.target.files?.[0]\r\n if (!file) return\r\n\r\n // 验证文件类型\r\n if (!file.type.startsWith('image/')) {\r\n alert('请选择图片文件')\r\n return\r\n }\r\n\r\n // 验证文件大小(最大5MB)\r\n if (file.size > 5 * 1024 * 1024) {\r\n alert('LOGO文件大小不能超过5MB')\r\n return\r\n }\r\n\r\n setUploadingLogin(true)\r\n try {\r\n const formData = new FormData()\r\n formData.append('logo', file)\r\n formData.append('type', 'login')\r\n\r\n const response = await fetch('/api/system-settings/upload-logo', {\r\n method: 'POST',\r\n body: formData,\r\n })\r\n\r\n if (response.ok) {\r\n const data = await response.json()\r\n setLoginLogoUrl(data.logoUrl)\r\n setLoginLogoPreview(data.logoUrl)\r\n alert('登录页LOGO上传成功!')\r\n } else {\r\n const error = await response.json()\r\n alert(error.error || 'LOGO上传失败')\r\n }\r\n } catch (error) {\r\n console.error('Upload logo error:', error)\r\n alert('上传失败,请稍后重试')\r\n } finally {\r\n setUploadingLogin(false)\r\n }\r\n }\r\n\r\n const handleColorReset = () => {\r\n setHeaderBgColor('#1f2937')\r\n }\r\n\r\n const handleHeaderLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''\r\n setHeaderLogoPreview(def)\r\n setHeaderLogoUrl(def)\r\n }\r\n\r\n const handleLoginLogoReset = () => {\r\n const def = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''\r\n setLoginLogoPreview(def)\r\n setLoginLogoUrl(def)\r\n }\r\n\r\n if (status === 'loading' || loading) {\r\n return (\r\n <div className=\"min-h-screen flex items-center justify-center dark:bg-gray-900\">\r\n <div className=\"text-xl text-gray-600 dark:text-gray-400\">加载中...</div>\r\n </div>\r\n )\r\n }\r\n\r\n if (!session || session.user.role !== 'ADMIN') {\r\n return null\r\n }\r\n\r\n return (\r\n <div className=\"min-h-screen bg-gray-50 dark:bg-gray-900\">\r\n <Header session={session} />\r\n\r\n <main className=\"container mx-auto px-4 py-8 max-w-4xl\">\r\n <div className=\"mb-6\">\r\n <h1 className=\"text-3xl font-bold text-gray-900 dark:text-white mb-2\">系统设置</h1>\r\n <p className=\"text-gray-600 dark:text-gray-400\">自定义网站外观和品牌信息</p>\r\n </div>\r\n\r\n <div className=\"bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-8\">\r\n {/* 网站名称设置 */}\r\n <div>\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 网站名称\r\n </h2>\r\n <div className=\"space-y-2\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\r\n 站点名称\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={siteName}\r\n onChange={(e) => setSiteName(e.target.value)}\r\n placeholder=\"FLNXI素材库\"\r\n 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\"\r\n />\r\n <p className=\"text-sm text-gray-500 dark:text-gray-400\">\r\n 该名称将显示在顶部导航栏和登录页面\r\n </p>\r\n </div>\r\n </div>\r\n\r\n {/* 导航栏LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 导航栏LOGO(深色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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 }}>\r\n {headerLogoPreview ? (\r\n <img src={headerLogoPreview} alt=\"导航栏LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于深色导航栏,建议使用浅色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingHeader ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传导航栏LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleHeaderLogoUpload}\r\n disabled={uploadingHeader}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleHeaderLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingHeader}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 登录页LOGO设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 登录页LOGO(浅色背景)\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* LOGO预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 当前LOGO预览\r\n </label>\r\n <div className=\"flex items-center gap-4\">\r\n <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\">\r\n {loginLogoPreview ? (\r\n <img src={loginLogoPreview} alt=\"登录页LOGO\" className=\"max-w-full max-h-full object-contain\" />\r\n ) : (\r\n <svg className=\"w-16 h-16 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n )}\r\n </div>\r\n <div className=\"flex-1\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 用于浅色登录页面,建议使用深色LOGO\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400 mb-2\">\r\n 建议尺寸:200x200px,支持PNG、JPG、SVG格式\r\n </p>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 文件大小不超过5MB\r\n </p>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 上传按钮 */}\r\n <div className=\"flex gap-3\">\r\n <label className=\"btn-primary cursor-pointer inline-flex items-center gap-2 disabled:opacity-50\">\r\n {uploadingLogin ? (\r\n <>\r\n <svg className=\"w-5 h-5 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 上传登录页LOGO\r\n </>\r\n )}\r\n <input\r\n type=\"file\"\r\n accept=\"image/*\"\r\n onChange={handleLoginLogoUpload}\r\n disabled={uploadingLogin}\r\n className=\"hidden\"\r\n />\r\n </label>\r\n <button\r\n onClick={handleLoginLogoReset}\r\n className=\"btn-secondary\"\r\n disabled={uploadingLogin}\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 主题颜色设置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 主题颜色\r\n </h2>\r\n <div className=\"space-y-4\">\r\n {/* 颜色预览 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 颜色预览\r\n </label>\r\n <div\r\n className=\"w-full h-16 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner\"\r\n style={{ backgroundColor: headerBgColor }}\r\n >\r\n <div className=\"flex items-center h-full px-4\">\r\n <span className=\"text-white font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>\r\n 顶部导航栏预览\r\n </span>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 颜色选择器 */}\r\n <div className=\"flex items-center gap-4\">\r\n <div className=\"flex-1\">\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 选择颜色\r\n </label>\r\n <div className=\"flex items-center gap-2\">\r\n <input\r\n type=\"color\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n className=\"w-16 h-12 rounded cursor-pointer border border-gray-300 dark:border-gray-600\"\r\n />\r\n <input\r\n type=\"text\"\r\n value={headerBgColor}\r\n onChange={(e) => setHeaderBgColor(e.target.value)}\r\n placeholder=\"#1f2937\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 opacity-0\">\r\n 操作\r\n </label>\r\n <button\r\n onClick={handleColorReset}\r\n className=\"btn-secondary whitespace-nowrap\"\r\n >\r\n 恢复默认\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* 预设颜色 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 快速选择\r\n </label>\r\n <div className=\"flex flex-wrap gap-2\">\r\n {[\r\n { name: '默认灰', color: '#1f2937' },\r\n { name: '深蓝', color: '#1e3a8a' },\r\n { name: '靛蓝', color: '#4f46e5' },\r\n { name: '紫色', color: '#7c3aed' },\r\n { name: '粉色', color: '#db2777' },\r\n { name: '红色', color: '#dc2626' },\r\n { name: '橙色', color: '#ea580c' },\r\n { name: '绿色', color: '#059669' },\r\n { name: '青色', color: '#0891b2' },\r\n { name: '深灰', color: '#374151' },\r\n ].map((preset) => (\r\n <button\r\n key={preset.color}\r\n onClick={() => setHeaderBgColor(preset.color)}\r\n className={`px-4 py-2 rounded-lg border-2 transition-all ${\r\n headerBgColor === preset.color\r\n ? 'border-blue-500 ring-2 ring-blue-200'\r\n : 'border-gray-300 dark:border-gray-600 hover:border-gray-400'\r\n }`}\r\n style={{ backgroundColor: preset.color }}\r\n title={preset.color}\r\n >\r\n <span className=\"text-white text-sm font-medium\" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>\r\n {preset.name}\r\n </span>\r\n </button>\r\n ))}\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 保存按钮 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center\">\r\n <button\r\n onClick={() => router.push('/admin')}\r\n className=\"btn-secondary\"\r\n >\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" />\r\n </svg>\r\n 返回\r\n </button>\r\n <button\r\n onClick={handleSave}\r\n disabled={saving}\r\n className=\"btn-primary text-lg px-8 py-3 disabled:opacity-50\"\r\n >\r\n {saving ? (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2 animate-spin\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n 保存中...\r\n </>\r\n ) : (\r\n <>\r\n <svg className=\"w-5 h-5 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\r\n </svg>\r\n 保存设置\r\n </>\r\n )}\r\n </button>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-medium mb-1\">温馨提示:</p>\r\n <ul className=\"list-disc list-inside space-y-1\">\r\n <li>修改设置后点击&ldquo;保存设置&rdquo;按钮,页面会自动刷新以应用新的设置</li>\r\n <li>LOGO会自动上传到MinIO S3存储桶,确保全站可访问</li>\r\n <li>建议使用高质量的LOGO图片以获得最佳显示效果</li>\r\n <li>主题颜色建议选择深色系,以确保白色文字清晰可见;主题颜色会应用到导航栏、按钮等UI元素</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* 功能开关 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n </svg>\r\n 功能开关\r\n </h2>\r\n <div className=\"space-y-6\">\r\n {/* 用户激活功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户激活功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后,新注册的用户需要管理员激活才能登录。默认关闭。\r\n </p>\r\n </div>\r\n <label className={`relative inline-flex items-center ${!(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\r\n <input\r\n type=\"checkbox\"\r\n checked={enableUserActivation}\r\n onChange={(e) => setEnableUserActivation(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 仅最高权限账号可修改此开关\r\n </p>\r\n )}\r\n\r\n {/* 用户注册功能开关 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 用户手动注册功能\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 关闭后,用户将无法通过注册页面手动注册。默认开启。\r\n </p>\r\n </div>\r\n <label className=\"relative inline-flex items-center cursor-pointer\">\r\n <input\r\n type=\"checkbox\"\r\n checked={enableRegistration}\r\n onChange={(e) => setEnableRegistration(e.target.checked)}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n\r\n {/* AI改图功能 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n AI改图功能(预留)\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 控制素材详情页的 AI 改图按钮显示。功能目前为占位,启用后将连通正式 API。\r\n </p>\r\n </div>\r\n <label\r\n className={`relative inline-flex items-center ${\r\n !(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'\r\n }`}\r\n >\r\n <input\r\n type=\"checkbox\"\r\n checked={enableAiImageEdit}\r\n onChange={(e) => setEnableAiImageEdit(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n\r\n {/* 内网穿透功能 */}\r\n <div className=\"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg\">\r\n <div className=\"flex-1\">\r\n <h3 className=\"text-lg font-medium text-gray-900 dark:text-white mb-1\">\r\n 内网穿透功能(付费)\r\n </h3>\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 开启后可在后台调度付费内网穿透服务,目前仅展示占位操作。请确认购买服务后再启用。\r\n </p>\r\n </div>\r\n <label\r\n className={`relative inline-flex items-center ${\r\n !(session?.user as any)?.isSuperAdmin ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'\r\n }`}\r\n >\r\n <input\r\n type=\"checkbox\"\r\n checked={enableTunnelService}\r\n onChange={(e) => setEnableTunnelService(e.target.checked)}\r\n disabled={!(session?.user as any)?.isSuperAdmin}\r\n className=\"sr-only peer\"\r\n />\r\n <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>\r\n </label>\r\n </div>\r\n\r\n {!(session?.user as any)?.isSuperAdmin && (\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-2\">\r\n 最高权限账号才可修改 AI/内网穿透等受保护功能开关\r\n </p>\r\n )}\r\n </div>\r\n </div>\r\n\r\n {/* S3存储桶配置 */}\r\n <div className=\"border-t border-gray-200 dark:border-gray-700 pt-8\">\r\n <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center\">\r\n <svg className=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n <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\" />\r\n </svg>\r\n S3存储桶配置\r\n </h2>\r\n <div className=\"space-y-4\">\r\n <p className=\"text-sm text-gray-600 dark:text-gray-400\">\r\n 配置S3对象存储,用于存储上传的图片和文件\r\n </p>\r\n\r\n {/* S3端点 */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n S3端点 (Endpoint)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Endpoint}\r\n onChange={(e) => setS3Endpoint(e.target.value)}\r\n placeholder=\"例如: s3.1080p.hk\"\r\n 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\"\r\n />\r\n </div>\r\n\r\n {/* Access Key & Secret Key */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Access Key (访问密钥)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3AccessKey}\r\n onChange={(e) => setS3AccessKey(e.target.value)}\r\n placeholder=\"输入Access Key\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n Secret Key (密钥)\r\n </label>\r\n <input\r\n type=\"password\"\r\n value={s3SecretKey}\r\n onChange={(e) => setS3SecretKey(e.target.value)}\r\n placeholder=\"输入Secret Key\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* Bucket & Region */}\r\n <div className=\"grid grid-cols-2 gap-4\">\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 存储桶名称 (Bucket)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Bucket}\r\n onChange={(e) => setS3Bucket(e.target.value)}\r\n placeholder=\"例如: my-bucket\"\r\n 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\"\r\n />\r\n </div>\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 区域 (Region)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3Region}\r\n onChange={(e) => setS3Region(e.target.value)}\r\n placeholder=\"例如: us-east-1\"\r\n 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\"\r\n />\r\n </div>\r\n </div>\r\n\r\n {/* 公网访问URL */}\r\n <div>\r\n <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2\">\r\n 公网访问URL (Public URL)\r\n </label>\r\n <input\r\n type=\"text\"\r\n value={s3PublicUrl}\r\n onChange={(e) => setS3PublicUrl(e.target.value)}\r\n placeholder=\"例如: https://cdn.example.com\"\r\n 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\"\r\n />\r\n <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">\r\n 如果配置了CDN或自定义域名,请填写此项\r\n </p>\r\n </div>\r\n\r\n {/* 提示信息 */}\r\n <div className=\"bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-4\">\r\n <div className=\"flex items-start gap-3\">\r\n <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\">\r\n <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\" />\r\n </svg>\r\n <div className=\"text-sm text-blue-800 dark:text-blue-300\">\r\n <p className=\"font-semibold mb-2\">配置说明:</p>\r\n <ul className=\"space-y-1 list-disc list-inside\">\r\n <li>S3端点通常是您的对象存储服务器地址</li>\r\n <li>Access Key和Secret Key用于身份验证</li>\r\n <li>存储桶需要预先创建并设置公开读取权限</li>\r\n <li>修改配置后需要重启应用才能生效</li>\r\n </ul>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </main>\r\n </div>\r\n )\r\n}\r\n\r\n" } ``` User decision: **accepted** @@ -9236,7 +9236,7 @@ model Material { headerBgColor: '#1f2937', headerLogoUrl: defaultLogos.header, loginLogoUrl: defaultLogos.login, - siteName: '由壹点3D素材', + siteName: 'FLNXI素材库', enableUserActivation: false, enableRegistration: true, enableAiImageEdit: featureFlags.aiEditEnabled, @@ -12652,10 +12652,10 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -pw 'lxvhNYFZ4160' -ba ``` ``` - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed @@ -12754,7 +12754,7 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -pw 'lxvhNYFZ4160' -ba #20 [builder 7/9] RUN npm run build #20 1.188 -#20 1.188 > youyidian-3d-material@1.0.0 build +#20 1.188 > flnxi-material@1.0.0 build #20 1.188 > prisma generate && next build #20 1.188 #20 3.918 Prisma schema loaded from prisma/schema.prisma @@ -13924,7 +13924,7 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -pw 'lxvhNYFZ4160' -ba #20 [builder 7/9] RUN npm run build #20 0.912 -#20 0.912 > youyidian-3d-material@1.0.0 build +#20 0.912 > flnxi-material@1.0.0 build #20 0.912 > prisma generate && next build #20 0.912 #20 3.706 Prisma schema loaded from prisma/schema.prisma @@ -15306,7 +15306,7 @@ Result: "diff": { "chunks": [ { - "diffString": " context: .\r\n dockerfile: Dockerfile\r\n+ args:\r\n+ DATABASE_URL: ${DATABASE_URL}\r\n+ NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n+ APP_PUBLIC_URL: ${APP_PUBLIC_URL:-}\r\n container_name: youyidian-3d-material\r\n restart: unless-stopped\r", + "diffString": " context: .\r\n dockerfile: Dockerfile\r\n+ args:\r\n+ DATABASE_URL: ${DATABASE_URL}\r\n+ NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n+ APP_PUBLIC_URL: ${APP_PUBLIC_URL:-}\r\n container_name: flnxi-material\r\n restart: unless-stopped\r", "oldStart": 4, "newStart": 4, "oldLines": 4, @@ -15318,7 +15318,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM docker-compose.yml", - "contentsAfterEdit": "services:\r\n app:\r\n build:\r\n context: .\r\n dockerfile: Dockerfile\r\n args:\r\n DATABASE_URL: ${DATABASE_URL}\r\n NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n APP_PUBLIC_URL: ${APP_PUBLIC_URL:-}\r\n container_name: youyidian-3d-material\r\n restart: unless-stopped\r\n ports:\r\n - \"4320:3000\"\r\n extra_hosts:\r\n - \"host.docker.internal:host-gateway\"\r\n dns:\r\n - 223.5.5.5 # 阿里云DNS\r\n - 8.8.8.8 # Google DNS\r\n - 114.114.114.114 # 114DNS\r\n env_file:\r\n - .env\r\n environment:\r\n - NODE_ENV=production\r\n # 数据库配置从.env文件自动读取\r\n # DATABASE_URL=${DATABASE_URL}\r\n # DATABASE_DRIVER=${DATABASE_DRIVER}\r\n # 站点公共URL(可选,优先用于生成邀请链接等)\r\n # APP_PUBLIC_URL=${APP_PUBLIC_URL}\r\n - KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=\r\n - VECTOR_DB=pgvector\r\n - PGVECTOR_DB_URL=${DATABASE_URL}\r\n - STORAGE_PROVIDER=s3\r\n # S3配置从.env文件自动读取\r\n # S3_ENDPOINT=${S3_ENDPOINT}\r\n # S3_BUCKET=${S3_BUCKET}\r\n # S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}\r\n # S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}\r\n # S3_REGION=${S3_REGION}\r\n # S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}\r\n - S3_ENABLE_PATH_STYLE=1\r\n - S3_SET_ACL=1\r\n - S3_PREVIEW_URL_EXPIRE_IN=7200\r\n - NEXT_PUBLIC_S3_FILE_PATH=${S3_FILE_PATH:-由壹点AI/}\r\n # NextAuth配置从.env文件自动读取\r\n # NEXTAUTH_URL=${NEXTAUTH_URL}\r\n # NEXTAUTH_SECRET=${NEXTAUTH_SECRET}\r\n - GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E\r\n - GEMINI_API_BASE_URL=https://api.huandutech.com/v1\r\n - GEMINI_MODEL=gemini-2.0-flash-exp\r\n - DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f\r\n - DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY\r\n volumes:\r\n - ./uploads:/app/uploads\r\n\r\n" + "contentsAfterEdit": "services:\r\n app:\r\n build:\r\n context: .\r\n dockerfile: Dockerfile\r\n args:\r\n DATABASE_URL: ${DATABASE_URL}\r\n NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n APP_PUBLIC_URL: ${APP_PUBLIC_URL:-}\r\n container_name: flnxi-material\r\n restart: unless-stopped\r\n ports:\r\n - \"4320:3000\"\r\n extra_hosts:\r\n - \"host.docker.internal:host-gateway\"\r\n dns:\r\n - 223.5.5.5 # 阿里云DNS\r\n - 8.8.8.8 # Google DNS\r\n - 114.114.114.114 # 114DNS\r\n env_file:\r\n - .env\r\n environment:\r\n - NODE_ENV=production\r\n # 数据库配置从.env文件自动读取\r\n # DATABASE_URL=${DATABASE_URL}\r\n # DATABASE_DRIVER=${DATABASE_DRIVER}\r\n # 站点公共URL(可选,优先用于生成邀请链接等)\r\n # APP_PUBLIC_URL=${APP_PUBLIC_URL}\r\n - KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=\r\n - VECTOR_DB=pgvector\r\n - PGVECTOR_DB_URL=${DATABASE_URL}\r\n - STORAGE_PROVIDER=s3\r\n # S3配置从.env文件自动读取\r\n # S3_ENDPOINT=${S3_ENDPOINT}\r\n # S3_BUCKET=${S3_BUCKET}\r\n # S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}\r\n # S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}\r\n # S3_REGION=${S3_REGION}\r\n # S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}\r\n - S3_ENABLE_PATH_STYLE=1\r\n - S3_SET_ACL=1\r\n - S3_PREVIEW_URL_EXPIRE_IN=7200\r\n - NEXT_PUBLIC_S3_FILE_PATH=${S3_FILE_PATH:-由壹点AI/}\r\n # NextAuth配置从.env文件自动读取\r\n # NEXTAUTH_URL=${NEXTAUTH_URL}\r\n # NEXTAUTH_SECRET=${NEXTAUTH_SECRET}\r\n - GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E\r\n - GEMINI_API_BASE_URL=https://api.huandutech.com/v1\r\n - GEMINI_MODEL=gemini-2.0-flash-exp\r\n - DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f\r\n - DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY\r\n volumes:\r\n - ./uploads:/app/uploads\r\n\r\n" } ``` User decision: **accepted** @@ -15362,7 +15362,7 @@ Result: "diff": { "chunks": [ { - "diffString": " DATABASE_URL: ${DATABASE_URL}\r\n NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n- APP_PUBLIC_URL: ${APP_PUBLIC_URL:-}\r\n+ APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}\r\n container_name: youyidian-3d-material\r\n restart: unless-stopped\r", + "diffString": " DATABASE_URL: ${DATABASE_URL}\r\n NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n- APP_PUBLIC_URL: ${APP_PUBLIC_URL:-}\r\n+ APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}\r\n container_name: flnxi-material\r\n restart: unless-stopped\r", "oldStart": 7, "newStart": 7, "oldLines": 5, @@ -15375,7 +15375,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM docker-compose.yml", - "contentsAfterEdit": "services:\r\n app:\r\n build:\r\n context: .\r\n dockerfile: Dockerfile\r\n args:\r\n DATABASE_URL: ${DATABASE_URL}\r\n NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}\r\n container_name: youyidian-3d-material\r\n restart: unless-stopped\r\n ports:\r\n - \"4320:3000\"\r\n extra_hosts:\r\n - \"host.docker.internal:host-gateway\"\r\n dns:\r\n - 223.5.5.5 # 阿里云DNS\r\n - 8.8.8.8 # Google DNS\r\n - 114.114.114.114 # 114DNS\r\n env_file:\r\n - .env\r\n environment:\r\n - NODE_ENV=production\r\n # 数据库配置从.env文件自动读取\r\n # DATABASE_URL=${DATABASE_URL}\r\n # DATABASE_DRIVER=${DATABASE_DRIVER}\r\n # 站点公共URL(可选,优先用于生成邀请链接等)\r\n # APP_PUBLIC_URL=${APP_PUBLIC_URL}\r\n - KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=\r\n - VECTOR_DB=pgvector\r\n - PGVECTOR_DB_URL=${DATABASE_URL}\r\n - STORAGE_PROVIDER=s3\r\n # S3配置从.env文件自动读取\r\n # S3_ENDPOINT=${S3_ENDPOINT}\r\n # S3_BUCKET=${S3_BUCKET}\r\n # S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}\r\n # S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}\r\n # S3_REGION=${S3_REGION}\r\n # S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}\r\n - S3_ENABLE_PATH_STYLE=1\r\n - S3_SET_ACL=1\r\n - S3_PREVIEW_URL_EXPIRE_IN=7200\r\n - NEXT_PUBLIC_S3_FILE_PATH=${S3_FILE_PATH:-由壹点AI/}\r\n # NextAuth配置从.env文件自动读取\r\n # NEXTAUTH_URL=${NEXTAUTH_URL}\r\n # NEXTAUTH_SECRET=${NEXTAUTH_SECRET}\r\n - GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E\r\n - GEMINI_API_BASE_URL=https://api.huandutech.com/v1\r\n - GEMINI_MODEL=gemini-2.0-flash-exp\r\n - DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f\r\n - DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY\r\n volumes:\r\n - ./uploads:/app/uploads\r\n\r\n" + "contentsAfterEdit": "services:\r\n app:\r\n build:\r\n context: .\r\n dockerfile: Dockerfile\r\n args:\r\n DATABASE_URL: ${DATABASE_URL}\r\n NEXTAUTH_URL: ${NEXTAUTH_URL}\r\n APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}\r\n container_name: flnxi-material\r\n restart: unless-stopped\r\n ports:\r\n - \"4320:3000\"\r\n extra_hosts:\r\n - \"host.docker.internal:host-gateway\"\r\n dns:\r\n - 223.5.5.5 # 阿里云DNS\r\n - 8.8.8.8 # Google DNS\r\n - 114.114.114.114 # 114DNS\r\n env_file:\r\n - .env\r\n environment:\r\n - NODE_ENV=production\r\n # 数据库配置从.env文件自动读取\r\n # DATABASE_URL=${DATABASE_URL}\r\n # DATABASE_DRIVER=${DATABASE_DRIVER}\r\n # 站点公共URL(可选,优先用于生成邀请链接等)\r\n # APP_PUBLIC_URL=${APP_PUBLIC_URL}\r\n - KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=\r\n - VECTOR_DB=pgvector\r\n - PGVECTOR_DB_URL=${DATABASE_URL}\r\n - STORAGE_PROVIDER=s3\r\n # S3配置从.env文件自动读取\r\n # S3_ENDPOINT=${S3_ENDPOINT}\r\n # S3_BUCKET=${S3_BUCKET}\r\n # S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}\r\n # S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}\r\n # S3_REGION=${S3_REGION}\r\n # S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}\r\n - S3_ENABLE_PATH_STYLE=1\r\n - S3_SET_ACL=1\r\n - S3_PREVIEW_URL_EXPIRE_IN=7200\r\n - NEXT_PUBLIC_S3_FILE_PATH=${S3_FILE_PATH:-由壹点AI/}\r\n # NextAuth配置从.env文件自动读取\r\n # NEXTAUTH_URL=${NEXTAUTH_URL}\r\n # NEXTAUTH_SECRET=${NEXTAUTH_SECRET}\r\n - GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E\r\n - GEMINI_API_BASE_URL=https://api.huandutech.com/v1\r\n - GEMINI_MODEL=gemini-2.0-flash-exp\r\n - DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f\r\n - DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY\r\n volumes:\r\n - ./uploads:/app/uploads\r\n\r\n" } ``` User decision: **accepted** @@ -15544,7 +15544,7 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -pw 'lxvhNYFZ4160' -ba #20 [builder 7/9] RUN npm run build #20 1.171 -#20 1.171 > youyidian-3d-material@1.0.0 build +#20 1.171 > flnxi-material@1.0.0 build #20 1.171 > prisma generate && next build #20 1.171 #20 3.761 Prisma schema loaded from prisma/schema.prisma @@ -15851,10 +15851,10 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -pw 'lxvhNYFZ4160' -ba youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started ```
@@ -22204,7 +22204,7 @@ cd D:\sucaiku\flnxi_sucai; npm run lint ``` -> youyidian-3d-material@1.0.0 lint +> flnxi-material@1.0.0 lint > next lint @@ -23270,10 +23270,10 @@ cd D:\sucaiku\flnxi_sucai; powershell -ExecutionPolicy Bypass -File scripts\depl ``` 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose down --timeout 30 || true - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed 鉁?瀹屾垚 @@ -23358,7 +23358,7 @@ cd D:\sucaiku\flnxi_sucai; powershell -ExecutionPolicy Bypass -File scripts\depl #20 [builder 7/9] RUN npm run build #20 0.859 -#20 0.859 > youyidian-3d-material@1.0.0 build +#20 0.859 > flnxi-material@1.0.0 build #20 0.859 > prisma generate && next build #20 0.859 #20 3.113 Prisma schema loaded from prisma/schema.prisma @@ -23476,7 +23476,7 @@ failed to solve: process "/bin/sh -c npm run build" did not complete s #20 [builder 7/9] RUN npm run build #20 1.108 -#20 1.108 > youyidian-3d-material@1.0.0 build +#20 1.108 > flnxi-material@1.0.0 build #20 1.108 > prisma generate && next build #20 1.108 #20 3.519 Prisma schema loaded from prisma/schema.prisma @@ -23754,7 +23754,7 @@ cd D:\sucaiku\flnxi_sucai; powershell -ExecutionPolicy Bypass -File scripts\depl #20 [builder 7/9] RUN npm run build #20 1.127 -#20 1.127 > youyidian-3d-material@1.0.0 build +#20 1.127 > flnxi-material@1.0.0 build #20 1.127 > prisma generate && next build #20 1.127 #20 3.427 Prisma schema loaded from prisma/schema.prisma @@ -24058,10 +24058,10 @@ cd D:\sucaiku\flnxi_sucai; powershell -ExecutionPolicy Bypass -File scripts\depl youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started 鉁?瀹屾垚 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma || docker-compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma @@ -24087,7 +24087,7 @@ npm notice 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose ps app || docker-compose ps app NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app 11 minutes ago Up 11 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app 11 minutes ago Up 11 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp 鉁?瀹屾垚 @@ -24189,7 +24189,7 @@ Deployment完成,当前容器已重新构建并启动。 - **远程构建**:通过 `scripts/deploy/run-remote-upgrade.ps1` 执行 `docker compose up -d --build app`,整体构建成功,仅保留原有的 ESLint warning 与 Next.js 的动态路由提示。 - **数据库**:`docker compose exec app npx prisma db push …` 已运行;日志提示缺少 `@prisma/client` wasm 运行时(通常是 npm 的瞬时拉取问题),但命令返回成功并显示数据库 Schema 已同步,可关注后续是否需要补装相关 runtime。 -容器状态:`youyidian-3d-material` 已启动并监听 `4320 -> 3000`。 +容器状态:`flnxi-material` 已启动并监听 `4320 -> 3000`。 如需继续验证,可: 1. 访问后台 `/admin` 检查模板管理与组织树。 @@ -24233,7 +24233,7 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 ``` NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app About an hour ago Up 9 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app About an hour ago Up 9 minutes 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp ```
@@ -24254,69 +24254,69 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 ``` ``` -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 320ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 344ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 289ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 340ms +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 320ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 344ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 289ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 340ms ```
@@ -24404,7 +24404,7 @@ cd D:\sucaiku\flnxi_sucai; npm run build ``` -> youyidian-3d-material@1.0.0 build +> flnxi-material@1.0.0 build > prisma generate && next build Environment variables loaded from .env @@ -25218,10 +25218,10 @@ cd D:\sucaiku\flnxi_sucai; powershell -ExecutionPolicy Bypass -File scripts\depl ``` 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose down --timeout 30 || true - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed 鉁?瀹屾垚 @@ -25494,7 +25494,7 @@ cd D:\sucaiku\flnxi_sucai; powershell -ExecutionPolicy Bypass -File scripts\depl #20 [builder 7/9] RUN npm run build #20 1.240 -#20 1.240 > youyidian-3d-material@1.0.0 build +#20 1.240 > flnxi-material@1.0.0 build #20 1.240 > prisma generate && next build #20 1.240 #20 3.632 Prisma schema loaded from prisma/schema.prisma @@ -25798,10 +25798,10 @@ cd D:\sucaiku\flnxi_sucai; powershell -ExecutionPolicy Bypass -File scripts\depl youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started 鉁?瀹屾垚 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma || docker-compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma @@ -25827,7 +25827,7 @@ npm notice 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose ps app || docker-compose ps app NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp 鉁?瀹屾垚 @@ -25866,12 +25866,12 @@ powershell -Command "& 'C:\Program Files\PuTTY\plink.exe' -ssh -pw 'lxvhNYFZ4160 ``` ``` -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 269ms +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 269ms ```
@@ -26153,9 +26153,9 @@ Headers : {[Vary, RSC, Next-Router-State-Tree, Next-Router-Prefetch, A Images : {@{innerHTML=; innerText=; outerHTML=由壹点3D素材; outerText=; tagName=IMG + ent" alt=FLNXI素材库 src="" width=80 height=80 data-nimg="1" decoding="async">; outerText=; tagName=IMG - ; class=rounded-apple shadow-apple; style=COLOR: transparent; alt=由壹点3D素材; src=; width=80; height=8 + ; class=rounded-apple shadow-apple; style=COLOR: transparent; alt=FLNXI素材库; src=; width=80; height=8 0; data-nimg=1; decoding=async}} @@ -26288,7 +26288,7 @@ C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -Command "& 'C:\Progra ``` % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed - 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0<!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/a841c60c4985eefe.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js"/><script src="/_next/static/chunks/fd9d1056-1ac2737fcdb1471b.js" async=""></script><script src="/_next/static/chunks/117-6f18816f2fe94a8e.js" async=""></script><script src="/_next/static/chunks/main-app-6fca1515cee9a03c.js" async=""></script><script src="/_next/static/chunks/605-5363e87410cec951.js" async=""></script><script src="/_next/static/chunks/785-0b1c1554ae914634.js" async=""></script><script src="/_next/static/chunks/648-0bfb552819a17e5e.js" async=""></script><script src="/_next/static/chunks/app/login/page-87a6a4bc2c11073c.js" async=""></script><script src="/_next/static/chunks/app/layout-d49c608feac6ab22.js" async=""></script><title>由壹点3D素材</title><meta name="description" content="3D素材分享平台"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="font-sans antialiased"><div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4"><div class="max-w-md w-full"><div class="text-center mb-8"><div class="flex justify-center mb-4"><img alt="由壹点3D素材" width="80" height="80" decoding="async" data-nimg="1" class="rounded-apple shadow-apple" style="color:transparent" src=""/></div><h1 class="text-3xl font-bold text-gray-900">由壹点3D素材</h1><p class="text-gray-600 mt-2">欢迎回来,请登录您的账号</p></div><div class="card-apple p-8"><form class="space-y-6"><div><label class="block text-sm font-medium text-gray-700 mb-2">邮箱</label><input type="email" class="input-apple" placeholder="your@email.com" required="" value=""/></div><div><label class="block text-sm font-medium text-gray-700 mb-2">密码</label><input type="password" class="input-apple" placeholder="••••••••" required="" value=""/></div><button type="submit" class="w-full btn-primary">登录</button></form><div class="mt-6"><div class="relative my-6"><div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-300"></div></div><div class="relative flex justify-center text-sm"><span class="px-2 bg-white text-gray-500">或</span></div></div><button id="dingtalk-login-btn" type="button" class="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 class="text-lg font-medium text-gray-700">钉钉登录</span></button></div><div class="mt-6 text-center"><p class="text-sm text-gray-600">还没有账号?<!-- --> <a class="text-gray-900 hover:text-gray-700 font-medium underline" href="/register">立即注册</a></p></div></div></div></div><script src="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/a841c60c4985eefe.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[6374,[\"605\",\"static/chunks/605-5363e87410cec951.js\",\"785\",\"static/chunks/785-0b1c1554ae914634.js\",\"648\",\"static/chunks/648-0bfb552819a17e5e.js\",\"626\",\"static/chunks/app/login/page-87a6a4bc2c11073c.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n8:I[9294,[\"605\",\"static/chunks/605-5363e87410cec951.js\",\"185\",\"static/chunks/app/layout-d49c608feac6ab22.js\"],\"default\"]\na:I[1060,[],\"\"]\nb:[]\n"])</script><script>self.__next_f.push([1,"0:[\"$\",\"$L2\",null,{\"buildId\":\"VXIXsB6Di3xRSrUNKou0J\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"login\"],\"initialTree\":[\"\",{\"children\":[\"login\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"login\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],null],null],null]},[null,[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"login\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\"}]],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/a841c60c4985eefe.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"className\":\"font-sans antialiased\",\"children\":[\"$\",\"$L8\",null,{\"children\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[]}]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L9\"],\"globalErrorComponent\":\"$a\",\"missingSlots\":\"$Wb\"}]\n"])</script><script>self.__next_f.push([1,"9:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"由壹点3D素材\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"3D素材分享平台\"}]]\n3:null\n"])</script></body></html> 100 7659 100 7659 0 0 12535 0 --:--:-- --:--:-- --:--:-- 12535 + 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0<!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/a841c60c4985eefe.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js"/><script src="/_next/static/chunks/fd9d1056-1ac2737fcdb1471b.js" async=""></script><script src="/_next/static/chunks/117-6f18816f2fe94a8e.js" async=""></script><script src="/_next/static/chunks/main-app-6fca1515cee9a03c.js" async=""></script><script src="/_next/static/chunks/605-5363e87410cec951.js" async=""></script><script src="/_next/static/chunks/785-0b1c1554ae914634.js" async=""></script><script src="/_next/static/chunks/648-0bfb552819a17e5e.js" async=""></script><script src="/_next/static/chunks/app/login/page-87a6a4bc2c11073c.js" async=""></script><script src="/_next/static/chunks/app/layout-d49c608feac6ab22.js" async=""></script><title>FLNXI素材库</title><meta name="description" content="3D素材分享平台"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="font-sans antialiased"><div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4"><div class="max-w-md w-full"><div class="text-center mb-8"><div class="flex justify-center mb-4"><img alt="FLNXI素材库" width="80" height="80" decoding="async" data-nimg="1" class="rounded-apple shadow-apple" style="color:transparent" src=""/></div><h1 class="text-3xl font-bold text-gray-900">FLNXI素材库</h1><p class="text-gray-600 mt-2">欢迎回来,请登录您的账号</p></div><div class="card-apple p-8"><form class="space-y-6"><div><label class="block text-sm font-medium text-gray-700 mb-2">邮箱</label><input type="email" class="input-apple" placeholder="your@email.com" required="" value=""/></div><div><label class="block text-sm font-medium text-gray-700 mb-2">密码</label><input type="password" class="input-apple" placeholder="••••••••" required="" value=""/></div><button type="submit" class="w-full btn-primary">登录</button></form><div class="mt-6"><div class="relative my-6"><div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-300"></div></div><div class="relative flex justify-center text-sm"><span class="px-2 bg-white text-gray-500">或</span></div></div><button id="dingtalk-login-btn" type="button" class="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 class="text-lg font-medium text-gray-700">钉钉登录</span></button></div><div class="mt-6 text-center"><p class="text-sm text-gray-600">还没有账号?<!-- --> <a class="text-gray-900 hover:text-gray-700 font-medium underline" href="/register">立即注册</a></p></div></div></div></div><script src="/_next/static/chunks/webpack-03f7c6bc932ce1e3.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/a841c60c4985eefe.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[6374,[\"605\",\"static/chunks/605-5363e87410cec951.js\",\"785\",\"static/chunks/785-0b1c1554ae914634.js\",\"648\",\"static/chunks/648-0bfb552819a17e5e.js\",\"626\",\"static/chunks/app/login/page-87a6a4bc2c11073c.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n8:I[9294,[\"605\",\"static/chunks/605-5363e87410cec951.js\",\"185\",\"static/chunks/app/layout-d49c608feac6ab22.js\"],\"default\"]\na:I[1060,[],\"\"]\nb:[]\n"])</script><script>self.__next_f.push([1,"0:[\"$\",\"$L2\",null,{\"buildId\":\"VXIXsB6Di3xRSrUNKou0J\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"login\"],\"initialTree\":[\"\",{\"children\":[\"login\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"login\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],null],null],null]},[null,[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"login\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\"}]],null]},[[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/a841c60c4985eefe.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"className\":\"font-sans antialiased\",\"children\":[\"$\",\"$L8\",null,{\"children\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[]}]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L9\"],\"globalErrorComponent\":\"$a\",\"missingSlots\":\"$Wb\"}]\n"])</script><script>self.__next_f.push([1,"9:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"FLNXI素材库\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"3D素材分享平台\"}]]\n3:null\n"])</script></body></html> 100 7659 100 7659 0 0 12535 0 --:--:-- --:--:-- --:--:-- 12535 ``` @@ -26416,206 +26416,206 @@ C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -Command "& 'C:\Progra ``` ``` -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 266ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 289ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 345ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 333ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 323ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 326ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 282ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 367ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 312ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 362ms -youyidian-3d-material | -youyidian-3d-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: -youyidian-3d-material | -youyidian-3d-material | Failed to deserialize constructor options. -youyidian-3d-material | -youyidian-3d-material | This usually happens when the javascript object passed to the constructor is missing -youyidian-3d-material | properties for the ConstructorOptions fields that must have some value. -youyidian-3d-material | -youyidian-3d-material | If you set some of these in javascript through environment variables, make sure there are -youyidian-3d-material | values for data_model, log_level, and any field that is not Option<T> -youyidian-3d-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } -youyidian-3d-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace -youyidian-3d-material | fatal runtime error: failed to initiate panic, error 5, aborting -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 346ms +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 266ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 289ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 345ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 333ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 323ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 326ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 282ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 367ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 312ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 362ms +flnxi-material | +flnxi-material | thread '<unnamed>' panicked at query-engine/query-engine-node-api/src/engine.rs:76:45: +flnxi-material | +flnxi-material | Failed to deserialize constructor options. +flnxi-material | +flnxi-material | This usually happens when the javascript object passed to the constructor is missing +flnxi-material | properties for the ConstructorOptions fields that must have some value. +flnxi-material | +flnxi-material | If you set some of these in javascript through environment variables, make sure there are +flnxi-material | values for data_model, log_level, and any field that is not Option<T> +flnxi-material | : Error { status: InvalidArg, reason: "missing field `enableTracing`", maybe_raw: 0x0 } +flnxi-material | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +flnxi-material | fatal runtime error: failed to initiate panic, error 5, aborting +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 346ms ``` @@ -26627,3 +26627,5 @@ youyidian-3d-material | ✓ Ready in 346ms --- + + diff --git a/.specstory/history/2025-11-13_00-57Z-继续讨论.md b/.specstory/history/2025-11-13_00-57Z-继续讨论.md index 1bfe2fc..1851597 100644 --- a/.specstory/history/2025-11-13_00-57Z-继续讨论.md +++ b/.specstory/history/2025-11-13_00-57Z-继续讨论.md @@ -188,10 +188,10 @@ powershell -ExecutionPolicy Bypass -File "d:\sucaiku\flnxi_sucai\scripts\deploy\ ``` 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose down --timeout 30 || true - Container youyidian-3d-material Stopping - Container youyidian-3d-material Stopped - Container youyidian-3d-material Removing - Container youyidian-3d-material Removed + Container flnxi-material Stopping + Container flnxi-material Stopped + Container flnxi-material Removing + Container flnxi-material Removed Network youyidian-3d_default Removing Network youyidian-3d_default Removed 鉁?瀹屾垚 @@ -320,10 +320,10 @@ powershell -ExecutionPolicy Bypass -File "d:\sucaiku\flnxi_sucai\scripts\deploy\ youyidian-3d-app Built Network youyidian-3d_default Creating Network youyidian-3d_default Created - Container youyidian-3d-material Creating - Container youyidian-3d-material Created - Container youyidian-3d-material Starting - Container youyidian-3d-material Started + Container flnxi-material Creating + Container flnxi-material Created + Container flnxi-material Starting + Container flnxi-material Started 鉁?瀹屾垚 鎵ц杩滅▼鍛戒护: sleep 20 @@ -345,16 +345,16 @@ npm notice 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose ps app || docker-compose ps app NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -youyidian-3d-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp +flnxi-material youyidian-3d-app "docker-entrypoint.s…" app About a minute ago Up About a minute 0.0.0.0:4320->3000/tcp, [::]:4320->3000/tcp 鉁?瀹屾垚 鎵ц杩滅▼鍛戒护: cd /root/youyidian-3d && docker compose logs app --tail=50 || docker-compose logs app --tail=50 -youyidian-3d-material | ▲ Next.js 14.2.33 -youyidian-3d-material | - Local: http://localhost:3000 -youyidian-3d-material | - Network: http://0.0.0.0:3000 -youyidian-3d-material | -youyidian-3d-material | ✓ Starting... -youyidian-3d-material | ✓ Ready in 210ms +flnxi-material | ▲ Next.js 14.2.33 +flnxi-material | - Local: http://localhost:3000 +flnxi-material | - Network: http://0.0.0.0:3000 +flnxi-material | +flnxi-material | ✓ Starting... +flnxi-material | ✓ Ready in 210ms 鉁?瀹屾垚 @@ -377,7 +377,7 @@ youyidian-3d-material | ✓ Ready in 210ms 3. **等待容器启动** - 等待 20 秒完成 4. **数据库迁移** - Prisma 数据库已与 schema 同步 5. **检查容器状态** - 容器正在运行 - - 容器名:`youyidian-3d-material` + - 容器名:`flnxi-material` - 端口映射:`0.0.0.0:4320->3000/tcp` - 状态:运行中(约 1 分钟前启动) 6. **查看应用日志** - Next.js 应用已启动 @@ -4576,3 +4576,4 @@ _**Agent (model default, mode Agent)**_ --- + diff --git a/.specstory/history/2025-11-19_08-42Z-统一代码构建和部署流程.md b/.specstory/history/2025-11-19_08-42Z-统一代码构建和部署流程.md index f267b8b..bcfa50e 100644 --- a/.specstory/history/2025-11-19_08-42Z-统一代码构建和部署流程.md +++ b/.specstory/history/2025-11-19_08-42Z-统一代码构建和部署流程.md @@ -437,7 +437,7 @@ Result: "diff": { "chunks": [ { - "diffString": " # 由壹点3D素材\n \n- 一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\n+ 一个遵循苹果设计语言的 3D 素材协作平台,聚焦企业/团队的私有化部署场景,内置 AI 辅助、部门权限、模板化元数据与可移植部署工具链。\n...\n- > ⚠️ 强烈建议:可迁移化部署时只需编辑根目录的 `config-bianliang.env`\n- >\n- > - 将你的域名、内网地址、图片域名白名单、CORS 白名单、钉钉回调、对象存储等统一填在该文件中\n- > - 系统启动会自动读取该文件:仅在变量“未设置”时注入,不会覆盖系统环境或 `.env/.env.local`\n- > - 修改后重启服务生效(重启 Node 或对应容器)\n- > - 前端可读变量以 `NEXT_PUBLIC_` 开头,服务端变量无需此前缀\n- \n- ## 🎯 项目目标\n- \n- **让其他公司能够一键部署或一条命令从Docker部署到自己的NAS或服务器上**\n- \n- ### 核心设计原则\n- - **性能开销优先** - 最小化资源消耗,确保在低配置设备上也能稳定运行\n- - **稳定性优先** - 确保长期稳定运行,减少崩溃和错误\n- - **易于部署** - 零配置或最小配置即可运行\n- \n- 详细规范请参考:[架构设计与编码规范](./架构设计与编码规范.md)\n- \n- ## 一键式可迁移配置(推荐)\n- \n- 不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。\n- 系统在启动时会自动读取该文件,只有当某个变量\"未设置\"时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。\n- \n- 适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。\n- \n- **快速部署指南**:[一键部署快速指南](./一键部署快速指南.md)\n- \n- 包含的关键项(全部已带中文注释):\n- - 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`\n- - 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`\n- - 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`\n- - 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`\n- - CORS 白名单:`ALLOWED_ORIGINS`\n- - 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`\n- \n- 提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。\n- \n- ## 技术栈\n- \n- - **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\n- - **认证**: NextAuth.js\n- - **数据库**: PostgreSQL + Prisma ORM\n- - **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\n- \n- ## 功能特性\n- \n- - ✅ 用户注册/登录系统\n- - ✅ 素材上传(预览图 + 压缩包)\n- - ✅ 素材浏览与下载\n- - ✅ 管理员后台管理\n- - ✅ 用户管理\n- - ✅ 苹果风格UI设计\n- \n- ## 快速开始\n- \n- ### 1. 安装依赖\n- \n- ```bash\n- npm install\n- ```\n- \n- ### 2. 配置环境变量\n- \n- 复制 `.env.example` 为 `.env` 并按需填写;部署时可结合 `config-bianliang.env` 管理差异化配置。\n- \n- ```bash\n- cp .env.example .env\n- ```\n- \n- - 如需在同一存储桶隔离测试与生产数据,可以设置 `S3_ENV_PREFIX`(例如 `testing/`),系统会自动在上传路径前添加该命名空间。\n- \n- ### 3. 初始化数据库\n- \n- ```bash\n- # 生成 Prisma Client\n- npm run prisma:generate\n- \n- # 推送数据库模式\n- npm run prisma:push\n- ```\n- \n- ### 4. 创建管理员账号\n- \n- 首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\n- \n- ```sql\n- UPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\n- ```\n- \n- ### 4. 快速自检(可选)\n- \n- ```bash\n- npm run check\n- ```\n- \n- ### 5. 启动开发服务器(Node 环境)\n- \n- ```bash\n- npm run dev\n- ```\n- \n- 访问 http://localhost:3000\n- \n- ## 🚀 部署到服务器\n- \n- ### 安全部署流程\n- \n- **重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\n- \n- > 可移植生产部署请优先参考:\n- > - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\n- \n- **快速部署:**\n- \n- 1. **本地测试**\n- ```bash\n- npm run dev\n- # 测试所有功能正常\n- ```\n- \n- 2. **部署前检查**\n- ```bash\n- # Windows\n- .\\部署前检查清单.bat\n- ```\n- \n- 3. **执行部署**\n- ```bash\n- # Windows PowerShell\n- .\\scripts\\deploy\\安全部署.ps1\n- \n- # Linux/Mac\n- bash scripts/deploy/安全部署.sh\n- ```\n- \n- **详细部署指南:**\n- - [安全部署流程](./docs/guides/安全部署流程.md)\n- - [安全部署说明](./docs/guides/安全部署说明.md)\n- - [安全部署指南](./docs/guides/安全部署指南.md)\n- \n- **部署检查清单:**\n- - [部署前检查清单](./部署前检查清单.bat)\n- \n- ## 项目结构\n- \n- ```\n- gongxiangp2/\n- ├── app/ # Next.js App Router\n- │ ├── api/ # API 路由\n- │ │ ├── auth/ # 认证相关\n- │ │ ├── materials/ # 素材相关\n- │ │ └── admin/ # 管理员相关\n- │ ├── login/ # 登录页面\n- │ ├── register/ # 注册页面\n- │ ├── admin/ # 管理员页面\n- │ └── page.tsx # 首页\n- ├── components/ # React 组件\n- │ ├── Header.tsx\n- │ └── UploadModal.tsx\n- ├── lib/ # 工具库\n- │ ├── prisma.ts # Prisma 客户端\n- │ ├── auth.ts # NextAuth 配置\n- │ └── s3.ts # S3 存储服务\n- ├── prisma/ # Prisma 配置\n- │ └── schema.prisma # 数据库模式\n- └── uploads/ # 本地上传文件存储\n- ```\n- \n- ## 数据库模型\n- \n- ### User (用户)\n- - id: UUID\n- - email: 邮箱\n- - username: 用户名\n- - password: 密码 (加密)\n- - role: 角色 (USER/ADMIN)\n- \n- ### Material (素材)\n- - id: UUID\n- - previewUrl: 预览图URL (S3)\n- - productModel: 商品型号\n- - sceneName: 场景名称\n- - authorName: 作者花名\n- - zipFileName: 压缩包文件名\n- - zipFilePath: 压缩包本地路径\n- - userId: 用户ID (外键)\n- \n- ## 部署\n- \n- ### 开发环境(Docker)\n- \n- ```bash\n- docker compose -f docker-compose.dev.yml up --build\n- ```\n- \n- ### 生产环境构建\n- \n- ```bash\n- docker compose build\n- docker compose up -d\n- ```\n- \n- ### 环境变量\n- \n- 确保在生产环境中设置所有必要的环境变量,特别是:\n- - `NEXTAUTH_SECRET`: 使用强随机字符串\n- - `NEXTAUTH_URL`: 设置为生产域名\n- - `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\n- \n- ## License\n- \n- MIT\n- \n- \n+ > ⚠️ 建议先阅读根目录 `config-bianliang.env`,将域名、白名单、S3、钉钉等变量集中管理。系统启动时只会补充“未设置”的变量,不会覆盖已有的系统环境或 `.env/.env.local`。\n+ \n+ ## 🎯 项目概览\n+ - **目标**:让其他团队能“一键部署 + 秒级迁移”,快速落地到自有 NAS、IDC 或混合网络环境。\n+ - **当前定位**:设计/电商团队素材仓库 + 管理驾驶舱 + AI 实验场。\n+ - **关键原则**:\n+ - 性能开销优先:组件懒加载、API 分页、S3 断点重试,保证低配服务器也能跑。\n+ - 稳定性优先:健康检查 `/api/health`、系统仪表盘 `/api/admin/system-status`、部署脚本全程日志。\n+ - 易部署:配置集中化、Docker Compose 套件、PowerShell/Bash 安全部署脚本、环境备份。\n+ \n+ 更多编码规范与约束可参考 `./架构设计与编码规范.md`、`./docs/PORTABLE_DEPLOYMENT_GUIDE.md`。\n+ \n+ ## 🌟 功能亮点(2025/11 最新版)\n+ \n+ ### 面向素材使用者\n+ - **门户体验**:首页卡片流展示、支持品牌/系列/SKU/渲染参数的多维搜索与筛选,附带点赞、下载、分享、榜单。\n+ - **AI 智能体**:`components/AIChatWidget.tsx` 绑定 Gemini,多模态对话、最多 5 张参考图上传、自动生成 4 张风格稿。\n+ - **实时互动**:`DanmakuWidget` 支持匿名弹幕;`RankingPanel` 展示贡献/下载/点赞排行榜,可拖拽定位。\n+ - **分享场景**:`/share/[id]` 公开链接可曝光素材详情(脱敏流量);`/complete-profile` 协助钉钉首次登录用户补全部门信息。\n+ - **网络自识别**:`/api/network-status` 根据 IP & 域名判断当前处于内/公网,自动提示内网地址。\n+ \n+ ### 面向管理员\n+ - **驾驶舱首页**:`/admin` 默认进入“监控仪表板”,展示 30 天新增、部门/角色分布、活跃度、最近上传等。\n+ - **权限与部门**:支持“部门管理员”操控子部门、折叠/搜索组织树、邀请链接 (`/api/invitations`) 与首位用户自动超级管理员 (`verify_first_user.js`)。\n+ - **模板/字段体系**:`TemplateManager` 内置多套电商模板,字段支持排序、可见性、下拉选项(单行文本规则),并与上传页/筛选器联动。\n+ - **分类配置**:分类/场景/平台均支持自定义下拉列表,后台统一管理(`/api/categories|scenes|platforms`)。\n+ - **素材深度信息**:`Material` 模型新增品牌、系列、SKU、材质、渲染耗时、输出分辨率、色彩模式等字段,全流程读写。\n+ - **系统设置**:LOGO、S3、注册/激活、AI 改图、内网穿透等开关存储于 `SystemSettings`,与 `config-bianliang.env` 协同。\n+ - **数据恢复**:`/api/restore-data` 可结合 S3 桶与本地 `uploads/` 批量匹配预览/压缩包并导入数据库(含管理员账号回填)。\n+ - **Ops 工具**:内置 `/api/tunnel/*` 占位接口用于 FRP/ZeroTier 控制,配合 `featureFlags.tunnelEnabled` 与 SystemSettings。\n+ \n+ ### 运维、部署与生态\n+ - `config-bianliang.env`:将域名、白名单、S3、钉钉、内外网地址、RESTORE 桶等集中管理,实现“拷贝项目即可部署”。\n+ - `scripts/deploy/`:包含 `安全部署.ps1/.sh`、`deploy-new-fixed.ps1`(阶段化进度)、`deploy-new.ps1`、`local-build-and-upload.ps1` 等脚本。\n+ - Docker 套件:`docker-compose.dev.yml`(带热更新)、`docker-compose.prod.yml`、`docker-compose.standard.yml`、`docker-compose-registry.yml` 等。\n+ - 诊断资源:`docs/guides/`、`docs/reports/`、`env-backups/`、`deploy-artifacts/`、`deploy-log.txt`、`temp_stdout/stderr`,便于回溯问题。\n+ \n+ ## 🧱 技术栈\n+ - **应用层**:Next.js 14 App Router、React 18、TypeScript、TailwindCSS。\n+ - **认证**:NextAuth.js + 邮箱密码 + 钉钉 OAuth。\n+ - **数据访问**:Prisma 5.22 + PostgreSQL(dev 默认 `prisma/dev.db` SQLite,生产建议 Postgres 15+)。\n+ - **存储**:MinIO / S3 兼容对象存储(预览图)+ 本地/NAS `uploads/`(压缩包)。\n+ - **AI 服务**:Google Gemini API(`GEMINI_API_KEY`),`/api/ai-chat`、`/api/ai/edit`。\n+ - **其他**:Next Middleware CORS、防火墙探测、bcrypt、AWS SDK v3、Formidable 上传、clsx/date-fns/zod。\n+ \n+ ## 🗂️ 项目结构速览\n+ \n+ ```\n+ .\n+ ├── app/\n+ │ ├── page.tsx # 门户首页\n+ │ ├── login|register|complete-profile\n+ │ ├── share/[id]/ # 公开分享页\n+ │ ├── dingtalk-callback/ # 钉钉扫码落地页\n+ │ ├── admin/\n+ │ │ ├── page.tsx # 后台入口 + 标签页\n+ │ │ ├── monitor/ # 仪表盘视图\n+ │ │ ├── settings/ # 系统设置\n+ │ │ └── _components/TemplateManager.tsx 等\n+ │ └── api/\n+ │ ├── materials|categories|scenes|platforms|rankings\n+ │ ├── admin/… # 权限、系统状态、用户\n+ │ ├── dingtalk/… # 钉钉登录 & 资料完善\n+ │ ├── ai-chat/ | ai/edit/ # Gemini 对话/改图\n+ │ ├── invitations|departments|tunnel|restore-data\n+ │ ├── danmaku|network-status|health\n+ │ └── …(详见 `app/api`)\n+ ├── components/ # UploadModal、EditMaterialModal、AIChatWidget、DanmakuWidget、RankingPanel…\n+ ├── lib/ # `auth.ts`、`prisma.ts`、`s3.ts`、`config.ts`、`network.ts`\n+ ├── prisma/ # schema、migrations、dev.db\n+ ├── scripts/ # deploy、tools、server、update-file-sizes…\n+ ├── docs/ # guides、reports、chats、WORKFLOW\n+ ├── config-bianliang.env # 可迁移化配置示例\n+ ├── docker-compose*.yml / Dockerfile\n+ ├── deploy-*.ps1 # 部署脚本\n+ ├── env-backups/ / deploy-artifacts/ # 产物与环境备份\n+ └── uploads/ # 本地压缩包目录\n+ ```\n+ \n+ ## ⚙️ 环境变量与配置\n+ \n+ ### 基础必填\n+ | 变量 | 用途 |\n+ | --- | --- |\n+ | `DATABASE_URL` | 指向 PostgreSQL 或 SQLite(开发)连接串 |\n+ | `NEXTAUTH_SECRET` | NextAuth 加密密钥 |\n+ | `NEXTAUTH_URL` / `APP_PUBLIC_URL` | 生成绝对链接、OAuth 回调、邀请链接 |\n+ | `S3_ENDPOINT` / `S3_BUCKET` / `S3_PUBLIC_DOMAIN` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_REGION` | MinIO/S3 上传、预览图读取 |\n+ | `S3_ENV_PREFIX` / `S3_FILE_PATH` | 同桶多环境隔离,前缀自动拼接 |\n+ | `ALLOWED_ORIGINS` | CORS 白名单(逗号分隔) |\n+ | `IMAGE_REMOTE_HOSTS` | Next/Image 远程域名(逗号分隔) |\n+ | `PUBLIC_DOMAINS` / `NEXT_PUBLIC_PUBLIC_DOMAINS` | 公网域名识别(含前端版本) |\n+ | `NEXT_PUBLIC_PORTAL_BASE_URL` / `NEXT_PUBLIC_INTRANET_URL` | 分享 & 网络状态提示 |\n+ | `NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL` / `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL` | 默认 LOGO |\n+ \n+ ### 可选增强\n+ | 变量 | 说明 |\n+ | --- | --- |\n+ | `GEMINI_API_KEY` / `GEMINI_API_BASE_URL` / `GEMINI_MODEL` | AI 聊天/生成配置 |\n+ | `DINGTALK_CLIENT_ID` / `DINGTALK_CLIENT_SECRET` | 钉钉扫码登录 |\n+ | `NEXT_PUBLIC_DINGTALK_CALLBACK_URL` | 前端跳转地址 |\n+ | `RESTORE_S3_BUCKET_URL` / `RESTORE_S3_PUBLIC_URL` | `/api/restore-data` 用于扫描历史素材 |\n+ | `AI_EDIT_ENABLED` / `TUNNEL_ENABLED` | Feature Flag(也可在 SystemSettings 开启) |\n+ | `APP_PUBLIC_URL` | 优先用于邀请链接、分享、邮件通知 |\n+ \n+ ### config-bianliang.env\n+ - 位置:项目根目录。\n+ - 适用:NAS、客户现场多域名切换、Batch 部署。\n+ - 生效顺序:`系统环境 > .env(.local) > config-bianliang.env`。\n+ - 修改后需重启 Node 或容器。\n+ \n+ ## 🚀 快速开始\n+ 1. **安装依赖**\n+ ```bash\n+ npm install\n+ ```\n+ 2. **复制环境模板并补齐配置**\n+ ```bash\n+ cp .env.example .env # 若在 Windows,可手动复制\n+ ```\n+ > 建议同步编辑 `config-bianliang.env`,以便后续迁移。\n+ 3. **数据库初始化**\n+ ```bash\n+ npm run prisma:generate\n+ npm run prisma:push # 或 `npx prisma migrate deploy`\n+ ```\n+ 4. **创建第一个账号**\n+ - 前台注册后,将该用户 `role` 更新为 `ADMIN`、`isSuperAdmin = true`。\n+ - 或运行 `node verify_first_user.js` 检查首位用户权限是否正确。\n+ 5. **可选自检**\n+ ```bash\n+ npm run check # type-check + lint + prisma validate\n+ npm run prisma:studio\n+ ```\n+ 6. **启动开发服务**\n+ ```bash\n+ npm run dev\n+ # http://localhost:3000\n+ ```\n+ 7. **Docker 开发模式**\n+ ```bash\n+ docker compose -f docker-compose.dev.yml up --build\n+ ```\n+ \n+ ## 🔧 常用脚本 & 工具\n+ - `npm run build`:自动执行 `prisma generate` 后构建 Next。\n+ - `npm run lint` / `npm run type-check`。\n+ - `npm run prisma:studio`、`npm run prisma:push`。\n+ - `node fix_user_permissions.js` / `update-file-sizes*.js` 等工具脚本。\n+ - `scripts/deploy/安全部署.ps1|.sh`:标准部署入口。\n+ - `deploy-new-fixed.ps1`:阶段化本地打包 -> 镜像 -> 推送 -> 远程 compose,自动轮询 `/api/health`。\n+ - `scripts/tools/quick-start.sh`:常用容器编排。\n+ \n+ ## 🗄️ 数据模型速览\n+ - **User**:`role`, `managedDepartment`, `isSuperAdmin`, `loginType`, `dingtalkUserId`, `canDownload`, `isActive`。\n+ - **Material**:`brandName`, `seriesName`, `skuCode`, `materialTexture`, `platformName`, `renderTime`, `imageResolution`, `colorMode`, `displayImages`, `templateId`。\n+ - **UploadTemplate / UploadField**:模板字段、布局、校验、下拉配置。\n+ - **Category / Scene / Platform**:支持 `options`(文本每行一个值)。\n+ - **Department**:树状管理(`parentId` 在 User 中维护)。\n+ - **Invitation**:多次/过期控制,生成邀请码链接。\n+ - **Danmaku / MaterialLike / FilterItem / SystemSettings**:互动、个性化和系统开关。\n+ \n+ 完整定义见 `prisma/schema.prisma`。\n+ \n+ ## 📦 部署与运维\n+ - **一键部署**:`./一键部署快速指南.md`、`./一键部署完整指南.md`、`./docs/guides/安全部署指南.md`。\n+ - **Docker**:`docker-compose.yml`(默认生产)、`docker-compose.prod.yml`(显式生产)、`docker-compose.standard.yml`(多服务)、`docker-compose-registry.yml`(私有镜像仓库)。\n+ - **PowerShell**:`deploy-fixed.ps1`、`deploy-new-fixed.ps1`(推荐)、`deploy-new.ps1`(实验版)。\n+ - **健康检查**:脚本完成后会轮询 `GET /api/health`,也可由外部监控使用 `HEAD /api/health`。\n+ - **日志与产物**:`deploy-log.txt`、`deploy-artifacts/*.tar`、`env-backups/*.env`。\n+ - **网络与安全**:`secure_ssh.ps1`、`scripts/server/*.sh`、`docs/guides/SSH安全评估报告.md`。\n+ \n+ ## 🆘 自检 & 排障\n+ - 前端浮动面板内含“网络检测”“AI 聊天”“弹幕”;后台仪表盘提供系统状态。\n+ - `logs/` 与 `temp_stdout|stderr.txt` 保存最近脚本输出。\n+ - `docs/guides/上传问题完整解决方案.md`、`docs/guides/FRP服务器诊断修复报告.md` 提供针对性排障。\n+ - `/api/admin/system-status` 若返回 403,检查是否使用管理员或超级管理员登录。\n+ - S3 上传失败将尝试 10 次自动重试(`lib/s3.ts`),最终报错请检查存储桶权限与 `S3_PUBLIC_DOMAIN`。\n+ \n+ ## 📚 文档入口\n+ - 开发流程:`docs/WORKFLOW.md`\n+ - 部署规范:`docs/PORTABLE_DEPLOYMENT_GUIDE.md`、`docs/guides/安全部署说明.md`\n+ - 需求实现记录:`docs/FEATURE_REQUESTS_IMPLEMENTATION.md`\n+ - 变更日志:`CHANGELOG.md`(示例:2025-11「权限进化 & 电商加成」版本)\n+ - 历史问题:`docs/reports/*`、`.specstory/history/*`\n+ \n+ ## 📝 License\n+ \n+ MIT", + "diffString": " # FLNXI素材库\n \n- 一个现代化的3D素材分享平台,采用苹果官方UI设计规范。\n+ 一个遵循苹果设计语言的 3D 素材协作平台,聚焦企业/团队的私有化部署场景,内置 AI 辅助、部门权限、模板化元数据与可移植部署工具链。\n...\n- > ⚠️ 强烈建议:可迁移化部署时只需编辑根目录的 `config-bianliang.env`\n- >\n- > - 将你的域名、内网地址、图片域名白名单、CORS 白名单、钉钉回调、对象存储等统一填在该文件中\n- > - 系统启动会自动读取该文件:仅在变量“未设置”时注入,不会覆盖系统环境或 `.env/.env.local`\n- > - 修改后重启服务生效(重启 Node 或对应容器)\n- > - 前端可读变量以 `NEXT_PUBLIC_` 开头,服务端变量无需此前缀\n- \n- ## 🎯 项目目标\n- \n- **让其他公司能够一键部署或一条命令从Docker部署到自己的NAS或服务器上**\n- \n- ### 核心设计原则\n- - **性能开销优先** - 最小化资源消耗,确保在低配置设备上也能稳定运行\n- - **稳定性优先** - 确保长期稳定运行,减少崩溃和错误\n- - **易于部署** - 零配置或最小配置即可运行\n- \n- 详细规范请参考:[架构设计与编码规范](./架构设计与编码规范.md)\n- \n- ## 一键式可迁移配置(推荐)\n- \n- 不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。\n- 系统在启动时会自动读取该文件,只有当某个变量\"未设置\"时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。\n- \n- 适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。\n- \n- **快速部署指南**:[一键部署快速指南](./一键部署快速指南.md)\n- \n- 包含的关键项(全部已带中文注释):\n- - 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`\n- - 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`\n- - 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`\n- - 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`\n- - CORS 白名单:`ALLOWED_ORIGINS`\n- - 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`\n- \n- 提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。\n- \n- ## 技术栈\n- \n- - **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS\n- - **认证**: NextAuth.js\n- - **数据库**: PostgreSQL + Prisma ORM\n- - **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)\n- \n- ## 功能特性\n- \n- - ✅ 用户注册/登录系统\n- - ✅ 素材上传(预览图 + 压缩包)\n- - ✅ 素材浏览与下载\n- - ✅ 管理员后台管理\n- - ✅ 用户管理\n- - ✅ 苹果风格UI设计\n- \n- ## 快速开始\n- \n- ### 1. 安装依赖\n- \n- ```bash\n- npm install\n- ```\n- \n- ### 2. 配置环境变量\n- \n- 复制 `.env.example` 为 `.env` 并按需填写;部署时可结合 `config-bianliang.env` 管理差异化配置。\n- \n- ```bash\n- cp .env.example .env\n- ```\n- \n- - 如需在同一存储桶隔离测试与生产数据,可以设置 `S3_ENV_PREFIX`(例如 `testing/`),系统会自动在上传路径前添加该命名空间。\n- \n- ### 3. 初始化数据库\n- \n- ```bash\n- # 生成 Prisma Client\n- npm run prisma:generate\n- \n- # 推送数据库模式\n- npm run prisma:push\n- ```\n- \n- ### 4. 创建管理员账号\n- \n- 首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。\n- \n- ```sql\n- UPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';\n- ```\n- \n- ### 4. 快速自检(可选)\n- \n- ```bash\n- npm run check\n- ```\n- \n- ### 5. 启动开发服务器(Node 环境)\n- \n- ```bash\n- npm run dev\n- ```\n- \n- 访问 http://localhost:3000\n- \n- ## 🚀 部署到服务器\n- \n- ### 安全部署流程\n- \n- **重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。\n- \n- > 可移植生产部署请优先参考:\n- > - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)\n- \n- **快速部署:**\n- \n- 1. **本地测试**\n- ```bash\n- npm run dev\n- # 测试所有功能正常\n- ```\n- \n- 2. **部署前检查**\n- ```bash\n- # Windows\n- .\\部署前检查清单.bat\n- ```\n- \n- 3. **执行部署**\n- ```bash\n- # Windows PowerShell\n- .\\scripts\\deploy\\安全部署.ps1\n- \n- # Linux/Mac\n- bash scripts/deploy/安全部署.sh\n- ```\n- \n- **详细部署指南:**\n- - [安全部署流程](./docs/guides/安全部署流程.md)\n- - [安全部署说明](./docs/guides/安全部署说明.md)\n- - [安全部署指南](./docs/guides/安全部署指南.md)\n- \n- **部署检查清单:**\n- - [部署前检查清单](./部署前检查清单.bat)\n- \n- ## 项目结构\n- \n- ```\n- gongxiangp2/\n- ├── app/ # Next.js App Router\n- │ ├── api/ # API 路由\n- │ │ ├── auth/ # 认证相关\n- │ │ ├── materials/ # 素材相关\n- │ │ └── admin/ # 管理员相关\n- │ ├── login/ # 登录页面\n- │ ├── register/ # 注册页面\n- │ ├── admin/ # 管理员页面\n- │ └── page.tsx # 首页\n- ├── components/ # React 组件\n- │ ├── Header.tsx\n- │ └── UploadModal.tsx\n- ├── lib/ # 工具库\n- │ ├── prisma.ts # Prisma 客户端\n- │ ├── auth.ts # NextAuth 配置\n- │ └── s3.ts # S3 存储服务\n- ├── prisma/ # Prisma 配置\n- │ └── schema.prisma # 数据库模式\n- └── uploads/ # 本地上传文件存储\n- ```\n- \n- ## 数据库模型\n- \n- ### User (用户)\n- - id: UUID\n- - email: 邮箱\n- - username: 用户名\n- - password: 密码 (加密)\n- - role: 角色 (USER/ADMIN)\n- \n- ### Material (素材)\n- - id: UUID\n- - previewUrl: 预览图URL (S3)\n- - productModel: 商品型号\n- - sceneName: 场景名称\n- - authorName: 作者花名\n- - zipFileName: 压缩包文件名\n- - zipFilePath: 压缩包本地路径\n- - userId: 用户ID (外键)\n- \n- ## 部署\n- \n- ### 开发环境(Docker)\n- \n- ```bash\n- docker compose -f docker-compose.dev.yml up --build\n- ```\n- \n- ### 生产环境构建\n- \n- ```bash\n- docker compose build\n- docker compose up -d\n- ```\n- \n- ### 环境变量\n- \n- 确保在生产环境中设置所有必要的环境变量,特别是:\n- - `NEXTAUTH_SECRET`: 使用强随机字符串\n- - `NEXTAUTH_URL`: 设置为生产域名\n- - `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。\n- \n- ## License\n- \n- MIT\n- \n- \n+ > ⚠️ 建议先阅读根目录 `config-bianliang.env`,将域名、白名单、S3、钉钉等变量集中管理。系统启动时只会补充“未设置”的变量,不会覆盖已有的系统环境或 `.env/.env.local`。\n+ \n+ ## 🎯 项目概览\n+ - **目标**:让其他团队能“一键部署 + 秒级迁移”,快速落地到自有 NAS、IDC 或混合网络环境。\n+ - **当前定位**:设计/电商团队素材仓库 + 管理驾驶舱 + AI 实验场。\n+ - **关键原则**:\n+ - 性能开销优先:组件懒加载、API 分页、S3 断点重试,保证低配服务器也能跑。\n+ - 稳定性优先:健康检查 `/api/health`、系统仪表盘 `/api/admin/system-status`、部署脚本全程日志。\n+ - 易部署:配置集中化、Docker Compose 套件、PowerShell/Bash 安全部署脚本、环境备份。\n+ \n+ 更多编码规范与约束可参考 `./架构设计与编码规范.md`、`./docs/PORTABLE_DEPLOYMENT_GUIDE.md`。\n+ \n+ ## 🌟 功能亮点(2025/11 最新版)\n+ \n+ ### 面向素材使用者\n+ - **门户体验**:首页卡片流展示、支持品牌/系列/SKU/渲染参数的多维搜索与筛选,附带点赞、下载、分享、榜单。\n+ - **AI 智能体**:`components/AIChatWidget.tsx` 绑定 Gemini,多模态对话、最多 5 张参考图上传、自动生成 4 张风格稿。\n+ - **实时互动**:`DanmakuWidget` 支持匿名弹幕;`RankingPanel` 展示贡献/下载/点赞排行榜,可拖拽定位。\n+ - **分享场景**:`/share/[id]` 公开链接可曝光素材详情(脱敏流量);`/complete-profile` 协助钉钉首次登录用户补全部门信息。\n+ - **网络自识别**:`/api/network-status` 根据 IP & 域名判断当前处于内/公网,自动提示内网地址。\n+ \n+ ### 面向管理员\n+ - **驾驶舱首页**:`/admin` 默认进入“监控仪表板”,展示 30 天新增、部门/角色分布、活跃度、最近上传等。\n+ - **权限与部门**:支持“部门管理员”操控子部门、折叠/搜索组织树、邀请链接 (`/api/invitations`) 与首位用户自动超级管理员 (`verify_first_user.js`)。\n+ - **模板/字段体系**:`TemplateManager` 内置多套电商模板,字段支持排序、可见性、下拉选项(单行文本规则),并与上传页/筛选器联动。\n+ - **分类配置**:分类/场景/平台均支持自定义下拉列表,后台统一管理(`/api/categories|scenes|platforms`)。\n+ - **素材深度信息**:`Material` 模型新增品牌、系列、SKU、材质、渲染耗时、输出分辨率、色彩模式等字段,全流程读写。\n+ - **系统设置**:LOGO、S3、注册/激活、AI 改图、内网穿透等开关存储于 `SystemSettings`,与 `config-bianliang.env` 协同。\n+ - **数据恢复**:`/api/restore-data` 可结合 S3 桶与本地 `uploads/` 批量匹配预览/压缩包并导入数据库(含管理员账号回填)。\n+ - **Ops 工具**:内置 `/api/tunnel/*` 占位接口用于 FRP/ZeroTier 控制,配合 `featureFlags.tunnelEnabled` 与 SystemSettings。\n+ \n+ ### 运维、部署与生态\n+ - `config-bianliang.env`:将域名、白名单、S3、钉钉、内外网地址、RESTORE 桶等集中管理,实现“拷贝项目即可部署”。\n+ - `scripts/deploy/`:包含 `安全部署.ps1/.sh`、`deploy-new-fixed.ps1`(阶段化进度)、`deploy-new.ps1`、`local-build-and-upload.ps1` 等脚本。\n+ - Docker 套件:`docker-compose.dev.yml`(带热更新)、`docker-compose.prod.yml`、`docker-compose.standard.yml`、`docker-compose-registry.yml` 等。\n+ - 诊断资源:`docs/guides/`、`docs/reports/`、`env-backups/`、`deploy-artifacts/`、`deploy-log.txt`、`temp_stdout/stderr`,便于回溯问题。\n+ \n+ ## 🧱 技术栈\n+ - **应用层**:Next.js 14 App Router、React 18、TypeScript、TailwindCSS。\n+ - **认证**:NextAuth.js + 邮箱密码 + 钉钉 OAuth。\n+ - **数据访问**:Prisma 5.22 + PostgreSQL(dev 默认 `prisma/dev.db` SQLite,生产建议 Postgres 15+)。\n+ - **存储**:MinIO / S3 兼容对象存储(预览图)+ 本地/NAS `uploads/`(压缩包)。\n+ - **AI 服务**:Google Gemini API(`GEMINI_API_KEY`),`/api/ai-chat`、`/api/ai/edit`。\n+ - **其他**:Next Middleware CORS、防火墙探测、bcrypt、AWS SDK v3、Formidable 上传、clsx/date-fns/zod。\n+ \n+ ## 🗂️ 项目结构速览\n+ \n+ ```\n+ .\n+ ├── app/\n+ │ ├── page.tsx # 门户首页\n+ │ ├── login|register|complete-profile\n+ │ ├── share/[id]/ # 公开分享页\n+ │ ├── dingtalk-callback/ # 钉钉扫码落地页\n+ │ ├── admin/\n+ │ │ ├── page.tsx # 后台入口 + 标签页\n+ │ │ ├── monitor/ # 仪表盘视图\n+ │ │ ├── settings/ # 系统设置\n+ │ │ └── _components/TemplateManager.tsx 等\n+ │ └── api/\n+ │ ├── materials|categories|scenes|platforms|rankings\n+ │ ├── admin/… # 权限、系统状态、用户\n+ │ ├── dingtalk/… # 钉钉登录 & 资料完善\n+ │ ├── ai-chat/ | ai/edit/ # Gemini 对话/改图\n+ │ ├── invitations|departments|tunnel|restore-data\n+ │ ├── danmaku|network-status|health\n+ │ └── …(详见 `app/api`)\n+ ├── components/ # UploadModal、EditMaterialModal、AIChatWidget、DanmakuWidget、RankingPanel…\n+ ├── lib/ # `auth.ts`、`prisma.ts`、`s3.ts`、`config.ts`、`network.ts`\n+ ├── prisma/ # schema、migrations、dev.db\n+ ├── scripts/ # deploy、tools、server、update-file-sizes…\n+ ├── docs/ # guides、reports、chats、WORKFLOW\n+ ├── config-bianliang.env # 可迁移化配置示例\n+ ├── docker-compose*.yml / Dockerfile\n+ ├── deploy-*.ps1 # 部署脚本\n+ ├── env-backups/ / deploy-artifacts/ # 产物与环境备份\n+ └── uploads/ # 本地压缩包目录\n+ ```\n+ \n+ ## ⚙️ 环境变量与配置\n+ \n+ ### 基础必填\n+ | 变量 | 用途 |\n+ | --- | --- |\n+ | `DATABASE_URL` | 指向 PostgreSQL 或 SQLite(开发)连接串 |\n+ | `NEXTAUTH_SECRET` | NextAuth 加密密钥 |\n+ | `NEXTAUTH_URL` / `APP_PUBLIC_URL` | 生成绝对链接、OAuth 回调、邀请链接 |\n+ | `S3_ENDPOINT` / `S3_BUCKET` / `S3_PUBLIC_DOMAIN` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_REGION` | MinIO/S3 上传、预览图读取 |\n+ | `S3_ENV_PREFIX` / `S3_FILE_PATH` | 同桶多环境隔离,前缀自动拼接 |\n+ | `ALLOWED_ORIGINS` | CORS 白名单(逗号分隔) |\n+ | `IMAGE_REMOTE_HOSTS` | Next/Image 远程域名(逗号分隔) |\n+ | `PUBLIC_DOMAINS` / `NEXT_PUBLIC_PUBLIC_DOMAINS` | 公网域名识别(含前端版本) |\n+ | `NEXT_PUBLIC_PORTAL_BASE_URL` / `NEXT_PUBLIC_INTRANET_URL` | 分享 & 网络状态提示 |\n+ | `NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL` / `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL` | 默认 LOGO |\n+ \n+ ### 可选增强\n+ | 变量 | 说明 |\n+ | --- | --- |\n+ | `GEMINI_API_KEY` / `GEMINI_API_BASE_URL` / `GEMINI_MODEL` | AI 聊天/生成配置 |\n+ | `DINGTALK_CLIENT_ID` / `DINGTALK_CLIENT_SECRET` | 钉钉扫码登录 |\n+ | `NEXT_PUBLIC_DINGTALK_CALLBACK_URL` | 前端跳转地址 |\n+ | `RESTORE_S3_BUCKET_URL` / `RESTORE_S3_PUBLIC_URL` | `/api/restore-data` 用于扫描历史素材 |\n+ | `AI_EDIT_ENABLED` / `TUNNEL_ENABLED` | Feature Flag(也可在 SystemSettings 开启) |\n+ | `APP_PUBLIC_URL` | 优先用于邀请链接、分享、邮件通知 |\n+ \n+ ### config-bianliang.env\n+ - 位置:项目根目录。\n+ - 适用:NAS、客户现场多域名切换、Batch 部署。\n+ - 生效顺序:`系统环境 > .env(.local) > config-bianliang.env`。\n+ - 修改后需重启 Node 或容器。\n+ \n+ ## 🚀 快速开始\n+ 1. **安装依赖**\n+ ```bash\n+ npm install\n+ ```\n+ 2. **复制环境模板并补齐配置**\n+ ```bash\n+ cp .env.example .env # 若在 Windows,可手动复制\n+ ```\n+ > 建议同步编辑 `config-bianliang.env`,以便后续迁移。\n+ 3. **数据库初始化**\n+ ```bash\n+ npm run prisma:generate\n+ npm run prisma:push # 或 `npx prisma migrate deploy`\n+ ```\n+ 4. **创建第一个账号**\n+ - 前台注册后,将该用户 `role` 更新为 `ADMIN`、`isSuperAdmin = true`。\n+ - 或运行 `node verify_first_user.js` 检查首位用户权限是否正确。\n+ 5. **可选自检**\n+ ```bash\n+ npm run check # type-check + lint + prisma validate\n+ npm run prisma:studio\n+ ```\n+ 6. **启动开发服务**\n+ ```bash\n+ npm run dev\n+ # http://localhost:3000\n+ ```\n+ 7. **Docker 开发模式**\n+ ```bash\n+ docker compose -f docker-compose.dev.yml up --build\n+ ```\n+ \n+ ## 🔧 常用脚本 & 工具\n+ - `npm run build`:自动执行 `prisma generate` 后构建 Next。\n+ - `npm run lint` / `npm run type-check`。\n+ - `npm run prisma:studio`、`npm run prisma:push`。\n+ - `node fix_user_permissions.js` / `update-file-sizes*.js` 等工具脚本。\n+ - `scripts/deploy/安全部署.ps1|.sh`:标准部署入口。\n+ - `deploy-new-fixed.ps1`:阶段化本地打包 -> 镜像 -> 推送 -> 远程 compose,自动轮询 `/api/health`。\n+ - `scripts/tools/quick-start.sh`:常用容器编排。\n+ \n+ ## 🗄️ 数据模型速览\n+ - **User**:`role`, `managedDepartment`, `isSuperAdmin`, `loginType`, `dingtalkUserId`, `canDownload`, `isActive`。\n+ - **Material**:`brandName`, `seriesName`, `skuCode`, `materialTexture`, `platformName`, `renderTime`, `imageResolution`, `colorMode`, `displayImages`, `templateId`。\n+ - **UploadTemplate / UploadField**:模板字段、布局、校验、下拉配置。\n+ - **Category / Scene / Platform**:支持 `options`(文本每行一个值)。\n+ - **Department**:树状管理(`parentId` 在 User 中维护)。\n+ - **Invitation**:多次/过期控制,生成邀请码链接。\n+ - **Danmaku / MaterialLike / FilterItem / SystemSettings**:互动、个性化和系统开关。\n+ \n+ 完整定义见 `prisma/schema.prisma`。\n+ \n+ ## 📦 部署与运维\n+ - **一键部署**:`./一键部署快速指南.md`、`./一键部署完整指南.md`、`./docs/guides/安全部署指南.md`。\n+ - **Docker**:`docker-compose.yml`(默认生产)、`docker-compose.prod.yml`(显式生产)、`docker-compose.standard.yml`(多服务)、`docker-compose-registry.yml`(私有镜像仓库)。\n+ - **PowerShell**:`deploy-fixed.ps1`、`deploy-new-fixed.ps1`(推荐)、`deploy-new.ps1`(实验版)。\n+ - **健康检查**:脚本完成后会轮询 `GET /api/health`,也可由外部监控使用 `HEAD /api/health`。\n+ - **日志与产物**:`deploy-log.txt`、`deploy-artifacts/*.tar`、`env-backups/*.env`。\n+ - **网络与安全**:`secure_ssh.ps1`、`scripts/server/*.sh`、`docs/guides/SSH安全评估报告.md`。\n+ \n+ ## 🆘 自检 & 排障\n+ - 前端浮动面板内含“网络检测”“AI 聊天”“弹幕”;后台仪表盘提供系统状态。\n+ - `logs/` 与 `temp_stdout|stderr.txt` 保存最近脚本输出。\n+ - `docs/guides/上传问题完整解决方案.md`、`docs/guides/FRP服务器诊断修复报告.md` 提供针对性排障。\n+ - `/api/admin/system-status` 若返回 403,检查是否使用管理员或超级管理员登录。\n+ - S3 上传失败将尝试 10 次自动重试(`lib/s3.ts`),最终报错请检查存储桶权限与 `S3_PUBLIC_DOMAIN`。\n+ \n+ ## 📚 文档入口\n+ - 开发流程:`docs/WORKFLOW.md`\n+ - 部署规范:`docs/PORTABLE_DEPLOYMENT_GUIDE.md`、`docs/guides/安全部署说明.md`\n+ - 需求实现记录:`docs/FEATURE_REQUESTS_IMPLEMENTATION.md`\n+ - 变更日志:`CHANGELOG.md`(示例:2025-11「权限进化 & 电商加成」版本)\n+ - 历史问题:`docs/reports/*`、`.specstory/history/*`\n+ \n+ ## 📝 License\n+ \n+ MIT", "oldStart": 1, "newStart": 1, "oldLines": 218, @@ -450,7 +450,7 @@ Result: }, "shouldAutoFixLints": true, "resultForModel": "Success. Updated the following files:\nM README.md", - "contentsAfterEdit": "# 由壹点3D素材\n\n一个遵循苹果设计语言的 3D 素材协作平台,聚焦企业/团队的私有化部署场景,内置 AI 辅助、部门权限、模板化元数据与可移植部署工具链。\n\n> ⚠️ 建议先阅读根目录 `config-bianliang.env`,将域名、白名单、S3、钉钉等变量集中管理。系统启动时只会补充“未设置”的变量,不会覆盖已有的系统环境或 `.env/.env.local`。\n\n## 🎯 项目概览\n- **目标**:让其他团队能“一键部署 + 秒级迁移”,快速落地到自有 NAS、IDC 或混合网络环境。\n- **当前定位**:设计/电商团队素材仓库 + 管理驾驶舱 + AI 实验场。\n- **关键原则**:\n - 性能开销优先:组件懒加载、API 分页、S3 断点重试,保证低配服务器也能跑。\n - 稳定性优先:健康检查 `/api/health`、系统仪表盘 `/api/admin/system-status`、部署脚本全程日志。\n - 易部署:配置集中化、Docker Compose 套件、PowerShell/Bash 安全部署脚本、环境备份。\n\n更多编码规范与约束可参考 `./架构设计与编码规范.md`、`./docs/PORTABLE_DEPLOYMENT_GUIDE.md`。\n\n## 🌟 功能亮点(2025/11 最新版)\n\n### 面向素材使用者\n- **门户体验**:首页卡片流展示、支持品牌/系列/SKU/渲染参数的多维搜索与筛选,附带点赞、下载、分享、榜单。\n- **AI 智能体**:`components/AIChatWidget.tsx` 绑定 Gemini,多模态对话、最多 5 张参考图上传、自动生成 4 张风格稿。\n- **实时互动**:`DanmakuWidget` 支持匿名弹幕;`RankingPanel` 展示贡献/下载/点赞排行榜,可拖拽定位。\n- **分享场景**:`/share/[id]` 公开链接可曝光素材详情(脱敏流量);`/complete-profile` 协助钉钉首次登录用户补全部门信息。\n- **网络自识别**:`/api/network-status` 根据 IP & 域名判断当前处于内/公网,自动提示内网地址。\n\n### 面向管理员\n- **驾驶舱首页**:`/admin` 默认进入“监控仪表板”,展示 30 天新增、部门/角色分布、活跃度、最近上传等。\n- **权限与部门**:支持“部门管理员”操控子部门、折叠/搜索组织树、邀请链接 (`/api/invitations`) 与首位用户自动超级管理员 (`verify_first_user.js`)。\n- **模板/字段体系**:`TemplateManager` 内置多套电商模板,字段支持排序、可见性、下拉选项(单行文本规则),并与上传页/筛选器联动。\n- **分类配置**:分类/场景/平台均支持自定义下拉列表,后台统一管理(`/api/categories|scenes|platforms`)。\n- **素材深度信息**:`Material` 模型新增品牌、系列、SKU、材质、渲染耗时、输出分辨率、色彩模式等字段,全流程读写。\n- **系统设置**:LOGO、S3、注册/激活、AI 改图、内网穿透等开关存储于 `SystemSettings`,与 `config-bianliang.env` 协同。\n- **数据恢复**:`/api/restore-data` 可结合 S3 桶与本地 `uploads/` 批量匹配预览/压缩包并导入数据库(含管理员账号回填)。\n- **Ops 工具**:内置 `/api/tunnel/*` 占位接口用于 FRP/ZeroTier 控制,配合 `featureFlags.tunnelEnabled` 与 SystemSettings。\n\n### 运维、部署与生态\n- `config-bianliang.env`:将域名、白名单、S3、钉钉、内外网地址、RESTORE 桶等集中管理,实现“拷贝项目即可部署”。\n- `scripts/deploy/`:包含 `安全部署.ps1/.sh`、`deploy-new-fixed.ps1`(阶段化进度)、`deploy-new.ps1`、`local-build-and-upload.ps1` 等脚本。\n- Docker 套件:`docker-compose.dev.yml`(带热更新)、`docker-compose.prod.yml`、`docker-compose.standard.yml`、`docker-compose-registry.yml` 等。\n- 诊断资源:`docs/guides/`、`docs/reports/`、`env-backups/`、`deploy-artifacts/`、`deploy-log.txt`、`temp_stdout/stderr`,便于回溯问题。\n\n## 🧱 技术栈\n- **应用层**:Next.js 14 App Router、React 18、TypeScript、TailwindCSS。\n- **认证**:NextAuth.js + 邮箱密码 + 钉钉 OAuth。\n- **数据访问**:Prisma 5.22 + PostgreSQL(dev 默认 `prisma/dev.db` SQLite,生产建议 Postgres 15+)。\n- **存储**:MinIO / S3 兼容对象存储(预览图)+ 本地/NAS `uploads/`(压缩包)。\n- **AI 服务**:Google Gemini API(`GEMINI_API_KEY`),`/api/ai-chat`、`/api/ai/edit`。\n- **其他**:Next Middleware CORS、防火墙探测、bcrypt、AWS SDK v3、Formidable 上传、clsx/date-fns/zod。\n\n## 🗂️ 项目结构速览\n\n```\n.\n├── app/\n│ ├── page.tsx # 门户首页\n│ ├── login|register|complete-profile\n│ ├── share/[id]/ # 公开分享页\n│ ├── dingtalk-callback/ # 钉钉扫码落地页\n│ ├── admin/\n│ │ ├── page.tsx # 后台入口 + 标签页\n│ │ ├── monitor/ # 仪表盘视图\n│ │ ├── settings/ # 系统设置\n│ │ └── _components/TemplateManager.tsx 等\n│ └── api/\n│ ├── materials|categories|scenes|platforms|rankings\n│ ├── admin/… # 权限、系统状态、用户\n│ ├── dingtalk/… # 钉钉登录 & 资料完善\n│ ├── ai-chat/ | ai/edit/ # Gemini 对话/改图\n│ ├── invitations|departments|tunnel|restore-data\n│ ├── danmaku|network-status|health\n│ └── …(详见 `app/api`)\n├── components/ # UploadModal、EditMaterialModal、AIChatWidget、DanmakuWidget、RankingPanel…\n├── lib/ # `auth.ts`、`prisma.ts`、`s3.ts`、`config.ts`、`network.ts`\n├── prisma/ # schema、migrations、dev.db\n├── scripts/ # deploy、tools、server、update-file-sizes…\n├── docs/ # guides、reports、chats、WORKFLOW\n├── config-bianliang.env # 可迁移化配置示例\n├── docker-compose*.yml / Dockerfile\n├── deploy-*.ps1 # 部署脚本\n├── env-backups/ / deploy-artifacts/ # 产物与环境备份\n└── uploads/ # 本地压缩包目录\n```\n\n## ⚙️ 环境变量与配置\n\n### 基础必填\n| 变量 | 用途 |\n| --- | --- |\n| `DATABASE_URL` | 指向 PostgreSQL 或 SQLite(开发)连接串 |\n| `NEXTAUTH_SECRET` | NextAuth 加密密钥 |\n| `NEXTAUTH_URL` / `APP_PUBLIC_URL` | 生成绝对链接、OAuth 回调、邀请链接 |\n| `S3_ENDPOINT` / `S3_BUCKET` / `S3_PUBLIC_DOMAIN` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_REGION` | MinIO/S3 上传、预览图读取 |\n| `S3_ENV_PREFIX` / `S3_FILE_PATH` | 同桶多环境隔离,前缀自动拼接 |\n| `ALLOWED_ORIGINS` | CORS 白名单(逗号分隔) |\n| `IMAGE_REMOTE_HOSTS` | Next/Image 远程域名(逗号分隔) |\n| `PUBLIC_DOMAINS` / `NEXT_PUBLIC_PUBLIC_DOMAINS` | 公网域名识别(含前端版本) |\n| `NEXT_PUBLIC_PORTAL_BASE_URL` / `NEXT_PUBLIC_INTRANET_URL` | 分享 & 网络状态提示 |\n| `NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL` / `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL` | 默认 LOGO |\n\n### 可选增强\n| 变量 | 说明 |\n| --- | --- |\n| `GEMINI_API_KEY` / `GEMINI_API_BASE_URL` / `GEMINI_MODEL` | AI 聊天/生成配置 |\n| `DINGTALK_CLIENT_ID` / `DINGTALK_CLIENT_SECRET` | 钉钉扫码登录 |\n| `NEXT_PUBLIC_DINGTALK_CALLBACK_URL` | 前端跳转地址 |\n| `RESTORE_S3_BUCKET_URL` / `RESTORE_S3_PUBLIC_URL` | `/api/restore-data` 用于扫描历史素材 |\n| `AI_EDIT_ENABLED` / `TUNNEL_ENABLED` | Feature Flag(也可在 SystemSettings 开启) |\n| `APP_PUBLIC_URL` | 优先用于邀请链接、分享、邮件通知 |\n\n### config-bianliang.env\n- 位置:项目根目录。\n- 适用:NAS、客户现场多域名切换、Batch 部署。\n- 生效顺序:`系统环境 > .env(.local) > config-bianliang.env`。\n- 修改后需重启 Node 或容器。\n\n## 🚀 快速开始\n1. **安装依赖**\n ```bash\n npm install\n ```\n2. **复制环境模板并补齐配置**\n ```bash\n cp .env.example .env # 若在 Windows,可手动复制\n ```\n > 建议同步编辑 `config-bianliang.env`,以便后续迁移。\n3. **数据库初始化**\n ```bash\n npm run prisma:generate\n npm run prisma:push # 或 `npx prisma migrate deploy`\n ```\n4. **创建第一个账号**\n - 前台注册后,将该用户 `role` 更新为 `ADMIN`、`isSuperAdmin = true`。\n - 或运行 `node verify_first_user.js` 检查首位用户权限是否正确。\n5. **可选自检**\n ```bash\n npm run check # type-check + lint + prisma validate\n npm run prisma:studio\n ```\n6. **启动开发服务**\n ```bash\n npm run dev\n # http://localhost:3000\n ```\n7. **Docker 开发模式**\n ```bash\n docker compose -f docker-compose.dev.yml up --build\n ```\n\n## 🔧 常用脚本 & 工具\n- `npm run build`:自动执行 `prisma generate` 后构建 Next。\n- `npm run lint` / `npm run type-check`。\n- `npm run prisma:studio`、`npm run prisma:push`。\n- `node fix_user_permissions.js` / `update-file-sizes*.js` 等工具脚本。\n- `scripts/deploy/安全部署.ps1|.sh`:标准部署入口。\n- `deploy-new-fixed.ps1`:阶段化本地打包 -> 镜像 -> 推送 -> 远程 compose,自动轮询 `/api/health`。\n- `scripts/tools/quick-start.sh`:常用容器编排。\n\n## 🗄️ 数据模型速览\n- **User**:`role`, `managedDepartment`, `isSuperAdmin`, `loginType`, `dingtalkUserId`, `canDownload`, `isActive`。\n- **Material**:`brandName`, `seriesName`, `skuCode`, `materialTexture`, `platformName`, `renderTime`, `imageResolution`, `colorMode`, `displayImages`, `templateId`。\n- **UploadTemplate / UploadField**:模板字段、布局、校验、下拉配置。\n- **Category / Scene / Platform**:支持 `options`(文本每行一个值)。\n- **Department**:树状管理(`parentId` 在 User 中维护)。\n- **Invitation**:多次/过期控制,生成邀请码链接。\n- **Danmaku / MaterialLike / FilterItem / SystemSettings**:互动、个性化和系统开关。\n\n完整定义见 `prisma/schema.prisma`。\n\n## 📦 部署与运维\n- **一键部署**:`./一键部署快速指南.md`、`./一键部署完整指南.md`、`./docs/guides/安全部署指南.md`。\n- **Docker**:`docker-compose.yml`(默认生产)、`docker-compose.prod.yml`(显式生产)、`docker-compose.standard.yml`(多服务)、`docker-compose-registry.yml`(私有镜像仓库)。\n- **PowerShell**:`deploy-fixed.ps1`、`deploy-new-fixed.ps1`(推荐)、`deploy-new.ps1`(实验版)。\n- **健康检查**:脚本完成后会轮询 `GET /api/health`,也可由外部监控使用 `HEAD /api/health`。\n- **日志与产物**:`deploy-log.txt`、`deploy-artifacts/*.tar`、`env-backups/*.env`。\n- **网络与安全**:`secure_ssh.ps1`、`scripts/server/*.sh`、`docs/guides/SSH安全评估报告.md`。\n\n## 🆘 自检 & 排障\n- 前端浮动面板内含“网络检测”“AI 聊天”“弹幕”;后台仪表盘提供系统状态。\n- `logs/` 与 `temp_stdout|stderr.txt` 保存最近脚本输出。\n- `docs/guides/上传问题完整解决方案.md`、`docs/guides/FRP服务器诊断修复报告.md` 提供针对性排障。\n- `/api/admin/system-status` 若返回 403,检查是否使用管理员或超级管理员登录。\n- S3 上传失败将尝试 10 次自动重试(`lib/s3.ts`),最终报错请检查存储桶权限与 `S3_PUBLIC_DOMAIN`。\n\n## 📚 文档入口\n- 开发流程:`docs/WORKFLOW.md`\n- 部署规范:`docs/PORTABLE_DEPLOYMENT_GUIDE.md`、`docs/guides/安全部署说明.md`\n- 需求实现记录:`docs/FEATURE_REQUESTS_IMPLEMENTATION.md`\n- 变更日志:`CHANGELOG.md`(示例:2025-11「权限进化 & 电商加成」版本)\n- 历史问题:`docs/reports/*`、`.specstory/history/*`\n\n## 📝 License\n\nMIT" + "contentsAfterEdit": "# FLNXI素材库\n\n一个遵循苹果设计语言的 3D 素材协作平台,聚焦企业/团队的私有化部署场景,内置 AI 辅助、部门权限、模板化元数据与可移植部署工具链。\n\n> ⚠️ 建议先阅读根目录 `config-bianliang.env`,将域名、白名单、S3、钉钉等变量集中管理。系统启动时只会补充“未设置”的变量,不会覆盖已有的系统环境或 `.env/.env.local`。\n\n## 🎯 项目概览\n- **目标**:让其他团队能“一键部署 + 秒级迁移”,快速落地到自有 NAS、IDC 或混合网络环境。\n- **当前定位**:设计/电商团队素材仓库 + 管理驾驶舱 + AI 实验场。\n- **关键原则**:\n - 性能开销优先:组件懒加载、API 分页、S3 断点重试,保证低配服务器也能跑。\n - 稳定性优先:健康检查 `/api/health`、系统仪表盘 `/api/admin/system-status`、部署脚本全程日志。\n - 易部署:配置集中化、Docker Compose 套件、PowerShell/Bash 安全部署脚本、环境备份。\n\n更多编码规范与约束可参考 `./架构设计与编码规范.md`、`./docs/PORTABLE_DEPLOYMENT_GUIDE.md`。\n\n## 🌟 功能亮点(2025/11 最新版)\n\n### 面向素材使用者\n- **门户体验**:首页卡片流展示、支持品牌/系列/SKU/渲染参数的多维搜索与筛选,附带点赞、下载、分享、榜单。\n- **AI 智能体**:`components/AIChatWidget.tsx` 绑定 Gemini,多模态对话、最多 5 张参考图上传、自动生成 4 张风格稿。\n- **实时互动**:`DanmakuWidget` 支持匿名弹幕;`RankingPanel` 展示贡献/下载/点赞排行榜,可拖拽定位。\n- **分享场景**:`/share/[id]` 公开链接可曝光素材详情(脱敏流量);`/complete-profile` 协助钉钉首次登录用户补全部门信息。\n- **网络自识别**:`/api/network-status` 根据 IP & 域名判断当前处于内/公网,自动提示内网地址。\n\n### 面向管理员\n- **驾驶舱首页**:`/admin` 默认进入“监控仪表板”,展示 30 天新增、部门/角色分布、活跃度、最近上传等。\n- **权限与部门**:支持“部门管理员”操控子部门、折叠/搜索组织树、邀请链接 (`/api/invitations`) 与首位用户自动超级管理员 (`verify_first_user.js`)。\n- **模板/字段体系**:`TemplateManager` 内置多套电商模板,字段支持排序、可见性、下拉选项(单行文本规则),并与上传页/筛选器联动。\n- **分类配置**:分类/场景/平台均支持自定义下拉列表,后台统一管理(`/api/categories|scenes|platforms`)。\n- **素材深度信息**:`Material` 模型新增品牌、系列、SKU、材质、渲染耗时、输出分辨率、色彩模式等字段,全流程读写。\n- **系统设置**:LOGO、S3、注册/激活、AI 改图、内网穿透等开关存储于 `SystemSettings`,与 `config-bianliang.env` 协同。\n- **数据恢复**:`/api/restore-data` 可结合 S3 桶与本地 `uploads/` 批量匹配预览/压缩包并导入数据库(含管理员账号回填)。\n- **Ops 工具**:内置 `/api/tunnel/*` 占位接口用于 FRP/ZeroTier 控制,配合 `featureFlags.tunnelEnabled` 与 SystemSettings。\n\n### 运维、部署与生态\n- `config-bianliang.env`:将域名、白名单、S3、钉钉、内外网地址、RESTORE 桶等集中管理,实现“拷贝项目即可部署”。\n- `scripts/deploy/`:包含 `安全部署.ps1/.sh`、`deploy-new-fixed.ps1`(阶段化进度)、`deploy-new.ps1`、`local-build-and-upload.ps1` 等脚本。\n- Docker 套件:`docker-compose.dev.yml`(带热更新)、`docker-compose.prod.yml`、`docker-compose.standard.yml`、`docker-compose-registry.yml` 等。\n- 诊断资源:`docs/guides/`、`docs/reports/`、`env-backups/`、`deploy-artifacts/`、`deploy-log.txt`、`temp_stdout/stderr`,便于回溯问题。\n\n## 🧱 技术栈\n- **应用层**:Next.js 14 App Router、React 18、TypeScript、TailwindCSS。\n- **认证**:NextAuth.js + 邮箱密码 + 钉钉 OAuth。\n- **数据访问**:Prisma 5.22 + PostgreSQL(dev 默认 `prisma/dev.db` SQLite,生产建议 Postgres 15+)。\n- **存储**:MinIO / S3 兼容对象存储(预览图)+ 本地/NAS `uploads/`(压缩包)。\n- **AI 服务**:Google Gemini API(`GEMINI_API_KEY`),`/api/ai-chat`、`/api/ai/edit`。\n- **其他**:Next Middleware CORS、防火墙探测、bcrypt、AWS SDK v3、Formidable 上传、clsx/date-fns/zod。\n\n## 🗂️ 项目结构速览\n\n```\n.\n├── app/\n│ ├── page.tsx # 门户首页\n│ ├── login|register|complete-profile\n│ ├── share/[id]/ # 公开分享页\n│ ├── dingtalk-callback/ # 钉钉扫码落地页\n│ ├── admin/\n│ │ ├── page.tsx # 后台入口 + 标签页\n│ │ ├── monitor/ # 仪表盘视图\n│ │ ├── settings/ # 系统设置\n│ │ └── _components/TemplateManager.tsx 等\n│ └── api/\n│ ├── materials|categories|scenes|platforms|rankings\n│ ├── admin/… # 权限、系统状态、用户\n│ ├── dingtalk/… # 钉钉登录 & 资料完善\n│ ├── ai-chat/ | ai/edit/ # Gemini 对话/改图\n│ ├── invitations|departments|tunnel|restore-data\n│ ├── danmaku|network-status|health\n│ └── …(详见 `app/api`)\n├── components/ # UploadModal、EditMaterialModal、AIChatWidget、DanmakuWidget、RankingPanel…\n├── lib/ # `auth.ts`、`prisma.ts`、`s3.ts`、`config.ts`、`network.ts`\n├── prisma/ # schema、migrations、dev.db\n├── scripts/ # deploy、tools、server、update-file-sizes…\n├── docs/ # guides、reports、chats、WORKFLOW\n├── config-bianliang.env # 可迁移化配置示例\n├── docker-compose*.yml / Dockerfile\n├── deploy-*.ps1 # 部署脚本\n├── env-backups/ / deploy-artifacts/ # 产物与环境备份\n└── uploads/ # 本地压缩包目录\n```\n\n## ⚙️ 环境变量与配置\n\n### 基础必填\n| 变量 | 用途 |\n| --- | --- |\n| `DATABASE_URL` | 指向 PostgreSQL 或 SQLite(开发)连接串 |\n| `NEXTAUTH_SECRET` | NextAuth 加密密钥 |\n| `NEXTAUTH_URL` / `APP_PUBLIC_URL` | 生成绝对链接、OAuth 回调、邀请链接 |\n| `S3_ENDPOINT` / `S3_BUCKET` / `S3_PUBLIC_DOMAIN` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_REGION` | MinIO/S3 上传、预览图读取 |\n| `S3_ENV_PREFIX` / `S3_FILE_PATH` | 同桶多环境隔离,前缀自动拼接 |\n| `ALLOWED_ORIGINS` | CORS 白名单(逗号分隔) |\n| `IMAGE_REMOTE_HOSTS` | Next/Image 远程域名(逗号分隔) |\n| `PUBLIC_DOMAINS` / `NEXT_PUBLIC_PUBLIC_DOMAINS` | 公网域名识别(含前端版本) |\n| `NEXT_PUBLIC_PORTAL_BASE_URL` / `NEXT_PUBLIC_INTRANET_URL` | 分享 & 网络状态提示 |\n| `NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL` / `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL` | 默认 LOGO |\n\n### 可选增强\n| 变量 | 说明 |\n| --- | --- |\n| `GEMINI_API_KEY` / `GEMINI_API_BASE_URL` / `GEMINI_MODEL` | AI 聊天/生成配置 |\n| `DINGTALK_CLIENT_ID` / `DINGTALK_CLIENT_SECRET` | 钉钉扫码登录 |\n| `NEXT_PUBLIC_DINGTALK_CALLBACK_URL` | 前端跳转地址 |\n| `RESTORE_S3_BUCKET_URL` / `RESTORE_S3_PUBLIC_URL` | `/api/restore-data` 用于扫描历史素材 |\n| `AI_EDIT_ENABLED` / `TUNNEL_ENABLED` | Feature Flag(也可在 SystemSettings 开启) |\n| `APP_PUBLIC_URL` | 优先用于邀请链接、分享、邮件通知 |\n\n### config-bianliang.env\n- 位置:项目根目录。\n- 适用:NAS、客户现场多域名切换、Batch 部署。\n- 生效顺序:`系统环境 > .env(.local) > config-bianliang.env`。\n- 修改后需重启 Node 或容器。\n\n## 🚀 快速开始\n1. **安装依赖**\n ```bash\n npm install\n ```\n2. **复制环境模板并补齐配置**\n ```bash\n cp .env.example .env # 若在 Windows,可手动复制\n ```\n > 建议同步编辑 `config-bianliang.env`,以便后续迁移。\n3. **数据库初始化**\n ```bash\n npm run prisma:generate\n npm run prisma:push # 或 `npx prisma migrate deploy`\n ```\n4. **创建第一个账号**\n - 前台注册后,将该用户 `role` 更新为 `ADMIN`、`isSuperAdmin = true`。\n - 或运行 `node verify_first_user.js` 检查首位用户权限是否正确。\n5. **可选自检**\n ```bash\n npm run check # type-check + lint + prisma validate\n npm run prisma:studio\n ```\n6. **启动开发服务**\n ```bash\n npm run dev\n # http://localhost:3000\n ```\n7. **Docker 开发模式**\n ```bash\n docker compose -f docker-compose.dev.yml up --build\n ```\n\n## 🔧 常用脚本 & 工具\n- `npm run build`:自动执行 `prisma generate` 后构建 Next。\n- `npm run lint` / `npm run type-check`。\n- `npm run prisma:studio`、`npm run prisma:push`。\n- `node fix_user_permissions.js` / `update-file-sizes*.js` 等工具脚本。\n- `scripts/deploy/安全部署.ps1|.sh`:标准部署入口。\n- `deploy-new-fixed.ps1`:阶段化本地打包 -> 镜像 -> 推送 -> 远程 compose,自动轮询 `/api/health`。\n- `scripts/tools/quick-start.sh`:常用容器编排。\n\n## 🗄️ 数据模型速览\n- **User**:`role`, `managedDepartment`, `isSuperAdmin`, `loginType`, `dingtalkUserId`, `canDownload`, `isActive`。\n- **Material**:`brandName`, `seriesName`, `skuCode`, `materialTexture`, `platformName`, `renderTime`, `imageResolution`, `colorMode`, `displayImages`, `templateId`。\n- **UploadTemplate / UploadField**:模板字段、布局、校验、下拉配置。\n- **Category / Scene / Platform**:支持 `options`(文本每行一个值)。\n- **Department**:树状管理(`parentId` 在 User 中维护)。\n- **Invitation**:多次/过期控制,生成邀请码链接。\n- **Danmaku / MaterialLike / FilterItem / SystemSettings**:互动、个性化和系统开关。\n\n完整定义见 `prisma/schema.prisma`。\n\n## 📦 部署与运维\n- **一键部署**:`./一键部署快速指南.md`、`./一键部署完整指南.md`、`./docs/guides/安全部署指南.md`。\n- **Docker**:`docker-compose.yml`(默认生产)、`docker-compose.prod.yml`(显式生产)、`docker-compose.standard.yml`(多服务)、`docker-compose-registry.yml`(私有镜像仓库)。\n- **PowerShell**:`deploy-fixed.ps1`、`deploy-new-fixed.ps1`(推荐)、`deploy-new.ps1`(实验版)。\n- **健康检查**:脚本完成后会轮询 `GET /api/health`,也可由外部监控使用 `HEAD /api/health`。\n- **日志与产物**:`deploy-log.txt`、`deploy-artifacts/*.tar`、`env-backups/*.env`。\n- **网络与安全**:`secure_ssh.ps1`、`scripts/server/*.sh`、`docs/guides/SSH安全评估报告.md`。\n\n## 🆘 自检 & 排障\n- 前端浮动面板内含“网络检测”“AI 聊天”“弹幕”;后台仪表盘提供系统状态。\n- `logs/` 与 `temp_stdout|stderr.txt` 保存最近脚本输出。\n- `docs/guides/上传问题完整解决方案.md`、`docs/guides/FRP服务器诊断修复报告.md` 提供针对性排障。\n- `/api/admin/system-status` 若返回 403,检查是否使用管理员或超级管理员登录。\n- S3 上传失败将尝试 10 次自动重试(`lib/s3.ts`),最终报错请检查存储桶权限与 `S3_PUBLIC_DOMAIN`。\n\n## 📚 文档入口\n- 开发流程:`docs/WORKFLOW.md`\n- 部署规范:`docs/PORTABLE_DEPLOYMENT_GUIDE.md`、`docs/guides/安全部署说明.md`\n- 需求实现记录:`docs/FEATURE_REQUESTS_IMPLEMENTATION.md`\n- 变更日志:`CHANGELOG.md`(示例:2025-11「权限进化 & 电商加成」版本)\n- 历史问题:`docs/reports/*`、`.specstory/history/*`\n\n## 📝 License\n\nMIT" } ``` User decision: **accepted** @@ -592,15 +592,15 @@ dir deploy-artifacts Mode LastWriteTime Length Name ---- ------------- ------ ---- --a--- 2025/11/15 19:18 124656128 youyidian-3d-material-local-deploy-20251115191805.tar --a--- 2025/11/17 9:40 124655616 youyidian-3d-material-local-deploy-20251117094051.tar --a--- 2025/11/17 9:42 124655616 youyidian-3d-material-local-deploy-20251117094217.tar --a--- 2025/11/17 10:12 124655616 youyidian-3d-material-local-deploy-20251117101239.tar --a--- 2025/11/18 9:03 124650496 youyidian-3d-material-local-deploy-20251118090305.tar --a--- 2025/11/18 9:03 124650496 youyidian-3d-material-local-deploy-20251118090316.tar --a--- 2025/11/18 9:04 124650496 youyidian-3d-material-local-deploy-20251118090434.tar --a--- 2025/11/18 9:04 124650496 youyidian-3d-material-local-deploy-20251118090451.tar --a--- 2025/11/18 9:05 124650496 youyidian-3d-material-local-deploy-20251118090511.tar +-a--- 2025/11/15 19:18 124656128 flnxi-material-local-deploy-20251115191805.tar +-a--- 2025/11/17 9:40 124655616 flnxi-material-local-deploy-20251117094051.tar +-a--- 2025/11/17 9:42 124655616 flnxi-material-local-deploy-20251117094217.tar +-a--- 2025/11/17 10:12 124655616 flnxi-material-local-deploy-20251117101239.tar +-a--- 2025/11/18 9:03 124650496 flnxi-material-local-deploy-20251118090305.tar +-a--- 2025/11/18 9:03 124650496 flnxi-material-local-deploy-20251118090316.tar +-a--- 2025/11/18 9:04 124650496 flnxi-material-local-deploy-20251118090434.tar +-a--- 2025/11/18 9:04 124650496 flnxi-material-local-deploy-20251118090451.tar +-a--- 2025/11/18 9:05 124650496 flnxi-material-local-deploy-20251118090511.tar ``` @@ -1631,3 +1631,5 @@ _**Agent (model default, mode Agent)**_ --- + + diff --git a/.trae/documents/项目优化分析与建议.md b/.trae/documents/项目优化分析与建议.md new file mode 100644 index 0000000..8357775 --- /dev/null +++ b/.trae/documents/项目优化分析与建议.md @@ -0,0 +1,11 @@ +我已经完成了对项目的深入研究,并编写了一份详细的优化分析与建议文档。文档包括: + +1. 项目概述:明确了项目的最终目的和核心原则 +2. 优化点分析:从性能、稳定性、部署友好性和可维护性四个方面进行了详细分析 +3. 优化优先级排序:将优化点分为三个优先级,便于实施 +4. 具体优化建议:包括数据库索引优化、全局错误处理机制、结构化日志系统、图片懒加载和Docker镜像优化等 +5. 预期优化效果:列出了各项指标的预期改善情况 +6. 优化实施计划:制定了详细的实施时间表 +7. 监控与评估:说明了如何监控和评估优化效果 + +文档已经保存到根目录下,文件名为"项目优化分析与建议.md"。 \ No newline at end of file diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile index c563afc..8923496 100644 --- a/Dockerfile +++ b/Dockerfile @@ -172,11 +172,13 @@ RUN if [ ! -d "node_modules/.prisma" ]; then \ else \ unset PRISMA_SCHEMA_ENGINE_BINARY; \ fi && \ - npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && exit 0) + npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && true) -# 验证构建产物 +# 验证构建产物(允许 ESLint 警告,但不允许致命错误) RUN if [ ! -f ".next/standalone/server.js" ]; then \ - echo "错误: standalone 输出未生成,请检查 next.config.js 中的 output: 'standalone'" >&2 && exit 1; \ + echo "错误: standalone 输出未生成,请检查 next.config.js 中的 output: 'standalone'" >&2 && \ + echo "提示: 如果构建过程中有 React Hooks 或其他编译错误,请先修复后再构建" >&2 && \ + exit 1; \ fi # 为可选模块创建占位符(确保 COPY 不会失败) @@ -217,7 +219,11 @@ RUN apk add --no-cache \ pixman \ # 字体支持(canvas 渲染中文时需要) fontconfig \ - ttf-dejavu + ttf-dejavu \ + # FFmpeg(视频转码功能,可选) + # 如果不需要视频转码功能,可以注释掉以下两行以减小镜像大小(约节省 30-45MB) + ffmpeg \ + ffmpeg-libs # 创建非 root 用户 RUN addgroup --system --gid 1001 nodejs && \ diff --git a/README.md b/README.md index a11d443..e8e8f5b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 由壹点3D素材 +# FLNXI素材库 一个遵循苹果设计语言的 3D 素材协作平台,聚焦企业/团队的私有化部署场景,内置 AI 辅助、部门权限、模板化元数据与可移植部署工具链。 @@ -190,4 +190,4 @@ ## 📝 License -MIT \ No newline at end of file +MIT diff --git a/VERSION.txt b/VERSION.txt index 620b533..70b55a8 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1,4 +1,4 @@ -由壹点3D素材管理系统 +FLNXI素材库管理系统 版本历史 ============================================ @@ -48,3 +48,4 @@ Version 1.0.0 (2024-10-26) - [ ] 更多素材类型支持 - [ ] 协作功能增强 + diff --git a/app/admin/_components/AppearanceSettingsPanel.tsx b/app/admin/_components/AppearanceSettingsPanel.tsx index d8009bc..3ee73ea 100644 --- a/app/admin/_components/AppearanceSettingsPanel.tsx +++ b/app/admin/_components/AppearanceSettingsPanel.tsx @@ -31,7 +31,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [headerBgColor, setHeaderBgColor] = useState('#1f2937') - const [siteName, setSiteName] = useState('由壹点3D素材') + const [siteName, setSiteName] = useState('FLNXI素材库') const [headerLogoUrl, setHeaderLogoUrl] = useState('') const [headerLogoPreview, setHeaderLogoPreview] = useState('') const [loginLogoUrl, setLoginLogoUrl] = useState('') @@ -50,6 +50,9 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd const [enableRegistration, setEnableRegistration] = useState(true) const [enableAiImageEdit, setEnableAiImageEdit] = useState(false) const [enableTunnelService, setEnableTunnelService] = useState(false) + // 首页删除按钮:true = 仅管理员可见,false = 所有人可见 + const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true) + const [premiumRatio, setPremiumRatio] = useState(10) const [toast, setToast] = useState(null) const [lastSavedAt, setLastSavedAt] = useState(null) @@ -150,7 +153,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd if (response.ok) { const data = await response.json() setHeaderBgColor(data.headerBgColor || '#1f2937') - setSiteName(data.siteName || '由壹点3D素材') + setSiteName(data.siteName || 'FLNXI素材库') setHeaderLogoUrl(data.headerLogoUrl || '') setHeaderLogoPreview(data.headerLogoUrl || '') setLoginLogoUrl(data.loginLogoUrl || '') @@ -165,6 +168,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd setEnableRegistration(data.enableRegistration ?? true) setEnableAiImageEdit(data.enableAiImageEdit ?? false) setEnableTunnelService(data.enableTunnelService ?? false) + setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL') + setPremiumRatio(typeof data.premiumRatio === 'number' ? data.premiumRatio : 10) setLastSavedAt(data.updatedAt ? new Date(data.updatedAt) : null) } } catch (error) { @@ -184,6 +189,14 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd const handleSave = async () => { setSaving(true) try { + const numericRatio = Number(premiumRatio) + if (!Number.isFinite(numericRatio)) { + showToast({ type: 'error', message: '精品比例必须为数字' }) + setSaving(false) + return + } + const normalizedRatio = Math.min(100, Math.max(0, Number(numericRatio.toFixed(2)))) + const response = await fetch('/api/system-settings', { method: 'PUT', headers: { @@ -204,6 +217,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd enableRegistration, enableAiImageEdit, enableTunnelService, + homeDeleteButtonMode: homeDeleteButtonAdminOnly ? 'ADMIN_ONLY' : 'ALL', + premiumRatio: normalizedRatio, }), }) @@ -399,17 +414,19 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
{variant === 'standalone' && ( )} @@ -545,7 +562,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd type="text" value={siteName} onChange={(e) => setSiteName(e.target.value)} - placeholder="由壹点3D素材" + placeholder="FLNXI素材库" className={styles.inputStyles.base} />

将同步至后台导航、登录页和浏览器标题。

@@ -721,6 +738,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd onChange: setEnableRegistration, disabled: false, }, + { + title: '首页删除按钮仅管理员可见', + desc: '开启后首页素材卡片的删除按钮仅管理员可见;关闭后所有用户都能看到(实际删除权限仍受角色控制)。', + value: homeDeleteButtonAdminOnly, + onChange: setHomeDeleteButtonAdminOnly, + disabled: false, + }, { title: 'AI 改图能力(预留)', desc: '控制素材详情页的 AI 改图入口(功能预留)。', @@ -764,6 +788,46 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
))} +
+
+
+

+ 精品作品比例 +

+
+ { + const value = Number(event.target.value) + setPremiumRatio(Number.isFinite(value) ? value : 0) + }} + disabled={!sessionUser?.isSuperAdmin} + className={cn( + '!w-16 text-center font-semibold text-sm px-2 py-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:border-indigo-500 dark:focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-800 outline-none transition-all', + '[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none', + !sessionUser?.isSuperAdmin && 'opacity-60 cursor-not-allowed' + )} + /> + % +
+
+

+ 系统会根据素材总量 × 该比例取整作为允许加精的数量,默认 10%。举例:总素材 320 条且比例 10%,则加精上限为 ⌊320 × 10%⌋ = 32 条 +

+ {!sessionUser?.isSuperAdmin && ( +

仅超级管理员可修改

+ )} +
+
@@ -864,41 +928,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd -
- {toast && ( - - {toast.message} - - )} - - -
) } + diff --git a/app/admin/_components/CategoriesTab.tsx b/app/admin/_components/CategoriesTab.tsx index f0222d9..27eaa33 100644 --- a/app/admin/_components/CategoriesTab.tsx +++ b/app/admin/_components/CategoriesTab.tsx @@ -17,14 +17,28 @@ 'use client' -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo, type CSSProperties } from 'react' import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles' import { useDialog } from '@/components/ui/DialogProvider' import { cardAnimations, buttonAnimations, scrollAnimations, scrollToElementWithSpring } from '@/lib/ui-animations' +import { + DndContext, + DragEndEvent, + DragStartEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DraggableAttributes, +} from '@dnd-kit/core' +import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' interface TemplateCategory { fieldKey: string fieldLabel: string + fieldDefaultLabel: string + filterOrder: number options: string[] } @@ -40,10 +54,71 @@ interface CategoriesTabProps { onDataChange?: () => void } +const sanitizeOptionLabel = (value: string): string => { + let cleaned = value?.trim?.() ?? '' + if (!cleaned) return '' + + cleaned = cleaned.replace(/["'`“”]/g, '').trim() + + let safeGuard = 0 + while (cleaned.startsWith('[[') && cleaned.endsWith(']]') && safeGuard < 5) { + const next = cleaned.slice(1, -1).trim() + if (!next || next === cleaned) { + break + } + cleaned = next + safeGuard += 1 + } + + if ( + safeGuard > 0 && + cleaned.startsWith('[') && + cleaned.endsWith(']') && + !/[,\n,]/.test(cleaned.slice(1, -1)) + ) { + cleaned = cleaned.slice(1, -1).trim() + } + + return cleaned || value.trim() +} + +const expandOptionSegments = (value: string): string[] => { + const trimmed = value?.trim?.() ?? '' + if (!trimmed) return [] + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + const inner = trimmed.slice(1, -1).trim() + if (!inner) return [] + return inner + .split(/[,,]/) + .map((segment) => sanitizeOptionLabel(segment)) + .filter(Boolean) + } + + const sanitized = sanitizeOptionLabel(trimmed) + return sanitized ? [sanitized] : [] +} + +const normalizeOptionArray = (values: string[]): string[] => { + const seen = new Set() + const normalized: string[] = [] + values.forEach((value) => { + const segments = expandOptionSegments(value) + segments.forEach((segment) => { + if (!segment || seen.has(segment)) { + return + } + seen.add(segment) + normalized.push(segment) + }) + }) + return normalized +} + export default function CategoriesTab({ showToast, onDataChange }: CategoriesTabProps) { const styles = useThemeStyles() const theme = useTheme() - const { confirm } = useDialog() + const { confirm, prompt } = useDialog() const [templateCategories, setTemplateCategories] = useState([]) const [selectedFieldKey, setSelectedFieldKey] = useState(null) const [editingOptions, setEditingOptions] = useState>(new Map()) @@ -54,16 +129,58 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab const [categoryFieldLoading, setCategoryFieldLoading] = useState(false) const [savingFieldKey, setSavingFieldKey] = useState(null) const [isEditingFields, setIsEditingFields] = useState(false) + const [categoryOrder, setCategoryOrder] = useState([]) + const [categoryLabelDrafts, setCategoryLabelDrafts] = useState>(new Map()) + const [categoryDefaultLabels, setCategoryDefaultLabels] = useState>(new Map()) + const [categoryConfigSaving, setCategoryConfigSaving] = useState(false) const categoryRefs = useRef>(new Map()) const scrollTimeoutRef = useRef(null) const shouldScrollRef = useRef(false) // 控制是否应该滚动,只有用户点击左侧按钮时才滚动 + const reorderSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 6, + }, + }), + ) const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900' const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500' const textLabel = theme === 'dark' ? 'text-slate-200' : 'text-slate-700' const textHint = theme === 'dark' ? 'text-slate-400' : 'text-slate-500' - const enabledFieldMeta = categoryFieldOptions.filter(field => field.enabled) - const disabledFieldMeta = categoryFieldOptions.filter(field => !field.enabled) + const enabledFieldMeta = categoryFieldOptions.filter((field) => field.enabled) + const disabledFieldMeta = categoryFieldOptions.filter((field) => !field.enabled) + const categoryMap = useMemo(() => { + const map = new Map() + templateCategories.forEach((cat) => { + map.set(cat.fieldKey, cat) + }) + return map + }, [templateCategories]) + const orderedCategories = useMemo(() => { + if (!categoryOrder.length) { + return templateCategories + } + const ordered = categoryOrder.map((key) => categoryMap.get(key)).filter((cat): cat is TemplateCategory => !!cat) + const missing = templateCategories.filter((cat) => !categoryOrder.includes(cat.fieldKey)) + return [...ordered, ...missing] + }, [categoryMap, categoryOrder, templateCategories]) + + const enabledFieldMap = useMemo(() => { + const map = new Map() + enabledFieldMeta.forEach((field) => { + map.set(field.key, field) + }) + return map + }, [enabledFieldMeta]) + + const orderedEnabledFields = useMemo(() => { + const ordered = categoryOrder + .map((key) => enabledFieldMap.get(key)) + .filter((field): field is CategoryFieldMeta => !!field) + const remaining = enabledFieldMeta.filter((field) => !categoryOrder.includes(field.key)) + return [...ordered, ...remaining] + }, [categoryOrder, enabledFieldMeta, enabledFieldMap]) const fetchData = useCallback(async () => { try { @@ -72,10 +189,38 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab if (response.ok) { const data = await response.json() console.log('[CategoriesTab] Received template categories:', data) - setTemplateCategories(data) + const sortedData = [...data].sort( + (a: TemplateCategory, b: TemplateCategory) => (a.filterOrder ?? 0) - (b.filterOrder ?? 0), + ) + const sanitizedCategories = sortedData.map((cat: TemplateCategory) => ({ + ...cat, + options: normalizeOptionArray(cat.options), + })) + setTemplateCategories(sanitizedCategories) + setCategoryOrder((prev) => { + if (!prev || !prev.length) { + return sanitizedCategories.map((cat) => cat.fieldKey) + } + const validKeys = new Set(sanitizedCategories.map((cat) => cat.fieldKey)) + const preserved = prev.filter((key) => validKeys.has(key)) + sanitizedCategories.forEach((cat) => { + if (!preserved.includes(cat.fieldKey)) { + preserved.push(cat.fieldKey) + } + }) + return preserved + }) + setCategoryLabelDrafts((prev) => { + const next = new Map() + sanitizedCategories.forEach((cat) => { + next.set(cat.fieldKey, prev.get(cat.fieldKey) ?? cat.fieldLabel) + }) + return next + }) + setCategoryDefaultLabels(new Map(sanitizedCategories.map((cat) => [cat.fieldKey, cat.fieldDefaultLabel || cat.fieldLabel]))) // 初始化编辑状态 const editingMap = new Map() - data.forEach((cat: TemplateCategory) => { + sanitizedCategories.forEach((cat: TemplateCategory) => { editingMap.set(cat.fieldKey, [...cat.options]) }) setEditingOptions(editingMap) @@ -123,6 +268,32 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab fetchCategoryFieldOptions() }, [fetchCategoryFieldOptions]) + const handleCategoryLabelChange = useCallback((fieldKey: string, value: string) => { + setCategoryLabelDrafts((prev) => { + const next = new Map(prev) + next.set(fieldKey, value) + return next + }) + }, []) + + const handleCategoryDragStart = useCallback((_event: DragStartEvent) => { + // reserved for future visual feedback + }, []) + + const handleCategoryDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + setCategoryOrder((order) => { + const oldIndex = order.indexOf(String(active.id)) + const newIndex = order.indexOf(String(over.id)) + if (oldIndex === -1 || newIndex === -1) return order + return arrayMove(order, oldIndex, newIndex) + }) + }, + [], + ) + // 滚动到指定分类的函数(使用苹果风格的缓入缓出滚动,页面级滚动) const scrollToCategory = useCallback((fieldKey: string) => { const categoryElement = categoryRefs.current.get(fieldKey) @@ -140,6 +311,35 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab }) }, []) + const handleFieldRenamePrompt = useCallback(async (fieldKey: string) => { + const currentLabel = + (categoryLabelDrafts.get(fieldKey) ?? + categoryDefaultLabels.get(fieldKey) ?? + templateCategories.find((cat) => cat.fieldKey === fieldKey)?.fieldLabel ?? + fieldKey) || '' + + const result = await prompt({ + title: '重命名分类', + message: '请输入该分类的新显示名称', + defaultValue: currentLabel, + placeholder: '请输入显示名称', + confirmText: '保存', + cancelText: '取消', + }) + + if (result === null) { + return + } + + const trimmed = result.trim() + if (!trimmed) { + showToast('分类名称不能为空', 'error') + return + } + + handleCategoryLabelChange(fieldKey, trimmed) + }, [categoryDefaultLabels, categoryLabelDrafts, handleCategoryLabelChange, prompt, showToast, templateCategories]) + // 当选中字段变化时,只有在用户点击左侧按钮时才自动滚动到对应的卡片 useEffect(() => { if (!selectedFieldKey || !shouldScrollRef.current) { @@ -166,7 +366,8 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab }, [selectedFieldKey, scrollToCategory]) const handleUpdateOptions = async (fieldKey: string, options: string[]) => { - if (options.length === 0) { + const normalizedOptions = normalizeOptionArray(options) + if (normalizedOptions.length === 0) { showToast('选项不能为空', 'error') return } @@ -177,7 +378,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ options }), + body: JSON.stringify({ options: normalizedOptions }), }) if (response.ok) { @@ -194,21 +395,64 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab } } + const handleSaveCategoryConfig = useCallback(async () => { + if (!categoryOrder.length) { + setIsEditingFields(false) + return + } + try { + setCategoryConfigSaving(true) + const updates = categoryOrder.map((fieldKey, index) => { + const labelDraft = (categoryLabelDrafts.get(fieldKey) ?? '').trim() + const defaultLabel = (categoryDefaultLabels.get(fieldKey) ?? '').trim() + if (!labelDraft) { + throw new Error('分类名称不能为空') + } + return { + fieldKey, + filterOrder: index, + filterDisplayName: labelDraft === (defaultLabel || labelDraft) ? null : labelDraft, + } + }) + const response = await fetch('/api/category-fields', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ updates }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || '保存失败') + } + showToast('分类字段已保存') + setIsEditingFields(false) + await Promise.all([fetchCategoryFieldOptions(), fetchData()]) + onDataChange?.() + } catch (error: any) { + console.error('[CategoriesTab] Failed to save category config:', error) + showToast(error?.message || '保存失败', 'error') + } finally { + setCategoryConfigSaving(false) + } + }, [categoryDefaultLabels, categoryLabelDrafts, categoryOrder, fetchCategoryFieldOptions, fetchData, onDataChange, showToast]) + const handleAddOption = async (fieldKey: string) => { const currentOptions = editingOptions.get(fieldKey) || [] const newValue = newOptionValue.get(fieldKey)?.trim() || '' + const safeValue = sanitizeOptionLabel(newValue) - if (!newValue) { + if (!safeValue) { showToast('选项不能为空', 'error') return } - if (currentOptions.includes(newValue)) { + if (currentOptions.includes(safeValue)) { showToast('选项已存在', 'error') return } - const updatedOptions = [...currentOptions, newValue] + const updatedOptions = normalizeOptionArray([...currentOptions, safeValue]) setEditingOptions(new Map(editingOptions).set(fieldKey, updatedOptions)) setNewOptionValue(new Map(newOptionValue).set(fieldKey, '')) setAddingOption(new Map(addingOption).set(fieldKey, false)) @@ -233,24 +477,25 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab setRenamingOption(new Map(renamingOption).set(`${fieldKey}-${oldValue}`, { fieldKey, oldValue, - newValue: oldValue, + newValue: sanitizeOptionLabel(oldValue), })) } const handleRenameOption = async (fieldKey: string, oldValue: string, newValue: string) => { - const trimmed = newValue.trim() + const trimmed = sanitizeOptionLabel(newValue) if (!trimmed) { showToast('选项不能为空', 'error') return } const currentOptions = editingOptions.get(fieldKey) || [] - if (trimmed !== oldValue && currentOptions.includes(trimmed)) { + const originalValue = sanitizeOptionLabel(oldValue) + if (trimmed !== originalValue && currentOptions.includes(trimmed)) { showToast('选项已存在', 'error') return } - const updatedOptions = currentOptions.map(opt => opt === oldValue ? trimmed : opt) + const updatedOptions = currentOptions.map(opt => opt === originalValue ? trimmed : opt) setEditingOptions(new Map(editingOptions).set(fieldKey, updatedOptions)) setRenamingOption(new Map(renamingOption)) await handleUpdateOptions(fieldKey, updatedOptions) @@ -329,7 +574,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab {/* 按钮移动到底部,顶部仅保留标题 */}
{!isEditingFields && - templateCategories.map((category) => { + orderedCategories.map((category) => { const isSelected = selectedFieldKey === category.fieldKey return ( +
+

+ {displayLabel} +

+

+ {field.key} · {field.source === 'standard' ? '标准字段' : '模板字段'} +

+
+ + +
+ )} + + ) + })} - - - ))} - + + + )}

可添加字段

{disabledFieldMeta.length === 0 && ( -
- 暂无可添加字段 -
+
暂无可添加字段
)} {disabledFieldMeta.map((field) => (
handleToggleCategoryField(field.key, true)} > - {savingFieldKey === field.key ? '处理中…' : '添加'} + {savingFieldKey === field.key ? '处理中…' : '启用'}
))} @@ -442,8 +739,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab className={cn(styles.buttonStyles.secondary, 'flex-1')} onClick={async () => { if (isEditingFields) { - setIsEditingFields(false) - await Promise.all([fetchCategoryFieldOptions(), fetchData()]) + await handleSaveCategoryConfig() } else { if (categoryFieldOptions.length === 0) { await fetchCategoryFieldOptions() @@ -451,9 +747,9 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab setIsEditingFields(true) } }} - disabled={categoryFieldLoading} + disabled={categoryFieldLoading || categoryConfigSaving} > - {isEditingFields ? '保存' : '编辑'} + {isEditingFields ? (categoryConfigSaving ? '保存中…' : '保存') : '编辑'} @@ -475,16 +771,19 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab {/* 绿色框框:右侧选项卡片列表(页面级滚动,显示所有字段) */}
- {templateCategories.map((category) => { + {orderedCategories.map((category) => { const rawOptions = editingOptions.get(category.fieldKey) || category.options - // 去重:使用 Set 去除重复选项,保持顺序 - const categoryOptions = Array.from(new Set(rawOptions)) + const categoryOptions = normalizeOptionArray(rawOptions) const isAddingCategory = addingOption.get(category.fieldKey) || false const newValueCategory = newOptionValue.get(category.fieldKey) || '' + const displayLabel = + (categoryLabelDrafts.get(category.fieldKey) ?? category.fieldLabel)?.trim() || + category.fieldDefaultLabel || + category.fieldLabel return ( -
{ if (el) { categoryRefs.current.set(category.fieldKey, el) @@ -495,10 +794,9 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab className={cn(styles.cardStyles.base, 'space-y-4')} >
-

{category.fieldLabel}

+

{displayLabel}

- {/* 选项卡片 */} {categoryOptions.map((option, index) => { const renameKey = `${category.fieldKey}-${option}` const isRenaming = renamingOption.has(renameKey) @@ -509,7 +807,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab key={`${category.fieldKey}-${option}-${index}`} className={cn( styles.cardStyles.flat, - 'p-2 space-y-1.5 hover:shadow-md transition-all duration-300 hover:-translate-y-0.5' + 'p-2 space-y-1.5 hover:shadow-md transition-all duration-300 hover:-translate-y-0.5', )} > {isRenaming ? ( @@ -518,11 +816,13 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab type="text" value={renameData?.newValue || option} onChange={(e) => { - setRenamingOption(new Map(renamingOption).set(renameKey, { - fieldKey: category.fieldKey, - oldValue: option, - newValue: e.target.value, - })) + setRenamingOption( + new Map(renamingOption).set(renameKey, { + fieldKey: category.fieldKey, + oldValue: option, + newValue: e.target.value, + }), + ) }} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -531,7 +831,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab handleCancelRename() } }} - className={cn(styles.inputStyles.base, 'w-full text-sm max-w-xs')} // 限制最大宽度,避免输入框太宽 + className={cn(styles.inputStyles.base, 'w-full text-sm max-w-xs')} autoFocus />
@@ -566,7 +866,6 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab styles.buttonModifiers.compactXs, 'flex-1', buttonAnimations.base, - // 统一UI样式已包含颜色,不需要额外添加 )} > 删除 @@ -578,7 +877,6 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab ) })} - {/* 新增卡片 */} {isAddingCategory ? (
@@ -642,11 +940,11 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
) })} - {templateCategories.length === 0 && ( + {orderedCategories.length === 0 && (

- 暂无模板分类,请在模板管理中为目标字段勾选“添加到分类” + 暂无模板分类,请在模板管理中为目标字段勾选“添加到分类”

@@ -657,3 +955,34 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
) } + +type SortableItemWrapperProps = { + id: string + disabled: boolean + className?: string + children: (props: { + setNodeRef: (element: HTMLElement | null) => void + attributes: DraggableAttributes + listeners: Record | undefined + isDragging: boolean + }) => JSX.Element +} + +function SortableItemWrapper({ id, disabled, className, children }: SortableItemWrapperProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled, + }) + + const style: CSSProperties = { + transform: transform ? CSS.Transform.toString(transform) : undefined, + transition, + zIndex: isDragging ? 40 : undefined, + } + + return ( +
+ {children({ setNodeRef, attributes, listeners, isDragging })} +
+ ) +} diff --git a/app/admin/_components/InvitationsTab.tsx b/app/admin/_components/InvitationsTab.tsx index 37690f2..97b422f 100644 --- a/app/admin/_components/InvitationsTab.tsx +++ b/app/admin/_components/InvitationsTab.tsx @@ -15,6 +15,7 @@ import { useState, useEffect, useCallback } from 'react' import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles' import { useDialog } from '@/components/ui/DialogProvider' +import Modal from '@/components/ui/Modal' import { formatDateTimeLocale } from '../_utils/formatters' import type { Invitation, ToastFn } from './types' @@ -278,67 +279,74 @@ export default function InvitationsTab({ showToast }: InvitationsTabProps) {
{/* Create Invitation Modal */} - {showCreateInvitationModal && ( -
-
-

- 创建邀请链接 -

-
-
- - setNewInvitationMaxUses(parseInt(e.target.value) || 1)} - className={cn(styles.inputStyles.base)} - placeholder="请输入最大使用次数" - /> -

- 邀请链接最多可以被使用多少次 -

-
-
- - setNewInvitationExpiresInDays(e.target.value ? parseInt(e.target.value) : null)} - className={cn(styles.inputStyles.base)} - placeholder="留空表示永不过期" - /> -

- 邀请链接在多少天后过期,留空表示永不过期 -

-
-
- - -
-
+ { + setShowCreateInvitationModal(false) + setNewInvitationMaxUses(1) + setNewInvitationExpiresInDays(null) + }} + title="创建邀请链接" + size="small" + footer={ +
+ + +
+ } + > +
+
+ + setNewInvitationMaxUses(parseInt(e.target.value) || 1)} + className={cn(styles.inputStyles.base)} + placeholder="请输入最大使用次数" + /> +

+ 邀请链接最多可以被使用多少次 +

+
+
+ + setNewInvitationExpiresInDays(e.target.value ? parseInt(e.target.value) : null)} + className={cn(styles.inputStyles.base)} + placeholder="留空表示永不过期" + /> +

+ 邀请链接在多少天后过期,留空表示永不过期 +

- )} +
) } diff --git a/app/admin/_components/MaterialsTab.tsx b/app/admin/_components/MaterialsTab.tsx index 9369167..ed493b9 100644 --- a/app/admin/_components/MaterialsTab.tsx +++ b/app/admin/_components/MaterialsTab.tsx @@ -12,65 +12,175 @@ 'use client' -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import Image from 'next/image' import EditMaterialModal from '@/components/EditMaterialModal' import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles' import { useDialog } from '@/components/ui/DialogProvider' -import type { Material, ToastFn } from './types' +import type { Material, ToastFn, User } from './types' import LoadingState from './ui/LoadingState' +const PAGE_SIZE = 40 + interface MaterialsTabProps { showToast: ToastFn } export default function MaterialsTab({ showToast }: MaterialsTabProps) { const styles = useThemeStyles() - const { confirm } = useDialog() + const { confirm, prompt } = useDialog() const theme = useTheme() const [materials, setMaterials] = useState([]) const [loading, setLoading] = useState(true) const [editingMaterial, setEditingMaterial] = useState(null) const [materialSearchTerm, setMaterialSearchTerm] = useState('') + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [showDeleted, setShowDeleted] = useState(false) // 是否显示回收站 + const [currentPage, setCurrentPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const loadMoreRef = useRef(null) + const [isUserSelectOpen, setIsUserSelectOpen] = useState(false) + const [userSearchTerm, setUserSearchTerm] = useState('') + const [userSearchLoading, setUserSearchLoading] = useState(false) + const [userSearchResults, setUserSearchResults] = useState([]) + const [selectedTargetUser, setSelectedTargetUser] = useState(null) - const fetchMaterials = useCallback(async () => { - try { - setLoading(true) - const response = await fetch('/api/materials?pageSize=1000') - if (response.ok) { - const data = await response.json() - setMaterials(data.materials || data) - } else { - showToast('获取素材列表失败', 'error') + const handleSearchUsers = useCallback( + async (keyword: string) => { + const trimmed = keyword.trim() + setUserSearchTerm(keyword) + if (!trimmed) { + setUserSearchResults([]) + return } - } catch (error) { - console.error('Error fetching materials:', error) - showToast('获取素材列表失败', 'error') - } finally { - setLoading(false) - } - }, [showToast]) + setUserSearchLoading(true) + try { + const params = new URLSearchParams() + params.set('pageSize', '20') + params.set('search', trimmed) + const response = await fetch(`/api/admin/users?${params.toString()}`) + if (!response.ok) { + showToast('搜索用户失败', 'error') + return + } + const data = await response.json() + const users = Array.isArray(data.users) ? (data.users as User[]) : [] + setUserSearchResults(users) + } catch (error) { + console.error('Search users error:', error) + showToast('搜索用户失败', 'error') + } finally { + setUserSearchLoading(false) + } + }, + [showToast], + ) useEffect(() => { - fetchMaterials() + const handler = setTimeout(() => { + setDebouncedSearchTerm(materialSearchTerm.trim()) + }, 300) + return () => clearTimeout(handler) + }, [materialSearchTerm]) + + const fetchMaterials = useCallback( + async (page = 1, append = false) => { + try { + if (append) { + setIsLoadingMore(true) + } else { + setLoading(true) + setHasMore(true) + } + + const params = new URLSearchParams() + params.set('page', page.toString()) + params.set('pageSize', PAGE_SIZE.toString()) + if (showDeleted) { + params.set('showDeleted', 'true') + } + if (debouncedSearchTerm) { + params.set('search', debouncedSearchTerm) + } + + const response = await fetch(`/api/materials?${params.toString()}`) + if (response.ok) { + const data = await response.json() + const fetchedMaterials = (Array.isArray(data.materials) + ? data.materials + : Array.isArray(data) + ? data + : []) as Material[] + + if (append) { + setMaterials((prev) => { + const existingIds = new Set(prev.map((item) => item.id)) + const deduped = fetchedMaterials.filter((item) => !existingIds.has(item.id)) + if (deduped.length === 0) return prev + return [...prev, ...deduped] + }) + } else { + setMaterials(fetchedMaterials) + } + + const appendedCount = fetchedMaterials.length + const totalPages = typeof data?.totalPages === 'number' ? data.totalPages : undefined + + setCurrentPage(page) + if (totalPages) { + setHasMore(page < totalPages) + } else { + setHasMore(appendedCount === PAGE_SIZE) + } + return appendedCount + } + + showToast('获取素材列表失败', 'error') + } catch (error) { + console.error('Error fetching materials:', error) + showToast('获取素材列表失败', 'error') + } finally { + if (append) { + setIsLoadingMore(false) + } else { + setLoading(false) + } + } + + setHasMore(false) + return 0 + }, + [debouncedSearchTerm, showDeleted, showToast] + ) + + useEffect(() => { + fetchMaterials(1, false) }, [fetchMaterials]) - const handleDeleteMaterial = async (id: string) => { + const handleDeleteMaterial = async (id: string, permanent = false) => { const confirmed = await confirm({ - title: '确认删除', - message: '确定要删除这个素材吗?', + title: permanent ? '确认永久删除' : '确认删除', + message: permanent + ? '确定要永久删除这个素材吗?此操作不可恢复!' + : '确定要删除这个素材吗?素材将移至回收站,7天后自动清除。', variant: 'danger', }) if (!confirmed) return try { - const response = await fetch(`/api/materials?id=${id}`, { + const url = permanent + ? `/api/materials?id=${id}&permanent=true` + : `/api/materials?id=${id}` + const response = await fetch(url, { method: 'DELETE', }) if (response.ok) { setMaterials(prevMaterials => prevMaterials.filter(m => m.id !== id)) - showToast('删除成功') + showToast(permanent ? '永久删除成功' : '删除成功') + fetchMaterials() } else { showToast('删除失败', 'error') } @@ -80,20 +190,216 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) { } } - const filteredMaterials = useMemo(() => { - if (!materialSearchTerm.trim()) return materials - const term = materialSearchTerm.toLowerCase() - return materials.filter((material) => { - return ( - material.productModel?.toLowerCase().includes(term) || - material.brandName?.toLowerCase().includes(term) || - material.categoryName?.toLowerCase().includes(term) || - material.sceneName?.toLowerCase().includes(term) || - material.materialType?.toLowerCase().includes(term) || - material.user.username?.toLowerCase().includes(term) - ) + const handleBulkRestore = useCallback(async () => { + const ids = Array.from(selectedIds) + if (!ids.length) return + + const confirmed = await confirm({ + title: '批量恢复素材', + message: `确定要恢复选中的 ${ids.length} 条素材吗?`, + variant: 'default', }) - }, [materials, materialSearchTerm]) + if (!confirmed) return + + try { + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await fetch(`/api/materials/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + } + setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id))) + setSelectedIds(new Set()) + showToast('批量恢复完成') + fetchMaterials() + } catch (error) { + console.error('Bulk restore materials error:', error) + showToast('批量恢复失败,请稍后重试', 'error') + } + }, [selectedIds, confirm, showToast, fetchMaterials]) + + const handleRestoreMaterial = async (id: string) => { + try { + const response = await fetch(`/api/materials/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + + if (response.ok) { + showToast('恢复成功') + fetchMaterials() + } else { + showToast('恢复失败', 'error') + } + } catch (error) { + console.error('Error restoring material:', error) + showToast('恢复失败', 'error') + } + } + + const toggleSelectMaterial = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + }, []) + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()) + }, []) + + const selectAllVisible = useCallback((list: Material[]) => { + setSelectedIds(new Set(list.map((material) => material.id))) + }, []) + + const handleBulkDelete = useCallback(async () => { + const ids = Array.from(selectedIds) + if (!ids.length) return + + if (showDeleted) { + // 回收站中:批量永久删除 + const confirmed = await confirm({ + title: '批量永久删除素材', + message: `确定要永久删除选中的 ${ids.length} 条素材吗?此操作不可恢复!`, + variant: 'danger', + }) + if (!confirmed) return + + try { + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await fetch(`/api/materials?id=${id}&permanent=true`, { method: 'DELETE' }) + } + setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id))) + setSelectedIds(new Set()) + showToast('批量永久删除完成') + } catch (error) { + console.error('Bulk permanent delete materials error:', error) + showToast('批量永久删除失败,请稍后重试', 'error') + } + } else { + // 正常列表:批量删除(软删除) + const confirmed = await confirm({ + title: '批量删除素材', + message: `确定要删除选中的 ${ids.length} 条素材吗?素材将移至回收站,7天后自动清除。`, + variant: 'danger', + }) + if (!confirmed) return + + try { + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await fetch(`/api/materials?id=${id}`, { method: 'DELETE' }) + } + setMaterials((prev) => prev.filter((material) => !selectedIds.has(material.id))) + setSelectedIds(new Set()) + showToast('批量删除完成') + } catch (error) { + console.error('Bulk delete materials error:', error) + showToast('批量删除失败,请稍后重试', 'error') + } + } + }, [selectedIds, confirm, showToast, showDeleted]) + + useEffect(() => { + // 移除已被删除的选择项 + setSelectedIds((prev) => { + if (prev.size === 0) return prev + const availableIds = new Set(materials.map((material) => material.id)) + const next = new Set() + prev.forEach((id) => { + if (availableIds.has(id)) { + next.add(id) + } + }) + return next + }) + }, [materials]) + + const handleLoadMore = useCallback(async () => { + if (loading || isLoadingMore || !hasMore) return + const nextPage = currentPage + 1 + await fetchMaterials(nextPage, true) + }, [currentPage, fetchMaterials, hasMore, isLoadingMore, loading]) + + useEffect(() => { + if (!hasMore) return + const sentinel = loadMoreRef.current + if (!sentinel) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + handleLoadMore() + } + }, + { threshold: 0.2 } + ) + + observer.observe(sentinel) + return () => { + observer.disconnect() + } + }, [handleLoadMore, hasMore]) + + const selectionCount = selectedIds.size + const isAllVisibleSelected = + materials.length > 0 && materials.every((material) => selectedIds.has(material.id)) + + const handleBulkTransferToTeam = useCallback(async () => { + const ids = Array.from(selectedIds) + if (!ids.length) return + + const confirmed = await confirm({ + title: '移交到团队空间', + message: `确定要将选中的 ${ids.length} 条素材的归属移交到团队空间吗?`, + variant: 'default', + }) + if (!confirmed) return + + try { + const response = await fetch('/api/admin/materials/transfer-owner', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + materialIds: ids, + toTeam: true, + }), + }) + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + showToast(data.error || '移交失败', 'error') + return + } + + showToast('已将选中素材移交到团队空间', 'success') + setSelectedIds(new Set()) + fetchMaterials(1, false) + } catch (error) { + console.error('Bulk transfer to team error:', error) + showToast('移交失败,请稍后重试', 'error') + } + }, [selectedIds, confirm, showToast, fetchMaterials]) + + const handleBulkTransferToUser = useCallback(async () => { + const ids = Array.from(selectedIds) + if (!ids.length) return + + // 打开「选择用户」弹窗 + setIsUserSelectOpen(true) + setUserSearchTerm('') + setUserSearchResults([]) + setSelectedTargetUser(null) + }, [selectedIds]) if (loading) { return @@ -124,14 +430,28 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) {
{/* 搜索框 */}
-
- setMaterialSearchTerm(e.target.value)} - className={cn(styles.inputStyles.base, 'pl-11 pr-10')} - /> +
+ +
+ setMaterialSearchTerm(e.target.value)} + className={cn(styles.inputStyles.base, 'pl-11 pr-10')} + /> @@ -152,21 +472,123 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) { )} +
+ {/* 批量操作 */} + {materials.length > 0 && ( +
+
+ 已选 {selectionCount} 项 +
+
+ + + + + {showDeleted && ( + + )} + +
+
+ )} + {/* 素材列表 */} - {filteredMaterials.length === 0 ? ( + {materials.length === 0 ? (
- {materialSearchTerm ? '暂无匹配的素材' : '暂无素材'} + {debouncedSearchTerm ? '暂无匹配的素材' : '暂无素材'}
) : ( -
- {filteredMaterials.map((material) => ( +
+
+ {materials.map((material) => (
+
{material.previewUrl ? ( 分类: {material.categoryName}
上传者: {material.user.username}
-
- - +
+ {!showDeleted ? ( + <> + + + + ) : ( + <> + + + + )}
))} +
+ {(hasMore || isLoadingMore) && ( +
+ {isLoadingMore ? '加载中...' : '继续向下滚动加载更多'} +
+ )} + {!hasMore && materials.length > 0 && ( +
已经到底啦~
+ )}
)}
@@ -218,7 +671,7 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) { {/* Edit Material Modal */} {editingMaterial && ( setEditingMaterial(null)} onSuccess={async () => { setEditingMaterial(null) @@ -227,6 +680,210 @@ export default function MaterialsTab({ showToast }: MaterialsTabProps) { }} /> )} + + {/* 选择目标用户弹窗 */} + {isUserSelectOpen && ( +
+
setIsUserSelectOpen(false)} /> +
+
+
+
选择目标用户
+
+ 支持按用户名、邮箱或部门关键字搜索,然后点选要接收素材的用户。 +
+
+ +
+ +
+
+ +
+ handleSearchUsers(e.target.value)} + placeholder="输入用户名、邮箱或部门关键字,例如:zhangsan / 设计部" + className={cn( + 'flex-1 rounded-lg border px-3 py-1.5 text-sm outline-none', + 'placeholder:text-slate-400', + theme === 'dark' + ? 'bg-slate-900 border-slate-700 text-slate-100 focus:border-indigo-400 focus:ring-1 focus:ring-indigo-500/70' + : 'bg-white border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40', + )} + /> +
+

+ 小提示:可以直接输入邮箱或完整用户名,结果会自动高亮匹配。 +

+
+ +
+ {userSearchLoading ? ( +
搜索中...
+ ) : userSearchResults.length === 0 ? ( +
+ {userSearchTerm.trim() + ? '未找到匹配的用户,请尝试调整关键字。' + : '请输入关键字开始搜索用户。'} +
+ ) : ( +
    + {userSearchResults.map((user) => { + const isSelected = selectedTargetUser?.id === user.id + const label = user.username || user.email || user.id + const subLabel = user.email && user.username ? user.email : user.email || user.department || '' + const dept = user.department || user.managedDepartment || '' + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+ +
+
+ 当前已选中素材:{selectedIds.size} 条 +
+
+ + +
+
+
+
+ )} ) } diff --git a/app/admin/_components/MonitorTab.tsx b/app/admin/_components/MonitorTab.tsx index 785fbf4..e9565c1 100644 --- a/app/admin/_components/MonitorTab.tsx +++ b/app/admin/_components/MonitorTab.tsx @@ -12,8 +12,9 @@ 'use client' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles' import { formatDateTimeLocale } from '../_utils/formatters' import type { ToastFn, DashboardData } from './types' @@ -21,6 +22,7 @@ import { useDashboardData } from '../_hooks/useDashboardData' import LoadingState from './ui/LoadingState' import ErrorState from './ui/ErrorState' import EmptyState from './ui/EmptyState' +import Modal from '@/components/ui/Modal' interface MonitorTabProps { showToast: ToastFn @@ -31,6 +33,7 @@ const AUTO_REFRESH_INTERVAL = 60_000 export default function MonitorTab({ showToast }: MonitorTabProps) { const styles = useThemeStyles() const theme = useTheme() + const router = useRouter() // 根据主题选择颜色类名 const borderColor = theme === 'dark' ? 'border-slate-800/70' : 'border-slate-200/70' @@ -42,23 +45,72 @@ export default function MonitorTab({ showToast }: MonitorTabProps) { const textRow = theme === 'dark' ? 'text-slate-400' : 'text-slate-600' const { data: session } = useSession() const currentIsSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin) - const { data, loading, error, refresh, updateSystemSetting, lastUpdated, refreshing } = useDashboardData({ showToast }) + const currentIsAdmin = Boolean((session?.user as any)?.role === 'ADMIN') + const { data, loading, error, refresh, lastUpdated, refreshing } = useDashboardData({ showToast }) + const [showExportModal, setShowExportModal] = useState(false) + const [showLogsModal, setShowLogsModal] = useState(false) + const [exportOptions, setExportOptions] = useState({ + includeBasicInfo: true, + includeStats: true, + includeUserInfo: true, + includeDates: true, + includeFiles: false, + }) + const [logs, setLogs] = useState>([]) + const [materialLogs, setMaterialLogs] = useState>([]) + const [loadingMaterialLogs, setLoadingMaterialLogs] = useState(false) useEffect(() => { refresh() + addLog('info', '监控仪表板已加载') }, [refresh]) useEffect(() => { const timer = setInterval(() => { void refresh({ background: true }) + addLog('info', '自动刷新数据') }, AUTO_REFRESH_INTERVAL) return () => clearInterval(timer) }, [refresh]) - const materialUploadMax = useMemo(() => { - if (!data?.trends.materialUploads?.length) return 1 - return Math.max(...data.trends.materialUploads.map((item) => item.count), 1) - }, [data?.trends.materialUploads]) + useEffect(() => { + if (data) { + addLog('info', '数据加载成功') + // 检查数据完整性 + console.log('[MonitorTab] Raw data.trends:', data.trends) + console.log('[MonitorTab] materialUploadsDaily:', data.trends?.materialUploadsDaily) + console.log('[MonitorTab] materialUploadsMonthly:', data.trends?.materialUploadsMonthly) + + if (!data.trends?.materialUploadsDaily || data.trends.materialUploadsDaily.length === 0) { + addLog('warn', '本月新素材数据为空,请检查API返回') + console.warn('[MonitorTab] materialUploadsDaily is empty') + } else { + console.log('[MonitorTab] materialUploadsDaily sample:', data.trends.materialUploadsDaily.slice(0, 5)) + } + if (!data.trends?.materialUploadsMonthly || data.trends.materialUploadsMonthly.length === 0) { + addLog('warn', '素材上传趋势(月度)数据为空,请检查API返回') + console.warn('[MonitorTab] materialUploadsMonthly is empty') + } else { + console.log('[MonitorTab] materialUploadsMonthly sample:', data.trends.materialUploadsMonthly.slice(0, 5)) + } + } + }, [data]) + + useEffect(() => { + if (error) { + addLog('error', `数据加载失败: ${error}`) + } + }, [error]) const formattedLastUpdated = useMemo(() => { if (!lastUpdated) return '尚未获取' @@ -66,9 +118,348 @@ export default function MonitorTab({ showToast }: MonitorTabProps) { }, [lastUpdated]) const handleManualRefresh = () => { + addLog('info', '手动刷新数据') void refresh({ background: Boolean(data) }) } + // 导出素材CSV功能 + const handleExportMaterials = async () => { + const escapeCell = (value: string | number | null | undefined) => + `"${String(value ?? '').replace(/"/g, '""')}"` + try { + const [materialsResponse, logsResponse] = await Promise.all([ + fetch('/api/materials?pageSize=10000'), + fetch('/api/admin/material-logs?limit=200'), + ]) + if (!materialsResponse.ok) throw new Error('获取素材数据失败') + const result = await materialsResponse.json() + const materials = result.materials || [] + const logsPayload = logsResponse.ok ? await logsResponse.json() : null + const materialLogsForExport = (logsPayload?.logs || []).slice(0, 200) + + const csvRows: string[] = [] + const headers: string[] = [] + + if (exportOptions.includeBasicInfo) { + headers.push('ID', '产品型号', '品牌名称', '系列名称', 'SKU编码', '材质纹理') + } + if (exportOptions.includeStats) { + headers.push('下载次数', '点赞次数', '是否精品') + } + if (exportOptions.includeUserInfo) { + headers.push('上传者', '作者名称') + } + if (exportOptions.includeDates) { + headers.push('创建时间', '更新时间') + } + if (exportOptions.includeFiles) { + headers.push('ZIP文件名', 'ZIP文件大小(字节)') + } + headers.push('分类', '场景', '平台', '素材类型', '渲染器', '渲染时间', '图像分辨率', '颜色模式') + + csvRows.push(headers.join(',')) + + materials.forEach((material: any) => { + const row: string[] = [] + if (exportOptions.includeBasicInfo) { + row.push( + material.id || '', + `"${(material.productModel || '').replace(/"/g, '""')}"`, + `"${(material.brandName || '').replace(/"/g, '""')}"`, + `"${(material.seriesName || '').replace(/"/g, '""')}"`, + `"${(material.skuCode || '').replace(/"/g, '""')}"`, + `"${(material.materialTexture || '').replace(/"/g, '""')}"` + ) + } + if (exportOptions.includeStats) { + row.push( + material.downloadCount || 0, + material.likeCount || 0, + material.isPremium ? '是' : '否' + ) + } + if (exportOptions.includeUserInfo) { + row.push( + `"${(material.user?.username || '').replace(/"/g, '""')}"`, + `"${(material.authorName || '').replace(/"/g, '""')}"` + ) + } + if (exportOptions.includeDates) { + row.push( + material.createdAt ? new Date(material.createdAt).toLocaleString('zh-CN') : '', + material.updatedAt ? new Date(material.updatedAt).toLocaleString('zh-CN') : '' + ) + } + if (exportOptions.includeFiles) { + row.push( + `"${(material.zipFileName || '').replace(/"/g, '""')}"`, + material.zipFileSize?.toString() || '0' + ) + } + row.push( + `"${(material.categoryName || '').replace(/"/g, '""')}"`, + `"${(material.sceneName || '').replace(/"/g, '""')}"`, + `"${(material.platformName || '').replace(/"/g, '""')}"`, + `"${(material.materialType || '').replace(/"/g, '""')}"`, + `"${(material.renderer || '').replace(/"/g, '""')}"`, + `"${(material.renderTime || '').replace(/"/g, '""')}"`, + `"${(material.imageResolution || '').replace(/"/g, '""')}"`, + `"${(material.colorMode || '').replace(/"/g, '""')}"` + ) + csvRows.push(row.join(',')) + }) + + if (materialLogsForExport.length) { + csvRows.push('') + csvRows.push('=== 素材操作日志(最近200条) ===') + csvRows.push('时间,操作,素材名称,操作人,邮箱') + materialLogsForExport.forEach((log: any) => { + const row = [ + escapeCell(log.timestamp), + escapeCell(log.action), + escapeCell(log.materialName), + escapeCell(log.username), + escapeCell(log.userEmail), + ] + csvRows.push(row.join(',')) + }) + } + + const csvContent = csvRows.join('\n') + const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `素材导出_${new Date().toISOString().split('T')[0]}.csv` + link.click() + URL.revokeObjectURL(url) + + setShowExportModal(false) + showToast('素材数据导出成功', 'success') + + // 添加日志 + addLog('info', `导出素材数据,共 ${materials.length} 条记录`) + } catch (error) { + console.error('导出失败:', error) + showToast('导出失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error') + addLog('error', `导出素材数据失败: ${error instanceof Error ? error.message : '未知错误'}`) + } + } + + // 添加日志 + const addLog = (level: string, message: string) => { + const newLog = { + time: new Date().toLocaleString('zh-CN'), + level, + message, + } + setLogs((prev) => [newLog, ...prev].slice(0, 100)) // 只保留最近100条 + } + + // 获取素材操作日志 + const fetchMaterialLogs = async () => { + setLoadingMaterialLogs(true) + try { + const response = await fetch('/api/admin/material-logs?limit=100') + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || `获取素材日志失败 (状态码: ${response.status})`) + } + const result = await response.json() + setMaterialLogs(result.logs || []) + } catch (error: any) { + console.error('获取素材日志失败:', error) + const errorMessage = error?.message || '获取素材日志失败,请稍后重试' + showToast(errorMessage, 'error') + } finally { + setLoadingMaterialLogs(false) + } + } + + // 导出素材日志CSV + const handleExportMaterialLogs = () => { + try { + const escapeCell = (value: string | number | null | undefined) => + `"${String(value ?? '').replace(/"/g, '""')}"` + + const csvRows: string[] = [] + csvRows.push('时间,操作,素材名称,素材ID,操作人,邮箱') + + materialLogs.forEach((log) => { + const row = [ + escapeCell(new Date(log.timestamp).toLocaleString('zh-CN')), + escapeCell(log.action), + escapeCell(log.materialName), + escapeCell(log.materialId), + escapeCell(log.username), + escapeCell(log.userEmail), + ] + csvRows.push(row.join(',')) + }) + + const csvContent = csvRows.join('\n') + const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `素材操作日志_${new Date().toISOString().split('T')[0]}.csv` + link.click() + URL.revokeObjectURL(url) + + showToast('素材日志导出成功', 'success') + addLog('info', `导出素材日志,共 ${materialLogs.length} 条记录`) + } catch (error) { + console.error('导出日志失败:', error) + showToast('导出日志失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error') + addLog('error', `导出素材日志失败: ${error instanceof Error ? error.message : '未知错误'}`) + } + } + + const monthlyUploads = useMemo(() => { + if (!data?.trends?.materialUploadsMonthly) { + console.log('[MonitorTab] No materialUploadsMonthly data') + return [] + } + // 只显示最近12个月的数据 + const now = new Date() + const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 12, 1) + + console.log('[MonitorTab] Filtering monthly data from:', twelveMonthsAgo.toISOString()) + console.log('[MonitorTab] Total monthly records:', data.trends.materialUploadsMonthly.length) + + const filtered = data.trends.materialUploadsMonthly + .filter((item) => { + try { + if (!item.date) { + console.warn('[MonitorTab] Monthly item has no date:', item) + return false + } + // 解析日期,支持 YYYY-MM 格式 + let date: Date + if (typeof item.date === 'string') { + if (item.date.match(/^\d{4}-\d{2}$/)) { + const parts = item.date.split('-').map((part) => Number(part)) + date = new Date(parts[0], parts[1] - 1, 1) + } else { + date = new Date(item.date) + } + } else { + date = new Date(item.date) + } + + const matches = date >= twelveMonthsAgo + if (matches) { + console.log('[MonitorTab] Matched monthly item:', item.date, '->', date.toISOString(), 'count:', item.count) + } + + return matches + } catch (error) { + console.error('[MonitorTab] Error parsing monthly date:', item.date, error) + return false + } + }) + + console.log('[MonitorTab] Filtered monthly data:', filtered.length, 'items') + + return filtered.sort((a, b) => { + try { + const dateA = typeof a.date === 'string' && a.date.match(/^\d{4}-\d{2}$/) + ? new Date(a.date.split('-').map(Number).join('-') + '-01') + : new Date(a.date) + const dateB = typeof b.date === 'string' && b.date.match(/^\d{4}-\d{2}$/) + ? new Date(b.date.split('-').map(Number).join('-') + '-01') + : new Date(b.date) + return dateA.getTime() - dateB.getTime() + } catch { + return 0 + } + }) + }, [data?.trends?.materialUploadsMonthly]) + const materialUploadMonthlyMax = monthlyUploads.length + ? Math.max(...monthlyUploads.map((item) => item.count), 1) + : 1 + + const currentMonthDailyData = useMemo(() => { + if (!data?.trends?.materialUploadsDaily) { + console.log('[MonitorTab] No materialUploadsDaily data') + return [] + } + const now = new Date() + const currentYear = now.getFullYear() + const currentMonth = now.getMonth() + + console.log('[MonitorTab] Filtering for current month:', currentYear, currentMonth + 1) + console.log('[MonitorTab] Total daily records:', data.trends.materialUploadsDaily.length) + + // 过滤出本月的数据,使用更宽松的匹配 + const filtered = data.trends.materialUploadsDaily.filter((item) => { + try { + if (!item.date) { + console.warn('[MonitorTab] Item has no date:', item) + return false + } + // 尝试多种日期格式解析 + let date: Date + if (typeof item.date === 'string') { + // 如果是 YYYY-MM-DD 格式 + if (item.date.match(/^\d{4}-\d{2}-\d{2}$/)) { + const parts = item.date.split('-').map((part) => Number(part)) + date = new Date(parts[0], parts[1] - 1, parts[2]) + } else { + date = new Date(item.date) + } + } else { + date = new Date(item.date) + } + + const itemYear = date.getFullYear() + const itemMonth = date.getMonth() + const matches = itemMonth === currentMonth && itemYear === currentYear + + if (matches) { + console.log('[MonitorTab] Matched item:', item.date, '->', date.toISOString(), 'count:', item.count) + } + + return matches + } catch (error) { + console.error('[MonitorTab] Error parsing date:', item.date, error) + return false + } + }) + + console.log('[MonitorTab] Filtered current month daily data:', filtered.length, 'items') + + // 按日期排序 + return filtered.sort((a, b) => { + try { + const dateA = typeof a.date === 'string' && a.date.match(/^\d{4}-\d{2}-\d{2}$/) + ? new Date(a.date.split('-').map(Number).join('-')) + : new Date(a.date) + const dateB = typeof b.date === 'string' && b.date.match(/^\d{4}-\d{2}-\d{2}$/) + ? new Date(b.date.split('-').map(Number).join('-')) + : new Date(b.date) + return dateA.getTime() - dateB.getTime() + } catch { + return 0 + } + }) + }, [data?.trends?.materialUploadsDaily]) + const currentMonthDailyMax = currentMonthDailyData.length + ? Math.max(...currentMonthDailyData.map((item) => item.count), 1) + : 1 + + const parseDateStringToLocal = (value: string) => { + const parts = value.split('-').map((part) => Number(part)) + if (parts.length < 3) return new Date(value) + const [year, month, day] = parts + return new Date(year, month - 1, day) + } + + const formatDayLabel = (value: string) => + parseDateStringToLocal(value).toLocaleDateString('zh-CN', { + day: '2-digit', + }) + if (loading) { return } @@ -81,9 +472,6 @@ export default function MonitorTab({ showToast }: MonitorTabProps) { return } - const handleToggle = (key: SystemSettingKey, value: boolean) => { - void updateSystemSetting(key, value).catch(() => undefined) - } return (
@@ -125,77 +513,196 @@ export default function MonitorTab({ showToast }: MonitorTabProps) { 刷新数据 + {currentIsAdmin && ( + <> + + + + )}
-
- {OVERVIEW_CARDS.map((card) => ( -
-
+
+ {OVERVIEW_CARDS.map((card) => { + const CardContent = ( +
-
- {card.icon} +
+ {card.icon}
-
{card.label}
-
{card.getValue(data)}
+
{card.label}
+
{card.getValue(data)}
-
- ))} + ) + + if (card.href) { + return ( +
router.push(card.href!)} + title={`点击查看${card.label}详情`} + > + {CardContent} +
+ ) + } + + return ( +
+ {CardContent} +
+ ) + })}
-
-

📊 素材上传趋势(最近12个月)

-
- {data.trends.materialUploads.length ? ( -
- {data.trends.materialUploads.map((item) => ( -
- {item.date} -
-
-
+
+
+
+

🆕 本月新素材(每日上传)

+ 累计 {currentMonthDailyData.reduce((sum, item) => sum + item.count, 0)} +
+
+ {currentMonthDailyData.length ? ( +
+ {currentMonthDailyData.map((item) => { + const barHeight = Math.max(8, (item.count / currentMonthDailyMax) * 100) + return ( +
+ {item.count} +
+
+
+ {formatDayLabel(item.date)}
- {item.count} -
-
- ))} -
- ) : ( -
- 暂无趋势数据 -
- )} + ) + })} +
+ ) : ( +
本月暂未上传素材
+ )} +
+
+ +
+
+

📊 素材上传趋势(最近12个月)

+ {monthlyUploads.length > 0 && ( +
+ 最近月份上传: + + {monthlyUploads[monthlyUploads.length - 1]?.count || 0} + +
+ )} +
+
+ {monthlyUploads.length ? ( +
+ {monthlyUploads.map((item) => { + const barHeight = Math.max(8, (item.count / materialUploadMonthlyMax) * 100) + return ( +
+ {item.count} +
+
+
+ {item.date} +
+ ) + })} +
+ ) : ( +
暂无趋势数据
+ )} +
-

🏢 部门分布

-
- {data.distribution.departments.length ? ( - data.distribution.departments.map((item) => ( -
- {item.department || '未设置'} - {item.count} -
- )) - ) : ( -

暂无部门分布数据

- )} +

🏢 架构分布(一级架构)

+
+ {(() => { + // 只显示一级部门(没有"-"分隔符的部门) + if (!data.distribution.departments.length) { + return

暂无架构分布数据

+ } + + const deptMap = new Map() + + data.distribution.departments.forEach((item) => { + const dept = item.department || '未设置' + // 如果部门名包含"-",取第一部分作为一级部门 + const firstLevel = dept.includes('-') ? dept.split('-')[0] : dept + deptMap.set(firstLevel, (deptMap.get(firstLevel) || 0) + item.count) + }) + + const firstLevelDepartments = Array.from(deptMap.entries()) + .map(([department, count]) => ({ department, count })) + .sort((a, b) => b.count - a.count) + + return firstLevelDepartments.length ? ( + firstLevelDepartments.map((item) => ( +
+ + {item.department} + + {item.count} +
+ )) + ) : ( +

暂无架构分布数据

+ ) + })()}
@@ -224,24 +731,24 @@ export default function MonitorTab({ showToast }: MonitorTabProps) {

⚙️ 系统信息

- handleToggle('registrationEnabled', value)} - activeText="开启" - inactiveText="关闭" - /> - handleToggle('userActivationRequired', value)} - activeText="需要" - inactiveText="关闭" - />
- 数据库状态 + 站点名称 + {data.systemInfo.siteName || '未设置'} +
+
+ 注册功能 + + {data.systemInfo.registrationEnabled ? '开启' : '关闭'} + +
+
+ 用户激活 + + {data.systemInfo.userActivationRequired ? '需要' : '关闭'} + +
+
+ 数据库状态
- 运行时间 - - {Math.floor(data.status.uptime / 3600)} 小时 + 运行时间 + + {Math.floor(data.status.uptime / 3600)} 小时 {Math.floor((data.status.uptime % 3600) / 60)} 分钟
@@ -279,101 +786,267 @@ export default function MonitorTab({ showToast }: MonitorTabProps) { - {data.recentActivity.map((activity) => ( - - {activity.title} - - {activity.uploadedBy.username} - - - {new Date(activity.createdAt).toLocaleString('zh-CN')} + {data.recentActivity && data.recentActivity.length > 0 ? ( + data.recentActivity.map((activity) => ( + + {activity.title} + + {activity.uploadedBy.username} + + + {new Date(activity.createdAt).toLocaleString('zh-CN')} + + + )) + ) : ( + + + 暂无最近活动 - ))} + )}
+ + {/* 导出素材CSV弹窗 */} + setShowExportModal(false)} + title="导出素材数据" + size="medium" + footer={ +
+ + +
+ } + > +
+

请选择要导出的字段:

+
+ + + + + +
+
+
+ + {/* 素材操作日志弹窗 */} + setShowLogsModal(false)} + title="素材操作日志" + size="large" + footer={ +
+ + + +
+ } + > +
+ {loadingMaterialLogs ? ( +
+ + 加载中... +
+ ) : materialLogs.length === 0 ? ( +

暂无操作日志

+ ) : ( + materialLogs.map((log) => { + const cardColorMap: Record = { + upload: 'border-green-500/50 bg-green-50/50 dark:bg-green-900/20', + update: 'border-blue-500/50 bg-blue-50/50 dark:bg-blue-900/20', + delete: 'border-red-500/50 bg-red-50/50 dark:bg-red-900/20', + toggle: 'border-amber-500/50 bg-amber-50/50 dark:bg-amber-900/20', + } + + const badgeColorMap: Record = { + upload: 'bg-green-500 text-white', + update: 'bg-blue-500 text-white', + delete: 'bg-red-500 text-white', + toggle: 'bg-amber-500 text-white', + } + + const cardTone = cardColorMap[log.type] || (theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50') + const badgeTone = badgeColorMap[log.type] || 'bg-slate-500 text-white' + + return ( +
+
+
+
+ + {log.action} + + + {new Date(log.timestamp).toLocaleString('zh-CN')} + +
+

+ 素材:{log.materialName || '未命名素材'} +

+ {(log as any).actionDetail && ( +
+ {Array.isArray((log as any).actionDetail) ? ( + (log as any).actionDetail.map((detail: string, idx: number) => ( +

+ {detail} +

+ )) + ) : ( +

{(log as any).actionDetail}

+ )} +
+ )} +

+ 操作人:{log.username} ({log.userEmail}) +

+
+
+
+ ) + }) + )} +
+
) } -type SystemSettingKey = 'registrationEnabled' | 'userActivationRequired' - const OVERVIEW_CARDS: Array<{ label: string icon: string className: string getValue: (data: DashboardData) => number + href?: string + onClick?: () => void }> = [ { label: '总用户数', icon: '👥', className: 'bg-blue-500 shadow-blue-500/40', getValue: (data) => data.overview.totalUsers, + href: '/admin?tab=users', }, { label: '活跃用户', icon: '⚡', className: 'bg-green-500 shadow-green-500/40', getValue: (data) => data.overview.activeUsers, + href: '/admin?tab=users', }, { label: '总素材数', icon: '📦', className: 'bg-purple-500 shadow-purple-500/40', getValue: (data) => data.overview.totalMaterials, + href: '/', }, { label: '本月新用户', icon: '🆕', className: 'bg-yellow-500 shadow-yellow-500/40', getValue: (data) => data.overview.newUsersThisMonth, - }, - { - label: '本月新素材', - icon: '📈', - className: 'bg-indigo-500 shadow-indigo-500/40', - getValue: (data) => data.overview.newMaterialsThisMonth, + href: '/admin?tab=users', }, ] -interface SystemToggleRowProps { - label: string - enabled: boolean - editable: boolean - onToggle: (value: boolean) => void - activeText: string - inactiveText: string -} - -function SystemToggleRow({ label, enabled, editable, onToggle, activeText, inactiveText }: SystemToggleRowProps) { - // 需要使用主题 Hook,但这个组件在函数外部定义,暂时保留 dark: 前缀 - // 可以考虑将这个组件移到函数内部或传入 theme 参数 - if (!editable) { - return ( -
- {label} - - {enabled ? activeText : inactiveText} - -
- ) - } - - return ( -
- {label} -
+
{editorOpen && form && ( -
-
+ /> +
)}
diff --git a/app/admin/_components/UsersTab.tsx b/app/admin/_components/UsersTab.tsx index f3d6089..83788e4 100644 --- a/app/admin/_components/UsersTab.tsx +++ b/app/admin/_components/UsersTab.tsx @@ -79,20 +79,29 @@ export default function UsersTab({ return map }, [users]) + // 过滤掉admin账户(如果有其他最高权限账户) + const filteredUsers = useMemo(() => { + const hasOtherSuperAdmin = users.some((u) => u.isSuperAdmin && u.email !== 'admin') + if (hasOtherSuperAdmin) { + return users.filter((u) => u.email !== 'admin') + } + return users + }, [users]) + const { treeRoots } = useMemo(() => { - if (!users.length) { + if (!filteredUsers.length) { return { treeRoots: [] as UserTreeNode[] } } const childMap = new Map() - users.forEach((user) => { + filteredUsers.forEach((user) => { if (user?.id) { childMap.set(user.id, []) } }) const rootIds: string[] = [] - users.forEach((user) => { + filteredUsers.forEach((user) => { if (!user?.id) return const parentId = user.parentId if (parentId && childMap.has(parentId)) { @@ -126,7 +135,7 @@ export default function UsersTab({ .sort((a, b) => compareUsers(a.user, b.user)) return { treeRoots: buildNodes(rootIds) } - }, [userLookup, users]) + }, [userLookup, filteredUsers]) const toggleUserNodeCollapse = useCallback((nodeId: string) => { setCollapsedUserNodeIds((prev) => { @@ -251,7 +260,7 @@ export default function UsersTab({ const filterNodes = (nodes: UserTreeNode[]): UserTreeNode[] => nodes .map((node) => { - const filteredChildren = filterNodes(node.children) + const filteredChildren = filterNodes(node.children) return matchUser(node.user) || filteredChildren.length ? { user: node.user, children: filteredChildren } : null @@ -283,7 +292,7 @@ export default function UsersTab({ return labelA.localeCompare(labelB, 'zh-CN') } - users.forEach((user) => { + filteredUsers.forEach((user) => { const deptName = (user.department && user.department.trim()) || '未分配部门' const entry = ensureEntry(deptName) if (user.isSuperAdmin || user.managedDepartment || user.role === 'ADMIN') { @@ -302,17 +311,17 @@ export default function UsersTab({ }) return map - }, [users, departments]) + }, [filteredUsers, departments]) const departmentMaterialCounts = useMemo(() => { const counts = new Map() - users.forEach((user) => { + filteredUsers.forEach((user) => { const deptName = (user.department && user.department.trim()) || '未分配部门' const current = counts.get(deptName) || 0 counts.set(deptName, current + (user._count?.materials || 0)) }) return counts - }, [users]) + }, [filteredUsers]) const hasDepartments = departments.length > 0 const editingUserDepartment = editingUser?.department ?? '' @@ -326,71 +335,71 @@ export default function UsersTab({ }, [editingUserDepartment, users]) const renderStatusBadges = (user: User) => ( - <> - + - {user.isActive ? '已激活' : '未激活'} - - + {user.isActive ? '已激活' : '未激活'} + + - {user.canDownload ? '可下载' : '禁止下载'} - - - ) + > + {user.canDownload ? '可下载' : '禁止下载'} + + + ) const renderActionButtons = useCallback((user: User, options?: { showRoleToggle?: boolean }) => { - const allowed = canManageUser(user) - if (!allowed && user.id !== currentUserId) { + const allowed = canManageUser(user) + if (!allowed && user.id !== currentUserId) { return 无权限修改 - } + } - const actionButton = ( - label: string, - className: string, - onClick: () => void, + const actionButton = ( + label: string, + className: string, + onClick: () => void, visible: boolean = true, - ) => { - if (!visible) return null - return ( - - ) - } + onClick={() => { + if (!allowed) return + onClick() + }} + > + {label} + + ) + } - return ( -
- {actionButton( - '编辑', + return ( +
+ {actionButton( + '编辑', styles.buttonStyles.secondary, () => handleEditUser(user), - )} - {actionButton( - user.isActive ? '禁用' : '激活', + )} + {actionButton( + user.isActive ? '禁用' : '激活', user.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success, () => onToggleActive(user.id, user.isActive), )} @@ -524,7 +533,7 @@ export default function UsersTab({ collapsedDepartments={collapsedDepartments} onToggleDepartmentCollapse={toggleDepartmentCollapse} renderUserRow={renderUserRow} - totalUsers={users.length} + totalUsers={filteredUsers.length} /> )}
diff --git a/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx b/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx index ee3c333..0393c60 100644 --- a/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx +++ b/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx @@ -67,22 +67,55 @@ export default function TemplateBasicsForm({

草稿:仅保存,不生效;启用:用户可以使用

-
- - -

新版本中模板即素材类型,这里用于绑定老数据。

+
+
+ + +

用于展示的业务类型,例如“视频”、“PSD”等。

+
+ +
+ +
+ {[ + { label: '图片/文档', value: 'IMAGE', hint: '普通素材,走图片/文件流程' }, + { label: '视频', value: 'VIDEO', hint: '走视频上传与转码流程' }, + { label: '其他', value: 'OTHER', hint: '暂不归类,可后续扩展' }, + ].map((option) => { + const active = (form.materialKind || 'IMAGE') === option.value + return ( + + ) + })} +
+

用于驱动上传/处理链路,视频模板请选择“视频”。

+
{showPresetSelector && ( diff --git a/app/admin/_components/template-manager/editor/TemplateFieldsEditor.tsx b/app/admin/_components/template-manager/editor/TemplateFieldsEditor.tsx index 453f888..ddf0ebb 100644 --- a/app/admin/_components/template-manager/editor/TemplateFieldsEditor.tsx +++ b/app/admin/_components/template-manager/editor/TemplateFieldsEditor.tsx @@ -69,7 +69,7 @@ export default function TemplateFieldsEditor({

字段配置

添加用户上传时需要填写的字段

- 先选预设再按需调整,拖拽左侧卡片即可重排顺序。 + 先选预设再按需调整,使用上下箭头按钮调整字段顺序。

@@ -112,15 +112,11 @@ export default function TemplateFieldsEditor({ } ${ isDragging ? 'opacity-40 scale-[0.96] cursor-grabbing border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/50 transition-all duration-200' - : 'cursor-grab' + : 'cursor-default' }`} - draggable + draggable={false} onMouseEnter={() => onEditingFieldChange(index)} onMouseLeave={() => onEditingFieldChange(null)} - onDragStart={(event) => onFieldDragStart(event, index)} - onDragOver={(event) => onFieldDragOver(event, index, 'editor')} - onDrop={onFieldDrop} - onDragEnd={onFieldDragEnd} >
@@ -337,16 +333,6 @@ export default function TemplateFieldsEditor({ /> 必填 -
{/* Form */} @@ -217,8 +217,8 @@ function CompleteProfileContent() { /> )}
-

钉钉账号

-

{userInfo.realName}

+

钉钉账号

+

{userInfo.realName}

@@ -239,17 +239,17 @@ function CompleteProfileContent() { {/* 显示钉钉用户名(只读) */}
-
-
-
- ) + return null } - diff --git a/app/globals.css b/app/globals.css index 254c4a6..7b90825 100644 --- a/app/globals.css +++ b/app/globals.css @@ -40,11 +40,45 @@ body { } .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; + @apply btn-apple bg-slate-900 dark:bg-slate-700 text-white hover:bg-slate-800 dark:hover:bg-slate-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; + @apply btn-apple bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-slate-200 hover:bg-slate-300 dark:hover:bg-slate-600 active:scale-95; +} + +/* 滑块样式 */ +.slider::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: #3b82f6; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: #3b82f6; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.slider:focus { + outline: none; +} + +.slider:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); +} + +.slider:focus::-moz-range-thumb { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); } .btn-danger { @@ -53,12 +87,12 @@ body { /* 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; + @apply w-full px-4 py-3 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-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; + @apply bg-white dark:bg-slate-800 rounded-apple shadow-apple hover:shadow-apple-lg transition-all duration-300 border border-transparent dark:border-slate-700; } /* Scrollbar styling (light & dark) */ @@ -167,3 +201,6 @@ body { 0 1px 0 rgba(0, 0, 0, 0.4); } +/* Material 预览弹窗现在重新使用统一 UI Modal 样式, + 不再需要单独的溢出与尺寸覆盖规则。 */ + diff --git a/app/layout.tsx b/app/layout.tsx index 4074adb..f35750f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,7 +25,7 @@ export async function generateMetadata(): Promise { const settings = await getSystemSettings() return { - title: settings?.siteName || '由壹点3D素材', + title: settings?.siteName || 'FLNXI素材库', description: '3D素材分享平台', } } @@ -68,3 +68,4 @@ export default function RootLayout({ ) } + diff --git a/app/login/page.tsx b/app/login/page.tsx index 447f9ac..b87cdb5 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -16,7 +16,7 @@ export default function LoginPage() { // 系统设置 const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '') - const [siteName, setSiteName] = useState('由壹点3D素材') + const [siteName, setSiteName] = useState('FLNXI素材库') // Logo 加载状态 const [logoError, setLogoError] = useState(false) @@ -54,7 +54,7 @@ export default function LoginPage() { if (response.ok) { const settings = await response.json() setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '') - setSiteName(settings.siteName || '由壹点3D素材') + setSiteName(settings.siteName || 'FLNXI素材库') } } catch (error) { console.error('加载系统设置失败:', error) @@ -285,8 +285,8 @@ export default function LoginPage() {
)}
-

{siteName}

-

欢迎回来,请登录您的账号

+

{siteName}

+

欢迎回来,请登录您的账号

{/* Login Form */} @@ -299,7 +299,7 @@ export default function LoginPage() { )}
-
-

+

还没有账号?{' '} 立即注册 @@ -383,11 +383,11 @@ export default function LoginPage() { className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()} > -

-

钉钉扫码登录

+
+

钉钉扫码登录

+ {typeList.map((type) => { + const normalized = normalizeMaterialType(type) + const isActive = Boolean(normalized) && selectedMaterialType === normalized + const count = materialTypesCount[type] ?? 0 + return ( + + ) + })} +
+ ) + } + + const handleHomeFilterSelection = (fieldKey: string, option: string | HomeFilterOption) => { + const optionValue = typeof option === 'string' ? option : option.value + setHomeFilterSelections((prev) => { + const next = { ...prev } + if (prev[fieldKey] === optionValue) { + delete next[fieldKey] + } else { + next[fieldKey] = optionValue + } + return next + }) + } + + const getHomeFilterOptionLabel = (fieldKey: string, optionValue: string) => { + const field = homeFilterFields[fieldKey] + if (!field) return optionValue + const match = field.options.find((option) => option.value === optionValue) + return match?.label || optionValue + } + + const clearHomeFilterSelection = (fieldKey: string) => { + setHomeFilterSelections((prev) => { + if (!(fieldKey in prev)) return prev + const next = { ...prev } + delete next[fieldKey] + return next + }) + } + const handleDownload = (id: string) => { - setDownloadingId(id) - setDownloadProgress(0) + // 如果已经在下载,直接返回 + if (downloadingIds.has(id)) { + return + } + + console.log(`[Download] Starting download for material ${id}`) + setDownloadingIds((prev) => new Set(prev).add(id)) + setDownloadProgresses((prev) => ({ ...prev, [id]: 0 })) + const scrollPosition = typeof window !== 'undefined' ? window.scrollY : 0 const xhr = new XMLHttpRequest() + xhr.responseType = 'arraybuffer' // 监听下载进度 xhr.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = Math.round((e.loaded / e.total) * 100) - setDownloadProgress(percentComplete) + setDownloadProgresses((prev) => ({ ...prev, [id]: percentComplete })) } }) - + xhr.addEventListener('load', () => { + console.log(`[Download] Response received, status: ${xhr.status}`) if (xhr.status === 200) { try { - const blob = new Blob([xhr.response], { type: 'application/zip' }) + const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream' + const blob = new Blob([xhr.response], { type: contentType }) // 从响应头获取文件名 const contentDisposition = xhr.getResponseHeader('Content-Disposition') - let filename = 'material.zip' + let filename = 'material' if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) if (filenameMatch && filenameMatch[1]) { filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '')) } + } else if (contentType) { + const extensionMap: Record = { + 'application/zip': '.zip', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/x-msvideo': '.avi', + 'video/quicktime': '.mov', + } + const matchedExt = Object.entries(extensionMap).find(([type]) => contentType.includes(type)) + if (matchedExt) { + filename += matchedExt[1] + } } + // 使用download属性直接下载,避免浏览器提示 const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename + a.style.display = 'none' document.body.appendChild(a) a.click() - a.remove() - window.URL.revokeObjectURL(url) + // 延迟移除,确保下载开始 + setTimeout(() => { + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + }, 100) - // 刷新列表以更新下载次数 - fetchMaterials() + // 本地更新下载次数,避免整页刷新 + setMaterials((prev) => + prev.map((material) => + material.id === id + ? { + ...material, + downloadCount: (material.downloadCount || 0) + 1, + } + : material + ) + ) + if (previewMaterial && previewMaterial.id === id) { + setPreviewMaterial({ + ...previewMaterial, + downloadCount: (previewMaterial.downloadCount || 0) + 1, + }) + } + // 排行榜可能发生变化,刷新排行榜数据 + fetchMaterialRankings() + if (typeof window !== 'undefined') { + requestAnimationFrame(() => { + window.scrollTo({ top: scrollPosition, behavior: 'auto' }) + }) + } } catch (error) { console.error('下载处理失败:', error) alert({ @@ -507,42 +1174,77 @@ export default function Home() { }) } } else { + console.error(`[Download] Download failed with status ${xhr.status}`) try { const errorText = xhr.responseText + console.error(`[Download] Error response:`, errorText) const error = JSON.parse(errorText) alert({ title: '下载失败', - message: error.error || '下载失败', + message: error.error || `下载失败 (状态码: ${xhr.status})`, variant: 'error', }) - } catch { + } catch (parseError) { + console.error('[Download] Failed to parse error response:', parseError) alert({ title: '下载失败', - message: '下载失败', + message: `下载失败 (状态码: ${xhr.status})`, variant: 'error', }) } } - setDownloadingId(null) - setDownloadProgress(0) + + // 清理下载状态 + setDownloadingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + setDownloadProgresses((prev) => { + const next = { ...prev } + delete next[id] + return next + }) }) - xhr.addEventListener('error', () => { + xhr.addEventListener('error', (e) => { + console.error('[Download] Network error:', e) alert({ title: '下载失败', - message: '下载失败,请稍后重试', + message: '网络错误,请检查网络连接后重试', variant: 'error', }) - setDownloadingId(null) - setDownloadProgress(0) + setDownloadingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + setDownloadProgresses((prev) => { + const next = { ...prev } + delete next[id] + return next + }) }) xhr.open('GET', `/api/materials/download?id=${id}`) - xhr.responseType = 'arraybuffer' xhr.send() } const handleLike = async (id: string) => { + if (currentUserId) { + const targetMaterial = + materials.find((item) => item.id === id) || + (previewMaterial && previewMaterial.id === id ? previewMaterial : null) + if (targetMaterial && targetMaterial.userId === currentUserId) { + alert({ + title: '无法点赞', + message: '不能给自己的素材点赞', + variant: 'warning', + }) + return + } + } + try { const response = await fetch('/api/materials/like', { method: 'POST', @@ -553,8 +1255,30 @@ export default function Home() { }) if (response.ok) { - // 刷新列表以更新点赞状态 - fetchMaterials() + const result = await response.json() + // 更新本地状态 + setMaterials((prev) => + prev.map((m) => { + if (m.id === id) { + return { + ...m, + isLiked: result.liked, + likeCount: result.liked ? m.likeCount + 1 : Math.max(0, m.likeCount - 1), + } + } + return m + }) + ) + // 如果预览的素材是当前素材,也更新预览状态 + if (previewMaterial && previewMaterial.id === id) { + setPreviewMaterial({ + ...previewMaterial, + isLiked: result.liked, + likeCount: result.liked ? previewMaterial.likeCount + 1 : Math.max(0, previewMaterial.likeCount - 1), + }) + } + // 点赞榜可能发生变化,重新获取排行榜(不会刷新列表) + fetchMaterialRankings() } else { const error = await response.json() alert({ @@ -573,12 +1297,187 @@ export default function Home() { } } - // 编辑素材 - const handleEdit = (material: Material) => { - router.push(`/edit/${material.id}`) + const handleFavorite = async (id: string) => { + try { + const response = await fetch('/api/materials/favorite', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ materialId: id }), + }) + + if (!response.ok) { + const error = await response.json() + alert({ + title: '操作失败', + message: error.error || '更新收藏失败', + variant: 'error', + }) + return + } + + const result = await response.json() + setMaterials((prev) => + prev.map((material) => + material.id === id ? { ...material, isFavorited: result.favorited } : material + ) + ) + setPreviewMaterial((prev) => { + if (!prev || prev.id !== id) return prev + return { ...prev, isFavorited: result.favorited } + }) + } catch (error) { + console.error('Favorite error:', error) + alert({ + title: '操作失败', + message: '更新收藏失败,请稍后重试', + variant: 'error', + }) + } } - if (status === 'loading' || loading) { + const handlePremiumToggle = async (id: string, nextState: boolean) => { + try { + const response = await fetch('/api/materials/premium', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ materialId: id, isPremium: nextState }), + }) + + if (!response.ok) { + const error = await response.json() + alert({ + title: '操作失败', + message: error.error || '更新精品状态失败', + variant: 'error', + }) + return + } + + setMaterials((prev) => + prev.map((material) => + material.id === id ? { ...material, isPremium: nextState } : material + ) + ) + setPreviewMaterial((prev) => { + if (!prev || prev.id !== id) return prev + return { + ...prev, + isPremium: nextState, + rankingBadges: getRankingBadges(id, { isPremium: nextState }), + } + }) + setMaterialRankings((prev) => ({ + ...prev, + premium: nextState + ? Array.from(new Set([id, ...prev.premium])).slice(0, 10) + : prev.premium.filter((item) => item !== id), + })) + } catch (error) { + console.error('Premium toggle error:', error) + alert({ + title: '操作失败', + message: '更新精品状态失败,请稍后重试', + variant: 'error', + }) + } + } + + // 删除素材(仅管理员有权限,按钮可根据系统设置控制是否对普通用户展示) + const handleDelete = async (id: string) => { + // 后端仍然会做权限验证,这里主要负责 UI 确认和反馈 + const confirmed = await confirm({ + title: '删除素材', + message: '确定要删除这个素材吗?该操作不可恢复。', + confirmText: '删除', + cancelText: '取消', + variant: 'danger', + }) + + if (!confirmed) return + + try { + const response = await fetch(`/api/materials?id=${encodeURIComponent(id)}`, { + method: 'DELETE', + }) + + if (response.ok) { + alert({ + title: '删除成功', + message: '素材已删除。', + variant: 'success', + }) + fetchMaterials() + } else { + const error = await response.json() + alert({ + title: '删除失败', + message: error.error || '删除失败,请稍后重试', + variant: 'error', + }) + } + } catch (error) { + console.error('Delete error:', error) + alert({ + title: '删除失败', + message: '删除失败,请稍后重试', + variant: 'error', + }) + } + } + + // 编辑素材 + const [editingMaterial, setEditingMaterial] = useState(null) + const handleEdit = async (material: Material) => { + // 获取完整的素材信息(包括templateId) + try { + const response = await fetch(`/api/materials/${material.id}`) + if (response.ok) { + const fullMaterial = await response.json() + setEditingMaterial(fullMaterial as Material) + } else { + // 如果获取失败,使用现有数据 + setEditingMaterial(material) + } + } catch (error) { + console.error('Failed to fetch material details:', error) + setEditingMaterial(material) + } + } + + // 检测URL中的edit参数,自动打开编辑弹窗 + useEffect(() => { + const editId = searchParams?.get('edit') + if (editId && status === 'authenticated' && !editingMaterial) { + // 从materials中查找对应的素材 + const material = materials.find((m) => m.id === editId) + if (material) { + setEditingMaterial(material) + // 清除URL参数 + router.replace('/', { scroll: false }) + } else { + // 如果素材不在列表中,尝试从API获取 + fetch(`/api/materials/${editId}`) + .then((res) => res.json()) + .then((data) => { + if (data && data.id) { + setEditingMaterial(data as Material) + router.replace('/', { scroll: false }) + } + }) + .catch((err) => { + console.error('Failed to fetch material for edit:', err) + }) + } + } + }, [searchParams, status, materials, editingMaterial, router]) + + const isInitialLoading = status === 'loading' || (loading && materials.length === 0) + + if (isInitialLoading) { return (
加载中...
@@ -592,153 +1491,26 @@ export default function Home() { return (
-
- - {/* 模板TAG筛选 - 移到最顶部(Header下方),纯文字模式 */} - {templates.length > 0 && ( -
-
-
- - {templates.map((template) => { - const isSelected = selectedTemplateId === template.id - - return ( - - ) - })} -
-
-
- )} - - {/* 收缩的顶部搜索栏和TAG(滚动时显示) */} - {isScrolled && ( -
-
-
- {/* 缩略TAG组 */} - {templates.length > 0 && ( -
setHoveredTagGroup(true)} - onMouseLeave={() => setHoveredTagGroup(false)} - > - - {/* 悬停时显示完整TAG列表 */} - {hoveredTagGroup && ( -
-
- - {templates.map((template) => { - const isSelected = selectedTemplateId === template.id - - return ( - - ) - })} -
-
- )} -
- )} - - {/* 收缩的搜索框 */} -
- setSearchKeyword(e.target.value)} - placeholder="搜索SPU、店铺、场景类别、作者..." - className={cn(styles.inputStyles.base, 'pl-10 pr-4 text-sm py-2')} - /> - - - -
-
-
-
- )} +
setSearchKeyword(value), + onOpenFilter: () => setShowFilterModal(true), + onOpenUpload: () => setShowUploadModal(true), + categoryItems: [ + { key: '', label: '全部素材' }, + ...materialTypes.map((type) => ({ key: type, label: type })), + ], + selectedCategoryKey: selectedMaterialType, + onSelectCategory: (key) => { + // 分类选择不算筛选,与tag按钮逻辑一致,可二次点击取消 + handleMaterialTypeSelection(key) + }, + themeColor, + }} + /> {/* 内网切换提示 */} {showIntranetTip && ( @@ -784,34 +1556,27 @@ export default function Home() {
{/* Header Section */} -
-
- +
+
+
+ {renderMaterialTypeChips()}
- - {/* Search Bar and Filter Button */} -
-
- setSearchKeyword(e.target.value)} - placeholder="搜索SPU、店铺、场景类别、作者..." - className={`${styles.inputStyles.base} pl-12 pr-4`} - /> +
+
+ setSearchKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setSearchQuery(searchKeyword.trim()) + } + }} + placeholder="搜索SPU、店铺、场景类别、作者..." + className={`${styles.inputStyles.base} pl-12 pr-10`} + /> {searchKeyword && ( )}
- {/* 筛选按钮 */} -
- - {/* 筛选选项已整合到筛选按钮中,这里隐藏 */} - {false && ( - <> -
-
-

类型分页

- {selectedMaterialType && ( - - {materials.length} 个素材 - - )} -
-
- - {materialTypes.map((type) => ( - - ))} +
- {/* Category Filter (使用店铺) */} -
-
-

使用店铺

- {selectedCategory && ( - - {materials.length} 个素材 - - )} -
-
- - {categories.map((category) => ( - - ))} -
-
- - {/* Scene Filter (场景类别) */} -
-
-

场景类别

- {selectedScene && ( - - {materials.length} 个素材 - - )} -
-
- - {scenes.map((scene) => ( - - ))} -
-
- - {/* Platform Filter (平台分类) - 条件显示,仅在有平台时显示 */} - {platforms.length > 0 && ( -
-
-

平台分类

- {selectedPlatform && ( - - {materials.length} 个素材 - - )} -
-
- - {platforms.map((platform) => ( - - ))} -
-
- )} - - {/* Month Filter (上传月份) */} -
-
-

上传月份

-
-
- - {months.map((month) => ( - - ))} -
-
- - - )} - - {/* Sort Options (排序方式) - 保留排序选项 */} -
-
+
+

排序方式

@@ -1138,184 +1662,272 @@ export default function Home() { > 下载最多 + + {hasActiveFilters && ( + + )}
- - {/* Active Filters Display */} - {(searchKeyword || selectedCategory || selectedScene || selectedMaterialType || selectedPlatform || selectedMonth) && ( -
-
- 当前筛选: - {searchKeyword && ( - - 搜索: {searchKeyword} - - - )} - {selectedCategory && ( - - 店铺: {selectedCategory} - - - )} - {selectedScene && ( - - 场景类别: {selectedScene} - - - )} - {selectedMaterialType && ( - - 素材类型: {selectedMaterialType} - - - )} - {selectedPlatform && ( - - 平台分类: {selectedPlatform} - - - )} - {selectedMonth && ( - - 月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth} - - - )} - -
-
- 💡 提示:可以组合使用多个筛选条件,找到更精确的素材 -
-
- )}
{/* Materials Grid */} - {loading ? ( -
-

加载中...

-
- ) : materials.length === 0 ? ( + {materials.length === 0 ? (

- {searchKeyword || selectedCategory || selectedScene || selectedMaterialType || selectedPlatform ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'} + {hasActiveFilters ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'}

) : ( <> -
-
- {materials.map((material) => ( -
- {/* Preview Image */} -
{ - // PSD、模特和视频类型直接跳转到详情页,其他类型打开预览弹窗 - if (material.materialType === 'PSD' || material.materialType === '模特' || material.materialType === '视频') { - router.push(`/share/${material.id}`) - } else { - setPreviewImage({ url: material.previewUrl, title: material.productModel }) - } - }} - > - {material.sceneName} - {/* SPU标签 */} -
- {material.productModel} -
- {/* 放大提示 */} -
- - - -
-
- {/* Info */} -
- {/* 店铺、场景、素材类型和渲染器 */} -
- {material.categoryName} - {material.sceneName} - {material.materialType} - {material.materialType === '3DMAX' && ( - {material.renderer} - )} +
+
+ {materials.slice(0, displayedCount).map((material) => { + const rankingBadges = getRankingBadges(material.id, { isPremium: material.isPremium }) + const isOwnMaterial = Boolean(currentUserId && material.userId === currentUserId) + const materialRecord = material as unknown as Record + const seenTagValues = new Set() + const filterTags = orderedHomeFilterKeys + .map((fieldKey) => { + const rawValue = materialRecord[fieldKey] + if (typeof rawValue !== 'string') return null + const value = rawValue.trim() + if (!value || seenTagValues.has(value)) return null + seenTagValues.add(value) + return { fieldKey, label: value } + }) + .filter((item): item is { fieldKey: string; label: string } => Boolean(item)) + const resolvedMaterialKind = (material.materialKind || '').toUpperCase() + const isVideoMaterial = resolvedMaterialKind === 'VIDEO' || material.materialType === '视频' + const videoStatus = isVideoMaterial + ? resolveVideoStatus(material.videoProcessingStatus || 'pending') + : 'done' + const videoStatusMeta = isVideoMaterial ? VIDEO_STATUS_META[videoStatus] : null + const videoCover = material.videoCoverUrl || material.previewUrl + const videoClipUrl = material.videoPreviewClipUrl || '' + const canPlayClip = isVideoMaterial && videoStatus === 'done' && Boolean(videoClipUrl) + const videoDurationLabel = isVideoMaterial ? formatVideoDuration(material.videoDuration ?? null) : '' + + return ( +
+
{ + setPreviewMaterial({ + ...material, + rankingBadges: getRankingBadges(material.id, { isPremium: material.isPremium }), + user: material.user, // 确保包含user信息用于头像显示 + }) + }} + onMouseEnter={() => { + if (canPlayClip) { + handleCardVideoHover(material.id, true) + } + }} + onMouseLeave={() => { + if (canPlayClip) { + handleCardVideoHover(material.id, false) + } + }} + > + {isVideoMaterial ? ( + canPlayClip ? ( + + ) : ( + {material.sceneName} + ) + ) : ( + {material.sceneName} + )} + {isVideoMaterial && !canPlayClip && ( +
+ + {videoStatusMeta?.label || '预览生成中'} +
+ )} +
+ {rankingBadges.length > 0 && ( +
+ {rankingBadges.map((badge) => ( + + {badge.label} + + ))} +
+ )} + {/* 视频可预览角标按需求移除,避免视觉信息过载 */} +
+ {material.productModel} +
+
+ {!isOwnMaterial && currentUserId && ( + + )} + {isVideoMaterial && videoDurationLabel && ( +
+ + {videoDurationLabel} +
+ )} +
+ {format(new Date(material.createdAt), 'yyyy-MM-dd')} +
+
+ +
+
+ {material.materialType && ( + + {material.materialType} + + )} + {filterTags.length > 0 && filterTags.map((tag, index) => { + const color: TagColorToken = TAG_COLOR_PALETTE[index % TAG_COLOR_PALETTE.length] + return ( + + {tag.label} + + ) + })}
- {/* 作者和时间 */} -
-

- 作者: +

+
+ + {material.authorName} -

-

- 文件: - {material.zipFileName} -

-

- 大小: - {formatFileSize(material.zipFileSize)} -

-

- {format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')} -

+
- - {/* 统计数据 */} -
-
+
@@ -1331,203 +1943,70 @@ export default function Home() {
- {/* Action Buttons */} -
- {/* 编辑按钮:仅素材上传者和管理员可见 */} - {session?.user && ( - material.userId === (session.user as any).id || (session.user as any).role === 'ADMIN' - ) && ( - - )} - - + handleEdit(material) + : undefined + } + canEdit={ + !!( + session?.user && + (material.userId === (session.user as any).id || (session.user as any).role === 'ADMIN') + ) + } + onDelete={ + session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) + ? () => handleDelete(material.id) + : undefined + } + canDelete={ + !!( + session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) + ) + } + onShare={() => handleShare(material.id)} + onLike={ + !isOwnMaterial && currentUserId + ? () => handleLike(material.id) + : undefined + } + onDownload={() => handleDownload(material.id)} + isDownloading={downloadingIds.has(material.id)} + downloadProgress={downloadProgresses[material.id] || 0} + />
+ ) + })} + + {/* 懒加载触发器 */} + {materials.length > 0 && (displayedCount < materials.length || hasMoreMaterials) && ( +
+ {isLoadingMore ? ( +
+ + 加载中... +
+ ) : ( +
继续向下滚动加载更多
+ )}
- ))} -
+ )} + {materials.length > 0 && !hasMoreMaterials && displayedCount >= materials.length && ( +
+ 已经到底啦~ +
+ )} +
- {/* 分页组件 */} - {totalPages > 1 && ( -
-
- {/* 首页 */} - - - {/* 上一页 */} - - - {/* 页码 */} -
- {Array.from({ length: Math.min(totalPages, 7) }, (_, i) => { - let pageNum - if (totalPages <= 7) { - pageNum = i + 1 - } else if (currentPage <= 4) { - pageNum = i + 1 - } else if (currentPage >= totalPages - 3) { - pageNum = totalPages - 6 + i - } else { - pageNum = currentPage - 3 + i - } - - return ( - - ) - })} -
- - {/* 下一页 */} - - - {/* 末页 */} - -
- - {/* 页码跳转和信息 */} -
- - 第 {currentPage} / {totalPages} 页,共 {totalMaterialsCount} 条 - -
- 跳至 - setJumpPage(e.target.value)} - onKeyPress={(e) => { - if (e.key === 'Enter') { - const page = parseInt(jumpPage) - if (page >= 1 && page <= totalPages) { - setCurrentPage(page) - setJumpPage('') - window.scrollTo({ top: 0, behavior: 'smooth' }) - } - } - }} - className={`${styles.inputStyles.base} w-16 px-2 py-1 text-center`} - placeholder="" - /> - 页 - -
-
-
- )} )}
@@ -1539,18 +2018,61 @@ export default function Home() { onSuccess={() => { setShowUploadModal(false) fetchMaterials() - fetchCategories() - fetchScenes() + fetchHomeFilterFields() }} /> )} {/* Image Preview Modal */} setPreviewImage(null)} - imageUrl={previewImage?.url || ''} - title={previewImage?.title} + isOpen={!!previewMaterial} + onClose={() => setPreviewMaterial(null)} + material={previewMaterial} + onDownload={previewMaterial ? () => handleDownload(previewMaterial.id) : undefined} + actionSection={ + previewMaterial ? ( + handleEdit(previewMaterial) + : undefined + } + canEdit={ + !!( + session?.user && + (previewMaterial.userId === (session.user as any).id || + (session.user as any).role === 'ADMIN') + ) + } + onDelete={ + session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) + ? () => handleDelete(previewMaterial.id) + : undefined + } + canDelete={ + !!( + session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) + ) + } + onShare={() => handleShare(previewMaterial.id)} + onLike={ + currentUserId && previewMaterial.userId !== currentUserId + ? () => handleLike(previewMaterial.id) + : undefined + } + onDownload={() => handleDownload(previewMaterial.id)} + isDownloading={downloadingIds.has(previewMaterial.id)} + downloadProgress={downloadProgresses[previewMaterial.id] || 0} + canTogglePremium={(session?.user as any)?.role === 'ADMIN'} + onTogglePremium={(nextState) => handlePremiumToggle(previewMaterial.id, nextState)} + /> + ) : undefined + } /> {/* Share Notification */} @@ -1564,247 +2086,321 @@ export default function Home() { )} {/* 筛选弹窗 */} - {showFilterModal && ( -
setShowFilterModal(false)} - > -
e.stopPropagation()} - > -
-

筛选选项

- -
- -
- {/* 类型分页 */} -
-

类型分页

-
- - {materialTypes.map((type) => ( - - ))} -
-
- - {/* 使用店铺 */} -
-

使用店铺

-
+ setShowFilterModal(false)} + size="xlarge" + title="筛选选项" + showCloseButton + closeOnOverlayClick + closeOnEscape + containerClassName="max-h-[92vh] w-full max-w-[760px]" + bodyClassName="max-h-[80vh] overflow-y-auto pr-1" + > +
+
+ {showCategoryFilter && categoryOptions.length > 0 && ( +
+

{categoryLabel}

+
- {categories.map((category) => ( + {categoryOptions.map((option) => ( ))}
-
+ + )} - {/* 场景类别 */} -
-

场景类别

-
+ {showSceneFilter && sceneOptions.length > 0 && ( +
+

{sceneLabel}

+
- {scenes.map((scene) => ( + {sceneOptions.map((option) => ( ))}
-
+ + )} - {/* 平台分类 */} - {platforms.length > 0 && ( -
-

平台分类

-
+ {platformOptions.length > 0 && showPlatformFilter && ( +
+

{platformLabel}

+
- {platforms.map((platform) => ( + {platformOptions.map((option) => ( ))}
-
- )} + + )} - {/* 上传月份 */} -
-

上传月份

-
+
+

上传年份

+
+ + {yearOptions.length === 0 ? ( + 暂无年份数据 + ) : ( + yearOptions.map(({ year, count }) => ( - {months.map((month) => ( + )) + )} +
+
+ +
+

上传月份

+ {!selectedYear && ( +

请先选择年份,再选择具体月份

+ )} +
+ + {selectedYear && availableMonths.length === 0 && ( +

该年份暂无月份数据

+ )} + {availableMonths.map((monthKey) => { + const isActive = selectedMonth === monthKey + const monthCount = monthCountsForSelectedYear[monthKey] ?? 0 + return ( + + ) + })} +
+
+ + {dynamicHomeFilterEntries.map(([fieldKey, field]) => ( +
+

{field.label}

+
+ + {field.options.map((option) => { + const optionValue = option.value + const isActive = homeFilterSelections[fieldKey] === optionValue + return ( - ))} -
+ ) + })}
+ + ))}
-
- - + 应用筛选 +
- )} +
+
- {/* Share Modal */} - {showShareModal && shareUrl && ( -
setShowShareModal(false)} - > -
e.stopPropagation()} - > -

分享链接

-
- {shareUrl} -
-
- +
+ } + > +
+

+ 复制下方链接发送给同事,对方即可直接访问该素材的分享页面。 +

+
+
+ + - +
+

+ 如浏览器拦截自动复制,可手动选中上方链接后使用 Ctrl+C(或 ⌘+C) +

+ + + {/* Edit Material Modal */} + {editingMaterial && ( + setEditingMaterial(null)} + onSuccess={() => { + setEditingMaterial(null) + fetchMaterials() + }} + /> )} {/* AI聊天助手 */} diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..ab45dd1 --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,669 @@ +/** + * 个人中心页面 + * + * 用户个人信息管理页面 + * 包含用户信息展示、头像设置、上传的素材、下载记录等 + * 使用三层架构样式系统,支持深浅色主题 + * + * @created 2025-11-25 + * @page + */ + +'use client' + +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { useState, useEffect } from 'react' +import Image from 'next/image' +import { useThemeStyles, cn } from '@/lib/ui-styles' +import Header from '@/components/Header' +import Avatar from '@/components/ui/Avatar' +import Modal from '@/components/ui/Modal' +import Button from '@/components/ui/Button' +import MaterialPreviewModal from '@/components/MaterialPreviewModal' +import MaterialActionButtons from '@/components/MaterialActionButtons' +import { useDialog } from '@/components/ui/DialogProvider' + +interface UserProfile { + id: string + username: string + email: string + avatar: string | null + department: string + realName: string | null + phone: string | null + createdAt: string +} + +interface Material { + id: string + userId: string + productModel: string + previewUrl: string + displayImages?: string[] + materialType?: string + materialKind?: 'IMAGE' | 'VIDEO' | 'OTHER' + categoryName?: string + sceneName?: string + renderer?: string + authorName?: string + downloadCount: number + likeCount: number + createdAt: string + isPremium?: boolean + isFavorited?: boolean + isLiked?: boolean + zipFileName?: string + zipFileSize?: string | number + videoCoverUrl?: string | null + videoPreview540Url?: string | null + videoPreviewClipUrl?: string | null + videoProcessingStatus?: string | null + videoDuration?: number | null + user?: { + username?: string + avatar?: string | null + } + rankingBadges?: Array<{ label: string; gradient: string }> +} + +export default function ProfilePage() { + const { data: session, status } = useSession() + const router = useRouter() + const styles = useThemeStyles() + const { alert } = useDialog() + const [profile, setProfile] = useState(null) + const [materials, setMaterials] = useState([]) + const [loading, setLoading] = useState(true) + const [showAvatarModal, setShowAvatarModal] = useState(false) + const [avatarFile, setAvatarFile] = useState(null) + const [avatarPreview, setAvatarPreview] = useState('') + const [uploading, setUploading] = useState(false) + const [previewMaterial, setPreviewMaterial] = useState(null) + const [favoriteMaterials, setFavoriteMaterials] = useState([]) + const [downloadingIds, setDownloadingIds] = useState>(new Set()) + const [downloadProgresses, setDownloadProgresses] = useState>({}) + + const handleDownload = (id: string) => { + // 如果已经在下载,直接返回 + if (downloadingIds.has(id)) { + return + } + + console.log(`[Download] Starting download for material ${id}`) + setDownloadingIds((prev) => new Set(prev).add(id)) + setDownloadProgresses((prev) => ({ ...prev, [id]: 0 })) + + const xhr = new XMLHttpRequest() + + // 监听下载进度 + xhr.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = Math.round((e.loaded / e.total) * 100) + setDownloadProgresses((prev) => ({ ...prev, [id]: percentComplete })) + } + }) + + xhr.addEventListener('load', () => { + console.log(`[Download] Response received, status: ${xhr.status}`) + if (xhr.status === 200) { + try { + const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream' + const blob = new Blob([xhr.response], { type: contentType }) + + // 从响应头获取文件名 + const contentDisposition = xhr.getResponseHeader('Content-Disposition') + let filename = 'material' + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (filenameMatch && filenameMatch[1]) { + filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '')) + } + } else if (contentType) { + const extensionMap: Record = { + 'application/zip': '.zip', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/x-msvideo': '.avi', + 'video/quicktime': '.mov', + } + const matchedExt = Object.entries(extensionMap).find(([type]) => contentType.includes(type)) + if (matchedExt) { + filename += matchedExt[1] + } + } + + // 使用download属性直接下载,避免浏览器提示 + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.style.display = 'none' + document.body.appendChild(a) + a.click() + // 延迟移除,确保下载开始 + setTimeout(() => { + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + }, 100) + + // 刷新列表以更新下载次数 + fetchMaterials() + fetchLikedMaterials() + } catch (error) { + console.error('下载处理失败:', error) + alert({ + title: '下载失败', + message: '下载失败,请稍后重试', + variant: 'error', + }) + } + } else { + console.error(`[Download] Download failed with status ${xhr.status}`) + try { + const errorText = xhr.responseText + console.error(`[Download] Error response:`, errorText) + const error = JSON.parse(errorText) + alert({ + title: '下载失败', + message: error.error || `下载失败 (状态码: ${xhr.status})`, + variant: 'error', + }) + } catch (parseError) { + console.error('[Download] Failed to parse error response:', parseError) + alert({ + title: '下载失败', + message: `下载失败 (状态码: ${xhr.status})`, + variant: 'error', + }) + } + } + + // 清理下载状态 + setDownloadingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + setDownloadProgresses((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + }) + + xhr.addEventListener('error', (e) => { + console.error('[Download] Network error:', e) + alert({ + title: '下载失败', + message: '网络错误,请检查网络连接后重试', + variant: 'error', + }) + setDownloadingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + setDownloadProgresses((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + }) + + xhr.open('GET', `/api/materials/download?id=${id}`) + xhr.responseType = 'arraybuffer' + xhr.send() + } + + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/login') + return + } + + if (status === 'authenticated' && session) { + fetchProfile() + fetchMaterials() + fetchLikedMaterials() + } + }, [status, session, router]) + + const fetchProfile = async () => { + try { + const res = await fetch('/api/user/profile') + if (res.ok) { + const data = await res.json() + setProfile(data) + } + } catch (error) { + console.error('Failed to fetch profile:', error) + } finally { + setLoading(false) + } + } + + const fetchMaterials = async () => { + try { + const res = await fetch('/api/user/materials') + if (res.ok) { + const data = await res.json() + setMaterials(data) + } + } catch (error) { + console.error('Failed to fetch materials:', error) + } + } + + const fetchLikedMaterials = async () => { + try { + const res = await fetch('/api/user/likes') + if (res.ok) { + const data = await res.json() + setFavoriteMaterials(data) + } + } catch (error) { + console.error('Failed to fetch liked materials:', error) + } + } + + const handleAvatarSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + if (!file.type.startsWith('image/')) { + alert({ title: '提示', message: '请选择图片文件', variant: 'warning' }) + return + } + setAvatarFile(file) + setAvatarPreview(URL.createObjectURL(file)) + setShowAvatarModal(true) + } + } + + const handleAvatarUpload = async () => { + if (!avatarFile) return + + setUploading(true) + try { + const formData = new FormData() + formData.append('avatar', avatarFile) + + const res = await fetch('/api/user/avatar', { + method: 'POST', + body: formData, + }) + + if (res.ok) { + const data = await res.json() + setProfile(prev => prev ? { ...prev, avatar: data.avatarUrl } : null) + setShowAvatarModal(false) + setAvatarFile(null) + setAvatarPreview('') + // 刷新页面以更新 session 中的头像(JWT callback 会从数据库获取最新头像) + // 由于 JWT callback 每5分钟自动刷新一次,也可以等待自动刷新 + window.location.reload() + } else { + const error = await res.json() + alert({ title: '上传失败', message: error.error || '上传失败', variant: 'error' }) + } + } catch (error) { + console.error('Failed to upload avatar:', error) + alert({ title: '上传失败', message: '上传失败,请稍后重试', variant: 'error' }) + } finally { + setUploading(false) + } + } + + if (status === 'loading' || loading) { + return ( +
+

加载中...

+
+ ) + } + + if (!session || !profile) { + return null + } + + return ( +
+
+
+ {/* 用户信息卡片 */} +
+
+ {/* 头像 */} +
+ + +
+ + {/* 用户信息 */} +
+

+ {profile.realName || profile.username} +

+

@{profile.username}

+

{profile.email}

+
+
+ 部门: + {profile.department || '未设置'} +
+ {profile.phone && ( +
+ 手机: + {profile.phone} +
+ )} +
+ 注册时间: + + {new Date(profile.createdAt).toLocaleDateString('zh-CN')} + +
+
+
+
+
+ + {/* 我的素材 */} +
+

我的素材

+ {materials.length === 0 ? ( +

还没有上传过素材

+ ) : ( +
+ {materials.map((material) => ( +
{ + // 获取完整的素材信息 + try { + const response = await fetch(`/api/materials/${material.id}`) + if (response.ok) { + const fullMaterial = await response.json() + + // 解析 displayImages(可能是字符串或数组) + let displayImagesArray: string[] = [] + const parseDisplayImages = (value: any): string[] => { + if (!value) return [] + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + } + } catch { + return value.split(',').filter((s: string) => s.trim()) + } + } + return [] + } + displayImagesArray = parseDisplayImages(fullMaterial.displayImages) || parseDisplayImages(material.displayImages) + + // 确保所有必需字段都存在 + const safeMaterial: Material = { + id: fullMaterial.id || material.id, + userId: fullMaterial.userId || material.userId || '', + productModel: fullMaterial.productModel || material.productModel, + previewUrl: fullMaterial.previewUrl || material.previewUrl, + displayImages: displayImagesArray.length > 0 ? displayImagesArray : material.displayImages, + materialType: fullMaterial.materialType || material.materialType, + materialKind: fullMaterial.materialKind || material.materialKind, + categoryName: fullMaterial.categoryName || material.categoryName || '', + sceneName: fullMaterial.sceneName || material.sceneName || '', + renderer: fullMaterial.renderer || material.renderer, + authorName: fullMaterial.authorName || material.authorName, + downloadCount: fullMaterial.downloadCount ?? material.downloadCount ?? 0, + likeCount: fullMaterial.likeCount ?? material.likeCount ?? 0, + createdAt: fullMaterial.createdAt || material.createdAt, + isPremium: fullMaterial.isPremium ?? material.isPremium ?? false, + isFavorited: fullMaterial.isFavorited ?? material.isFavorited ?? false, + isLiked: fullMaterial.isLiked ?? material.isLiked ?? false, + zipFileName: fullMaterial.zipFileName || material.zipFileName, + zipFileSize: fullMaterial.zipFileSize || material.zipFileSize, + videoCoverUrl: fullMaterial.videoCoverUrl ?? material.videoCoverUrl, + videoPreview540Url: fullMaterial.videoPreview540Url ?? material.videoPreview540Url, + videoPreviewClipUrl: fullMaterial.videoPreviewClipUrl ?? material.videoPreviewClipUrl, + videoProcessingStatus: fullMaterial.videoProcessingStatus ?? material.videoProcessingStatus, + videoDuration: fullMaterial.videoDuration ?? material.videoDuration, + user: { + username: fullMaterial.user?.username || material.user?.username, + avatar: fullMaterial.user?.avatar || material.user?.avatar || null, + }, + rankingBadges: fullMaterial.rankingBadges || material.rankingBadges, + } + setPreviewMaterial(safeMaterial) + } else { + setPreviewMaterial(material) + } + } catch (error) { + console.error('Failed to fetch material details:', error) + setPreviewMaterial(material) + } + }} + onDoubleClick={() => router.push(`/share/${material.id}`)} + title="单击预览,双击查看详情" + > + {material.productModel} +
+

{material.productModel}

+
+ 下载 {material.downloadCount} + 点赞 {material.likeCount} +
+
+
+ ))} +
+ )} +
+ + {/* 我的点赞 */} +
+

我的点赞

+ {favoriteMaterials.length === 0 ? ( +

还没有点赞素材

+ ) : ( +
+ {favoriteMaterials.map((material) => ( +
{ + // 获取完整的素材信息 + try { + const response = await fetch(`/api/materials/${material.id}`) + if (response.ok) { + const fullMaterial = await response.json() + + // 解析 displayImages(可能是字符串或数组) + let displayImagesArray: string[] = [] + const parseDisplayImages = (value: any): string[] => { + if (!value) return [] + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + } + } catch { + return value.split(',').filter((s: string) => s.trim()) + } + } + return [] + } + displayImagesArray = parseDisplayImages(fullMaterial.displayImages) || parseDisplayImages(material.displayImages) + + // 确保所有必需字段都存在 + const safeMaterial: Material = { + id: fullMaterial.id || material.id, + userId: fullMaterial.userId || material.userId || '', + productModel: fullMaterial.productModel || material.productModel, + previewUrl: fullMaterial.previewUrl || material.previewUrl, + displayImages: displayImagesArray.length > 0 ? displayImagesArray : material.displayImages, + materialType: fullMaterial.materialType || material.materialType, + materialKind: fullMaterial.materialKind || material.materialKind, + categoryName: fullMaterial.categoryName || material.categoryName || '', + sceneName: fullMaterial.sceneName || material.sceneName || '', + renderer: fullMaterial.renderer || material.renderer, + authorName: fullMaterial.authorName || material.authorName, + downloadCount: fullMaterial.downloadCount ?? material.downloadCount ?? 0, + likeCount: fullMaterial.likeCount ?? material.likeCount ?? 0, + createdAt: fullMaterial.createdAt || material.createdAt, + isPremium: fullMaterial.isPremium ?? material.isPremium ?? false, + isFavorited: fullMaterial.isFavorited ?? material.isFavorited ?? false, + isLiked: fullMaterial.isLiked ?? material.isLiked ?? false, + zipFileName: fullMaterial.zipFileName || material.zipFileName, + zipFileSize: fullMaterial.zipFileSize || material.zipFileSize, + videoCoverUrl: fullMaterial.videoCoverUrl ?? material.videoCoverUrl, + videoPreview540Url: fullMaterial.videoPreview540Url ?? material.videoPreview540Url, + videoPreviewClipUrl: fullMaterial.videoPreviewClipUrl ?? material.videoPreviewClipUrl, + videoProcessingStatus: fullMaterial.videoProcessingStatus ?? material.videoProcessingStatus, + videoDuration: fullMaterial.videoDuration ?? material.videoDuration, + user: { + username: fullMaterial.user?.username || material.user?.username, + avatar: fullMaterial.user?.avatar || material.user?.avatar || null, + }, + rankingBadges: fullMaterial.rankingBadges || material.rankingBadges, + } + setPreviewMaterial(safeMaterial) + } else { + setPreviewMaterial(material) + } + } catch (error) { + console.error('Failed to fetch material details:', error) + setPreviewMaterial(material) + } + }} + onDoubleClick={() => router.push(`/share/${material.id}`)} + title="单击预览,双击查看详情" + > + {material.productModel} +
+

{material.productModel}

+
+ 下载 {material.downloadCount} + 点赞 {material.likeCount} +
+
+
+ ))} +
+ )} +
+
+ + {/* 头像上传确认弹窗 */} + { + setShowAvatarModal(false) + if (avatarPreview) { + URL.revokeObjectURL(avatarPreview) + } + setAvatarFile(null) + setAvatarPreview('') + }} + size="small" + title="上传头像" + > +
+ {avatarPreview && ( +
+
+ Avatar preview +
+
+ )} +
+ + +
+
+
+ + setPreviewMaterial(null)} + material={previewMaterial} + onDownload={previewMaterial ? () => handleDownload(previewMaterial.id) : undefined} + actionSection={ + previewMaterial ? ( + handleDownload(previewMaterial.id)} + isDownloading={downloadingIds.has(previewMaterial.id)} + downloadProgress={downloadProgresses[previewMaterial.id] || 0} + inlineLikeButton={false} + /> + ) : undefined + } + /> +
+ ) +} + diff --git a/app/register/page.tsx b/app/register/page.tsx index ec679cf..c77c4bc 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -80,15 +80,15 @@ export default function RegisterPage() {
由壹点3D素材
-

由壹点3D素材

-

创建您的账号,开始分享3D素材

+

FLNXI素材库

+

创建您的账号,开始分享3D素材

{/* Register Form */} @@ -101,7 +101,7 @@ export default function RegisterPage() { )}
-