Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
20e9d43c48
|
@ -46,7 +46,7 @@ export class GenDevService {
|
|||
try {
|
||||
await this.calculateCounts();
|
||||
await this.generateDepartments(3, 6);
|
||||
await this.generateTerms(2, 6);
|
||||
await this.generateTerms(1, 3);
|
||||
await this.generateStaffs(4);
|
||||
|
||||
} catch (err) {
|
||||
|
@ -60,7 +60,7 @@ export class GenDevService {
|
|||
this.logger.log(`${capitalizeFirstLetter(key)} count: ${value}`);
|
||||
});
|
||||
}
|
||||
private async generateTerms(depth: number = 2, count: number = 10) {
|
||||
private async generateTerms(depth: number = 1, count: number = 5) {
|
||||
if (this.counts.termCount === 0) {
|
||||
this.logger.log('Generate terms');
|
||||
await this.createTerms(null, TaxonomySlug.CATEGORY, depth, count);
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
"@nice/client": "workspace:^",
|
||||
"@nice/common": "workspace:^",
|
||||
"@nice/iconer": "workspace:^",
|
||||
"@nice/theme": "workspace:^",
|
||||
"@nice/ui": "workspace:^",
|
||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
|
@ -70,6 +71,7 @@
|
|||
"superjson": "^2.2.1",
|
||||
"swiper": "^11.2.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"uuid": "^10.0.0",
|
||||
"yjs": "^13.6.20",
|
||||
"zod": "^3.23.8"
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
--ag-borders-input: solid 1px;
|
||||
--ag-border-color: var(--color-border-secondary);
|
||||
--ag-secondary-border-color: var(--color-border-secondary);
|
||||
--ag-secondary-foreground-color: var(--color-text-tertiary);
|
||||
--ag-secondary-foreground-color: var(--color-text-tertiary-300);
|
||||
/* --ag-border-radius: 2px; */
|
||||
--ag-header-column-separator-display: block;
|
||||
--ag-header-column-separator-height: 30%;
|
||||
|
|
|
@ -4,14 +4,13 @@ import "./App.css";
|
|||
import { RouterProvider } from "react-router-dom";
|
||||
import QueryProvider from "./providers/query-provider";
|
||||
import { router } from "./routes";
|
||||
import ThemeProvider from "./providers/theme-provider";
|
||||
import { App as AntdApp, ConfigProvider, theme } from "antd";
|
||||
import locale from "antd/locale/zh_CN";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { AuthProvider } from './providers/auth-provider';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
import {ThemeProvider} from "@nice/theme"
|
||||
dayjs.locale("zh-cn");
|
||||
function App() {
|
||||
|
||||
|
|
|
@ -1,68 +1,69 @@
|
|||
import { Form, Input, Button } from "antd";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const LoginForm = ({ form, onSubmit, isLoading }) => {
|
||||
const { register, handleSubmit, formState: { errors } } = form;
|
||||
export interface LoginFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: (data: LoginFormData) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const LoginForm = ({ onSubmit, isLoading }: LoginFormProps) => {
|
||||
const [form] = Form.useForm<LoginFormData>();
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-8">
|
||||
Sign In
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "Username must be at least 2 characters"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Username"
|
||||
/>
|
||||
{errors.username && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.username.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
{...register("password", {
|
||||
required: "Password is required"
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.password.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 transition-colors duration-300
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</motion.form>
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: "Username is required" },
|
||||
{ min: 2, message: "Username must be at least 2 characters" }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: "Password is required" }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Password"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
className="w-full h-10 rounded-lg"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,52 +1,30 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { RegisterForm } from "./register";
|
||||
import { LoginForm } from "./login";
|
||||
import { LoginFormInputs, RegisterFormInputs } from "./types";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { Card, Typography, Button, Spin, Divider } from "antd";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useAuthForm } from "./useAuthForm";
|
||||
import {
|
||||
GithubOutlined,
|
||||
GoogleOutlined,
|
||||
LoadingOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const AuthPage: React.FC = () => {
|
||||
const [showLogin, setShowLogin] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login, isAuthenticated, signup } = useAuth();
|
||||
const {
|
||||
showLogin,
|
||||
isLoading,
|
||||
toggleForm,
|
||||
handleLogin,
|
||||
handleRegister
|
||||
} = useAuthForm();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const loginForm = useForm<LoginFormInputs>({
|
||||
mode: "onChange"
|
||||
});
|
||||
const registerForm = useForm<RegisterFormInputs>({
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
const onSubmitLogin = async (data: LoginFormInputs) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log(data)
|
||||
await login(data.username, data.password);
|
||||
toast.success("Welcome back!");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "Invalid credentials");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitRegister = async (data: RegisterFormInputs) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signup(data);
|
||||
toast.success("Registration successful!");
|
||||
setShowLogin(true);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
|
@ -57,88 +35,110 @@ const AuthPage: React.FC = () => {
|
|||
}, [isAuthenticated, location]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#1B2735] to-[#090A0F] flex items-center justify-center p-4">
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-5xl bg-[#1C2C41]/90 backdrop-blur-xl rounded-lg shadow-2xl overflow-hidden border border-[#2A4562]"
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
{/* Left Panel */}
|
||||
<motion.div
|
||||
className="md:w-1/2 p-8 lg:p-12 text-white relative"
|
||||
initial={false}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="space-y-8"
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
key={showLogin ? "login" : "register"}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Logo Section */}
|
||||
<div className="relative">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<img
|
||||
src="/usaf-logo.svg"
|
||||
alt="United States Air Force Logo"
|
||||
className="w-28 h-28 mx-auto filter drop-shadow-lg"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#1B2735]/40 to-transparent rounded-full blur-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title Section */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-center tracking-tight">
|
||||
<span className="bg-gradient-to-r from-[#E6E9F0] to-[#B4C3D8] bg-clip-text text-transparent">
|
||||
USAF Leadership Portal
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-[#A3B8D3] text-center text-base lg:text-lg font-medium">
|
||||
{showLogin
|
||||
? "Access your secure USAF portal"
|
||||
: "Create your authorized account"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Switch Form Button */}
|
||||
<Button variant="soft-primary" fullWidth
|
||||
onClick={() => setShowLogin(!showLogin)}
|
||||
aria-label={showLogin ? "Switch to registration form" : "Switch to login form"}
|
||||
<Card
|
||||
className="w-full max-w-5xl shadow-xl rounded-3xl overflow-hidden transition-all duration-300 hover:shadow-3xl relative backdrop-blur-sm bg-white/90"
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row min-h-[650px]">
|
||||
{/* Left Panel - Welcome Section */}
|
||||
<div className="w-full md:w-1/2 p-12 bg-gradient-to-br from-primary-500 to-primary-100 text-white flex flex-col justify-center relative overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
>
|
||||
{showLogin ? "New User Registration" : "Return to Login"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div className=" text-4xl text-white mb-4">
|
||||
欢迎来到 Leader Mail
|
||||
</div>
|
||||
<Paragraph className="text-lg mb-8 text-blue-100">
|
||||
与领导者建立联系,分享见解,在专为职业发展设计的协作环境中共同成长。
|
||||
</Paragraph>
|
||||
{showLogin && (
|
||||
<Button
|
||||
type="default"
|
||||
ghost
|
||||
size="large"
|
||||
onClick={toggleForm}
|
||||
className="w-fit hover:bg-white hover:text-blue-700 transition-all"
|
||||
>
|
||||
创建新账户
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Forms */}
|
||||
<div className="md:w-1/2 bg-[#1C2C41]/30 p-8 lg:p-12 backdrop-blur-sm relative">
|
||||
<AnimatePresence mode="wait">
|
||||
{showLogin ? (
|
||||
<LoginForm
|
||||
form={loginForm}
|
||||
onSubmit={onSubmitLogin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
form={registerForm}
|
||||
onSubmit={onSubmitRegister}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Right Panel - Form Section */}
|
||||
<div className="w-full md:w-1/2 p-10 lg:p-16 bg-white relative rounded-xl">
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{showLogin ? (
|
||||
<motion.div
|
||||
key="login"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div className="mb-8">
|
||||
<Title level={3} className="mb-2">登录</Title>
|
||||
<Text type="secondary">
|
||||
还没有账户?{' '}
|
||||
<Button
|
||||
type="link"
|
||||
onClick={toggleForm}
|
||||
className="p-0 font-medium"
|
||||
>
|
||||
立即注册
|
||||
</Button>
|
||||
</Text>
|
||||
</motion.div>
|
||||
<LoginForm
|
||||
onSubmit={handleLogin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="register"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div className="mb-8">
|
||||
<Title level={3} className="mb-2">创建账户</Title>
|
||||
<Text type="secondary">
|
||||
已有账户?{' '}
|
||||
<Button
|
||||
type="link"
|
||||
onClick={toggleForm}
|
||||
className="p-0 font-medium"
|
||||
>
|
||||
立即登录
|
||||
</Button>
|
||||
</Text>
|
||||
</motion.div>
|
||||
<RegisterForm
|
||||
onSubmit={handleRegister}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,203 +1,131 @@
|
|||
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
||||
import { Form, Input, Button, Select } from "antd";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
// RegisterForm.tsx
|
||||
export const RegisterForm = ({ form, onSubmit, isLoading }) => {
|
||||
const { register, handleSubmit, formState: { errors }, watch } = form;
|
||||
const password = watch("password");
|
||||
export interface RegisterFormData {
|
||||
deptId: string;
|
||||
username: string;
|
||||
showname: string;
|
||||
officerId: string;
|
||||
password: string;
|
||||
repeatPass: string;
|
||||
}
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSubmit: (data: RegisterFormData) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
|
||||
const [form] = Form.useForm<RegisterFormData>();
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-8">
|
||||
Create Account
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Department Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Department
|
||||
</label>
|
||||
<select
|
||||
{...register("deptId", { required: "Department is required" })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
>
|
||||
<option value="">Select Department</option>
|
||||
<option value="AF-HQ">Air Force Headquarters</option>
|
||||
<option value="AF-OPS">Operations Command</option>
|
||||
<option value="AF-LOG">Logistics Command</option>
|
||||
<option value="AF-TRN">Training Command</option>
|
||||
</select>
|
||||
{errors.deptId && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.deptId.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Information Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "Username must be at least 2 characters"
|
||||
},
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9._-]+$/,
|
||||
message: "Username can only contain letters, numbers, and ._-"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Username"
|
||||
/>
|
||||
{errors.username && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.username.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
{...register("showname", {
|
||||
required: "Full name is required",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "Name must be at least 2 characters"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Full Name"
|
||||
/>
|
||||
{errors.showname && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.showname.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Service ID
|
||||
</label>
|
||||
<input
|
||||
{...register("officerId", {
|
||||
required: "Service ID is required",
|
||||
pattern: {
|
||||
value: /^\d{5,12}$/,
|
||||
message: "Please enter a valid Service ID (5-12 digits)"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Service ID"
|
||||
/>
|
||||
{errors.officerId && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.officerId.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters"
|
||||
},
|
||||
pattern: {
|
||||
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
message: "Password must contain uppercase, lowercase, number and special character"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.password.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register("repeatPass", {
|
||||
required: "Please confirm your password",
|
||||
validate: value => value === password || "Passwords do not match"
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Confirm Password"
|
||||
/>
|
||||
{errors.repeatPass && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.repeatPass.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 transition-colors duration-300
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center space-x-2"
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
scrollToFirstError
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Creating Account...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Create Account</span>
|
||||
)}
|
||||
</button>
|
||||
<Form.Item
|
||||
name="deptId"
|
||||
label="部门"
|
||||
rules={[
|
||||
{ required: true, message: "请选择部门" }
|
||||
]}
|
||||
>
|
||||
<DepartmentSelect></DepartmentSelect>
|
||||
|
||||
</motion.form>
|
||||
</Form.Item>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{ required: true, message: "请输入用户名" },
|
||||
{ min: 2, message: "用户名至少需要2个字符" }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="showname"
|
||||
label="姓名"
|
||||
rules={[
|
||||
{ required: true, message: "请输入姓名" },
|
||||
{ min: 2, message: "姓名至少需要2个字符" }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="officerId"
|
||||
label="工号"
|
||||
rules={[
|
||||
{ required: true, message: "请输入工号" },
|
||||
{
|
||||
pattern: /^\d{5,12}$/,
|
||||
message: "请输入有效的工号(5-12位数字)"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input placeholder="工号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: "请输入密码" },
|
||||
{ min: 8, message: "密码至少需要8个字符" },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
message: "密码必须包含大小写字母、数字和特殊字符"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="repeatPass"
|
||||
label="确认密码"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: "请确认密码" },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="确认密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
className="w-full h-10 rounded-lg"
|
||||
>
|
||||
{isLoading ? "正在创建账户..." : "创建账户"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
export interface LoginFormInputs {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
export interface RegisterFormInputs extends LoginFormInputs {
|
||||
deptId: string;
|
||||
officerId: string;
|
||||
showname: string;
|
||||
repeatPass: string;
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import type { LoginFormData } from "./login";
|
||||
import type { RegisterFormData } from "./register";
|
||||
|
||||
export const useAuthForm = () => {
|
||||
const [showLogin, setShowLogin] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login, signup } = useAuth();
|
||||
|
||||
const toggleForm = () => setShowLogin(!showLogin);
|
||||
|
||||
const handleLogin = async (data: LoginFormData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await login(data.username, data.password);
|
||||
toast.success("登录成功!");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "用户名或密码错误");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (data: RegisterFormData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signup(data);
|
||||
toast.success("注册成功!");
|
||||
setShowLogin(true);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "注册失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showLogin,
|
||||
isLoading,
|
||||
toggleForm,
|
||||
handleLogin,
|
||||
handleRegister
|
||||
};
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import { Empty } from "antd";
|
||||
|
||||
export default function DeniedPage() {
|
||||
return <div className="pt-48 flex justify-center items-center text-tertiary">
|
||||
return <div className="pt-48 flex justify-center items-center text-tertiary-300">
|
||||
<Empty description='您无权访问此页面'></Empty>
|
||||
</div>
|
||||
}
|
|
@ -5,7 +5,7 @@ export default function ErrorPage() {
|
|||
return <div className=" flex justify-center items-center pt-64 ">
|
||||
<div className=" flex flex-col gap-4">
|
||||
<div className=" text-xl font-bold text-primary">哦?页面似乎出错了...</div>
|
||||
<div className=" text-tertiary" >{error?.statusText || error?.message}</div>
|
||||
<div className=" text-tertiary-300" >{error?.statusText || error?.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
|
@ -1,387 +0,0 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../providers/auth-provider";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
interface LoginFormInputs {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
interface RegisterFormInputs extends LoginFormInputs {
|
||||
deptId: string;
|
||||
officerId: string;
|
||||
showname: string;
|
||||
repeatPass: string;
|
||||
}
|
||||
const LoginPage: React.FC = () => {
|
||||
const [showLogin, setShowLogin] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login, isAuthenticated, signup } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const loginForm = useForm<LoginFormInputs>({
|
||||
mode: "onChange"
|
||||
});
|
||||
const registerForm = useForm<RegisterFormInputs>({
|
||||
mode: "onChange"
|
||||
});
|
||||
const onSubmitLogin = async (data: LoginFormInputs) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await login(data.username, data.password);
|
||||
toast.success("Welcome back!");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "Invalid credentials");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
const onSubmitRegister = async (data: RegisterFormInputs) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signup(data);
|
||||
toast.success("Registration successful!");
|
||||
setShowLogin(true);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const redirectUrl = params.get("redirect_url") || "/";
|
||||
navigate(redirectUrl, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, location]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-4xl bg-white/10rounded-2xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
<motion.div
|
||||
className="md:w-1/2 p-8 text-white"
|
||||
animate={{ x: showLogin ? 0 : -50 }}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<img
|
||||
src="/usaf-logo.svg"
|
||||
alt="USAF Logo"
|
||||
className="w-20 h-20 mx-auto"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold text-center">
|
||||
USAF Leadership Portal
|
||||
</h1>
|
||||
<p className="text-gray-300 text-center">
|
||||
{showLogin ? "Need an account?" : "Already registered?"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowLogin(!showLogin)}
|
||||
className="w-full py-2 px-4 border border-white/30 rounded-lg
|
||||
hover:bg-white/10 transition-all duration-300"
|
||||
>
|
||||
{showLogin ? "Register" : "Back to Login"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right Panel */}
|
||||
<div className="md:w-1/2 bg-white/5 p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{showLogin ? (
|
||||
<LoginForm
|
||||
form={loginForm}
|
||||
onSubmit={onSubmitLogin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
form={registerForm}
|
||||
onSubmit={onSubmitRegister}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// RegisterForm.tsx
|
||||
const RegisterForm = ({ form, onSubmit, isLoading }) => {
|
||||
const { register, handleSubmit, formState: { errors }, watch } = form;
|
||||
const password = watch("password");
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-8">
|
||||
Create Account
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Department Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Department
|
||||
</label>
|
||||
<select
|
||||
{...register("deptId", { required: "Department is required" })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
>
|
||||
<option value="">Select Department</option>
|
||||
<option value="AF-HQ">Air Force Headquarters</option>
|
||||
<option value="AF-OPS">Operations Command</option>
|
||||
<option value="AF-LOG">Logistics Command</option>
|
||||
<option value="AF-TRN">Training Command</option>
|
||||
</select>
|
||||
{errors.deptId && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.deptId.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Information Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "Username must be at least 2 characters"
|
||||
},
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9._-]+$/,
|
||||
message: "Username can only contain letters, numbers, and ._-"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Username"
|
||||
/>
|
||||
{errors.username && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.username.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
{...register("showname", {
|
||||
required: "Full name is required",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "Name must be at least 2 characters"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Full Name"
|
||||
/>
|
||||
{errors.showname && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.showname.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Service ID
|
||||
</label>
|
||||
<input
|
||||
{...register("officerId", {
|
||||
required: "Service ID is required",
|
||||
pattern: {
|
||||
value: /^\d{5,12}$/,
|
||||
message: "Please enter a valid Service ID (5-12 digits)"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Service ID"
|
||||
/>
|
||||
{errors.officerId && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.officerId.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters"
|
||||
},
|
||||
pattern: {
|
||||
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
message: "Password must contain uppercase, lowercase, number and special character"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.password.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register("repeatPass", {
|
||||
required: "Please confirm your password",
|
||||
validate: value => value === password || "Passwords do not match"
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Confirm Password"
|
||||
/>
|
||||
{errors.repeatPass && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.repeatPass.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 transition-colors duration-300
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center space-x-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Creating Account...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Create Account</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
</motion.form>
|
||||
);
|
||||
};
|
||||
const LoginForm = ({ form, onSubmit, isLoading }) => {
|
||||
const { register, handleSubmit, formState: { errors } } = form;
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-8">
|
||||
Sign In
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "Username must be at least 2 characters"
|
||||
}
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Username"
|
||||
/>
|
||||
{errors.username && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.username.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
{...register("password", {
|
||||
required: "Password is required"
|
||||
})}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-gray-600
|
||||
rounded-lg focus:ring-2 focus:ring-blue-500
|
||||
text-white placeholder-gray-400"
|
||||
placeholder="Password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<span className="text-red-400 text-sm mt-1">
|
||||
{errors.password.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 transition-colors duration-300
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</motion.form>
|
||||
);
|
||||
};
|
||||
export default LoginPage;
|
|
@ -1,20 +1,40 @@
|
|||
import { SearchFilters } from "./SearchFilter";
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="bg-[#00308F] bg-gradient-to-r from-[#00308F] to-[#0353A4] p-6">
|
||||
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
||||
<div className="mt-4 text-blue-50">
|
||||
<p className="text-base opacity-90">
|
||||
服务宗旨:畅通官兵诉求渠道 • 促进部队建设发展 • 提升单位战斗力
|
||||
</p>
|
||||
<div className="mt-2 text-sm opacity-80 flex gap-6">
|
||||
<span>实时跟踪反馈进度</span>
|
||||
<span>保障信息传递安全</span>
|
||||
<span>高效解决实际问题</span>
|
||||
</div>
|
||||
</div>
|
||||
<SearchFilters className="mt-4"></SearchFilters>
|
||||
</header>
|
||||
);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
|
||||
};
|
||||
|
||||
const handleFilterChange = () => {
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-gradient-to-r from-primary to-primary-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
||||
<div className="mt-4 text-blue-50">
|
||||
<p className="text-base opacity-90">
|
||||
服务宗旨:畅通诉求渠道 • 促进建设发展 • 提升单位战斗力
|
||||
</p>
|
||||
<div className="mt-2 text-sm opacity-80 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<span>实时跟踪反馈进度</span>
|
||||
<span>保障信息传递安全</span>
|
||||
<span>高效解决实际问题</span>
|
||||
</div>
|
||||
</div>
|
||||
<SearchFilters
|
||||
className="mt-4"
|
||||
searchTerm=""
|
||||
filterCategory={null}
|
||||
filterStatus={null}
|
||||
onSearchChange={handleSearch}
|
||||
onCategoryChange={handleFilterChange}
|
||||
onStatusChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,13 +15,13 @@ export function Pagination({
|
|||
}: PaginationProps) {
|
||||
const STYLE_CONFIG = {
|
||||
colors: {
|
||||
primary: 'bg-[#003875]', // USAF Blue
|
||||
primary: 'bg-primary', // USAF Blue
|
||||
hover: 'hover:bg-[#00264d]', // Darker USAF Blue
|
||||
disabled: 'bg-[#e6e6e6]',
|
||||
disabled: 'bg-disabled',
|
||||
text: {
|
||||
primary: 'text-[#003875]',
|
||||
primary: 'text-primary',
|
||||
light: 'text-white',
|
||||
secondary: 'text-[#4a4a4a]'
|
||||
secondary: 'text-tertiary-400'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
|
@ -38,7 +38,7 @@ export function Pagination({
|
|||
min-w-[2.5rem] h-10
|
||||
text-sm font-medium
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-[#003875] focus:ring-offset-2
|
||||
focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
||||
`
|
||||
}
|
||||
};
|
||||
|
@ -82,7 +82,7 @@ export function Pagination({
|
|||
className={`
|
||||
${STYLE_CONFIG.components.button}
|
||||
${STYLE_CONFIG.colors.text.secondary}
|
||||
bg-white
|
||||
bg-white
|
||||
`}
|
||||
>
|
||||
…
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Select, Spin } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
searchTerm: string;
|
||||
|
@ -7,8 +9,14 @@ interface SearchFiltersProps {
|
|||
onCategoryChange: (value: string) => void;
|
||||
filterStatus: string;
|
||||
onStatusChange: (value: string) => void;
|
||||
className?: string
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<Spin size="small" className="ml-2" />
|
||||
);
|
||||
|
||||
export function SearchFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
|
@ -16,69 +24,67 @@ export function SearchFilters({
|
|||
onCategoryChange,
|
||||
filterStatus,
|
||||
onStatusChange,
|
||||
className
|
||||
className,
|
||||
isLoading = false
|
||||
}: SearchFiltersProps) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 统一处理表单初始值
|
||||
const initialValues = {
|
||||
search: searchTerm,
|
||||
category: filterCategory,
|
||||
status: filterStatus
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(initialValues);
|
||||
}, [searchTerm, filterCategory, filterStatus, form]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row items-center gap-6 ${className}`}>
|
||||
<div className="flex-1 w-full">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-[#041E42] absolute left-4 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by keyword, sender, or unit..."
|
||||
className="w-full h-[46px] pl-12 pr-4 rounded-lg
|
||||
bg-white shadow-sm transition-all duration-200
|
||||
placeholder:text-gray-400
|
||||
focus:outline-none focus:border-[#00308F] focus:ring-2 focus:ring-[#00308F]/20"
|
||||
value={searchTerm}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className={className}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Form.Item name="search" noStyle>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索关键词、发件人或单位..."
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
suffix={isLoading ? <LoadingIndicator /> : null}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="category" noStyle>
|
||||
<Select
|
||||
className="w-full"
|
||||
onChange={onCategoryChange}
|
||||
options={[
|
||||
{ value: 'all', label: '所有分类' },
|
||||
{ value: 'complaint', label: '投诉' },
|
||||
{ value: 'suggestion', label: '建议' },
|
||||
{ value: 'request', label: '请求' },
|
||||
{ value: 'feedback', label: '反馈' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="status" noStyle>
|
||||
<Select
|
||||
className="w-full"
|
||||
onChange={onStatusChange}
|
||||
options={[
|
||||
{ value: 'all', label: '所有状态' },
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'in-progress', label: '处理中' },
|
||||
{ value: 'resolved', label: '已解决' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<FilterDropdowns
|
||||
filterCategory={filterCategory}
|
||||
onCategoryChange={onCategoryChange}
|
||||
filterStatus={filterStatus}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterDropdowns({
|
||||
filterCategory,
|
||||
onCategoryChange,
|
||||
filterStatus,
|
||||
onStatusChange,
|
||||
}: Pick<SearchFiltersProps, 'filterCategory' | 'onCategoryChange' | 'filterStatus' | 'onStatusChange'>) {
|
||||
const selectClassName = `min-w-[160px] h-[46px] px-4 rounded-lg
|
||||
bg-white shadow-sm transition-all duration-200
|
||||
text-[#041E42] font-medium
|
||||
focus:outline-none focus:border-[#00308F] focus:ring-2 focus:ring-[#00308F]/20
|
||||
hover:border-[#00308F]`;
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<select
|
||||
className={selectClassName}
|
||||
value={filterCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
<option value="complaint">Complaints</option>
|
||||
<option value="suggestion">Suggestions</option>
|
||||
<option value="request">Requests</option>
|
||||
<option value="feedback">Feedback</option>
|
||||
</select>
|
||||
<select
|
||||
className={selectClassName}
|
||||
value={filterStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in-progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,120 +1,175 @@
|
|||
import { useState } from 'react'
|
||||
import { Input, Button, Card, Steps, Tag, Spin, message } from 'antd'
|
||||
import { SearchOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
|
||||
interface FeedbackStatus {
|
||||
status: 'pending' | 'in-progress' | 'resolved'
|
||||
ticketId: string
|
||||
submittedDate: string
|
||||
lastUpdate: string
|
||||
title: string
|
||||
status: 'pending' | 'in-progress' | 'resolved'
|
||||
ticketId: string
|
||||
submittedDate: string
|
||||
lastUpdate: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const { Step } = Steps
|
||||
|
||||
export default function LetterProgressPage() {
|
||||
const [feedbackId, setFeedbackId] = useState('')
|
||||
const [status, setStatus] = useState<FeedbackStatus | null>(null)
|
||||
const [feedbackId, setFeedbackId] = useState('')
|
||||
const [status, setStatus] = useState<FeedbackStatus | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Mock data - In production this would come from an API
|
||||
const mockLookup = () => {
|
||||
setStatus({
|
||||
status: 'in-progress',
|
||||
ticketId: 'USAF-2025-0123',
|
||||
submittedDate: '2025-01-15',
|
||||
lastUpdate: '2025-01-21',
|
||||
title: 'Aircraft Maintenance Schedule Inquiry'
|
||||
})
|
||||
const validateInput = () => {
|
||||
if (!feedbackId.trim()) {
|
||||
setError('请输入有效的问题编号')
|
||||
return false
|
||||
}
|
||||
if (!/^USAF-\d{4}-\d{4}$/.test(feedbackId)) {
|
||||
setError('问题编号格式不正确,应为USAF-YYYY-NNNN')
|
||||
return false
|
||||
}
|
||||
setError('')
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-100 to-slate-200">
|
||||
{/* Header */}
|
||||
<header className="bg-[#00308F] bg-gradient-to-r from-[#00308F] to-[#0353A4] p-6 text-white">
|
||||
<h1 className="text-2xl font-semibold mb-3">
|
||||
USAF Feedback Progress Tracking
|
||||
</h1>
|
||||
<div className="text-lg opacity-90">
|
||||
<p>请输入您的问题编号以查询处理进度</p>
|
||||
</div>
|
||||
</header>
|
||||
const mockLookup = () => {
|
||||
if (!validateInput()) return
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Search Section */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<label
|
||||
htmlFor="feedbackId"
|
||||
className="block text-lg font-medium text-[#1F4E79]"
|
||||
>
|
||||
Enter Feedback ID
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
id="feedbackId"
|
||||
type="text"
|
||||
value={feedbackId}
|
||||
onChange={(e) => setFeedbackId(e.target.value)}
|
||||
placeholder="e.g. USAF-2025-0123"
|
||||
className="flex-1 p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-[#00538B] focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={mockLookup}
|
||||
className="px-6 py-3 bg-[#00538B] text-white rounded-md hover:bg-[#1F4E79] transition-colors duration-200"
|
||||
>
|
||||
Track
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Results Section */}
|
||||
{status && (
|
||||
<div className="bg-white rounded-lg p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<h2 className="text-xl font-semibold text-[#1F4E79]">
|
||||
Feedback Details
|
||||
</h2>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-[#00538B]">
|
||||
{status.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
setLoading(true)
|
||||
setTimeout(() => {
|
||||
setStatus({
|
||||
status: 'in-progress',
|
||||
ticketId: feedbackId,
|
||||
submittedDate: '2025-01-15',
|
||||
lastUpdate: '2025-01-21',
|
||||
title: 'Aircraft Maintenance Schedule Inquiry'
|
||||
})
|
||||
setLoading(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Ticket ID</p>
|
||||
<p className="font-medium">{status.ticketId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Submitted Date</p>
|
||||
<p className="font-medium">{status.submittedDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Last Update</p>
|
||||
<p className="font-medium">{status.lastUpdate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Title</p>
|
||||
<p className="font-medium">{status.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'orange'
|
||||
case 'in-progress':
|
||||
return 'blue'
|
||||
case 'resolved':
|
||||
return 'green'
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
{/* Progress Timeline */}
|
||||
<div className="mt-8">
|
||||
<div className="relative">
|
||||
<div className="absolute w-full h-1 bg-gray-200 top-4"></div>
|
||||
<div className="relative flex justify-between">
|
||||
{['Submitted', 'In Review', 'Resolved'].map((step, index) => (
|
||||
<div key={step} className="text-center">
|
||||
<div className={`w-8 h-8 mx-auto rounded-full flex items-center justify-center ${index <= 1 ? 'bg-[#00538B] text-white' : 'bg-gray-200'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium">{step}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
return (
|
||||
<div className="min-h-screen bg-white" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
{/* Header */}
|
||||
<header className="bg-[#003366] p-8 text-white">
|
||||
<div className="container mx-auto flex items-center">
|
||||
<SafetyCertificateOutlined className="text-4xl mr-4" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">USAF Feedback Tracking System</h1>
|
||||
<p className="text-lg">Enter your ticket ID to track progress</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Search Section */}
|
||||
<Card className="mb-8 border border-gray-200">
|
||||
<div className="space-y-4">
|
||||
<label className="block text-lg font-medium text-[#003366]">
|
||||
Ticket ID
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
prefix={<SearchOutlined className="text-[#003366]" />}
|
||||
size="large"
|
||||
value={feedbackId}
|
||||
onChange={(e) => setFeedbackId(e.target.value)}
|
||||
placeholder="e.g. USAF-2025-0123"
|
||||
status={error ? 'error' : ''}
|
||||
className="border border-gray-300"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SearchOutlined />}
|
||||
loading={loading}
|
||||
onClick={mockLookup}
|
||||
style={{ backgroundColor: '#003366', borderColor: '#003366' }}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
{status && (
|
||||
<Card>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<h2 className="text-xl font-semibold text-[#003366]">
|
||||
Ticket Details
|
||||
</h2>
|
||||
<Tag
|
||||
color={getStatusColor(status.status)}
|
||||
className="font-bold uppercase"
|
||||
>
|
||||
{status.status}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Ticket ID</p>
|
||||
<p className="font-medium text-[#003366]">{status.ticketId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Submitted Date</p>
|
||||
<p className="font-medium text-[#003366]">{status.submittedDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Update</p>
|
||||
<p className="font-medium text-[#003366]">{status.lastUpdate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Subject</p>
|
||||
<p className="font-medium text-[#003366]">{status.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Timeline */}
|
||||
<div className="mt-8">
|
||||
<Steps
|
||||
current={status.status === 'pending' ? 0 : status.status === 'in-progress' ? 1 : 2}
|
||||
className="usa-progress"
|
||||
>
|
||||
<Step
|
||||
title="Submitted"
|
||||
description="Ticket received"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="In Progress"
|
||||
description="Under review"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Resolved"
|
||||
description="Ticket completed"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Steps>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ export default function Filter() {
|
|||
|
||||
return <div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-tertiary-300 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or rank..."
|
||||
|
@ -24,7 +24,7 @@ export default function Filter() {
|
|||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-tertiary-300 w-5 h-5" />
|
||||
<select
|
||||
className="pl-10 pr-8 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-[#00308F] appearance-none bg-white"
|
||||
value={selectedDivision}
|
||||
|
|
|
@ -13,7 +13,7 @@ const QuillCharCounter: React.FC<QuillCharCounterProps> = ({
|
|||
const getStatusColor = () => {
|
||||
if (currentCount > (maxLength || Infinity)) return "text-red-500";
|
||||
if (currentCount < minLength) return "text-amber-500";
|
||||
return "text-gray-500";
|
||||
return "text-tertiary-300";
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -59,13 +59,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
info: 'bg-cyan-600 text-white hover:bg-cyan-700',
|
||||
light: 'bg-white text-gray-900 hover:bg-gray-50 border border-gray-200',
|
||||
dark: 'bg-gray-900 text-white hover:bg-gray-800',
|
||||
'soft-primary': 'bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||
'soft-secondary': 'bg-gray-50 text-gray-600 hover:bg-gray-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||
'soft-danger': 'bg-red-50 text-red-600 hover:bg-red-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||
'soft-success': 'bg-green-50 text-green-600 hover:bg-green-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||
'soft-warning': 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||
'soft-info': 'bg-cyan-50 text-cyan-600 hover:bg-cyan-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||
'ghost-primary': 'bg-transparent text-blue-600 hover:bg-blue-50',
|
||||
'soft-primary': 'bg-blue-50 text-primary-600 hover:bg-blue-100 disabled:bg-gray-50 disabled:text-tertiary-300',
|
||||
'soft-secondary': 'bg-gray-50 text-gray-600 hover:bg-gray-100 disabled:bg-gray-50 disabled:text-tertiary-300',
|
||||
'soft-danger': 'bg-red-50 text-red-600 hover:bg-red-100 disabled:bg-gray-50 disabled:text-tertiary-300',
|
||||
'soft-success': 'bg-green-50 text-green-600 hover:bg-green-100 disabled:bg-gray-50 disabled:text-tertiary-300',
|
||||
'soft-warning': 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100 disabled:bg-gray-50 disabled:text-tertiary-300',
|
||||
'soft-info': 'bg-cyan-50 text-cyan-600 hover:bg-cyan-100 disabled:bg-gray-50 disabled:text-tertiary-300',
|
||||
'ghost-primary': 'bg-transparent text-primary-600 hover:bg-blue-50',
|
||||
'ghost-secondary': 'bg-transparent text-gray-600 hover:bg-gray-50',
|
||||
'ghost-danger': 'bg-transparent text-red-600 hover:bg-red-50',
|
||||
'ghost-success': 'bg-transparent text-green-600 hover:bg-green-50',
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import React, { useState } from "react";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface InputProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"type"
|
||||
> {
|
||||
type?:
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
rows?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
type = "text",
|
||||
rows = 4,
|
||||
className,
|
||||
value = "",
|
||||
onChange,
|
||||
error,
|
||||
...restProps
|
||||
}: InputProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const inputClasses = `
|
||||
w-full rounded-lg border bg-white px-4 py-2 outline-none
|
||||
transition-all duration-200 ease-out placeholder:text-tertiary-400
|
||||
${error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200"
|
||||
: "border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
}
|
||||
${isFocused ? "ring-2 ring-opacity-50" : ""}
|
||||
${className || ""}
|
||||
`;
|
||||
|
||||
const InputElement = type === "textarea" ? "textarea" : "input";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<InputElement
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
type={type !== "textarea" ? type : undefined}
|
||||
rows={type === "textarea" ? rows : undefined}
|
||||
{...restProps}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
restProps.onBlur?.(e);
|
||||
}}
|
||||
className={inputClasses}
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center space-x-2">
|
||||
{value && isFocused && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-full text-tertiary-400 hover:text-gray-600 hover:bg-gray-100 transition-all duration-200 ease-out"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onChange?.({ target: { value: "" } } as any)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{value && !error && (
|
||||
<CheckIcon className="text-green-500 w-4 h-4 animate-fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -47,10 +47,10 @@ const LeaderCard: React.FC<LeaderCardProps> = ({
|
|||
<div className="space-y-3">
|
||||
{/* Name and Title */}
|
||||
<div className="transform transition-transform duration-300 group-hover:translate-x-2">
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-1 tracking-tight group-hover:text-blue-600 transition-colors">
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-1 tracking-tight group-hover:text-primary-600 transition-colors">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-base font-medium text-blue-600 tracking-wide group-hover:text-blue-500">
|
||||
<p className="text-base font-medium text-primary-600 tracking-wide group-hover:text-blue-500">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export interface TreeNode {
|
||||
id: string | number
|
||||
label: string
|
||||
children?: TreeNode[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface TreeSelectProps {
|
||||
data: TreeNode[]
|
||||
value?: string | number
|
||||
onChange?: (value: string | number) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
dropdownClassName?: string
|
||||
notFoundContent?: React.ReactNode
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function TreeSelect({
|
||||
data,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Please select...',
|
||||
disabled = false,
|
||||
className,
|
||||
dropdownClassName,
|
||||
notFoundContent = 'No data',
|
||||
loading = false,
|
||||
error,
|
||||
}: TreeSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string | number>>(new Set())
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedLabel, setSelectedLabel] = useState<string>('')
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Find and set selected label when value changes
|
||||
useEffect(() => {
|
||||
const findLabel = (nodes: TreeNode[]): string => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === value) return node.label
|
||||
if (node.children) {
|
||||
const childLabel = findLabel(node.children)
|
||||
if (childLabel) return childLabel
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
setSelectedLabel(findLabel(data))
|
||||
}, [value, data])
|
||||
|
||||
const toggleExpand = useCallback((key: string | number) => {
|
||||
setExpandedKeys(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback((node: TreeNode) => {
|
||||
if (node.disabled) return
|
||||
onChange?.(node.id)
|
||||
setIsOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const renderNode = useCallback((node: TreeNode, level = 0) => {
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isExpanded = expandedKeys.has(node.id)
|
||||
|
||||
return (
|
||||
<Fragment key={node.id}>
|
||||
<motion.div
|
||||
className={twMerge(
|
||||
'flex items-center px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
'transition-colors duration-150 ease-out',
|
||||
node.disabled && 'opacity-50 cursor-not-allowed',
|
||||
value === node.id && 'bg-blue-50 dark:bg-blue-900',
|
||||
level > 0 && 'pl-[calc(1rem*var(--level))]'
|
||||
)}
|
||||
style={{ '--level': level + 1 } as React.CSSProperties}
|
||||
onClick={() => !hasChildren && handleSelect(node)}
|
||||
role="option"
|
||||
aria-selected={value === node.id}
|
||||
tabIndex={0}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{hasChildren && (
|
||||
<motion.button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpand(node.id)
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-full mr-2"
|
||||
aria-expanded={isExpanded}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ rotate: isExpanded ? 90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)}
|
||||
{!hasChildren && <div className="w-6 mr-2" />}
|
||||
<span className="truncate">{node.label}</span>
|
||||
</motion.div>
|
||||
<AnimatePresence>
|
||||
{hasChildren && isExpanded && (
|
||||
<motion.div
|
||||
role="group"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{node.children.map(child => renderNode(child, level + 1))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Fragment>
|
||||
)
|
||||
}, [expandedKeys, handleSelect, toggleExpand, value])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={twMerge('relative', className)}>
|
||||
<div
|
||||
className={twMerge(
|
||||
'w-full rounded-lg border bg-white px-4 py-2',
|
||||
'transition-all duration-200 ease-out',
|
||||
'flex items-center justify-between cursor-pointer select-none',
|
||||
error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200'
|
||||
: 'border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
isOpen && !error && 'border-blue-500 ring-2 ring-blue-100'
|
||||
)}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
<span className={twMerge('truncate', !selectedLabel && 'text-tertiary-400')}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDownIcon className="h-5 w-5 text-tertiary-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className={twMerge(
|
||||
'absolute z-50 w-full mt-1 bg-white dark:bg-gray-800',
|
||||
'border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg',
|
||||
'max-h-60 overflow-auto',
|
||||
dropdownClassName
|
||||
)}
|
||||
role="listbox"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<motion.div
|
||||
className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
) : data.length > 0 ? (
|
||||
data.map(node => renderNode(node))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-tertiary-400 text-center">{notFoundContent}</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -13,7 +13,7 @@ interface ArrayFieldProps {
|
|||
}
|
||||
type ItemType = { id: string; value: string };
|
||||
const inputStyles =
|
||||
"w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm";
|
||||
"w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-tertiary-300 shadow-sm";
|
||||
const buttonStyles =
|
||||
"rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors ";
|
||||
export function FormArrayField({
|
||||
|
@ -93,10 +93,10 @@ export function FormArrayField({
|
|||
const newItems = items.map((i) =>
|
||||
i.id === item.id
|
||||
? {
|
||||
...i,
|
||||
value: e.target
|
||||
.value,
|
||||
}
|
||||
...i,
|
||||
value: e.target
|
||||
.value,
|
||||
}
|
||||
: i
|
||||
);
|
||||
updateItems(newItems);
|
||||
|
@ -108,7 +108,7 @@ export function FormArrayField({
|
|||
className={inputStyles}
|
||||
/>
|
||||
{inputProps.maxLength && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-gray-400">
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-tertiary-300">
|
||||
{inputProps.maxLength -
|
||||
(item.value?.length || 0)}
|
||||
</span>
|
||||
|
@ -123,7 +123,7 @@ export function FormArrayField({
|
|||
)
|
||||
)
|
||||
}
|
||||
className="absolute -right-10 p-2 text-gray-400 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100">
|
||||
className="absolute -right-10 p-2 text-tertiary-300 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
@ -139,7 +139,7 @@ export function FormArrayField({
|
|||
{ id: UUIDGenerator.generate(), value: "" },
|
||||
])
|
||||
}
|
||||
className={`${buttonStyles} text-blue-600 bg-blue-50 hover:bg-blue-100 focus:ring-blue-500`}>
|
||||
className={`${buttonStyles} text-primary-600 bg-blue-50 hover:bg-blue-100 focus:ring-blue-500`}>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{addButtonText}
|
||||
</button>
|
||||
|
|
|
@ -16,17 +16,17 @@ export interface DynamicFormInputProps
|
|||
subTitle?: string;
|
||||
label: string;
|
||||
type?:
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
|
@ -72,7 +72,7 @@ export function FormDynamicInputs({
|
|||
|
||||
const inputClasses = `
|
||||
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
|
||||
transition-all duration-300 ease-out placeholder:text-gray-400
|
||||
transition-all duration-300 ease-out placeholder:text-tertiary-300
|
||||
border-gray-300 focus:border-blue-500 focus:ring-blue-200
|
||||
`;
|
||||
|
||||
|
@ -83,7 +83,7 @@ export function FormDynamicInputs({
|
|||
{label}
|
||||
</label>
|
||||
{subTitle && (
|
||||
<label className="block text-sm font-normal text-gray-500">
|
||||
<label className="block text-sm font-normal text-tertiary-300">
|
||||
{subTitle}
|
||||
</label>
|
||||
)}
|
||||
|
@ -146,7 +146,7 @@ export function FormDynamicInputs({
|
|||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => append("")}
|
||||
className="flex items-center gap-1 text-blue-500 hover:text-blue-600
|
||||
className="flex items-center gap-1 text-blue-500 hover:text-primary-600
|
||||
transition-colors px-4 py-2 rounded-lg hover:bg-blue-50">
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
添加新{addTitle || label}
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { PencilIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "../element/Button";
|
||||
import FormError from "./FormError";
|
||||
|
||||
interface FormFieldProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
children: React.ReactElement;
|
||||
viewMode?: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
name,
|
||||
label,
|
||||
children,
|
||||
viewMode = false,
|
||||
maxLength,
|
||||
}: FormFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(!viewMode);
|
||||
const {
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
trigger,
|
||||
} = useFormContext();
|
||||
|
||||
const value = watch(name);
|
||||
const error = errors[name]?.message as string;
|
||||
|
||||
const handleChange = (newValue: any) => {
|
||||
setValue(name, newValue);
|
||||
};
|
||||
|
||||
const handleEditEnd = async () => {
|
||||
await trigger(name);
|
||||
if (viewMode) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const renderViewMode = () => (
|
||||
<div
|
||||
className="w-full text-gray-700 hover:text-primary-600 min-h-[48px] flex items-center gap-2 relative cursor-pointer group transition-all duration-200 ease-out select-none"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{value || <span className="text-tertiary-400">点击编辑</span>}
|
||||
</span>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<PencilIcon />}
|
||||
className="absolute -right-10 opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const enhancedChild = React.cloneElement(children, {
|
||||
value,
|
||||
onChange: (e: any) => {
|
||||
const newValue = e?.target?.value ?? e;
|
||||
handleChange(newValue);
|
||||
},
|
||||
onBlur: handleEditEnd,
|
||||
error,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && (
|
||||
<div className="flex justify-between items-center px-0.5">
|
||||
<label className="text-sm font-medium text-gray-700 transition-colors duration-200 group-focus-within:text-primary-600">
|
||||
{label}
|
||||
</label>
|
||||
{maxLength && typeof value === "string" && (
|
||||
<span className="text-sm text-tertiary-400">
|
||||
{value.length || 0}/{maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewMode && !isEditing ? renderViewMode() : enhancedChild}
|
||||
{error && !viewMode && <FormError error={error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,161 +0,0 @@
|
|||
import { useFormContext } from "react-hook-form";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { CheckIcon, PencilIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import FormError from "./FormError";
|
||||
import { Button } from "../element/Button";
|
||||
export interface FormInputProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"type"
|
||||
> {
|
||||
name: string;
|
||||
label?: string;
|
||||
type?:
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
rows?: number;
|
||||
viewMode?: boolean;
|
||||
}
|
||||
export function FormInput({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
rows = 4,
|
||||
className,
|
||||
viewMode = false, // 默认为编辑模式
|
||||
...restProps
|
||||
}: FormInputProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(!viewMode);
|
||||
const inputWrapper = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
trigger, // Add trigger from useFormContext
|
||||
} = useFormContext();
|
||||
const handleBlur = async () => {
|
||||
setIsFocused(false);
|
||||
await trigger(name); // Trigger validation for this field
|
||||
if (viewMode) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
const value = watch(name);
|
||||
const error = errors[name]?.message as string;
|
||||
const isValid = value && !error;
|
||||
const inputClasses = `
|
||||
w-full rounded-lg border bg-white px-4 py-2 outline-none
|
||||
transition-all duration-200 ease-out placeholder:text-gray-400
|
||||
${
|
||||
error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200"
|
||||
: "border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
}
|
||||
${isFocused ? "ring-2 ring-opacity-50" : ""}
|
||||
${className || ""}
|
||||
`;
|
||||
|
||||
const viewModeClasses = `
|
||||
w-full text-gray-700 hover:text-blue-600 min-h-[48px]
|
||||
flex items-center gap-2 relative cursor-pointer group
|
||||
transition-all duration-200 ease-out select-none
|
||||
`;
|
||||
|
||||
const InputElement = type === "textarea" ? "textarea" : "input";
|
||||
|
||||
const renderViewMode = () => (
|
||||
<div className={viewModeClasses} onClick={() => setIsEditing(true)}>
|
||||
<span className="font-medium ">
|
||||
{value || <span className="text-gray-400">点击编辑</span>}
|
||||
</span>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<PencilIcon />}
|
||||
className="absolute -right-10 opacity-0 group-hover:opacity-100 "></Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEditMode = () => (
|
||||
<div className="relative">
|
||||
<InputElement
|
||||
{...register(name)}
|
||||
type={type !== "textarea" ? type : undefined}
|
||||
rows={type === "textarea" ? rows : undefined}
|
||||
{...restProps}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
className={inputClasses}
|
||||
aria-label={label}
|
||||
autoFocus
|
||||
/>
|
||||
<div
|
||||
className="
|
||||
absolute right-3 top-1/2 -translate-y-1/2
|
||||
flex items-center space-x-2
|
||||
">
|
||||
{value && isFocused && (
|
||||
<button
|
||||
type="button"
|
||||
className="
|
||||
p-1.5 rounded-full text-gray-400
|
||||
hover:text-gray-600 hover:bg-gray-100
|
||||
transition-all duration-200 ease-out
|
||||
"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setValue(name, "")}
|
||||
aria-label={`清除${label}`}
|
||||
tabIndex={-1}>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{isValid && (
|
||||
<CheckIcon
|
||||
className="
|
||||
text-green-500 w-4 h-4
|
||||
animate-fade-in duration-200
|
||||
"
|
||||
/>
|
||||
)}
|
||||
{error && <FormError error={error} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div ref={inputWrapper} className="flex flex-col gap-2">
|
||||
{label && (
|
||||
<div className="flex justify-between items-center px-0.5">
|
||||
<label
|
||||
className="
|
||||
text-sm font-medium text-gray-700
|
||||
transition-colors duration-200
|
||||
group-focus-within:text-blue-600
|
||||
">
|
||||
{label}
|
||||
</label>
|
||||
{restProps.maxLength && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{value?.length || 0}/{restProps.maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewMode && !isEditing ? renderViewMode() : renderEditMode()}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -65,7 +65,7 @@ export function FormSelect({ name, label, options, placeholder = '请选择', cl
|
|||
? `ring-2 ring-opacity-50 ${error ? 'ring-red-200 border-red-500' : 'ring-blue-200 border-blue-500'}`
|
||||
: 'border-gray-300'
|
||||
}
|
||||
placeholder:text-gray-400
|
||||
placeholder:text-tertiary-300
|
||||
${className}
|
||||
`;
|
||||
return (
|
||||
|
@ -79,12 +79,12 @@ export function FormSelect({ name, label, options, placeholder = '请选择', cl
|
|||
className={containerClasses}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{selectedOption?.label || <span className="text-gray-400">{placeholder}</span>}
|
||||
{selectedOption?.label || <span className="text-tertiary-300">{placeholder}</span>}
|
||||
</div>
|
||||
|
||||
<ChevronUpDownIcon
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5
|
||||
text-gray-400 transition-transform duration-200
|
||||
text-tertiary-300 transition-transform duration-200
|
||||
${isOpen ? 'transform rotate-180' : ''}`}
|
||||
/>
|
||||
|
||||
|
@ -98,7 +98,7 @@ export function FormSelect({ name, label, options, placeholder = '请选择', cl
|
|||
<div
|
||||
key={option.value}
|
||||
className={`p-2 cursor-pointer flex items-center justify-between
|
||||
${value === option.value ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50'}`}
|
||||
${value === option.value ? 'bg-blue-50 text-primary-600' : 'hover:bg-gray-50'}`}
|
||||
onClick={() => {
|
||||
setValue(name, option.value, {
|
||||
shouldDirty: true,
|
||||
|
|
|
@ -15,17 +15,17 @@ export interface FormSignatureProps
|
|||
name: string;
|
||||
label?: string;
|
||||
type?:
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "password"
|
||||
| "email"
|
||||
| "number"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "search"
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime-local";
|
||||
viewMode?: boolean;
|
||||
width?: string; // 新添加的属性
|
||||
}
|
||||
|
@ -97,14 +97,14 @@ export function FormSignature({
|
|||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">
|
||||
<span className="text-sm text-tertiary-300">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
className="ml-2 text-gray-400">
|
||||
className="ml-2 text-tertiary-300">
|
||||
<PencilSquareIcon className="w-4 h-4" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
@ -127,7 +127,7 @@ export function FormSignature({
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
type="button"
|
||||
onClick={() => setValue(name, "")}
|
||||
className="p-1 rounded-full hover:bg-gray-200 text-gray-400
|
||||
className="p-1 rounded-full hover:bg-gray-200 text-tertiary-300
|
||||
hover:text-gray-600 transition-colors">
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</motion.button>
|
||||
|
|
|
@ -66,18 +66,17 @@ export function FormTags({
|
|||
|
||||
const inputClasses = `
|
||||
w-full rounded-lg border bg-white px-4 py-2 outline-none
|
||||
transition-all duration-200 ease-out placeholder:text-gray-400
|
||||
${
|
||||
error
|
||||
transition-all duration-200 ease-out placeholder:text-tertiary-300
|
||||
${error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200"
|
||||
: "border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
}
|
||||
}
|
||||
${isFocused ? "ring-2 ring-opacity-50" : ""}
|
||||
${className || ""}
|
||||
`;
|
||||
|
||||
const viewModeClasses = `
|
||||
w-full text-gray-700 hover:text-blue-600 min-h-[48px]
|
||||
w-full text-gray-700 hover:text-primary-600 min-h-[48px]
|
||||
flex items-center gap-2 relative cursor-pointer group
|
||||
transition-all duration-200 ease-out select-none
|
||||
`;
|
||||
|
@ -109,7 +108,7 @@ export function FormTags({
|
|||
{tags.length > 0 ? (
|
||||
renderTags()
|
||||
) : (
|
||||
<span className="text-gray-400">点击编辑标签</span>
|
||||
<span className="text-tertiary-300">点击编辑标签</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
|
@ -152,7 +151,7 @@ export function FormTags({
|
|||
<button
|
||||
type="button"
|
||||
className="
|
||||
p-1.5 rounded-full text-gray-400
|
||||
p-1.5 rounded-full text-tertiary-300
|
||||
hover:text-gray-600 hover:bg-gray-100
|
||||
transition-all duration-200 ease-out
|
||||
"
|
||||
|
@ -168,7 +167,7 @@ export function FormTags({
|
|||
{error && <FormError error={error} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-tertiary-300">
|
||||
{tags.length}/{maxTags} 个标签
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -44,7 +44,7 @@ const FileItem: React.FC<FileItemProps> = memo(
|
|||
onClick={() => onRemove(file.name)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
||||
aria-label={`Remove ${file.name}`}>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||
<XMarkIcon className="w-5 h-5 text-tertiary-300" />
|
||||
</button>
|
||||
</div>
|
||||
{!isUploaded && progress !== undefined && (
|
||||
|
@ -57,7 +57,7 @@ const FileItem: React.FC<FileItemProps> = memo(
|
|||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
<span className="text-xs text-tertiary-300 mt-1">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
@ -185,11 +185,10 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
relative flex flex-col items-center justify-center w-full h-32
|
||||
border-2 border-dashed rounded-lg cursor-pointer
|
||||
transition-colors duration-200 ease-in-out
|
||||
${
|
||||
isDragging
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300 hover:border-blue-500"
|
||||
}
|
||||
${isDragging
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300 hover:border-blue-500"
|
||||
}
|
||||
`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
@ -199,8 +198,8 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
accept={allowedTypes.join(",")}
|
||||
className="hidden"
|
||||
/>
|
||||
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
|
||||
<CloudArrowUpIcon className="w-10 h-10 text-tertiary-300" />
|
||||
<p className="mt-2 text-sm text-tertiary-300">{placeholder}</p>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
|
||||
<p className="text-blue-500 font-medium">
|
||||
|
|
|
@ -113,7 +113,7 @@ const FixedHeader: React.FC<FixedHeaderProps> = ({
|
|||
<Tooltip
|
||||
color="white"
|
||||
title={
|
||||
<span className="text-tertiary">
|
||||
<span className="text-tertiary-300">
|
||||
{value?.user.deptName && (
|
||||
<span className="mr-2 text-primary">{value?.user?.deptName}</span>
|
||||
)}
|
||||
|
|
|
@ -21,7 +21,7 @@ export function Footer() {
|
|||
drop-shadow-md">
|
||||
美国空军官方领导机关信箱
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
<p className="text-tertiary-300 text-sm">
|
||||
为美国空军提供安全可靠的通信服务
|
||||
</p>
|
||||
</div>
|
||||
|
@ -44,7 +44,7 @@ export function Footer() {
|
|||
<span>1-800-XXX-XXXX</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm italic">
|
||||
<p className="text-tertiary-300 text-sm italic">
|
||||
由软件小组提供 24/7 专业技术支持
|
||||
</p>
|
||||
</div>
|
||||
|
@ -57,7 +57,7 @@ export function Footer() {
|
|||
{/* Bottom Section */}
|
||||
<div className="text-center">
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2 mb-4 text-sm
|
||||
text-gray-400">
|
||||
text-tertiary-300">
|
||||
<span className="hover:text-gray-300 transition-colors duration-200">
|
||||
美国国防部授权网站
|
||||
</span>
|
||||
|
@ -68,7 +68,7 @@ export function Footer() {
|
|||
政府信息系统
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
<p className="text-tertiary-300 text-sm">
|
||||
© {new Date().getFullYear()} United States Air Force. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -4,35 +4,49 @@ import { memo } from "react";
|
|||
import { SearchBar } from "./SearchBar";
|
||||
import { Logo } from "./Logo";
|
||||
import Navigation from "./navigation";
|
||||
import { UserMenu} from "./usermenu";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
interface HeaderProps {
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
export const Header = memo(function Header({ onSearch }: HeaderProps) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-[#13294B] text-white shadow-lg">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Logo />
|
||||
<SearchBar onSearch={onSearch} />
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link
|
||||
to="/login"
|
||||
className="group flex items-center space-x-2 rounded-lg bg-[#00539B] px-5
|
||||
py-2.5 shadow-lg transition-all duration-300
|
||||
hover:-translate-y-0.5 hover:bg-[#0063B8]
|
||||
hover:shadow-[#00539B]/50 focus:outline-none
|
||||
focus:ring-2 focus:ring-[#8EADD4] focus:ring-offset-2"
|
||||
aria-label="Login"
|
||||
>
|
||||
<UserIcon className="h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<span className="font-medium">Login</span>
|
||||
</Link>
|
||||
<div className="flex-grow max-w-2xl">
|
||||
<SearchBar onSearch={onSearch} />
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
|
||||
{
|
||||
!isAuthenticated ? <Link
|
||||
to="/auth"
|
||||
className="group flex items-center gap-2 rounded-lg
|
||||
bg-[#00539B]/90 px-5 py-2.5 font-medium
|
||||
shadow-lg transition-all duration-300
|
||||
hover:-translate-y-0.5 hover:bg-[#0063B8]
|
||||
hover:shadow-xl hover:shadow-[#00539B]/30
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-[#8EADD4] focus:ring-offset-2
|
||||
focus:ring-offset-[#13294B]"
|
||||
aria-label="Login"
|
||||
>
|
||||
<UserIcon className="h-5 w-5 transition-transform
|
||||
group-hover:scale-110 group-hover:rotate-12" />
|
||||
<span>Login</span>
|
||||
</Link> : <UserMenu />
|
||||
|
||||
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Navigation></Navigation>
|
||||
<Navigation />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
});
|
|
@ -23,7 +23,7 @@ export function NotificationsPanel({ notificationItems }: NotificationsPanelProp
|
|||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
|
||||
<span className="text-sm text-blue-600 hover:text-blue-700 cursor-pointer">
|
||||
<span className="text-sm text-primary-600 hover:text-blue-700 cursor-pointer">
|
||||
Mark all as read
|
||||
</span>
|
||||
</div>
|
||||
|
@ -44,7 +44,7 @@ export function NotificationsPanel({ notificationItems }: NotificationsPanelProp
|
|||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900">{item.title}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-tertiary-300">
|
||||
<ClockIcon className='h-4 w-4'></ClockIcon>
|
||||
<span>{item.time}</span>
|
||||
</div>
|
||||
|
@ -55,7 +55,7 @@ export function NotificationsPanel({ notificationItems }: NotificationsPanelProp
|
|||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50">
|
||||
<button className="w-full text-sm text-center text-blue-600 hover:text-blue-700">
|
||||
<button className="w-full text-sm text-center text-primary-600 hover:text-blue-700">
|
||||
View all notifications
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -23,7 +23,7 @@ export function SearchBar({ recentSearches }: SearchBarProps) {
|
|||
: 'bg-gray-100 hover:bg-gray-200'
|
||||
}
|
||||
`}>
|
||||
<MagnifyingGlassIcon className="h-5 w-5 ml-3 text-gray-500" />
|
||||
<MagnifyingGlassIcon className="h-5 w-5 ml-3 text-tertiary-300" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for courses, topics, or instructors..."
|
||||
|
@ -40,7 +40,7 @@ export function SearchBar({ recentSearches }: SearchBarProps) {
|
|||
className="p-1.5 mr-2 rounded-full hover:bg-gray-200"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-gray-500" />
|
||||
<XMarkIcon className="h-4 w-4 text-tertiary-300" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -25,7 +25,7 @@ export function SearchDropdown({
|
|||
className="absolute top-12 left-4 right-4 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-3">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-2">Recent Searches</h3>
|
||||
<h3 className="text-xs font-medium text-tertiary-300 mb-2">Recent Searches</h3>
|
||||
<div className="space-y-1">
|
||||
{recentSearches.map((search, index) => (
|
||||
<motion.button
|
||||
|
@ -34,7 +34,7 @@ export function SearchDropdown({
|
|||
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-left"
|
||||
onClick={() => setSearchQuery(search)}
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-tertiary-300" />
|
||||
<span className="text-sm text-gray-700">{search}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { NavItem } from '@nice/client';
|
||||
|
||||
interface SidebarProps {
|
||||
navItems: Array<NavItem>;
|
||||
}
|
||||
|
||||
export function Sidebar({ navItems }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ x: -300, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -300, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.1, duration: 0.5 }}
|
||||
className="fixed left-0 top-16 bottom-0 w-64 bg-white border-r border-gray-200 z-40"
|
||||
>
|
||||
<div className="p-4 space-y-2">
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ x: 5 }}
|
||||
onClick={() => {
|
||||
navigate(item.path)
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full p-3 rounded-lg transition-colors
|
||||
${isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-blue-50 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
|
||||
import { NotificationsDropdown } from './notifications-dropdown';
|
||||
import { SearchBar } from './search-bar';
|
||||
import { UserMenuDropdown } from './usermenu-dropdown';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TopNavBarProps {
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
notifications: number;
|
||||
notificationItems: Array<any>;
|
||||
recentSearches: string[];
|
||||
}
|
||||
export function TopNavBar({
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
notifications,
|
||||
notificationItems,
|
||||
recentSearches
|
||||
}: TopNavBarProps) {
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 h-16 bg-white shadow-sm z-50">
|
||||
<div className="flex items-center justify-between h-full px-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<Bars3Icon className='w-5 h-5' />
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-slate-800 tracking-wide">
|
||||
fhmooc
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<SearchBar recentSearches={recentSearches} />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<NotificationsDropdown
|
||||
notifications={notifications}
|
||||
notificationItems={notificationItems}
|
||||
/>
|
||||
<UserMenuDropdown />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
|
@ -11,7 +11,6 @@ import { useMemo } from "react";
|
|||
// { to: "/write-letter", label: "举报投诉" },
|
||||
|
||||
// ] as const;
|
||||
|
||||
export function useNavItem() {
|
||||
const { data } = api.term.findMany.useQuery({
|
||||
where: {
|
||||
|
@ -20,19 +19,31 @@ export function useNavItem() {
|
|||
});
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
const defaultItems = [
|
||||
{ to: "/letter-list", label: "公开信件" },
|
||||
{ to: "/letter-progress", label: "进度查询" },
|
||||
{ to: "/help", label: "使用帮助" },
|
||||
// 定义固定的导航项
|
||||
const staticItems = {
|
||||
letterList: { to: "/letter-list", label: "公开信件" },
|
||||
letterProgress: { to: "/letter-progress", label: "进度查询" },
|
||||
help: { to: "/help", label: "使用帮助" }
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return [staticItems.letterList, staticItems.letterProgress, staticItems.help];
|
||||
}
|
||||
|
||||
// 构建分类导航项
|
||||
const categoryItems = data.map(term => ({
|
||||
to: `/write-letter?category=${term.id}`,
|
||||
label: term.name
|
||||
}));
|
||||
|
||||
// 按照指定顺序返回导航项
|
||||
return [
|
||||
staticItems.letterList,
|
||||
...categoryItems,
|
||||
staticItems.letterProgress,
|
||||
staticItems.help
|
||||
];
|
||||
|
||||
if (!data) return defaultItems;
|
||||
|
||||
return data.reduce((items, term) => {
|
||||
items.push({ to: `/write-letter?category=${term.id}`, label: term.name });
|
||||
return items;
|
||||
}, [{ to: "/letter-list", label: "公开信件" }].concat(defaultItems.slice(1)));
|
||||
}, [data]);
|
||||
|
||||
return { navItems };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeftStartOnRectangleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, UserCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '@web/src/providers/auth-provider';
|
||||
import { Avatar } from '../../presentation/user/Avatar';
|
||||
import { useClickOutside } from '@web/src/hooks/useClickOutside';
|
||||
|
||||
export function UserMenuDropdown() {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout } = useAuth()
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
const menuItems = [
|
||||
{ icon: <UserCircleIcon className='w-5 h-5'></UserCircleIcon>, label: '个人信息', action: () => { } },
|
||||
{ icon: <Cog6ToothIcon className='w-5 h-5'></Cog6ToothIcon>, label: '设置', action: () => { } },
|
||||
{ icon: <QuestionMarkCircleIcon className='w-5 h-5'></QuestionMarkCircleIcon>, label: '帮助', action: () => { } },
|
||||
{ icon: <ArrowLeftStartOnRectangleIcon className='w-5 h-5' />, label: '注销', action: () => { logout() } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="w-10 h-10" // 移除了边框相关的类
|
||||
>
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-gray-200 hover:ring-blue-500 transition-colors" // 使用 ring 替代 border
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-3 border-b border-gray-100">
|
||||
<h4 className="text-sm font-medium text-gray-900">{user?.showname}</h4>
|
||||
<p className="text-xs text-gray-500">{user?.username}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={item.action}
|
||||
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-gray-700 text-sm"
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
// ... existing imports ...
|
||||
|
||||
import { UserCircleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, ArrowLeftStartOnRectangleIcon } from "@heroicons/react/24/outline";
|
||||
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState, useRef } from "react";
|
||||
import { Avatar } from "../../common/element/Avatar";
|
||||
|
||||
export function UserMenu() {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout } = useAuth();
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
icon: <UserCircleIcon className="w-5 h-5" />,
|
||||
label: '个人信息',
|
||||
action: () => { },
|
||||
color: 'text-primary-600'
|
||||
},
|
||||
{
|
||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||
label: '设置',
|
||||
action: () => { },
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
icon: <QuestionMarkCircleIcon className="w-5 h-5" />,
|
||||
label: '帮助',
|
||||
action: () => { },
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
icon: <ArrowLeftStartOnRectangleIcon className="w-5 h-5" />,
|
||||
label: '注销',
|
||||
action: () => logout(),
|
||||
color: 'text-red-600'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="relative rounded-full focus:outline-none
|
||||
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
focus:ring-offset-[#13294B]"
|
||||
>
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white/80 hover:ring-blue-400
|
||||
transition-all duration-300"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 h-3 w-3
|
||||
rounded-full bg-green-500 ring-2 ring-white" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
}}
|
||||
style={{ zIndex: 100 }}
|
||||
className="absolute right-0 mt-2 w-56 origin-top-right
|
||||
bg-white rounded-xl shadow-lg ring-1 ring-black/5
|
||||
overflow-hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
{user?.showname}
|
||||
</h4>
|
||||
<p className="text-xs text-tertiary-300 mt-1">
|
||||
{user?.username}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ x: 4, backgroundColor: '#F3F4F6' }}
|
||||
onClick={item.action}
|
||||
className={`flex items-center gap-3 w-full p-2.5
|
||||
rounded-lg text-sm font-medium
|
||||
transition-colors duration-200
|
||||
${item.color} hover:bg-gray-100`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -15,7 +15,7 @@ export default function UserHeader() {
|
|||
style={{ color: token.colorTextLightSolid }}
|
||||
className="rounded flex items-center select-none justify-between">
|
||||
<div
|
||||
className="flex hover:bg-primaryHover rounded px-2 items-center gap-4 hover:cursor-pointer"
|
||||
className="flex hover:bg-blue-200 rounded px-2 items-center gap-4 hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
// if (user?.pilot?.id) {
|
||||
// navigate(`/pilots/${user?.pilot.id}`);
|
||||
|
|
|
@ -32,7 +32,7 @@ export const CourseHeader = ({
|
|||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 ">{title}</h3>
|
||||
{subTitle && <p className="mt-2 text-gray-600 ">{subTitle}</p>}
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 ">
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-tertiary-300 ">
|
||||
{level && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AcademicCapIcon className="h-4 w-4" />
|
||||
|
|
|
@ -22,7 +22,7 @@ export const CourseStats = ({
|
|||
<div className="font-semibold text-gray-900 ">
|
||||
{averageRating.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
<div className="text-xs text-tertiary-300 ">
|
||||
{numberOfReviews} 观看量
|
||||
</div>
|
||||
</div>
|
||||
|
@ -35,7 +35,7 @@ export const CourseStats = ({
|
|||
<div className="font-semibold text-gray-900 ">
|
||||
{completionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
<div className="text-xs text-tertiary-300 ">
|
||||
完成率
|
||||
</div>
|
||||
</div>
|
||||
|
@ -48,7 +48,7 @@ export const CourseStats = ({
|
|||
<div className="font-semibold text-gray-900 ">
|
||||
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
<div className="text-xs text-tertiary-300 ">
|
||||
总时长
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -23,12 +23,12 @@ export const LectureItem: React.FC<LectureItemProps> = ({
|
|||
<div className="flex-grow">
|
||||
<h4 className="font-medium text-gray-800">{lecture.title}</h4>
|
||||
{lecture.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-tertiary-300 mt-1">
|
||||
{lecture.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1 text-sm text-tertiary-300">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>{lecture.duration}分钟</span>
|
||||
</div>
|
||||
|
|
|
@ -32,7 +32,7 @@ export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
|||
<h3 className="text-left font-medium text-gray-900">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-tertiary-300">
|
||||
{section.totalLectures}节课 ·{" "}
|
||||
{Math.floor(section.totalDuration / 60)}分钟
|
||||
</p>
|
||||
|
@ -41,7 +41,7 @@ export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
|||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||
<ChevronDownIcon className="w-5 h-5 text-tertiary-300" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||
import { CourseLevel, CourseLevelLabel } from '@nice/common';
|
||||
import { FormInput } from '@web/src/components/common/form/FormInput';
|
||||
|
||||
import { FormSelect } from '@web/src/components/common/form/FormSelect';
|
||||
import { FormArrayField } from '@web/src/components/common/form/FormArrayField';
|
||||
import { convertToOptions } from '@nice/client';
|
||||
|
@ -12,8 +12,8 @@ export function CourseBasicForm() {
|
|||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput viewMode maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" />
|
||||
<FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" />
|
||||
{/* <FormInput viewMode maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" /> */}
|
||||
{/* <FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" /> */}
|
||||
<FormQuillInput maxLength={400} name="description" label="课程描述" placeholder="请输入课程描述"></FormQuillInput>
|
||||
|
||||
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
|
|
|
@ -7,10 +7,10 @@ import SectionFormList from "./SectionFormList";
|
|||
const CourseContentFormHeader = () =>
|
||||
<div className="mb-8 bg-blue-50 p-4 rounded-lg">
|
||||
<h2 className="text-xl font-semibold text-blue-800 mb-2">创建您的课程大纲</h2>
|
||||
<p className="text-blue-600">
|
||||
<p className="text-primary-600">
|
||||
通过组织清晰的章节和课时,帮助学员更好地学习。建议:
|
||||
</p>
|
||||
<ul className="mt-2 text-blue-600 list-disc list-inside">
|
||||
<ul className="mt-2 text-primary-600 list-disc list-inside">
|
||||
<li>将相关内容组织到章节中</li>
|
||||
<li>每个章节建议包含 3-7 个课时</li>
|
||||
<li>课时可以是视频、文章或测验</li>
|
||||
|
@ -18,7 +18,7 @@ const CourseContentFormHeader = () =>
|
|||
</div>
|
||||
const CourseSectionEmpty = () => (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<div className="text-gray-500">
|
||||
<div className="text-tertiary-300">
|
||||
<PlusIcon className="mx-auto h-12 w-12 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
||||
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { VideoCameraIcon, DocumentTextIcon, QuestionMarkCircleIcon, Bars3Icon, P
|
|||
import { Lecture } from "@nice/common";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
// import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
|
||||
import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
||||
import { useState } from "react";
|
||||
|
@ -102,7 +102,7 @@ export function LectureEditor({ lecture, onUpdate }: LectureEditorProps) {
|
|||
onClick={() => onUpdate({ ...lecture, type: key })}
|
||||
className={`flex items-center space-x-2 px-6 py-2.5 rounded-lg transition-all
|
||||
${lecture.type === key
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm ring-1 ring-blue-100'
|
||||
? 'bg-blue-50 text-primary-600 shadow-sm ring-1 ring-blue-100'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
|
|
@ -112,7 +112,7 @@ export function LectureFormList({ lectures, sectionId, onUpdate }: LectureFormLi
|
|||
|
||||
if (lectures.length === 0) {
|
||||
return (
|
||||
<div className="select-none flex items-center justify-center text-gray-500">
|
||||
<div className="select-none flex items-center justify-center text-tertiary-300">
|
||||
暂无课程内容
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Section, Lecture } from "@nice/common";
|
|||
import { useState } from "react";
|
||||
import { LectureFormList } from "./LectureFormList";
|
||||
import { cn } from "@web/src/utils/classname";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
// import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
interface SectionProps {
|
||||
|
@ -50,10 +50,10 @@ export function SectionFormItem({ section, index, onUpdate, onDelete }: SectionP
|
|||
|
||||
</Button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7
|
||||
bg-blue-50 text-blue-600 text-sm font-semibold rounded">
|
||||
bg-blue-50 text-primary-600 text-sm font-semibold rounded">
|
||||
{index + 1}
|
||||
</span>
|
||||
<FormInput viewMode name="title"></FormInput>
|
||||
{/* <FormInput viewMode name="title"></FormInput> */}
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ const courseStatusVariant: Record<CourseStatus, string> = {
|
|||
};
|
||||
export default function CourseEditorHeader() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
|
||||
|
||||
const { onSubmit, course } = useCourseEditor()
|
||||
|
@ -33,7 +33,7 @@ export default function CourseEditorHeader() {
|
|||
{course?.status ? CourseStatusLabel[course.status] : CourseStatusLabel[CourseStatus.DRAFT]}
|
||||
</Tag>
|
||||
{course?.totalDuration ? (
|
||||
<div className="hidden md:flex items-center text-gray-500 text-sm">
|
||||
<div className="hidden md:flex items-center text-tertiary-300 text-sm">
|
||||
<ClockIcon className="w-4 h-4 mr-1" />
|
||||
<span>总时长 {course?.totalDuration}</span>
|
||||
</div>
|
||||
|
|
|
@ -30,7 +30,7 @@ export default function CourseEditorSidebar({
|
|||
key={index}
|
||||
onClick={() => onNavigate(item, index)}
|
||||
className={`w-full flex ${!isHovered ? 'justify-center' : 'items-center'} px-5 py-2.5 mb-1 rounded-lg transition-all duration-200 ${selectedSection === index
|
||||
? "bg-blue-50 text-blue-600 shadow-sm"
|
||||
? "bg-blue-50 text-primary-600 shadow-sm"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
|
|
|
@ -120,16 +120,6 @@ export default function DepartmentSelect({
|
|||
try {
|
||||
const allKeyIds =
|
||||
keys.map((key) => key.toString()).filter(Boolean) || [];
|
||||
// const expandedNodes = await Promise.all(
|
||||
// keys.map(async (key) => {
|
||||
// return await utils.department.getChildSimpleTree.fetch({
|
||||
// deptId: key.toString(),
|
||||
// domain,
|
||||
// });
|
||||
// })
|
||||
// );
|
||||
//
|
||||
//上面那样一个个拉会拉爆,必须直接拉deptIds
|
||||
const expandedNodes =
|
||||
await utils.department.getChildSimpleTree.fetch({
|
||||
deptIds: allKeyIds,
|
||||
|
|
|
@ -4,7 +4,6 @@ import {
|
|||
LetterFormData,
|
||||
useLetterEditor,
|
||||
} from "../context/LetterEditorContext";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
|
||||
import { api } from "@nice/client";
|
||||
import {
|
||||
|
@ -92,11 +91,11 @@ export function LetterBasicForm() {
|
|||
<HashtagIcon className="w-5 h-5 inline mr-2 text-[#00308F]" />
|
||||
主题
|
||||
</label>
|
||||
<FormInput
|
||||
{/* <FormInput
|
||||
maxLength={20}
|
||||
name="title"
|
||||
placeholder="请输入主题"
|
||||
/>
|
||||
/> */}
|
||||
</motion.div>
|
||||
{/* 标签输入 */}
|
||||
<motion.div
|
||||
|
|
|
@ -93,7 +93,7 @@ export default function AssignList() {
|
|||
<div className="p-2 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 ">
|
||||
<span className=""> {role?.name}</span>
|
||||
<span className=" text-tertiary "> 角色成员列表</span>
|
||||
<span className=" text-tertiary-300 "> 角色成员列表</span>
|
||||
|
||||
</div>
|
||||
<div className=" flex items-center gap-4">
|
||||
|
|
|
@ -73,7 +73,7 @@ export default function RoleList() {
|
|||
style={{
|
||||
background: item.id === role?.id ? token.colorPrimaryBg : ""
|
||||
}}
|
||||
className={`p-2 hover:bg-textHover text-secondary ${item.id === role?.id ? " text-primary border-l-4 border-primaryHover" : ""} transition-all ease-in-out flex items-center justify-between `}
|
||||
className={`p-2 hover:bg-textHover text-tertiary-300 ${item.id === role?.id ? " text-primary border-l-4 border-primaryHover" : ""} transition-all ease-in-out flex items-center justify-between `}
|
||||
key={item.id}>
|
||||
<div className=" flex items-center gap-2">
|
||||
<span className="text-primary"> <UserOutlined></UserOutlined></span>
|
||||
|
|
|
@ -90,7 +90,7 @@ const StaffList = ({
|
|||
|
||||
return (
|
||||
params.value || (
|
||||
<span className="text-tertiary">未录入所属单位</span>
|
||||
<span className="text-tertiary-300">未录入所属单位</span>
|
||||
)
|
||||
);
|
||||
},
|
||||
|
@ -112,7 +112,7 @@ const StaffList = ({
|
|||
if (params?.data?.id)
|
||||
return (
|
||||
params.value || (
|
||||
<span className="text-tertiary">未录入帐号</span>
|
||||
<span className="text-tertiary-300">未录入帐号</span>
|
||||
)
|
||||
);
|
||||
},
|
||||
|
@ -128,7 +128,7 @@ const StaffList = ({
|
|||
if (params?.data?.id)
|
||||
return (
|
||||
params.value || (
|
||||
<span className="text-tertiary">未录入姓名</span>
|
||||
<span className="text-tertiary-300">未录入姓名</span>
|
||||
)
|
||||
);
|
||||
},
|
||||
|
|
|
@ -69,7 +69,7 @@ const StaffTransfer = forwardRef<StaffTransferRef, StaffTransferProps>(({ staffs
|
|||
{item.title?.slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<span>{item.title}</span>
|
||||
<span className="text-tertiary">{item.description}</span>
|
||||
<span className="text-tertiary-300">{item.description}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -128,7 +128,7 @@ export default function TermList() {
|
|||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" justify-between flex items-center gap-4 border-b p-2">
|
||||
<span className=" text-secondary">分类项列表</span>
|
||||
<span className=" text-tertiary-300">分类项列表</span>
|
||||
<div className=" flex items-center gap-4">
|
||||
<DepartmentSelect
|
||||
disabled={!canManageAnyTerm}
|
||||
|
|
|
@ -31,7 +31,7 @@ export const NavBar = ({ items, defaultSelected, onSelect }: NavBarProps) => {
|
|||
<button
|
||||
onClick={() => handleSelect(item.id)}
|
||||
className={`flex items-center space-x-2 px-2 py-4 text-sm font-medium transition-colors
|
||||
${selected === item.id ? "text-black" : "text-gray-500 hover:text-gray-800"}`}>
|
||||
${selected === item.id ? "text-black" : "text-tertiary-300 hover:text-gray-800"}`}>
|
||||
{item.icon && (
|
||||
<span className="w-4 h-4">{item.icon}</span>
|
||||
)}
|
||||
|
|
|
@ -37,8 +37,8 @@ const MenuContext = React.createContext<{
|
|||
}>({
|
||||
getItemProps: () => ({}),
|
||||
activeIndex: null,
|
||||
setActiveIndex: () => {},
|
||||
setHasFocusInside: () => {},
|
||||
setActiveIndex: () => { },
|
||||
setHasFocusInside: () => { },
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
|
@ -165,11 +165,10 @@ export const MenuComponent = React.forwardRef<
|
|||
data-open={isOpen ? "" : undefined}
|
||||
data-nested={isNested ? "" : undefined}
|
||||
data-focus-inside={hasFocusInside ? "" : undefined}
|
||||
className={`${
|
||||
isNested
|
||||
? "hover:bg-textHover hover:text-default"
|
||||
: "RootMenu"
|
||||
} rounded-lg outline-none flex items-center px-2 py-1 justify-between`}
|
||||
className={`${isNested
|
||||
? "hover:bg-textHover hover:text-default"
|
||||
: "RootMenu"
|
||||
} rounded-lg outline-none flex items-center px-2 py-1 justify-between`}
|
||||
{...getReferenceProps(
|
||||
parent.getItemProps({
|
||||
...props,
|
||||
|
@ -213,7 +212,7 @@ export const MenuComponent = React.forwardRef<
|
|||
returnFocus={!isNested}>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
className="Menu bg-container flex flex-col p-1 min-w-20 border-default text-sm text-secondary border outline-none rounded-lg shadow-lg z-20"
|
||||
className="Menu bg-container flex flex-col p-1 min-w-20 border-default text-sm text-tertiary-300 border outline-none rounded-lg shadow-lg z-20"
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}>
|
||||
{children}
|
||||
|
@ -248,11 +247,10 @@ export const MenuItem = React.forwardRef<
|
|||
ref={useMergeRefs([item.ref, forwardedRef])}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`MenuItem ${
|
||||
disabled
|
||||
? "text-quaternary"
|
||||
: "hover:bg-textHover hover:text-default"
|
||||
} outline-none rounded-lg flex items-center px-2 py-1 `}
|
||||
className={`MenuItem ${disabled
|
||||
? "text-quaternary"
|
||||
: "hover:bg-textHover hover:text-default"
|
||||
} outline-none rounded-lg flex items-center px-2 py-1 `}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
disabled={disabled}
|
||||
{...menu.getItemProps({
|
||||
|
|
|
@ -13,12 +13,12 @@ export default function IdCard({ id, ...rest }: IdCardProps) {
|
|||
{...rest}
|
||||
>
|
||||
{id ? (
|
||||
<div className="w-full truncate text-ellipsis flex items-center gap-2 text-secondary">
|
||||
<div className="w-full truncate text-ellipsis flex items-center gap-2 text-tertiary-300">
|
||||
<IdcardOutlined className="text-primary" />
|
||||
<span className="text-ellipsis truncate">{id}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-tertiary">未录入证件号</span>
|
||||
<span className="text-tertiary-300">未录入证件号</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -9,14 +9,14 @@ export default function PhoneBook({ phoneNumber, ...rest }: PhoneBookProps) {
|
|||
return (
|
||||
<div className="flex items-center" style={{ maxWidth: 150 }} {...rest}>
|
||||
{phoneNumber ? (
|
||||
<div className="w-full truncate text-ellipsis flex items-center gap-2 text-secondary">
|
||||
<div className="w-full truncate text-ellipsis flex items-center gap-2 text-tertiary-300">
|
||||
<PhoneOutlined className="text-primary" />
|
||||
<span className="text-ellipsis truncate">
|
||||
{phoneNumber}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-tertiary">未录入手机号</span>
|
||||
<span className="text-tertiary-300">未录入手机号</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -41,7 +41,7 @@ export default function TimeLine() {
|
|||
/>
|
||||
{/* 进度球 */}
|
||||
<motion.div
|
||||
className={`z-20 absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-primaryHover border-primaryActive border shadow-lg
|
||||
className={`z-20 absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-blue-200 border-primaryActive border shadow-lg
|
||||
${isHovering || isDragging ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||
style={{
|
||||
left: `${(currentTime / duration) * 100}%`,
|
||||
|
|
|
@ -99,7 +99,7 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({ style, value, onChange, c
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-tertiary">
|
||||
<span className="text-tertiary-300">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -13,3 +13,5 @@ export const env: {
|
|||
? (window as any).env.VITE_APP_VERSION
|
||||
: import.meta.env.VITE_APP_VERSION,
|
||||
};
|
||||
|
||||
console.log(env)
|
|
@ -16,7 +16,7 @@
|
|||
}
|
||||
|
||||
.ag-custom-dragging-class {
|
||||
@apply border-b-2 border-primaryHover;
|
||||
@apply border-b-2 border-blue-200;
|
||||
}
|
||||
|
||||
.ant-popover-inner {
|
||||
|
@ -68,10 +68,9 @@
|
|||
/* 滚动块 */
|
||||
::-webkit-scrollbar-thumb {
|
||||
/* background-color: #888; */
|
||||
|
||||
border-radius: 10px;
|
||||
border: 2px solid #f0f0f0;
|
||||
@apply hover:bg-primaryHover transition-all bg-gray-400 ease-in-out rounded-full;
|
||||
@apply hover:bg-blue-200 transition-all bg-gray-400 ease-in-out rounded-full;
|
||||
}
|
||||
|
||||
/* 鼠标悬停在滚动块上 */
|
||||
|
|
|
@ -30,8 +30,8 @@ export default function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
"--color-primary-active": token.colorPrimaryActive,
|
||||
"--color-primary-hover": token.colorPrimaryHover,
|
||||
"--color-bg-primary-hover": token.colorPrimaryBgHover,
|
||||
"--color-text-secondary": token.colorTextSecondary,
|
||||
"--color-text-tertiary": token.colorTextTertiary,
|
||||
"--color-text-tertiary-400": token.colorTextSecondary,
|
||||
"--color-text-tertiary-400": token.colorTextTertiary,
|
||||
"--color-bg-text-hover": token.colorBgTextHover,
|
||||
"--color-bg-container": token.colorBgContainer,
|
||||
"--color-bg-layout": token.colorBgLayout,
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "var(--color-primary)",
|
||||
primaryActive: "var(--color-primary-active)",
|
||||
primaryHover: "var(--color-primary-hover)",
|
||||
error: "var(--color-error)",
|
||||
warning: "var(--color-warning)",
|
||||
info: "var(--color-info)",
|
||||
success: "var(--color-success)",
|
||||
link: "var(--color-link)",
|
||||
highlight: "var(--color-highlight)",
|
||||
},
|
||||
backgroundColor: {
|
||||
layout: "var(--color-bg-layout)",
|
||||
mask: "var(--color-bg-mask)",
|
||||
container: "var(--color-bg-container)",
|
||||
textHover: "var(--color-bg-text-hover)",
|
||||
primary: "var(--color-bg-primary)",
|
||||
error: "var(--color-error-bg)",
|
||||
warning: "var(--color-warning-bg)",
|
||||
info: "var(--color-info-bg)",
|
||||
success: "var(--color-success-bg)",
|
||||
},
|
||||
textColor: {
|
||||
default: "var(--color-text)",
|
||||
quaternary: "var(--color-text-quaternary)",
|
||||
placeholder: "var(--color-text-placeholder)",
|
||||
description: "var(--color-text-description)",
|
||||
secondary: "var(--color-text-secondary)",
|
||||
tertiary: "var(--color-text-tertiary)",
|
||||
primary: "var(--color-text-primary)",
|
||||
heading: "var(--color-text-heading)",
|
||||
label: "var(--color-text-label)",
|
||||
lightSolid: "var(--color-text-lightsolid)"
|
||||
},
|
||||
borderColor: {
|
||||
default: "var(--color-border)",
|
||||
},
|
||||
boxShadow: {
|
||||
elegant: '0 3px 6px -2px rgba(46, 117, 182, 0.10), 0 2px 4px -1px rgba(46, 117, 182, 0.05)'
|
||||
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import type { Config } from 'tailwindcss'
|
||||
import { createTailwindTheme, defaultTheme } from '@nice/theme';
|
||||
|
||||
const tailwindTheme = createTailwindTheme(defaultTheme)
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: tailwindTheme,
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@nice/theme",
|
||||
"version": "1.0.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nice/utils": "workspace:^",
|
||||
"color": "^4.2.3",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "18.2.38",
|
||||
"@types/react-dom": "18.2.15",
|
||||
"concurrently": "^8.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import Color from "color";
|
||||
import { ColorScale } from "./types";
|
||||
|
||||
export function generateColorScale(baseColor: string): ColorScale {
|
||||
const color = Color(baseColor);
|
||||
const steps = [-0.4, -0.32, -0.24, -0.16, -0.08, 0, 0.08, 0.16, 0.24, 0.32, 0.4];
|
||||
const keys = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
||||
|
||||
const scale = Object.fromEntries(
|
||||
keys.map((key, index) => [
|
||||
key,
|
||||
color.lighten(-steps[index]).hex()
|
||||
])
|
||||
) as ColorScale;
|
||||
|
||||
return {
|
||||
...scale,
|
||||
DEFAULT: scale[500]
|
||||
};
|
||||
}
|
||||
export function withAlpha(color: string, alpha: number): string {
|
||||
|
||||
return Color(color).alpha(alpha).toString();
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { generateBorderRadius, generateBoxShadow, generateSpacing, generateTheme, generateTypography, generateZIndex } from "./generator";
|
||||
import { ThemeConfig, ThemeSeed } from "./types";
|
||||
|
||||
// 添加默认的主题配置
|
||||
export const USAFSeed: ThemeSeed = {
|
||||
colors: {
|
||||
primary: '#003087', // 深蓝色
|
||||
secondary: '#71767C', // 灰色
|
||||
neutral: '#4A4A4A', // 中性灰色
|
||||
success: '#287233', // 绿色
|
||||
warning: '#FF9F1C', // 警告橙色
|
||||
error: '#AF1E2D', // 红色
|
||||
info: '#00538E', // 信息蓝色
|
||||
|
||||
},
|
||||
config: {
|
||||
borderRadius: generateBorderRadius(),
|
||||
spacing: generateSpacing(),
|
||||
...generateTypography(),
|
||||
boxShadow: generateBoxShadow(),
|
||||
zIndex: generateZIndex(),
|
||||
},
|
||||
isDark: false
|
||||
};
|
||||
export const defaultTheme = generateTheme(USAFSeed)
|
|
@ -0,0 +1,50 @@
|
|||
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||
import type { Theme, ThemeConfig, ThemeSeed, ThemeToken } from './types';
|
||||
import { USAFSeed } from './constants';
|
||||
import { createTailwindTheme, injectThemeVariables } from './styles';
|
||||
import { generateTheme } from './generator';
|
||||
interface ThemeContextValue {
|
||||
token: ThemeToken
|
||||
setTheme: (options: ThemeSeed) => void;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
seed = USAFSeed,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
seed?: ThemeSeed;
|
||||
}) {
|
||||
const [themeSeed, setThemeSeed] = useState<ThemeSeed>(seed);
|
||||
const token = useMemo<ThemeToken>(() => {
|
||||
|
||||
const result = generateTheme(themeSeed)
|
||||
console.log(createTailwindTheme(result))
|
||||
|
||||
injectThemeVariables(result)
|
||||
return result.token;
|
||||
}, [themeSeed]);
|
||||
|
||||
const contextValue = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
token,
|
||||
setTheme: setThemeSeed,
|
||||
toggleDarkMode: () =>
|
||||
setThemeSeed((prev) => ({ ...prev, isDark: !prev.isDark })),
|
||||
}),
|
||||
[token]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
import { Theme, ThemeConfig, ThemeColors, ThemeSemantics, ThemeSeed, ThemeToken } from './types';
|
||||
import { withAlpha, generateColorScale } from './colors';
|
||||
import { darkMode } from './utils';
|
||||
export function generateThemeColors(seed: ThemeSeed['colors']): ThemeColors {
|
||||
const defaultColors = {
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6'
|
||||
};
|
||||
const colors = { ...defaultColors, ...seed };
|
||||
return Object.fromEntries(
|
||||
Object.entries(colors).map(([key, value]) => [
|
||||
key,
|
||||
generateColorScale(value)
|
||||
])
|
||||
) as ThemeColors;
|
||||
}
|
||||
|
||||
export function generateSemantics(colors: ThemeColors, isDark: boolean): ThemeSemantics {
|
||||
const neutral = colors.neutral;
|
||||
const primary = colors.primary;
|
||||
|
||||
const statusColors = {
|
||||
success: colors.success,
|
||||
warning: colors.warning,
|
||||
error: colors.error,
|
||||
info: colors.info
|
||||
};
|
||||
return {
|
||||
colors,
|
||||
textColor: {
|
||||
DEFAULT: darkMode(isDark, neutral[100], neutral[900]),
|
||||
primary: darkMode(isDark, neutral[100], neutral[900]),
|
||||
secondary: darkMode(isDark, neutral[300], neutral[700]),
|
||||
tertiary: darkMode(isDark, neutral[400], neutral[600]),
|
||||
disabled: darkMode(isDark, neutral[500], neutral[400]),
|
||||
inverse: darkMode(isDark, neutral[900], neutral[100]),
|
||||
success: statusColors.success[darkMode(isDark, 400, 600)],
|
||||
warning: statusColors.warning[darkMode(isDark, 400, 600)],
|
||||
error: statusColors.error[darkMode(isDark, 400, 600)],
|
||||
info: statusColors.info[darkMode(isDark, 400, 600)],
|
||||
link: primary[darkMode(isDark, 400, 600)],
|
||||
linkHover: primary[darkMode(isDark, 300, 700)],
|
||||
placeholder: darkMode(isDark, neutral[500], neutral[400]),
|
||||
highlight: primary[darkMode(isDark, 300, 700)]
|
||||
},
|
||||
|
||||
backgroundColor: {
|
||||
DEFAULT: darkMode(isDark, neutral[900], neutral[50]),
|
||||
paper: darkMode(isDark, neutral[800], neutral[100]),
|
||||
subtle: darkMode(isDark, neutral[700], neutral[200]),
|
||||
inverse: darkMode(isDark, neutral[50], neutral[900]),
|
||||
success: withAlpha(statusColors.success[darkMode(isDark, 900, 50)], 0.12),
|
||||
warning: withAlpha(statusColors.warning[darkMode(isDark, 900, 50)], 0.12),
|
||||
error: withAlpha(statusColors.error[darkMode(isDark, 900, 50)], 0.12),
|
||||
info: withAlpha(statusColors.info[darkMode(isDark, 900, 50)], 0.12),
|
||||
primaryHover: withAlpha(primary[darkMode(isDark, 800, 100)], 0.08),
|
||||
primaryActive: withAlpha(primary[darkMode(isDark, 700, 200)], 0.12),
|
||||
primaryDisabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
secondaryHover: withAlpha(neutral[darkMode(isDark, 800, 100)], 0.08),
|
||||
secondaryActive: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
secondaryDisabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
selected: withAlpha(primary[darkMode(isDark, 900, 50)], 0.16),
|
||||
hover: withAlpha(neutral[darkMode(isDark, 800, 100)], 0.08),
|
||||
focused: withAlpha(primary[darkMode(isDark, 900, 50)], 0.12),
|
||||
disabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
overlay: withAlpha(neutral[900], 0.5)
|
||||
},
|
||||
|
||||
border: {
|
||||
DEFAULT: darkMode(isDark, neutral[600], neutral[300]),
|
||||
subtle: darkMode(isDark, neutral[700], neutral[200]),
|
||||
strong: darkMode(isDark, neutral[500], neutral[400]),
|
||||
focus: primary[500],
|
||||
inverse: darkMode(isDark, neutral[300], neutral[600]),
|
||||
success: statusColors.success[darkMode(isDark, 500, 500)],
|
||||
warning: statusColors.warning[darkMode(isDark, 500, 500)],
|
||||
error: statusColors.error[darkMode(isDark, 500, 500)],
|
||||
info: statusColors.info[darkMode(isDark, 500, 500)],
|
||||
disabled: darkMode(isDark, neutral[600], neutral[300])
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function generateSpacing(): ThemeConfig['spacing'] {
|
||||
return {
|
||||
0: '0',
|
||||
xs: '0.25rem',
|
||||
sm: '0.5rem',
|
||||
DEFAULT: '1rem',
|
||||
lg: '1.5rem',
|
||||
xl: '2rem',
|
||||
'2xl': '3rem'
|
||||
};
|
||||
}
|
||||
|
||||
export function generateBorderRadius(): ThemeConfig['borderRadius'] {
|
||||
return {
|
||||
none: '0',
|
||||
xs: '0.125rem',
|
||||
sm: '0.25rem',
|
||||
DEFAULT: '0.375rem',
|
||||
lg: '0.5rem',
|
||||
xl: '0.75rem',
|
||||
full: '9999px'
|
||||
};
|
||||
}
|
||||
|
||||
export function generateTypography(): Pick<ThemeConfig, 'fontFamily' | 'fontSize' | 'lineHeight' | 'letterSpacing' | 'fontWeight'> {
|
||||
return {
|
||||
fontFamily: {
|
||||
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
|
||||
serif: ['Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'],
|
||||
mono: ['Menlo', 'Monaco', 'Consolas', '"Liberation Mono"', '"Courier New"', 'monospace'],
|
||||
DEFAULT: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif']
|
||||
},
|
||||
fontSize: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
base: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
display: '2rem',
|
||||
DEFAULT: '1rem'
|
||||
},
|
||||
lineHeight: {
|
||||
xs: '1rem',
|
||||
sm: '1.25rem',
|
||||
base: '1.5rem',
|
||||
lg: '1.75rem',
|
||||
xl: '1.75rem',
|
||||
'2xl': '2rem',
|
||||
display: '2.5rem',
|
||||
DEFAULT: '1.5rem'
|
||||
},
|
||||
letterSpacing: {
|
||||
xs: '-0.05em',
|
||||
sm: '-0.025em',
|
||||
base: '0',
|
||||
lg: '0.025em',
|
||||
xl: '0.025em',
|
||||
'2xl': '0.025em',
|
||||
display: '0',
|
||||
DEFAULT: '0'
|
||||
},
|
||||
fontWeight: {
|
||||
xs: 400,
|
||||
sm: 400,
|
||||
base: 400,
|
||||
lg: 500,
|
||||
xl: 500,
|
||||
'2xl': 600,
|
||||
display: 600,
|
||||
DEFAULT: 400
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function generateBoxShadow(): ThemeConfig['boxShadow'] {
|
||||
return {
|
||||
none: 'none',
|
||||
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)'
|
||||
};
|
||||
}
|
||||
|
||||
export function generateZIndex(): ThemeConfig['zIndex'] {
|
||||
return {
|
||||
negative: '-1',
|
||||
0: '0',
|
||||
10: '10',
|
||||
20: '20',
|
||||
30: '30',
|
||||
40: '40',
|
||||
50: '50',
|
||||
modal: '1000',
|
||||
popover: '1100',
|
||||
tooltip: '1200',
|
||||
DEFAULT: '0'
|
||||
};
|
||||
}
|
||||
|
||||
export function generateThemeConfig(): ThemeConfig {
|
||||
return {
|
||||
borderRadius: generateBorderRadius(),
|
||||
spacing: generateSpacing(),
|
||||
...generateTypography(),
|
||||
boxShadow: generateBoxShadow(),
|
||||
zIndex: generateZIndex()
|
||||
};
|
||||
}
|
||||
|
||||
export function generateTheme(seed: ThemeSeed): Theme {
|
||||
const isDark = seed.isDark ?? false;
|
||||
const colors = generateThemeColors(seed.colors);
|
||||
const semantics = generateSemantics(colors, isDark);
|
||||
const config = generateThemeConfig();
|
||||
return {
|
||||
token: {
|
||||
...colors,
|
||||
...semantics,
|
||||
...config
|
||||
},
|
||||
isDark
|
||||
};
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./context"
|
||||
export * from "./types"
|
||||
export * from "./utils"
|
||||
export * from "./styles"
|
||||
export * from "./constants"
|
|
@ -0,0 +1,100 @@
|
|||
import { Theme, ThemeToken } from './types'
|
||||
import { flattenObject, toKebabCase } from './utils'
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const PREFIX = '--nice'
|
||||
|
||||
/**
|
||||
* 生成CSS变量键名
|
||||
*/
|
||||
function createCssVariableName(path: string[]): string {
|
||||
return `${PREFIX}-${path.map(p => toKebabCase(p.toLowerCase())).join('-')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将主题对象转换为CSS变量对象
|
||||
*/
|
||||
export function themeToCssVariables(theme: Theme): Record<string, string> {
|
||||
const flattenedToken = flattenObject(theme.token)
|
||||
console.log(flattenedToken)
|
||||
const cssVars: Record<string, string> = {}
|
||||
for (const [path, value] of Object.entries(flattenedToken)) {
|
||||
|
||||
const cssVarName = createCssVariableName(path.split('.'))
|
||||
|
||||
cssVars[cssVarName] = value
|
||||
}
|
||||
return cssVars
|
||||
}
|
||||
|
||||
export function injectThemeVariables(theme: Theme) {
|
||||
const cssVars = themeToCssVariables(theme)
|
||||
const root = document.documentElement
|
||||
|
||||
Object.entries(cssVars).forEach(([key, value]) => {
|
||||
console.log(key, value)
|
||||
root.style.setProperty(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平化的主题对象转换为Tailwind主题配置
|
||||
*/
|
||||
function transformToTailwindConfig(flattenedToken: Record<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
// 处理对象路径,将其转换为嵌套结构
|
||||
for (const [path, _] of Object.entries(flattenedToken)) {
|
||||
const parts = path.split('.')
|
||||
let current = result
|
||||
|
||||
// 遍历路径的每一部分,构建嵌套结构
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
const isLast = i === parts.length - 1
|
||||
|
||||
// 如果是最后一个部分,设置CSS变量引用
|
||||
if (isLast) {
|
||||
current[part] = `var(${createCssVariableName(parts)})`
|
||||
} else {
|
||||
// 如果不是最后一个部分,确保存在嵌套对象
|
||||
current[part] = current[part] || {}
|
||||
current = current[part]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建引用CSS变量的Tailwind主题配置
|
||||
*/
|
||||
export function createTailwindTheme(theme: Theme): Partial<Config["theme"]> {
|
||||
const flattenedToken = flattenObject(theme.token)
|
||||
const themeConfig = transformToTailwindConfig(flattenedToken)
|
||||
|
||||
// 将主题配置映射到Tailwind的结构
|
||||
const result = {
|
||||
extend: {
|
||||
colors: themeConfig.colors,
|
||||
textColor: themeConfig.textColor,
|
||||
backgroundColor: themeConfig.backgroundColor,
|
||||
borderColor: themeConfig.border,
|
||||
borderRadius: themeConfig.borderRadius,
|
||||
spacing: themeConfig.spacing,
|
||||
fontFamily: Object.entries(themeConfig.fontFamily || {}).reduce((acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: (value as string).split(',')
|
||||
}), {}),
|
||||
fontSize: themeConfig.fontSize,
|
||||
lineHeight: themeConfig.lineHeight,
|
||||
letterSpacing: themeConfig.letterSpacing,
|
||||
fontWeight: themeConfig.fontWeight,
|
||||
boxShadow: themeConfig.boxShadow,
|
||||
zIndex: themeConfig.zIndex
|
||||
}
|
||||
}
|
||||
console.log(result)
|
||||
return result
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* 定义颜色比例尺,从最浅(50)到最深(950)的颜色梯度
|
||||
* 用于构建一致的颜色系统,每个数字代表颜色的深浅程度
|
||||
*/
|
||||
export type ColorScale = {
|
||||
50: string; // 最浅色调
|
||||
100: string; // 非常浅色调
|
||||
200: string; // 浅色调
|
||||
300: string; // 中浅色调
|
||||
400: string; // 中等偏浅色调
|
||||
500: string; // 基准色调
|
||||
600: string; // 中等偏深色调
|
||||
700: string; // 深色调
|
||||
800: string; // 很深色调
|
||||
900: string; // 非常深色调
|
||||
950: string; // 最深色调
|
||||
DEFAULT: string; // Tailwind 默认值,通常对应 500
|
||||
}
|
||||
/**
|
||||
* 主题的基础颜色配置
|
||||
* 定义系统中使用的所有基础色板
|
||||
*/
|
||||
export type ThemeColors = {
|
||||
primary: ColorScale; // 主要品牌色
|
||||
secondary: ColorScale; // 次要品牌色
|
||||
neutral: ColorScale; // 中性色,通常用于文本和背景
|
||||
success: ColorScale; // 成功状态颜色
|
||||
warning: ColorScale; // 警告状态颜色
|
||||
error: ColorScale; // 错误状态颜色
|
||||
info: ColorScale; // 信息状态颜色
|
||||
}
|
||||
/**
|
||||
* 主题的语义化颜色配置
|
||||
* 定义具体UI元素的颜色应用场景
|
||||
*/
|
||||
export type ThemeSemantics = {
|
||||
colors: ThemeColors,
|
||||
/** 文本颜色相关配置 */
|
||||
textColor: {
|
||||
DEFAULT: string; // 默认文本颜色
|
||||
primary: string; // 主要文本
|
||||
secondary: string; // 次要文本
|
||||
tertiary: string; // 第三级文本
|
||||
disabled: string; // 禁用状态
|
||||
inverse: string; // 反色文本
|
||||
success: string; // 成功状态
|
||||
warning: string; // 警告状态
|
||||
error: string; // 错误状态
|
||||
info: string; // 信息提示
|
||||
link: string; // 链接文本
|
||||
linkHover: string; // 链接悬浮
|
||||
placeholder: string; // 占位符文本
|
||||
highlight: string; // 高亮文本
|
||||
};
|
||||
|
||||
/** 背景颜色相关配置 */
|
||||
backgroundColor: {
|
||||
DEFAULT: string; // 默认背景色
|
||||
paper: string; // 卡片/纸张背景
|
||||
subtle: string; // 轻微背景
|
||||
inverse: string; // 反色背景
|
||||
success: string; // 成功状态背景
|
||||
warning: string; // 警告状态背景
|
||||
error: string; // 错误状态背景
|
||||
info: string; // 信息提示背景
|
||||
primaryHover: string; // 主要按钮悬浮
|
||||
primaryActive: string; // 主要按钮激活
|
||||
primaryDisabled: string; // 主要按钮禁用
|
||||
secondaryHover: string; // 次要按钮悬浮
|
||||
secondaryActive: string; // 次要按钮激活
|
||||
secondaryDisabled: string; // 次要按钮禁用
|
||||
selected: string; // 选中状态
|
||||
hover: string; // 通用悬浮态
|
||||
focused: string; // 聚焦状态
|
||||
disabled: string; // 禁用状态
|
||||
overlay: string; // 遮罩层
|
||||
};
|
||||
|
||||
/** 边框颜色配置 */
|
||||
border: {
|
||||
DEFAULT: string; // 默认边框
|
||||
subtle: string; // 轻微边框
|
||||
strong: string; // 强调边框
|
||||
focus: string; // 聚焦边框
|
||||
inverse: string; // 反色边框
|
||||
success: string; // 成功状态边框
|
||||
warning: string; // 警告状态边框
|
||||
error: string; // 错误状态边框
|
||||
info: string; // 信息提示边框
|
||||
disabled: string; // 禁用状态边框
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
/** 圆角配置 */
|
||||
borderRadius: {
|
||||
none: string;
|
||||
xs: string;
|
||||
sm: string;
|
||||
DEFAULT: string;
|
||||
lg: string;
|
||||
xl: string;
|
||||
full: string;
|
||||
};
|
||||
/** 间距配置 */
|
||||
spacing: {
|
||||
0: string;
|
||||
xs: string;
|
||||
sm: string;
|
||||
DEFAULT: string;
|
||||
lg: string;
|
||||
xl: string;
|
||||
'2xl': string;
|
||||
};
|
||||
/** 字体族配置 */
|
||||
fontFamily: {
|
||||
sans: string[];
|
||||
serif: string[];
|
||||
mono: string[];
|
||||
DEFAULT: string[];
|
||||
};
|
||||
/** 字体大小配置 */
|
||||
fontSize: {
|
||||
xs: string;
|
||||
sm: string;
|
||||
base: string;
|
||||
lg: string;
|
||||
xl: string;
|
||||
'2xl': string;
|
||||
display: string;
|
||||
DEFAULT: string;
|
||||
};
|
||||
/** 行高配置 */
|
||||
lineHeight: {
|
||||
xs: string;
|
||||
sm: string;
|
||||
base: string;
|
||||
lg: string;
|
||||
xl: string;
|
||||
'2xl': string;
|
||||
display: string;
|
||||
DEFAULT: string;
|
||||
};
|
||||
/** 字母间距配置 */
|
||||
letterSpacing: {
|
||||
xs?: string;
|
||||
sm?: string;
|
||||
base?: string;
|
||||
lg?: string;
|
||||
xl?: string;
|
||||
'2xl'?: string;
|
||||
display?: string;
|
||||
DEFAULT: string;
|
||||
};
|
||||
/** 字重配置 */
|
||||
fontWeight: {
|
||||
xs?: string | number;
|
||||
sm?: string | number;
|
||||
base?: string | number;
|
||||
lg?: string | number;
|
||||
xl?: string | number;
|
||||
'2xl'?: string | number;
|
||||
display?: string | number;
|
||||
DEFAULT?: string | number;
|
||||
};
|
||||
/** 阴影配置 */
|
||||
boxShadow: {
|
||||
none: string;
|
||||
sm: string;
|
||||
DEFAULT: string;
|
||||
lg: string;
|
||||
xl: string;
|
||||
inner: string;
|
||||
};
|
||||
/** Z轴层级配置 */
|
||||
zIndex: {
|
||||
negative: string;
|
||||
0: string;
|
||||
10: string;
|
||||
20: string;
|
||||
30: string;
|
||||
40: string;
|
||||
50: string;
|
||||
modal: string;
|
||||
popover: string;
|
||||
tooltip: string;
|
||||
DEFAULT: string;
|
||||
};
|
||||
}
|
||||
export type ThemeToken = ThemeSemantics & ThemeConfig
|
||||
export interface Theme {
|
||||
token: ThemeToken;
|
||||
isDark: boolean;
|
||||
}
|
||||
export interface ThemeSeed {
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
neutral: string;
|
||||
success?: string;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
info?: string;
|
||||
}
|
||||
config: ThemeConfig
|
||||
isDark?: boolean;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
// Helper function to generate conditional values based on dark mode
|
||||
export function darkMode<T>(isDark: boolean, darkValue: T, lightValue: T): T {
|
||||
return isDark ? darkValue : lightValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将驼峰命名转换为kebab-case
|
||||
*/
|
||||
export function toKebabCase(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 将嵌套对象扁平化,使用点号连接键名
|
||||
*/
|
||||
export function flattenObject(obj: Record<string, any>, prefix = ''): Record<string, string> {
|
||||
return Object.keys(obj).reduce((acc: Record<string, string>, k: string) => {
|
||||
const pre = prefix.length ? prefix + '.' : ''
|
||||
|
||||
if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) {
|
||||
Object.assign(acc, flattenObject(obj[k], pre + k))
|
||||
} else {
|
||||
acc[pre + k] = obj[k].toString()
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "esnext",
|
||||
"allowJs": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"es2022",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noImplicitOverride": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"outDir": "dist",
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/__tests__"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
minify: true,
|
||||
external: ['react', 'react-dom'],
|
||||
bundle: true,
|
||||
target: "esnext"
|
||||
})
|
|
@ -12,9 +12,11 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.1.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@nice/utils": "workspace:^",
|
||||
"@xyflow/react": "^12.3.6",
|
||||
"color": "^4.2.3",
|
||||
"dagre": "^0.8.5",
|
||||
"nanoid": "^5.0.9",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
|
@ -25,6 +27,7 @@
|
|||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "18.2.38",
|
||||
|
|
|
@ -1 +1 @@
|
|||
export * from "./components/mindmap"
|
||||
export { }
|
100
pnpm-lock.yaml
100
pnpm-lock.yaml
|
@ -293,6 +293,9 @@ importers:
|
|||
'@nice/iconer':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/iconer
|
||||
'@nice/theme':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/theme
|
||||
'@nice/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/ui
|
||||
|
@ -404,6 +407,9 @@ importers:
|
|||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
usehooks-ts:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(react@18.2.0)
|
||||
uuid:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
|
@ -618,6 +624,55 @@ importers:
|
|||
specifier: ^5.5.4
|
||||
version: 5.7.2
|
||||
|
||||
packages/theme:
|
||||
dependencies:
|
||||
'@nice/utils':
|
||||
specifier: workspace:^
|
||||
version: link:../utils
|
||||
color:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
nanoid:
|
||||
specifier: ^5.0.9
|
||||
version: 5.0.9
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
react-dom:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
devDependencies:
|
||||
'@types/color':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@types/dagre':
|
||||
specifier: ^0.7.52
|
||||
version: 0.7.52
|
||||
'@types/node':
|
||||
specifier: ^20.3.1
|
||||
version: 20.17.12
|
||||
'@types/react':
|
||||
specifier: 18.2.38
|
||||
version: 18.2.38
|
||||
'@types/react-dom':
|
||||
specifier: 18.2.15
|
||||
version: 18.2.15
|
||||
concurrently:
|
||||
specifier: ^8.0.0
|
||||
version: 8.2.2
|
||||
rimraf:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
ts-node:
|
||||
specifier: ^10.9.1
|
||||
version: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.17.12)(typescript@5.7.2)
|
||||
tsup:
|
||||
specifier: ^8.3.5
|
||||
version: 8.3.5(@swc/core@1.10.6(@swc/helpers@0.5.15))(jiti@1.21.7)(postcss@8.4.49)(typescript@5.7.2)(yaml@2.7.0)
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.7.2
|
||||
|
||||
packages/tus:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
|
@ -675,6 +730,9 @@ importers:
|
|||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@ctrl/tinycolor':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
'@dagrejs/dagre':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4
|
||||
|
@ -684,6 +742,9 @@ importers:
|
|||
'@xyflow/react':
|
||||
specifier: ^12.3.6
|
||||
version: 12.3.6(@types/react@18.2.38)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
color:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
dagre:
|
||||
specifier: ^0.8.5
|
||||
version: 0.8.5
|
||||
|
@ -703,6 +764,9 @@ importers:
|
|||
specifier: ^5.0.3
|
||||
version: 5.0.3(@types/react@18.2.38)(react@18.2.0)(use-sync-external-store@1.4.0(react@18.2.0))
|
||||
devDependencies:
|
||||
'@types/color':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@types/dagre':
|
||||
specifier: ^0.7.52
|
||||
version: 0.7.52
|
||||
|
@ -1214,6 +1278,10 @@ packages:
|
|||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@ctrl/tinycolor@4.1.0':
|
||||
resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@dagrejs/dagre@1.1.4':
|
||||
resolution: {integrity: sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==}
|
||||
|
||||
|
@ -2696,6 +2764,15 @@ packages:
|
|||
'@types/body-parser@1.19.5':
|
||||
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
|
||||
|
||||
'@types/color-convert@2.0.4':
|
||||
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
|
||||
|
||||
'@types/color-name@1.1.5':
|
||||
resolution: {integrity: sha512-j2K5UJqGTxeesj6oQuGpMgifpT5k9HprgQd8D1Y0lOFqKHl3PJu5GMeS4Y5EgjS55AE6OQxf8mPED9uaGbf4Cg==}
|
||||
|
||||
'@types/color@4.2.0':
|
||||
resolution: {integrity: sha512-6+xrIRImMtGAL2X3qYkd02Mgs+gFGs+WsK0b7VVMaO4mYRISwyTjcqNrO0mNSmYEoq++rSLDB2F5HDNmqfOe+A==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
|
@ -6738,6 +6815,12 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
usehooks-ts@3.1.0:
|
||||
resolution: {integrity: sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==}
|
||||
engines: {node: '>=16.15.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
|
@ -7920,6 +8003,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@ctrl/tinycolor@4.1.0': {}
|
||||
|
||||
'@dagrejs/dagre@1.1.4':
|
||||
dependencies:
|
||||
'@dagrejs/graphlib': 2.2.4
|
||||
|
@ -9437,6 +9522,16 @@ snapshots:
|
|||
'@types/connect': 3.4.38
|
||||
'@types/node': 20.17.12
|
||||
|
||||
'@types/color-convert@2.0.4':
|
||||
dependencies:
|
||||
'@types/color-name': 1.1.5
|
||||
|
||||
'@types/color-name@1.1.5': {}
|
||||
|
||||
'@types/color@4.2.0':
|
||||
dependencies:
|
||||
'@types/color-convert': 2.0.4
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 20.17.12
|
||||
|
@ -14183,6 +14278,11 @@ snapshots:
|
|||
dependencies:
|
||||
react: 18.2.0
|
||||
|
||||
usehooks-ts@3.1.0(react@18.2.0):
|
||||
dependencies:
|
||||
lodash.debounce: 4.0.8
|
||||
react: 18.2.0
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
util@0.12.5:
|
||||
|
|
Loading…
Reference in New Issue