This commit is contained in:
ditiqi 2025-02-14 11:45:41 +08:00
parent 3bb7b74695
commit 7ec9f7b97b
58 changed files with 305384 additions and 1430 deletions

View File

@ -82,6 +82,9 @@ export class PostRouter {
.mutation(async ({ input }) => {
return await this.postService.deleteMany(input);
}),
getPostCount: this.trpc.procedure.query(async ({ ctx, input }) => {
return await this.postService.getPostCount();
}),
findManyWithCursor: this.trpc.protectProcedure
.input(
z.object({

View File

@ -8,6 +8,7 @@ import {
ResPerm,
ObjectType,
PostType,
PostState,
} from '@nice/common';
import { MessageService } from '../message/message.service';
import { BaseService } from '../base/base.service';
@ -205,4 +206,23 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
data: {}, // 空对象会自动更新 updatedAt 时间戳
});
}
async getPostCount() {
const receiveCount = await db.post.count({
where: {
type: PostType.POST,
deletedAt: null,
},
});
const resolvedCount = await db.post.count({
where: {
type: PostType.POST,
deletedAt: null,
state: PostState.RESOLVED,
},
});
return {
receiveCount,
resolvedCount,
};
}
}

View File

@ -5,6 +5,7 @@ import {
ObjectType,
UserProfile,
Prisma,
PostType,
} from '@nice/common';
import { DepartmentService } from '../department/department.service';
import { z } from 'zod';
@ -12,6 +13,7 @@ import { BaseService } from '../base/base.service';
import * as argon2 from 'argon2';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
import { DefaultArgs } from '@prisma/client/runtime/library';
import { setPostRelation } from './utils';
@Injectable()
export class StaffService extends BaseService<Prisma.StaffDelegate> {
@ -137,7 +139,17 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
in: childDepts,
};
}
return super.findManyWithPagination(args as any);
return this.wrapResult(
super.findManyWithPagination(args as any),
async (result) => {
const { items } = result;
await Promise.all(
items.map(async (item) => {
await setPostRelation({ data: item });
}),
);
return { ...result, items };
},
);
}
}

View File

@ -0,0 +1,37 @@
import { db, PostState, PostType, Staff, UserProfile } from '@nice/common';
export async function setPostRelation(params: {
data: Staff;
staff?: UserProfile;
}) {
const { data, staff } = params;
// 在函数开始时计算一次时间
const replyCount = await db.post.count({
where: {
type: PostType.POST,
receivers: {
some: {
id: data?.id,
},
},
state: PostState.RESOLVED,
},
});
const receiveCount = await db.post.count({
where: {
type: PostType.POST,
receivers: {
some: {
id: data?.id,
},
},
},
});
Object.assign(data, {
replyCount,
receiveCount,
});
// console.log('data', data);
return data; // 明确返回修改后的数据
}

View File

@ -1,5 +1,5 @@
VITE_APP_SERVER_IP=192.168.112.239
VITE_APP_UOLOAD_PORT=80
VITE_APP_UPLOAD_PORT=80
VITE_APP_SERVER_PORT=3000
VITE_APP_VERSION=0.3.0
VITE_APP_APP_NAME=信箱

View File

@ -8,7 +8,7 @@
window.env = {
VITE_APP_SERVER_IP: "$VITE_APP_SERVER_IP",
VITE_APP_SERVER_PORT: "$VITE_APP_SERVER_PORT",
VITE_APP_UOLOAD_PORT: "$VITE_APP_UOLOAD_PORT",
VITE_APP_UPLOAD_PORT: "$VITE_APP_UPLOAD_PORT",
VITE_APP_APP_NAME: "$VITE_APP_APP_NAME",
VITE_APP_VERSION: "$VITE_APP_VERSION",
};

BIN
apps/web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -114,6 +114,9 @@ export default function BaseSettingPage() {
<Input></Input>
</Form.Item>
</div>
<Form.Item label="写信须知" name={["appConfig", "notice"]}>
<Input.TextArea></Input.TextArea>
</Form.Item>
<div className="p-2 grid grid-cols-8 gap-2 border-b">
<Form.Item
label="网站logo"

View File

@ -68,13 +68,13 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
</Form.Item>
<Form.Item
name="showname"
label="名"
label=""
noStyle
rules={[
{ required: true, message: "请输入名" },
{ min: 2, message: "名至少需要2个字符" },
{ required: true, message: "请输入" },
{ min: 2, message: "至少需要2个字符" },
]}>
<Input placeholder="名" />
<Input placeholder="" />
</Form.Item>
<Form.Item
noStyle

View File

@ -1,15 +1,15 @@
export function Header() {
return (
<header className="bg-gradient-to-r from-primary to-primary-400 p-6 rounded-t-xl">
<header className="bg-gradient-to-r from-primary-500/80 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 className="mt-2 text-blue-100 text-lg">
</p>
</p> */}
</div>
{/* 服务特点说明 */}
@ -60,14 +60,6 @@ export function Header() {
<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

@ -1,8 +1,13 @@
import LetterList from "@web/src/components/models/post/list/LetterList";
import { Header } from "./Header";
import { useAuth } from "@web/src/providers/auth-provider";
import { useMemo } from "react";
import { PostType, RolePerms } from "@nice/common";
export default function InboxPage() {
const { user } = useAuth();
const { user, hasSomePermissions } = useAuth();
const readAnyPost = useMemo(() => {
return hasSomePermissions(RolePerms.READ_ANY_POST);
}, []);
return (
// 添加 flex flex-col 使其成为弹性布局容器
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-slate-200">
@ -12,11 +17,15 @@ export default function InboxPage() {
<LetterList
params={{
where: {
receivers: {
some: {
id: user?.id,
},
},
type: PostType.POST,
deletedAt: null,
receivers: !readAnyPost
? {
some: {
id: user?.id,
},
}
: undefined,
},
}}></LetterList>
</div>

View File

@ -4,8 +4,6 @@ import LetterListPage from "../list/page";
export default function IndexPage() {
const { user } = useAuth();
if (user) {
return <InboxPage></InboxPage>;
}
return <LetterListPage></LetterListPage>;
}

View File

@ -1,71 +1,22 @@
import { useAppConfig } from "@nice/client";
export function Header() {
return (
<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>
);
}
const { notice } = useAppConfig();
return (
<header className="bg-gradient-to-r from-primary-500/80 to-primary-400 p-6 rounded-t-xl">
<div className="flex flex-col space-y-3">
{/* 主标题区域 */}
<div>
<h1 className="text-3xl font-bold tracking-wider text-white ">
</h1>
</div>
<div className="text-xl text-blue-100 border-t border-blue-400/30 pt-4">
<p className="leading-relaxed text-blue-100 whitespace-pre-wrap">
{notice}
</p>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,62 @@
import React from "react";
import { UserOutlined } from "@ant-design/icons";
import { Button } from "antd";
export interface StaffCardProps {
staff: {
id?: string;
showname?: string;
username?: string;
meta?: {
photoUrl?: string;
rank?: string;
};
replyCount?: number;
receiveCount?: number;
};
}
export function StaffCard({ staff }: StaffCardProps) {
const handleSendLetter = () => {
window.open(`/editor?&receiverId=${staff.id}`, "_blank");
};
return (
<div className="bg-slate-100 border border-white rounded-lg overflow-hidden flex transition-transform duration-300 hover:translate-y-[-5px] hover:shadow-lg">
{/* Image Container */}
<div className="w-24 h-32 flex-shrink-0 flex items-center justify-center relative">
{staff.meta?.photoUrl ? (
<img
src={staff.meta.photoUrl}
alt={staff?.showname || staff?.username}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center text-gray-400 w-full h-full bg-gray-200">
<UserOutlined style={{ fontSize: "32px" }} />
</div>
)}
</div>
{/* Content Container */}
<div className="flex-1 p-4 flex flex-col justify-center gap-2">
<h3 className="text-2xl font-semibold text-primary">
{staff?.showname || staff?.username || "未知"}
</h3>
<div className=" flex justify-start gap-2 items-center text-base text-primary">
<div className="">
{staff?.replyCount} / {staff?.receiveCount}
</div>
<Button
size="small"
onClick={handleSendLetter}
type="primary"
style={{
boxShadow: "none",
}}>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import React from "react";
import { Typography } from "antd";
import { useAppConfig } from "@nice/client";
const { Paragraph } = Typography;
const TopNotificationInput = () => {
const { notice } = useAppConfig();
return (
<div className="top-notification-wrapper">
<Paragraph
className="top-notification-text "
style={{
whiteSpace: "pre-wrap",
padding: 40,
fontSize: 22,
fontWeight: "bold",
}}>
{notice}
</Paragraph>
</div>
);
};
export default TopNotificationInput;

View File

@ -1,21 +1,54 @@
import LetterList from "@web/src/components/models/post/list/LetterList";
import { Header } from "./Header";
import { useSearchParams } from "react-router-dom";
import { SendCard } from "../write/SendCard";
import { StaffCard } from "./StaffCard";
import StaffList from "@web/src/components/models/staff/staff-list";
import SendStaffList from "@web/src/components/models/post/list/SendStaffList";
import { PostType } from "@nice/common";
import TopNotificationInput from "./TopNotification";
export default function LetterListPage() {
const [params] = useSearchParams()
const keyword = params.get("keyword")
return (
// 添加 flex flex-col 使其成为弹性布局容器
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-slate-200 flex flex-col">
<Header />
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
const [params] = useSearchParams();
const keyword = params.get("keyword");
const mockStaff = {
id: "12345",
showname: "张三",
username: "zhangsan",
phoneNumber: "123-4567-8901",
department: {
name: "工程部",
},
meta: {
photoUrl: "https://example.com/zhangsan-photo.jpg",
rank: "高级工程师",
email: "zhangsan@example.com",
office: "A栋101室",
},
};
<LetterList search={keyword} params={{
where: {
isPublic: true
}
}}></LetterList>
</div>
);
return (
<div className="min-h-screen ">
{/* Left side - 3/4 width */}
<TopNotificationInput></TopNotificationInput>
<div className="flex gap-4 mt-4">
<div className="w-4/5 shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-slate-200 flex flex-col">
<LetterList
search={keyword}
params={{
where: {
deletedAt: null,
type: PostType.POST,
},
}}></LetterList>
</div>
{/* Right side - 1/4 width */}
<div className="w-1/5">
{/* <Staff */}
<SendStaffList></SendStaffList>
{/* Add your content for the right side here */}
</div>
</div>
</div>
);
}

View File

@ -12,6 +12,7 @@ export default function OutboxPage() {
<LetterList
params={{
where: {
deletedAt: null,
authorId: user?.id,
},
}}></LetterList>

View File

@ -1,70 +1,66 @@
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>
return (
<header className=" rounded-t-xl bg-gradient-to-r from-primary-500/80 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>
}
{/* 处理状态说明 */}
<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>
</header>
);
}

View File

@ -1,72 +1,68 @@
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">
</p>
</div>
return (
<header className=" rounded-t-xl bg-gradient-to-r from-primary-500/80 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">
</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 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>
{/* 隐私承诺 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p></p>
</div>
</div>
</header>
</header>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect} from "react";
import { useState, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSearchParams } from "react-router-dom";
import { SendCard } from "./SendCard";
@ -8,7 +8,7 @@ import DepartmentSelect from "@web/src/components/models/department/department-s
import debounce from "lodash/debounce";
import { SearchOutlined } from "@ant-design/icons";
import WriteHeader from "./WriteHeader";
import { RoleName, staffDetailSelect } from "@nice/common";
import { RoleName, staffDetailSelect } from "@nice/common";
export default function WriteLetterPage() {
const [searchParams] = useSearchParams();
@ -20,7 +20,11 @@ export default function WriteLetterPage() {
const { getTerm } = useTerm();
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
api.rolemap.getStaffIdsByRoleNames.useQuery({
roleNames: [RoleName.Leader, RoleName.Organization],
roleNames: [
RoleName.Leader,
RoleName.Organization,
RoleName.RootAdmin,
],
});
const { data, isLoading, error } =
api.staff.findManyWithPagination.useQuery(
@ -29,11 +33,12 @@ export default function WriteLetterPage() {
pageSize,
select: staffDetailSelect,
where: {
id: enabledStaffIds
? {
in: enabledStaffIds,
}
: undefined,
id:
enabledStaffIds?.filter(Boolean)?.length > 0
? {
in: enabledStaffIds,
}
: undefined,
deptId: selectedDept,
OR: [
{
@ -91,7 +96,7 @@ export default function WriteLetterPage() {
prefix={
<SearchOutlined className="text-gray-400" />
}
placeholder="搜索领导名或职级..."
placeholder="搜索领导或职级..."
onChange={debounce(
(e) => setSearchQuery(e.target.value),
300

View File

@ -1,61 +1,58 @@
import { useState, useMemo } from 'react';
import { NavLink, matchPath, useLocation, useMatches } from 'react-router-dom';
import { Layout, Menu, theme } from 'antd';
import type { MenuProps } from 'antd';
import { CustomRouteObject } from '@web/src/routes/types';
import { useState, useMemo } from "react";
import { NavLink, matchPath, useLocation, useMatches } from "react-router-dom";
import { Layout, Menu, theme } from "antd";
import type { MenuProps } from "antd";
import { CustomRouteObject } from "@web/src/routes/types";
const { Sider } = Layout;
const { useToken } = theme;
type SidebarProps = {
routes: CustomRouteObject[];
routes: CustomRouteObject[];
};
export default function AdminSidebar({ routes }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const { token } = useToken();
let matches = useMatches();
console.log(matches)
const menuItems: MenuProps['items'] = useMemo(() =>
routes.map(route => ({
key: route.path,
icon: route.icon,
label: (
<NavLink
to={route.path}
const [collapsed, setCollapsed] = useState(false);
const { token } = useToken();
const matches = useMatches();
console.log(matches);
const menuItems: MenuProps["items"] = useMemo(
() =>
routes.map((route) => ({
key: route.path,
icon: route.icon,
label: <NavLink to={route.path}>{route.name}</NavLink>,
})),
[routes]
);
>
{route.name}
</NavLink>
),
}))
, [routes]);
return (
<Sider
collapsible
collapsed={collapsed}
onCollapse={(value) => setCollapsed(value)}
width={150}
className="h-screen sticky top-0"
style={{
backgroundColor: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`
}}
>
<Menu
theme="light"
mode="inline"
selectedKeys={routes
.filter(route => matches.some(match => match.pathname.includes(route.path)))
.map(route => route.path)}
items={menuItems}
className="border-r-0"
style={{
borderRight: 0
}}
/>
</Sider>
);
return (
<Sider
collapsible
collapsed={collapsed}
onCollapse={(value) => setCollapsed(value)}
width={150}
className="h-screen sticky top-0"
style={{
backgroundColor: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}>
<Menu
theme="light"
mode="inline"
selectedKeys={routes
.filter((route) =>
matches.some((match) =>
match.pathname.includes(route.path)
)
)
.map((route) => route.path)}
items={menuItems}
className="border-r-0"
style={{
borderRight: 0,
}}
/>
</Sider>
);
}

View File

@ -126,27 +126,15 @@ export default function StaffForm() {
}}></AvatarUploader>
</Form.Item>
</div>
<div className="grid grid-cols-1 gap-2 flex-1">
<Form.Item
noStyle
rules={[{ required: true }]}
name={"username"}
label="帐号">
<Input
placeholder="请输入用户名"
allowClear
autoComplete="new-username" // 使用非标准的自动完成值
spellCheck={false}
/>
</Form.Item>
<Form.Item
noStyle
rules={[{ required: true }]}
name={"showname"}
label="名">
label="名称">
<Input
placeholder="请输入名"
placeholder="请输入名称"
allowClear
autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false}
@ -172,77 +160,17 @@ export default function StaffForm() {
rules={[{ required: true }]}>
<DepartmentSelect rootId={domainId} />
</Form.Item>
<Form.Item noStyle label="密码" name={"password"}>
<Input.Password
placeholder="修改密码"
spellCheck={false}
visibilityToggle
autoComplete="new-password"
/>
</Form.Item>
</div>
</div>
<div className="grid grid-cols-1 gap-2 flex-1">
<Form.Item noStyle name={"rank"}>
<Input
placeholder="请输入职级(可选)"
autoComplete="off"
spellCheck={false}
allowClear
/>
</Form.Item>
<Form.Item
// rules={[
// {
// required: false,
// pattern: /^\d{5,18}$/,
// message: "请输入正确的证件号(数字)",
// },
// ]}
noStyle
name={"officerId"}>
<Input
placeholder="请输入证件号(可选)"
autoComplete="off"
spellCheck={false}
allowClear
/>
</Form.Item>
<Form.Item
rules={[
{
required: false,
pattern: /^\d{6,11}$/,
message: "请输入正确的手机号(数字)",
},
]}
noStyle
name={"phoneNumber"}
label="手机号">
<Input
placeholder="请输入手机号(可选)"
autoComplete="new-phone" // 使用非标准的自动完成值
spellCheck={false}
allowClear
/>
</Form.Item>
<Form.Item noStyle name={"email"}>
<Input
placeholder="请输入邮箱(可选)"
autoComplete="off"
spellCheck={false}
allowClear
/>
</Form.Item>
<Form.Item noStyle name={"office"}>
<Input
placeholder="请输入办公地点(可选)"
autoComplete="off"
spellCheck={false}
allowClear
/>
</Form.Item>
<Form.Item noStyle label="密码" name={"password"}>
<Input.Password
placeholder="修改密码"
spellCheck={false}
visibilityToggle
autoComplete="new-password"
/>
</Form.Item>
</div>
</Form>
</div>
);

View File

@ -165,12 +165,9 @@ export function UserMenu() {
{/* 用户信息,显示在 Avatar 右侧 */}
<div className="flex flex-col space-y-0.5 ml-3 items-start">
<span className="text-sm font-semibold text-white">
<span className="text-base text-primary flex items-center gap-1.5">
{user?.showname || user?.username}
</span>
<span className="text-xs text-white flex items-center gap-1.5">
{user?.department?.name}
</span>
</div>
</motion.button>

View File

@ -1,69 +1,77 @@
import { PhoneOutlined, MailOutlined, CloudOutlined, HomeOutlined, FileSearchOutlined, FireTwoTone, FireOutlined } from '@ant-design/icons';
import Logo from '../../common/element/Logo';
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-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-gray-400 text-xs italic">
</p>
</div>
return (
<footer className="bg-gradient-to-b from-primary-500 to-primary-500 text-white ">
<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-white text-xs italic">
</p>
</div>
{/* 联系方式 */}
<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">628118</span>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
</div>
</div>
{/* 联系方式 */}
<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-white text-xs">628118</span>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-white text-xs">
gcsjs6@tx3l.nb.kj
</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://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站"
>
<HomeOutlined className="text-lg" />
</a>
<a
href="https://27.57.72.14"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火青云"
>
<CloudOutlined className="text-lg" />
</a>
{/* 系统链接 */}
<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://27.57.72.21"
className="text-white hover:text-white transition-colors"
title="访问门户网站">
<HomeOutlined className="text-lg" />
</a>
<a
href="https://27.57.72.14"
className="text-white hover:text-white transition-colors"
title="访问烽火青云">
<CloudOutlined className="text-lg" />
</a>
<a
href="http://27.57.72.38"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火律询"
>
<FileSearchOutlined className="text-lg" />
</a>
</div>
</div>
</div>
<a
href="http://27.57.72.38"
className="text-white hover:text-white transition-colors"
title="访问烽火律询">
<FileSearchOutlined className="text-lg" />
</a>
</div>
</div>
</div>
{/* 版权信息 */}
<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>
);
{/* 版权信息 */}
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
<p className="text-white text-xs">
© {new Date().getFullYear()} . All rights
reserved.
</p>
</div>
</div>
</footer>
);
}

View File

@ -5,34 +5,47 @@ import Navigation from "./navigation";
import { useAuth } from "@web/src/providers/auth-provider";
import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu/usermenu";
import logo from "@web/src/assets/logo.png"
import logo from "@web/src/assets/logo.png";
import { env } from "@web/src/env";
import { Button } from "antd";
import usePublicImage from "@web/src/hooks/usePublicImage";
export const Header = memo(function Header() {
const { isAuthenticated } = useAuth();
const navigate = useNavigate()
const navigate = useNavigate();
const { imageUrl } = usePublicImage("logo.png");
return (
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
<header className="sticky top-0 z-50 shadow-lg bg-slate-50">
<div className="mx-auto px-4">
<div className="py-2 relative"> {/* 添加relative定位上下文 */}
<div className="py-1 relative">
{" "}
{/* 添加relative定位上下文 */}
<div className="flex items-center justify-between gap-4">
{/* 左侧logo部分 */}
<div className="flex items-center flex-shrink-0"> {/* 防止压缩 */}
<img className="w-24" src={logo} alt="logo" />
<div>
<span className="text-xl font-bold"> {env.APP_NAME || '信箱'}</span>
<p className="text-sm text-secondary-50">
怀
</p>
<div className="flex items-center flex-shrink-0">
{" "}
{/* 防止压缩 */}
<div className="text-xl font-bold text-primary-500/80 whitespace-nowrap">
{env.APP_NAME}
</div>
</div>
{/* <SearchBar /> */}
{/* 中间标题部分 */}
{/* <div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2"></div> */}
{/* 右侧登录部分 */}
<div className="flex items-center flex-shrink-0"> {/* 防止压缩 */}
<div className="flex items-center flex-shrink-0">
{" "}
{/* 防止压缩 */}
{!isAuthenticated ? (
<Button size="large" onClick={() => {
navigate("/auth")
}} type="primary" icon={<UserOutlined></UserOutlined>}>
<Button
className=" text-lg bg-primary-500/80 "
style={{
boxShadow: "none",
}}
onClick={() => {
navigate("/auth");
}}
type="primary"
icon={<UserOutlined></UserOutlined>}>
</Button>
) : (
@ -41,13 +54,13 @@ export const Header = memo(function Header() {
</div>
{/* 独立定位的搜索栏 */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl px-4">
<SearchBar />
</div>
{/* <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl px-4">
</div> */}
</div>
</div>
<Navigation />
{/* <Navigation /> */}
</div>
</header>
);
});
});

View File

@ -1,32 +1,36 @@
import { motion } from 'framer-motion'
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
import { Footer } from './Footer'
import { useEffect } from 'react'
import { env } from '@web/src/env'
import { motion } from "framer-motion";
import { Outlet } from "react-router-dom";
import { Header } from "./Header";
import { Footer } from "./Footer";
import { useEffect } from "react";
import { env } from "@web/src/env";
import Navigation from "./navigation";
import TopPic from "./TopPic";
export function MainLayout() {
useEffect(() => {
document.title = env.APP_NAME || '信箱'
}, [])
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{/* 顶部 Header */}
<Header />
useEffect(() => {
document.title = env.APP_NAME || "信箱";
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}>
{/* 顶部 Header */}
<Header />
{/* 主要内容区域 */}
<main className="min-h-screen bg-slate-50">
<TopPic></TopPic>
<div className=" mx-auto px-4 pb-8 ">
<div className="my-6">
<Navigation />
</div>
<Outlet />
</div>
</main>
{/* 主要内容区域 */}
<main className="min-h-screen bg-slate-50">
<div className=" mx-auto px-4 py-8">
<Outlet />
</div>
</main>
{/* 底部 Footer */}
<Footer />
</motion.div>
)
{/* 底部 Footer */}
<Footer />
</motion.div>
);
}

View File

@ -0,0 +1,15 @@
import usePublicImage from "@web/src/hooks/usePublicImage";
export default function TopPic() {
const { imageUrl } = usePublicImage("logo.png");
return (
<div className="w-full overflow-hidden">
<img
src={imageUrl}
alt="Banner"
className="w-full h-auto object-cover"
/>
</div>
);
}

View File

@ -26,81 +26,38 @@ export default function Navigation({ className }: NavigationProps) {
};
return (
<nav
className={twMerge(
"mt-4 rounded-t-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-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>
<nav className={twMerge("mt-4 w-full", className)}>
<div className="flex justify-between">
<div className="flex w-full space-x-4 overflow-x-auto scrollbar-none">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive: active }) =>
twMerge(
"flex-1 px-6 py-4 text-2xl font-bold rounded-xl", // 调整字体大小和粗细
"transition-all duration-200 ease-out",
"bg-gradient-to-r from-primary-500/80 to-primary-400 text-white",
"hover:from-primary-500 hover:to-primary-500 ",
active &&
"from-primary-600 to-primary-500 *:"
)
}>
<span className="flex items-center justify-center gap-4">
{" "}
{/* 增加图标和文字间距 */}
{item.icon &&
React.cloneElement(
item.icon as React.ReactElement,
{ style: { fontSize: "28px" } }
)}{" "}
{/* 调整图标大小 */}
<span className="tracking-wide">
{item.label}
</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: 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>
</span>
</NavLink>
))}
</div>
</div>
</nav>

View File

@ -1,7 +1,13 @@
import { api } from "@nice/client";
import { TaxonomySlug } from "@nice/common";
import React, { useMemo } from "react";
import { MailOutlined, SendOutlined } from "@ant-design/icons";
import {
FileSearchOutlined,
FormOutlined,
InboxOutlined,
MailOutlined,
SendOutlined,
} from "@ant-design/icons";
import {
FileTextOutlined,
ScheduleOutlined,
@ -10,6 +16,7 @@ import {
TagsOutlined,
} from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider";
import { env } from "@web/src/env";
export interface NavItem {
to: string;
@ -28,58 +35,40 @@ export function useNavItem() {
const navItems = useMemo(() => {
// 定义固定的导航项
const staticItems = {
inbox: {
to: user ? "/" : "/inbox",
label: "我收到的",
letterList: {
to: "/",
label: "全部来信",
icon: <MailOutlined className="text-base" />,
},
outbox: {
to: "/outbox",
label: "我发出的",
icon: <SendOutlined className="text-base" />,
},
letterList: {
to: !user ? "/" : "/letter-list",
label: "公开信件",
icon: <FileTextOutlined className="text-base" />,
editor: {
to: "/editor",
label: "我要写信",
icon: <FormOutlined className="text-base" />,
},
letterProgress: {
to: "/letter-progress",
label: "进度查询",
icon: <ScheduleOutlined className="text-base" />,
label: "信件查询",
icon: <FileSearchOutlined className="text-base" />,
},
inbox: {
to: user ? "/inbox" : "auth",
label: "我的信件",
icon: <InboxOutlined className="text-base" />,
},
help: {
to: env.LIB_URL || "27.57.72.38",
label: "法规查询",
icon: <QuestionCircleOutlined className="text-base" />,
},
// help: {
// to: "/help",
// label: "使用帮助",
// icon: <QuestionCircleOutlined className="text-base" />
// }
};
if (!data) {
return [
user && staticItems.inbox,
user && staticItems.outbox,
staticItems.letterList,
staticItems.letterProgress,
// staticItems.help,
].filter(Boolean);
}
// 构建分类导航项
const categoryItems = data.map((term) => ({
to: `/write-letter?termId=${term.id}`,
label: term.name,
icon: <TagsOutlined className="text-base"></TagsOutlined>,
}));
// 按照指定顺序返回导航项
return [
user && staticItems.inbox,
user && staticItems.outbox,
staticItems.letterList,
staticItems.editor,
staticItems.letterProgress,
...categoryItems,
// staticItems.help,
staticItems.inbox,
staticItems.help,
].filter(Boolean);
}, [data, user]);

View File

@ -1,128 +1,139 @@
import { PostState, PostStateLabels } from '@nice/common';
import { PostState, PostStateLabels } from "@nice/common";
import {
ExclamationCircleOutlined,
BulbOutlined,
QuestionCircleOutlined,
CommentOutlined,
ClockCircleOutlined,
SyncOutlined,
CheckCircleOutlined,
TagOutlined,
} from '@ant-design/icons';
ExclamationCircleOutlined,
BulbOutlined,
QuestionCircleOutlined,
CommentOutlined,
ClockCircleOutlined,
SyncOutlined,
CheckCircleOutlined,
TagOutlined,
CalendarOutlined,
} from "@ant-design/icons";
export const BADGE_STYLES = {
category: {
complaint: {
bg: "bg-orange-50",
text: "text-orange-700",
border: "border border-orange-100",
category: {
complaint: {
bg: "bg-orange-50",
text: "text-orange-700",
border: "border border-orange-100",
icon: <ExclamationCircleOutlined className="text-orange-400 text-sm" />,
icon: (
<ExclamationCircleOutlined className="text-orange-400 text-sm" />
),
},
suggestion: {
bg: "bg-blue-50",
text: "text-blue-700",
border: "border border-blue-100",
},
suggestion: {
bg: "bg-blue-50",
text: "text-blue-700",
border: "border border-blue-100",
icon: <BulbOutlined className="text-blue-400 text-sm" />,
},
request: {
bg: "bg-purple-50",
text: "text-purple-700",
border: "border border-purple-100",
icon: <BulbOutlined className="text-blue-400 text-sm" />,
icon: (
<QuestionCircleOutlined className="text-purple-400 text-sm" />
),
},
feedback: {
bg: "bg-teal-50",
text: "text-teal-700",
border: "border border-teal-100",
},
request: {
bg: "bg-purple-50",
text: "text-purple-700",
border: "border border-purple-100",
icon: <CommentOutlined className="text-teal-400 text-sm" />,
},
},
state: {
[PostState.PENDING]: {
bg: "bg-yellow-50",
text: "text-yellow-700",
border: "border border-yellow-100",
icon: <QuestionCircleOutlined className="text-purple-400 text-sm" />,
icon: <ClockCircleOutlined className="text-yellow-400 text-sm" />,
},
[PostState.PROCESSING]: {
bg: "bg-blue-50",
text: "text-blue-700",
border: "border border-blue-100",
},
feedback: {
bg: "bg-teal-50",
text: "text-teal-700",
border: "border border-teal-100",
icon: <SyncOutlined className="text-blue-400 text-sm" spin />,
},
[PostState.RESOLVED]: {
bg: "bg-green-50",
text: "text-green-700",
border: "border border-green-100",
icon: <CommentOutlined className="text-teal-400 text-sm" />,
icon: <CheckCircleOutlined className="text-green-400 text-sm" />,
},
},
tag: {
default: {
bg: "bg-gray-50",
text: "text-gray-700",
border: "border border-gray-100",
},
},
state: {
[PostState.PENDING]: {
bg: "bg-yellow-50",
text: "text-yellow-700",
border: "border border-yellow-100",
icon: <TagOutlined className="text-gray-400 text-sm" />,
},
},
date: {
default: {
bg: "bg-gray-50",
text: "text-gray-700",
border: "border border-gray-100",
icon: <ClockCircleOutlined className="text-yellow-400 text-sm" />,
},
[PostState.PROCESSING]: {
bg: "bg-blue-50",
text: "text-blue-700",
border: "border border-blue-100",
icon: <SyncOutlined className="text-blue-400 text-sm" spin />,
},
[PostState.RESOLVED]: {
bg: "bg-green-50",
text: "text-green-700",
border: "border border-green-100",
icon: <CheckCircleOutlined className="text-green-400 text-sm" />,
},
},
tag: {
default: {
bg: "bg-gray-50",
text: "text-gray-700",
border: "border border-gray-100",
icon: <TagOutlined className="text-gray-400 text-sm" />,
}
},
icon: <CalendarOutlined className="text-gray-400 text-sm" />,
},
},
} as const;
export const getBadgeStyle = (
type: keyof typeof BADGE_STYLES,
value: string
type: keyof typeof BADGE_STYLES,
value: string
) => {
const style = type === 'tag'
? BADGE_STYLES.tag.default
: BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || {
bg: "bg-gray-50",
text: "text-gray-700",
icon: <TagOutlined className="text-gray-400 text-sm" />,
};
const style =
type === "tag"
? BADGE_STYLES.tag.default
: type === "date"
? BADGE_STYLES.date.default
: BADGE_STYLES[type][
value as keyof (typeof BADGE_STYLES)[typeof type]
] || {
bg: "bg-gray-50",
text: "text-gray-700",
icon: <TagOutlined className="text-gray-400 text-sm" />,
};
return style;
return style;
};
export function LetterBadge({
type,
value,
className = "",
type,
value,
className = "",
}: {
type: "category" | "state" | "tag";
value: string;
className?: string;
type: "category" | "state" | "tag" | "date";
value: string;
className?: string;
}) {
if (!value) return null;
if (!value) return null;
const style = getBadgeStyle(type, value);
const style = getBadgeStyle(type, value);
return (
<span
className={`
inline-flex items-center gap-2 px-2 py-1 rounded-full
text-xs transition-all
return (
<span
className={`
inline-flex items-center gap-2 px-2 py-1 rounded-md
text-base transition-all
${style.bg} ${style.text} ${style.border}
${className}
`}>
{style.icon}
<span className="tracking-wide">
{type === 'state' ? PostStateLabels[value] : value}
</span>
</span>
);
{style.icon}
<span className="tracking-wide">
{type === "state" ? PostStateLabels[value] : value}
</span>
</span>
);
}

View File

@ -26,82 +26,55 @@ export function LetterCard({ letter }: LetterCardProps) {
<div
onClick={() => {
window.open(`/${letter.id}/detail`);
// console.log(letter);
}}
className="cursor-pointer p-6 bg-slate-100/80 rounded-xl hover:ring-white hover:ring-1 transition-all
duration-300 ease-in-out hover:-translate-y-0.5
active:scale-[0.98] border border-white
group relative overflow-hidden">
<div className="flex flex-col gap-4">
<div className=" text-xl text-primary font-bold">
{letter.title}
</div>
{/* Meta Info */}
<div className="flex justify-between items-center text-sm text-gray-600 gap-4 flex-wrap">
<div className="flex items-center gap-4 flex-1 min-w-[300px]">
{letter.author?.department?.name && (
<div className="flex items-center gap-2">
<BankOutlined className="text-secondary-400 text-base" />
<Text className="text-gray-600 font-medium">
{letter.author?.department?.name}
</Text>
</div>
)}
<div className="flex items-center gap-2">
<UserOutlined className="text-primary text-base" />
<Text className="text-primary font-medium">
{letter?.meta?.signature ||
letter.author?.showname ||
"匿名用户"}
</Text>
</div>
{letter.receivers.some((item) => item.showname) && (
<div className="flex items-center gap-2">
<MailOutlined className="text-primary-400 text-base" />
<div className=" text-2xl text-primary font-bold flex justify-between gap-2">
<div className="flex items-center gap-2 ">
<MailOutlined className="" />
{letter.receivers.some((item) => item?.showname) && (
<div className="flex items-center gap-2 text-primary-400 ">
<Tooltip
title={letter?.receivers
?.map((item) => item.showname)
?.map((item) => `${item?.showname}信箱`)
.filter(Boolean)
.join(", ")}>
<Text className="text-primary-400">
<Text className="text-primary-400 text-2xl flex">
<span>{"[ "}</span>
{letter.receivers
.map((item) => item.showname)
.map(
(item) =>
`${item?.showname}信箱`
)
.filter(Boolean)
.slice(0, 2)
.join("、")}
{letter.receivers.filter(
(item) => item.showname
(item) => `${item?.showname}信箱`
).length > 2 && " 等"}
<span>{" ]"}</span>
</Text>
</Tooltip>
</div>
)}
</div>
<div className="flex items-center gap-2">
<CalendarOutlined className="text-secondary-400 text-base" />
<Text className="text-gray-500">
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
</Text>
{letter.title}
{/* 印章样式的"密"字 */}
{!letter?.isPublic && (
<div className=" bg-red-600 text-white px-2 py-1 flex justify-center items-center rounded-md text-base font-bold border-2 border-white shadow-md">
</div>
)}
</div>
</div>
{/* Content Preview */}
{letter.content && (
<div className="flex-1 leading-relaxed text-sm">
<div
dangerouslySetInnerHTML={{ __html: letter.content }}
className="line-clamp-2"
/>
</div>
)}
{/* Badges & Interactions */}
<div className="flex justify-between items-center ">
<div className="flex flex-wrap gap-2">
<LetterBadge type="state" value={letter.state} />
{letter?.meta?.tags?.map((tag) => (
<LetterBadge key={tag} type="tag" value={tag} />
))}
{letter.terms.map((term) => (
<LetterBadge
key={term.name}
@ -109,16 +82,24 @@ export function LetterCard({ letter }: LetterCardProps) {
value={term.name}
/>
))}
<LetterBadge
type="date"
value={dayjs(letter.createdAt).format("YYYY-MM-DD")}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<Button
type="default"
type="text"
shape="round"
style={{
color: "#4b5563",
}}
icon={<EyeOutlined />}>
<span className="mr-1"></span>
{letter.views}
</Button>
<PostHateButton post={letter as any}></PostHateButton>
<PostLikeButton post={letter as any}></PostLikeButton>
</div>

View File

@ -36,9 +36,16 @@ export default function PostCommentList() {
where: {
parentId: post?.id,
type: PostType.POST_COMMENT,
OR: [
{ authorId: null }, // 允许 authorId 为 null
{ authorId: { notIn: receiverIds } }, // 排除 receiverIds 中的 authorId
AND: [
{
OR: [{ authorId: null }, { authorId: { not: null } }],
},
{
OR: [
{ authorId: null },
{ authorId: { notIn: receiverIds } },
],
},
],
},
select: postDetailSelect,

View File

@ -4,6 +4,7 @@ import PostDetailLayout from "./layout/PostDetailLayout";
export default function PostDetail({ id }: { id?: string }) {
return <PostDetailProvider editId={id}>
<PostDetailLayout></PostDetailLayout>
</PostDetailProvider>
}

View File

@ -33,11 +33,11 @@ export default function PostHateButton({ post }: { post: PostDto }) {
return (
<Button
title={post?.hated ? "取消点踩" : "点踩"}
type={post?.hated ? "primary" : "default"}
type={"text"}
style={{
backgroundColor: post?.hated ? "#ff4d4f" : "#fff",
borderColor: post?.hated ? "transparent" : "",
color: post?.hated ? "#fff" : "#000",
// backgroundColor: post?.hated ? "#ff4d4f" : "transparent",
// borderColor: post?.hated ? "transparent" : "transparent",
color: post?.hated ? "#ff4d4f" : "#4b5563",
boxShadow: "none", // 去除阴影
}}
shape="round"

View File

@ -33,10 +33,10 @@ export default function PostLikeButton({ post }: { post: PostDto }) {
return (
<Button
title={post?.liked ? "取消点赞" : "点赞"}
type={post?.liked ? "primary" : "default"}
type={"text"}
shape="round"
style={{
boxShadow: "none", // 去除阴影
color: post?.liked ? "#1677ff" : "#4b5563",
}}
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
onClick={(e) => {

View File

@ -1,35 +1,181 @@
import React, { useContext } from "react";
import React, { useContext, useMemo, useState } from "react";
import { motion } from "framer-motion";
import {
LikeFilled,
LikeOutlined,
EyeOutlined,
CommentOutlined,
SendOutlined,
DeleteFilled,
DeleteOutlined,
} from "@ant-design/icons";
import { Button, Tooltip } from "antd/lib";
import { PostDetailContext } from "../context/PostDetailContext";
import PostLikeButton from "./PostLikeButton";
import { Modal } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
const { confirm } = Modal;
import PostResources from "../PostResources";
import PostHateButton from "./PostHateButton";
import { useAuth } from "@web/src/providers/auth-provider";
import { RolePerms } from "@nice/common";
import { usePost } from "@nice/client";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import StaffSelect from "../../../staff/staff-select";
import LimitedStaffSelect from "../../../staff/limited-staff-select";
export function StatsSection() {
const { post } = useContext(PostDetailContext);
const { hasSomePermissions } = useAuth();
const [isResending, setIsResending] = useState(false);
const [receiverIds, setReceiverIds] = useState(
post?.receivers?.map((receiver) => receiver?.id)?.filter(Boolean)
);
const showResend = () => {
setIsResending(true);
};
async function handleResend() {
try {
await update.mutateAsync({
where: {
id: post?.id,
},
data: {
receivers: {
connect: receiverIds
?.filter(Boolean)
?.map((id) => ({ id })),
},
},
});
toast.success("分发成功");
} catch (err) {
console.log(err);
}
setIsResending(false);
}
const handleCancelResend = () => {
setIsResending(false);
};
const navigate = useNavigate();
const canEdit = useMemo(() => {
return hasSomePermissions(RolePerms.MANAGE_ANY_POST);
}, [hasSomePermissions, post]);
const { update } = usePost();
const showDeleteConfirm = () => {
confirm({
title: "确定要删除这条记录吗?",
icon: <ExclamationCircleOutlined />,
content: "删除后将无法恢复",
okText: "确定",
okType: "danger",
cancelText: "取消",
centered: true, // 添加这一行
onOk() {
deleteById();
},
onCancel() {
console.log("取消删除");
},
});
};
async function deleteById() {
try {
await update.mutateAsync({
where: {
id: post.id,
},
data: {
deletedAt: new Date(),
},
});
navigate(-1);
toast.success("删除成功");
} catch (err) {
console.log(err);
toast.error("删除失败");
}
}
return (
<div
className="mt-6 flex flex-wrap gap-4 justify-end items-center">
<div className="mt-6 flex flex-wrap gap-4 justify-end items-center">
<div className=" flex gap-2">
<Button title="浏览量" type="default" shape="round" icon={<EyeOutlined />}>
<span className="mr-1"></span>{post?.views}
<Button
title="浏览量"
type="text"
style={{
color: "#4b5563",
}}
shape="round"
icon={<EyeOutlined />}>
<span className="mr-1"></span>
{post?.views}
</Button>
<Button type="default" title="回复数" shape="round" icon={<CommentOutlined />}>
<span className="mr-1"></span>{post?.commentsCount}
<Button
type="text"
style={{
color: "#4b5563",
}}
title="回复数"
shape="round"
icon={<CommentOutlined />}>
<span className="mr-1"></span>
{post?.commentsCount}
</Button>
<PostHateButton post={post}></PostHateButton>
<PostLikeButton post={post}></PostLikeButton>
{canEdit && (
<>
<Button
type="text"
style={{
color: "#00308a",
}}
shape="round"
icon={<SendOutlined />}
onClick={() => showResend()}>
<span className="mr-1"></span>
</Button>
<Modal
centered
title="选择分发领导"
visible={isResending}
onOk={handleResend}
onCancel={handleCancelResend}>
<div className="flex flex-1 justify-center">
<LimitedStaffSelect
value={receiverIds}
multiple
onChange={(value) => {
setReceiverIds(value as string[]);
}}
style={{
width: "90%",
}}
/>
</div>
</Modal>
</>
)}
{canEdit && (
<Button
type="text"
style={{
color: "#ff0000",
}}
onClick={showDeleteConfirm}
shape="round"
icon={<DeleteOutlined />}>
<span className="mr-1"></span>
</Button>
)}
</div>
</div>
);
}

View File

@ -15,7 +15,7 @@ export default function PostResources({ post }: { post: PostDto }) {
const sortedResources = post.resources
.map((resource) => {
const original = `http://${env.SERVER_IP}:${env.UOLOAD_PORT}/uploads/${resource.url}`;
const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${resource.url}`;
const isImg = isImage(resource.url);
return {
...resource,

View File

@ -1,7 +1,19 @@
import { api, usePost } from "@nice/client";
import { Post, postDetailSelect, PostDto, UserProfile } from "@nice/common";
import {
Post,
postDetailSelect,
PostDto,
RolePerms,
UserProfile,
} from "@nice/common";
import { useAuth } from "@web/src/providers/auth-provider";
import React, { createContext, ReactNode, useEffect, useState } from "react";
import React, {
createContext,
ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { PostParams } from "@nice/client/src/singleton/DataHolder";
interface PostDetailContextType {
@ -9,6 +21,8 @@ interface PostDetailContextType {
post?: PostDto;
isLoading?: boolean;
user?: UserProfile;
setKeyCode?: React.Dispatch<React.SetStateAction<string>>;
canSee?: boolean;
}
interface PostFormProviderProps {
children: ReactNode;
@ -21,13 +35,14 @@ export function PostDetailProvider({
children,
editId,
}: PostFormProviderProps) {
const { user } = useAuth();
const { user, hasSomePermissions } = useAuth();
const postParams = PostParams.getInstance();
const queryParams = {
where: { id: editId },
select: postDetailSelect,
};
const [keyCode, setKeyCode] = useState<string>("");
useEffect(() => {
if (editId) {
postParams.addDetailItem(queryParams);
@ -42,7 +57,23 @@ export function PostDetailProvider({
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
api.post.findFirst as any
).useQuery(queryParams, { enabled: Boolean(editId) });
const canSee = useMemo(() => {
if (hasSomePermissions(RolePerms.READ_ANY_POST)) {
return true;
} else if (
post?.receivers?.map((receiver) => receiver.id)?.includes(user?.id)
) {
return true;
} else if (!post?.isPublic) {
if (keyCode === post?.id) {
return true;
} else {
return false;
}
} else {
return true;
}
}, [keyCode, post]);
return (
<PostDetailContext.Provider
value={{
@ -50,6 +81,8 @@ export function PostDetailProvider({
post,
user,
isLoading,
canSee,
setKeyCode,
}}>
{children}
</PostDetailContext.Provider>

View File

@ -1,4 +1,4 @@
import { useContext, useEffect } from "react";
import { useContext, useEffect, useState } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import PostCommentEditor from "../PostCommentEditor";
import PostCommentList from "../PostCommentList";
@ -6,9 +6,11 @@ import { useVisitor } from "@nice/client";
import { VisitType } from "@nice/common";
import Header from "../PostHeader/Header";
import Content from "../PostHeader/Content";
import { Button, Input, Skeleton } from "antd";
export default function PostDetailLayout() {
const { post, user } = useContext(PostDetailContext);
const { post, user, canSee, setKeyCode, isLoading } =
useContext(PostDetailContext);
const { read } = useVisitor();
useEffect(() => {
@ -22,10 +24,48 @@ export default function PostDetailLayout() {
});
}
}, [post]);
return <>
<Header></Header>
<Content></Content>
<PostCommentEditor></PostCommentEditor>
<PostCommentList></PostCommentList>
</>
const [password, setPassword] = useState("");
const handlePasswordSubmit = () => {
setKeyCode(password);
};
if (isLoading) {
return (
<div style={{ padding: "20px" }}>
<Skeleton active avatar paragraph={{ rows: 4 }} />
</div>
);
}
if (!canSee) {
return (
<div
className=" justify-center gap-2"
style={{
height: 400,
}}>
<div className=" mb-2 mt-8 flex gap-1 justify-center text-primary text-2xl font-bold flex-1">
</div>
<div className="flex gap-1 justify-center items-center flex-1 ">
<Input
placeholder="请输入密码"
onChange={(e) => setPassword(e.target.value)}
style={{
width: 400,
}}
/>
<Button type="primary" onClick={handlePasswordSubmit}>
</Button>
</div>
</div>
);
}
return (
<>
<Header></Header>
<Content></Content>
<PostCommentEditor></PostCommentEditor>
<PostCommentList></PostCommentList>
</>
);
}

View File

@ -11,7 +11,7 @@ export interface LetterFormData {
content: string;
resources?: string[];
receivers?: string[];
terms?: string[];
term?: string;
isPublic?: boolean;
signature?: string;
meta: {
@ -47,17 +47,18 @@ export function LetterFormProvider({
try {
console.log(data);
const receivers = data?.receivers;
const terms = data?.terms;
const term = data?.term;
delete data.receivers;
delete data.terms;
delete data.term;
console.log("term", term);
const result = await create.mutateAsync({
data: {
...data,
type: PostType.POST,
terms: {
connect: (terms || [])?.filter(Boolean).map((id) => ({
id,
})),
connect: {
id: term,
},
},
receivers: {
connect: (receivers || [])
@ -70,12 +71,12 @@ export function LetterFormProvider({
isPublic: data?.isPublic,
resources: data.resources?.length
? {
connect: (
data.resources?.filter(Boolean) || []
).map((fileId) => ({
fileId,
})),
}
connect: (
data.resources?.filter(Boolean) || []
).map((fileId) => ({
fileId,
})),
}
: undefined,
},
});
@ -100,6 +101,8 @@ export function LetterFormProvider({
duration: 5000, // 10秒
}
);
navigate(-1);
// navigate(`/${result.id}/detail`, {
// replace: true,
// state: { scrollToTop: true },

View File

@ -7,16 +7,29 @@ import StaffSelect from "../../../staff/staff-select";
import TermSelect from "../../../term/term-select";
import TabPane from "antd/es/tabs/TabPane";
import toast from "react-hot-toast";
import { api } from "@nice/client";
import { RoleName } from "@nice/common";
import { useNavigate } from "react-router-dom";
export function LetterBasicForm() {
const { onSubmit, receiverId, termId, form } = useLetterEditor();
const handleFinish = async (values: any) => {
await onSubmit(values);
};
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
api.rolemap.getStaffIdsByRoleNames.useQuery({
roleNames: [
RoleName.Leader,
RoleName.Organization,
RoleName.RootAdmin,
],
});
const handleSubmit = async () => {
try {
await form.validateFields();
form.submit();
// toast.success("提交成功!");
} catch (error) {
// 提取所有错误信息
@ -47,15 +60,19 @@ export function LetterBasicForm() {
onFinish={handleFinish}
initialValues={{
meta: { tags: [] },
receivers: [receiverId],
terms: [termId],
receivers: [receiverId].filter(Boolean),
// terms: [termId],
isPublic: true,
}}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Form.Item label="收件人" name={"receivers"}>
<StaffSelect multiple placeholder="选择收信人员" />
<StaffSelect
multiple
limitedIds={enabledStaffIds?.filter(Boolean)}
placeholder="选择收信人员"
/>
</Form.Item>
<Form.Item label="分类" name={"terms"}>
<Form.Item label="分类" name={"term"}>
<TermSelect placeholder="选择信件分类" />
</Form.Item>
</div>
@ -69,7 +86,7 @@ export function LetterBasicForm() {
/>
</Form.Item>
{/* Tags Input */}
<Form.Item name={["meta", "tags"]} className="mb-6">
{/* <Form.Item name={["meta", "tags"]} className="mb-6">
<Select
mode="tags"
showSearch={false}
@ -83,7 +100,7 @@ export function LetterBasicForm() {
className="w-full"
dropdownStyle={{ display: "none" }}
/>
</Form.Item>
</Form.Item> */}
<Tabs defaultActiveKey="1">
{/* Content Editor */}

View File

@ -0,0 +1,109 @@
import { useState, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSearchParams } from "react-router-dom";
import { Spin, Empty, Input, Alert, Pagination } from "antd";
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";
// import WriteHeader from "./WriteHeader";
import { RoleName, staffDetailSelect } from "@nice/common";
import { StaffCard } from "@web/src/app/main/letter/list/StaffCard";
export default function SendStaffList() {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 15;
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
api.rolemap.getStaffIdsByRoleNames.useQuery({
roleNames: [
RoleName.Leader,
RoleName.Organization,
RoleName.RootAdmin,
],
});
const { data, isLoading, error } =
api.staff.findManyWithPagination.useQuery(
{
page: currentPage,
pageSize,
select: staffDetailSelect,
where: {
id: {
in: enabledStaffIds?.filter(Boolean),
},
deletedAt: null,
},
orderBy: {
order: "asc",
},
},
{
enabled: !roleMapIsLoading,
}
);
// Reset page when search or department changes
return (
<div className="min-h-screen shadow-elegant rounded-xl ">
{error && (
<div className="mb-4 space-y-4">
{
<Alert
message="加载失败"
description="获取数据时出现错误,请刷新页面重试。"
type="error"
showIcon
/>
}
</div>
)}
<AnimatePresence>
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Spin size="large" tip="加载中..." />
</div>
) : data?.items.length > 0 ? (
<motion.div
className="grid grid-cols-1 gap-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}>
{data?.items.map((item: any) => (
<StaffCard key={item.id} staff={item} />
))}
</motion.div>
) : (
<motion.div
className="text-center py-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}>
<Empty
description="没有找到匹配的收信人"
className="py-12"
/>
</motion.div>
)}
</AnimatePresence>
{/* Pagination */}
{data?.items.length > pageSize && (
<div className="flex justify-center mt-8">
<Pagination
current={currentPage}
total={data?.totalPages || 0}
pageSize={pageSize}
onChange={(page) => {
setCurrentPage(page);
window.scrollTo(0, 0);
}}
showSizeChanger={false}
showTotal={(total) => `${total} 条记录`}
/>
</div>
)}
</div>
);
}

View File

@ -1,118 +1,134 @@
import AgServerTable from "@web/src/components/presentation/ag-server-table";
import { ObjectType } from "@nice/common"
import { ObjectType } from "@nice/common";
import { ICellRendererParams } from "@ag-grid-community/core";
import { Menu, MenuItem } from "@web/src/components/presentation/dropdown-menu";
import { DeleteOutlined, EllipsisOutlined, PlusOutlined } from "@ant-design/icons";
import {
DeleteOutlined,
EllipsisOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { ColDef, ValueGetterParams } from "@ag-grid-community/core";
import { Button } from "antd";
import DepartmentSelect from "../../department/department-select";
import { useContext, useEffect } from "react";
import { RoleEditorContext } from "./role-editor";
import { useAuth } from "@web/src/providers/auth-provider";
import { useRoleMap } from "@nice/client"
import { useRoleMap } from "@nice/client";
const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => {
const { deleteMany } = useRoleMap()
return (
<div>
<Menu
node={
<EllipsisOutlined className=" hover:bg-textHover p-1 rounded" />
}>
<MenuItem
label="移除"
onClick={() => {
deleteMany.mutateAsync({
ids: [props?.data?.id],
});
}}
icon={<DeleteOutlined></DeleteOutlined>}></MenuItem>
</Menu>
</div>
);
const { deleteMany } = useRoleMap();
return (
<div>
<Menu
node={
<EllipsisOutlined className=" hover:bg-textHover p-1 rounded" />
}>
<MenuItem
label="移除"
onClick={() => {
deleteMany.mutateAsync({
ids: [props?.data?.id],
});
}}
icon={<DeleteOutlined></DeleteOutlined>}></MenuItem>
</Menu>
</div>
);
};
export default function AssignList() {
const { user, hasSomePermissions } = useAuth();
const { domainId, setModalOpen, role, setDomainId, canManageRole } =
useContext(RoleEditorContext);
useEffect(() => {
if (user) {
setDomainId?.(user.domainId);
}
}, [user]);
const columnDefs: ColDef[] = [
{
headerName: "帐号",
field: "staff.username",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_username;
},
filter: "agTextColumnFilter",
maxWidth: 300,
},
{
headerName: "姓名",
field: "staff.showname",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_showname;
},
filter: "agTextColumnFilter",
maxWidth: 300,
},
{
headerName: "证件号",
field: "staff.officer_id",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_officer_id;
},
filter: "agTextColumnFilter",
},
{
headerName: "所在单位",
field: "department.name",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.department_name;
},
filter: "agTextColumnFilter",
maxWidth: 300,
},
const { user, hasSomePermissions } = useAuth();
const { domainId, setModalOpen, role, setDomainId, canManageRole } =
useContext(RoleEditorContext);
useEffect(() => {
if (user) {
setDomainId?.(user.domainId);
}
}, [user]);
const columnDefs: ColDef[] = [
{
headerName: "帐号",
field: "staff.username",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_username;
},
filter: "agTextColumnFilter",
maxWidth: 300,
},
{
headerName: "名称",
field: "staff.showname",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_showname;
},
filter: "agTextColumnFilter",
maxWidth: 300,
},
{
headerName: "证件号",
field: "staff.officer_id",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_officer_id;
},
filter: "agTextColumnFilter",
},
{
headerName: "所在单位",
field: "department.name",
sort: "desc",
valueGetter: (params: ValueGetterParams) => {
return params.data?.department_name;
},
filter: "agTextColumnFilter",
maxWidth: 300,
},
{
headerName: "操作",
sortable: true,
{
headerName: "操作",
sortable: true,
cellRenderer: (props) => <OpreationRenderer props={props}></OpreationRenderer>, // 指定 cellRenderer
maxWidth: 100,
},
];
return <div className=" flex-grow">
<div className="p-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2 ">
<span className=""> {role?.name}</span>
<span className=" text-tertiary-300 "> </span>
</div>
<div className=" flex items-center gap-4">
<DepartmentSelect onChange={(value) => setDomainId(value as string)} rootId={user?.domainId} value={domainId} disabled={!canManageRole} domain={true} className=" w-48"></DepartmentSelect>
{canManageRole && <Button
onClick={() => {
setModalOpen(true)
}}
type="primary" icon={<PlusOutlined></PlusOutlined>}></Button>}
</div>
</div>
<AgServerTable
rowGroupPanelShow="onlyWhenGrouping"
height={"calc(100vh - 48px - 49px - 49px)"}
columnDefs={columnDefs}
rowHeight={50}
params={{ domainId, roleId: role?.id }}
objectType={ObjectType.ROLE_MAP}
/>
</div>
cellRenderer: (props) => (
<OpreationRenderer props={props}></OpreationRenderer>
), // 指定 cellRenderer
maxWidth: 100,
},
];
return (
<div className=" flex-grow">
<div className="p-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2 ">
<span className=""> {role?.name}</span>
<span className=" text-tertiary-300 "> </span>
</div>
<div className=" flex items-center gap-4">
<DepartmentSelect
onChange={(value) => setDomainId(value as string)}
rootId={user?.domainId}
value={domainId}
disabled={!canManageRole}
domain={true}
className=" w-48"></DepartmentSelect>
{canManageRole && (
<Button
onClick={() => {
setModalOpen(true);
}}
type="primary"
icon={<PlusOutlined></PlusOutlined>}>
</Button>
)}
</div>
</div>
<AgServerTable
rowGroupPanelShow="onlyWhenGrouping"
height={"calc(100vh - 48px - 49px - 49px)"}
columnDefs={columnDefs}
rowHeight={50}
params={{ domainId, roleId: role?.id }}
objectType={ObjectType.ROLE_MAP}
/>
</div>
);
}

View File

@ -0,0 +1,101 @@
import { useMemo, useState } from "react";
import { Button, Select, Spin } from "antd";
import type { SelectProps } from "antd";
import { api } from "@nice/client";
import React from "react";
import { SizeType } from "antd/es/config-provider/SizeContext";
import { RoleName } from "@nice/common";
interface StaffSelectProps {
value?: string | string[];
onChange?: (value: string | string[]) => void;
style?: React.CSSProperties;
multiple?: boolean;
domainId?: string;
placeholder?: string;
size?: SizeType;
}
export default function LimitedStaffSelect({
value,
onChange,
placeholder,
style,
multiple,
domainId,
size,
}: StaffSelectProps) {
const [keyword, setQuery] = useState<string>("");
// Determine ids based on whether value is an array or not
const ids = useMemo(() => {
return Array.isArray(value) ? value : [];
}, [value]);
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
api.rolemap.getStaffIdsByRoleNames.useQuery({
roleNames: [
RoleName.Leader,
RoleName.Organization,
RoleName.RootAdmin,
],
});
// Adjust the query to include ids when they are present
const { data, isLoading } = api.staff.findMany.useQuery({
where: {
id:
enabledStaffIds?.length > 0
? { in: enabledStaffIds }
: { in: [] },
deletedAt: null,
OR: [
{
username: {
contains: keyword,
},
},
{
showname: {
contains: keyword,
},
},
{
id: {
in: ids,
},
},
],
domainId,
},
select: { id: true, showname: true, username: true },
take: 30,
orderBy: { order: "asc" },
});
const handleSearch = (value: string) => {
setQuery(value);
};
const options: SelectProps["options"] =
data?.map((staff: any) => ({
value: staff.id,
label: staff?.showname || staff?.username,
})) || [];
return (
<>
<Select
size={size}
allowClear
showSearch
mode={multiple ? "multiple" : undefined}
placeholder={placeholder || "请选择人员"}
notFoundContent={isLoading ? <Spin size="small" /> : null}
filterOption={false}
onSearch={handleSearch}
options={options}
value={value}
onChange={onChange}
style={{ minWidth: 200, ...style }}
/>{" "}
</>
);
}

View File

@ -163,9 +163,9 @@ export default function StaffForm() {
noStyle
rules={[{ required: true }]}
name={"showname"}
label="名">
label="">
<Input
placeholder="请输入名"
placeholder="请输入"
allowClear
autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false}

View File

@ -1,13 +1,11 @@
import { Icon } from "@nice/iconer"; import {
import { Icon } from "@nice/iconer";
import {
DeleteOutlined,
EditFilled,
EllipsisOutlined,
} from "@ant-design/icons";
import { ICellRendererParams } from "@ag-grid-community/core";
import {
ColDef,
ValueGetterParams,
} from "@ag-grid-community/core";
import { ColDef, ValueGetterParams } from "@ag-grid-community/core";
import { ObjectType, StaffRowModel } from "@nice/common";
import { Menu, MenuItem } from "../../presentation/dropdown-menu";
import AgServerTable from "../../presentation/ag-server-table";
@ -21,7 +19,7 @@ import { message, Tag } from "antd";
import { CustomCellRendererProps } from "@ag-grid-community/react";
const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => {
const { setEditId, setModalOpen } = useContext(StaffEditorContext);
const { softDeleteByIds } = useStaff()
const { softDeleteByIds } = useStaff();
if (props?.data?.id)
return (
<div>
@ -39,14 +37,21 @@ const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => {
<MenuItem
label="移除"
onClick={() => {
softDeleteByIds.mutateAsync({
ids: [props?.data?.id],
}, {
onSettled: () => {
message.success("删除成功");
emitDataChange(ObjectType.STAFF, props.data as any, CrudOperation.DELETED)
softDeleteByIds.mutateAsync(
{
ids: [props?.data?.id],
},
});
{
onSettled: () => {
message.success("删除成功");
emitDataChange(
ObjectType.STAFF,
props.data as any,
CrudOperation.DELETED
);
},
}
);
}}
icon={<DeleteOutlined></DeleteOutlined>}></MenuItem>
</Menu>
@ -63,13 +68,12 @@ const StaffList = ({
const { canManageAnyStaff } = useContext(StaffEditorContext);
const [params, setParams] = useState({ domainId: null });
useEffect(() => {
if (domainId) {
setParams((prev) => ({ ...prev, domainId }))
setParams((prev) => ({ ...prev, domainId }));
} else {
setParams((prev) => ({ ...prev, domainId: null }))
setParams((prev) => ({ ...prev, domainId: null }));
}
}, [domainId])
}, [domainId]);
const columnDefs: ColDef[] = [
canManageAnyStaff && {
headerName: "所属域",
@ -87,10 +91,11 @@ const StaffList = ({
return params.data?.dept_name;
},
cellRenderer: (params) => {
return (
params.value || (
<span className="text-tertiary-300"></span>
<span className="text-tertiary-300">
</span>
)
);
},
@ -103,7 +108,7 @@ const StaffList = ({
{
field: "order",
hide: true,
sort: "asc" as SortDirection
sort: "asc" as SortDirection,
},
{
headerName: "帐号",
@ -112,7 +117,9 @@ const StaffList = ({
if (params?.data?.id)
return (
params.value || (
<span className="text-tertiary-300"></span>
<span className="text-tertiary-300">
</span>
)
);
},
@ -122,13 +129,15 @@ const StaffList = ({
maxWidth: 300,
},
{
headerName: "名",
headerName: "",
field: "showname",
cellRenderer: (params) => {
if (params?.data?.id)
return (
params.value || (
<span className="text-tertiary-300"></span>
<span className="text-tertiary-300">
</span>
)
);
},
@ -156,7 +165,9 @@ const StaffList = ({
cellRenderer: (params) => {
const { data }: { data: StaffRowModel } = params;
if (params?.data?.id)
return <PhoneBook phoneNumber={data?.phone_number}></PhoneBook>;
return (
<PhoneBook phoneNumber={data?.phone_number}></PhoneBook>
);
},
},
{
@ -165,8 +176,11 @@ const StaffList = ({
sortable: true,
enableRowGroup: true,
cellRenderer: (props: CustomCellRendererProps) => {
return <Tag color={props?.data?.enabled ? "success" : "error"}>{props?.data?.enabled ? "已启用" : "已禁用"}</Tag>
return (
<Tag color={props?.data?.enabled ? "success" : "error"}>
{props?.data?.enabled ? "已启用" : "已禁用"}
</Tag>
);
},
},
{

View File

@ -11,6 +11,7 @@ interface StaffSelectProps {
multiple?: boolean;
domainId?: string;
placeholder?: string;
limitedIds?: string[];
size?: SizeType;
}
@ -20,6 +21,7 @@ export default function StaffSelect({
placeholder,
style,
multiple,
limitedIds,
domainId,
size,
}: StaffSelectProps) {
@ -33,6 +35,8 @@ export default function StaffSelect({
// Adjust the query to include ids when they are present
const { data, isLoading } = api.staff.findMany.useQuery({
where: {
id: limitedIds?.length > 0 ? { in: limitedIds } : undefined,
deletedAt: null,
OR: [
{
username: {

View File

@ -2,8 +2,9 @@ export const env: {
APP_NAME: string;
SERVER_IP: string;
VERSION: string;
UOLOAD_PORT: string;
UPLOAD_PORT: string;
SERVER_PORT: string;
LIB_URL: string;
} = {
APP_NAME: import.meta.env.PROD
? (window as any).env.VITE_APP_APP_NAME
@ -11,15 +12,18 @@ export const env: {
SERVER_IP: import.meta.env.PROD
? (window as any).env.VITE_APP_SERVER_IP
: import.meta.env.VITE_APP_SERVER_IP,
UOLOAD_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_UOLOAD_PORT
: import.meta.env.VITE_APP_UOLOAD_PORT,
UPLOAD_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_UPLOAD_PORT
: import.meta.env.VITE_APP_UPLOAD_PORT,
SERVER_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_SERVER_PORT
: import.meta.env.VITE_APP_SERVER_PORT,
VERSION: import.meta.env.PROD
? (window as any).env.VITE_APP_VERSION
: import.meta.env.VITE_APP_VERSION,
LIB_URL: import.meta.env.PROD
? (window as any).env.VITE_APP_LIB_URL
: import.meta.env.VITE_APP_LIB_URL,
};
console.log(env);

View File

@ -0,0 +1,34 @@
import { useState, useEffect } from "react";
const usePublicImage = (imageName: string) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
useEffect(() => {
const loadImage = async () => {
try {
const response = await fetch(`/${imageName}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setImageUrl(url);
} catch (error) {
console.error("Error loading image:", error);
setImageUrl(null);
}
};
loadImage();
return () => {
if (imageUrl) {
URL.revokeObjectURL(imageUrl);
}
};
}, [imageName]);
return { imageUrl };
};
export default usePublicImage;

View File

@ -35,7 +35,7 @@ export function useTusUpload() {
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
throw new Error("Invalid upload URL format");
}
const resUrl = `http://${env.SERVER_IP}:${env.UOLOAD_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
const resUrl = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
return resUrl;
};

View File

@ -117,3 +117,29 @@
.ql-snow .ql-picker.ql-header .ql-picker-label:not([data-value])::before {
content: "正文" !important;
}
.top-notification-wrapper {
position: relative;
padding: 10px;
margin: 10px 0;
}
.top-notification-text {
border: 20px dashed transparent;
background-image: linear-gradient(white, white),
repeating-linear-gradient(
45deg,
#ff0000 0,
#ff0000 40px,
transparent 40px,
transparent 60px,
#0000ff 60px,
#0000ff 100px,
transparent 100px,
transparent 120px
);
background-origin: border-box;
background-clip: padding-box, border-box;
padding: 15px;
font-size: 16px;
line-height: 1.5;
}

View File

@ -32,6 +32,9 @@ export function useAppConfig() {
const splashScreen = useMemo(() => {
return baseSetting?.appConfig?.splashScreen;
}, [baseSetting]);
const notice = useMemo(() => {
return baseSetting?.appConfig?.notice;
}, [baseSetting]);
const devDept = useMemo(() => {
return baseSetting?.appConfig?.devDept;
}, [baseSetting]);
@ -46,6 +49,7 @@ export function useAppConfig() {
splashScreen,
devDept,
isLoading,
notice,
logo,
};
}

View File

@ -5,8 +5,8 @@ export const staffDetailSelect: Prisma.StaffSelect = {
department: {
select: {
id: true,
name: true
}
name: true,
},
},
showname: true,
phoneNumber: true,
@ -14,12 +14,12 @@ export const staffDetailSelect: Prisma.StaffSelect = {
domain: {
select: {
id: true,
name: true
}
name: true,
},
},
domainId: true,
meta: true
}
meta: true,
};
export const postDetailSelect: Prisma.PostSelect = {
id: true,
type: true,
@ -63,6 +63,7 @@ export const postDetailSelect: Prisma.PostSelect = {
name: true,
},
},
meta: true,
},
},
meta: true,

View File

@ -45,6 +45,8 @@ export type StaffDto = Staff & {
domain?: Department;
department?: Department;
meta?: StaffMeta;
replyCount?: number;
receiveCount?: number;
};
export interface AuthDto {
token: string;
@ -222,6 +224,7 @@ export interface BaseSetting {
splashScreen?: string;
devDept?: string;
logo?: string;
notice?: string;
};
}
export interface PostMeta {

View File

@ -4,7 +4,7 @@ import { ThemeSeed } from "./types";
// 添加默认的主题配置
export const USAFSeed: ThemeSeed = {
colors: {
primary: '#003087', // 深蓝色
primary: '#3498DB', // 深蓝色
secondary: '#71767C', // 灰色
neutral: '#4A4A4A', // 中性灰色
success: '#287233', // 绿色

View File

@ -7,13 +7,13 @@ export const NiceTailwindConfig: Config = {
colors: {
// 主色调 - 空军蓝
primary: {
50: "#e8f2ff",
50: "#e8f2ff",
100: "#c5d9f7",
150: "#EDF0F8", // via color
150: "#EDF0F8", // via color
200: "#9fc0ef",
250: "#E6E9F0", // from color
250: "#E6E9F0", // from color
300: "#78a7e7",
350: "#D8E2EF", // to color
350: "#D8E2EF", // to color
400: "#528edf",
500: "#00308a",
600: "#00256b",
@ -28,7 +28,7 @@ export const NiceTailwindConfig: Config = {
100: "#e0e0e0",
200: "#c2c2c2",
300: "#a3a3a3",
350: "#97A9C4", // New color inserted
350: "#97A9C4", // New color inserted
400: "#858585",
500: "#666666",
600: "#4d4d4d",
@ -77,21 +77,30 @@ 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 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)",
"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)",
"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)",
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",

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@
window.env = {
VITE_APP_SERVER_IP: "192.168.112.239",
VITE_APP_SERVER_PORT: "3002",
VITE_APP_UOLOAD_PORT: "8090",
VITE_APP_UPLOAD_PORT: "8090",
VITE_APP_APP_NAME: "领导信箱",
VITE_APP_VERSION: "0.1.0",
};