first commit
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Sync to CNB / sync (push) Has been cancelled
Delete old workflow runs / del_runs (push) Has been cancelled
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled

This commit is contained in:
2026-01-30 16:57:44 +08:00
commit 3d175d75af
119 changed files with 35834 additions and 0 deletions

257
app/wheel/page.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FerrisWheel, Trophy, X } from "lucide-react";
import { toast } from "sonner";
const COLORS = [
"#f87171", "#fb923c", "#fbbf24", "#a3e635",
"#34d399", "#22d3ee", "#818cf8", "#e879f9"
];
export default function WheelPage() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [items, setItems] = useState<string[]>(["今晚吃火锅", "去看电影", "写代码", "早点睡觉", "打游戏", "喝奶茶"]);
const [newItem, setNewItem] = useState("");
const [isSpinning, setIsSpinning] = useState(false);
const [winner, setWinner] = useState<string | null>(null);
// Animation Refs
const rotationRef = useRef(0);
const speedRef = useRef(0);
const requestRef = useRef<number>(0);
// Draw Wheel
const drawLine = (ctx: CanvasRenderingContext2D, center: number, radius: number) => {
ctx.clearRect(0, 0, center * 2, center * 2);
const total = items.length;
const arc = (2 * Math.PI) / total;
// Draw Slices
for (let i = 0; i < total; i++) {
const angle = rotationRef.current + i * arc;
ctx.beginPath();
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.moveTo(center, center);
ctx.arc(center, center, radius, angle, angle + arc);
ctx.lineTo(center, center);
ctx.fill();
ctx.stroke();
// Text
ctx.save();
ctx.translate(center, center);
ctx.rotate(angle + arc / 2);
ctx.textAlign = "right";
ctx.fillStyle = "#fff";
ctx.font = "bold 14px Arial";
ctx.fillText(items[i], radius - 20, 5);
ctx.restore();
}
// Draw Pointer
ctx.beginPath();
ctx.fillStyle = "white";
ctx.moveTo(center + radius - 10, center);
ctx.lineTo(center + radius + 15, center - 10);
ctx.lineTo(center + radius + 15, center + 10);
ctx.fill();
ctx.strokeStyle = "#333";
ctx.stroke();
// Center Circle
ctx.beginPath();
ctx.arc(center, center, 20, 0, 2 * Math.PI);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();
};
const animate = () => {
if (speedRef.current > 0.001) {
speedRef.current *= 0.985; // Friction
rotationRef.current += speedRef.current;
rotationRef.current %= 2 * Math.PI;
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) drawLine(ctx, 200, 180);
}
requestRef.current = requestAnimationFrame(animate);
} else {
setIsSpinning(false);
cancelAnimationFrame(requestRef.current);
// Calculate Winner
const total = items.length;
const arc = (2 * Math.PI) / total;
// Pointer is at 0 degrees (right side), wheel rotates clockwise
// Effective angle is (2PI - current_rotation) % 2PI
// But we need to account for slice index
// Let's simplify:
// Angle of slice i is: current_rot + i * arc
// We want to know which slice covers angle 0
// (current_rot + i * arc) % 2PI needs to enclose 0 (or 2PI)
let currentRot = rotationRef.current % (2 * Math.PI);
if (currentRot < 0) currentRot += 2 * Math.PI; // normalize
// The pointer is at 0 radians (right).
// A slice i spans from [currentRot + i*arc] to [currentRot + (i+1)*arc]
// We find i such that this range includes 0 or 2PI.
// Actually simpler: Which index i corresponds to angle 0?
// angle_i_start = currentRot + i * arc
// We want angle_i_start <= 2PI*k <= angle_i_end
// Let's invert: what angle corresponds to pointer (which is at 0 relative to canvas)
// relative to wheel 0? -> -currentRot
let pointerAngle = (0 - currentRot);
if (pointerAngle < 0) pointerAngle += 2 * Math.PI;
const winningIndex = Math.floor(pointerAngle / arc);
const winItem = items[winningIndex % total];
setWinner(winItem);
toast.success(`结果揭晓:${winItem}`);
}
};
const spin = () => {
if (items.length < 2) {
toast.error("至少需要两个选项");
return;
}
if (isSpinning) return;
setIsSpinning(true);
setWinner(null);
speedRef.current = Math.random() * 0.3 + 0.4; // Initial speed
animate();
};
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
canvas.width = 400;
canvas.height = 400;
drawLine(ctx, 200, 180);
}
}
}, [items]);
const addItem = () => {
if (newItem.trim()) {
setItems([...items, newItem.trim()]);
setNewItem("");
}
};
const removeItem = (idx: number) => {
setItems(items.filter((_, i) => i !== idx));
};
const clearItems = () => {
if (confirm("确定要清空所有选项吗?")) setItems([]);
}
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center space-x-4 border-b pb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-rose-400 to-red-500 shadow-lg">
<FerrisWheel className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-12">
<Card className="lg:col-span-7 flex flex-col items-center justify-center p-8 bg-muted/10">
<div className="relative">
<canvas
ref={canvasRef}
className="max-w-full h-auto cursor-pointer"
onClick={spin}
/>
{/* Center Button Override just in case */}
<button
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16 rounded-full bg-white shadow-xl flex items-center justify-center font-bold text-gray-800 border-4 border-gray-100 hover:scale-105 transition-transform z-10"
onClick={spin}
>
{isSpinning ? "..." : "GO"}
</button>
</div>
{winner && (
<div className="mt-8 text-center animate-in zoom-in duration-300">
<p className="text-muted-foreground text-sm"></p>
<h2 className="text-3xl font-extrabold text-primary flex items-center gap-3 justify-center">
<Trophy className="h-8 w-8 text-yellow-500" />
{winner}
</h2>
</div>
)}
</Card>
<Card className="lg:col-span-5 h-fit">
<CardHeader>
<CardTitle className="flex justify-between items-center">
<span> ({items.length})</span>
<Button variant="ghost" size="sm" onClick={clearItems} className="text-destructive hover:text-destructive">
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addItem()}
placeholder="输入新选项..."
/>
<Button onClick={addItem}></Button>
</div>
<div className="bg-muted/30 rounded-lg p-2 max-h-100 overflow-y-auto space-y-2 border">
{items.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
</div>
)}
{items.map((item, idx) => (
<div key={idx} className="flex items-center justify-between p-2 bg-background rounded shadow-xs border text-sm group">
<span className="truncate flex-1">{item}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
onClick={() => removeItem(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
<div className="pt-2">
<Button onClick={spin} disabled={isSpinning || items.length < 2} className="w-full" size="lg">
{isSpinning ? "抽奖中..." : "开始抽奖"}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}