This commit is contained in:
ditiqi 2025-01-22 23:32:50 +08:00
commit 7df1e997c1
27 changed files with 1591 additions and 560 deletions

View File

@ -0,0 +1,68 @@
import { motion } from "framer-motion";
export 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>
);
};

View File

@ -0,0 +1,145 @@
import React, { useState, useRef, 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";
const AuthPage: 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);
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) {
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-[#1B2735] to-[#090A0F] 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]"
>
<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"}
>
{showLogin ? "New User Registration" : "Return to Login"}
</Button>
</motion.div>
</motion.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>
</div>
</div>
</motion.div>
</div>
);
};
export default AuthPage;

View File

@ -0,0 +1,203 @@
import { motion } from "framer-motion";
// RegisterForm.tsx
export 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>
);
};

View File

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

View File

@ -1,46 +1,54 @@
import React, { useState, useRef, useEffect } from "react";
import { Form, Input, Button, message, Row, Col } from "antd";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/auth-provider";
import DepartmentSelect from "../components/models/department/department-select";
import SineWave from "../components/animation/sine-wave";
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 [registerLoading, setRegisterLoading] = useState(false);
const {
login,
isAuthenticated,
signup
} = useAuth()
const loginFormRef = useRef<any>(null);
const registerFormRef = useRef<any>(null);
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated, signup } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const onFinishLogin = async (values: any) => {
const loginForm = useForm<LoginFormInputs>({
mode: "onChange"
});
const registerForm = useForm<RegisterFormInputs>({
mode: "onChange"
});
const onSubmitLogin = async (data: LoginFormInputs) => {
try {
const { username, password } = values;
await login(username, password);
setIsLoading(true);
await login(data.username, data.password);
toast.success("Welcome back!");
} catch (err: any) {
message.error(err?.response?.data?.message || "帐号或密码错误!");
console.error(err);
}
};
const onFinishRegister = async (values: any) => {
setRegisterLoading(true);
const { username, password, deptId, officerId, showname } = values;
try {
await signup({ username, password, deptId, officerId, showname });
message.success("注册成功!");
setShowLogin(true);
// loginFormRef.current.submit();
} catch (err: any) {
message.error(err?.response?.data?.message);
toast.error(err?.response?.data?.message || "Invalid credentials");
} finally {
setRegisterLoading(false);
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);
@ -50,224 +58,330 @@ const LoginPage: React.FC = () => {
}, [isAuthenticated, location]);
return (
<div
className="flex justify-center items-center h-screen w-full bg-gray-200"
style={{
// backgroundImage: `url(${backgroundUrl})`,
// backgroundSize: "cover",
}}>
<div
className="flex items-center transition-all hover:bg-white overflow-hidden border-2 border-white bg-gray-50 shadow-elegant rounded-xl "
style={{ width: 800, height: 600 }}>
<div
className={`transition-all h-full flex-1 text-white p-10 flex items-center justify-center bg-primary`}>
{showLogin ? (
<div className="flex flex-col">
<SineWave width={300} height={200} />
<div className="text-2xl my-4"></div>
<div className="my-4 font-thin text-sm">
</div>
<div
onClick={() => setShowLogin(false)}
className="hover:translate-y-1 my-8 p-2 text-center rounded-xl border-white border hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all">
</div>
<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>
) : (
<div className="flex flex-col">
<div className="text-2xl my-4"></div>
<div className="my-4 font-thin text-sm">
</div>
<div
onClick={() => setShowLogin(true)}
className="hover:translate-y-1 my-8 rounded-xl text-center border-white border p-2 hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all">
</div>
<SineWave width={300} height={200} />
</div>
)}
</div>
<div className="flex-1 py-10 px-10">
{showLogin ? (
<>
<div className="text-center text-2xl text-primary select-none">
<span className="px-2"></span>
</div>
<Form
ref={loginFormRef}
onFinish={onFinishLogin}
layout="vertical"
requiredMark="optional"
size="large">
<Form.Item
name="username"
label="帐号"
rules={[
{
required: true,
message: "请输入帐号",
},
]}>
<Input />
</Form.Item>
</motion.div>
<Form.Item
name="password"
label="密码"
rules={[
{
required: true,
message: "请输入密码",
},
]}>
<Input.Password />
</Form.Item>
<div className="flex items-center justify-center">
<Button type="primary" htmlType="submit">
</Button>
</div>
</Form>
</>
) : (
<div>
<div className="text-center text-2xl text-primary">
</div>
<Form
requiredMark="optional"
ref={registerFormRef}
onFinish={onFinishRegister}
layout="vertical"
size="large">
<Form.Item
name="deptId"
label="所属单位"
rules={[
{
required: true,
message: "请选择单位",
},
]}>
<DepartmentSelect
domain={true}></DepartmentSelect>
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item
name="username"
label="帐号"
rules={[
{
required: true,
message: "请输入帐号",
},
{
min: 2,
max: 15,
message:
"帐号长度为 2 到 15 个字符",
},
]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="showname"
label="姓名"
rules={[
{
required: true,
message: "请输入姓名",
},
{
min: 2,
max: 15,
message:
"姓名长度为 2 到 15 个字符",
},
]}>
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item
name="officerId"
label="证件号"
rules={[
{
required: true,
pattern: /^\d{5,12}$/,
message:
"请输入正确的证件号(数字格式)",
},
]}>
<Input />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{
required: true,
message: "请输入密码",
},
{
min: 6,
message: "密码长度不能小于 6 位",
},
]}>
<Input.Password />
</Form.Item>
<Form.Item
name="repeatPass"
label="确认密码"
dependencies={["password"]}
hasFeedback
rules={[
{
required: true,
message: "请再次输入密码",
},
({ getFieldValue }) => ({
validator(_, value) {
if (
!value ||
getFieldValue(
"password"
) === value
) {
return Promise.resolve();
}
return Promise.reject(
new Error(
"两次输入的密码不一致"
)
);
},
}),
]}>
<Input.Password />
</Form.Item>
<div className="flex items-center justify-center">
<Button
loading={registerLoading}
type="primary"
htmlType="submit">
</Button>
</div>
</Form>
</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>
</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

@ -0,0 +1,3 @@
export default function HelpPage() {
return <>help</>
}

View File

@ -0,0 +1,20 @@
import { SearchFilters } from "./SearchFilter";
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>
);
}

View File

@ -0,0 +1,63 @@
import { StarIcon } from '@heroicons/react/24/outline';
import { Letter } from './types';
import { getBadgeStyle } from './utils';
interface LetterCardProps {
letter: Letter;
}
export function LetterCard({ letter }: LetterCardProps) {
return (
<div className="group relative border-b hover:bg-blue-100/30 p-6 transition-all duration-300 ease-in-out ">
<div className="flex flex-col space-y-4">
{/* Header Section */}
<div className="flex justify-between items-center space-x-4">
<div className="flex items-center space-x-3">
<a
target="_blank"
href={`/letters/${letter.id}`}
className="text-xl font-semibold text-navy-900 group-hover:text-blue-700 transition-colors hover:underline"
>
{letter.title}
</a>
</div>
</div>
{/* Meta Information */}
<div className="text-sm text-gray-600 flex items-center justify-between">
<span className="font-medium text-gray-800">{letter.sender} | {letter.unit}</span>
<span>{letter.date}</span>
</div>
{/* Badges Section */}
<div className="flex flex-wrap gap-2 mt-2">
<Badge type="category" value={letter.category} />
<Badge type="status" value={letter.status} />
{letter.priority && <Badge type="priority" value={letter.priority} />}
</div>
{/* Content Preview */}
{letter.content && (
<p className="text-sm text-gray-700 line-clamp-2 leading-relaxed mt-2">
{letter.content}
</p>
)}
</div>
</div>
);
}
function Badge({ type, value }: { type: 'priority' | 'category' | 'status'; value: string }) {
return (
<span
className={`
inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold
${getBadgeStyle(type, value)}
transition-all duration-150 ease-in-out transform hover:scale-105
`}
>
{value.toUpperCase()}
</span>
);
}

View File

@ -0,0 +1,167 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { useMemo } from 'react';
interface PaginationProps {
totalItems: number;
itemsPerPage: number;
currentPage?: number;
onPageChange?: (page: number) => void;
}
export function Pagination({
totalItems,
itemsPerPage,
currentPage = 1,
onPageChange = () => { },
}: PaginationProps) {
const STYLE_CONFIG = {
colors: {
primary: 'bg-[#003875]', // USAF Blue
hover: 'hover:bg-[#00264d]', // Darker USAF Blue
disabled: 'bg-[#e6e6e6]',
text: {
primary: 'text-[#003875]',
light: 'text-white',
secondary: 'text-[#4a4a4a]'
}
},
components: {
container: `
flex items-center justify-between
px-4 py-3 sm:px-6
`,
pagination: `
inline-flex shadow-sm rounded-md
divide-x divide-gray-200
`,
button: `
relative inline-flex items-center justify-center
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
`
}
};
// Memoized calculations
const totalPages = useMemo(() => Math.ceil(totalItems / itemsPerPage), [totalItems, itemsPerPage]);
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
// Navigation handlers
const handlePrevious = () => currentPage > 1 && onPageChange(currentPage - 1);
const handleNext = () => currentPage < totalPages && onPageChange(currentPage + 1);
// Generate page numbers with improved logic
const getPageNumbers = () => {
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
if (currentPage <= 3) {
return [1, 2, 3, '...', totalPages];
}
if (currentPage >= totalPages - 2) {
return [1, '...', totalPages - 2, totalPages - 1, totalPages];
}
return [
1,
'...',
currentPage - 1,
currentPage,
currentPage + 1,
'...',
totalPages
];
};
const renderPageButton = (pageNum: number | string, index: number) => {
if (pageNum === '...') {
return (
<span
key={`ellipsis-${index}`}
className={`
${STYLE_CONFIG.components.button}
${STYLE_CONFIG.colors.text.secondary}
bg-white
`}
>
&#8230;
</span>
);
}
const isCurrentPage = currentPage === pageNum;
const buttonStyle = isCurrentPage
? `
${STYLE_CONFIG.components.button}
${STYLE_CONFIG.colors.primary}
${STYLE_CONFIG.colors.text.light}
${STYLE_CONFIG.colors.hover}
`
: `
${STYLE_CONFIG.components.button}
bg-white
${STYLE_CONFIG.colors.text.primary}
hover:bg-gray-50
`;
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum as number)}
className={buttonStyle}
>
{pageNum}
</button>
);
};
return (
<div className={STYLE_CONFIG.components.container}>
<div className="flex-1 flex items-center justify-between">
<div>
<p className={STYLE_CONFIG.colors.text.secondary}>
Showing <span className="font-semibold">{startItem}</span> to{' '}
<span className="font-semibold">{endItem}</span> of{' '}
<span className="font-semibold">{totalItems}</span> results
</p>
</div>
<nav className="relative z-0 inline-flex">
<div className={STYLE_CONFIG.components.pagination}>
<button
onClick={handlePrevious}
disabled={currentPage === 1}
className={`
${STYLE_CONFIG.components.button}
rounded-l-md
${currentPage === 1 ? 'opacity-50 cursor-not-allowed' : ''}
bg-white
${STYLE_CONFIG.colors.text.primary}
hover:bg-gray-50
`}
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
{getPageNumbers().map(renderPageButton)}
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className={`
${STYLE_CONFIG.components.button}
rounded-r-md
${currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : ''}
bg-white
${STYLE_CONFIG.colors.text.primary}
hover:bg-gray-50
`}
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</div>
</nav>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
interface SearchFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
filterCategory: string;
onCategoryChange: (value: string) => void;
filterStatus: string;
onStatusChange: (value: string) => void;
className?: string
}
export function SearchFilters({
searchTerm,
onSearchChange,
filterCategory,
onCategoryChange,
filterStatus,
onStatusChange,
className
}: SearchFiltersProps) {
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}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
</div>
<FilterDropdowns
filterCategory={filterCategory}
onCategoryChange={onCategoryChange}
filterStatus={filterStatus}
onStatusChange={onStatusChange}
/>
</div>
);
}
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

@ -0,0 +1,56 @@
import { Letter } from "./types";
export const letters: Letter[] = [
{
id: '1',
title: 'F-35 Maintenance Schedule Optimization Proposal',
sender: 'John Doe',
rank: 'TSgt',
unit: '33d Fighter Wing',
date: '2025-01-22',
priority: 'high',
status: 'pending',
category: 'suggestion',
isStarred: false,
content: 'Proposal for improving F-35 maintenance efficiency...'
},
{
id: '2',
title: 'Base Housing Facility Improvement Request',
sender: 'Jane Smith',
rank: 'SSgt',
unit: '96th Test Wing',
date: '2025-01-21',
priority: 'medium',
status: 'in-progress',
category: 'request',
isStarred: true,
content: 'Request for updating base housing facilities...'
},
{
id: '3',
title: 'Training Program Enhancement Feedback',
sender: 'Robert Johnson',
rank: 'Capt',
unit: '58th Special Operations Wing',
date: '2025-01-20',
priority: 'medium',
status: 'pending',
category: 'feedback',
isStarred: false,
content: 'Feedback regarding current training procedures...'
},
{
id: '4',
title: 'Cybersecurity Protocol Update Suggestion',
sender: 'Emily Wilson',
rank: 'MSgt',
unit: '67th Cyberspace Wing',
date: '2025-01-19',
priority: 'high',
status: 'pending',
category: 'suggestion',
isStarred: true,
content: 'Suggestions for improving base cybersecurity measures...'
}
];

View File

@ -1,118 +1,37 @@
import { useState } from 'react';
import {
MagnifyingGlassIcon,
StarIcon,
TrashIcon,
EnvelopeIcon,
FunnelIcon,
} from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
interface Letter {
id: string;
sender: string;
subject: string;
date: string;
priority: 'high' | 'medium' | 'low';
isRead: boolean;
isStarred: boolean;
}
import { letters } from "./constants";
import { Header } from "./Header";
import { LetterCard } from "./LetterCard";
import { Pagination } from "./Pagination";
import { SearchFilters } from "./SearchFilter";
import { useLetterFilters } from "./useLetterFilters";
export default function LetterListPage() {
const [letters, setLetters] = useState<Letter[]>([
{
id: '1',
sender: 'Gen. Charles Q. Brown Jr.',
subject: 'Strategic Force Posture Update',
date: '2024-01-22',
priority: 'high',
isRead: false,
isStarred: true,
},
// ... existing code ...
]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedFilter, setSelectedFilter] = useState('all');
const {
searchTerm,
setSearchTerm,
filterCategory,
setFilterCategory,
filterStatus,
setFilterStatus,
filteredLetters,
} = useLetterFilters(letters);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-[#00538B] text-white p-4 shadow-lg">
<div className="container mx-auto flex items-center gap-2">
<EnvelopeIcon className="w-8 h-8" />
<h1 className="text-2xl font-bold">USAF Leadership Mailbox</h1>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto py-6 px-4">
{/* Search and Filter Bar */}
<div className="flex gap-4 mb-6">
<div className="flex-1 relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search letters..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#00538B] focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<select
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#00538B] appearance-none bg-white"
value={selectedFilter}
onChange={(e) => setSelectedFilter(e.target.value)}
>
<option value="all">All Letters</option>
<option value="unread">Unread</option>
<option value="starred">Starred</option>
<option value="high">High Priority</option>
</select>
</div>
</div>
{/* Letters List */}
<div className="bg-white rounded-lg shadow">
{letters.map((letter) => (
<div
key={letter.id}
className={`flex items-center p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors ${!letter.isRead ? 'font-semibold bg-blue-50' : ''
}`}
>
<div className="flex items-center gap-4 flex-1">
<button
className="focus:outline-none"
onClick={() => {/* Toggle star */ }}
>
{letter.isStarred ? (
<StarIconSolid className="w-6 h-6 text-yellow-400" />
) : (
<StarIcon className="w-6 h-6 text-gray-400 hover:text-yellow-400" />
)}
</button>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-gray-900">{letter.sender}</span>
{letter.priority === 'high' && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-800 rounded-full">
High Priority
</span>
)}
</div>
<div className="text-gray-600">{letter.subject}</div>
</div>
<div className="text-sm text-gray-500">{letter.date}</div>
<button className="p-1 rounded-full hover:bg-gray-100 focus:outline-none">
<TrashIcon className="w-5 h-5 text-gray-400 hover:text-red-500" />
</button>
</div>
</div>
))}
</div>
</main>
// 添加 flex flex-col 使其成为弹性布局容器
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
<Header />
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
<div className="flex-grow">
{filteredLetters.map((letter) => (
<LetterCard key={letter.id} letter={letter} />
))}
</div>
{/* Pagination 会自然固定在底部 */}
<Pagination
totalItems={filteredLetters.length}
itemsPerPage={2}
/>
</div>
);
}

View File

@ -0,0 +1,16 @@
export type Priority = 'high' | 'medium' | 'low';
export type Category = 'complaint' | 'suggestion' | 'request' | 'feedback';
export type Status = 'pending' | 'in-progress' | 'resolved';
export interface Letter {
id: string;
title: string;
sender: string;
rank: string;
unit: string;
date: string;
priority: Priority;
category: Category;
status: Status;
content?: string;
isStarred: boolean;
}

View File

@ -0,0 +1,33 @@
import { useState, useMemo } from 'react';
import { Letter } from './types';
export function useLetterFilters(letters: Letter[]) {
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [filterCategory, setFilterCategory] = useState<string>('all');
const filteredLetters = useMemo(() => {
return letters.filter(letter => {
const matchesSearch = [letter.title, letter.sender, letter.unit]
.some(field => field.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesCategory = filterCategory === 'all' || letter.category === filterCategory;
const matchesStatus = filterStatus === 'all' || letter.status === filterStatus;
return matchesSearch && matchesCategory && matchesStatus;
});
}, [letters, searchTerm, filterCategory, filterStatus]);
return {
currentPage,
setCurrentPage,
searchTerm,
setSearchTerm,
filterStatus,
setFilterStatus,
filterCategory,
setFilterCategory,
filteredLetters,
};
}

View File

@ -0,0 +1,25 @@
export const BADGE_STYLES = {
priority: {
high: 'bg-red-100 text-red-800',
medium: 'bg-yellow-100 text-yellow-800',
low: 'bg-green-100 text-green-800',
},
category: {
complaint: 'bg-orange-100 text-orange-800',
suggestion: 'bg-blue-100 text-blue-800',
request: 'bg-purple-100 text-purple-800',
feedback: 'bg-teal-100 text-teal-800',
},
status: {
pending: 'bg-yellow-100 text-yellow-800',
'in-progress': 'bg-blue-100 text-blue-800',
resolved: 'bg-green-100 text-green-800',
},
} as const;
export const getBadgeStyle = (
type: keyof typeof BADGE_STYLES,
value: string
): string => {
return BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || 'bg-gray-100 text-gray-800';
};

View File

@ -0,0 +1,120 @@
import { useState } from 'react'
interface FeedbackStatus {
status: 'pending' | 'in-progress' | 'resolved'
ticketId: string
submittedDate: string
lastUpdate: string
title: string
}
export default function LetterProgressPage() {
const [feedbackId, setFeedbackId] = useState('')
const [status, setStatus] = useState<FeedbackStatus | null>(null)
// 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'
})
}
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>
{/* 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>
<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>
{/* 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>
</div>
)
}

View File

@ -0,0 +1,41 @@
import { FunnelIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { leaders } from "./mock";
import { useMemo, useState } from "react";
import { Leader } from "./types";
export default function Filter() {
const [selectedLeader, setSelectedLeader] = useState<Leader | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedDivision, setSelectedDivision] = useState<string>('all');
const divisions = useMemo(() => {
return ['all', ...new Set(leaders.map(leader => leader.division))];
}, []);
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" />
<input
type="text"
placeholder="Search by name or rank..."
className="w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-[#00308F] focus:border-transparent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 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}
onChange={(e) => setSelectedDivision(e.target.value)}
>
{divisions.map(division => (
<option key={division} value={division}>
{division === 'all' ? 'All Divisions' : division}
</option>
))}
</select>
</div>
</div>
}

View File

@ -0,0 +1,46 @@
export default function Header() {
return <header className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white p-6">
<div className="flex flex-col space-y-6">
{/* 主标题 */}
<div>
<h1 className="text-3xl font-bold tracking-wider">
</h1>
<p className="mt-2 text-blue-100 text-lg">
</p>
</div>
{/* 隐私保护说明 */}
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span></span>
</div>
</div>
{/* 隐私承诺 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p></p>
</div>
</div>
</header>
}

View File

@ -3,6 +3,8 @@ import { motion } from 'framer-motion';
import { FunnelIcon, MagnifyingGlassIcon, PaperAirplaneIcon } from '@heroicons/react/24/outline';
import { Leader } from './types';
import { leaders } from './mock';
import Header from './header';
import Filter from './filter';
export default function WriteLetterPage() {
@ -25,83 +27,11 @@ export default function WriteLetterPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-100 to-slate-200">
{/* Header Banner */}
<div className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white py-8">
<div className="container mx-auto px-4">
<div className="flex flex-col space-y-6">
{/* 主标题 */}
<div>
<h1 className="text-3xl font-bold tracking-wider">
</h1>
<p className="mt-2 text-blue-100 text-lg">
</p>
</div>
{/* 隐私保护说明 */}
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span></span>
</div>
</div>
{/* 隐私承诺 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p></p>
</div>
</div>
</div>
</div>
<Header></Header>
{/* 搜索和筛选区域 */}
<div className="container mx-auto px-4 py-8">
<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" />
<input
type="text"
placeholder="Search by name or rank..."
className="w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-[#00308F] focus:border-transparent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 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}
onChange={(e) => setSelectedDivision(e.target.value)}
>
{divisions.map(division => (
<option key={division} value={division}>
{division === 'all' ? 'All Divisions' : division}
</option>
))}
</select>
</div>
</div>
<div className=" px-4 py-8">
<Filter></Filter>
{/* Modified Leader Cards Grid */}
<div className="grid grid-cols-1 gap-6">
{filteredLeaders.map((leader) => (

View File

@ -1,25 +1,20 @@
import { MagnifyingGlassIcon, UserIcon } from "@heroicons/react/24/outline";
import { Link, NavLink } from "react-router-dom";
import { memo } from "react";
import { NAV_ITEMS } from "./constants";
import { SearchBar } from "./SearchBar";
import { Logo } from "./Logo";
import Navigation from "./navigation";
interface HeaderProps {
onSearch?: (query: string) => void;
}
export const Header = memo(function Header({ onSearch }: HeaderProps) {
return (
<header className="sticky top-0 z-50 bg-[#13294B] text-white shadow-lg">
<div className="container mx-auto px-4">
{/* Main Content */}
<div className="py-4">
<div className="flex items-center justify-between">
<Logo />
<SearchBar onSearch={onSearch} />
{/* User Actions */}
<div className="flex items-center space-x-6">
<Link
to="/login"
@ -36,49 +31,7 @@ export const Header = memo(function Header({ onSearch }: HeaderProps) {
</div>
</div>
</div>
{/* Navigation */}
<nav className="mt-4 rounded-lg bg-[#0B1A32]/90">
<div className="flex items-center justify-between px-6 py-1">
<div className="flex space-x-1">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `
group relative px-4 py-3 rounded-md
transition-all duration-300 ease-out
outline-none
${isActive ? 'text-white font-medium' : 'text-[#8EADD4]'}
`}
>
{({ isActive }) => (
<>
<span className="relative z-10 transition-colors group-hover:text-white">
{item.label}
</span>
<span className={`
absolute inset-0 rounded-md bg-gradient-to-b
from-white/10 to-transparent transition-opacity
duration-300 ease-out opacity-0 group-hover:opacity-10
${isActive ? 'opacity-5' : ''}
`} />
<span className={`
absolute bottom-1.5 left-1/2 h-[2px] bg-white
transition-all duration-300 ease-out
transform -translate-x-1/2
${isActive
? 'w-12 opacity-100'
: 'w-0 opacity-0 group-hover:w-8 group-hover:opacity-50'
}
`} />
</>
)}
</NavLink>
))}
</div>
</div>
</nav>
<Navigation></Navigation>
</div>
</header>
);

View File

@ -1,7 +1 @@
export const NAV_ITEMS = [
{ to: "/write-letter", label: "Write Letter" },
{ to: "/search-letter", label: "Search Letters" },
{ to: "/public-letters", label: "Public Letters" },
{ to: "/announcements", label: "Announcements" },
{ to: "/guidelines", label: "Guidelines" },
] as const;
export { }

View File

@ -0,0 +1,41 @@
import { NavLink } from "react-router-dom";
import { useNavItem } from "./useNavItem";
export default function Navigation() {
const { navItems } = useNavItem()
return <nav className="mt-4 rounded-lg bg-[#0B1A32]/90">
<div className="flex items-center justify-between px-6 py-1">
<div className="flex space-x-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `
group relative px-4 py-3
transition-all duration-300 ease-out
${isActive ? 'text-white font-medium' : 'text-[#8EADD4]'}
`}
>
{({ isActive }) => (
<>
<span className="relative z-10 transition-colors group-hover:text-white">
{item.label}
</span>
<span className={`
absolute bottom-1.5 left-1/2 h-[2px] bg-white
transition-all duration-300 ease-out
transform -translate-x-1/2
${isActive
? 'w-12 opacity-100'
: 'w-0 opacity-0 group-hover:w-8 group-hover:opacity-50'
}
`} />
</>
)}
</NavLink>
))}
</div>
</div>
</nav>
}

View File

@ -0,0 +1,38 @@
import { api } from "@nice/client";
import { TaxonomySlug } from "@nice/common";
import { useMemo } from "react";
// export const NAV_ITEMS = [
// { to: "/write-letter", label: "咨询求助" },
// { to: "/write-letter", label: "解难帮困" },
// { to: "/write-letter", label: "需求提报" },
// { to: "/write-letter", label: "意见建议" },
// { to: "/write-letter", label: "问题反映" },
// { to: "/write-letter", label: "举报投诉" },
// ] as const;
export function useNavItem() {
const { data } = api.term.findMany.useQuery({
where: {
taxonomy: { slug: TaxonomySlug.CATEGORY }
}
});
const navItems = useMemo(() => {
const defaultItems = [
{ to: "/letter-list", label: "公开信件" },
{ to: "/letter-progress", label: "进度查询" },
{ to: "/help", label: "使用帮助" },
];
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

@ -19,67 +19,11 @@ export const useAppTheme = () => useContext(AppThemeContext);
export default function ThemeProvider({ children }: { children: ReactNode }) {
const { token } = theme.useToken();
const applyTheme = (tailwindTheme: TailwindTheme) => {
for (let key in tailwindTheme) {
document.documentElement.style.setProperty(key, tailwindTheme[key]);
}
};
// const agTheme = useMemo(
// () =>
// themeQuartz.withPart(iconSetQuartzLight).withParams({
// accentColor: token.colorPrimary,
// backgroundColor: token.colorBgContainer,
// borderColor: token.colorBorderSecondary,
// // borderRadius: 2,
// browserColorScheme: "light",
// cellHorizontalPaddingScale: 0.7,
// fontSize: token.fontSize,
// foregroundColor: token.colorText,
// headerBackgroundColor: token.colorFillQuaternary,
// headerFontSize: token.fontSize,
// headerFontWeight: 600,
// headerTextColor: token.colorPrimary,
// rowBorder: true,
// rowVerticalPaddingScale: 0.9,
// sidePanelBorder: true,
// spacing: 6,
// oddRowBackgroundColor: token.colorFillQuaternary,
// wrapperBorder: true,
// wrapperBorderRadius: 0,
// // headerRowBorder: true,
// // columnBorder: true,
// // headerRowBorder: true,
// pinnedRowBorder: true
// }),
// [token]
// );
// const subTableTheme = useMemo(
// () =>
// themeQuartz.withPart(iconSetQuartzLight).withParams({
// accentColor: token.colorTextSecondary, // 可以使用不同的强调色
// backgroundColor: token.colorBgLayout,
// borderColor: token.colorBorderSecondary,
// fontSize: token.fontSizeSM, // 可以使用不同的字体大小
// foregroundColor: token.colorTextSecondary,
// headerBackgroundColor: token.colorFillSecondary,
// headerFontSize: token.fontSizeSM,
// headerFontWeight: 500, // 可以使用不同的字体粗细
// headerTextColor: token.colorTextTertiary,
// rowBorder: false, // 可以选择不显示行边框
// rowVerticalPaddingScale: 0.6,
// sidePanelBorder: false,
// spacing: 4,
// oddRowBackgroundColor: token.colorFillQuaternary,
// wrapperBorder: false,
// wrapperBorderRadius: 0,
// columnBorder: false,
// }),
// [token]
// );
const tailwindTheme: TailwindTheme = useMemo(
() => ({
"--color-primary": token.colorPrimary,

View File

@ -11,7 +11,6 @@ import TermAdminPage from "../app/admin/term/page";
import StaffAdminPage from "../app/admin/staff/page";
import RoleAdminPage from "../app/admin/role/page";
import WithAuth from "../components/utils/with-auth";
import LoginPage from "../app/login";
import BaseSettingPage from "../app/admin/base-setting/page";
import { MainLayout } from "../components/layout/main/MainLayout";
import HomePage from "../app/main/home/page";
@ -22,6 +21,9 @@ import CourseSettingForm from "../components/models/course/editor/form/CourseSet
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
import WriteLetterPage from "../app/main/letter/write/page";
import LetterListPage from "../app/main/letter/list/page";
import LetterProgressPage from "../app/main/letter/progress/page";
import HelpPage from "../app/main/help/page";
import AuthPage from "../app/auth/page";
import React from "react";
import LetterEditorLayout from "../components/models/post/LetterEditor/layout/LetterEditorLayout";
import { LetterBasicForm } from "../components/models/post/LetterEditor/form/LetterBasicForm";
@ -82,9 +84,17 @@ export const routes: CustomRouteObject[] = [
element: <WriteLetterPage></WriteLetterPage>,
},
{
path: "list",
element: <LetterListPage></LetterListPage>,
path: "letter-list",
element: <LetterListPage></LetterListPage>
}
, {
path: 'letter-progress',
element: <LetterProgressPage></LetterProgressPage>
},
{
path: 'help',
element: <HelpPage></HelpPage>
}
],
},
@ -231,9 +241,9 @@ export const routes: CustomRouteObject[] = [
],
},
{
path: "/login",
path: "/auth",
breadcrumb: "登录",
element: <LoginPage></LoginPage>,
element: <AuthPage></AuthPage>,
},
];

View File

@ -20,8 +20,6 @@ export function useTerm() {
},
});
const update = api.term.update.useMutation({
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey });
@ -61,11 +59,9 @@ export function useTerm() {
};
return {
create,
update,
softDeleteByIds,
getTerm,
upsertTags,
upsertTags
};
}

View File

@ -60,15 +60,7 @@ export const InitTaxonomies: { name: string; slug: string }[] = [
{
name: "分类",
slug: TaxonomySlug.CATEGORY,
},
{
name: "研判单元",
slug: TaxonomySlug.UNIT,
},
{
name: "标签",
slug: TaxonomySlug.TAG,
},
}
];
export const InitAppConfigs: Prisma.AppConfigCreateInput[] = [
{