This commit is contained in:
ditiqi 2025-01-24 00:19:17 +08:00
commit 20e9d43c48
86 changed files with 2172 additions and 1511 deletions

View File

@ -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);

View File

@ -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"

View File

@ -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%;

View File

@ -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() {

View File

@ -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>
);
};
};

View File

@ -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>
);
};

View File

@ -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>
);
};
};

View File

@ -1,10 +0,0 @@
export interface LoginFormInputs {
username: string;
password: string;
}
export interface RegisterFormInputs extends LoginFormInputs {
deptId: string;
officerId: string;
showname: string;
repeatPass: string;
}

View File

@ -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
};
};

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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
`}
>
&#8230;

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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}

View File

@ -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 (

View File

@ -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',

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>
);
});
});

View File

@ -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>

View File

@ -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>

View File

@ -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>
))}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 };
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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}`);

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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'
}`}
>

View File

@ -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>
);

View File

@ -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>

View File

@ -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>

View File

@ -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"
}`}
>

View File

@ -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,

View File

@ -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

View File

@ -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">

View File

@ -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>

View File

@ -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>
)
);
},

View File

@ -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>
)}
/>

View File

@ -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}

View File

@ -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>
)}

View File

@ -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({

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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}%`,

View File

@ -99,7 +99,7 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({ style, value, onChange, c
)}
</>
) : (
<span className="text-tertiary">
<span className="text-tertiary-300">
{placeholder}
</span>
)}

View File

@ -13,3 +13,5 @@ export const env: {
? (window as any).env.VITE_APP_VERSION
: import.meta.env.VITE_APP_VERSION,
};
console.log(env)

View File

@ -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;
}
/* 鼠标悬停在滚动块上 */

View File

@ -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,

View File

@ -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: [],
}

13
apps/web/tailwind.config.ts Executable file
View File

@ -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

View File

@ -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"
}
}

View File

@ -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();
}

View File

@ -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)

View File

@ -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;
}

View File

@ -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
};
}

View File

@ -0,0 +1,5 @@
export * from "./context"
export * from "./types"
export * from "./utils"
export * from "./styles"
export * from "./constants"

View File

@ -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
}

207
packages/theme/src/types.ts Normal file
View File

@ -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;
}

View File

@ -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
}, {})
}

View File

@ -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__"
]
}

View File

@ -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"
})

View File

@ -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",

View File

@ -1 +1 @@
export * from "./components/mindmap"
export { }

View File

@ -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: