Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
7df1e997c1
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
export interface LoginFormInputs {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
export interface RegisterFormInputs extends LoginFormInputs {
|
||||
deptId: string;
|
||||
officerId: string;
|
||||
showname: string;
|
||||
repeatPass: string;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export default function HelpPage() {
|
||||
return <>help</>
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
`}
|
||||
>
|
||||
…
|
||||
</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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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...'
|
||||
}
|
||||
];
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
};
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
|
||||
}
|
|
@ -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) => (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 { }
|
|
@ -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>
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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>,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue