feat: 修改登录页,重构样式
This commit is contained in:
+2
-2
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2225%22 fill=%22%2310b981%22/><text x=%2250%22 y=%2272%22 font-size=%2265%22 fill=%22white%22 text-anchor=%22middle%22 font-family=%22system-ui, sans-serif%22 font-weight=%22bold%22>S</text></svg>" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>植趣ZeeQ - 后台管理系统</title>
|
<title>Sundynix Console - 光衍矩阵</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface SysAiConfig {
|
|||||||
embeddingApiUrl: string
|
embeddingApiUrl: string
|
||||||
embeddingApiKey: string
|
embeddingApiKey: string
|
||||||
embeddingModelName: string
|
embeddingModelName: string
|
||||||
|
// 用量限制
|
||||||
|
dailyQueryLimit: number
|
||||||
// 基础字段
|
// 基础字段
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
createdAtStr?: string
|
createdAtStr?: string
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} className="rounded-full" style={{
|
||||||
|
width: size, height: size, backgroundColor: pupilColor,
|
||||||
|
transform: `translate(${p.x}px, ${p.y}px)`, transition: 'transform 0.1s ease-out',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} className="rounded-full flex items-center justify-center transition-all duration-150"
|
||||||
|
style={{ width: size, height: isBlinking ? 2 : size, backgroundColor: eyeColor, overflow: 'hidden' }}>
|
||||||
|
{!isBlinking && (
|
||||||
|
<div className="rounded-full" style={{
|
||||||
|
width: pupilSize, height: pupilSize, backgroundColor: pupilColor,
|
||||||
|
transform: `translate(${p.x}px, ${p.y}px)`, transition: 'transform 0.1s ease-out',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [isPrimaryBlink, setIsPrimaryBlink] = useState(false);
|
||||||
|
const [isDarkBlink, setIsDarkBlink] = useState(false);
|
||||||
|
const [isLooking, setIsLooking] = useState(false);
|
||||||
|
const [isPrimaryPeek, setIsPrimaryPeek] = useState(false);
|
||||||
|
const primaryRef = useRef<HTMLDivElement>(null);
|
||||||
|
const darkRef = useRef<HTMLDivElement>(null);
|
||||||
|
const yellowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const orangeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: MouseEvent) => { setMouseX(e.clientX); setMouseY(e.clientY); };
|
||||||
|
window.addEventListener('mousemove', h);
|
||||||
|
return () => window.removeEventListener('mousemove', h);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Primary blink
|
||||||
|
useEffect(() => {
|
||||||
|
const schedule = (): ReturnType<typeof setTimeout> => setTimeout(() => {
|
||||||
|
setIsPrimaryBlink(true);
|
||||||
|
setTimeout(() => { setIsPrimaryBlink(false); schedule(); }, 150);
|
||||||
|
}, Math.random() * 4000 + 3000);
|
||||||
|
const t = schedule(); return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Dark blink
|
||||||
|
useEffect(() => {
|
||||||
|
const schedule = (): ReturnType<typeof setTimeout> => setTimeout(() => {
|
||||||
|
setIsDarkBlink(true);
|
||||||
|
setTimeout(() => { setIsDarkBlink(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]);
|
||||||
|
|
||||||
|
// Primary peek when password visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (passwordLength > 0 && showPassword) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setIsPrimaryPeek(true);
|
||||||
|
setTimeout(() => setIsPrimaryPeek(false), 800);
|
||||||
|
}, Math.random() * 3000 + 2000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
} else setIsPrimaryPeek(false);
|
||||||
|
}, [passwordLength, showPassword, isPrimaryPeek]);
|
||||||
|
|
||||||
|
const calcPos = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||||
|
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(primaryRef), bp = calcPos(darkRef);
|
||||||
|
const yp = calcPos(yellowRef), op = calcPos(orangeRef);
|
||||||
|
const hiding = passwordLength > 0 && !showPassword;
|
||||||
|
const showing = passwordLength > 0 && showPassword;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full" style={{ height: 300 }}>
|
||||||
|
{/* Primary tall - back */}
|
||||||
|
<div ref={primaryRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||||
|
left: '12%', width: '33%', height: (isTyping || hiding) ? '110%' : '100%',
|
||||||
|
backgroundColor: '#10b981', borderRadius: '10px 10px 0 0', zIndex: 1, /* emerald-500 */
|
||||||
|
transform: showing ? 'skewX(0deg)' : (isTyping || hiding)
|
||||||
|
? `skewX(${(pp.bodySkew || 0) - 12}deg) translateX(40px)` : `skewX(${pp.bodySkew || 0}deg)`,
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
}}>
|
||||||
|
<div className="absolute flex gap-8 transition-all duration-700 ease-in-out" style={{
|
||||||
|
left: showing ? 20 : isLooking ? 55 : 45 + pp.faceX,
|
||||||
|
top: showing ? 35 : isLooking ? 65 : 40 + pp.faceY,
|
||||||
|
}}>
|
||||||
|
<EyeBall size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#064e3b"
|
||||||
|
isBlinking={isPrimaryBlink}
|
||||||
|
forceLookX={showing ? (isPrimaryPeek ? 4 : -4) : isLooking ? 3 : undefined}
|
||||||
|
forceLookY={showing ? (isPrimaryPeek ? 5 : -4) : isLooking ? 4 : undefined} />
|
||||||
|
<EyeBall size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#064e3b"
|
||||||
|
isBlinking={isPrimaryBlink}
|
||||||
|
forceLookX={showing ? (isPrimaryPeek ? 4 : -4) : isLooking ? 3 : undefined}
|
||||||
|
forceLookY={showing ? (isPrimaryPeek ? 5 : -4) : isLooking ? 4 : undefined} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dark - middle */}
|
||||||
|
<div ref={darkRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||||
|
left: '44%', width: '22%', height: '77%',
|
||||||
|
backgroundColor: '#0f766e', borderRadius: '8px 8px 0 0', zIndex: 2, /* teal-700 */
|
||||||
|
transform: showing ? 'skewX(0deg)' : isLooking
|
||||||
|
? `skewX(${(bp.bodySkew || 0) * 1.5 + 10}deg) translateX(20px)`
|
||||||
|
: (isTyping || hiding) ? `skewX(${(bp.bodySkew || 0) * 1.5}deg)` : `skewX(${bp.bodySkew || 0}deg)`,
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
}}>
|
||||||
|
<div className="absolute flex gap-6 transition-all duration-700 ease-in-out" style={{
|
||||||
|
left: showing ? 10 : isLooking ? 32 : 26 + bp.faceX,
|
||||||
|
top: showing ? 28 : isLooking ? 12 : 32 + bp.faceY,
|
||||||
|
}}>
|
||||||
|
<EyeBall size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#042f2e"
|
||||||
|
isBlinking={isDarkBlink}
|
||||||
|
forceLookX={showing ? -4 : isLooking ? 0 : undefined}
|
||||||
|
forceLookY={showing ? -4 : isLooking ? -4 : undefined} />
|
||||||
|
<EyeBall size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#042f2e"
|
||||||
|
isBlinking={isDarkBlink}
|
||||||
|
forceLookX={showing ? -4 : isLooking ? 0 : undefined}
|
||||||
|
forceLookY={showing ? -4 : isLooking ? -4 : undefined} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orange semi-circle - front left */}
|
||||||
|
<div ref={orangeRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||||
|
left: '0%', width: '44%', height: '50%', backgroundColor: '#f59e0b', /* amber-500 */
|
||||||
|
borderRadius: '120px 120px 0 0', zIndex: 3,
|
||||||
|
transform: showing ? 'skewX(0deg)' : `skewX(${op.bodySkew || 0}deg)`,
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
}}>
|
||||||
|
<div className="absolute flex gap-8 transition-all duration-200 ease-out" style={{
|
||||||
|
left: showing ? '20%' : `calc(34% + ${op.faceX || 0}px)`,
|
||||||
|
top: showing ? '42%' : `calc(45% + ${op.faceY || 0}px)`,
|
||||||
|
}}>
|
||||||
|
<Pupil size={12} maxDistance={5} pupilColor="#78350f"
|
||||||
|
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||||
|
<Pupil size={12} maxDistance={5} pupilColor="#78350f"
|
||||||
|
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Yellow rounded - front right */}
|
||||||
|
<div ref={yellowRef} className="absolute bottom-0 transition-all duration-700 ease-in-out" style={{
|
||||||
|
left: '56%', width: '26%', height: '58%', backgroundColor: '#84cc16', /* lime-500 */
|
||||||
|
borderRadius: '70px 70px 0 0', zIndex: 4,
|
||||||
|
transform: showing ? 'skewX(0deg)' : `skewX(${yp.bodySkew || 0}deg)`,
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
}}>
|
||||||
|
<div className="absolute flex gap-6 transition-all duration-200 ease-out" style={{
|
||||||
|
left: showing ? '14%' : `calc(37% + ${yp.faceX || 0}px)`,
|
||||||
|
top: showing ? '15%' : `calc(17% + ${yp.faceY || 0}px)`,
|
||||||
|
}}>
|
||||||
|
<Pupil size={12} maxDistance={5} pupilColor="#3f6212"
|
||||||
|
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||||
|
<Pupil size={12} maxDistance={5} pupilColor="#3f6212"
|
||||||
|
forceLookX={showing ? -5 : undefined} forceLookY={showing ? -4 : undefined} />
|
||||||
|
</div>
|
||||||
|
{/* mouth */}
|
||||||
|
<div className="absolute w-16 h-[4px] bg-[#3f6212] rounded-full transition-all duration-200 ease-out"
|
||||||
|
style={{
|
||||||
|
left: showing ? '7%' : `calc(28% + ${yp.faceX || 0}px)`,
|
||||||
|
top: showing ? '38%' : `calc(38% + ${yp.faceY || 0}px)`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ interface SearchBarProps {
|
|||||||
|
|
||||||
export function SearchBar({ value, onChange, onSearch, placeholder = '搜索...', extra }: SearchBarProps) {
|
export function SearchBar({ value, onChange, onSearch, placeholder = '搜索...', extra }: SearchBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 p-4 rounded-2xl border border-border/60 bg-card shadow-soft">
|
<div className="flex items-center gap-3 p-4 rounded-[1.25rem] bg-card shadow-soft">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -104,7 +104,7 @@ interface DataTableProps {
|
|||||||
|
|
||||||
export function DataTable({ loading, empty, emptyText = '暂无数据', children }: DataTableProps) {
|
export function DataTable({ loading, empty, emptyText = '暂无数据', children }: DataTableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border/60 bg-card shadow-soft overflow-hidden">
|
<div className="rounded-[1.25rem] bg-card shadow-soft overflow-hidden">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="h-7 w-7 animate-spin text-primary/50" />
|
<Loader2 className="h-7 w-7 animate-spin text-primary/50" />
|
||||||
|
|||||||
+107
-64
@@ -1,72 +1,78 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap');
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Modern green-based color palette */
|
/* Nature-inspired Light Theme - Refined Premium */
|
||||||
--color-background: oklch(0.985 0.002 120);
|
--color-background: oklch(0.98 0.01 145); /* Very soft cool mint/gray */
|
||||||
--color-foreground: oklch(0.18 0.01 120);
|
--color-foreground: oklch(0.15 0.02 145);
|
||||||
--color-card: oklch(1 0 0);
|
--color-card: oklch(1 0 0); /* Pure white cards */
|
||||||
--color-card-foreground: oklch(0.18 0.01 120);
|
--color-card-foreground: oklch(0.15 0.02 145);
|
||||||
--color-popover: oklch(1 0 0);
|
--color-popover: oklch(1 0 0);
|
||||||
--color-popover-foreground: oklch(0.18 0.01 120);
|
--color-popover-foreground: oklch(0.15 0.02 145);
|
||||||
--color-primary: oklch(0.52 0.14 150);
|
--color-primary: oklch(0.55 0.12 145); /* Emerald */
|
||||||
--color-primary-foreground: oklch(0.99 0 0);
|
--color-primary-foreground: oklch(1 0 0);
|
||||||
--color-secondary: oklch(0.965 0.015 150);
|
--color-secondary: oklch(0.96 0.01 145);
|
||||||
--color-secondary-foreground: oklch(0.30 0.06 150);
|
--color-secondary-foreground: oklch(0.35 0.08 145);
|
||||||
--color-muted: oklch(0.965 0.005 120);
|
--color-muted: oklch(0.96 0.01 145);
|
||||||
--color-muted-foreground: oklch(0.48 0.01 120);
|
--color-muted-foreground: oklch(0.55 0.02 145);
|
||||||
--color-accent: oklch(0.96 0.02 150);
|
--color-accent: oklch(0.96 0.01 145);
|
||||||
--color-accent-foreground: oklch(0.30 0.06 150);
|
--color-accent-foreground: oklch(0.35 0.08 145);
|
||||||
--color-destructive: oklch(0.58 0.18 25);
|
--color-destructive: oklch(0.58 0.18 25);
|
||||||
--color-destructive-foreground: oklch(0.99 0 0);
|
--color-destructive-foreground: oklch(1 0 0);
|
||||||
--color-border: oklch(0.92 0.005 120);
|
--color-border: oklch(0.92 0.01 145); /* Softer borders */
|
||||||
--color-input: oklch(0.92 0.005 120);
|
--color-input: oklch(0.92 0.01 145);
|
||||||
--color-ring: oklch(0.52 0.14 150);
|
--color-ring: oklch(0.55 0.12 145);
|
||||||
|
|
||||||
/* Sidebar colors */
|
/* Sidebar colors */
|
||||||
--color-sidebar-background: oklch(0.99 0.002 120);
|
--color-sidebar-background: oklch(0.985 0.005 100 / 0.8);
|
||||||
--color-sidebar-foreground: oklch(0.40 0.01 120);
|
--color-sidebar-foreground: oklch(0.30 0.02 140);
|
||||||
--color-sidebar-primary: oklch(0.52 0.14 150);
|
--color-sidebar-primary: oklch(0.55 0.12 145);
|
||||||
--color-sidebar-primary-foreground: oklch(0.99 0 0);
|
--color-sidebar-primary-foreground: oklch(0.99 0 0);
|
||||||
--color-sidebar-accent: oklch(0.965 0.02 150);
|
--color-sidebar-accent: oklch(0.95 0.02 135);
|
||||||
--color-sidebar-accent-foreground: oklch(0.25 0.06 150);
|
--color-sidebar-accent-foreground: oklch(0.35 0.08 145);
|
||||||
--color-sidebar-border: oklch(0.94 0.005 120);
|
--color-sidebar-border: oklch(0.90 0.01 110 / 0.3);
|
||||||
--color-sidebar-ring: oklch(0.52 0.14 150);
|
--color-sidebar-ring: oklch(0.55 0.12 145);
|
||||||
|
|
||||||
/* Refined radius for smoother look */
|
/* Rounder, softer radii for premium feel */
|
||||||
--radius-lg: 0.875rem;
|
--radius-lg: 1.5rem;
|
||||||
--radius-md: 0.625rem;
|
--radius-md: 1rem;
|
||||||
--radius-sm: 0.375rem;
|
--radius-sm: 0.6rem;
|
||||||
|
|
||||||
|
/* Custom shadows - more diffused and elegant */
|
||||||
|
--shadow-soft: 0 4px 20px -2px oklch(0 0 0 / 0.05), 0 0 3px oklch(0 0 0 / 0.02);
|
||||||
|
--shadow-soft-lg: 0 10px 40px -4px oklch(0 0 0 / 0.08), 0 0 4px oklch(0 0 0 / 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--color-background: oklch(0.11 0.01 120);
|
/* Midnight Forest Dark Theme */
|
||||||
--color-foreground: oklch(0.96 0.005 120);
|
--color-background: oklch(0.18 0.02 145);
|
||||||
--color-card: oklch(0.14 0.01 120);
|
--color-foreground: oklch(0.96 0.01 130);
|
||||||
--color-card-foreground: oklch(0.96 0.005 120);
|
--color-card: oklch(0.22 0.02 145);
|
||||||
--color-popover: oklch(0.14 0.01 120);
|
--color-card-foreground: oklch(0.96 0.01 130);
|
||||||
--color-popover-foreground: oklch(0.96 0.005 120);
|
--color-popover: oklch(0.22 0.02 145);
|
||||||
--color-primary: oklch(0.62 0.14 150);
|
--color-popover-foreground: oklch(0.96 0.01 130);
|
||||||
--color-primary-foreground: oklch(0.10 0 0);
|
--color-primary: oklch(0.65 0.15 145);
|
||||||
--color-secondary: oklch(0.20 0.02 150);
|
--color-primary-foreground: oklch(0.12 0.02 145);
|
||||||
--color-secondary-foreground: oklch(0.88 0.02 150);
|
--color-secondary: oklch(0.28 0.03 145);
|
||||||
--color-muted: oklch(0.20 0.01 120);
|
--color-secondary-foreground: oklch(0.92 0.02 145);
|
||||||
--color-muted-foreground: oklch(0.62 0.01 120);
|
--color-muted: oklch(0.25 0.02 145);
|
||||||
--color-accent: oklch(0.20 0.02 150);
|
--color-muted-foreground: oklch(0.70 0.02 145);
|
||||||
--color-accent-foreground: oklch(0.88 0.02 150);
|
--color-accent: oklch(0.28 0.03 145);
|
||||||
|
--color-accent-foreground: oklch(0.92 0.02 145);
|
||||||
--color-destructive: oklch(0.58 0.18 25);
|
--color-destructive: oklch(0.58 0.18 25);
|
||||||
--color-destructive-foreground: oklch(0.99 0 0);
|
--color-destructive-foreground: oklch(0.99 0 0);
|
||||||
--color-border: oklch(0.26 0.01 120);
|
--color-border: oklch(0.32 0.03 145);
|
||||||
--color-input: oklch(0.26 0.01 120);
|
--color-input: oklch(0.32 0.03 145);
|
||||||
--color-ring: oklch(0.62 0.14 150);
|
--color-ring: oklch(0.65 0.15 145);
|
||||||
--color-sidebar-background: oklch(0.10 0.01 120);
|
|
||||||
--color-sidebar-foreground: oklch(0.68 0.01 120);
|
--color-sidebar-background: oklch(0.16 0.02 145 / 0.8);
|
||||||
--color-sidebar-primary: oklch(0.62 0.14 150);
|
--color-sidebar-foreground: oklch(0.85 0.02 145);
|
||||||
--color-sidebar-primary-foreground: oklch(0.10 0 0);
|
--color-sidebar-primary: oklch(0.65 0.15 145);
|
||||||
--color-sidebar-accent: oklch(0.20 0.02 150);
|
--color-sidebar-primary-foreground: oklch(0.12 0.02 145);
|
||||||
--color-sidebar-accent-foreground: oklch(0.88 0.02 150);
|
--color-sidebar-accent: oklch(0.28 0.03 145);
|
||||||
--color-sidebar-border: oklch(0.26 0.01 120);
|
--color-sidebar-accent-foreground: oklch(0.92 0.02 145);
|
||||||
--color-sidebar-ring: oklch(0.62 0.14 150);
|
--color-sidebar-border: oklch(0.32 0.03 145 / 0.3);
|
||||||
|
--color-sidebar-ring: oklch(0.65 0.15 145);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -81,10 +87,21 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.015em;
|
||||||
|
/* More premium, subtle background */
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 145 / 0.05), transparent 25%),
|
||||||
|
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 170 / 0.06), transparent 25%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 15% 50%, oklch(0.55 0.12 145 / 0.15), transparent 25%),
|
||||||
|
radial-gradient(circle at 85% 30%, oklch(0.65 0.15 170 / 0.15), transparent 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth scrollbar */
|
/* Smooth scrollbar */
|
||||||
@@ -164,6 +181,10 @@ button {
|
|||||||
@apply transition-all duration-150;
|
@apply transition-all duration-150;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
/* Input focus enhancements */
|
/* Input focus enhancements */
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
@@ -171,14 +192,7 @@ select:focus {
|
|||||||
@apply transition-shadow duration-150;
|
@apply transition-shadow duration-150;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modern shadows */
|
|
||||||
.shadow-soft {
|
|
||||||
box-shadow: 0 2px 8px -2px oklch(0 0 0 / 0.08), 0 4px 16px -4px oklch(0 0 0 / 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-soft-lg {
|
|
||||||
box-shadow: 0 4px 12px -2px oklch(0 0 0 / 0.1), 0 8px 24px -4px oklch(0 0 0 / 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status colors */
|
/* Status colors */
|
||||||
.status-success {
|
.status-success {
|
||||||
@@ -225,3 +239,32 @@ select:focus {
|
|||||||
.skeleton {
|
.skeleton {
|
||||||
@apply bg-muted animate-pulse rounded;
|
@apply bg-muted animate-pulse rounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Utilities */
|
||||||
|
.glass-panel {
|
||||||
|
@apply bg-background/60 backdrop-blur-2xl border-white/40 dark:border-white/10 shadow-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
@apply bg-card/80 backdrop-blur-xl border border-white/60 dark:border-white/10 shadow-soft transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
@apply shadow-soft-lg -translate-y-1 bg-card/90 border-white/80 dark:border-white/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Transitions */
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-38
@@ -145,9 +145,9 @@ function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; colla
|
|||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sidebar-foreground transition-all duration-200 hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground group',
|
'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sidebar-foreground transition-all duration-300 hover:bg-sidebar-accent/60 group relative overflow-hidden',
|
||||||
(open || hasActiveChild) && 'text-sidebar-accent-foreground font-medium',
|
(open || hasActiveChild) && 'text-primary font-medium bg-sidebar-accent/30',
|
||||||
level > 0 && 'text-[13px]'
|
level > 0 && 'text-[13px] py-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("transition-colors", (open || hasActiveChild) ? "text-primary" : "text-muted-foreground group-hover:text-primary")}>
|
<span className={cn("transition-colors", (open || hasActiveChild) ? "text-primary" : "text-muted-foreground group-hover:text-primary")}>
|
||||||
@@ -185,12 +185,17 @@ function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; colla
|
|||||||
end
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sidebar-foreground transition-all duration-200 hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground mb-0.5 group',
|
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sidebar-foreground transition-all duration-300 hover:bg-sidebar-accent/60 mb-1 group relative overflow-hidden',
|
||||||
isActive && 'bg-primary/10 text-primary font-medium shadow-none',
|
isActive && 'bg-primary/10 text-primary font-semibold',
|
||||||
level > 0 && 'text-[13px] py-1.5'
|
level > 0 && 'text-[13px] py-2'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* Active indicator bar */}
|
||||||
|
<span className={cn(
|
||||||
|
"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-0 bg-primary rounded-r-full transition-all duration-300",
|
||||||
|
location.pathname === item.href && "h-3/5"
|
||||||
|
)} />
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
({ isActive }: { isActive: boolean }) => isActive ? "text-primary" : "text-muted-foreground group-hover:text-primary"
|
({ isActive }: { isActive: boolean }) => isActive ? "text-primary" : "text-muted-foreground group-hover:text-primary"
|
||||||
@@ -200,8 +205,8 @@ function NavItemComponent({ item, collapsed, level = 0 }: { item: NavItem; colla
|
|||||||
|
|
||||||
{level > 0 && (
|
{level > 0 && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"w-1.5 h-1.5 rounded-full transition-all bg-current opacity-40 group-hover:opacity-100",
|
"w-1.5 h-1.5 rounded-full transition-all bg-current opacity-30 group-hover:opacity-70",
|
||||||
location.pathname === item.href && "opacity-100 scale-110 bg-primary"
|
location.pathname === item.href && "opacity-100 scale-110 bg-primary shadow-[0_0_8px_rgba(var(--color-primary),0.5)]"
|
||||||
)} />
|
)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -295,22 +300,22 @@ export default function AdminLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-background">
|
<div className="flex min-h-screen bg-background p-2 lg:p-3 gap-2 lg:gap-3">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-sidebar-border/50 bg-sidebar-background/80 backdrop-blur-md transition-all duration-300',
|
'fixed inset-y-2 lg:inset-y-3 left-2 lg:left-3 z-40 flex flex-col rounded-2xl border border-white/40 dark:border-white/5 glass-panel transition-all duration-300 overflow-hidden',
|
||||||
sidebarOpen ? 'w-64' : 'w-16',
|
sidebarOpen ? 'w-[260px]' : 'w-20',
|
||||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
mobileMenuOpen ? 'translate-x-0' : '-translate-x-[120%] lg:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex h-16 items-center justify-between border-b border-sidebar-border/40 px-4 shrink-0 bg-sidebar-background/50">
|
<div className="flex h-16 items-center justify-between px-5 shrink-0 bg-transparent">
|
||||||
<div className={cn("flex items-center gap-3 overflow-hidden transition-all", !sidebarOpen && "justify-center w-full")}>
|
<div className={cn("flex items-center gap-3 overflow-hidden transition-all", !sidebarOpen && "justify-center w-full")}>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm shadow-primary/20">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 text-white shadow-lg shadow-emerald-500/20">
|
||||||
<Leaf className="h-4 w-4" />
|
<Leaf className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
{sidebarOpen && <span className="font-semibold text-base tracking-tight text-sidebar-foreground truncate">植趣 Admin</span>}
|
{sidebarOpen && <span className="font-bold text-lg tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-emerald-600 to-teal-600 truncate">Sundynix Console</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -324,23 +329,24 @@ export default function AdminLayout() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
<div className="border-t border-sidebar-border/40 p-2 shrink-0 bg-sidebar-background/50">
|
<div className="p-3 shrink-0 bg-sidebar-background/50 border-t border-white/10 dark:border-white/5">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className={cn(
|
<button className={cn(
|
||||||
"flex w-full items-center gap-3 rounded-lg p-2 transition-all duration-200 hover:bg-sidebar-accent/50 outline-none",
|
"flex w-full items-center gap-3 rounded-xl p-2 transition-all duration-200 hover:bg-white/50 dark:hover:bg-white/5 outline-none ring-1 ring-transparent hover:ring-black/5 dark:hover:ring-white/10",
|
||||||
!sidebarOpen && "justify-center"
|
!sidebarOpen && "justify-center"
|
||||||
)}>
|
)}>
|
||||||
<Avatar className="h-8 w-8 ring-1 ring-sidebar-border/50 transition-transform group-hover:scale-105">
|
<Avatar className="h-9 w-9 ring-2 ring-white/80 dark:ring-white/10 shadow-sm transition-transform group-hover:scale-105">
|
||||||
<AvatarImage src={user?.avatar?.url} alt={user?.name || user?.account} />
|
<AvatarImage src={user?.avatar?.url} alt={user?.name || user?.account} />
|
||||||
<AvatarFallback className="bg-primary/5 text-primary text-xs font-medium">{(user?.name || user?.account)?.charAt(0).toUpperCase()}</AvatarFallback>
|
<AvatarFallback className="bg-emerald-100 text-emerald-700 text-xs font-bold">{(user?.name || user?.account)?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="flex-1 text-left overflow-hidden">
|
<div className="flex-1 text-left overflow-hidden">
|
||||||
<p className="text-sm font-medium text-sidebar-foreground truncate leading-none mb-1">{user?.name || user?.account}</p>
|
<p className="text-sm font-semibold text-sidebar-foreground truncate leading-none mb-1.5">{user?.name || user?.account}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground truncate leading-none">管理员</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sidebarOpen && <ChevronDown className="h-3 w-3 text-muted-foreground/70" />}
|
{sidebarOpen && <ChevronDown className="h-4 w-4 text-muted-foreground/50" />}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56" side="right" sideOffset={12}>
|
<DropdownMenuContent align="start" className="w-56" side="right" sideOffset={12}>
|
||||||
@@ -379,16 +385,16 @@ export default function AdminLayout() {
|
|||||||
|
|
||||||
{/* Main content wrapper */}
|
{/* Main content wrapper */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex-1 flex flex-col min-h-screen transition-all duration-300 ease-in-out',
|
'flex-1 flex flex-col min-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1.5rem)] transition-all duration-300 ease-in-out bg-white/40 dark:bg-slate-900/40 rounded-2xl border border-white/50 dark:border-white/5 shadow-soft overflow-hidden relative backdrop-blur-xl',
|
||||||
sidebarOpen ? 'lg:pl-64' : 'lg:pl-16'
|
sidebarOpen ? 'lg:ml-[268px]' : 'lg:ml-[88px]'
|
||||||
)}>
|
)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-border/40 bg-background/80 px-4 backdrop-blur-md lg:px-6">
|
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-white/30 dark:border-white/5 bg-white/30 dark:bg-black/10 backdrop-blur-md px-4 lg:px-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hidden lg:flex h-8 w-8"
|
className="hidden lg:flex h-8 w-8 rounded-full hover:bg-white/50 dark:hover:bg-white/10"
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
>
|
>
|
||||||
{sidebarOpen ? <ChevronLeft className="h-4 w-4 text-muted-foreground" /> : <Menu className="h-4 w-4 text-muted-foreground" />}
|
{sidebarOpen ? <ChevronLeft className="h-4 w-4 text-muted-foreground" /> : <Menu className="h-4 w-4 text-muted-foreground" />}
|
||||||
@@ -396,39 +402,41 @@ export default function AdminLayout() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="lg:hidden h-8 w-8"
|
className="lg:hidden h-8 w-8 rounded-full"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-3">
|
||||||
<div className="w-px h-4 bg-border/60 mx-1"></div>
|
<div className="w-px h-4 bg-border/60 mx-1"></div>
|
||||||
<Breadcrumb />
|
<Breadcrumb />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className="hidden md:flex relative group">
|
<div className="hidden md:flex relative group">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground group-focus-within:text-emerald-500 transition-colors" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索..."
|
placeholder="全局搜索..."
|
||||||
className="w-48 focus:w-64 pl-8 bg-muted/40 border-transparent focus:bg-background focus:border-primary/20 transition-all h-8 text-sm rounded-full shadow-sm"
|
className="w-56 focus:w-72 pl-9 bg-white/50 dark:bg-black/20 border-white/40 dark:border-white/10 focus:bg-white dark:focus:bg-slate-800 focus:border-emerald-500/30 transition-all h-9 text-sm rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="relative rounded-full h-8 w-8 hover:bg-muted">
|
<Button variant="ghost" size="icon" className="relative rounded-full h-9 w-9 hover:bg-white/50 dark:hover:bg-white/10">
|
||||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="absolute top-2 right-2 w-1.5 h-1.5 bg-red-500 rounded-full ring-2 ring-background"></span>
|
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-red-500 rounded-full ring-2 ring-white dark:ring-slate-900"></span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="flex-1 p-4 lg:p-6 bg-muted/10">
|
<ScrollArea className="flex-1">
|
||||||
<div className="mx-auto w-full animate-fadeIn space-y-6">
|
<main className="p-4 lg:p-6 relative z-0 min-h-full">
|
||||||
<Outlet />
|
<div className="mx-auto w-full max-w-[1600px] animate-fade-in-up space-y-6">
|
||||||
</div>
|
<Outlet />
|
||||||
</main>
|
</div>
|
||||||
|
</main>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-10
@@ -37,8 +37,8 @@ export default function DashboardPage() {
|
|||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{stats.map((s, i) => (
|
{stats.map((s, i) => (
|
||||||
<div key={i} className="relative overflow-hidden rounded-2xl border border-border/50 bg-card p-5 shadow-soft hover:shadow-soft-lg transition-shadow">
|
<div key={i} className="relative overflow-hidden rounded-[1.25rem] bg-card p-5 shadow-soft hover:shadow-soft-lg transition-all duration-300 hover:-translate-y-1 group">
|
||||||
<div className={`absolute inset-0 bg-gradient-to-br ${s.from} to-transparent opacity-40 pointer-events-none`} />
|
<div className={`absolute inset-0 bg-gradient-to-br ${s.from} to-transparent opacity-20 group-hover:opacity-40 transition-opacity pointer-events-none`} />
|
||||||
<div className="relative flex items-start justify-between mb-4">
|
<div className="relative flex items-start justify-between mb-4">
|
||||||
<div className={`h-10 w-10 rounded-xl flex items-center justify-center shadow-sm ${s.iconBg}`}>
|
<div className={`h-10 w-10 rounded-xl flex items-center justify-center shadow-sm ${s.iconBg}`}>
|
||||||
<s.icon className="h-5 w-5" />
|
<s.icon className="h-5 w-5" />
|
||||||
@@ -60,8 +60,8 @@ export default function DashboardPage() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="grid gap-5 lg:grid-cols-2">
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
{/* Recent Topics */}
|
{/* Recent Topics */}
|
||||||
<div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
|
<div className="rounded-[1.25rem] bg-card shadow-soft overflow-hidden">
|
||||||
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
|
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/30 bg-muted/5">
|
||||||
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
<MessageSquare className="h-4 w-4 text-primary" />
|
<MessageSquare className="h-4 w-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
@@ -89,8 +89,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activities */}
|
{/* Activities */}
|
||||||
<div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
|
<div className="rounded-[1.25rem] bg-card shadow-soft overflow-hidden">
|
||||||
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
|
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/30 bg-muted/5">
|
||||||
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
<TrendingUp className="h-4 w-4 text-primary" />
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
@@ -118,8 +118,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plant categories */}
|
{/* Plant categories */}
|
||||||
<div className="rounded-2xl border border-border/50 bg-card shadow-soft overflow-hidden">
|
<div className="rounded-[1.25rem] bg-card shadow-soft overflow-hidden">
|
||||||
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/40">
|
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-border/30 bg-muted/5">
|
||||||
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
<div className="h-7 w-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
<Leaf className="h-4 w-4 text-primary" />
|
<Leaf className="h-4 w-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
@@ -128,8 +128,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
||||||
{mockCategories.map(cat => (
|
{mockCategories.map(cat => (
|
||||||
<div key={cat.id} className="flex items-center gap-3.5 rounded-xl border border-border/40 p-4 hover:bg-muted/30 hover:border-primary/20 transition-all cursor-pointer group">
|
<div key={cat.id} className="flex items-center gap-3.5 rounded-xl border border-transparent hover:border-border/60 p-4 hover:bg-muted/20 transition-all cursor-pointer group hover:shadow-sm">
|
||||||
<div className="h-11 w-11 rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl shrink-0">
|
<div className="h-11 w-11 rounded-xl bg-gradient-to-br from-primary/5 to-primary/10 flex items-center justify-center text-2xl shrink-0 group-hover:scale-105 transition-transform">
|
||||||
{cat.icon || '🌱'}
|
{cat.icon || '🌱'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+127
-107
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Leaf, Eye, EyeOff, RefreshCw, Loader2 } from 'lucide-react'
|
import { Leaf, Eye, EyeOff, Loader2, Disc3 } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { getCaptcha, login as apiLogin } from '@/api/system'
|
import { getCaptcha, login as apiLogin } from '@/api/system'
|
||||||
|
import LoginCharacters from '@/components/LoginCharacters'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -18,6 +18,7 @@ export default function LoginPage() {
|
|||||||
const [captchaId, setCaptchaId] = useState('')
|
const [captchaId, setCaptchaId] = useState('')
|
||||||
const [captchaImg, setCaptchaImg] = useState('')
|
const [captchaImg, setCaptchaImg] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [passwordFocused, setPasswordFocused] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -70,127 +71,146 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-green-50/50 to-teal-50 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-[#f3f4f6] p-6 relative">
|
||||||
{/* Background decoration */}
|
{/* Background pattern */}
|
||||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
<div className="absolute inset-0 z-0 opacity-[0.03] pointer-events-none" style={{
|
||||||
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-gradient-to-br from-primary/10 to-emerald-100/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4" />
|
backgroundImage: 'radial-gradient(circle, #000 1px, transparent 1px)',
|
||||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-gradient-to-tr from-teal-100/30 to-cyan-100/20 rounded-full blur-3xl translate-y-1/3 -translate-x-1/4" />
|
backgroundSize: '24px 24px'
|
||||||
<div className="absolute top-1/2 left-1/2 w-[300px] h-[300px] bg-gradient-to-r from-green-100/20 to-emerald-100/10 rounded-full blur-2xl -translate-x-1/2 -translate-y-1/2" />
|
}} />
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="relative w-full max-w-[400px] shadow-xl shadow-black/5 border-0 bg-white/70 backdrop-blur-xl">
|
<div className="relative z-10 w-full max-w-[1000px] grid grid-cols-1 lg:grid-cols-2 bg-white rounded-[2rem] shadow-2xl shadow-emerald-900/10 overflow-hidden ring-1 ring-black/5">
|
||||||
<CardHeader className="space-y-4 text-center pb-2 pt-8">
|
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-emerald-600 shadow-lg shadow-primary/30 transition-transform hover:scale-105">
|
{/* Left - Characters Banner */}
|
||||||
<Leaf className="h-7 w-7 text-white" />
|
<div className="relative hidden lg:flex flex-col justify-between bg-slate-800 p-10 text-white overflow-hidden">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="relative z-20 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||||
|
<Leaf className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tracking-tight">Sundynix Console</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<CardTitle className="text-xl font-semibold tracking-tight text-foreground">
|
{/* Characters Area */}
|
||||||
植趣ZeeQ
|
<div className="relative z-20 flex items-end justify-center mt-auto" style={{ height: 320 }}>
|
||||||
</CardTitle>
|
<LoginCharacters
|
||||||
<CardDescription className="text-muted-foreground text-sm">
|
isTyping={passwordFocused}
|
||||||
请登录您的管理员账号
|
showPassword={showPassword}
|
||||||
</CardDescription>
|
passwordLength={password.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-6 pb-8">
|
{/* Bottom */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<div className="relative z-20 mt-10 text-xs text-white/40 font-medium tracking-wide">
|
||||||
{error && (
|
© {new Date().getFullYear() > 2026 ? `2026-${new Date().getFullYear()}` : '2026'} 光衍矩阵 · SUNDYNIX TECH
|
||||||
<div className="rounded-xl bg-red-50 border border-red-100 px-4 py-3 text-sm text-red-600 flex items-center gap-2">
|
</div>
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
|
||||||
{error}
|
{/* Decorative Background for the left panel */}
|
||||||
|
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-emerald-500/10 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/4 pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] bg-teal-500/10 rounded-full blur-[60px] translate-y-1/4 -translate-x-1/4 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Login Form */}
|
||||||
|
<div className="flex flex-col items-center justify-center px-8 py-16 sm:px-12 bg-white relative">
|
||||||
|
<div className="w-full max-w-[360px]">
|
||||||
|
{/* Mobile logo */}
|
||||||
|
<div className="lg:hidden flex flex-col items-center justify-center gap-3 mb-10">
|
||||||
|
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||||
|
<Leaf className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="text-2xl font-bold tracking-tight text-slate-900">Sundynix Console</span>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="account" className="text-sm font-medium text-foreground">用户名</Label>
|
|
||||||
<Input
|
|
||||||
id="account"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
value={account}
|
|
||||||
onChange={e => setAccount(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
className="h-11 rounded-xl border-border/50 bg-white/60 placeholder:text-muted-foreground/60 focus:bg-white transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Header */}
|
||||||
<Label htmlFor="password" className="text-sm font-medium text-foreground">密码</Label>
|
<div className="text-center mb-10">
|
||||||
<div className="relative">
|
<h1 className="text-3xl font-bold tracking-tight mb-2 text-slate-900">
|
||||||
<Input
|
欢迎回来!
|
||||||
id="password"
|
</h1>
|
||||||
type={showPassword ? 'text' : 'password'}
|
<p className="text-slate-500 text-sm">请输入管理员账号与密码</p>
|
||||||
placeholder="请输入密码"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
className="h-11 rounded-xl border-border/50 bg-white/60 placeholder:text-muted-foreground/60 focus:bg-white transition-colors pr-11"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Form */}
|
||||||
<Label htmlFor="captcha" className="text-sm font-medium text-foreground">验证码</Label>
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="flex gap-3">
|
{error && (
|
||||||
|
<div className="rounded-xl bg-red-50 border border-red-100 px-4 py-3 text-sm text-red-600 flex items-center gap-2 animate-in slide-in-from-top-2">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold text-slate-700">
|
||||||
|
账号
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="captcha"
|
placeholder="请输入管理员账号"
|
||||||
placeholder="请输入验证码"
|
className="h-12 bg-slate-50/50 border-slate-200 focus:bg-white focus:border-emerald-500 focus:ring-emerald-500/20 text-slate-900 placeholder:text-slate-400 rounded-xl transition-all"
|
||||||
value={captcha}
|
value={account}
|
||||||
onChange={e => setCaptcha(e.target.value)}
|
onChange={(e) => setAccount(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 h-11 rounded-xl border-border/50 bg-white/60 placeholder:text-muted-foreground/60 focus:bg-white transition-colors"
|
|
||||||
/>
|
/>
|
||||||
<div
|
</div>
|
||||||
className="flex-shrink-0 h-11 w-36 rounded-xl border border-border/50 bg-white/80 overflow-hidden cursor-pointer relative group transition-all hover:border-border hover:shadow-sm"
|
|
||||||
onClick={fetchCaptcha}
|
<div className="space-y-2">
|
||||||
>
|
<Label className="text-sm font-semibold text-slate-700">
|
||||||
{captchaImg ? (
|
密码
|
||||||
<img
|
</Label>
|
||||||
src={captchaImg}
|
<div className="relative">
|
||||||
alt="验证码"
|
<Input
|
||||||
className="h-full w-full object-fill"
|
type={showPassword ? 'text' : 'password'}
|
||||||
/>
|
placeholder="••••••••"
|
||||||
) : (
|
className="h-12 pr-10 bg-slate-50/50 border-slate-200 focus:bg-white focus:border-emerald-500 focus:ring-emerald-500/20 text-slate-900 placeholder:text-slate-400 rounded-xl transition-all"
|
||||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
|
value={password}
|
||||||
<RefreshCw className="h-4 w-4" />
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</div>
|
onFocus={() => setPasswordFocused(true)}
|
||||||
)}
|
onBlur={() => setPasswordFocused(false)}
|
||||||
<div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-[1px]">
|
disabled={loading}
|
||||||
<RefreshCw className="h-4 w-4 text-foreground/70" />
|
/>
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
tabIndex={-1}>
|
||||||
|
{showPassword ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold text-slate-700">
|
||||||
|
验证码
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="验证码"
|
||||||
|
className="h-12 flex-1 bg-slate-50/50 border-slate-200 focus:bg-white focus:border-emerald-500 focus:ring-emerald-500/20 text-slate-900 placeholder:text-slate-400 rounded-xl transition-all"
|
||||||
|
value={captcha}
|
||||||
|
onChange={(e) => setCaptcha(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<div className="h-12 w-32 border border-slate-200 rounded-xl overflow-hidden cursor-pointer hover:border-slate-300 transition-colors flex items-center justify-center bg-white group relative shrink-0"
|
||||||
|
onClick={fetchCaptcha}>
|
||||||
|
{captchaImg ? (
|
||||||
|
<img src={captchaImg} alt="captcha" className="h-full w-full object-fill" />
|
||||||
|
) : (
|
||||||
|
<Disc3 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button type="submit" disabled={loading}
|
||||||
type="submit"
|
className="w-full h-12 mt-4 text-base font-bold bg-emerald-600 hover:bg-emerald-700 text-white border-0 shadow-lg shadow-emerald-600/20 rounded-xl transition-all duration-300">
|
||||||
className="w-full h-11 rounded-xl bg-gradient-to-r from-primary to-emerald-600 hover:from-primary/90 hover:to-emerald-600/90 shadow-md shadow-primary/20 font-medium transition-all hover:shadow-lg hover:shadow-primary/25"
|
{loading ? (
|
||||||
disabled={loading}
|
<span className="flex items-center gap-2">
|
||||||
>
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
{loading ? (
|
登录中...
|
||||||
<>
|
</span>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
) : '登 录'}
|
||||||
登录中...
|
</Button>
|
||||||
</>
|
</form>
|
||||||
) : (
|
|
||||||
'登录'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-5 border-t border-border/30">
|
|
||||||
<p className="text-xs text-center text-muted-foreground">
|
|
||||||
© {new Date().getFullYear() > 2026 ? `2026-${new Date().getFullYear()}` : '2026'} sundynix · 安全登录
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Bot, Plus, Pencil, Trash2, CheckCircle2,
|
Bot, Plus, Pencil, Trash2, CheckCircle2,
|
||||||
RefreshCw, Database, Cpu, ChevronDown, ChevronUp,
|
RefreshCw, Database, Cpu, ChevronDown, ChevronUp,
|
||||||
AlertTriangle, Loader2, Zap, Sparkles, Activity,
|
AlertTriangle, Loader2, Zap, Sparkles, Activity,
|
||||||
Server, Key, ExternalLink
|
Server, Key, ExternalLink, Shield
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -31,6 +31,7 @@ const defaultForm: Omit<SysAiConfig, 'id' | 'createdAt' | 'updatedAt'> = {
|
|||||||
embeddingApiUrl: 'http://localhost:11434/v1',
|
embeddingApiUrl: 'http://localhost:11434/v1',
|
||||||
embeddingApiKey: '',
|
embeddingApiKey: '',
|
||||||
embeddingModelName: 'bge-m3',
|
embeddingModelName: 'bge-m3',
|
||||||
|
dailyQueryLimit: 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -136,6 +137,14 @@ function ConfigCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Daily limit tag */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-4 px-3 py-1.5 rounded-lg bg-amber-50/60 border border-amber-100/80">
|
||||||
|
<Shield className="h-3 w-3 text-amber-500 shrink-0" />
|
||||||
|
<span className="text-[11px] font-medium text-amber-700">
|
||||||
|
每日限额: {cfg.dailyQueryLimit > 0 ? `${cfg.dailyQueryLimit} 次/人` : '不限'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Qdrant URL */}
|
{/* Qdrant URL */}
|
||||||
<div className="flex items-center gap-1.5 mb-4 px-2.5 py-1.5 rounded-lg bg-muted/40 border border-border/40">
|
<div className="flex items-center gap-1.5 mb-4 px-2.5 py-1.5 rounded-lg bg-muted/40 border border-border/40">
|
||||||
<Server className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
<Server className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
@@ -238,6 +247,7 @@ export default function AiConfigPage() {
|
|||||||
embeddingApiUrl: cfg.embeddingApiUrl,
|
embeddingApiUrl: cfg.embeddingApiUrl,
|
||||||
embeddingApiKey: cfg.embeddingApiKey,
|
embeddingApiKey: cfg.embeddingApiKey,
|
||||||
embeddingModelName: cfg.embeddingModelName,
|
embeddingModelName: cfg.embeddingModelName,
|
||||||
|
dailyQueryLimit: cfg.dailyQueryLimit ?? 20,
|
||||||
})
|
})
|
||||||
setSelectedConfig(cfg)
|
setSelectedConfig(cfg)
|
||||||
setIsEdit(true)
|
setIsEdit(true)
|
||||||
@@ -569,6 +579,28 @@ export default function AiConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Usage Limit Section ── */}
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<div className="rounded-xl border border-amber-200/60 bg-amber-50/30 p-4 space-y-4">
|
||||||
|
<SectionLabel
|
||||||
|
icon={<Shield className="h-3.5 w-3.5" />}
|
||||||
|
label="用量限制"
|
||||||
|
color="text-amber-600"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FieldRow label="每用户每日问答上限" hint="0 = 不限制">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.dailyQueryLimit}
|
||||||
|
onChange={e => f('dailyQueryLimit', Number(e.target.value))}
|
||||||
|
placeholder="20"
|
||||||
|
className="h-8 text-sm bg-white"
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="px-6 pb-6 pt-0">
|
<DialogFooter className="px-6 pb-6 pt-0">
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)} className="h-9">取消</Button>
|
<Button variant="outline" onClick={() => setDialogOpen(false)} className="h-9">取消</Button>
|
||||||
<Button onClick={handleSubmit} disabled={submitting} className="h-9 min-w-[96px]">
|
<Button onClick={handleSubmit} disabled={submitting} className="h-9 min-w-[96px]">
|
||||||
|
|||||||
Reference in New Issue
Block a user