Compare commits

13 Commits

Author SHA1 Message Date
lan
fdd1d0c17b fix: 修复ci缓存导致的错误
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 4m46s
2026-02-24 17:07:48 +08:00
lan
42c2fb4ce3 fix: 修复ci中出现的错误
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 4m29s
2026-02-24 12:50:38 +08:00
lan
2e85be4657 chore: remove unnecessary QEMU setup for single platform build
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 15m14s
2026-02-24 11:27:29 +08:00
lan
dad28881ed chore: add Docker configuration and Gitea CI/CD workflow
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-02-24 11:26:38 +08:00
lan
0c6c0ae1ac fix: 修复了大写导致的不能正常上传镜像
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-02-24 11:18:00 +08:00
lan
2124790c8d feat: add Docker support and texture deletion functionality
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.
2026-02-24 11:09:37 +08:00
f5455afaf2 Merge pull request '解决了不跟手问题' (#8) from feature/slide-captcha into main
Reviewed-on: #8
2026-02-15 00:26:04 +08:00
eed6920d4a 移除了滑动验证码的延迟渲染 2026-02-14 00:11:00 +08:00
00984b6d67 移除了滑动验证码的延迟渲染 2026-02-13 23:59:56 +08:00
344cae80af Merge pull request '添加了滑动验证码组件' (#7) from feature/slide-captcha into main
Reviewed-on: #7
2026-02-12 23:08:56 +08:00
321b32e312 添加了滑动验证码组件 2026-02-12 20:54:06 +08:00
914ea7524b Merge pull request 'WIP:合并滑动验证码于注册' (#6) from feature/slide-captcha into main
Reviewed-on: #6
2026-01-22 21:23:49 +08:00
4d3f34e1c8 Merge pull request '移除神必谷歌字体' (#5) from uNagi/carrotskin:main into main
Reviewed-on: #5
2026-01-19 21:23:40 +08:00
15 changed files with 363 additions and 136 deletions

41
.dockerignore Normal file
View 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

View File

@@ -0,0 +1,85 @@
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: carrotskin/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.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- 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 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
# 禁用 buildcache 以避免 413 错误
# 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
View 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"]

View File

@@ -1,7 +1,15 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: 'standalone',
rewrites: async () => {
return [
{
source: '/api/v1/:path*',
destination: 'http://localhost:8080/api/v1/:path*',
},
];
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -38,6 +38,7 @@ import {
generateAvatarUploadUrl, generateAvatarUploadUrl,
updateAvatarUrl, updateAvatarUrl,
resetYggdrasilPassword, resetYggdrasilPassword,
deleteTexture,
type Texture, type Texture,
type Profile type Profile
} from '@/lib/api'; } from '@/lib/api';
@@ -194,7 +195,18 @@ export default function ProfilePage() {
const handleDeleteSkin = async (skinId: number) => { const handleDeleteSkin = async (skinId: number) => {
if (!confirm('确定要删除这个皮肤吗?')) return; if (!confirm('确定要删除这个皮肤吗?')) return;
try {
const response = await deleteTexture(skinId);
if (response.code === 200) {
setMySkins(prev => prev.filter(skin => skin.id !== skinId)); 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) => { const handleToggleFavorite = async (skinId: number) => {
@@ -379,6 +391,8 @@ export default function ProfilePage() {
try { try {
const response = await resetYggdrasilPassword(); const response = await resetYggdrasilPassword();
if (response.code === 200) { if (response.code === 200) {
setYggdrasilPassword(response.data.password);
setShowYggdrasilPassword(true);
messageManager.success('Yggdrasil密码重置成功请妥善保管新密码。', { duration: 5000 }); messageManager.success('Yggdrasil密码重置成功请妥善保管新密码。', { duration: 5000 });
} else { } else {
throw new Error(response.message || '重置Yggdrasil密码失败'); throw new Error(response.message || '重置Yggdrasil密码失败');
@@ -955,14 +969,15 @@ export default function ProfilePage() {
} }
}} }}
> >
<div className="w-full h-full flex items-center justify-center">
<SkinViewer <SkinViewer
skinUrl={skin.url} skinUrl={skin.url}
isSlim={skin.is_slim} isSlim={skin.is_slim}
width={200} width={180}
height={200} height={180}
className="w-full h-full"
autoRotate={false} autoRotate={false}
/> />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center"> <div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center">
{skin.name} {skin.name}
</div> </div>

View File

@@ -2,14 +2,15 @@
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef, Suspense } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
interface PageTransitionProps { interface PageTransitionProps {
children: React.ReactNode; children: React.ReactNode;
} }
export default function PageTransition({ children }: PageTransitionProps) { // 内部组件:使用 useSearchParams 的部分
function PageTransitionContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -171,3 +172,20 @@ export default function PageTransition({ children }: PageTransitionProps) {
</> </>
); );
} }
// 加载状态组件
function PageTransitionFallback() {
return (
<div className="min-h-screen">
{null}
</div>
);
}
export default function PageTransition({ children }: PageTransitionProps) {
return (
<Suspense fallback={<PageTransitionFallback />}>
<PageTransitionContent>{children}</PageTransitionContent>
</Suspense>
);
}

View File

@@ -171,15 +171,15 @@ export default function SkinCard({
</AnimatePresence> </AnimatePresence>
{texture.type === 'SKIN' ? ( {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 <SkinViewer
skinUrl={texture.url} skinUrl={texture.url}
isSlim={texture.is_slim} isSlim={texture.is_slim}
width={300} width={280}
height={300} height={280}
className={`w-full h-full transition-all duration-500 ${ className={`transition-opacity duration-500 ${
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95' imageLoaded ? 'opacity-100' : 'opacity-0'
} ${isHovered ? 'scale-110' : ''}`} }`}
autoRotate={isHovered} autoRotate={isHovered}
walking={false} walking={false}
onImageLoaded={() => setImageLoaded(true)} onImageLoaded={() => setImageLoaded(true)}

View File

@@ -296,7 +296,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
isSlim={texture.is_slim} isSlim={texture.is_slim}
width={600} width={600}
height={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} autoRotate={autoRotate}
walking={currentAnimation === 'walking'} walking={currentAnimation === 'walking'}
running={currentAnimation === 'running'} running={currentAnimation === 'running'}

View File

@@ -83,28 +83,24 @@ export default function SkinViewer({
try { try {
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height }); console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
// 使用canvas的实际尺寸参考blessingskin // 使用传入的宽高参数初始化
const canvas = canvasRef.current; const canvas = canvasRef.current;
const viewer = new SkinViewer3D({ const viewer = new SkinViewer3D({
canvas: canvas, canvas: canvas,
width: canvas.clientWidth || width, width: width,
height: canvas.clientHeight || height, height: height,
skin: skinUrl, skin: skinUrl,
cape: capeUrl, cape: capeUrl,
model: isSlim ? 'slim' : 'default', model: isSlim ? 'slim' : 'default',
zoom: 1.0, // 使用blessingskin的zoom方式 zoom: 1.0,
}); });
viewerRef.current = viewer; viewerRef.current = viewer;
// 设置背景和控制选项 - 参考blessingskin // 设置背景和控制选项
viewer.background = null; // 透明背景 viewer.background = null; // 透明背景
viewer.autoRotate = false; // 完全禁用自动旋转 viewer.autoRotate = false; // 完全禁用自动旋转
// 调整光照设置,避免皮肤发黑
viewer.globalLight.intensity = 0.8; // 增加环境光强度
viewer.cameraLight.intensity = 0.4; // 降低相机光源强度,避免过强的阴影
// 外部预览时禁用所有动画和旋转 // 外部预览时禁用所有动画和旋转
if (isExternalPreview) { if (isExternalPreview) {
viewer.autoRotate = false; viewer.autoRotate = false;
@@ -268,11 +264,8 @@ export default function SkinViewer({
ref={canvasRef} ref={canvasRef}
className={className} className={className}
style={{ style={{
display: 'flex', width: width,
justifyContent: 'center', height: height
alignItems: 'center',
width: '100%',
height: '100%'
}} }}
/> />
); );

View File

@@ -76,18 +76,19 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const response = await axios.get(`${API_BASE_URL}/captcha/generate`, { const response = await axios.get(`${API_BASE_URL}/captcha/generate`, {
withCredentials: true // 关键:允许跨域携带凭证 withCredentials: true // 关键:允许跨域携带凭证
}); });
const { code, msg: resMsg, captcha_id, mBase64, tBase64, y } = response.data; const { code, msg: resMsg, data } = response.data;
const { masterImage, tileImage, captchaId, y } = data;
// 后端返回成功状态code=200 // 后端返回成功状态code=200
if (code === 200) { if (code === 200) {
// 设置背景图 // 设置背景图
setBackgroundImage(mBase64); setBackgroundImage(masterImage);
// 设置拼图图片 // 设置拼图图片
setPuzzleImage(tBase64); setPuzzleImage(tileImage);
// 设置拼图y坐标从后端获取以背景图左上角为原点 // 设置拼图y坐标从后端获取以背景图左上角为原点
setPuzzleY(y); setPuzzleY(y);
// 设置进程ID用于后续验证 // 设置进程ID用于后续验证
setProcessId(captcha_id); setProcessId(captchaId);
// 随机生成拼图x坐标确保拼图在背景图内 // 随机生成拼图x坐标确保拼图在背景图内
// setPuzzlePosition(Math.random() * (CANVAS_WIDTH - 50 - 50) + 50); // setPuzzlePosition(Math.random() * (CANVAS_WIDTH - 50 - 50) + 50);
// 保存后端返回的提示信息 // 保存后端返回的提示信息
@@ -163,47 +164,59 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
try { try {
// 向后端发送验证请求参数为滑块位置x坐标和进程ID // 向后端发送验证请求参数为滑块位置x坐标和进程ID
// 使用sliderPosition作为dx值这是拼图块左上角的位置
const response = await axios.post(`${API_BASE_URL}/captcha/verify`, { const response = await axios.post(`${API_BASE_URL}/captcha/verify`, {
dx: sliderPosition, // 滑块位置拼图左上角x坐标以背景图左上角为原点 dx: sliderPosition, // 滑块位置拼图左上角x坐标以背景图左上角为原点
captcha_id: processId // 验证码进程ID captchaId: processId // 验证码进程ID
},{ withCredentials: true }); },{ withCredentials: true });
const { code, msg: resMsg, data } = response.data; const { code, msg: resMsg, data } = response.data;
// 保存后端返回的提示信息 // 保存后端返回的提示信息
setMsg(resMsg); setMsg(resMsg);
// 后端返回成功 code=200 // 根据后端返回的code判断验证结果
// 验证成功code=200
if (code === 200) { if (code === 200) {
// 验证成功data=true // 增加尝试次数
if (data === true) { setAttempts(prev => prev + 1);
// 重置所有状态,确保验证成功状态的纯净性
setShowError(false);
setVerifyResult(false);
// 直接设置验证成功状态,不使用异步更新
setIsVerified(true); setIsVerified(true);
setVerifyResult(true);
// 延迟1.2秒后调用验证成功回调 // 延迟1.2秒后调用验证成功回调
setTimeout(() => onVerify(true), 1200); setTimeout(() => onVerify(true), 1200);
} }
// 验证失败data=false // 验证失败code=400
else { else if (code === 400) {
// 确保错误状态的正确性:验证失败显示红色
setVerifyResult(false); setVerifyResult(false);
setShowError(true); setShowError(true);
setIsVerified(false);
// 增加尝试次数 // 增加尝试次数
setAttempts(prev => prev + 1); setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置隐藏错误提示 // 1.5秒后重置滑块位置隐藏错误提示并重置验证结果
setTimeout(() => { setTimeout(() => {
setSliderPosition(0); setSliderPosition(0);
setShowError(false); setShowError(false);
setVerifyResult(false);
setIsVerified(false);
}, 1500); }, 1500);
} }
} // 后端返回系统错误500
// 后端返回参数错误400或系统错误500 else if (code === 500) {
else if (code === 400 || code === 500) { // 系统错误显示橙色
setVerifyResult('error'); setVerifyResult('error');
setShowError(true); setShowError(true);
setIsVerified(false);
// 增加尝试次数 // 增加尝试次数
setAttempts(prev => prev + 1); setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置隐藏错误提示 // 1.5秒后重置滑块位置隐藏错误提示并重置验证结果
setTimeout(() => { setTimeout(() => {
setSliderPosition(0); setSliderPosition(0);
setShowError(false); setShowError(false);
setVerifyResult(false);
setIsVerified(false);
}, 1500); }, 1500);
} }
@@ -318,12 +331,12 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
// 加载中显示旋转动画 // 加载中显示旋转动画
return <div className="w-5 h-5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />; return <div className="w-5 h-5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />;
} }
// 验证成功时,无论其他状态如何,都显示对勾图标
if (isVerified) { if (isVerified) {
// 验证成功显示对勾图标
return <Check className="w-5 h-5 text-green-600" />; return <Check className="w-5 h-5 text-green-600" />;
} }
if (showError) { // 验证失败或错误时显示叉号图标
// 验证失败显示叉号图标 if (showError || verifyResult === 'error') {
return <X className="w-5 h-5 text-red-600" />; return <X className="w-5 h-5 text-red-600" />;
} }
// 默认显示蓝色圆点 // 默认显示蓝色圆点
@@ -332,8 +345,12 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const getStatusText = () => { const getStatusText = () => {
if (verifyResult === 'error' || showError || isVerified) { if (isVerified) {
// 错误、验证失败或成功时显示后端返回的消息 // 验证成功时优先显示成功消息
return msg;
}
if (verifyResult === 'error' || showError) {
// 错误或验证失败时显示后端返回的消息
return msg; return msg;
} }
// 默认显示拖拽提示 // 默认显示拖拽提示
@@ -342,17 +359,21 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const getStatusColor = () => { const getStatusColor = () => {
if (verifyResult === 'error') return 'text-orange-700';
if (isVerified) return 'text-green-700'; if (isVerified) return 'text-green-700';
if (verifyResult === 'error') return 'text-orange-700';
if (showError) return 'text-red-700'; if (showError) return 'text-red-700';
return 'text-gray-600'; return 'text-gray-600';
}; };
const getProgressColor = () => { const getProgressColor = () => {
if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500'; // 验证成功时,无论其他状态如何,都显示绿色渐变
if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500'; if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500';
if (showError) return 'bg-gradient-to-r from-red-400 to-red-500'; // 系统错误后端返回400/500显示橙色渐变
if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500';
// 验证失败后端返回200但data=false显示红色渐变
if (showError && verifyResult !== 'error') return 'bg-gradient-to-r from-red-400 to-red-500';
// 默认显示蓝色渐变
return 'bg-gradient-to-r from-blue-400 to-blue-500'; return 'bg-gradient-to-r from-blue-400 to-blue-500';
}; };
@@ -386,9 +407,9 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
/> />
)} )}
{/* 可移动拼图块 */} {/* 可移动拼图块 */}
{puzzleImage && !isVerified && ( {puzzleImage && (
<div <div
className="absolute transition-all duration-300" className={`absolute ${isDragging ? '' : 'transition-all duration-300'}`}
style={{ style={{
left: `${sliderPosition}px`, // 滑块x位置拼图左上角x坐标 left: `${sliderPosition}px`, // 滑块x位置拼图左上角x坐标
top: `${puzzleY}px`, // 拼图y位置从后端获取拼图左上角y坐标 top: `${puzzleY}px`, // 拼图y位置从后端获取拼图左上角y坐标
@@ -400,7 +421,6 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
alt="拼图块" alt="拼图块"
className={`${isVerified ? 'opacity-100' : 'opacity-90'}`} className={`${isVerified ? 'opacity-100' : 'opacity-90'}`}
style={{ style={{
filter: isVerified ? 'drop-shadow(0 0 10px rgba(34, 197, 94, 0.5))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' filter: isVerified ? 'drop-shadow(0 0 10px rgba(34, 197, 94, 0.5))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))'
}} }}
/> />
@@ -415,7 +435,7 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
<div className="relative bg-gray-100 rounded-full h-12 overflow-hidden select-none" ref={trackRef} style={{ width: `${TRACK_WIDTH}px`, margin: '0 auto' }}> <div className="relative bg-gray-100 rounded-full h-12 overflow-hidden select-none" ref={trackRef} style={{ width: `${TRACK_WIDTH}px`, margin: '0 auto' }}>
{/* 进度条 */} {/* 进度条 */}
<div <div
className={`absolute left-0 top-0 h-full transition-all duration-200 ease-out ${getProgressColor()}`} className={`absolute left-0 top-0 h-full ${isDragging ? '' : 'transition-all duration-200 ease-out'} ${getProgressColor()}`}
style={{ style={{
width: `${sliderPosition + SLIDER_WIDTH}px`, width: `${sliderPosition + SLIDER_WIDTH}px`,
transform: isDragging ? 'scaleY(1.05)' : 'scaleY(1)', transform: isDragging ? 'scaleY(1.05)' : 'scaleY(1)',
@@ -424,14 +444,13 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
/> />
{/* 滑块按钮 */} {/* 滑块按钮 */}
<div <div
className={`absolute top-1 w-10 h-10 bg-white rounded-full shadow-lg cursor-pointer flex items-center justify-center transition-all duration-200 ease-out select-none ${ className={`absolute top-1 w-10 h-10 bg-white rounded-full shadow-lg cursor-pointer flex items-center justify-center ${isDragging ? '' : 'transition-all duration-200 ease-out'} select-none ${
isDragging ? 'scale-110 shadow-xl' : 'scale-100' isDragging ? 'scale-110 shadow-xl' : 'scale-100'
} ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`} } ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`}
style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }} style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }}
onMouseDown={handleMouseDown} onMouseDown={verifyResult === 'error' ? undefined : handleMouseDown}
onTouchStart={handleTouchStart} onTouchStart={verifyResult === 'error' ? undefined : handleTouchStart}
ref={sliderRef} ref={sliderRef}
disabled={verifyResult === 'error'}
> >
{getSliderIcon()} {getSliderIcon()}
</div> </div>
@@ -450,7 +469,7 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
{/* 底部信息区域 */} {/* 底部信息区域 */}
<div className="px-6 pb-6"> <div className="px-6 pb-6">
<div className="flex items-center justify-between text-xs text-gray-500"> <div className="flex items-center justify-between text-xs text-gray-500">
<span>: {attempts + 1}/5</span> <span>: {attempts}</span>
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Shield className="w-3 h-3" /> <Shield className="w-3 h-3" />
<span></span> <span></span>

View File

@@ -42,19 +42,32 @@ export default function CharacterCard({
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 flex-1">
{isEditing ? ( {isEditing ? (
<input <input
type="text" type="text"
value={editName} value={editName}
onChange={(e) => onEditNameChange(e.target.value)} 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" 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)} onBlur={() => onSave(profile.uuid)}
onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)} onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)}
autoFocus autoFocus
/> />
) : ( ) : (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate flex-1">{profile.name}</h3> <>
<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 && ( {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"> <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" /> <CheckIcon className="w-3 h-3" />
@@ -68,9 +81,8 @@ export default function CharacterCard({
<SkinViewer <SkinViewer
skinUrl={skinUrl} skinUrl={skinUrl}
isSlim={isSlim} isSlim={isSlim}
width={200} width={180}
height={200} height={180}
className="w-full h-full"
autoRotate={false} autoRotate={false}
/> />
) : ( ) : (
@@ -82,32 +94,10 @@ export default function CharacterCard({
<UserIcon className="w-10 h-10 text-white" /> <UserIcon className="w-10 h-10 text-white" />
</motion.div> </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>
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="flex gap-2"> <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 ? ( {isEditing ? (
<> <>
<motion.button <motion.button
@@ -130,13 +120,12 @@ export default function CharacterCard({
) : ( ) : (
<> <>
<motion.button <motion.button
onClick={() => onEdit(profile.uuid, profile.name)} onClick={() => onSelectSkin(profile.uuid)}
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" 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 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<PencilIcon className="w-4 h-4 inline mr-1" />
</motion.button> </motion.button>
<motion.button <motion.button
onClick={() => onDelete(profile.uuid)} onClick={() => onDelete(profile.uuid)}

View File

@@ -68,7 +68,6 @@ export default function MySkinsTab({
key={skin.id} key={skin.id}
texture={skin} texture={skin}
onViewDetails={handleViewDetails} onViewDetails={handleViewDetails}
onToggleVisibility={onToggleVisibility}
customActions={ customActions={
<div className="flex gap-2"> <div className="flex gap-2">
<button <button

View File

@@ -22,7 +22,7 @@ interface AuthContextType {
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>; 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; logout: () => void;
updateUser: (userData: Partial<User>) => void; updateUser: (userData: Partial<User>) => void;
refreshUser: () => Promise<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); setIsLoading(true);
try { try {
const response = await fetch(`${API_BASE_URL}/auth/register`, { const response = await fetch(`${API_BASE_URL}/auth/register`, {
@@ -119,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
email, email,
password, password,
verification_code: verificationCode, verification_code: verificationCode,
...(captchaId && { captcha_id: captchaId }),
}), }),
}); });

View File

@@ -1,4 +1,4 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1'; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '/api/v1';
export interface Texture { export interface Texture {
id: number; id: number;
@@ -313,3 +313,13 @@ export async function resetYggdrasilPassword(): Promise<ApiResponse<{
return response.json(); 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();
}