diff --git a/package-lock.json b/package-lock.json index 27d80f1..6549f0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,20 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@lottielab/lottie-player": "^1.1.3", "@tailwindcss/vite": "^4.2.1", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.34.3", + "lottie-react": "^2.4.1", "lucide-react": "^0.575.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.2", + "react-is": "^19.2.4", "react-router-dom": "^7.13.1", "recharts": "^3.7.0", "sonner": "^2.0.7", @@ -1534,6 +1537,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lottielab/lottie-player": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lottielab/lottie-player/-/lottie-player-1.1.3.tgz", + "integrity": "sha512-3Em6ZwBnIyFsAU3XsJf9GcTwxVz3FQWguxIp99Vs7dCZNelYne3k+PyCkp+3MSJPnk104KRt71SgVJ7cXUUgoA==", + "license": "MIT", + "dependencies": { + "lottie-web": "github:lottielab/lottie-web#c671e8eaefb95099fdb126d2fc68a566327e4354" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@lottielab/lottie-player/node_modules/lottie-web": { + "version": "5.12.2", + "resolved": "git+ssh://git@github.com/lottielab/lottie-web.git#c671e8eaefb95099fdb126d2fc68a566327e4354", + "integrity": "sha512-h+bTZ3ETXAuqBT/jm3yNW6d8GHg+izWDI7k1UGnxzEG9RK60f2AxQANBgVsB1lpKv2sgYMH73HM5clZeD9UE0Q==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.27.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", @@ -7440,6 +7466,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lottie-react": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz", + "integrity": "sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==", + "license": "MIT", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lottie-web": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", + "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8457,8 +8502,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", diff --git a/package.json b/package.json index 282f72e..1716df5 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,20 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@lottielab/lottie-player": "^1.1.3", "@tailwindcss/vite": "^4.2.1", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.34.3", + "lottie-react": "^2.4.1", "lucide-react": "^0.575.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.2", + "react-is": "^19.2.4", "react-router-dom": "^7.13.1", "recharts": "^3.7.0", "sonner": "^2.0.7", diff --git a/src/api/radio.ts b/src/api/radio.ts index 1c4a9a3..6997329 100644 --- a/src/api/radio.ts +++ b/src/api/radio.ts @@ -40,8 +40,8 @@ export const updateProgramApi = (data: Partial): Promise => request.post('/radio/program/update', data); export const deleteProgramApi = (data: { ids: (string | number)[] }): Promise => request.post('/radio/program/delete', data); -export const generateTtsApi = (id: string | number): Promise => - request.get('/radio/program/generate-tts', { params: { id } }); +export const generateTtsApi = (id: string | number, speaker?: string): Promise => + request.get('/radio/program/generate-tts', { params: { id, speaker } }); // --- VIP API --- export const getVipConfigDetailApi = (): Promise> => request.post('/vip/config/detail'); @@ -72,3 +72,17 @@ export const getVipStatsApi = (params: Record = {}): Promise> => request.get('/radio/user/list', { params }); + +// --- Voice API --- +export const getVoiceListApi = (data: import('../types/radio').VoiceListParams): Promise> => + request.post('/radio/voice/list', data); +export const saveVoiceApi = (data: Partial): Promise => + request.post('/radio/voice/save', data); +export const updateVoiceApi = (data: Partial): Promise => + request.post('/radio/voice/update', data); +export const deleteVoiceApi = (data: { ids: (string | number)[] }): Promise => + request.post('/radio/voice/delete', data); +export const setDefaultVoiceApi = (data: { id: string | number }): Promise => + request.post('/radio/voice/set-default', data, { params: { id: data.id } }); +export const getVoiceOptionsApi = (): Promise => + request.get('/radio/voice/options'); diff --git a/src/components/AnimatedCounter.tsx b/src/components/AnimatedCounter.tsx new file mode 100644 index 0000000..35572f4 --- /dev/null +++ b/src/components/AnimatedCounter.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from 'react'; +import { useInView, useMotionValue, useSpring } from 'framer-motion'; + +export function AnimatedCounter({ + value, + format = 'number', + className = '' +}: { + value: number | string; + format?: 'number' | 'currency' | 'percent'; + className?: string; +}) { + const ref = useRef(null); + const motionValue = useMotionValue(0); + const springValue = useSpring(motionValue, { + stiffness: 50, + damping: 20, + mass: 1, + }); + const isInView = useInView(ref, { once: true, margin: '-50px' }); + + useEffect(() => { + if (isInView) { + const numericValue = typeof value === 'string' ? parseFloat(value.replace(/[^0-9.-]+/g, '')) : value; + if (!isNaN(numericValue)) { + motionValue.set(numericValue); + } + } + }, [value, isInView, motionValue]); + + useEffect(() => { + const unsubscribe = springValue.on('change', (latest) => { + if (ref.current) { + if (format === 'currency') { + ref.current.textContent = `¥${latest.toFixed(2)}`; + } else if (format === 'percent') { + ref.current.textContent = `${latest.toFixed(1)}%`; + } else { + ref.current.textContent = Math.round(latest).toLocaleString(); + } + } + }); + return () => unsubscribe(); + }, [springValue, format]); + + return ( + + {typeof value === 'string' && isNaN(parseFloat(value.replace(/[^0-9.-]+/g, ''))) ? value : (format === 'currency' ? '¥0.00' : '0')} + + ); +} diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..13bb136 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,69 @@ +import { motion } from 'framer-motion'; + +interface EmptyStateProps { + title?: string; + description?: string; + className?: string; +} + +export function EmptyState({ title = "暂无数据", description = "这里暂时什么都没有", className = "" }: EmptyStateProps) { + return ( +
+ +
+ + + + + + + + + {title} + + + {description} + +
+ ); +} diff --git a/src/components/LoginCharacters.tsx b/src/components/LoginCharacters.tsx new file mode 100644 index 0000000..f985d1d --- /dev/null +++ b/src/components/LoginCharacters.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect, useRef } from 'react'; + +interface PupilProps { + size?: number; + maxDistance?: number; + pupilColor?: string; + forceLookX?: number; + forceLookY?: number; +} + +const Pupil = ({ + size = 12, maxDistance = 5, pupilColor = 'black', + forceLookX, forceLookY, +}: PupilProps) => { + const [mouseX, setMouseX] = useState(0); + const [mouseY, setMouseY] = useState(0); + const ref = useRef(null); + + useEffect(() => { + const h = (e: MouseEvent) => { setMouseX(e.clientX); setMouseY(e.clientY); }; + window.addEventListener('mousemove', h); + return () => window.removeEventListener('mousemove', h); + }, []); + + const calc = () => { + if (!ref.current) return { x: 0, y: 0 }; + if (forceLookX !== undefined && forceLookY !== undefined) return { x: forceLookX, y: forceLookY }; + const r = ref.current.getBoundingClientRect(); + const cx = r.left + r.width / 2, cy = r.top + r.height / 2; + const dx = mouseX - cx, dy = mouseY - cy; + const dist = Math.min(Math.sqrt(dx * dx + dy * dy), maxDistance); + const a = Math.atan2(dy, dx); + return { x: Math.cos(a) * dist, y: Math.sin(a) * dist }; + }; + const p = calc(); + return ( +
+ ); +}; + +interface EyeBallProps { + size?: number; pupilSize?: number; maxDistance?: number; + eyeColor?: string; pupilColor?: string; isBlinking?: boolean; + forceLookX?: number; forceLookY?: number; +} + +const EyeBall = ({ + size = 48, pupilSize = 16, maxDistance = 10, + eyeColor = 'white', pupilColor = 'black', isBlinking = false, + forceLookX, forceLookY, +}: EyeBallProps) => { + const [mouseX, setMouseX] = useState(0); + const [mouseY, setMouseY] = useState(0); + const ref = useRef(null); + + useEffect(() => { + const h = (e: MouseEvent) => { setMouseX(e.clientX); setMouseY(e.clientY); }; + window.addEventListener('mousemove', h); + return () => window.removeEventListener('mousemove', h); + }, []); + + const calc = () => { + if (!ref.current) return { x: 0, y: 0 }; + if (forceLookX !== undefined && forceLookY !== undefined) return { x: forceLookX, y: forceLookY }; + const r = ref.current.getBoundingClientRect(); + const cx = r.left + r.width / 2, cy = r.top + r.height / 2; + const dx = mouseX - cx, dy = mouseY - cy; + const dist = Math.min(Math.sqrt(dx * dx + dy * dy), maxDistance); + const a = Math.atan2(dy, dx); + return { x: Math.cos(a) * dist, y: Math.sin(a) * dist }; + }; + const p = calc(); + return ( +
+ {!isBlinking && ( +
+ )} +
+ ); +}; + +interface Props { isTyping?: boolean; showPassword?: boolean; passwordLength?: number; } + +export default function LoginCharacters({ isTyping = false, showPassword = false, passwordLength = 0 }: Props) { + const [mouseX, setMouseX] = useState(0); + const [mouseY, setMouseY] = useState(0); + const [isPurpleBlink, setIsPurpleBlink] = useState(false); + const [isBlackBlink, setIsBlackBlink] = useState(false); + const [isLooking, setIsLooking] = useState(false); + const [isPurplePeek, setIsPurplePeek] = useState(false); + const purpleRef = useRef(null); + const blackRef = useRef(null); + const yellowRef = useRef(null); + const orangeRef = useRef(null); + + useEffect(() => { + const h = (e: MouseEvent) => { setMouseX(e.clientX); setMouseY(e.clientY); }; + window.addEventListener('mousemove', h); + return () => window.removeEventListener('mousemove', h); + }, []); + + // Purple blink + useEffect(() => { + const schedule = (): ReturnType => setTimeout(() => { + setIsPurpleBlink(true); + setTimeout(() => { setIsPurpleBlink(false); schedule(); }, 150); + }, Math.random() * 4000 + 3000); + const t = schedule(); return () => clearTimeout(t); + }, []); + + // Black blink + useEffect(() => { + const schedule = (): ReturnType => setTimeout(() => { + setIsBlackBlink(true); + setTimeout(() => { setIsBlackBlink(false); schedule(); }, 150); + }, Math.random() * 4000 + 3000); + const t = schedule(); return () => clearTimeout(t); + }, []); + + // Look at each other when typing + useEffect(() => { + if (isTyping) { setIsLooking(true); const t = setTimeout(() => setIsLooking(false), 800); return () => clearTimeout(t); } + else setIsLooking(false); + }, [isTyping]); + + // Purple peek when password visible + useEffect(() => { + if (passwordLength > 0 && showPassword) { + const t = setTimeout(() => { + setIsPurplePeek(true); + setTimeout(() => setIsPurplePeek(false), 800); + }, Math.random() * 3000 + 2000); + return () => clearTimeout(t); + } else setIsPurplePeek(false); + }, [passwordLength, showPassword, isPurplePeek]); + + const calcPos = (ref: React.RefObject) => { + if (!ref.current) return { faceX: 0, faceY: 0, bodySkew: 0 }; + const r = ref.current.getBoundingClientRect(); + const cx = r.left + r.width / 2, cy = r.top + r.height / 3; + const dx = mouseX - cx, dy = mouseY - cy; + return { + faceX: Math.max(-15, Math.min(15, dx / 20)), + faceY: Math.max(-10, Math.min(10, dy / 30)), + bodySkew: Math.max(-6, Math.min(6, -dx / 120)), + }; + }; + + const pp = calcPos(purpleRef), bp = calcPos(blackRef); + const yp = calcPos(yellowRef), op = calcPos(orangeRef); + const hiding = passwordLength > 0 && !showPassword; + const showing = passwordLength > 0 && showPassword; + + return ( +
+ {/* Purple tall - back */} +
+
+ + +
+
+ + {/* Black - middle */} +
+
+ + +
+
+ + {/* Orange semi-circle - front left */} +
+
+ + +
+
+ + {/* Yellow rounded - front right */} +
+
+ + +
+ {/* mouth */} +
+
+
+ ); +} diff --git a/src/index.css b/src/index.css index f0717ff..b81f0e3 100644 --- a/src/index.css +++ b/src/index.css @@ -46,31 +46,25 @@ } :root { - --radius: 1.5rem; - /* 24px */ - --background: #FAF5E6; - /* 暖奶油色 */ - --foreground: #4A3A2C; - /* 深暖棕色 */ - --card: rgba(255, 253, 235, 0.85); - /* 浅黄油色 */ - --card-foreground: #4A3A2C; - --popover: rgba(255, 253, 235, 0.9); - --popover-foreground: #4A3A2C; - --primary: #D28F4F; - /* 柔和的琥珀色 */ + --radius: 0.75rem; /* 12px for modern rounded look */ + --background: #F9FAFB; /* gray-50 */ + --foreground: #111827; /* gray-900 */ + --card: #FFFFFF; + --card-foreground: #111827; + --popover: #FFFFFF; + --popover-foreground: #111827; + --primary: #111827; --primary-foreground: #FFFFFF; - --secondary: #6A7F6A; - /* 灰绿色 */ - --secondary-foreground: #FFFFFF; - --muted: #F2EDE4; - --muted-foreground: #8C7E6C; - --accent: rgba(210, 143, 79, 0.1); - --accent-foreground: #D28F4F; - --destructive: #A64452; - --border: rgba(74, 58, 44, 0.08); - --input: rgba(74, 58, 44, 0.05); - --ring: #D28F4F; + --secondary: #F3F4F6; /* gray-100 */ + --secondary-foreground: #111827; + --muted: #F3F4F6; + --muted-foreground: #6B7280; /* gray-500 */ + --accent: #F3F4F6; + --accent-foreground: #111827; + --destructive: #EF4444; /* red-500 */ + --border: #E5E7EB; /* gray-200 */ + --input: #E5E7EB; + --ring: #111827; /* Custom Design Tokens */ --morning-gradient: linear-gradient(135deg, #FDE68A 0%, #FBBF24 50%, #7DD3FC 100%); @@ -91,24 +85,24 @@ } .dark { - --background: #121212; - --foreground: #D4D4D4; - --card: #1E1E1E; - --card-foreground: #D4D4D4; - --popover: #1E1E1E; - --popover-foreground: #D4D4D4; - --primary: #D28F4F; - --primary-foreground: #FFFFFF; - --secondary: #6A7F6A; - --secondary-foreground: #FFFFFF; - --muted: #262626; - --muted-foreground: #737373; - --accent: rgba(210, 143, 79, 0.2); - --accent-foreground: #D28F4F; - --destructive: #EF4444; - --border: #262626; - --input: #262626; - --ring: #D28F4F; + --background: #030712; /* gray-950 */ + --foreground: #F9FAFB; + --card: #111827; /* gray-900 */ + --card-foreground: #F9FAFB; + --popover: #111827; + --popover-foreground: #F9FAFB; + --primary: #F9FAFB; + --primary-foreground: #111827; + --secondary: #1F2937; /* gray-800 */ + --secondary-foreground: #F9FAFB; + --muted: #1F2937; + --muted-foreground: #9CA3AF; + --accent: #1F2937; + --accent-foreground: #F9FAFB; + --destructive: #7F1D1D; + --border: #374151; /* gray-700 */ + --input: #374151; + --ring: #F9FAFB; --glass-bg: rgba(30, 30, 30, 0.8); --glass-border: rgba(255, 255, 255, 0.05); @@ -162,43 +156,11 @@ overflow: hidden; } -/* 暖色噪点纹理 */ -.warm-noise::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.06; - pointer-events: none; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); - z-index: 0; -} - -.sidebar-noise { - position: relative; - background-color: var(--sidebar-twilight); -} - -.sidebar-noise::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.03; - pointer-events: none; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); -} - -/* 侧边栏选中光晕 */ -.sidebar-halo { - position: relative; - box-shadow: 0 0 15px rgba(210, 143, 79, 0.3); - border: 1px solid rgba(210, 143, 79, 0.4) !important; -} +/* Classes nullified for new aesthetic */ +.warm-noise::before { display: none; } +.sidebar-noise { background-color: transparent; } +.sidebar-noise::before { display: none; } +.sidebar-halo { } .audio-wave { display: flex; @@ -263,11 +225,63 @@ body { @apply bg-background text-foreground font-sans; - background-image: radial-gradient(at 0% 0%, rgba(210, 143, 79, 0.05) 0px, transparent 50%), - radial-gradient(at 100% 100%, rgba(106, 127, 106, 0.05) 0px, transparent 50%); } } +/* --- Premium Animations & Shadows --- */ +.shadow-soft { + box-shadow: 0 4px 40px -4px rgba(0, 0, 0, 0.04), 0 2px 10px -2px rgba(0,0,0,0.02); +} + +.shadow-glass { + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.05); +} + +.shadow-hover-spring { + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} +.shadow-hover-spring:hover { + transform: translateY(-4px) scale(1.01); + box-shadow: 0 20px 40px -5px rgba(0, 0, 0, 0.08), 0 10px 20px -5px rgba(0, 0, 0, 0.04); +} + +/* 1px semi-transparent inner border for glass effect */ +.border-glass { + border: 1px solid rgba(255, 255, 255, 0.6); + background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(255,255,255,0.6)); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.dark .border-glass { + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(135deg, rgba(30,30,30,0.9), rgba(15,15,15,0.8)); +} + +/* Button Sweep Animation */ +.btn-sweep { + position: relative; + overflow: hidden; + z-index: 1; +} + +.btn-sweep::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 50%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + transform: skewX(-20deg); + transition: all 0.6s ease; + z-index: -1; +} + +.btn-sweep:hover::after { + left: 150%; +} + .custom-scrollbar::-webkit-scrollbar { width: 5px; } diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 70eb7ce..d561078 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -10,7 +10,8 @@ import { LogOut, User as UserIcon, ChevronDown, - Crown + Crown, + Volume2 } from 'lucide-react'; import { Button } from '../components/ui/button'; import { @@ -51,60 +52,61 @@ export default function AdminLayout() { { name: '节目管理', path: '/radio/program', icon: Disc3 }, { name: 'VIP配置', path: '/radio/vip', icon: Crown }, { name: '用户管理', path: '/radio/user', icon: UserIcon }, + { name: '音色管理', path: '/radio/voice', icon: Volume2 }, { name: '文件管理', path: '/system/oss', icon: FolderOpen }, ]; return ( -
+
{/* Sidebar */} -