feat: add Docker support and texture deletion functionality
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
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.
This commit is contained in:
41
.dockerignore
Normal file
41
.dockerignore
Normal file
@@ -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
|
||||
87
.gitea/workflows/docker-build.yml
Normal file
87
.gitea/workflows/docker-build.yml
Normal file
@@ -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
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -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"]
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
rewrites: async () => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SkinViewer
|
||||
skinUrl={skin.url}
|
||||
isSlim={skin.is_slim}
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-full h-full"
|
||||
autoRotate={false}
|
||||
/>
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<SkinViewer
|
||||
skinUrl={skin.url}
|
||||
isSlim={skin.is_slim}
|
||||
width={180}
|
||||
height={180}
|
||||
autoRotate={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center">
|
||||
{skin.name}
|
||||
</div>
|
||||
|
||||
@@ -171,15 +171,15 @@ export default function SkinCard({
|
||||
</AnimatePresence>
|
||||
|
||||
{texture.type === 'SKIN' ? (
|
||||
<div className="relative w-full h-full bg-white dark:bg-gray-800">
|
||||
<div className="relative w-full h-full flex items-center justify-center bg-white dark:bg-gray-800">
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={300}
|
||||
height={300}
|
||||
className={`w-full h-full transition-all duration-500 ${
|
||||
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
||||
} ${isHovered ? 'scale-110' : ''}`}
|
||||
width={280}
|
||||
height={280}
|
||||
className={`transition-opacity duration-500 ${
|
||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
autoRotate={isHovered}
|
||||
walking={false}
|
||||
onImageLoaded={() => setImageLoaded(true)}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -448,10 +448,9 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ 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()}
|
||||
</div>
|
||||
|
||||
@@ -42,19 +42,32 @@ export default function CharacterCard({
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
) : (
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate flex-1">{profile.name}</h3>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">{profile.name}</h3>
|
||||
<motion.button
|
||||
onClick={() => onEdit(profile.uuid, profile.name)}
|
||||
className="text-gray-500 hover:text-orange-500 transition-colors"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="改名"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{profile.is_active && (
|
||||
<span className="px-2 py-1 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs rounded-full flex items-center space-x-1">
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
@@ -68,9 +81,8 @@ export default function CharacterCard({
|
||||
<SkinViewer
|
||||
skinUrl={skinUrl}
|
||||
isSlim={isSlim}
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-full h-full"
|
||||
width={180}
|
||||
height={180}
|
||||
autoRotate={false}
|
||||
/>
|
||||
) : (
|
||||
@@ -82,32 +94,10 @@ export default function CharacterCard({
|
||||
<UserIcon className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 皮肤选择按钮 */}
|
||||
<motion.button
|
||||
onClick={() => 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="选择皮肤"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
{!profile.is_active && (
|
||||
<motion.button
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
使用
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<motion.button
|
||||
@@ -130,13 +120,12 @@ export default function CharacterCard({
|
||||
) : (
|
||||
<>
|
||||
<motion.button
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
<PencilIcon className="w-4 h-4 inline mr-1" />
|
||||
编辑
|
||||
修改皮肤
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => onDelete(profile.uuid)}
|
||||
|
||||
@@ -68,7 +68,6 @@ export default function MySkinsTab({
|
||||
key={skin.id}
|
||||
texture={skin}
|
||||
onViewDetails={handleViewDetails}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
customActions={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -22,7 +22,7 @@ interface AuthContextType {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string, verificationCode: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string, verificationCode: string, captchaId?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (userData: Partial<User>) => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
@@ -106,7 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (username: string, email: string, password: string, verificationCode: string) => {
|
||||
const register = async (username: string, email: string, password: string, verificationCode: string, captchaId?: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||
@@ -119,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
email,
|
||||
password,
|
||||
verification_code: verificationCode,
|
||||
...(captchaId && { captcha_id: captchaId }),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -313,3 +313,13 @@ export async function resetYggdrasilPassword(): Promise<ApiResponse<{
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 删除材质
|
||||
export async function deleteTexture(id: number): Promise<ApiResponse<null>> {
|
||||
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user