This commit is contained in:
ditiqi 2025-01-25 21:20:56 +08:00
commit 8f43808ea2
22 changed files with 542 additions and 373 deletions

View File

@ -269,15 +269,18 @@ export class TermService extends BaseTreeService<Prisma.TermDelegate> {
}
async getChildSimpleTree(
staff: UserProfile,
staff: UserProfile | null | undefined, // 允许 staff 为 null 或 undefined
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
) {
const { domainId = null, permissions } = staff;
const hasAnyPerms =
staff?.permissions?.includes(RolePerms.MANAGE_ANY_TERM) ||
staff?.permissions?.includes(RolePerms.READ_ANY_TERM);
// 处理 staff 不存在的情况,设置默认值
const domainId = staff?.domainId ?? null;
const permissions = staff?.permissions ?? [];
// 权限判断逻辑调整
const hasAnyPerms = permissions.includes(RolePerms.MANAGE_ANY_TERM) ||
permissions.includes(RolePerms.READ_ANY_TERM);
const { termIds, parentId, taxonomyId } = data;
// 提取非空 deptIds
const validTermIds = termIds?.filter((id) => id !== null) ?? [];
const hasNullTermId = termIds?.includes(null) ?? false;
@ -286,24 +289,19 @@ export class TermService extends BaseTreeService<Prisma.TermDelegate> {
where: {
...(termIds && {
OR: [
...(validTermIds.length
? [{ ancestorId: { in: validTermIds } }]
: []),
...(validTermIds.length ? [{ ancestorId: { in: validTermIds } }] : []),
...(hasNullTermId ? [{ ancestorId: null }] : []),
],
}),
descendant: {
taxonomyId: taxonomyId,
// 动态权限控制条件
...(hasAnyPerms
? {} // 当有全局权限时,不添加任何额外条件
: {
// 当无全局权限时添加域ID过滤
OR: [
{ domainId: null }, // 通用记录
{ domainId: domainId }, // 特定域记录
],
}),
// 当 staff 不存在时 hasAnyPerms 自动为 false会应用域过滤
...(hasAnyPerms ? {} : {
OR: [
{ domainId: null },
...(domainId ? [{ domainId }] : []) // 如果 domainId 为 null 则不添加
].filter(Boolean) // 过滤空数组
}),
},
ancestorId: parentId,
relDepth: 1,
@ -315,31 +313,28 @@ export class TermService extends BaseTreeService<Prisma.TermDelegate> {
}),
termIds
? db.term.findMany({
where: {
...(termIds && {
OR: [
...(validTermIds.length
? [{ id: { in: validTermIds } }]
: []),
],
}),
taxonomyId: taxonomyId,
// 动态权限控制条件
...(hasAnyPerms
? {} // 当有全局权限时,不添加任何额外条件
: {
// 当无全局权限时添加域ID过滤
OR: [
{ domainId: null }, // 通用记录
{ domainId: domainId }, // 特定域记录
],
}),
},
include: { children: true },
orderBy: { order: 'asc' },
})
where: {
...(termIds && {
OR: [
...(validTermIds.length ? [{ id: { in: validTermIds } }] : []),
],
}),
taxonomyId: taxonomyId,
// 同理处理第二个查询的权限
...(hasAnyPerms ? {} : {
OR: [
{ domainId: null },
...(domainId ? [{ domainId }] : [])
].filter(Boolean)
}),
},
include: { children: true },
orderBy: { order: 'asc' },
})
: [],
]);
// 后续处理保持不变...
const children = childrenData
.map(({ descendant }) => descendant)
.filter(Boolean)
@ -349,77 +344,68 @@ export class TermService extends BaseTreeService<Prisma.TermDelegate> {
}
async getParentSimpleTree(
staff: UserProfile,
staff: UserProfile | null | undefined, // 允许 staff 为 null 或 undefined
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
) {
const { domainId = null, permissions } = staff;
// 安全解构 staff 属性并设置默认值
const domainId = staff?.domainId ?? null;
const permissions = staff?.permissions ?? [];
// 权限判断逻辑(自动处理空权限数组)
const hasAnyPerms =
permissions.includes(RolePerms.READ_ANY_TERM) ||
permissions.includes(RolePerms.MANAGE_ANY_TERM);
// 解构输入参数
const { termIds, taxonomyId } = data;
// 并行查询父级部门ancestry和自身部门数据
// 使用Promise.all提高查询效率,减少等待时间
// 构建通用权限过滤条件
const buildDomainFilter = () => ({
OR: [
{ domainId: null }, // 始终包含公共记录
...(domainId ? [{ domainId }] : []) // 动态添加域过滤
].filter(Boolean) // 确保数组有效性
});
const [parentData, selfData] = await Promise.all([
// 查询指定部门的所有祖先节点,包含子节点和父节点信息
db.termAncestry.findMany({
where: {
descendantId: { in: termIds }, // 查询条件:descendant在给定的部门ID列表中
descendantId: { in: termIds },
ancestor: {
taxonomyId: taxonomyId,
...(hasAnyPerms
? {} // 当有全局权限时,不添加任何额外条件
: {
// 当无全局权限时添加域ID过滤
OR: [
{ domainId: null }, // 通用记录
{ domainId: domainId }, // 特定域记录
],
}),
},
taxonomyId,
// 应用动态权限过滤
...(hasAnyPerms ? {} : buildDomainFilter())
}
},
include: {
ancestor: {
include: {
children: true, // 包含子节点信息
parent: true, // 包含父节点信息
},
},
children: true,
parent: true
}
}
},
orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序
orderBy: { ancestor: { order: 'asc' } }
}),
// 查询自身部门数据
db.term.findMany({
where: {
id: { in: termIds },
taxonomyId: taxonomyId,
...(hasAnyPerms
? {} // 当有全局权限时,不添加任何额外条件
: {
// 当无全局权限时添加域ID过滤
OR: [
{ domainId: null }, // 通用记录
{ domainId: domainId }, // 特定域记录
],
}),
taxonomyId,
// 应用相同的权限过滤逻辑
...(hasAnyPerms ? {} : buildDomainFilter())
},
include: { children: true }, // 包含子节点信息
orderBy: { order: 'asc' }, // 按顺序升序排序
}),
include: { children: true },
orderBy: { order: 'asc' }
})
]);
// 处理父级节点:过滤并映射为简单树结构
// 数据处理保持不变...
const parents = parentData
.map(({ ancestor }) => ancestor) // 提取祖先节点
.filter((ancestor) => ancestor) // 过滤有效且超出根节点层级的节点
.map(mapToTermSimpleTree); // 映射为简单树结构
.map(({ ancestor }) => ancestor)
.filter(Boolean)
.map(mapToTermSimpleTree);
// 处理自身节点:映射为简单树结构
const selfItems = selfData.map(mapToTermSimpleTree);
// 合并并去重父级和自身节点,返回唯一项
return getUniqueItems([...parents, ...selfItems], 'id');
}
}

View File

@ -31,12 +31,12 @@ export const LoginForm = ({ onSubmit, isLoading }: LoginFormProps) => {
label="用户名"
name="username"
rules={[
{ required: true, message: "Username is required" },
{ min: 2, message: "Username must be at least 2 characters" }
{ required: true, message: "请输入用户名" },
{ min: 2, message: "用户名至少需要2个字符" }
]}
>
<Input
placeholder="Username"
placeholder="请输入用户名"
className="rounded-lg"
/>
</Form.Item>
@ -45,11 +45,11 @@ export const LoginForm = ({ onSubmit, isLoading }: LoginFormProps) => {
label="密码"
name="password"
rules={[
{ required: true, message: "Password is required" }
{ required: true, message: "请输入密码" }
]}
>
<Input.Password
placeholder="Password"
placeholder="请输入密码"
className="rounded-lg"
/>
</Form.Item>

View File

@ -6,11 +6,6 @@ import { LoginForm } from "./login";
import { Card, Typography, Button, Spin, Divider } from "antd";
import { AnimatePresence, motion } from "framer-motion";
import { useAuthForm } from "./useAuthForm";
import {
GithubOutlined,
GoogleOutlined,
LoadingOutlined
} from '@ant-design/icons';
const { Title, Text, Paragraph } = Typography;
@ -41,23 +36,22 @@ const AuthPage: React.FC = () => {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card
className="w-full max-w-5xl shadow-xl rounded-3xl overflow-hidden transition-all duration-300 hover:shadow-3xl relative backdrop-blur-sm bg-white/90"
bodyStyle={{ padding: 0 }}
<div
className="w-full max-w-5xl shadow-elegant border-2 border-white rounded-xl overflow-hidden transition-all duration-300 relative"
>
<div className="flex flex-col md:flex-row min-h-[650px]">
{/* Left Panel - Welcome Section */}
<div className="w-full md:w-1/2 p-12 bg-gradient-to-br from-primary to-primary-400 text-white flex flex-col justify-center relative overflow-hidden">
<div className="w-full md:w-1/2 p-12 bg-gradient-to-br from-primary to-primary-600 text-white flex flex-col justify-center relative overflow-hidden">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<div className=" text-4xl text-white mb-4">
Leader Mail
<div className="text-4xl text-white mb-4 font-serif">
</div>
<Paragraph className="text-lg mb-8 text-blue-100">
<Paragraph className="text-lg mb-8 text-blue-100 leading-relaxed text-justify">
</Paragraph>
{showLogin && (
<Button
@ -67,7 +61,7 @@ const AuthPage: React.FC = () => {
onClick={toggleForm}
className="w-fit hover:bg-white hover:text-blue-700 transition-all"
>
</Button>
)}
</motion.div>
@ -88,13 +82,13 @@ const AuthPage: React.FC = () => {
<motion.div className="mb-8">
<Title level={3} className="mb-2"></Title>
<Text type="secondary">
{' '}
使{' '}
<Button
type="link"
onClick={toggleForm}
className="p-0 font-medium"
>
</Button>
</Text>
</motion.div>
@ -113,15 +107,15 @@ const AuthPage: React.FC = () => {
transition={{ duration: 0.3 }}
>
<motion.div className="mb-8">
<Title level={3} className="mb-2"></Title>
<Title level={3} className="mb-2"></Title>
<Text type="secondary">
{' '}
{' '}
<Button
type="link"
onClick={toggleForm}
className="p-0 font-medium"
>
</Button>
</Text>
</motion.div>
@ -135,7 +129,7 @@ const AuthPage: React.FC = () => {
</AnimatePresence>
</div>
</div>
</Card>
</div>
</motion.div>
</div>

View File

@ -122,7 +122,7 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
loading={isLoading}
className="w-full h-10 rounded-lg"
>
{isLoading ? "正在创建账户..." : "创建账户"}
{isLoading ? "正在注册..." : "注册"}
</Button>
</Form.Item>
</Form>

View File

@ -9,7 +9,7 @@ export default function LetterEditorPage() {
const termId = searchParams.get("termId");
return (
<div className="min-h-screen rounded-xl bg-gradient-to-b from-slate-100 to-slate-50 ">
<div className="min-h-screen rounded-xl shadow-elegant border-2 border-white bg-gradient-to-b from-slate-100 to-slate-50 ">
<WriteHeader></WriteHeader>
<LetterFormProvider receiverId={receiverId} termId={termId}>
<LetterBasicForm />

View File

@ -1,20 +1,72 @@
export function Header() {
return (
<header className="bg-gradient-to-r from-primary to-primary-400 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 flex-wrap gap-x-6 gap-y-2">
<span></span>
<span></span>
<span></span>
<header className="bg-gradient-to-r from-primary to-primary-400 p-6 rounded-t-xl">
<div className="flex flex-col space-y-6">
{/* 主标题区域 */}
<div>
<h1 className="text-3xl font-bold tracking-wider text-white"></h1>
<p className="mt-2 text-blue-100 text-lg">
</p>
</div>
{/* 服务特点说明 */}
<div className="flex flex-wrap gap-6 text-sm text-white">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<span></span>
</div>
</div>
{/* 服务宗旨说明 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p className="leading-relaxed">
</p>
</div>
</div>
</header>
);
}
}

View File

@ -5,7 +5,7 @@ export default function LetterListPage() {
return (
// 添加 flex flex-col 使其成为弹性布局容器
<div className="min-h-screen rounded-xl overflow-hidden bg-gradient-to-b from-slate-100 to-slate-50 flex flex-col">
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-gradient-to-b from-slate-100 to-slate-50 flex flex-col">
<Header />
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}

View File

@ -0,0 +1,70 @@
export default function ProgressHeader() {
return <header className=" rounded-t-xl bg-gradient-to-r from-primary to-primary-400 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

@ -1,6 +1,7 @@
import { useState } from "react";
import { Input, Button, Card, Steps, Tag, Spin, message } from "antd";
import { SearchOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
import ProgressHeader from "./ProgressHeader";
interface FeedbackStatus {
status: "pending" | "processing" | "resolved";
@ -62,61 +63,37 @@ export default function LetterProgressPage() {
return (
<div
className="min-h-screen bg-white"
className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-gradient-to-b from-slate-100 to-slate-50 "
style={{ fontFamily: "Arial, sans-serif" }}>
{/* Header */}
<header className="bg-[#003366] p-8 text-white">
<div className="container mx-auto flex items-center">
<SafetyCertificateOutlined className="text-4xl mr-4" />
<div>
<h1 className="text-3xl font-bold mb-2">
USAF Feedback Tracking System
</h1>
<p className="text-lg">
Enter your ticket ID to track progress
</p>
</div>
</div>
</header>
<ProgressHeader></ProgressHeader>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
{/* Search Section */}
<Card className="mb-8 border border-gray-200">
<div className="space-y-4">
<label className="block text-lg font-medium text-[#003366]">
Ticket ID
</label>
<div className="flex gap-4">
<Input
prefix={
<SearchOutlined className="text-[#003366]" />
}
size="large"
value={feedbackId}
onChange={(e) => setFeedbackId(e.target.value)}
placeholder="e.g. USAF-2025-0123"
status={error ? "error" : ""}
className="border border-gray-300"
/>
<Button
type="primary"
size="large"
icon={<SearchOutlined />}
loading={loading}
onClick={mockLookup}
style={{
backgroundColor: "#003366",
borderColor: "#003366",
}}>
Track
</Button>
</div>
{error && (
<p className="text-red-600 text-sm">{error}</p>
)}
<main className="container mx-auto p-6">
<div className="space-y-4">
<div className="flex gap-4">
<Input
prefix={
<SearchOutlined className=" text-secondary-300" />
}
size="large"
value={feedbackId}
onChange={(e) => setFeedbackId(e.target.value)}
placeholder="请输入信件编码查询处理状态"
status={error ? "error" : ""}
className="border border-gray-300"
/>
<Button
type="primary"
size="large"
loading={loading}
onClick={mockLookup}
>
</Button>
</div>
</Card>
{error && (
<p className="text-red-600 text-sm">{error}</p>
)}
</div>
{/* Results Section */}
{status && (

View File

@ -1,7 +1,7 @@
import { motion } from 'framer-motion';
import { StaffDto } from "@nice/common";
import { Button, Tooltip, Badge } from 'antd';
import { SendOutlined } from '@ant-design/icons';
import { BankFilled, SendOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
export interface SendCardProps {
@ -13,15 +13,12 @@ export function SendCard({ staff, termId }: SendCardProps) {
const navigate = useNavigate();
const handleSendLetter = () => {
navigate(`/editor?termId=${termId || ''}&receiverId=${staff.id}`);
window.open(`/editor?termId=${termId || ''}&receiverId=${staff.id}`, '_blank');
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.005 }}
transition={{ duration: 0.2 }}
className="bg-white rounded-xl shadow-lg overflow-hidden border-2 border-gray-100 hover:border-primary-200 transition-all duration-300"
<div
className="bg-white rounded-xl overflow-hidden border-2 hover:border-primary transition-all duration-300"
>
<div className="flex flex-col sm:flex-row">
{/* Image Container */}
@ -61,11 +58,14 @@ export function SendCard({ staff, termId }: SendCardProps) {
<div>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-2xl font-semibold text-gray-900">
{staff?.showname}
{staff?.showname || staff?.username}
</h3>
<Badge status="success" />
</div>
<p className="text-gray-600 text-lg font-medium">{staff.department?.name || '未设置部门'}</p>
<p className="text-gray-600 text-lg font-medium flex items-center gap-2">
<BankFilled></BankFilled>
{staff.department?.name || '未设置部门'}</p>
</div>
<Tooltip title="职级">
<span className="inline-flex items-center px-4 py-1.5 text-sm font-medium bg-gradient-to-r from-blue-50 to-blue-100 text-primary rounded-full hover:from-blue-100 hover:to-blue-200 transition-colors shadow-sm">
@ -112,6 +112,6 @@ export function SendCard({ staff, termId }: SendCardProps) {
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@ -1,10 +1,12 @@
export default function WriteHeader() {
import { TermDto } from "@nice/common";
export default function WriteHeader({ term }: { term?: TermDto }) {
return <header className=" rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6">
<div className="flex flex-col space-y-6">
{/* 主标题 */}
<div>
<h1 className="text-3xl font-bold tracking-wider">
{term?.name}
</h1>
<p className="mt-2 text-blue-100 text-lg">

View File

@ -4,7 +4,7 @@ import { useSearchParams } from 'react-router-dom';
import { SendCard } from './SendCard';
import { Spin, Empty, Input, Alert, Pagination } from 'antd';
import { api } from '@nice/client';
import { api, useTerm } from '@nice/client';
import DepartmentSelect from '@web/src/components/models/department/department-select';
import debounce from 'lodash/debounce';
import { SearchOutlined } from '@ant-design/icons';
@ -17,6 +17,7 @@ export default function WriteLetterPage() {
const [selectedDept, setSelectedDept] = useState<string>();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
const { getTerm } = useTerm()
const { data, isLoading, error } = api.staff.findManyWithPagination.useQuery({
page: currentPage,
@ -45,8 +46,8 @@ export default function WriteLetterPage() {
}, [searchQuery, selectedDept, resetPage]);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-100 to-slate-50">
<WriteHeader />
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-gradient-to-b from-slate-100 to-slate-50">
<WriteHeader term={getTerm(termId)} />
<div className="container mx-auto px-4 py-8">
<div className="mb-8 space-y-4">
{/* Search and Filter Section */}
@ -120,7 +121,10 @@ export default function WriteLetterPage() {
current={currentPage}
total={data?.totalPages || 0}
pageSize={pageSize}
onChange={(page) => setCurrentPage(page)}
onChange={(page) => {
setCurrentPage(page);
window.scrollTo(0, 0);
}}
showSizeChanger={false}
showTotal={(total) => `${total} 条记录`}
/>

View File

@ -0,0 +1,77 @@
import React from 'react';
interface LogoProps {
size?: 'small' | 'medium' | 'large';
className?: string;
}
const InnovationLogo: React.FC<LogoProps> = ({
size = 'medium',
className = ''
}) => {
const sizeClasses = {
small: 'w-8 h-8',
medium: 'w-16 h-16',
large: 'w-24 h-24'
};
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 200 200"
className={`${sizeClasses[size]} ${className}`}
>
{/* 背景圆形 */}
<circle
cx="100"
cy="100"
r="90"
fill="#1A1A1A"
className="transition-colors duration-300"
/>
{/* 六边形原子核心 */}
<polygon
points="100,20 160,55 160,125 100,160 40,125 40,55"
fill="none"
stroke="#61DAFB"
strokeWidth="12"
className="transition-all duration-500 hover:stroke-blue-400"
/>
{/* 三个原子轨道 */}
<g stroke="#61DAFB" strokeWidth="4" fill="none">
{/* 外层轨道 */}
<circle
cx="100"
cy="100"
r="80"
className="animate-spin-slow opacity-50"
/>
{/* 中层轨道 */}
<circle
cx="100"
cy="100"
r="60"
className="animate-spin-reverse-slow opacity-50"
/>
{/* 内层轨道 */}
<circle
cx="100"
cy="100"
r="40"
className="animate-spin-slow opacity-50"
/>
</g>
{/* 闪电/创新元素 */}
<path
d="M80,110 L120,90 L100,140 Z"
fill="#FFD700"
className="transition-all duration-300 hover:scale-110"
/>
</svg>
);
};
export default InnovationLogo;

View File

@ -1,46 +0,0 @@
import { Link } from "react-router-dom";
export const Logo = () => (
<Link to="/" className="flex items-center space-x-3 group rounded-lg focus:outline-none focus:ring-2 focus:ring-[#8EADD4]" aria-label="Go to homepage">
<div className="relative h-12 w-12 transform transition-transform group-hover:scale-105">
<svg
viewBox="0 0 100 100"
className="h-full w-full transition-transform duration-300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Mail envelope base */}
<rect
x="10"
y="25"
width="80"
height="50"
rx="4"
className="fill-[#8EADD4] transition-colors duration-300 group-hover:fill-[#6B8CB3] rounded-lg"
/>
{/* Envelope flap */}
<path
d="M10 29L50 55L90 29"
strokeWidth="3"
strokeLinecap="round"
className="stroke-white"
/>
{/* People silhouette */}
<path
d="M40 45C40 45 35 50 30 50C25 50 20 45 20 45C20 45 25 55 30 55C35 55 40 45 40 45Z"
className="fill-white"
/>
<circle cx="30" cy="42" r="5" className="fill-white" />
{/* Leadership star */}
<path
d="M70 42L72.5 47L78 48L74 52L75 57L70 54.5L65 57L66 52L62 48L67.5 47L70 42Z"
className="fill-white transition-transform origin-center group-hover:rotate-45 duration-500"
/>
</svg>
</div>
</Link>
);

View File

@ -1,78 +1,69 @@
import { PhoneIcon } from '@heroicons/react/24/outline'
import { PhoneOutlined, MailOutlined, CloudOutlined, HomeOutlined, FileSearchOutlined, FireTwoTone, FireOutlined } from '@ant-design/icons';
import Logo from '../../common/element/Logo';
export function Footer() {
return (
<footer className="bg-gradient-to-b from-[#13294B] to-[#0c1c33] text-gray-300">
<div className="container mx-auto px-6 py-10">
{/* Main Footer Content */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 mb-8">
{/* Logo and Main Info */}
<div className="text-center md:text-left">
<div className="flex md:justify-start justify-center mb-4 group">
<img
src="/usaf-emblem.png"
alt="USAF Official Emblem"
className="h-16 w-auto transform transition-all duration-300
group-hover:scale-105 group-hover:brightness-110
drop-shadow-lg"
/>
</div>
<h3 className="text-white font-bold text-lg tracking-wide mb-2
drop-shadow-md">
<footer className="bg-gradient-to-b from-primary-600 to-primary-800 text-secondary-200">
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 开发组织信息 */}
<div className="text-center md:text-left space-y-2">
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
</h3>
<p className="text-tertiary-300 text-sm">
<p className="text-gray-400 text-xs">
</p>
</div>
{/* Technical Support */}
<div className="text-center md:text-left md:pl-6 md:border-l border-gray-700">
<h4 className="text-white font-semibold mb-4 text-lg tracking-wide
drop-shadow-md">
</h4>
<div className="space-y-4">
<div className="bg-gradient-to-br from-[#1a3a6a] to-[#234785]
p-4 rounded-lg shadow-lg hover:shadow-xl
transition-all duration-300 transform hover:-translate-y-0.5">
<p className="text-white font-medium mb-2"> </p>
<p className="text-gray-300 text-sm"></p>
<div className="mt-3 flex items-center justify-center md:justify-start gap-2
text-sm text-gray-300">
<PhoneIcon className="w-4 h-4" />
<span>1-800-XXX-XXXX</span>
</div>
</div>
<p className="text-tertiary-300 text-sm italic">
24/7
</p>
{/* 联系方式 */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center space-x-2">
<PhoneOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">1-800-XXX-XXXX</span>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">support@example.com</span>
</div>
</div>
{/* 系统链接 */}
<div className="text-center md:text-right space-y-2">
<div className="flex items-center justify-center md:justify-end space-x-4">
<a
href="https://portal.example.com"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站"
>
<HomeOutlined className="text-lg" />
</a>
<a
href="https://nextcloud.example.com"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火青云"
>
<CloudOutlined className="text-lg" />
</a>
<a
href="https://regulation.example.com"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火律询"
>
<FileSearchOutlined className="text-lg" />
</a>
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-gray-700/50 my-6"></div>
{/* Bottom Section */}
<div className="text-center">
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2 mb-4 text-sm
text-tertiary-300">
<span className="hover:text-gray-300 transition-colors duration-200">
</span>
<span className="hover:text-gray-300 transition-colors duration-200">
</span>
<span className="hover:text-gray-300 transition-colors duration-200">
</span>
</div>
<p className="text-tertiary-300 text-sm">
© {new Date().getFullYear()} United States Air Force. All rights reserved.
{/* 版权信息 */}
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
<p className="text-gray-400 text-xs">
© {new Date().getFullYear()} . All rights reserved.
</p>
</div>
</div>
</footer>
)
);
}

View File

@ -5,18 +5,21 @@ import Navigation from "./navigation";
import { useAuth } from "@web/src/providers/auth-provider";
import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu";
import { Logo } from "../element/Logo";
import SineWavesCanvas from "../../animation/sine-wave";
interface HeaderProps {
onSearch?: (query: string) => void;
}
export const Header = memo(function Header({ onSearch }: HeaderProps) {
const { isAuthenticated } = useAuth()
return (
<header className="sticky top-0 z-50 bg-[#13294B] text-white shadow-lg">
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
<div className="container mx-auto px-4">
<div className="py-3">
<div className="flex items-center justify-between gap-4">
<Logo />
<div className=" text-xl font-bold">
</div>
<div className="flex-grow max-w-2xl">
<SearchBar onSearch={onSearch} />
</div>

View File

@ -1,5 +1,12 @@
import { NavLink } from "react-router-dom";
import { NavLink, useLocation } from "react-router-dom";
import { useNavItem } from "./useNavItem";
import { twMerge } from "tailwind-merge";
interface NavItem {
to: string;
label: string;
icon?: React.ReactNode;
}
interface NavigationProps {
className?: string;
@ -7,46 +14,83 @@ interface NavigationProps {
export default function Navigation({ className }: NavigationProps) {
const { navItems } = useNavItem();
const location = useLocation();
const isActive = (to: string) => {
const [pathname, search] = to.split('?');
return location.pathname === pathname &&
(!search ? !location.search : location.search === `?${search}`);
};
return (
<nav className={`mt-4 rounded-lg bg-[#0B1A32]/90 ${className}`}>
<nav className={twMerge(
"mt-4 rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 shadow-lg",
className
)}>
<div className="flex flex-col md:flex-row items-stretch">
{/* Desktop Navigation */}
<div className="hidden md:flex items-center px-6 py-1 w-full overflow-x-auto scrollbar-thin scrollbar-thumb-[#00308F] scrollbar-track-transparent">
<div className="hidden md:flex items-center px-6 py-2 w-full overflow-x-auto">
<div className="flex space-x-6 min-w-max">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive: active }) => twMerge(
"relative px-4 py-2.5 text-sm font-medium",
"text-gray-300 hover:text-white",
"transition-all duration-200 ease-out group",
active && "text-white"
)}
>
<span className="relative z-10 flex items-center gap-2 transition-transform group-hover:translate-y-[-1px]">
{item.icon}
<span className="tracking-wide">{item.label}</span>
</span>
{/* Active Indicator */}
<span className={twMerge(
"absolute bottom-0 left-1/2 h-[2px] bg-blue-400",
"transition-all duration-300 ease-out",
"transform -translate-x-1/2",
isActive(item.to)
? "w-full opacity-100"
: "w-0 opacity-0 group-hover:w-1/2 group-hover:opacity-40"
)} />
{/* Hover Glow Effect */}
<span className={twMerge(
"absolute inset-0 rounded-lg bg-blue-400/0",
"transition-all duration-300",
"group-hover:bg-blue-400/5"
)} />
</NavLink>
))}
</div>
</div>
{/* Mobile Navigation */}
<div className="md:hidden flex overflow-x-auto scrollbar-none px-4 py-2">
<div className="flex space-x-4 min-w-max">
{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 flex items-center gap-2">
{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'
}
`}
/>
</>
className={({ isActive: active }) => twMerge(
"px-3 py-1.5 text-sm font-medium rounded-full",
"transition-colors duration-200",
"text-gray-300 hover:text-white",
active && "bg-blue-500/20 text-white"
)}
>
<span className="flex items-center gap-1.5">
{item.icon}
<span>{item.label}</span>
</span>
</NavLink>
))}
</div>
</div>
</div>
</nav>)
</nav>
);
}

View File

@ -1,6 +1,19 @@
import { api } from "@nice/client";
import { TaxonomySlug } from "@nice/common";
import { useMemo } from "react";
import {
FileTextOutlined,
ScheduleOutlined,
QuestionCircleOutlined,
FolderOutlined,
TagsOutlined
} from "@ant-design/icons";
export interface NavItem {
to: string;
label: string;
icon?: React.ReactNode;
}
export function useNavItem() {
const { data } = api.term.findMany.useQuery({
@ -12,9 +25,21 @@ export function useNavItem() {
const navItems = useMemo(() => {
// 定义固定的导航项
const staticItems = {
letterList: { to: "/", label: "公开信件" },
letterProgress: { to: "/letter-progress", label: "进度查询" },
help: { to: "/help", label: "使用帮助" }
letterList: {
to: "/",
label: "公开信件",
icon: <FileTextOutlined className="text-base" />
},
letterProgress: {
to: "/letter-progress",
label: "进度查询",
icon: <ScheduleOutlined className="text-base" />
},
help: {
to: "/help",
label: "使用帮助",
icon: <QuestionCircleOutlined className="text-base" />
}
};
if (!data) {
@ -24,14 +49,15 @@ export function useNavItem() {
// 构建分类导航项
const categoryItems = data.map(term => ({
to: `/write-letter?termId=${term.id}`,
label: term.name
label: term.name,
icon: <TagsOutlined className="text-base"></TagsOutlined>
}));
// 按照指定顺序返回导航项
return [
staticItems.letterList,
...categoryItems,
staticItems.letterProgress,
...categoryItems,
staticItems.help
];
}, [data]);

View File

@ -67,7 +67,6 @@ export function LetterFormProvider({
},
state: PostState.PENDING,
isPublic: data?.isPublic,
resources: data.resources?.length
? {
connect: (
@ -97,11 +96,7 @@ export function LetterFormProvider({
termId,
form,
}}>
<Form<LetterFormData>
form={form}
initialValues={{ meta: { tags: [] } }}>
{children}
</Form>
{children}
</LetterEditorContext.Provider>
);
}

View File

@ -2,7 +2,6 @@ import { Form, Input, Button, Checkbox, Select } from "antd";
import { useLetterEditor } from "../context/LetterEditorContext";
import { SendOutlined } from "@ant-design/icons";
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
import { PostBadge } from "../../detail/badge/PostBadge";
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
import StaffSelect from "../../../staff/staff-select";
import TermSelect from "../../../term/term-select";
@ -20,8 +19,8 @@ export function LetterBasicForm() {
onFinish={handleFinish}
initialValues={{
meta: { tags: [] },
receiverId,
termId,
receivers: [receiverId],
terms: [termId],
isPublic: true,
}}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -97,7 +96,7 @@ export function LetterBasicForm() {
<Form.Item
name="isPublic"
valuePropName="checked"
initialValue={true}>
>
<Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm">
</Checkbox>

View File

@ -27,7 +27,6 @@ export default function TermSelect({
multiple = false,
taxonomyId,
domainId,
disabled = false,
}: TermSelectProps) {
@ -87,7 +86,7 @@ export default function TermSelect({
setListTreeData(combinedDepts);
} catch (error) {
console.error("Error fetching departments:", error);
console.error("Error fetching terms:", error);
}
}, [defaultValue, value, taxonomyId, utils, fetchParentTerms]);
@ -129,16 +128,6 @@ export default function TermSelect({
try {
const allKeyIds =
keys.map((key) => key.toString()).filter(Boolean) || [];
// const expandedNodes = await Promise.all(
// keys.map(async (key) => {
// return await utils.department.getChildSimpleTree.fetch({
// deptId: key.toString(),
// domain,
// });
// })
// );
//
//上面那样一个个拉会拉爆必须直接拉deptIds
const expandedNodes = await utils.term.getChildSimpleTree.fetch({
termIds: allKeyIds,
taxonomyId,

View File

@ -77,15 +77,21 @@ export const NiceTailwindConfig: Config = {
solid: "2px 2px 0 0 rgba(0, 0, 0, 0.2)",
glow: "0 0 8px rgba(230, 180, 0, 0.8)",
inset: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.15)",
"elevation-1": "0 1px 2px 0 rgba(0, 0, 0, 0.1)",
"elevation-2": "0 2px 4px 0 rgba(0, 0, 0, 0.15)",
"elevation-3": "0 4px 8px 0 rgba(0, 0, 0, 0.2)",
"elevation-4": "0 8px 16px 0 rgba(0, 0, 0, 0.25)",
"elevation-5": "0 16px 32px 0 rgba(0, 0, 0, 0.3)",
panel: "0 4px 6px -1px rgba(0, 48, 138, 0.1), 0 2px 4px -2px rgba(0, 48, 138, 0.1)",
button: "0 2px 4px rgba(0, 48, 138, 0.2), inset 0 1px 2px rgba(255, 255, 255, 0.1)",
card: "0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)",
modal: "0 8px 32px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.15)",
"elevation-1": "0 1px 2px rgba(0, 48, 138, 0.05), 0 1px 1px rgba(0, 0, 0, 0.05)",
"elevation-2": "0 2px 4px rgba(0, 48, 138, 0.1), 0 2px 2px rgba(0, 0, 0, 0.1)",
"elevation-3": "0 4px 8px rgba(0, 48, 138, 0.15), 0 4px 4px rgba(0, 0, 0, 0.15)",
"elevation-4": "0 8px 16px rgba(0, 48, 138, 0.2), 0 8px 8px rgba(0, 0, 0, 0.2)",
"elevation-5": "0 16px 32px rgba(0, 48, 138, 0.25), 0 16px 16px rgba(0, 0, 0, 0.25)",
panel: "0 4px 6px -1px rgba(0, 48, 138, 0.1), 0 2px 4px -2px rgba(0, 48, 138, 0.1), inset 0 -1px 2px rgba(255, 255, 255, 0.05)",
button: "0 2px 4px rgba(0, 48, 138, 0.2), inset 0 1px 2px rgba(255, 255, 255, 0.1), 0 1px 1px rgba(0, 0, 0, 0.05)",
card: "0 4px 6px rgba(0, 48, 138, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08), inset 0 -1px 2px rgba(255, 255, 255, 0.05)",
modal: "0 8px 32px rgba(0, 48, 138, 0.2), 0 4px 16px rgba(0, 0, 0, 0.15), inset 0 -2px 4px rgba(255, 255, 255, 0.05)",
"soft-primary": "0 2px 4px rgba(0, 48, 138, 0.1), 0 4px 8px rgba(0, 48, 138, 0.05)",
"soft-secondary": "0 2px 4px rgba(77, 77, 77, 0.1), 0 4px 8px rgba(77, 77, 77, 0.05)",
"soft-accent": "0 2px 4px rgba(230, 180, 0, 0.1), 0 4px 8px rgba(230, 180, 0, 0.05)",
"inner-glow": "inset 0 0 8px rgba(0, 48, 138, 0.1)",
"outer-glow": "0 0 16px rgba(0, 48, 138, 0.1)",
"elegant": "0 4px 24px rgba(0, 48, 138, 0.15), 0 2px 12px rgba(0, 48, 138, 0.1), 0 1px 6px rgba(0, 48, 138, 0.05), inset 0 -1px 2px rgba(255, 255, 255, 0.1)",
},
animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",