This commit is contained in:
longdayi 2025-01-26 16:08:54 +08:00
commit 003208a5f5
10 changed files with 286 additions and 226 deletions

View File

@ -169,12 +169,16 @@ export class AuthService {
password, password,
officerId, officerId,
showname, showname,
department: deptId && { department: deptId
connect: { id: deptId }, ? {
}, connect: { id: deptId },
domain: { }
connect: deptId && { id: deptId }, : undefined,
}, domain: deptId
? {
connect: { id: deptId },
}
: undefined,
// domainId: data.deptId, // domainId: data.deptId,
meta: { meta: {
photoUrl, photoUrl,

View File

@ -31,7 +31,6 @@ export async function setPostRelation(params: {
visitorId: staff?.id, visitorId: staff?.id,
}, },
})) > 0; })) > 0;
const liked = const liked =
(await db.visit.count({ (await db.visit.count({
where: { where: {
@ -50,6 +49,24 @@ export async function setPostRelation(params: {
}), }),
}, },
})) > 0; })) > 0;
const hated =
(await db.visit.count({
where: {
postId: data.id,
type: VisitType?.HATE,
...(staff?.id
? // 如果有 staff查找对应的 visitorId
{ visitorId: staff.id }
: // 如果没有 staff查找相同 IP 且 visitorId 为 null 且 30 分钟内的记录
{
visitorId: null,
meta: { path: ['ip'], equals: clientIp },
updatedAt: {
gte: thirtyMinutesAgo,
},
}),
},
})) > 0;
const readedCount = await db.visit.count({ const readedCount = await db.visit.count({
where: { where: {
postId: data.id, postId: data.id,
@ -61,6 +78,7 @@ export async function setPostRelation(params: {
readed, readed,
readedCount, readedCount,
liked, liked,
hated,
commentsCount, commentsCount,
}); });
// console.log('data', data); // console.log('data', data);

View File

@ -78,6 +78,13 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
visitType: VisitType.LIKE, visitType: VisitType.LIKE,
}); });
} }
if (args.data.type === VisitType.HATE) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: postId,
visitType: VisitType.HATE,
});
}
} }
return result; return result;
} }

View File

@ -37,19 +37,20 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
layout="vertical" layout="vertical"
onFinish={onSubmit} onFinish={onSubmit}
scrollToFirstError> scrollToFirstError>
<div className=" flex items-center gap-4 mb-2"> <div className="flex items-center gap-2 mb-2">
<div> <div className="flex-1">
<Form.Item name="photoUrl" label="头像" noStyle> <Form.Item name="photoUrl" label="头像" noStyle>
<AvatarUploader <AvatarUploader
className="rounded-lg" className="rounded-lg"
placeholder="点击上传头像" placeholder="点击上传头像"
style={{ style={{
height: 150, width: `100%`,
width: 120, height: 210,
}}></AvatarUploader> }}
/>
</Form.Item> </Form.Item>
</div> </div>
<div className="grid grid-cols-1 gap-2 flex-1"> <div className="flex-1 grid grid-cols-1 gap-3">
<Form.Item <Form.Item
name="username" name="username"
label="用户名" label="用户名"
@ -88,108 +89,108 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
noStyle noStyle
label="部门" label="部门"
rules={[{ required: true, message: "请选择部门" }]}> rules={[{ required: true, message: "请选择部门" }]}>
<DepartmentSelect <DepartmentSelect rootId={domainId} />
rootId={domainId}></DepartmentSelect> </Form.Item>
<Form.Item noStyle name={"rank"}>
<Input
placeholder="请输入职级(可选)"
autoComplete="off"
spellCheck={false}
allowClear
/>
</Form.Item> </Form.Item>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-2 flex-1 mb-2"> <div className="flex items-center gap-2">
<Form.Item noStyle name={"rank"}> <div className="flex-1 grid grid-cols-1 gap-2">
<Input <Form.Item
placeholder="请输入职级(可选)" noStyle
autoComplete="off" rules={[
spellCheck={false} {
allowClear required: false,
/> pattern: /^\d{6,11}$/,
</Form.Item> message: "请输入正确的手机号(数字)",
<Form.Item
name="officerId"
label="证件号"
noStyle
rules={[
{ required: true, message: "请输入证件号" },
{
pattern: /^\d{5,12}$/,
message: "请输入有效的证件号5-12位数字",
},
]}>
<Input placeholder="证件号(可选)" />
</Form.Item>
<Form.Item
noStyle
rules={[
{
required: false,
pattern: /^\d{6,11}$/,
message: "请输入正确的手机号(数字)",
},
]}
name={"phoneNumber"}
label="手机号">
<Input
autoComplete="new-phone" // 使用非标准的自动完成值
spellCheck={false}
allowClear
placeholder="请输入手机号(可选)"
/>
</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
name="password"
label="密码"
noStyle
rules={[
{ required: true, message: "请输入密码" },
{ min: 8, message: "密码至少需要8个字符" },
{
pattern:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
message:
"密码必须包含大小写字母、数字和特殊字符",
},
]}>
<Input.Password placeholder="密码" />
</Form.Item>
<Form.Item
name="repeatPass"
label="确认密码"
noStyle
dependencies={["password"]}
rules={[
{ required: true, message: "请确认密码" },
({ getFieldValue }) => ({
validator(_, value) {
if (
!value ||
getFieldValue("password") === value
) {
return Promise.resolve();
}
return Promise.reject(
new Error("两次输入的密码不一致")
);
}, },
}), ]}
]}> name={"phoneNumber"}
<Input.Password placeholder="确认密码" /> label="手机号">
</Form.Item> <Input
autoComplete="new-phone" // 使用非标准的自动完成值
spellCheck={false}
allowClear
placeholder="请输入手机号(可选)"
/>
</Form.Item>
<Form.Item noStyle name={"office"}>
<Input
placeholder="请输入办公室地点"
autoComplete="off"
spellCheck={false}
allowClear
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: "请输入密码" },
{ min: 8, message: "密码至少需要8个字符" },
{
pattern:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
message:
"密码必须包含大小写字母、数字和特殊字符",
},
]}>
<Input.Password placeholder="密码" />
</Form.Item>
</div>
<div className="flex-1 grid grid-cols-1 gap-2">
<Form.Item
name="officerId"
label="证件号"
noStyle
rules={[
{ required: true, message: "请输入证件号" },
{
pattern: /^\d{5,12}$/,
message: "请输入有效的证件号5-12位数字",
},
]}>
<Input placeholder="证件号(可选)" />
</Form.Item>
<Form.Item noStyle name={"email"}>
<Input
placeholder="请输入邮箱(可选)"
autoComplete="off"
spellCheck={false}
allowClear
/>
</Form.Item>
<Form.Item
name="repeatPass"
dependencies={["password"]}
rules={[
{ required: true, message: "请确认密码" },
({ getFieldValue }) => ({
validator(_, value) {
if (
!value ||
getFieldValue("password") === value
) {
return Promise.resolve();
}
return Promise.reject(
new Error("两次输入的密码不一致")
);
},
}),
]}>
<Input.Password placeholder="确认密码" />
</Form.Item>
</div>
</div> </div>
<div className="grid grid-cols-1 flex-1 my-2"></div>
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"

View File

@ -1,13 +1,13 @@
import { import {
EyeOutlined, EyeOutlined,
LikeOutlined, LikeOutlined,
LikeFilled, LikeFilled,
UserOutlined, UserOutlined,
BankOutlined, BankOutlined,
CalendarOutlined, CalendarOutlined,
FileTextOutlined, FileTextOutlined,
SendOutlined, SendOutlined,
MailOutlined, MailOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Button, Typography, Space, Tooltip } from "antd"; import { Button, Typography, Space, Tooltip } from "antd";
import { PostDto, PostStateLabels } from "@nice/common"; import { PostDto, PostStateLabels } from "@nice/common";
@ -17,100 +17,109 @@ 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) {
return ( return (
<div <div
onClick={() => { onClick={() => {
window.open(`/${letter.id}/detail`) window.open(`/${letter.id}/detail`);
}} }}
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-xl text-primary font-bold"> {letter.title}
{letter.title} </div>
</div> {/* Meta Info */}
{/* Meta Info */} <div className="flex justify-between items-center text-sm text-gray-600 gap-4 flex-wrap">
<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]">
<div className="flex items-center gap-4 flex-1 min-w-[300px]"> {letter.author?.department?.name && (
{letter.author?.department?.name && ( <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <BankOutlined className="text-secondary-400 text-base" />
<BankOutlined className="text-secondary-400 text-base" /> <Text className="text-gray-600 font-medium">
<Text className="text-gray-600 font-medium"> {letter.author?.department?.name}
{letter.author?.department?.name} </Text>
</Text> </div>
</div> )}
)} <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <UserOutlined className="text-primary text-base" />
<UserOutlined className="text-primary text-base" /> <Text className="text-primary font-medium">
<Text className="text-primary font-medium"> {letter.author?.showname || "匿名用户"}
{letter.author?.showname || '匿名用户'} </Text>
</Text> </div>
</div>
{letter.receivers.some(item => item.showname) && ( {letter.receivers.some((item) => item.showname) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MailOutlined className="text-primary-400 text-base" /> <MailOutlined className="text-primary-400 text-base" />
<Tooltip title={letter.receivers.map(item => item.showname).filter(Boolean).join(', ')}> <Tooltip
<Text className="text-primary-400"> title={letter?.receivers
{letter.receivers ?.map((item) => item.showname)
.map(item => item.showname) .filter(Boolean)
.filter(Boolean) .join(", ")}>
.slice(0, 2) <Text className="text-primary-400">
.join('、')} {letter.receivers
{letter.receivers.filter(item => item.showname).length > 2 && ' 等'} .map((item) => item.showname)
</Text> .filter(Boolean)
</Tooltip> .slice(0, 2)
</div> .join("、")}
)} {letter.receivers.filter(
</div> (item) => item.showname
<div className="flex items-center gap-2"> ).length > 2 && " 等"}
<CalendarOutlined className="text-secondary-400 text-base" /> </Text>
<Text className="text-gray-500"> </Tooltip>
{dayjs(letter.createdAt).format("YYYY-MM-DD")} </div>
</Text> )}
</div> </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>
</div>
</div>
{/* Content Preview */} {/* Content Preview */}
{letter.content && ( {letter.content && (
<div className="flex-1 leading-relaxed text-sm"> <div className="flex-1 leading-relaxed text-sm">
<div <div
dangerouslySetInnerHTML={{ __html: letter.content }} dangerouslySetInnerHTML={{ __html: letter.content }}
className="line-clamp-2" className="line-clamp-2"
/> />
</div> </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 => ( {letter?.meta?.tags?.map((tag) => (
<LetterBadge key={tag} type="tag" value={tag} /> <LetterBadge key={tag} type="tag" value={tag} />
))} ))}
{letter.terms.map(term => ( {letter.terms.map((term) => (
<LetterBadge key={term.name} type="category" value={term.name} /> <LetterBadge
))} key={term.name}
</div> type="category"
value={term.name}
/>
))}
</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
type="default" type="default"
shape="round" shape="round"
icon={<EyeOutlined />} icon={<EyeOutlined />}>
> <span className="mr-1"></span>
<span className="mr-1"></span>{letter.views} {letter.views}
</Button> </Button>
<PostLikeButton post={letter as any}></PostLikeButton> <PostLikeButton post={letter as any}></PostLikeButton>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@ -137,7 +137,7 @@ export default function PostCommentList() {
} }
if (!items.length) { if (!items.length) {
return null return null;
} }
return ( return (
@ -177,11 +177,9 @@ export default function PostCommentList() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="flex flex-col items-center py-4 space-y-2"> className="flex flex-col items-center py-4 space-y-2">
<span className="text-sm text-gray-500 font-medium"> <span className="text-sm text-gray-500 font-medium">
</span> </span>
</motion.div> </motion.div>
) )
)} )}

View File

@ -8,7 +8,10 @@ import { formatFileSize } from '@nice/utils';
export default function PostResources({ post }: { post: PostDto }) { export default function PostResources({ post }: { post: PostDto }) {
const { resources } = useMemo(() => { const { resources } = useMemo(() => {
if (!post?.resources) return { resources: [] }; if (!post?.resources) return { resources: [] };
const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
const isImage = (url: string) =>
/\.(png|jpg|jpeg|gif|webp)$/i.test(url);
const sortedResources = post.resources const sortedResources = post.resources
.map((resource) => ({ .map((resource) => ({
...resource, ...resource,
@ -36,8 +39,7 @@ export default function PostResources({ post }: { post: PostDto }) {
md={6} md={6}
lg={6} lg={6}
xl={4} xl={4}
className="relative" className="relative">
>
<div className="relative aspect-square rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow duration-300 bg-gray-100"> <div className="relative aspect-square rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow duration-300 bg-gray-100">
<div className="w-full h-full"> <div className="w-full h-full">
<Image <Image
@ -78,17 +80,22 @@ export default function PostResources({ post }: { post: PostDto }) {
{fileResources.map((resource) => ( {fileResources.map((resource) => (
<div <div
key={resource.url} key={resource.url}
className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-md transition-colors duration-200" className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-md transition-colors duration-200">
>
<div className="flex items-center space-x-3 min-w-0"> <div className="flex items-center space-x-3 min-w-0">
<span className="text-xl">{getFileIcon(resource.url)}</span> <span className="text-xl">
{getFileIcon(resource.url)}
</span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-gray-800 truncate"> <p className="text-gray-800 truncate">
{resource.title || "未命名文件"} {resource.title || "未命名文件"}
</p> </p>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{resource.url.split(".").pop()?.toUpperCase()} {resource.url
.split(".")
.pop()
?.toUpperCase()}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{resource.meta.size && {resource.meta.size &&
@ -100,8 +107,7 @@ export default function PostResources({ post }: { post: PostDto }) {
<Button <Button
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
href={resource.url} href={resource.url}
download download>
>
</Button> </Button>
</div> </div>
@ -111,4 +117,4 @@ export default function PostResources({ post }: { post: PostDto }) {
)} )}
</div> </div>
); );
} }

View File

@ -118,6 +118,21 @@ export function useVisitor() {
})) }))
); );
const hate = api.visitor.create.useMutation(
createOptimisticMutation((item) => ({
...item,
hates: (item.hates || 0) + 1,
hated: true,
}))
);
const unHate = api.visitor.deleteMany.useMutation(
createOptimisticMutation((item) => ({
...item,
hates: item.hates - 1 || 0,
hated: false,
}))
);
const addStar = api.visitor.create.useMutation( const addStar = api.visitor.create.useMutation(
createOptimisticMutation((item) => ({ createOptimisticMutation((item) => ({
...item, ...item,

View File

@ -70,11 +70,11 @@ model TermAncestry {
} }
model Staff { model Staff {
id String @id @default(cuid()) id String @id @default(cuid())
showname String? @map("showname") showname String? @map("showname")
username String @unique @map("username") username String @unique @map("username")
avatar String? @map("avatar") avatar String? @map("avatar")
password String? @map("password") password String? @map("password")
phoneNumber String? @unique @map("phone_number") phoneNumber String? @unique @map("phone_number")
@ -203,8 +203,9 @@ model Post {
authorId String? @map("author_id") authorId String? @map("author_id")
author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型 author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
visits Visit[] // 访问记录,关联 Visit 模型 visits Visit[] // 访问记录,关联 Visit 模型
views Int @default(0) views Int @default(0)
likes Int @default(0) likes Int @default(0)
hates 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 模型

View File

@ -14,6 +14,7 @@ export enum VisitType {
STAR = "star", STAR = "star",
READED = "read", READED = "read",
LIKE = "like", LIKE = "like",
HATE = "hate",
} }
export enum StorageProvider { export enum StorageProvider {