Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
7e1942fa07
|
@ -17,7 +17,7 @@ export class PostRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly postService: PostService,
|
private readonly postService: PostService,
|
||||||
) {}
|
) { }
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.protectProcedure
|
create: this.trpc.protectProcedure
|
||||||
.input(PostCreateArgsSchema)
|
.input(PostCreateArgsSchema)
|
||||||
|
@ -105,8 +105,10 @@ export class PostRouter {
|
||||||
select: PostSelectSchema.optional(),
|
select: PostSelectSchema.optional(),
|
||||||
}),
|
}),
|
||||||
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return await this.postService.findManyWithPagination(input);
|
const { staff, req } = ctx;
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
return await this.postService.findManyWithPagination(input, staff, ip);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { BaseService } from '../base/base.service';
|
||||||
import { DepartmentService } from '../department/department.service';
|
import { DepartmentService } from '../department/department.service';
|
||||||
import { setPostRelation, updatePostState } from './utils';
|
import { setPostRelation, updatePostState } from './utils';
|
||||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||||
|
import { DefaultArgs } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PostService extends BaseService<Prisma.PostDelegate> {
|
export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
|
@ -96,7 +97,24 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
return { ...result, items };
|
return { ...result, items };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async findManyWithPagination(
|
||||||
|
args: { page?: number; pageSize?: number; where?: Prisma.PostWhereInput; select?: Prisma.PostSelect<DefaultArgs>; },
|
||||||
|
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) => {
|
||||||
|
const { items } = result;
|
||||||
|
await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
await setPostRelation({ data: item, staff, clientIp });
|
||||||
|
await this.setPerms(item, staff);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { ...result, items };
|
||||||
|
});
|
||||||
|
}
|
||||||
protected async setPerms(data: Post, staff?: UserProfile) {
|
protected async setPerms(data: Post, staff?: UserProfile) {
|
||||||
if (!staff) return;
|
if (!staff) return;
|
||||||
const perms: ResPerm = {
|
const perms: ResPerm = {
|
||||||
|
|
|
@ -61,9 +61,7 @@ export async function setPostRelation(params: {
|
||||||
readed,
|
readed,
|
||||||
readedCount,
|
readedCount,
|
||||||
liked,
|
liked,
|
||||||
// limitedComments,
|
|
||||||
commentsCount,
|
commentsCount,
|
||||||
// trouble
|
|
||||||
});
|
});
|
||||||
// console.log('data', data);
|
// console.log('data', data);
|
||||||
return data; // 明确返回修改后的数据
|
return data; // 明确返回修改后的数据
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import { PostState } from "@nice/common";
|
|
||||||
|
|
||||||
export const BADGE_STYLES = {
|
|
||||||
priority: {
|
|
||||||
high: "bg-red-100 text-red-800",
|
|
||||||
medium: "bg-yellow-100 text-yellow-800",
|
|
||||||
low: "bg-green-100 text-green-800",
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
complaint: "bg-orange-100 text-orange-800",
|
|
||||||
suggestion: "bg-blue-100 text-blue-800",
|
|
||||||
request: "bg-purple-100 text-purple-800",
|
|
||||||
feedback: "bg-teal-100 text-teal-800",
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
[PostState.PENDING]: "bg-yellow-100 text-yellow-800",
|
|
||||||
[PostState.PROCESSING]: "bg-blue-100 text-blue-800",
|
|
||||||
[PostState.RESOLVED]: "bg-green-100 text-green-800",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const getBadgeStyle = (
|
|
||||||
type: keyof typeof BADGE_STYLES,
|
|
||||||
value: string
|
|
||||||
): string => {
|
|
||||||
return (
|
|
||||||
BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] ||
|
|
||||||
"bg-gray-100 text-gray-800"
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -2,64 +2,21 @@ import { useState } from "react";
|
||||||
import { Input, Button, Card, Steps, Tag, Spin, message } from "antd";
|
import { Input, Button, Card, Steps, Tag, Spin, message } from "antd";
|
||||||
import { SearchOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
|
import { SearchOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
|
||||||
import ProgressHeader from "./ProgressHeader";
|
import ProgressHeader from "./ProgressHeader";
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import { LetterBadge } from "@web/src/components/models/post/LetterBadge";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
interface FeedbackStatus {
|
|
||||||
status: "pending" | "processing" | "resolved";
|
|
||||||
ticketId: string;
|
|
||||||
submittedDate: string;
|
|
||||||
lastUpdate: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Step } = Steps;
|
const { Step } = Steps;
|
||||||
|
|
||||||
export default function LetterProgressPage() {
|
export default function LetterProgressPage() {
|
||||||
const [feedbackId, setFeedbackId] = useState("");
|
const [letterId, setLetterId] = useState<string | undefined>();
|
||||||
const [status, setStatus] = useState<FeedbackStatus | null>(null);
|
const { data } = api.post.findFirst.useQuery({
|
||||||
const [loading, setLoading] = useState(false);
|
where: {
|
||||||
const [error, setError] = useState("");
|
id: letterId
|
||||||
|
|
||||||
const validateInput = () => {
|
|
||||||
if (!feedbackId.trim()) {
|
|
||||||
setError("请输入有效的问题编号");
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
if (!/^USAF-\d{4}-\d{4}$/.test(feedbackId)) {
|
}, { enabled: Boolean(letterId) })
|
||||||
setError("问题编号格式不正确,应为USAF-YYYY-NNNN");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockLookup = () => {
|
|
||||||
if (!validateInput()) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setStatus({
|
|
||||||
status: "processing",
|
|
||||||
ticketId: feedbackId,
|
|
||||||
submittedDate: "2025-01-15",
|
|
||||||
lastUpdate: "2025-01-21",
|
|
||||||
title: "Aircraft Maintenance Schedule Inquiry",
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return "orange";
|
|
||||||
case "processing":
|
|
||||||
return "blue";
|
|
||||||
case "resolved":
|
|
||||||
return "green";
|
|
||||||
default:
|
|
||||||
return "gray";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -75,40 +32,25 @@ export default function LetterProgressPage() {
|
||||||
<SearchOutlined className=" text-secondary-300" />
|
<SearchOutlined className=" text-secondary-300" />
|
||||||
}
|
}
|
||||||
size="large"
|
size="large"
|
||||||
value={feedbackId}
|
value={letterId}
|
||||||
onChange={(e) => setFeedbackId(e.target.value)}
|
onChange={(e) => setLetterId(e.target.value)}
|
||||||
placeholder="请输入信件编码查询处理状态"
|
placeholder="请输入信件编码查询处理状态"
|
||||||
status={error ? "error" : ""}
|
|
||||||
className="border border-gray-300"
|
className="border border-gray-300"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
loading={loading}
|
|
||||||
onClick={mockLookup}
|
|
||||||
>
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
|
||||||
<p className="text-red-600 text-sm">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section */}
|
||||||
{status && (
|
{data && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
<h2 className="text-xl font-semibold text-[#003366]">
|
<h2 className="text-xl font-semibold text-[#003366]">
|
||||||
Ticket Details
|
处理进度
|
||||||
</h2>
|
</h2>
|
||||||
<Tag
|
<LetterBadge type="state" value={data?.state}></LetterBadge>
|
||||||
color={getStatusColor(status.status)}
|
|
||||||
className="font-bold uppercase">
|
|
||||||
{status.status}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details Grid */}
|
{/* Details Grid */}
|
||||||
|
@ -118,31 +60,31 @@ export default function LetterProgressPage() {
|
||||||
Ticket ID
|
Ticket ID
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium text-[#003366]">
|
<p className="font-medium text-[#003366]">
|
||||||
{status.ticketId}
|
{data.id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Submitted Date
|
提交日期
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium text-[#003366]">
|
<p className="font-medium text-[#003366]">
|
||||||
{status.submittedDate}
|
{dayjs(data.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Last Update
|
最后更新
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium text-[#003366]">
|
<p className="font-medium text-[#003366]">
|
||||||
{status.lastUpdate}
|
{dayjs(data.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Subject
|
标题
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium text-[#003366]">
|
<p className="font-medium text-[#003366]">
|
||||||
{status.title}
|
{data.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -151,9 +93,9 @@ export default function LetterProgressPage() {
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Steps
|
<Steps
|
||||||
current={
|
current={
|
||||||
status.status === "pending"
|
data.state === "pending"
|
||||||
? 0
|
? 0
|
||||||
: status.status === "processing"
|
: data.state === "processing"
|
||||||
? 1
|
? 1
|
||||||
: 2
|
: 2
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { PostState, PostStateLabels } from '@nice/common';
|
||||||
|
import {
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
CommentOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
TagOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
[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",
|
||||||
|
},
|
||||||
|
[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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const getBadgeStyle = (
|
||||||
|
type: keyof typeof BADGE_STYLES,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
const style = type === 'tag'
|
||||||
|
? BADGE_STYLES.tag.default
|
||||||
|
: BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || {
|
||||||
|
bg: "bg-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",
|
||||||
|
};
|
||||||
|
|
||||||
|
return style;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LetterBadge({
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
type: "category" | "state" | "tag";
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const style = getBadgeStyle(type, value);
|
||||||
|
|
||||||
|
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
|
||||||
|
${className}
|
||||||
|
`}>
|
||||||
|
{style.icon}
|
||||||
|
<span className="tracking-wide">
|
||||||
|
{type === 'state' ? PostStateLabels[value] : value}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,158 +1,127 @@
|
||||||
import {
|
import {
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
LikeOutlined,
|
LikeOutlined,
|
||||||
LikeFilled,
|
LikeFilled,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
BankOutlined,
|
BankOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
|
SendOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { Button, Typography, Space, Tooltip } from "antd";
|
import { Button, Typography, Space, Tooltip } from "antd";
|
||||||
import toast from "react-hot-toast";
|
import { PostDto, PostStateLabels } from "@nice/common";
|
||||||
import { useState } from "react";
|
|
||||||
import { getBadgeStyle } from "@web/src/app/main/letter/list/utils";
|
|
||||||
import { PostDto } from "@nice/common";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import PostLikeButton from "./detail/PostHeader/PostLikeButton";
|
||||||
|
import { LetterBadge } from "./LetterBadge";
|
||||||
const { Title, Paragraph, Text } = Typography;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface LetterCardProps {
|
interface LetterCardProps {
|
||||||
letter: PostDto;
|
letter: PostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LetterCard({ letter }: LetterCardProps) {
|
export function LetterCard({ letter }: LetterCardProps) {
|
||||||
const [likes, setLikes] = useState(0);
|
return (
|
||||||
const [liked, setLiked] = useState(false);
|
<div onClick={() => {
|
||||||
const [views] = useState(Math.floor(Math.random() * 100)); // 模拟浏览量数据
|
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 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"
|
||||||
|
|
||||||
const handleLike = () => {
|
className="text-primary hover:text-primary-600 transition-colors duration-200 hover:underline">
|
||||||
if (!liked) {
|
{letter.title}
|
||||||
setLikes((prev) => prev + 1);
|
</a>
|
||||||
setLiked(true);
|
</Title>
|
||||||
toast.success("已点赞!", {
|
</div>
|
||||||
icon: <LikeFilled className="text-blue-500" />,
|
{/* Meta Info */}
|
||||||
className: "custom-message",
|
<div className="flex justify-between items-center text-sm text-gray-500">
|
||||||
});
|
<div className="flex items-center gap-6">
|
||||||
} else {
|
<div className="flex items-center gap-2">
|
||||||
setLikes((prev) => prev - 1);
|
<UserOutlined className="text-secondary-400 text-base" />
|
||||||
setLiked(false);
|
<Text className="text-gray-600 font-medium">
|
||||||
toast("已取消点赞", {
|
{letter.author?.showname || '匿名用户'}
|
||||||
className: "custom-message",
|
</Text>
|
||||||
});
|
</div>
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
{letter.receivers.some(item => item.department?.name) && (
|
||||||
<div className="w-full p-4 bg-white transition-all duration-300 ease-in-out group">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-col gap-3">
|
<BankOutlined className="text-secondary-400 text-base" />
|
||||||
{/* Title & Priority */}
|
<Tooltip title={letter.receivers.map(item => item.department?.name).filter(Boolean).join(', ')}>
|
||||||
<div className="flex justify-between items-start">
|
<Text className="text-gray-600">
|
||||||
<Title level={4} className="!mb-0 flex-1">
|
{letter.receivers
|
||||||
<a
|
.map(item => item.department?.name)
|
||||||
href={`/${letter.id}/detail`}
|
.filter(Boolean)
|
||||||
target="_blank"
|
.slice(0, 2)
|
||||||
className="text-primary transition-all duration-300 relative
|
.join('、')}
|
||||||
before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-primary-600
|
{letter.receivers.filter(item => item.department?.name).length > 2 && ' 等'}
|
||||||
group-hover:before:w-full before:transition-all before:duration-300
|
</Text>
|
||||||
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md">
|
</Tooltip>
|
||||||
{letter.title}
|
</div>
|
||||||
</a>
|
)}
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta Info */}
|
{letter.receivers.some(item => item.showname) && (
|
||||||
<div className="flex justify-between items-center text-sm text-secondary">
|
<div className="flex items-center gap-2">
|
||||||
<Space size="middle">
|
<SendOutlined className="text-secondary-400 text-base" />
|
||||||
<Space>
|
<Tooltip title={letter.receivers.map(item => item.showname).filter(Boolean).join(', ')}>
|
||||||
<UserOutlined className="text-secondary-400" />
|
<Text className="text-gray-600">
|
||||||
<Text strong>
|
{letter.receivers
|
||||||
{letter.author?.showname ||
|
.map(item => item.showname)
|
||||||
letter?.author?.username}
|
.filter(Boolean)
|
||||||
</Text>
|
.slice(0, 2)
|
||||||
</Space>
|
.join('、')}
|
||||||
<Text type="secondary">|</Text>
|
{letter.receivers.filter(item => item.showname).length > 2 && ' 等'}
|
||||||
<Space>
|
</Text>
|
||||||
<BankOutlined className="text-secondary-400" />
|
</Tooltip>
|
||||||
<Text>{letter.author?.department?.name}</Text>
|
</div>
|
||||||
</Space>
|
)}
|
||||||
</Space>
|
</div>
|
||||||
<Space>
|
<div className="flex items-center gap-2">
|
||||||
<CalendarOutlined className="text-secondary-400" />
|
<CalendarOutlined className="text-secondary-400 text-base" />
|
||||||
<Text type="secondary">
|
<Text className="text-gray-500">
|
||||||
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
|
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Preview */}
|
{/* Content Preview */}
|
||||||
{letter.content && (
|
{letter.content && (
|
||||||
<div className="flex items-start gap-2">
|
<Paragraph
|
||||||
<FileTextOutlined className="text-gray-400 mt-1" />
|
ellipsis={{ rows: 2 }}
|
||||||
<Paragraph
|
className="!mb-4 text-gray-600 flex-1 leading-relaxed text-sm font-sans">
|
||||||
ellipsis={{ rows: 2 }}
|
{letter.content}
|
||||||
className="!mb-3 text-gray-600 flex-1">
|
</Paragraph>
|
||||||
{letter.content}
|
)}
|
||||||
</Paragraph>
|
<div className="flex flex-wrap gap-2">
|
||||||
</div>
|
<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">
|
||||||
|
{letter.terms.map(term => (
|
||||||
|
<LetterBadge key={term.name} type="category" value={term.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Badges & Interactions */}
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex justify-between items-center">
|
<Button
|
||||||
<Space size="small" wrap className="flex-1">
|
type="default"
|
||||||
<Badge type="category" value={"11"} />
|
shape="round"
|
||||||
<Badge type="status" value={"22"} />
|
icon={<EyeOutlined />}
|
||||||
</Space>
|
>
|
||||||
|
{letter.views}
|
||||||
<div className="flex items-center gap-4">
|
</Button>
|
||||||
<div className="flex items-center gap-1 text-gray-500">
|
<PostLikeButton post={letter as any}></PostLikeButton>
|
||||||
<EyeOutlined className="text-lg" />
|
</div>
|
||||||
<span className="text-sm">{views}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip
|
</div>
|
||||||
title={liked ? "取消点赞" : "点赞"}
|
);
|
||||||
placement="top">
|
|
||||||
<Button
|
|
||||||
type={liked ? "primary" : "default"}
|
|
||||||
shape="round"
|
|
||||||
size="small"
|
|
||||||
icon={liked ? <LikeFilled /> : <LikeOutlined />}
|
|
||||||
onClick={handleLike}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-1 px-3 transform transition-all duration-300
|
|
||||||
hover:scale-105 hover:shadow-md
|
|
||||||
${liked ? "bg-blue-500 hover:bg-blue-600" : "hover:border-blue-500 hover:text-blue-500"}
|
|
||||||
`}>
|
|
||||||
<span className={liked ? "text-white" : ""}>
|
|
||||||
{likes}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Badge({
|
|
||||||
type,
|
|
||||||
value,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
type: "priority" | "category" | "status";
|
|
||||||
value: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
value && (
|
|
||||||
<span
|
|
||||||
className={`
|
|
||||||
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
|
||||||
${getBadgeStyle(type, value)}
|
|
||||||
transition-all duration-200 ease-in-out transform hover:scale-105
|
|
||||||
${className}
|
|
||||||
`}>
|
|
||||||
{value?.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
import { PostDto, VisitType } from "@nice/common";
|
import { PostDto, VisitType } from "@nice/common";
|
||||||
import { useVisitor } from "@nice/client";
|
import { useVisitor } from "@nice/client";
|
||||||
import { useContext } from "react";
|
|
||||||
import { PostDetailContext } from "../context/PostDetailContext";
|
|
||||||
import { Button, Tooltip } from "antd";
|
import { Button, Tooltip } from "antd";
|
||||||
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
|
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
|
||||||
export default function PostLikeButton({ post }: { post: PostDto }) {
|
export default function PostLikeButton({ post }: { post: PostDto }) {
|
||||||
const { user } = useContext(PostDetailContext);
|
const { user } = useAuth();
|
||||||
const { like, unLike } = useVisitor();
|
const { like, unLike } = useVisitor();
|
||||||
|
|
||||||
function likeThisPost() {
|
function likeThisPost() {
|
||||||
if (!post?.liked) {
|
if (!post?.liked) {
|
||||||
post.likes += 1;
|
post.likes += 1;
|
||||||
|
@ -37,17 +35,14 @@ export default function PostLikeButton({ post }: { post: PostDto }) {
|
||||||
<Button
|
<Button
|
||||||
type={post?.liked ? "primary" : "default"}
|
type={post?.liked ? "primary" : "default"}
|
||||||
shape="round"
|
shape="round"
|
||||||
size="small"
|
ghost={post?.liked}
|
||||||
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
|
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
|
||||||
onClick={likeThisPost}
|
onClick={(e) => {
|
||||||
className={`
|
e.stopPropagation()
|
||||||
flex items-center gap-1 px-3 transform transition-all duration-300
|
likeThisPost()
|
||||||
hover:scale-105 hover:shadow-md
|
}}
|
||||||
${post?.liked ? "bg-blue-500 hover:bg-blue-600" : "hover:border-blue-500 hover:text-blue-500"}
|
>
|
||||||
`}>
|
{post?.likes}
|
||||||
<span className={post?.liked ? "text-white" : ""}>
|
|
||||||
{post?.likes}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { api, usePost } from "@nice/client";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PostState, PostType } from "@nice/common";
|
import { PostState, PostType } from "@nice/common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export interface LetterFormData {
|
export interface LetterFormData {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -69,18 +70,34 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// console.log(123);
|
const formattedDateTime = dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
// 创建包含信件编号和提交时间的文本
|
||||||
|
const fileContent = `信件编号: ${result.id}\n投递时间: ${formattedDateTime}`;
|
||||||
|
// 创建包含信件编号和提交时间的Blob对象
|
||||||
|
const blob = new Blob([fileContent], { type: 'text/plain' });
|
||||||
|
// 创建下载链接
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = `信件编号-${result.id}.txt`; // 设置下载文件名
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
|
toast.success(`信件投递成功!信件编号已保存到本地,请妥善保管用于进度查询`, {
|
||||||
|
duration: 5000 // 10秒
|
||||||
|
});
|
||||||
navigate(`/${result.id}/detail`, { replace: true });
|
navigate(`/${result.id}/detail`, { replace: true });
|
||||||
toast.success("发送成功!");
|
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting form:", error);
|
console.error("Error submitting form:", error);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { LetterCard } from "../LetterCard";
|
||||||
import { NonVoid } from "@nice/utils";
|
import { NonVoid } from "@nice/utils";
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
|
import { postDetailSelect } from '@nice/common';
|
||||||
export default function LetterList({ params }: { params: NonVoid<RouterInputs["post"]["findManyWithPagination"]> }) {
|
export default function LetterList({ params }: { params: NonVoid<RouterInputs["post"]["findManyWithPagination"]> }) {
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
@ -20,9 +21,14 @@ export default function LetterList({ params }: { params: NonVoid<RouterInputs["p
|
||||||
}],
|
}],
|
||||||
...params?.where
|
...params?.where
|
||||||
},
|
},
|
||||||
select: params.select
|
select: {
|
||||||
|
...postDetailSelect,
|
||||||
|
...params.select
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(data)
|
||||||
|
}, [data])
|
||||||
// Debounced search function
|
// Debounced search function
|
||||||
const debouncedSearch = useMemo(
|
const debouncedSearch = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -73,7 +79,7 @@ export default function LetterList({ params }: { params: NonVoid<RouterInputs["p
|
||||||
</div>
|
</div>
|
||||||
) : data?.items.length ? (
|
) : data?.items.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
{data.items.map((letter: any) => (
|
{data.items.map((letter: any) => (
|
||||||
<LetterCard key={letter.id} letter={letter} />
|
<LetterCard key={letter.id} letter={letter} />
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -192,11 +192,7 @@ model Post {
|
||||||
state String? // 状态 : 未读、处理中、已回答
|
state String? // 状态 : 未读、处理中、已回答
|
||||||
title String? // 帖子标题,可为空
|
title String? // 帖子标题,可为空
|
||||||
content String? // 帖子内容,可为空
|
content String? // 帖子内容,可为空
|
||||||
|
|
||||||
domainId String? @map("domain_id")
|
domainId String? @map("domain_id")
|
||||||
// term Term? @relation(fields: [termId], references: [id])
|
|
||||||
// termId String? @map("term_id")
|
|
||||||
// 添加多对多关系
|
|
||||||
terms Term[] @relation("post_term")
|
terms Term[] @relation("post_term")
|
||||||
// 日期时间类型字段
|
// 日期时间类型字段
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
@ -208,7 +204,6 @@ model Post {
|
||||||
visits Visit[] // 访问记录,关联 Visit 模型
|
visits Visit[] // 访问记录,关联 Visit 模型
|
||||||
views Int @default(0)
|
views Int @default(0)
|
||||||
likes Int @default(0)
|
likes Int @default(0)
|
||||||
|
|
||||||
receivers Staff[] @relation("post_receiver")
|
receivers Staff[] @relation("post_receiver")
|
||||||
parentId String? @map("parent_id")
|
parentId String? @map("parent_id")
|
||||||
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
|
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
|
||||||
|
|
|
@ -12,11 +12,9 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
resources: true,
|
resources: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
|
||||||
terms: {
|
terms: {
|
||||||
include: {
|
select: { id: true, name: true },
|
||||||
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
authorId: true,
|
authorId: true,
|
||||||
author: {
|
author: {
|
||||||
|
|
|
@ -135,7 +135,7 @@ export type PostDto = Post & {
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
readedCount: number;
|
readedCount: number;
|
||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
term: TermDto;
|
terms: TermDto[];
|
||||||
author: StaffDto | undefined;
|
author: StaffDto | undefined;
|
||||||
receivers: StaffDto[];
|
receivers: StaffDto[];
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
|
|
Loading…
Reference in New Issue