This commit is contained in:
longdayi 2025-01-25 22:32:46 +08:00
parent 700b3eb2c7
commit 6336806073
9 changed files with 258 additions and 208 deletions

View File

@ -17,7 +17,7 @@ export class PostRouter {
constructor(
private readonly trpc: TrpcService,
private readonly postService: PostService,
) {}
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(PostCreateArgsSchema)
@ -105,8 +105,10 @@ export class PostRouter {
select: PostSelectSchema.optional(),
}),
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.postService.findManyWithPagination(input);
.query(async ({ input, ctx }) => {
const { staff, req } = ctx;
const ip = getClientIp(req);
return await this.postService.findManyWithPagination(input, staff, ip);
}),
});
}

View File

@ -14,6 +14,7 @@ import { BaseService } from '../base/base.service';
import { DepartmentService } from '../department/department.service';
import { setPostRelation, updatePostState } from './utils';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
import { DefaultArgs } from '@prisma/client/runtime/library';
@Injectable()
export class PostService extends BaseService<Prisma.PostDelegate> {
@ -96,7 +97,24 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
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) {
if (!staff) return;
const perms: ResPerm = {

View File

@ -61,9 +61,7 @@ export async function setPostRelation(params: {
readed,
readedCount,
liked,
// limitedComments,
commentsCount,
// trouble
});
// console.log('data', data);
return data; // 明确返回修改后的数据

View File

@ -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"
);
};

View File

@ -2,64 +2,21 @@ import { useState } from "react";
import { Input, Button, Card, Steps, Tag, Spin, message } from "antd";
import { SearchOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
import ProgressHeader from "./ProgressHeader";
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;
export default function LetterProgressPage() {
const [feedbackId, setFeedbackId] = useState("");
const [status, setStatus] = useState<FeedbackStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const validateInput = () => {
if (!feedbackId.trim()) {
setError("请输入有效的问题编号");
return false;
const [letterId, setLetterId] = useState<string | undefined>();
const { data } = api.post.findFirst.useQuery({
where: {
id: letterId
}
if (!/^USAF-\d{4}-\d{4}$/.test(feedbackId)) {
setError("问题编号格式不正确应为USAF-YYYY-NNNN");
return false;
}
setError("");
return true;
};
}, { enabled: Boolean(letterId) })
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 (
<div
@ -75,40 +32,25 @@ export default function LetterProgressPage() {
<SearchOutlined className=" text-secondary-300" />
}
size="large"
value={feedbackId}
onChange={(e) => setFeedbackId(e.target.value)}
value={letterId}
onChange={(e) => setLetterId(e.target.value)}
placeholder="请输入信件编码查询处理状态"
status={error ? "error" : ""}
className="border border-gray-300"
/>
<Button
type="primary"
size="large"
loading={loading}
onClick={mockLookup}
>
</Button>
</div>
{error && (
<p className="text-red-600 text-sm">{error}</p>
)}
</div>
{/* Results Section */}
{status && (
{data && (
<Card>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between border-b pb-4">
<h2 className="text-xl font-semibold text-[#003366]">
Ticket Details
</h2>
<Tag
color={getStatusColor(status.status)}
className="font-bold uppercase">
{status.status}
</Tag>
<LetterBadge type="state" value={data?.state}></LetterBadge>
</div>
{/* Details Grid */}
@ -118,31 +60,31 @@ export default function LetterProgressPage() {
Ticket ID
</p>
<p className="font-medium text-[#003366]">
{status.ticketId}
{data.id}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
Submitted Date
</p>
<p className="font-medium text-[#003366]">
{status.submittedDate}
{dayjs(data.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
Last Update
</p>
<p className="font-medium text-[#003366]">
{status.lastUpdate}
{dayjs(data.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<div>
<p className="text-sm text-gray-600">
Subject
</p>
<p className="font-medium text-[#003366]">
{status.title}
{data.title}
</p>
</div>
</div>
@ -151,9 +93,9 @@ export default function LetterProgressPage() {
<div className="mt-8">
<Steps
current={
status.status === "pending"
data.state === "pending"
? 0
: status.status === "processing"
: data.state === "processing"
? 1
: 2
}

View File

@ -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>
);
}

View File

@ -9,12 +9,10 @@ import {
SendOutlined,
} from "@ant-design/icons";
import { Button, Typography, Space, Tooltip } from "antd";
import toast from "react-hot-toast";
import { useState } from "react";
import { getBadgeStyle } from "@web/src/app/main/letter/list/utils";
import { PostDto } from "@nice/common";
import { PostDto, PostStateLabels } from "@nice/common";
import dayjs from "dayjs";
import PostLikeButton from "./detail/PostHeader/PostLikeButton";
import { LetterBadge } from "./LetterBadge";
const { Title, Paragraph, Text } = Typography;
interface LetterCardProps {
@ -22,78 +20,104 @@ interface LetterCardProps {
}
export function LetterCard({ letter }: LetterCardProps) {
return (
<div className="w-full p-4 bg-white transition-all duration-300 ease-in-out group">
<div className="flex flex-col gap-3">
{/* Title & Priority */}
<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 className="flex flex-col gap-4">
<div className="flex justify-between items-start">
<Title level={4} className="!mb-0 flex-1">
<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 transition-all duration-300 relative
before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-primary-600
group-hover:before:w-full before:transition-all before:duration-300
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md">
className="text-primary hover:text-primary-600 transition-colors duration-200 hover:underline">
{letter.title}
</a>
</Title>
</div>
{/* Meta Info */}
<div className="flex justify-between items-center text-sm text-secondary">
<Space size="middle">
<Space>
<UserOutlined className=" text-secondary-400"></UserOutlined>
<Text>
<div className="flex justify-between items-center text-sm text-gray-500">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<UserOutlined className="text-secondary-400 text-base" />
<Text className="text-gray-600 font-medium">
{letter.author?.showname || '匿名用户'}
</Text>
</Space>
<Space>
<BankOutlined className="text-secondary-400" />
<Text>{letter.receivers.map(item => item.department?.name).toString()}</Text>
</Space>
<Space>
<SendOutlined className=" text-secondary-400"></SendOutlined>
<Text >
{letter.receivers.map(item => item.showname).toString()}
</Text>
</Space>
</Space>
<Space>
<CalendarOutlined className="text-secondary-400" />
<Text type="secondary">
</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" />
<Tooltip title={letter.receivers.map(item => item.showname).filter(Boolean).join(', ')}>
<Text className="text-gray-600">
{letter.receivers
.map(item => item.showname)
.filter(Boolean)
.slice(0, 2)
.join('、')}
{letter.receivers.filter(item => item.showname).length > 2 && ' 等'}
</Text>
</Tooltip>
</div>
)}
</div>
<div className="flex items-center gap-2">
<CalendarOutlined className="text-secondary-400 text-base" />
<Text className="text-gray-500">
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
</Text>
</Space>
</div>
</div>
{/* Content Preview */}
{letter.content && (
<div className="flex items-start gap-2">
<FileTextOutlined className="text-gray-400 mt-1" />
<Paragraph
ellipsis={{ rows: 2 }}
className="!mb-3 text-gray-600 flex-1">
{letter.content}
</Paragraph>
</div>
<Paragraph
ellipsis={{ rows: 2 }}
className="!mb-4 text-gray-600 flex-1 leading-relaxed text-sm font-sans">
{letter.content}
</Paragraph>
)}
<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">
<Space size="small" wrap className="flex-1">
<Badge type="category" value={"11"} />
<Badge type="status" value={"22"} />
</Space>
<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>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 text-gray-500">
<EyeOutlined className="text-lg" />
<span className="text-sm">{letter.views}</span>
</div>
<Button
type="default"
shape="round"
icon={<EyeOutlined />}
>
{letter.views}
</Button>
<PostLikeButton post={letter as any}></PostLikeButton>
</div>
</div>
@ -101,27 +125,3 @@ export function LetterCard({ letter }: LetterCardProps) {
</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>
)
);
}

View File

@ -1,7 +1,5 @@
import { PostDto, VisitType } from "@nice/common";
import { useVisitor } from "@nice/client";
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { Button, Tooltip } from "antd";
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider";
@ -37,17 +35,14 @@ export default function PostLikeButton({ post }: { post: PostDto }) {
<Button
type={post?.liked ? "primary" : "default"}
shape="round"
size="small"
ghost={post?.liked}
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
onClick={likeThisPost}
className={`
flex items-center gap-1 px-3 transform transition-all duration-300
hover:scale-105 hover:shadow-md
${post?.liked ? "bg-blue-500 hover:bg-blue-600" : "hover:border-blue-500 hover:text-blue-500"}
`}>
<span className={post?.liked ? "text-white" : ""}>
{post?.likes}
</span>
onClick={(e) => {
e.stopPropagation()
likeThisPost()
}}
>
{post?.likes}
</Button>
</Tooltip>
);

View File

@ -135,7 +135,7 @@ export type PostDto = Post & {
liked: boolean;
readedCount: number;
commentsCount: number;
term: TermDto;
terms: TermDto[];
author: StaffDto | undefined;
receivers: StaffDto[];
resources: Resource[];