From 2124790c8d83cf2a411aa97bd76759d98027ae94 Mon Sep 17 00:00:00 2001 From: lan Date: Tue, 24 Feb 2026 11:09:37 +0800 Subject: [PATCH] feat: add Docker support and texture deletion functionality Add Docker configuration with standalone output mode for containerized deployment. Implement texture deletion API with proper error handling and user feedback. Fix skin viewer sizing issues by using explicit dimensions and removing conflicting layout properties. Add captcha ID parameter to registration flow. Improve profile page UX including Yggdrasil password reset display and character card editing controls. --- .dockerignore | 41 +++++++++++ .gitea/workflows/docker-build.yml | 87 ++++++++++++++++++++++++ Dockerfile | 49 +++++++++++++ next.config.ts | 2 +- src/app/profile/page.tsx | 37 +++++++--- src/components/SkinCard.tsx | 12 ++-- src/components/SkinDetailModal.tsx | 2 +- src/components/SkinViewer.tsx | 21 ++---- src/components/SliderCaptcha.tsx | 5 +- src/components/profile/CharacterCard.tsx | 73 +++++++++----------- src/components/profile/MySkinsTab.tsx | 1 - src/contexts/AuthContext.tsx | 5 +- src/lib/api.ts | 10 +++ 13 files changed, 264 insertions(+), 81 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/docker-build.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8985a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# 依赖 +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Next.js 构建产物 +.next +out + +# 测试 +coverage +.nyc_output + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# 操作系统 +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Docker +Dockerfile +.dockerignore + +# 文档 +docs +*.md + +# 其他 +.env +.env.local +.env.*.local diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..fd42b22 --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -0,0 +1,87 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + - master + - develop + pull_request: + branches: + - main + - master + workflow_dispatch: + +env: + REGISTRY: code.littlelan.cn + IMAGE_NAME: ${{ github.repository_owner }}/carrotskin + +jobs: + build-and-push: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.GIT_USERNAME }} + password: ${{ secrets.GIT_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # main/master 分支标记为 latest + type=raw,value=latest,enable={{is_default_branch}} + # 所有分支的标签 + type=ref,event=branch + # Git tag 时创建版本标签(如 1.0.0, 1.0) + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + # 每次构建的 SHA 标签 + type=sha + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + provenance: false + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + + - name: Show image tags + run: | + echo "Built and pushed image with tags:" + echo "${{ steps.meta.outputs.tags }}" + echo "" + echo "Image digest: ${{ steps.meta.outputs.digest }}" + + - name: Summary + if: always() + run: | + echo "## Docker Image Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY + echo "**Tags:**" >> $GITHUB_STEP_SUMMARY + echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY + echo "**Digest:** ${{ steps.meta.outputs.digest }}" >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..648e604 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# 构建阶段 +FROM node:alpine AS builder + +# 设置工作目录 +WORKDIR /app + +# 复制 package 文件 +COPY package*.json ./ + +# 安装所有依赖(包括 devDependencies) +RUN npm ci + +# 复制源代码 +COPY . . + +# 构建应用 +RUN npm run build + +# 生产阶段 +FROM node:alpine AS runner + +# 设置工作目录 +WORKDIR /app + +# 创建非 root 用户 +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# 复制构建产物(standalone 模式) +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# 设置正确的权限 +RUN chown -R nextjs:nodejs /app + +# 切换到非 root 用户 +USER nextjs + +# 暴露端口 +EXPOSE 3000 + +# 设置环境变量 +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# 启动应用 +CMD ["node", "server.js"] diff --git a/next.config.ts b/next.config.ts index 5f003e5..2408fa2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: 'standalone', rewrites: async () => { return [ { diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index a03b9fb..e756a94 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -38,6 +38,7 @@ import { generateAvatarUploadUrl, updateAvatarUrl, resetYggdrasilPassword, + deleteTexture, type Texture, type Profile } from '@/lib/api'; @@ -194,7 +195,18 @@ export default function ProfilePage() { const handleDeleteSkin = async (skinId: number) => { if (!confirm('确定要删除这个皮肤吗?')) return; - setMySkins(prev => prev.filter(skin => skin.id !== skinId)); + try { + const response = await deleteTexture(skinId); + if (response.code === 200) { + setMySkins(prev => prev.filter(skin => skin.id !== skinId)); + messageManager.success('皮肤删除成功', { duration: 3000 }); + } else { + messageManager.error(response.message || '删除皮肤失败', { duration: 3000 }); + } + } catch (error) { + console.error('删除皮肤失败:', error); + messageManager.error('删除皮肤失败', { duration: 3000 }); + } }; const handleToggleFavorite = async (skinId: number) => { @@ -373,12 +385,14 @@ export default function ProfilePage() { const handleResetYggdrasilPassword = async () => { if (!confirm('确定要重置Yggdrasil密码吗?这将生成一个新的密码。')) return; - + setIsResettingYggdrasilPassword(true); - + try { const response = await resetYggdrasilPassword(); if (response.code === 200) { + setYggdrasilPassword(response.data.password); + setShowYggdrasilPassword(true); messageManager.success('Yggdrasil密码重置成功!请妥善保管新密码。', { duration: 5000 }); } else { throw new Error(response.message || '重置Yggdrasil密码失败'); @@ -955,14 +969,15 @@ export default function ProfilePage() { } }} > - +
+ +
{skin.name}
diff --git a/src/components/SkinCard.tsx b/src/components/SkinCard.tsx index 9bd84f0..7487826 100644 --- a/src/components/SkinCard.tsx +++ b/src/components/SkinCard.tsx @@ -171,15 +171,15 @@ export default function SkinCard({ {texture.type === 'SKIN' ? ( -
+
setImageLoaded(true)} diff --git a/src/components/SkinDetailModal.tsx b/src/components/SkinDetailModal.tsx index 7858575..d607efd 100644 --- a/src/components/SkinDetailModal.tsx +++ b/src/components/SkinDetailModal.tsx @@ -296,7 +296,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr isSlim={texture.is_slim} width={600} height={600} - className="w-full h-full rounded-2xl shadow-2xl border-2 border-white/60 dark:border-gray-600/60 relative z-10" + className="rounded-2xl shadow-2xl border-2 border-white/60 dark:border-gray-600/60 relative z-10" autoRotate={autoRotate} walking={currentAnimation === 'walking'} running={currentAnimation === 'running'} diff --git a/src/components/SkinViewer.tsx b/src/components/SkinViewer.tsx index 579fbbf..d5b56d6 100644 --- a/src/components/SkinViewer.tsx +++ b/src/components/SkinViewer.tsx @@ -83,28 +83,24 @@ export default function SkinViewer({ try { console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height }); - // 使用canvas的实际尺寸,参考blessingskin + // 使用传入的宽高参数初始化 const canvas = canvasRef.current; const viewer = new SkinViewer3D({ canvas: canvas, - width: canvas.clientWidth || width, - height: canvas.clientHeight || height, + width: width, + height: height, skin: skinUrl, cape: capeUrl, model: isSlim ? 'slim' : 'default', - zoom: 1.0, // 使用blessingskin的zoom方式 + zoom: 1.0, }); viewerRef.current = viewer; - // 设置背景和控制选项 - 参考blessingskin + // 设置背景和控制选项 viewer.background = null; // 透明背景 viewer.autoRotate = false; // 完全禁用自动旋转 - // 调整光照设置,避免皮肤发黑 - viewer.globalLight.intensity = 0.8; // 增加环境光强度 - viewer.cameraLight.intensity = 0.4; // 降低相机光源强度,避免过强的阴影 - // 外部预览时禁用所有动画和旋转 if (isExternalPreview) { viewer.autoRotate = false; @@ -268,11 +264,8 @@ export default function SkinViewer({ ref={canvasRef} className={className} style={{ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - width: '100%', - height: '100%' + width: width, + height: height }} /> ); diff --git a/src/components/SliderCaptcha.tsx b/src/components/SliderCaptcha.tsx index cc9c212..e4947fb 100644 --- a/src/components/SliderCaptcha.tsx +++ b/src/components/SliderCaptcha.tsx @@ -448,10 +448,9 @@ export const SliderCaptcha: React.FC = ({ onVerify, onClose isDragging ? 'scale-110 shadow-xl' : 'scale-100' } ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`} style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }} - onMouseDown={handleMouseDown} - onTouchStart={handleTouchStart} + onMouseDown={verifyResult === 'error' ? undefined : handleMouseDown} + onTouchStart={verifyResult === 'error' ? undefined : handleTouchStart} ref={sliderRef} - disabled={verifyResult === 'error'} > {getSliderIcon()}
diff --git a/src/components/profile/CharacterCard.tsx b/src/components/profile/CharacterCard.tsx index 0545afe..7add921 100644 --- a/src/components/profile/CharacterCard.tsx +++ b/src/components/profile/CharacterCard.tsx @@ -42,19 +42,32 @@ export default function CharacterCard({ transition={{ duration: 0.2 }} >
- {isEditing ? ( - onEditNameChange(e.target.value)} - className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1 mr-2" - onBlur={() => onSave(profile.uuid)} - onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)} - autoFocus - /> - ) : ( -

{profile.name}

- )} +
+ {isEditing ? ( + onEditNameChange(e.target.value)} + className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1" + onBlur={() => onSave(profile.uuid)} + onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)} + autoFocus + /> + ) : ( + <> +

{profile.name}

+ onEdit(profile.uuid, profile.name)} + className="text-gray-500 hover:text-orange-500 transition-colors" + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + title="改名" + > + + + + )} +
{profile.is_active && ( @@ -68,9 +81,8 @@ export default function CharacterCard({ ) : ( @@ -82,32 +94,10 @@ export default function CharacterCard({ )} - - {/* 皮肤选择按钮 */} - onSelectSkin(profile.uuid)} - className="absolute bottom-2 right-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-2 rounded-full shadow-lg" - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.9 }} - title="选择皮肤" - > - -
{/* 操作按钮 */}
- {!profile.is_active && ( - onSetActive(profile.uuid)} - className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200" - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - 使用 - - )} - {isEditing ? ( <> onEdit(profile.uuid, profile.name)} - className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200" + onClick={() => onSelectSkin(profile.uuid)} + className="flex-1 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200" whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} > - - 编辑 + 修改皮肤 onDelete(profile.uuid)} diff --git a/src/components/profile/MySkinsTab.tsx b/src/components/profile/MySkinsTab.tsx index 7027d90..ecafe61 100644 --- a/src/components/profile/MySkinsTab.tsx +++ b/src/components/profile/MySkinsTab.tsx @@ -68,7 +68,6 @@ export default function MySkinsTab({ key={skin.id} texture={skin} onViewDetails={handleViewDetails} - onToggleVisibility={onToggleVisibility} customActions={