This commit is contained in:
longdayi 2025-01-26 08:26:17 +08:00
parent ad76e0dac7
commit 13d79ee235
26 changed files with 295 additions and 429 deletions

View File

@ -9,6 +9,7 @@ import {
UpdateOrderArgs,
TransactionType,
SelectArgs,
OrderByArgs,
} from './base.type';
import {
NotFoundException,
@ -451,8 +452,9 @@ export class BaseService<
pageSize?: number;
where?: WhereArgs<A['findMany']>;
select?: SelectArgs<A['findMany']>;
orderBy?: OrderByArgs<A['findMany']>
}): Promise<{ items: R['findMany']; totalPages: number, totalCount: number }> {
const { page = 1, pageSize = 10, where, select } = args;
const { page = 1, pageSize = 10, where, select, orderBy } = args;
try {
// 获取总记录数
@ -462,6 +464,7 @@ export class BaseService<
const items = (await this.getModel().findMany({
where,
select,
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} as any)) as R['findMany'];

View File

@ -12,6 +12,7 @@ const PostDeleteManyArgsSchema: ZodType<Prisma.PostDeleteManyArgs> = z.any();
const PostWhereInputSchema: ZodType<Prisma.PostWhereInput> = z.any();
const PostSelectSchema: ZodType<Prisma.PostSelect> = z.any();
const PostUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any();
const PostOrderBySchema: ZodType<Prisma.PostOrderByWithRelationInput> = z.any()
@Injectable()
export class PostRouter {
constructor(
@ -103,6 +104,7 @@ export class PostRouter {
pageSize: z.number().optional(),
where: PostWhereInputSchema.optional(),
select: PostSelectSchema.optional(),
orderBy: PostOrderBySchema.optional()
}),
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input, ctx }) => {

View File

@ -71,8 +71,11 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
const transDto = await this.wrapResult(
super.findFirst(args),
async (result) => {
await setPostRelation({ data: result, staff, clientIp });
await this.setPerms(result, staff);
if (result) {
await setPostRelation({ data: result, staff, clientIp });
await this.setPerms(result, staff);
}
return result;
},
);
@ -98,13 +101,13 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
});
}
async findManyWithPagination(
args: { page?: number; pageSize?: number; where?: Prisma.PostWhereInput; select?: Prisma.PostSelect<DefaultArgs>; },
args: { page?: number; pageSize?: number; where?: Prisma.PostWhereInput; select?: Prisma.PostSelect<DefaultArgs>; orderBy?: Prisma.PostOrderByWithRelationInput },
staff?: UserProfile,
clientIp?: string,
) {
if (!args.where) args.where = {};
args.where.OR = await this.preFilter(args.where.OR, staff);
return this.wrapResult(super.findManyWithPagination(args), async (result) => {
return this.wrapResult(super.findManyWithPagination(args as any), async (result) => {
const { items } = result;
await Promise.all(
items.map(async (item) => {

View File

@ -4,5 +4,8 @@ import { useParams } from "react-router-dom";
export default function LetterDetailPage() {
const { id } = useParams();
return <PostDetail id={id}></PostDetail>;
return <div className="shadow-elegant border-2 border-white rounded-xl bg-gradient-to-b from-slate-100 to-slate-50">
<PostDetail id={id}></PostDetail>
</div>;
}

View File

@ -4,7 +4,7 @@ export function Header() {
<div className="flex flex-col space-y-6">
{/* 主标题区域 */}
<div>
<h1 className="text-3xl font-bold tracking-wider text-white"></h1>
<h1 className="text-3xl font-bold tracking-wider text-white"></h1>
<p className="mt-2 text-blue-100 text-lg">
</p>

View File

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

View File

@ -1,18 +1,18 @@
import { useEffect, useState } from "react";
import { Input, Button, Card, Steps, Tag, Spin, message, Empty } from "antd";
import { SearchOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
import { SearchOutlined, SafetyCertificateOutlined, SendOutlined, MailOutlined, SyncOutlined, CheckCircleOutlined } from "@ant-design/icons";
import ProgressHeader from "./ProgressHeader";
import { api } from "@nice/client";
import { LetterBadge } from "@web/src/components/models/post/LetterBadge";
import dayjs from "dayjs";
import { LetterCard } from "@web/src/components/models/post/LetterCard";
import { postDetailSelect } from "@nice/common";
import { postDetailSelect, PostState } from "@nice/common";
export default function LetterProgressPage() {
const [letterId, setLetterId] = useState<string>();
const { data, isFetching, error } = api.post.findFirst.useQuery({
const { data, isLoading, error } = api.post.findFirst.useQuery({
where: {
id: letterId
},
@ -28,11 +28,12 @@ export default function LetterProgressPage() {
return (
<div
className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-gradient-to-b from-slate-100 to-slate-50">
className=" shadow-elegant border-2 border-white rounded-xl bg-slate-200">
<ProgressHeader></ProgressHeader>
<main className="container mx-auto p-6 flex flex-col gap-4">
<div className="flex gap-4">
<Input
variant="filled"
prefix={
<SearchOutlined className=" text-secondary-300" />
}
@ -43,91 +44,45 @@ export default function LetterProgressPage() {
/>
</div>
{isLoading && <div className="flex justify-center items-center pt-6">
<Spin size="large" ></Spin>
</div>}
{!isLoading && letterId && !data && (
<div className=" p-6">
<Empty
description={`未找到编码为 ${letterId} 的信件`}
></Empty>
</div>
)}
{data && (
<>
<LetterCard letter={data as any} />
<div className=" p-6 bg-slate-100 border hover:ring-1 ring-white ease-in-out hover:-translate-y-0.5 border-white rounded-xl cursor-pointer overflow-hidden transition-all duration-300">
{/* Results Section */}
<Spin spinning={isFetching} tip="查询中...">
{data ? (
<>
<div className="space-y-6 p-4 rounded-xl bg-white">
{/* Header */}
<div className="flex items-center justify-between border-b pb-4 space-y-2">
<h2 className="text-xl font-semibold text-primary">
</h2>
<LetterBadge type="state" value={data?.state} className="text-sm" />
</div>
<Steps
current={[PostState.PENDING, PostState.PROCESSING, PostState.RESOLVED].indexOf(data.state as PostState)}
{/* Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div>
<p className="text-sm text-gray-600">
</p>
<p className="font-medium text-[#003366]">
{data.id}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
</p>
<p className="font-medium text-[#003366]">
{dayjs(data.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
</p>
<p className="font-medium text-[#003366]">
{dayjs(data.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
</p>
<p className="font-medium text-[#003366]">
{data.title}
</p>
</div>
</div>
{/* Progress Timeline */}
<div className="mt-8">
<Steps
current={['pending', 'processing', 'completed'].indexOf(data.state)}
items={[
{
title: '已提交',
description: '信件已成功接收',
icon: <SafetyCertificateOutlined />,
},
{
title: '处理中',
description: '正在审核处理',
icon: <SafetyCertificateOutlined />,
},
{
title: '已办结',
description: '信件处理完成',
icon: <SafetyCertificateOutlined />,
},
]}
/>
</div>
</div>
<LetterCard letter={data as any} /></>
) : !isFetching && letterId && (
<div className=" p-6">
<Empty
description={`未找到编码为 ${letterId} 的信件`}
></Empty>
items={[
{
title: '已提交',
description: '信件已成功接收',
icon: <MailOutlined></MailOutlined>
},
{
title: '处理中',
description: '正在审核处理',
icon: <SyncOutlined></SyncOutlined>
},
{
title: '已办结',
description: '信件处理完成',
icon: <CheckCircleOutlined></CheckCircleOutlined>
},
]}
/>
</div>
)}
</Spin>
</>
)}
</main>
</div>
);

View File

@ -1,7 +1,7 @@
import { motion } from 'framer-motion';
import { StaffDto } from "@nice/common";
import { Button, Tooltip, Badge } from 'antd';
import { BankFilled, SendOutlined } from '@ant-design/icons';
import { BankFilled, SendOutlined, UserOutlined, MailOutlined, PhoneOutlined, HomeOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
export interface SendCardProps {
@ -10,19 +10,17 @@ export interface SendCardProps {
}
export function SendCard({ staff, termId }: SendCardProps) {
const navigate = useNavigate();
const handleSendLetter = () => {
window.open(`/editor?termId=${termId || ''}&receiverId=${staff.id}`, '_blank');
};
return (
<div
className="bg-white rounded-xl overflow-hidden border-2 hover:border-primary transition-all duration-300"
onClick={handleSendLetter}
className="bg-slate-100 border hover:ring-1 ring-white ease-in-out hover:-translate-y-0.5 border-white rounded-xl cursor-pointer overflow-hidden transition-all duration-300"
>
<div className="flex flex-col sm:flex-row">
{/* Image Container */}
<div className="sm:w-56 h-72 sm:h-auto flex-shrink-0 bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center relative">
<div className="sm:w-56 h-72 sm:h-auto flex-shrink-0 border-r flex items-center justify-center relative">
{staff.meta?.photoUrl ? (
<img
src={staff.meta.photoUrl}
@ -31,20 +29,7 @@ export function SendCard({ staff, termId }: SendCardProps) {
/>
) : (
<div className="flex flex-col items-center justify-center text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-20 w-20 mb-3 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span className="text-sm"></span>
</div>
)}
@ -57,15 +42,11 @@ export function SendCard({ staff, termId }: SendCardProps) {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-2xl font-semibold text-gray-900">
<h3 className="text-2xl font-semibold text-gray-700">
{staff?.showname || staff?.username}
</h3>
</div>
<p className="text-gray-600 text-lg font-medium flex items-center gap-2">
<BankFilled></BankFilled>
{staff.department?.name || '未设置部门'}</p>
</div>
<Tooltip title="职级">
<span className="inline-flex items-center px-4 py-1.5 text-sm font-medium bg-gradient-to-r from-blue-50 to-blue-100 text-primary rounded-full hover:from-blue-100 hover:to-blue-200 transition-colors shadow-sm">
@ -76,39 +57,26 @@ export function SendCard({ staff, termId }: SendCardProps) {
{/* Contact Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600 mb-6">
<div className="flex items-center bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-all duration-200 hover:shadow-sm">
<svg className="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<div className="flex items-center bg-gray-50 rounded-lg p-3 transition-all duration-200 hover:shadow-sm">
<BankFilled className="w-4 h-4 mr-2 text-gray-500" rev={undefined} />
<span className="truncate">{staff.department?.name || '未设置部门'}</span>
</div>
<div className="flex items-center bg-gray-50 rounded-lg p-3 transition-all duration-200 hover:shadow-sm">
<MailOutlined className="w-4 h-4 mr-2 text-gray-500" rev={undefined} />
<span className="truncate">{staff.meta?.email || '未设置邮箱'}</span>
</div>
<div className="flex items-center bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-all duration-200 hover:shadow-sm">
<svg className="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<div className="flex items-center bg-gray-50 rounded-lg p-3 transition-all duration-200 hover:shadow-sm">
<PhoneOutlined className="w-4 h-4 mr-2 text-gray-500" rev={undefined} />
<span className="truncate">{staff.phoneNumber || '未设置电话'}</span>
</div>
<div className="flex items-center bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-all duration-200 hover:shadow-sm md:col-span-2">
<svg className="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<div className="flex items-center bg-gray-50 rounded-lg p-3 transition-all duration-200 hover:shadow-sm">
<HomeOutlined className="w-4 h-4 mr-2 text-gray-500" rev={undefined} />
<span className="truncate">{staff.meta?.office || '未设置办公室'}</span>
</div>
</div>
</div>
<Button
type="primary"
size="large"
icon={<SendOutlined />}
onClick={handleSendLetter}
>
</Button>
</div>
</div>
</div>

View File

@ -46,27 +46,29 @@ export default function WriteLetterPage() {
}, [searchQuery, selectedDept, resetPage]);
return (
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-gradient-to-b from-slate-100 to-slate-50">
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
<WriteHeader term={getTerm(termId)} />
<div className="container mx-auto px-4 py-8">
<div className="mb-8 space-y-4">
{/* Search and Filter Section */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="w-full md:w-96">
<Input
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="搜索领导姓名或职级..."
onChange={debounce((e) => setSearchQuery(e.target.value), 300)}
className="w-full"
size="large"
/>
</div>
<DepartmentSelect
variant='filled'
size="large"
value={selectedDept}
onChange={setSelectedDept as any}
className="w-full md:w-64"
className="w-1/2"
/>
<Input
variant='filled'
className={'w-1/2'}
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="搜索领导姓名或职级..."
onChange={debounce((e) => setSearchQuery(e.target.value), 300)}
size="large"
/>
</div>
{error && (

View File

@ -11,8 +11,8 @@ export function Footer() {
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
</h3>
<p className="text-gray-400 text-xs">
<p className="text-gray-400 text-xs italic">
</p>
</div>
@ -20,11 +20,11 @@ export function Footer() {
<div className="text-center space-y-2">
<div className="flex items-center justify-center space-x-2">
<PhoneOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">1-800-XXX-XXXX</span>
<span className="text-gray-300 text-xs">628118</span>
</div>
<div className="flex items-center justify-center space-x-2">
<MailOutlined className="text-gray-400" />
<span className="text-gray-300 text-xs">support@example.com</span>
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
</div>
</div>
@ -32,14 +32,14 @@ export function Footer() {
<div className="text-center md:text-right space-y-2">
<div className="flex items-center justify-center md:justify-end space-x-4">
<a
href="https://portal.example.com"
href="https://27.57.72.21"
className="text-gray-400 hover:text-white transition-colors"
title="访问门户网站"
>
<HomeOutlined className="text-lg" />
</a>
<a
href="https://nextcloud.example.com"
href="https://27.57.72.14"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火青云"
>
@ -47,7 +47,7 @@ export function Footer() {
</a>
<a
href="https://regulation.example.com"
href="http://27.57.72.38"
className="text-gray-400 hover:text-white transition-colors"
title="访问烽火律询"
>

View File

@ -15,6 +15,7 @@ interface DepartmentSelectProps {
disabled?: boolean;
className?: string;
size?: "small" | "middle" | "large";
variant?: "outlined" | "borderless" | "filled"
}
export default function DepartmentSelect({
@ -28,6 +29,7 @@ export default function DepartmentSelect({
size,
disabled = false,
domain = undefined,
variant
}: DepartmentSelectProps) {
const utils = api.useUtils();
const [listTreeData, setListTreeData] = useState<
@ -148,6 +150,7 @@ export default function DepartmentSelect({
return (
<TreeSelect
variant={variant}
treeDataSimpleMode
disabled={disabled}
showSearch

View File

@ -13,64 +13,72 @@ import {
export const BADGE_STYLES = {
category: {
complaint: {
bg: "bg-gradient-to-r from-orange-50 to-orange-100",
text: "text-orange-800",
border: "border-orange-200",
icon: <ExclamationCircleOutlined className="text-orange-500" />,
hover: "hover:from-orange-100 hover:to-orange-200",
bg: "bg-orange-50",
text: "text-orange-700",
border: "border border-orange-100",
icon: <ExclamationCircleOutlined className="text-orange-400 text-sm" />,
},
suggestion: {
bg: "bg-gradient-to-r from-blue-50 to-blue-100",
text: "text-blue-800",
border: "border-blue-200",
icon: <BulbOutlined className="text-blue-500" />,
hover: "hover:from-blue-100 hover:to-blue-200",
bg: "bg-blue-50",
text: "text-blue-700",
border: "border border-blue-100",
icon: <BulbOutlined className="text-blue-400 text-sm" />,
},
request: {
bg: "bg-gradient-to-r from-purple-50 to-purple-100",
text: "text-purple-800",
border: "border-purple-200",
icon: <QuestionCircleOutlined className="text-purple-500" />,
hover: "hover:from-purple-100 hover:to-purple-200",
bg: "bg-purple-50",
text: "text-purple-700",
border: "border border-purple-100",
icon: <QuestionCircleOutlined className="text-purple-400 text-sm" />,
},
feedback: {
bg: "bg-gradient-to-r from-teal-50 to-teal-100",
text: "text-teal-800",
border: "border-teal-200",
icon: <CommentOutlined className="text-teal-500" />,
hover: "hover:from-teal-100 hover:to-teal-200",
bg: "bg-teal-50",
text: "text-teal-700",
border: "border border-teal-100",
icon: <CommentOutlined className="text-teal-400 text-sm" />,
},
},
state: {
[PostState.PENDING]: {
bg: "bg-gradient-to-r from-yellow-50 to-yellow-100",
text: "text-yellow-800",
border: "border-yellow-200",
icon: <ClockCircleOutlined className="text-yellow-500" />,
hover: "hover:from-yellow-100 hover:to-yellow-200",
bg: "bg-yellow-50",
text: "text-yellow-700",
border: "border border-yellow-100",
icon: <ClockCircleOutlined className="text-yellow-400 text-sm" />,
},
[PostState.PROCESSING]: {
bg: "bg-gradient-to-r from-blue-50 to-blue-100",
text: "text-blue-800",
border: "border-blue-200",
icon: <SyncOutlined className="text-blue-500" spin />,
hover: "hover:from-blue-100 hover:to-blue-200",
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-gradient-to-r from-green-50 to-green-100",
text: "text-green-800",
border: "border-green-200",
icon: <CheckCircleOutlined className="text-green-500" />,
hover: "hover:from-green-100 hover:to-green-200",
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-gradient-to-r from-gray-50 to-gray-100",
text: "text-gray-800",
border: "border-gray-200",
icon: <TagOutlined className="text-gray-500" />,
hover: "hover:from-gray-100 hover:to-gray-200",
bg: "bg-gray-50",
text: "text-gray-700",
border: "border border-gray-100",
icon: <TagOutlined className="text-gray-400 text-sm" />,
}
},
} as const;
@ -82,11 +90,9 @@ export const getBadgeStyle = (
const style = type === 'tag'
? BADGE_STYLES.tag.default
: BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || {
bg: "bg-gradient-to-r from-gray-50 to-gray-100",
text: "text-gray-800",
border: "border-gray-200",
icon: <TagOutlined className="text-gray-500" />,
hover: "hover:from-gray-100 hover:to-gray-200",
bg: "bg-gray-50",
text: "text-gray-700",
icon: <TagOutlined className="text-gray-400 text-sm" />,
};
return style;
@ -108,12 +114,9 @@ export function LetterBadge({
return (
<span
className={`
inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium
${style.bg} ${style.text} ${style.hover}
border ${style.border}
shadow-sm backdrop-blur-sm
transition-all duration-300 ease-out
hover:shadow-md hover:-translate-y-0.5
inline-flex items-center gap-2 px-2 py-1 rounded-full
text-xs transition-all
${style.bg} ${style.text} ${style.border}
${className}
`}>
{style.icon}

View File

@ -7,6 +7,7 @@ import {
CalendarOutlined,
FileTextOutlined,
SendOutlined,
MailOutlined,
} from "@ant-design/icons";
import { Button, Typography, Space, Tooltip } from "antd";
import { PostDto, PostStateLabels } from "@nice/common";
@ -21,54 +22,42 @@ interface LetterCardProps {
export function LetterCard({ letter }: LetterCardProps) {
return (
<div onClick={() => {
window.open(`/${letter.id}/detail`)
}}
className=" cursor-pointer w-full p-6 bg-white rounded-xl group relative overflow-hidden duration-300 hover:-translate-y-1 transition-all ease-in-out "
<div
onClick={() => {
window.open(`/${letter.id}/detail`)
}}
className="cursor-pointer p-6 bg-slate-100/80 rounded-xl hover:ring-white hover:ring-1 transition-all
duration-300 ease-in-out hover:-translate-y-0.5
active:scale-[0.98] border border-white
group relative overflow-hidden"
>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start">
<Title level={4} className="!mb-0 flex-1 font-serif tracking-tight text-gray-800">
<a
href={`/${letter.id}/detail`}
target="_blank"
className="text-primary hover:text-primary-600 transition-colors duration-200 hover:underline">
{letter.title}
</a>
</Title>
<div className=" text-xl text-primary font-bold">
{letter.title}
</div>
{/* Meta Info */}
<div className="flex justify-between items-center text-sm text-gray-500">
<div className="flex items-center gap-6">
<div className="flex justify-between items-center text-sm text-gray-600 gap-4 flex-wrap">
<div className="flex items-center gap-4 flex-1 min-w-[300px]">
{letter.author?.department?.name && (
<div className="flex items-center gap-2">
<BankOutlined className="text-secondary-400 text-base" />
<Text className="text-gray-600 font-medium">
{letter.author?.department?.name}
</Text>
</div>
)}
<div className="flex items-center gap-2">
<UserOutlined className="text-secondary-400 text-base" />
<Text className="text-gray-600 font-medium">
<UserOutlined className="text-primary text-base" />
<Text className="text-primary font-medium">
{letter.author?.showname || '匿名用户'}
</Text>
</div>
{letter.receivers.some(item => item.department?.name) && (
<div className="flex items-center gap-2">
<BankOutlined className="text-secondary-400 text-base" />
<Tooltip title={letter.receivers.map(item => item.department?.name).filter(Boolean).join(', ')}>
<Text className="text-gray-600">
{letter.receivers
.map(item => item.department?.name)
.filter(Boolean)
.slice(0, 2)
.join('、')}
{letter.receivers.filter(item => item.department?.name).length > 2 && ' 等'}
</Text>
</Tooltip>
</div>
)}
{letter.receivers.some(item => item.showname) && (
<div className="flex items-center gap-2">
<SendOutlined className="text-secondary-400 text-base" />
<MailOutlined className="text-primary-400 text-base" />
<Tooltip title={letter.receivers.map(item => item.showname).filter(Boolean).join(', ')}>
<Text className="text-gray-600">
<Text className="text-primary-400">
{letter.receivers
.map(item => item.showname)
.filter(Boolean)
@ -90,21 +79,21 @@ export function LetterCard({ letter }: LetterCardProps) {
{/* Content Preview */}
{letter.content && (
<Paragraph
ellipsis={{ rows: 2 }}
className="!mb-4 text-gray-600 flex-1 leading-relaxed text-sm font-sans">
{letter.content}
</Paragraph>
<div className="flex-1 leading-relaxed text-sm">
<div
dangerouslySetInnerHTML={{ __html: letter.content }}
className="line-clamp-2"
/>
</div>
)}
<div className="flex flex-wrap gap-2">
<LetterBadge type="state" value={letter.state} />
{letter.meta.tags.map(tag => (
<LetterBadge key={tag} type="tag" value={tag} />
))}
</div>
{/* Badges & Interactions */}
<div className="flex justify-between items-center ">
<div className="flex flex-wrap gap-2">
<LetterBadge type="state" value={letter.state} />
{letter.meta.tags.map(tag => (
<LetterBadge key={tag} type="tag" value={tag} />
))}
{letter.terms.map(term => (
<LetterBadge key={term.name} type="category" value={term.name} />
))}

View File

@ -6,7 +6,7 @@ import { Avatar } from "antd";
import { useVisitor } from "@nice/client";
import { useContext, useEffect, useRef, useState } from "react";
import { PostDetailContext } from "./context/PostDetailContext";
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
import { CheckCircleOutlined, LikeFilled, LikeOutlined } from "@ant-design/icons";
import PostLikeButton from "./PostHeader/PostLikeButton";
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
import PostResources from "./PostResources";
@ -22,7 +22,7 @@ export default function PostCommentCard({
}) {
return (
<motion.div
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
className="bg-white rounded-lg border p-4"
layout>
<div className="flex items-start space-x-2 gap-2">
<div className="flex-shrink-0">
@ -35,7 +35,7 @@ export default function PostCommentCard({
<div className="flex-1">
<div className={`flex-1 min-w-0 `}>
<div className="flex flex-1 justify-between ">
<div className="flex space-x-2">
<div className="flex items-center space-x-2">
<span className="flex font-medium text-slate-900">
{post.author?.showname || "匿名用户"}
</span>
@ -45,10 +45,9 @@ export default function PostCommentCard({
)}
</span>
{isReceiverComment && (
<div className=" ">
<span className=" py-0.5 px-2 text-xs rounded-full bg-blue-100 text-blue-800">
</span>
<div className=" py-1 px-4 rounded-full bg-primary-50 text-primary-500">
</div>
)}
</div>

View File

@ -52,19 +52,19 @@ export default function PostCommentEditor() {
};
return (
<div className="w-full mx-auto mt-1">
<div className="p-6">
<form onSubmit={handleSubmit} className="space-y-2">
<Tabs defaultActiveKey="1">
<TabPane tab="回复" key="1">
<div className="relative rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="relative rounded-lg border bg-white">
<QuillEditor
value={content}
onChange={setContent}
placeholder="写下你的回复..."
className="bg-transparent"
theme="snow"
minRows={6}
maxRows={12}
modules={{
toolbar: [
["bold", "italic", "strike"],
@ -77,12 +77,7 @@ export default function PostCommentEditor() {
["clean"],
],
}}
style={
{
"--ql-border-color": "transparent",
"--ql-toolbar-bg": "rgb(248, 250, 252)", // slate-50
} as React.CSSProperties
}
/>
</div>
</TabPane>
@ -101,6 +96,7 @@ export default function PostCommentEditor() {
<Button
type="primary"
htmlType="submit"
size="large"
disabled={isContentEmpty(content)}
className="flex items-center space-x-2 bg-primary"
icon={<SendOutlined />}>

View File

@ -6,6 +6,7 @@ import { motion, AnimatePresence } from "framer-motion";
import PostCommentCard from "./PostCommentCard";
import { useInView } from "react-intersection-observer";
import { LoadingCard } from "@web/src/components/models/post/detail/LoadingCard";
import { Spin } from "antd";
export default function PostCommentList() {
const { post } = useContext(PostDetailContext);
const { ref: loadMoreRef, inView } = useInView();
@ -136,18 +137,11 @@ export default function PostCommentList() {
}
if (!items.length) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-12 text-slate-500">
</motion.div>
);
return null
}
return (
<div className="space-y-2 mt-2">
<div className=" space-y-2 p-6">
<AnimatePresence mode="popLayout">
{items.map((comment, index) => (
<motion.div
@ -173,9 +167,7 @@ export default function PostCommentList() {
{/* 加载更多触发器 */}
<div ref={loadMoreRef} className="h-20">
{isFetchingNextOfficialPage || isFetchingNextNonOfficialPage ? (
<div className="flex justify-center py-4">
<div className="w-6 h-6 border-2 border-[#00308F] border-t-transparent rounded-full animate-spin" />
</div>
<Spin></Spin>
) : (
items.length > 0 &&
!hasNextOfficialPage &&
@ -185,21 +177,11 @@ export default function PostCommentList() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex flex-col items-center py-4 space-y-2">
<div className="h-px w-16 bg-gradient-to-r from-transparent via-[#00308F]/30 to-transparent" />
<span className="text-sm text-gray-500 font-medium">
</span>
<motion.div
className="h-px w-16 bg-gradient-to-r from-transparent via-[#00308F]/30 to-transparent"
initial={{ width: "4rem" }}
animate={{ width: "12rem" }}
transition={{
duration: 1.5,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut",
}}
/>
</motion.div>
)
)}

View File

@ -1,16 +1,9 @@
import { useEffect } from "react";
import { PostDetailProvider } from "./context/PostDetailContext";
import PostDetailLayout from "./layout/PostDetailLayout";
export default function PostDetail({ id }: { id?: string }) {
return (
<>
<PostDetailProvider editId={id}>
<PostDetailLayout></PostDetailLayout>
</PostDetailProvider>
</>
);
return <PostDetailProvider editId={id}>
<PostDetailLayout></PostDetailLayout>
</PostDetailProvider>
}

View File

@ -19,16 +19,15 @@ export default function Content() {
}, [post?.content]);
return (
<div className="relative bg-white rounded-b-xl p-4 pt-2 border border-[#97A9C4]/30">
<div className="text-secondary-700">
<div className="p-6 text-base " >
<div className="text-secondary-700 bg-white rounded-lg p-6 ">
{/* 包装整个内容区域的容器 */}
<div
ref={contentWrapperRef}
className={`duration-300 ${
shouldCollapse && !isExpanded
? `max-h-[150px] overflow-hidden relative`
: ""
}`}>
className={`duration-300 ${shouldCollapse && !isExpanded
? `max-h-[150px] overflow-hidden relative`
: ""
}`}>
{/* 内容区域 */}
<div
className="ql-editor p-0 space-y-1 leading-relaxed"
@ -37,8 +36,7 @@ export default function Content() {
}}
/>
{/* PostResources 组件 */}
<PostResources post={post} />
{/* 渐变遮罩 */}
{shouldCollapse && !isExpanded && (
@ -55,8 +53,6 @@ export default function Content() {
</button>
)}
</div>
{/* Stats Section */}
<StatsSection />
</div>
);

View File

@ -12,16 +12,12 @@ import {
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { CornerBadge } from "../badge/CornerBadeg";
import { LetterBadge } from "../../LetterBadge";
const { Title, Paragraph, Text } = Typography;
export default function Header() {
const { post, user } = useContext(PostDetailContext);
return (
<header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-4 relative">
{/* 右上角标签 */}
{/* <CornerBadge type="state" value={post?.state}></CornerBadge> */}
<header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6 relative">
<div className="flex flex-col space-b-1">
{/* 主标题 */}
<div>
@ -29,42 +25,42 @@ export default function Header() {
{post?.title}
</h1>
</div>
<div className="space-y-1">
{/* 收件人信息行 */}
<div className="space-y-2">
<Space className="mr-4">
<MailOutlined className="text-white" />
<span className="text-white"></span>
<span className="text-white"></span>
<Text className="text-white" strong>
{post?.author?.showname || "匿名用户"}
</Text>
</Space>
<Space className="mr-4">
<span className="text-white"> </span>
{post?.receivers?.map((receiver, index) => (
<Text
strong
className="text-white"
key={`${index}`}>
{receiver?.showname}
{receiver?.meta?.rank} {receiver?.showname || '匿名用户'}
</Text>
))}
</Space>
<Space className="mr-4">
<SendOutlined className="text-white" />
<span className="text-white">:</span>
<Text className="text-white" strong>
{post?.author?.showname || "匿名用户"}
</Text>
</Space>
{/* Date Info Badge */}
<Space className="mr-4">
<CalendarOutlined className="text-white" />
<span className="text-white"></span>
<Text className="text-white">
:
{dayjs(post?.createdAt).format("YYYY-MM-DD")}
</Text>
</Space>
{/* Last Updated Badge */}
<Space className="mr-4">
<ClockCircleOutlined className="text-white" />
<span className="text-white"></span>
<Text className="text-white">
:
{dayjs(post?.updatedAt).format("YYYY-MM-DD")}
</Text>
</Space>
@ -76,7 +72,7 @@ export default function Header() {
<LockOutlined className="text-white" />
)}
<Text className="text-white">
{post?.isPublic ? "公开" : "私信"}
{post?.isPublic ? "公开" : "保密"}
</Text>
</Space>
@ -86,7 +82,7 @@ export default function Header() {
</div>
{/* Second Row - Term and Tags */}
{post?.meta?.tags?.length > 0 && (
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-2">
{/* Tags Badges */}
<LetterBadge type="state" value={post?.state} />

View File

@ -1,20 +0,0 @@
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { motion } from "framer-motion";
import { StatsSection } from "./StatsSection";
import PostResources from "../PostResources";
import Header from "./Header";
import Content from "./Content";
export default function PostHeader() {
const { post, user } = useContext(PostDetailContext);
return (
<>
<Header></Header>
<Content></Content>
</>
);
}

View File

@ -31,19 +31,19 @@ export default function PostLikeButton({ post }: { post: PostDto }) {
}
}
return (
<Tooltip title={post?.liked ? "取消点赞" : "点赞"} placement="top">
<Button
type={post?.liked ? "primary" : "default"}
shape="round"
ghost={post?.liked}
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
onClick={(e) => {
e.stopPropagation()
likeThisPost()
}}
>
{post?.likes}
</Button>
</Tooltip>
<Button
title={post?.liked ? "取消点赞" : "点赞"}
type={post?.liked ? "primary" : "default"}
shape="round"
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
onClick={(e) => {
e.stopPropagation()
likeThisPost()
}}
>
{post?.likes}
</Button>
);
}

View File

@ -8,27 +8,27 @@ import {
} from "@ant-design/icons";
import { Button, Tooltip } from "antd/lib";
import { PostDetailContext } from "../context/PostDetailContext";
import { useVisitor } from "@nice/client";
import { VisitType } from "packages/common/dist";
import PostLikeButton from "./PostLikeButton";
import PostResources from "../PostResources";
export function StatsSection() {
const { post } = useContext(PostDetailContext);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="mt-6 flex flex-wrap gap-4 justify-between items-center">
<div
className="mt-6 flex flex-wrap gap-4 justify-between items-center">
{/* PostResources 组件 */}
<PostResources post={post} />
<div className=" flex gap-2">
<Button type="default" shape="round" icon={<EyeOutlined />}>
<Button title="浏览量" type="default" shape="round" icon={<EyeOutlined />}>
{post?.views}
</Button>
<Button type="default" shape="round" icon={<CommentOutlined />}>
<Button type="default" title="评论数" shape="round" icon={<CommentOutlined />}>
{post?.commentsCount}
</Button>
<PostLikeButton post={post}></PostLikeButton>
</div>
<PostLikeButton post={post}></PostLikeButton>
</motion.div>
</div>
);
}

View File

@ -1,13 +1,11 @@
import { motion } from "framer-motion";
import { useContext, useEffect } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import PostHeader from "../PostHeader/PostHeader";
import WriteHeader from "../PostHeader/Header";
import PostCommentEditor from "../PostCommentEditor";
import PostCommentList from "../PostCommentList";
import { useVisitor } from "@nice/client";
import { useAuth } from "@web/src/providers/auth-provider";
import { VisitType } from "@nice/common";
import Header from "../PostHeader/Header";
import Content from "../PostHeader/Content";
export default function PostDetailLayout() {
const { post, user } = useContext(PostDetailContext);
@ -15,7 +13,6 @@ export default function PostDetailLayout() {
useEffect(() => {
if (post) {
console.log("read");
read.mutateAsync({
data: {
visitorId: user?.id || null,
@ -25,11 +22,10 @@ export default function PostDetailLayout() {
});
}
}, [post]);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
<PostHeader></PostHeader>
<PostCommentEditor></PostCommentEditor>
<PostCommentList></PostCommentList>
</div>
);
return <>
<Header></Header>
<Content></Content>
<PostCommentEditor></PostCommentEditor>
<PostCommentList></PostCommentList>
</>
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { Input, Pagination, Empty } from 'antd';
import { Input, Pagination, Empty, Spin } from 'antd';
import { api, RouterInputs } from "@nice/client";
import { LetterCard } from "../LetterCard";
import { NonVoid } from "@nice/utils";
@ -21,15 +21,15 @@ export default function LetterList({ params }: { params: NonVoid<RouterInputs["p
}],
...params?.where
},
orderBy: {
updatedAt: "desc"
},
select: {
...postDetailSelect,
...params.select
}
});
useEffect(() => {
console.log(data)
}, [data])
// Debounced search function
const debouncedSearch = useMemo(
() =>
debounce((value: string) => {
@ -57,29 +57,27 @@ export default function LetterList({ params }: { params: NonVoid<RouterInputs["p
return (
<div className="flex flex-col h-full">
{/* Search Bar */}
<div className="p-6 mb-6 transition-all ">
<div className="max-w-2xl ">
<Input
placeholder="搜索信件标题..."
allowClear
size="large"
onChange={(e) => handleSearch(e.target.value)}
prefix={<SearchOutlined className="text-gray-400" />}
/>
</div>
<div className="p-6 transition-all ">
<Input
variant="filled"
className='w-full'
placeholder="搜索信件标题..."
allowClear
size="large"
onChange={(e) => handleSearch(e.target.value)}
prefix={<SearchOutlined className="text-gray-400" />}
/>
</div>
{/* Content Area */}
<div className="flex-grow overflow-auto px-4">
<div className="flex-grow px-6">
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-pulse">
{[...Array(6)].map((_, index) => (
<div key={index} className="h-48 bg-gray-100 rounded-lg"></div>
))}
<div className='flex justify-center items-center pt-6'>
<Spin size='large'></Spin>
</div>
) : data?.items.length ? (
<>
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-4 mb-6">
{data.items.map((letter: any) => (
<LetterCard key={letter.id} letter={letter} />
))}
@ -96,16 +94,14 @@ export default function LetterList({ params }: { params: NonVoid<RouterInputs["p
</div>
</>
) : (
<div className="flex flex-col justify-center items-center h-96">
<div className="flex flex-col justify-center items-center pt-6">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<span className="text-gray-600">
{searchText ? "未找到相关信件" : "暂无信件"}
</span>
searchText ? "未找到相关信件" : "暂无信件"
}
className="flex flex-col items-center"
/>
</div>
)}

View File

@ -13,6 +13,7 @@
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
border: none;
@apply text-base
}
.ag-custom-dragging-class {

View File

@ -198,5 +198,5 @@ export enum PostState {
export const PostStateLabels = {
[PostState.PENDING]: "待处理",
[PostState.PROCESSING]: "处理中",
[PostState.RESOLVED]: "已解答",
[PostState.RESOLVED]: "已完成",
};