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 }) => { .mutation(async ({ input }) => {
return await this.postService.deleteMany(input); return await this.postService.deleteMany(input);
}), }),
getPostCount: this.trpc.procedure.query(async ({ ctx, input }) => {
return await this.postService.getPostCount();
}),
findManyWithCursor: this.trpc.protectProcedure findManyWithCursor: this.trpc.protectProcedure
.input( .input(
z.object({ z.object({

View File

@ -8,6 +8,7 @@ import {
ResPerm, ResPerm,
ObjectType, ObjectType,
PostType, PostType,
PostState,
} from '@nice/common'; } from '@nice/common';
import { MessageService } from '../message/message.service'; import { MessageService } from '../message/message.service';
import { BaseService } from '../base/base.service'; import { BaseService } from '../base/base.service';
@ -205,4 +206,23 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
data: {}, // 空对象会自动更新 updatedAt 时间戳 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, ObjectType,
UserProfile, UserProfile,
Prisma, Prisma,
PostType,
} from '@nice/common'; } from '@nice/common';
import { DepartmentService } from '../department/department.service'; import { DepartmentService } from '../department/department.service';
import { z } from 'zod'; import { z } from 'zod';
@ -12,6 +13,7 @@ import { BaseService } from '../base/base.service';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import EventBus, { CrudOperation } from '@server/utils/event-bus'; import EventBus, { CrudOperation } from '@server/utils/event-bus';
import { DefaultArgs } from '@prisma/client/runtime/library'; import { DefaultArgs } from '@prisma/client/runtime/library';
import { setPostRelation } from './utils';
@Injectable() @Injectable()
export class StaffService extends BaseService<Prisma.StaffDelegate> { export class StaffService extends BaseService<Prisma.StaffDelegate> {
@ -137,7 +139,17 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
in: childDepts, in: childDepts,
}; };
} }
return this.wrapResult(
return super.findManyWithPagination(args as any); 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_SERVER_IP=192.168.112.239
VITE_APP_UOLOAD_PORT=80 VITE_APP_UPLOAD_PORT=80
VITE_APP_SERVER_PORT=3000 VITE_APP_SERVER_PORT=3000
VITE_APP_VERSION=0.3.0 VITE_APP_VERSION=0.3.0
VITE_APP_APP_NAME=信箱 VITE_APP_APP_NAME=信箱

View File

@ -8,7 +8,7 @@
window.env = { window.env = {
VITE_APP_SERVER_IP: "$VITE_APP_SERVER_IP", VITE_APP_SERVER_IP: "$VITE_APP_SERVER_IP",
VITE_APP_SERVER_PORT: "$VITE_APP_SERVER_PORT", 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_APP_NAME: "$VITE_APP_APP_NAME",
VITE_APP_VERSION: "$VITE_APP_VERSION", 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> <Input></Input>
</Form.Item> </Form.Item>
</div> </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"> <div className="p-2 grid grid-cols-8 gap-2 border-b">
<Form.Item <Form.Item
label="网站logo" label="网站logo"

View File

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

View File

@ -1,15 +1,15 @@
export function Header() { export function Header() {
return ( 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 className="flex flex-col space-y-6">
{/* 主标题区域 */} {/* 主标题区域 */}
<div> <div>
<h1 className="text-3xl font-bold tracking-wider text-white"> <h1 className="text-3xl font-bold tracking-wider text-white">
</h1> </h1>
<p className="mt-2 text-blue-100 text-lg"> {/* <p className="mt-2 text-blue-100 text-lg">
</p> </p> */}
</div> </div>
{/* 服务特点说明 */} {/* 服务特点说明 */}
@ -60,14 +60,6 @@ export function Header() {
<span></span> <span></span>
</div> </div>
</div> </div>
{/* 服务宗旨说明 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p className="leading-relaxed">
</p>
</div>
</div> </div>
</header> </header>
); );

View File

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

View File

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

View File

@ -1,71 +1,22 @@
import { useAppConfig } from "@nice/client";
export function Header() { export function Header() {
return ( const { notice } = useAppConfig();
<header className="bg-gradient-to-r from-primary to-primary-400 p-6 rounded-t-xl"> return (
<div className="flex flex-col space-y-6"> <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>
<p className="mt-2 text-blue-100 text-lg"> <h1 className="text-3xl font-bold tracking-wider text-white ">
</p> </h1>
</div> </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">
<div className="flex flex-wrap gap-6 text-sm text-white"> {notice}
<div className="flex items-center gap-2"> </p>
<svg </div>
className="w-5 h-5" </div>
fill="none" </header>
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

@ -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 LetterList from "@web/src/components/models/post/list/LetterList";
import { Header } from "./Header"; import { Header } from "./Header";
import { useSearchParams } from "react-router-dom"; 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() { export default function LetterListPage() {
const [params] = useSearchParams() const [params] = useSearchParams();
const keyword = params.get("keyword") const keyword = params.get("keyword");
return ( const mockStaff = {
// 添加 flex flex-col 使其成为弹性布局容器 id: "12345",
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-slate-200 flex flex-col"> showname: "张三",
<Header /> username: "zhangsan",
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */} 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={{ return (
where: { <div className="min-h-screen ">
isPublic: true {/* Left side - 3/4 width */}
} <TopNotificationInput></TopNotificationInput>
}}></LetterList> <div className="flex gap-4 mt-4">
</div> <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 <LetterList
params={{ params={{
where: { where: {
deletedAt: null,
authorId: user?.id, authorId: user?.id,
}, },
}}></LetterList> }}></LetterList>

View File

@ -1,70 +1,66 @@
export default function ProgressHeader() { export default function ProgressHeader() {
return <header className=" rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6"> return (
<div className="flex flex-col space-y-6"> <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"> <div>
<h1 className="text-3xl font-bold tracking-wider">
</h1>
<p className="mt-2 text-blue-100 text-lg"> </h1>
{/* <p className="mt-2 text-blue-100 text-lg">
</p>
</div> </p> */}
</div>
{/* 处理状态说明 */} {/* 处理状态说明 */}
<div className="flex flex-wrap gap-6 text-sm"> <div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
className="w-5 h-5" className="w-5 h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" 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" 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> </svg>
<span></span> <span></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
className="w-5 h-5" className="w-5 h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" 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" 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> </svg>
<span></span> <span></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
className="w-5 h-5" className="w-5 h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" 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" 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> </svg>
<span></span> <span></span>
</div> </div>
</div> </div>
</div>
{/* 处理说明 */} </header>
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4"> );
<p></p> }
</div>
</div>
</header>
}

View File

@ -1,72 +1,68 @@
import { TermDto } from "@nice/common"; import { TermDto } from "@nice/common";
export default function WriteHeader({ term }: { term?: TermDto }) { 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"> return (
<div className="flex flex-col space-y-6"> <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"> <div>
{term?.name} <h1 className="text-3xl font-bold tracking-wider">
</h1> {term?.name}
<p className="mt-2 text-blue-100 text-lg"> </h1>
{/* <p className="mt-2 text-blue-100 text-lg">
</p>
</div> </p> */}
</div>
{/* 隐私保护说明 */} {/* 隐私保护说明 */}
<div className="flex flex-wrap gap-6 text-sm"> <div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
className="w-5 h-5" className="w-5 h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" 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" 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> </svg>
<span></span> <span></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
className="w-5 h-5" className="w-5 h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" 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" 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> </svg>
<span></span> <span></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
className="w-5 h-5" className="w-5 h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" 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" 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> </svg>
<span></span> <span></span>
</div>
</div> </div>
</div> </div>
</header>
{/* 隐私承诺 */} );
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p></p>
</div>
</div>
</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 { motion, AnimatePresence } from "framer-motion";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { SendCard } from "./SendCard"; import { SendCard } from "./SendCard";
@ -8,7 +8,7 @@ import DepartmentSelect from "@web/src/components/models/department/department-s
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { SearchOutlined } from "@ant-design/icons"; import { SearchOutlined } from "@ant-design/icons";
import WriteHeader from "./WriteHeader"; import WriteHeader from "./WriteHeader";
import { RoleName, staffDetailSelect } from "@nice/common"; import { RoleName, staffDetailSelect } from "@nice/common";
export default function WriteLetterPage() { export default function WriteLetterPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -20,7 +20,11 @@ export default function WriteLetterPage() {
const { getTerm } = useTerm(); const { getTerm } = useTerm();
const { data: enabledStaffIds, isLoading: roleMapIsLoading } = const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
api.rolemap.getStaffIdsByRoleNames.useQuery({ api.rolemap.getStaffIdsByRoleNames.useQuery({
roleNames: [RoleName.Leader, RoleName.Organization], roleNames: [
RoleName.Leader,
RoleName.Organization,
RoleName.RootAdmin,
],
}); });
const { data, isLoading, error } = const { data, isLoading, error } =
api.staff.findManyWithPagination.useQuery( api.staff.findManyWithPagination.useQuery(
@ -29,11 +33,12 @@ export default function WriteLetterPage() {
pageSize, pageSize,
select: staffDetailSelect, select: staffDetailSelect,
where: { where: {
id: enabledStaffIds id:
? { enabledStaffIds?.filter(Boolean)?.length > 0
in: enabledStaffIds, ? {
} in: enabledStaffIds,
: undefined, }
: undefined,
deptId: selectedDept, deptId: selectedDept,
OR: [ OR: [
{ {
@ -91,7 +96,7 @@ export default function WriteLetterPage() {
prefix={ prefix={
<SearchOutlined className="text-gray-400" /> <SearchOutlined className="text-gray-400" />
} }
placeholder="搜索领导名或职级..." placeholder="搜索领导或职级..."
onChange={debounce( onChange={debounce(
(e) => setSearchQuery(e.target.value), (e) => setSearchQuery(e.target.value),
300 300

View File

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

View File

@ -126,27 +126,15 @@ export default function StaffForm() {
}}></AvatarUploader> }}></AvatarUploader>
</Form.Item> </Form.Item>
</div> </div>
<div className="grid grid-cols-1 gap-2 flex-1"> <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 <Form.Item
noStyle noStyle
rules={[{ required: true }]} rules={[{ required: true }]}
name={"showname"} name={"showname"}
label="名"> label="名称">
<Input <Input
placeholder="请输入名" placeholder="请输入名称"
allowClear allowClear
autoComplete="new-name" // 使用非标准的自动完成值 autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false} spellCheck={false}
@ -172,77 +160,17 @@ export default function StaffForm() {
rules={[{ required: true }]}> rules={[{ required: true }]}>
<DepartmentSelect rootId={domainId} /> <DepartmentSelect rootId={domainId} />
</Form.Item> </Form.Item>
<Form.Item noStyle label="密码" name={"password"}>
<Input.Password
placeholder="修改密码"
spellCheck={false}
visibilityToggle
autoComplete="new-password"
/>
</Form.Item>
</div> </div>
</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> </Form>
</div> </div>
); );

View File

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

View File

@ -1,69 +1,77 @@
import { PhoneOutlined, MailOutlined, CloudOutlined, HomeOutlined, FileSearchOutlined, FireTwoTone, FireOutlined } from '@ant-design/icons'; import {
import Logo from '../../common/element/Logo'; PhoneOutlined,
MailOutlined,
CloudOutlined,
HomeOutlined,
FileSearchOutlined,
FireTwoTone,
FireOutlined,
} from "@ant-design/icons";
import Logo from "../../common/element/Logo";
export function Footer() { export function Footer() {
return ( return (
<footer className="bg-gradient-to-b from-primary-600 to-primary-800 text-secondary-200"> <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="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 开发组织信息 */} {/* 开发组织信息 */}
<div className="text-center md:text-left space-y-2"> <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 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
</h3> </h3>
<p className="text-gray-400 text-xs italic"> <p className="text-white text-xs italic">
</p> </p>
</div> </div>
{/* 联系方式 */} {/* 联系方式 */}
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<PhoneOutlined className="text-gray-400" /> <PhoneOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">628118</span> <span className="text-white text-xs">628118</span>
</div> </div>
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" /> <MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span> <span className="text-white text-xs">
</div> gcsjs6@tx3l.nb.kj
</div> </span>
</div>
</div>
{/* 系统链接 */} {/* 系统链接 */}
<div className="text-center md:text-right space-y-2"> <div className="text-center md:text-right space-y-2">
<div className="flex items-center justify-center md:justify-end space-x-4"> <div className="flex items-center justify-center md:justify-end space-x-4">
<a <a
href="https://27.57.72.21" href="https://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors" className="text-white hover:text-white transition-colors"
title="访问门户网站" title="访问门户网站">
> <HomeOutlined className="text-lg" />
<HomeOutlined className="text-lg" /> </a>
</a> <a
<a href="https://27.57.72.14"
href="https://27.57.72.14" className="text-white hover:text-white transition-colors"
className="text-gray-400 hover:text-white transition-colors" title="访问烽火青云">
title="访问烽火青云" <CloudOutlined className="text-lg" />
> </a>
<CloudOutlined className="text-lg" />
</a>
<a <a
href="http://27.57.72.38" href="http://27.57.72.38"
className="text-gray-400 hover:text-white transition-colors" className="text-white hover:text-white transition-colors"
title="访问烽火律询" title="访问烽火律询">
> <FileSearchOutlined className="text-lg" />
<FileSearchOutlined className="text-lg" /> </a>
</a> </div>
</div> </div>
</div> </div>
</div>
{/* 版权信息 */} {/* 版权信息 */}
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center"> <div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
<p className="text-gray-400 text-xs"> <p className="text-white text-xs">
© {new Date().getFullYear()} . All rights reserved. © {new Date().getFullYear()} . All rights
</p> reserved.
</div> </p>
</div> </div>
</footer> </div>
); </footer>
);
} }

View File

@ -5,34 +5,47 @@ import Navigation from "./navigation";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { UserOutlined } from "@ant-design/icons"; import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu/usermenu"; 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 { env } from "@web/src/env";
import { Button } from "antd"; import { Button } from "antd";
import usePublicImage from "@web/src/hooks/usePublicImage";
export const Header = memo(function Header() { export const Header = memo(function Header() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const navigate = useNavigate() const navigate = useNavigate();
const { imageUrl } = usePublicImage("logo.png");
return ( 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="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"> <div className="flex items-center justify-between gap-4">
{/* 左侧logo部分 */} {/* 左侧logo部分 */}
<div className="flex items-center flex-shrink-0"> {/* 防止压缩 */} <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> <div className="text-xl font-bold text-primary-500/80 whitespace-nowrap">
<p className="text-sm text-secondary-50"> {env.APP_NAME}
怀
</p>
</div> </div>
</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 ? ( {!isAuthenticated ? (
<Button size="large" onClick={() => { <Button
navigate("/auth") className=" text-lg bg-primary-500/80 "
}} type="primary" icon={<UserOutlined></UserOutlined>}> style={{
boxShadow: "none",
}}
onClick={() => {
navigate("/auth");
}}
type="primary"
icon={<UserOutlined></UserOutlined>}>
</Button> </Button>
) : ( ) : (
@ -41,13 +54,13 @@ export const Header = memo(function Header() {
</div> </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 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> */}
</div> </div>
</div> </div>
<Navigation /> {/* <Navigation /> */}
</div> </div>
</header> </header>
); );
}); });

View File

@ -1,32 +1,36 @@
import { motion } from 'framer-motion' import { motion } from "framer-motion";
import { Outlet } from 'react-router-dom' import { Outlet } from "react-router-dom";
import { Header } from './Header' import { Header } from "./Header";
import { Footer } from './Footer' import { Footer } from "./Footer";
import { useEffect } from 'react' import { useEffect } from "react";
import { env } from '@web/src/env' import { env } from "@web/src/env";
import Navigation from "./navigation";
import TopPic from "./TopPic";
export function MainLayout() { export function MainLayout() {
useEffect(() => { useEffect(() => {
document.title = env.APP_NAME || '信箱' document.title = env.APP_NAME || "信箱";
}, []) }, []);
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}>
> {/* 顶部 Header */}
{/* 顶部 Header */} <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>
{/* 主要内容区域 */} {/* 底部 Footer */}
<main className="min-h-screen bg-slate-50"> <Footer />
<div className=" mx-auto px-4 py-8"> </motion.div>
<Outlet /> );
</div>
</main>
{/* 底部 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 ( return (
<nav <nav className={twMerge("mt-4 w-full", className)}>
className={twMerge( <div className="flex justify-between">
"mt-4 rounded-t-xl bg-gradient-to-r from-primary-500 to-primary-600 shadow-lg", <div className="flex w-full space-x-4 overflow-x-auto scrollbar-none">
className {navItems.map((item) => (
)}> <NavLink
<div className="flex flex-col md:flex-row items-stretch"> key={item.to}
{/* Desktop Navigation */} to={item.to}
<div className="hidden md:flex items-center px-6 py-2 w-full overflow-x-auto"> className={({ isActive: active }) =>
<div className="flex space-x-6 min-w-max"> twMerge(
{navItems.map((item) => ( "flex-1 px-6 py-4 text-2xl font-bold rounded-xl", // 调整字体大小和粗细
<NavLink "transition-all duration-200 ease-out",
key={item.to} "bg-gradient-to-r from-primary-500/80 to-primary-400 text-white",
to={item.to} "hover:from-primary-500 hover:to-primary-500 ",
className={({ isActive: active }) => active &&
twMerge( "from-primary-600 to-primary-500 *:"
"relative px-4 py-2.5 text-sm font-medium", )
"text-gray-300 hover:text-white", }>
"transition-all duration-200 ease-out group", <span className="flex items-center justify-center gap-4">
active && "text-white" {" "}
) {/* 增加图标和文字间距 */}
}> {item.icon &&
<span className="relative z-10 flex items-center gap-2 transition-transform group-hover:translate-y-[-1px]"> React.cloneElement(
{item.icon} item.icon as React.ReactElement,
<span className="tracking-wide"> { style: { fontSize: "28px" } }
{item.label} )}{" "}
</span> {/* 调整图标大小 */}
<span className="tracking-wide">
{item.label}
</span> </span>
</span>
{/* Active Indicator */} </NavLink>
<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>
</div> </div>
</div> </div>
</nav> </nav>

View File

@ -1,7 +1,13 @@
import { api } from "@nice/client"; import { api } from "@nice/client";
import { TaxonomySlug } from "@nice/common"; import { TaxonomySlug } from "@nice/common";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { MailOutlined, SendOutlined } from "@ant-design/icons"; import {
FileSearchOutlined,
FormOutlined,
InboxOutlined,
MailOutlined,
SendOutlined,
} from "@ant-design/icons";
import { import {
FileTextOutlined, FileTextOutlined,
ScheduleOutlined, ScheduleOutlined,
@ -10,6 +16,7 @@ import {
TagsOutlined, TagsOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { env } from "@web/src/env";
export interface NavItem { export interface NavItem {
to: string; to: string;
@ -28,58 +35,40 @@ export function useNavItem() {
const navItems = useMemo(() => { const navItems = useMemo(() => {
// 定义固定的导航项 // 定义固定的导航项
const staticItems = { const staticItems = {
inbox: { letterList: {
to: user ? "/" : "/inbox", to: "/",
label: "我收到的", label: "全部来信",
icon: <MailOutlined className="text-base" />, icon: <MailOutlined className="text-base" />,
}, },
outbox: { editor: {
to: "/outbox", to: "/editor",
label: "我发出的", label: "我要写信",
icon: <SendOutlined className="text-base" />, icon: <FormOutlined className="text-base" />,
},
letterList: {
to: !user ? "/" : "/letter-list",
label: "公开信件",
icon: <FileTextOutlined className="text-base" />,
}, },
letterProgress: { letterProgress: {
to: "/letter-progress", to: "/letter-progress",
label: "进度查询", label: "信件查询",
icon: <ScheduleOutlined className="text-base" />, 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 [ return [
user && staticItems.inbox,
user && staticItems.outbox,
staticItems.letterList, staticItems.letterList,
staticItems.editor,
staticItems.letterProgress, staticItems.letterProgress,
...categoryItems, staticItems.inbox,
// staticItems.help, staticItems.help,
].filter(Boolean); ].filter(Boolean);
}, [data, user]); }, [data, user]);

View File

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

View File

@ -26,82 +26,55 @@ export function LetterCard({ letter }: LetterCardProps) {
<div <div
onClick={() => { onClick={() => {
window.open(`/${letter.id}/detail`); 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 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 duration-300 ease-in-out hover:-translate-y-0.5
active:scale-[0.98] border border-white active:scale-[0.98] border border-white
group relative overflow-hidden"> group relative overflow-hidden">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className=" text-xl text-primary font-bold"> <div className=" text-2xl text-primary font-bold flex justify-between gap-2">
{letter.title} <div className="flex items-center gap-2 ">
</div> <MailOutlined className="" />
{/* Meta Info */} {letter.receivers.some((item) => item?.showname) && (
<div className="flex justify-between items-center text-sm text-gray-600 gap-4 flex-wrap"> <div className="flex items-center gap-2 text-primary-400 ">
<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" />
<Tooltip <Tooltip
title={letter?.receivers title={letter?.receivers
?.map((item) => item.showname) ?.map((item) => `${item?.showname}信箱`)
.filter(Boolean) .filter(Boolean)
.join(", ")}> .join(", ")}>
<Text className="text-primary-400"> <Text className="text-primary-400 text-2xl flex">
<span>{"[ "}</span>
{letter.receivers {letter.receivers
.map((item) => item.showname) .map(
(item) =>
`${item?.showname}信箱`
)
.filter(Boolean) .filter(Boolean)
.slice(0, 2) .slice(0, 2)
.join("、")} .join("、")}
{letter.receivers.filter( {letter.receivers.filter(
(item) => item.showname (item) => `${item?.showname}信箱`
).length > 2 && " 等"} ).length > 2 && " 等"}
<span>{" ]"}</span>
</Text> </Text>
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> {letter.title}
<div className="flex items-center gap-2"> {/* 印章样式的"密"字 */}
<CalendarOutlined className="text-secondary-400 text-base" /> {!letter?.isPublic && (
<Text className="text-gray-500"> <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">
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
</Text> </div>
)}
</div> </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 */} {/* Badges & Interactions */}
<div className="flex justify-between items-center "> <div className="flex justify-between items-center ">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<LetterBadge type="state" value={letter.state} /> <LetterBadge type="state" value={letter.state} />
{letter?.meta?.tags?.map((tag) => (
<LetterBadge key={tag} type="tag" value={tag} />
))}
{letter.terms.map((term) => ( {letter.terms.map((term) => (
<LetterBadge <LetterBadge
key={term.name} key={term.name}
@ -109,16 +82,24 @@ export function LetterCard({ letter }: LetterCardProps) {
value={term.name} value={term.name}
/> />
))} ))}
<LetterBadge
type="date"
value={dayjs(letter.createdAt).format("YYYY-MM-DD")}
/>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center">
<Button <Button
type="default" type="text"
shape="round" shape="round"
style={{
color: "#4b5563",
}}
icon={<EyeOutlined />}> icon={<EyeOutlined />}>
<span className="mr-1"></span> <span className="mr-1"></span>
{letter.views} {letter.views}
</Button> </Button>
<PostHateButton post={letter as any}></PostHateButton> <PostHateButton post={letter as any}></PostHateButton>
<PostLikeButton post={letter as any}></PostLikeButton> <PostLikeButton post={letter as any}></PostLikeButton>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -33,10 +33,10 @@ export default function PostLikeButton({ post }: { post: PostDto }) {
return ( return (
<Button <Button
title={post?.liked ? "取消点赞" : "点赞"} title={post?.liked ? "取消点赞" : "点赞"}
type={post?.liked ? "primary" : "default"} type={"text"}
shape="round" shape="round"
style={{ style={{
boxShadow: "none", // 去除阴影 color: post?.liked ? "#1677ff" : "#4b5563",
}} }}
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />} icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
onClick={(e) => { 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 { motion } from "framer-motion";
import { import {
LikeFilled, LikeFilled,
LikeOutlined, LikeOutlined,
EyeOutlined, EyeOutlined,
CommentOutlined, CommentOutlined,
SendOutlined,
DeleteFilled,
DeleteOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Button, Tooltip } from "antd/lib"; import { Button, Tooltip } from "antd/lib";
import { PostDetailContext } from "../context/PostDetailContext"; import { PostDetailContext } from "../context/PostDetailContext";
import PostLikeButton from "./PostLikeButton"; import PostLikeButton from "./PostLikeButton";
import { Modal } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
const { confirm } = Modal;
import PostResources from "../PostResources"; import PostResources from "../PostResources";
import PostHateButton from "./PostHateButton"; 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() { export function StatsSection() {
const { post } = useContext(PostDetailContext); 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 ( return (
<div <div className="mt-6 flex flex-wrap gap-4 justify-end items-center">
className="mt-6 flex flex-wrap gap-4 justify-end items-center">
<div className=" flex gap-2"> <div className=" flex gap-2">
<Button title="浏览量" type="default" shape="round" icon={<EyeOutlined />}> <Button
<span className="mr-1"></span>{post?.views} title="浏览量"
type="text"
style={{
color: "#4b5563",
}}
shape="round"
icon={<EyeOutlined />}>
<span className="mr-1"></span>
{post?.views}
</Button> </Button>
<Button type="default" title="回复数" shape="round" icon={<CommentOutlined />}> <Button
<span className="mr-1"></span>{post?.commentsCount} type="text"
style={{
color: "#4b5563",
}}
title="回复数"
shape="round"
icon={<CommentOutlined />}>
<span className="mr-1"></span>
{post?.commentsCount}
</Button> </Button>
<PostHateButton post={post}></PostHateButton> <PostHateButton post={post}></PostHateButton>
<PostLikeButton post={post}></PostLikeButton> <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>
</div> </div>
); );
} }

View File

@ -15,7 +15,7 @@ export default function PostResources({ post }: { post: PostDto }) {
const sortedResources = post.resources const sortedResources = post.resources
.map((resource) => { .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); const isImg = isImage(resource.url);
return { return {
...resource, ...resource,

View File

@ -1,7 +1,19 @@
import { api, usePost } from "@nice/client"; 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 { 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"; import { PostParams } from "@nice/client/src/singleton/DataHolder";
interface PostDetailContextType { interface PostDetailContextType {
@ -9,6 +21,8 @@ interface PostDetailContextType {
post?: PostDto; post?: PostDto;
isLoading?: boolean; isLoading?: boolean;
user?: UserProfile; user?: UserProfile;
setKeyCode?: React.Dispatch<React.SetStateAction<string>>;
canSee?: boolean;
} }
interface PostFormProviderProps { interface PostFormProviderProps {
children: ReactNode; children: ReactNode;
@ -21,13 +35,14 @@ export function PostDetailProvider({
children, children,
editId, editId,
}: PostFormProviderProps) { }: PostFormProviderProps) {
const { user } = useAuth(); const { user, hasSomePermissions } = useAuth();
const postParams = PostParams.getInstance(); const postParams = PostParams.getInstance();
const queryParams = { const queryParams = {
where: { id: editId }, where: { id: editId },
select: postDetailSelect, select: postDetailSelect,
}; };
const [keyCode, setKeyCode] = useState<string>("");
useEffect(() => { useEffect(() => {
if (editId) { if (editId) {
postParams.addDetailItem(queryParams); postParams.addDetailItem(queryParams);
@ -42,7 +57,23 @@ export function PostDetailProvider({
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = ( const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
api.post.findFirst as any api.post.findFirst as any
).useQuery(queryParams, { enabled: Boolean(editId) }); ).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 ( return (
<PostDetailContext.Provider <PostDetailContext.Provider
value={{ value={{
@ -50,6 +81,8 @@ export function PostDetailProvider({
post, post,
user, user,
isLoading, isLoading,
canSee,
setKeyCode,
}}> }}>
{children} {children}
</PostDetailContext.Provider> </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 { PostDetailContext } from "../context/PostDetailContext";
import PostCommentEditor from "../PostCommentEditor"; import PostCommentEditor from "../PostCommentEditor";
import PostCommentList from "../PostCommentList"; import PostCommentList from "../PostCommentList";
@ -6,9 +6,11 @@ import { useVisitor } from "@nice/client";
import { VisitType } from "@nice/common"; import { VisitType } from "@nice/common";
import Header from "../PostHeader/Header"; import Header from "../PostHeader/Header";
import Content from "../PostHeader/Content"; import Content from "../PostHeader/Content";
import { Button, Input, Skeleton } from "antd";
export default function PostDetailLayout() { export default function PostDetailLayout() {
const { post, user } = useContext(PostDetailContext); const { post, user, canSee, setKeyCode, isLoading } =
useContext(PostDetailContext);
const { read } = useVisitor(); const { read } = useVisitor();
useEffect(() => { useEffect(() => {
@ -22,10 +24,48 @@ export default function PostDetailLayout() {
}); });
} }
}, [post]); }, [post]);
return <> const [password, setPassword] = useState("");
<Header></Header> const handlePasswordSubmit = () => {
<Content></Content> setKeyCode(password);
<PostCommentEditor></PostCommentEditor> };
<PostCommentList></PostCommentList> 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; content: string;
resources?: string[]; resources?: string[];
receivers?: string[]; receivers?: string[];
terms?: string[]; term?: string;
isPublic?: boolean; isPublic?: boolean;
signature?: string; signature?: string;
meta: { meta: {
@ -47,17 +47,18 @@ export function LetterFormProvider({
try { try {
console.log(data); console.log(data);
const receivers = data?.receivers; const receivers = data?.receivers;
const terms = data?.terms; const term = data?.term;
delete data.receivers; delete data.receivers;
delete data.terms; delete data.term;
console.log("term", term);
const result = await create.mutateAsync({ const result = await create.mutateAsync({
data: { data: {
...data, ...data,
type: PostType.POST, type: PostType.POST,
terms: { terms: {
connect: (terms || [])?.filter(Boolean).map((id) => ({ connect: {
id, id: term,
})), },
}, },
receivers: { receivers: {
connect: (receivers || []) connect: (receivers || [])
@ -70,12 +71,12 @@ export function LetterFormProvider({
isPublic: data?.isPublic, isPublic: data?.isPublic,
resources: data.resources?.length resources: data.resources?.length
? { ? {
connect: ( connect: (
data.resources?.filter(Boolean) || [] data.resources?.filter(Boolean) || []
).map((fileId) => ({ ).map((fileId) => ({
fileId, fileId,
})), })),
} }
: undefined, : undefined,
}, },
}); });
@ -100,6 +101,8 @@ export function LetterFormProvider({
duration: 5000, // 10秒 duration: 5000, // 10秒
} }
); );
navigate(-1);
// navigate(`/${result.id}/detail`, { // navigate(`/${result.id}/detail`, {
// replace: true, // replace: true,
// state: { scrollToTop: true }, // state: { scrollToTop: true },

View File

@ -7,16 +7,29 @@ import StaffSelect from "../../../staff/staff-select";
import TermSelect from "../../../term/term-select"; import TermSelect from "../../../term/term-select";
import TabPane from "antd/es/tabs/TabPane"; import TabPane from "antd/es/tabs/TabPane";
import toast from "react-hot-toast"; 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() { export function LetterBasicForm() {
const { onSubmit, receiverId, termId, form } = useLetterEditor(); const { onSubmit, receiverId, termId, form } = useLetterEditor();
const handleFinish = async (values: any) => { const handleFinish = async (values: any) => {
await onSubmit(values); await onSubmit(values);
}; };
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
api.rolemap.getStaffIdsByRoleNames.useQuery({
roleNames: [
RoleName.Leader,
RoleName.Organization,
RoleName.RootAdmin,
],
});
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await form.validateFields(); await form.validateFields();
form.submit(); form.submit();
// toast.success("提交成功!"); // toast.success("提交成功!");
} catch (error) { } catch (error) {
// 提取所有错误信息 // 提取所有错误信息
@ -47,15 +60,19 @@ export function LetterBasicForm() {
onFinish={handleFinish} onFinish={handleFinish}
initialValues={{ initialValues={{
meta: { tags: [] }, meta: { tags: [] },
receivers: [receiverId], receivers: [receiverId].filter(Boolean),
terms: [termId], // terms: [termId],
isPublic: true, isPublic: true,
}}> }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Form.Item label="收件人" name={"receivers"}> <Form.Item label="收件人" name={"receivers"}>
<StaffSelect multiple placeholder="选择收信人员" /> <StaffSelect
multiple
limitedIds={enabledStaffIds?.filter(Boolean)}
placeholder="选择收信人员"
/>
</Form.Item> </Form.Item>
<Form.Item label="分类" name={"terms"}> <Form.Item label="分类" name={"term"}>
<TermSelect placeholder="选择信件分类" /> <TermSelect placeholder="选择信件分类" />
</Form.Item> </Form.Item>
</div> </div>
@ -69,7 +86,7 @@ export function LetterBasicForm() {
/> />
</Form.Item> </Form.Item>
{/* Tags Input */} {/* Tags Input */}
<Form.Item name={["meta", "tags"]} className="mb-6"> {/* <Form.Item name={["meta", "tags"]} className="mb-6">
<Select <Select
mode="tags" mode="tags"
showSearch={false} showSearch={false}
@ -83,7 +100,7 @@ export function LetterBasicForm() {
className="w-full" className="w-full"
dropdownStyle={{ display: "none" }} dropdownStyle={{ display: "none" }}
/> />
</Form.Item> </Form.Item> */}
<Tabs defaultActiveKey="1"> <Tabs defaultActiveKey="1">
{/* Content Editor */} {/* 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 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 { ICellRendererParams } from "@ag-grid-community/core";
import { Menu, MenuItem } from "@web/src/components/presentation/dropdown-menu"; 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 { ColDef, ValueGetterParams } from "@ag-grid-community/core";
import { Button } from "antd"; import { Button } from "antd";
import DepartmentSelect from "../../department/department-select"; import DepartmentSelect from "../../department/department-select";
import { useContext, useEffect } from "react"; import { useContext, useEffect } from "react";
import { RoleEditorContext } from "./role-editor"; import { RoleEditorContext } from "./role-editor";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useRoleMap } from "@nice/client" import { useRoleMap } from "@nice/client";
const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => { const OpreationRenderer = ({ props }: { props: ICellRendererParams }) => {
const { deleteMany } = useRoleMap() const { deleteMany } = useRoleMap();
return ( return (
<div> <div>
<Menu <Menu
node={ node={
<EllipsisOutlined className=" hover:bg-textHover p-1 rounded" /> <EllipsisOutlined className=" hover:bg-textHover p-1 rounded" />
}> }>
<MenuItem <MenuItem
label="移除" label="移除"
onClick={() => { onClick={() => {
deleteMany.mutateAsync({ deleteMany.mutateAsync({
ids: [props?.data?.id], ids: [props?.data?.id],
}); });
}} }}
icon={<DeleteOutlined></DeleteOutlined>}></MenuItem> icon={<DeleteOutlined></DeleteOutlined>}></MenuItem>
</Menu> </Menu>
</div> </div>
); );
}; };
export default function AssignList() { export default function AssignList() {
const { user, hasSomePermissions } = useAuth(); const { user, hasSomePermissions } = useAuth();
const { domainId, setModalOpen, role, setDomainId, canManageRole } = const { domainId, setModalOpen, role, setDomainId, canManageRole } =
useContext(RoleEditorContext); useContext(RoleEditorContext);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setDomainId?.(user.domainId); setDomainId?.(user.domainId);
} }
}, [user]); }, [user]);
const columnDefs: ColDef[] = [ const columnDefs: ColDef[] = [
{ {
headerName: "帐号", headerName: "帐号",
field: "staff.username", field: "staff.username",
sort: "desc", sort: "desc",
valueGetter: (params: ValueGetterParams) => { valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_username; return params.data?.staff_username;
}, },
filter: "agTextColumnFilter", filter: "agTextColumnFilter",
maxWidth: 300, maxWidth: 300,
}, },
{ {
headerName: "姓名", headerName: "名称",
field: "staff.showname", field: "staff.showname",
sort: "desc", sort: "desc",
valueGetter: (params: ValueGetterParams) => { valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_showname; return params.data?.staff_showname;
}, },
filter: "agTextColumnFilter", filter: "agTextColumnFilter",
maxWidth: 300, maxWidth: 300,
}, },
{ {
headerName: "证件号", headerName: "证件号",
field: "staff.officer_id", field: "staff.officer_id",
sort: "desc", sort: "desc",
valueGetter: (params: ValueGetterParams) => { valueGetter: (params: ValueGetterParams) => {
return params.data?.staff_officer_id; return params.data?.staff_officer_id;
}, },
filter: "agTextColumnFilter", filter: "agTextColumnFilter",
}, },
{ {
headerName: "所在单位", headerName: "所在单位",
field: "department.name", field: "department.name",
sort: "desc", sort: "desc",
valueGetter: (params: ValueGetterParams) => { valueGetter: (params: ValueGetterParams) => {
return params.data?.department_name; return params.data?.department_name;
}, },
filter: "agTextColumnFilter", filter: "agTextColumnFilter",
maxWidth: 300, maxWidth: 300,
}, },
{ {
headerName: "操作", headerName: "操作",
sortable: true, sortable: true,
cellRenderer: (props) => <OpreationRenderer props={props}></OpreationRenderer>, // 指定 cellRenderer cellRenderer: (props) => (
maxWidth: 100, <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 "> return (
<span className=""> {role?.name}</span> <div className=" flex-grow">
<span className=" text-tertiary-300 "> </span> <div className="p-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2 ">
</div> <span className=""> {role?.name}</span>
<div className=" flex items-center gap-4"> <span className=" text-tertiary-300 "> </span>
<DepartmentSelect onChange={(value) => setDomainId(value as string)} rootId={user?.domainId} value={domainId} disabled={!canManageRole} domain={true} className=" w-48"></DepartmentSelect> </div>
{canManageRole && <Button <div className=" flex items-center gap-4">
onClick={() => { <DepartmentSelect
setModalOpen(true) onChange={(value) => setDomainId(value as string)}
}} rootId={user?.domainId}
type="primary" icon={<PlusOutlined></PlusOutlined>}></Button>} value={domainId}
</div> disabled={!canManageRole}
domain={true}
</div> className=" w-48"></DepartmentSelect>
<AgServerTable {canManageRole && (
rowGroupPanelShow="onlyWhenGrouping" <Button
height={"calc(100vh - 48px - 49px - 49px)"} onClick={() => {
columnDefs={columnDefs} setModalOpen(true);
rowHeight={50} }}
params={{ domainId, roleId: role?.id }} type="primary"
objectType={ObjectType.ROLE_MAP} icon={<PlusOutlined></PlusOutlined>}>
/>
</div> </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 noStyle
rules={[{ required: true }]} rules={[{ required: true }]}
name={"showname"} name={"showname"}
label="名"> label="">
<Input <Input
placeholder="请输入名" placeholder="请输入"
allowClear allowClear
autoComplete="new-name" // 使用非标准的自动完成值 autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false} spellCheck={false}

View File

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

View File

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

View File

@ -2,8 +2,9 @@ export const env: {
APP_NAME: string; APP_NAME: string;
SERVER_IP: string; SERVER_IP: string;
VERSION: string; VERSION: string;
UOLOAD_PORT: string; UPLOAD_PORT: string;
SERVER_PORT: string; SERVER_PORT: string;
LIB_URL: string;
} = { } = {
APP_NAME: import.meta.env.PROD APP_NAME: import.meta.env.PROD
? (window as any).env.VITE_APP_APP_NAME ? (window as any).env.VITE_APP_APP_NAME
@ -11,15 +12,18 @@ export const env: {
SERVER_IP: import.meta.env.PROD SERVER_IP: import.meta.env.PROD
? (window as any).env.VITE_APP_SERVER_IP ? (window as any).env.VITE_APP_SERVER_IP
: import.meta.env.VITE_APP_SERVER_IP, : import.meta.env.VITE_APP_SERVER_IP,
UOLOAD_PORT: import.meta.env.PROD UPLOAD_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_UOLOAD_PORT ? (window as any).env.VITE_APP_UPLOAD_PORT
: import.meta.env.VITE_APP_UOLOAD_PORT, : import.meta.env.VITE_APP_UPLOAD_PORT,
SERVER_PORT: import.meta.env.PROD SERVER_PORT: import.meta.env.PROD
? (window as any).env.VITE_APP_SERVER_PORT ? (window as any).env.VITE_APP_SERVER_PORT
: import.meta.env.VITE_APP_SERVER_PORT, : import.meta.env.VITE_APP_SERVER_PORT,
VERSION: import.meta.env.PROD VERSION: import.meta.env.PROD
? (window as any).env.VITE_APP_VERSION ? (window as any).env.VITE_APP_VERSION
: import.meta.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); 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) { if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
throw new Error("Invalid upload URL format"); 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; return resUrl;
}; };

View File

@ -117,3 +117,29 @@
.ql-snow .ql-picker.ql-header .ql-picker-label:not([data-value])::before { .ql-snow .ql-picker.ql-header .ql-picker-label:not([data-value])::before {
content: "正文" !important; 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(() => { const splashScreen = useMemo(() => {
return baseSetting?.appConfig?.splashScreen; return baseSetting?.appConfig?.splashScreen;
}, [baseSetting]); }, [baseSetting]);
const notice = useMemo(() => {
return baseSetting?.appConfig?.notice;
}, [baseSetting]);
const devDept = useMemo(() => { const devDept = useMemo(() => {
return baseSetting?.appConfig?.devDept; return baseSetting?.appConfig?.devDept;
}, [baseSetting]); }, [baseSetting]);
@ -46,6 +49,7 @@ export function useAppConfig() {
splashScreen, splashScreen,
devDept, devDept,
isLoading, isLoading,
notice,
logo, logo,
}; };
} }

View File

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

View File

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

View File

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

View File

@ -7,13 +7,13 @@ export const NiceTailwindConfig: Config = {
colors: { colors: {
// 主色调 - 空军蓝 // 主色调 - 空军蓝
primary: { primary: {
50: "#e8f2ff", 50: "#e8f2ff",
100: "#c5d9f7", 100: "#c5d9f7",
150: "#EDF0F8", // via color 150: "#EDF0F8", // via color
200: "#9fc0ef", 200: "#9fc0ef",
250: "#E6E9F0", // from color 250: "#E6E9F0", // from color
300: "#78a7e7", 300: "#78a7e7",
350: "#D8E2EF", // to color 350: "#D8E2EF", // to color
400: "#528edf", 400: "#528edf",
500: "#00308a", 500: "#00308a",
600: "#00256b", 600: "#00256b",
@ -28,7 +28,7 @@ export const NiceTailwindConfig: Config = {
100: "#e0e0e0", 100: "#e0e0e0",
200: "#c2c2c2", 200: "#c2c2c2",
300: "#a3a3a3", 300: "#a3a3a3",
350: "#97A9C4", // New color inserted 350: "#97A9C4", // New color inserted
400: "#858585", 400: "#858585",
500: "#666666", 500: "#666666",
600: "#4d4d4d", 600: "#4d4d4d",
@ -77,21 +77,30 @@ export const NiceTailwindConfig: Config = {
solid: "2px 2px 0 0 rgba(0, 0, 0, 0.2)", solid: "2px 2px 0 0 rgba(0, 0, 0, 0.2)",
glow: "0 0 8px rgba(230, 180, 0, 0.8)", glow: "0 0 8px rgba(230, 180, 0, 0.8)",
inset: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.15)", 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-1":
"elevation-2": "0 2px 4px rgba(0, 48, 138, 0.1), 0 2px 2px rgba(0, 0, 0, 0.1)", "0 1px 2px rgba(0, 48, 138, 0.05), 0 1px 1px rgba(0, 0, 0, 0.05)",
"elevation-3": "0 4px 8px rgba(0, 48, 138, 0.15), 0 4px 4px rgba(0, 0, 0, 0.15)", "elevation-2":
"elevation-4": "0 8px 16px rgba(0, 48, 138, 0.2), 0 8px 8px rgba(0, 0, 0, 0.2)", "0 2px 4px rgba(0, 48, 138, 0.1), 0 2px 2px rgba(0, 0, 0, 0.1)",
"elevation-5": "0 16px 32px rgba(0, 48, 138, 0.25), 0 16px 16px rgba(0, 0, 0, 0.25)", "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)", 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)", 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)", 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)", 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-primary":
"soft-secondary": "0 2px 4px rgba(77, 77, 77, 0.1), 0 4px 8px rgba(77, 77, 77, 0.05)", "0 2px 4px rgba(0, 48, 138, 0.1), 0 4px 8px rgba(0, 48, 138, 0.05)",
"soft-accent": "0 2px 4px rgba(230, 180, 0, 0.1), 0 4px 8px rgba(230, 180, 0, 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)", "inner-glow": "inset 0 0 8px rgba(0, 48, 138, 0.1)",
"outer-glow": "0 0 16px 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: { animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite", "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 = { window.env = {
VITE_APP_SERVER_IP: "192.168.112.239", VITE_APP_SERVER_IP: "192.168.112.239",
VITE_APP_SERVER_PORT: "3002", VITE_APP_SERVER_PORT: "3002",
VITE_APP_UOLOAD_PORT: "8090", VITE_APP_UPLOAD_PORT: "8090",
VITE_APP_APP_NAME: "领导信箱", VITE_APP_APP_NAME: "领导信箱",
VITE_APP_VERSION: "0.1.0", VITE_APP_VERSION: "0.1.0",
}; };