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={