From 77238913583a05fec83e95f3b0ab82332ad5d2fa Mon Sep 17 00:00:00 2001 From: Li1304553726 <1304553726@qq.com> Date: Thu, 1 May 2025 19:11:49 +0800 Subject: [PATCH] add --- .../src/models/resource/resource.router.ts | 34 +- .../src/models/resource/resource.service.ts | 77 +++- apps/web/src/app/admin/base-setting/page.tsx | 1 + apps/web/src/app/auth/page.tsx | 6 +- apps/web/src/app/main/help/CourseContent.tsx | 8 + apps/web/src/app/main/help/MoreContent.tsx | 8 + apps/web/src/app/main/help/MusicContent.tsx | 273 ++++++++++++++ apps/web/src/app/main/help/PsychologyNav.tsx | 88 +++++ apps/web/src/app/main/help/ScienceContent.tsx | 8 + apps/web/src/app/main/help/VideoContent.tsx | 343 ++++++++++++++++++ apps/web/src/app/main/help/page.tsx | 7 +- apps/web/src/app/main/help/pt.css | 53 +++ .../web/src/app/main/letter/progress/page.tsx | 2 +- .../common/uploader/TusUploader.tsx | 11 +- .../layout/element/usermenu/usermenu.tsx | 18 +- .../web/src/components/layout/main/Footer.tsx | 2 +- .../web/src/components/layout/main/Header.tsx | 2 +- .../src/components/layout/main/useNavItem.tsx | 1 - .../models/post/detail/PostCommentCard.tsx | 4 +- packages/common/prisma/schema.prisma | 22 +- 20 files changed, 923 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/app/main/help/CourseContent.tsx create mode 100644 apps/web/src/app/main/help/MoreContent.tsx create mode 100644 apps/web/src/app/main/help/MusicContent.tsx create mode 100644 apps/web/src/app/main/help/PsychologyNav.tsx create mode 100644 apps/web/src/app/main/help/ScienceContent.tsx create mode 100644 apps/web/src/app/main/help/VideoContent.tsx create mode 100644 apps/web/src/app/main/help/pt.css diff --git a/apps/server/src/models/resource/resource.router.ts b/apps/server/src/models/resource/resource.router.ts index 30af7b5..6b91c40 100755 --- a/apps/server/src/models/resource/resource.router.ts +++ b/apps/server/src/models/resource/resource.router.ts @@ -24,10 +24,12 @@ export class ResourceRouter { router = this.trpc.router({ create: this.trpc.protectProcedure .input(ResourceCreateArgsSchema) + .mutation(async ({ ctx, input }) => { const { staff } = ctx; return await this.resourceService.create(input, { staff }); }), + createMany: this.trpc.protectProcedure .input(z.array(ResourceCreateManyInputSchema)) .mutation(async ({ ctx, input }) => { @@ -35,7 +37,7 @@ export class ResourceRouter { return await this.resourceService.createMany({ data: input }, staff); }), - deleteMany: this.trpc.procedure + deleteMany: this.trpc.protectProcedure .input(ResourceDeleteManyArgsSchema) .mutation(async ({ input }) => { return await this.resourceService.deleteMany(input); @@ -46,9 +48,12 @@ export class ResourceRouter { return await this.resourceService.findFirst(input); }), softDeleteByIds: this.trpc.protectProcedure - .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema - .mutation(async ({ input }) => { - return this.resourceService.softDeleteByIds(input.ids); + .input(z.object({ + ids: z.array(z.string()) + })) + .mutation(async ({ input, ctx }) => { + const result = await this.resourceService.softDeleteByIds(input.ids); + return result; }), updateOrder: this.trpc.protectProcedure .input(UpdateOrderSchema) @@ -60,7 +65,7 @@ export class ResourceRouter { .query(async ({ input }) => { return await this.resourceService.findMany(input); }), - findManyWithCursor: this.trpc.protectProcedure + findManyWithCursor: this.trpc.procedure .input( z.object({ cursor: z.any().nullish(), @@ -73,5 +78,24 @@ export class ResourceRouter { const { staff } = ctx; return await this.resourceService.findManyWithCursor(input); }), + count: this.trpc.procedure + .input( + z.object({ + where: z.object({ + AND: z.object({ + title: z.object({ + not: z.null() + }), + description: z.object({ + not: z.null() + }) + }).optional(), + deletedAt: z.date().nullable().optional(), + }).optional(), + }), + ) + .query(async ({ input }) => { + return await this.resourceService.count(input); + }), }); } diff --git a/apps/server/src/models/resource/resource.service.ts b/apps/server/src/models/resource/resource.service.ts index 3a35a7e..b8817aa 100755 --- a/apps/server/src/models/resource/resource.service.ts +++ b/apps/server/src/models/resource/resource.service.ts @@ -6,22 +6,69 @@ import { ObjectType, Prisma, Resource, - ResourceStatus, + PrismaClient, } from '@nice/common'; @Injectable() export class ResourceService extends BaseService { + protected db: PrismaClient; + constructor() { super(db, ObjectType.RESOURCE); + this.db = db; } async create( args: Prisma.ResourceCreateArgs, params?: { staff?: UserProfile }, ): Promise { - if (params?.staff) { - args.data.ownerId = params?.staff?.id; + try { + // 检查文件是否已存在 + if (args.data.fileId) { + const existingResource = await this.db.resource.findUnique({ + where: { + fileId: args.data.fileId, + deletedAt: null, // 只检查未删除的资源 + }, + }); + + // 如果文件已存在但已被"删除",可以更新而不是创建 + if (existingResource && existingResource.deletedAt) { + return this.db.resource.update({ + where: { id: existingResource.id }, + data: { + ...args.data, + deletedAt: null, + updatedAt: new Date(), + }, + }); + } + + // 如果文件已存在且未删除,可以返回现有记录或抛出更友好的错误 + if (existingResource) { + return existingResource; // 或者抛出自定义错误 + } + } + + // 设置所有者 + if (params?.staff) { + args.data.ownerId = params.staff.id; + } + // 确保将 description 写入数据库 + // if (args.data.description) { + // args.data.description = args.data.description.trim(); + // } + // 设置其他必要字段 + args.data = { + ...args.data, + createdAt: new Date(), + updatedAt: new Date(), + }; + console.log('创建资源参数:', args.data); // 调试日志 + return super.create(args); + } catch (error) { + console.error('创建资源失败:', error); + throw error; } - return super.create(args); } async softDeleteByFileId(fileId: string) { return this.update({ @@ -33,4 +80,26 @@ export class ResourceService extends BaseService { }, }); } + async softDeleteMany(ids: string[]) { + try { + console.log('softDeleteByIds called with ids:', ids); + const result = await this.db.resource.updateMany({ + where: { + id: { + in: ids, + }, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + updatedAt: new Date(), + }, + }); + console.log('softDeleteByIds result:', result); + return result; + } catch (error) { + console.error('Service delete error:', error); + throw error; + } + } } diff --git a/apps/web/src/app/admin/base-setting/page.tsx b/apps/web/src/app/admin/base-setting/page.tsx index 821a1aa..c38e3f2 100755 --- a/apps/web/src/app/admin/base-setting/page.tsx +++ b/apps/web/src/app/admin/base-setting/page.tsx @@ -9,6 +9,7 @@ import AdminHeader from "@web/src/components/layout/admin/AdminHeader"; import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader"; import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader"; import CarouselUrlInput from "@web/src/components/common/uploader/CarouselUrlInput"; +import { TusUploader } from "@web/src/components/common/uploader/TusUploader"; export default function BaseSettingPage() { const { update, baseSetting } = useAppConfig(); diff --git a/apps/web/src/app/auth/page.tsx b/apps/web/src/app/auth/page.tsx index 73ad65e..5f77540 100755 --- a/apps/web/src/app/auth/page.tsx +++ b/apps/web/src/app/auth/page.tsx @@ -40,11 +40,11 @@ const AuthPage: React.FC = () => { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.5 }}>
- {env.APP_NAME || "信箱"} + {env.APP_NAME || "心灵树洞"}
- 聆音于微 润心以答

- 纾难化雨 解忧惟勤 + 聆听细语 润泽心灵

+ 勤勉解困 化雨无声
{showLogin && ( + + )} + + {/* 文件展示区域 */} +
+ {/* 图片资源展示 */} + {/* {imageResources?.length > 0 && ( +
+

图片列表

+ + + {imageResources.map((resource) => ( + +
+ {resource.title} +
+
+ {resource.title} +
+
+
+ + ))} +
+
+
+ )} */} + + {/* 其他文件资源展示 */} + {fileResources?.length > 0 && ( +
+

文件列表

+
+ {fileResources.map((resource) => ( +
+
+ {getFileIcon(resource.url)} +
+
+
+ {resource.title || '未命名文件'} +
+ {resource.description && ( +
+ 描述: {resource.description} +
+ )} +
+ {dayjs(resource.createdAt).format('YYYY-MM-DD')} + {resource.meta?.size && formatFileSize(resource.meta.size)} +
+
+
+ +
+
+ ))} +
+
+ )} +
+ + + ); +}; \ No newline at end of file diff --git a/apps/web/src/app/main/help/PsychologyNav.tsx b/apps/web/src/app/main/help/PsychologyNav.tsx new file mode 100644 index 0000000..56cdde6 --- /dev/null +++ b/apps/web/src/app/main/help/PsychologyNav.tsx @@ -0,0 +1,88 @@ +import { Tabs } from 'antd'; +import { + VideoCameraOutlined, + FileTextOutlined, + CustomerServiceOutlined, + ReadOutlined, + BookOutlined +} from '@ant-design/icons'; +import { VideoContent } from './VideoContent'; +import { CourseContent } from './CourseContent'; +import { MusicContent } from './MusicContent'; +import { ScienceContent } from './ScienceContent'; +import { MoreContent } from './MoreContent'; +import './pt.css'; + +export function PsychologyNav() { + const items = [ + // { + // key: 'more', + // label: ( + // + // + // 宣传报道 + // + // ), + // children: + // }, + // { + // key: 'music', + // label: ( + // + // < ReadOutlined className="text-lg" /> + // 常识科普 + // + // ), + // children: + // }, + // { + // key: 'courses', + // label: ( + // + // + // 案例分析 + // + // ), + // children: + // }, + { + key: 'science', + label: ( + + < FileTextOutlined className="text-lg" /> + 心理课件 + + ), + children: + }, + { + key: 'vedio', + label: ( + + + 音视频 + + ), + children: + }, + + + ]; + return ( +
+ +
+ ); + +} +export default PsychologyNav; \ No newline at end of file diff --git a/apps/web/src/app/main/help/ScienceContent.tsx b/apps/web/src/app/main/help/ScienceContent.tsx new file mode 100644 index 0000000..42c80da --- /dev/null +++ b/apps/web/src/app/main/help/ScienceContent.tsx @@ -0,0 +1,8 @@ +export function ScienceContent() { + return ( +
+

科普

+ {/* 这里放视频列表内容 */} +
+ ); + } \ No newline at end of file diff --git a/apps/web/src/app/main/help/VideoContent.tsx b/apps/web/src/app/main/help/VideoContent.tsx new file mode 100644 index 0000000..d5b4f26 --- /dev/null +++ b/apps/web/src/app/main/help/VideoContent.tsx @@ -0,0 +1,343 @@ +import React, { useState, useMemo } from "react"; +import { + Tabs, + Button, + message, + Image, + Row, + Col, + Modal, + Input, + Alert, +} from "antd"; +import { TusUploader } from "@web/src/components/common/uploader/TusUploader"; +import { SendOutlined, DeleteOutlined, LoginOutlined } from "@ant-design/icons"; +import { api } from "@nice/client"; +import dayjs from "dayjs"; +import { env } from "@web/src/env"; +import { getFileIcon } from "@web/src/components/models/post/detail/utils"; +import { formatFileSize, getCompressedImageUrl } from "@nice/utils"; +import { ResourceDto, RoleName } from "packages/common/dist"; +import { useAuth } from "@web/src/providers/auth-provider"; +const { TabPane } = Tabs; + +export function VideoContent() { + const { isAuthenticated, user, hasSomePermissions } = useAuth(); + const [fileIds, setFileIds] = useState([]); + const [uploaderKey, setUploaderKey] = useState(0); + // const [description, setDescription] = useState(''); + + // 检查是否为域管理员 + const isDomainAdmin = useMemo(() => { + return hasSomePermissions("MANAGE_DOM_STAFF", "MANAGE_ANY_STAFF"); // 使用权限检查而不是角色名称 + }, [hasSomePermissions]); + + // 获取资源列表 + const { + data: resources, + refetch, + }: { data: ResourceDto[]; refetch: () => void } = + api.resource.findMany.useQuery({ + where: { + deletedAt: null, + postId: null, + }, + orderBy: { + createdAt: "desc", + }, + }); + // 处理资源数据 + const { imageResources, fileResources } = useMemo(() => { + if (!resources) return { imageResources: [], fileResources: [] }; + const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url); + + const processedResources = resources + .map((resource) => { + if (!resource?.url) return null; + const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${resource.url}`; + const isImg = isImage(resource.url); + return { + ...resource, + url: isImg ? getCompressedImageUrl(original) : original, + originalUrl: original, + isImage: isImg, + }; + }) + .filter(Boolean); + + return { + imageResources: processedResources.filter((res) => res.isImage), + fileResources: processedResources.filter((res) => !res.isImage), + }; + }, [resources]); + + const createMutation = api.resource.create.useMutation({}); + const handleSubmit = async () => { + if (!isAuthenticated) { + message.error("请先登录"); + return; + } + + if (!isDomainAdmin) { + message.error("只有管理员才能上传文件"); + return; + } + + if (!fileIds.length) { + message.error("请先上传文件"); + return; + } + + try { + // 逐个上传文件,而不是使用 Promise.all + for (const fileId of fileIds) { + try { + await createMutation.mutateAsync({ + data: { + fileId, + // description: description.trim(), + isPublic: true, + }, + }); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Unique constraint failed") + ) { + console.warn(`文件 ${fileId} 已存在,跳过`); + continue; + } + throw error; + } + } + message.success("上传成功!"); + setFileIds([]); + // setDescription(''); + setUploaderKey((prev) => prev + 1); + refetch(); // 刷新列表 + } catch (error) { + console.error("Error uploading:", error); + message.error("上传失败,请稍后重试"); + } + }; + + // 删除资源 + const deleteMutation = api.resource.softDeleteByIds.useMutation(); + + const handleDelete = async (id: string) => { + if (!isAuthenticated) { + message.error("请先登录"); + return; + } + + if (!isDomainAdmin) { + message.error("只有域管理员才能删除文件"); + return; + } + + console.log("Delete resource id:", id); + try { + const confirmed = await new Promise((resolve) => { + Modal.confirm({ + title: "确认删除", + content: "确定要删除这个文件吗?此操作不可恢复。", + okText: "确认", + cancelText: "取消", + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + + if (!confirmed) return; + + await deleteMutation.mutateAsync({ + ids: [id], + }); + } catch (error) { + console.error("Delete error:", error); + message.error("删除失败,请重试"); + } + + refetch(); + message.success("删除成功"); + }; + + return ( +
+
+
+

锦囊资源上传

+

支持视频、图片、文档、PPT等多种格式文件

+
+
+ + {!isAuthenticated && ( + + )} + + {isAuthenticated && !isDomainAdmin && ( + + )} + + {/* 上传区域 */} +
+ + +
+ { + if (!isDomainAdmin) { + message.error("只有管理员才能上传文件"); + return; + } + setFileIds(value); + }} + /> + {/* {!isDomainAdmin && ( +
+ + 只有管理员才能使用上传功能 + +
+ )} */} +
+
+
+ {isDomainAdmin && fileIds.length > 0 && ( +
+ +
+ )} + + {/* 文件展示区域 */} +
+ {/* 图片资源展示 */} + {imageResources?.length > 0 && ( +
+

图片列表

+ + + {imageResources.map((resource) => ( + +
+ {resource.title} +
+
+ + {resource.title} + + {isDomainAdmin && ( +
+
+
+ + ))} +
+
+
+ )} + + {/* 其他文件资源展示 */} + {fileResources?.length > 0 && ( +
+

文件列表

+
+ {fileResources.map((resource) => ( +
+
+ {getFileIcon(resource.url)} +
+
+
+ {resource.title || "未命名文件"} +
+ {resource.description && ( +
+ 描述: {resource.description} +
+ )} +
+ + {dayjs(resource.createdAt).format("YYYY-MM-DD")} + + + {resource.meta?.size && + formatFileSize(resource.meta.size)} + +
+
+
+ + {isDomainAdmin && ( +
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/main/help/page.tsx b/apps/web/src/app/main/help/page.tsx index d860461..aa1a081 100755 --- a/apps/web/src/app/main/help/page.tsx +++ b/apps/web/src/app/main/help/page.tsx @@ -1,3 +1,8 @@ +import PsychologyNav from "./PsychologyNav"; + export default function HelpPage() { - return <>help + + return
+ +
} \ No newline at end of file diff --git a/apps/web/src/app/main/help/pt.css b/apps/web/src/app/main/help/pt.css new file mode 100644 index 0000000..58bbde6 --- /dev/null +++ b/apps/web/src/app/main/help/pt.css @@ -0,0 +1,53 @@ +.psychology-tabs { + /* 标签页样式 */ + .ant-tabs-nav { + margin: 0 !important; + background: #fff; + border-bottom: 1px solid #f0f0f0; + } + + .ant-tabs-tab { + padding: 12px 0 !important; + font-size: 15px; + transition: all 0.3s; + + &:hover { + color: #1890ff; /* 修改悬停颜色为Ant Design默认蓝色 */ + } + } + + .ant-tabs-tab-active { + .ant-tabs-tab-btn { + color: #1890ff !important; /* 修改激活状态文字颜色 */ + } + + /* 修改图标和文字颜色 */ + .anticon, span { + color: #1890ff !important; + } + } + + /* 激活标签的下划线样式 */ + .ant-tabs-ink-bar { + height: 2px !important; + background: #1890ff; /* 修改下划线颜色 */ + } + +} + + .overflow-auto::-webkit-scrollbar { + width: 6px; + } + + .overflow-auto::-webkit-scrollbar-track { + background: #f1f1f1; + } + + .overflow-auto::-webkit-scrollbar-thumb { + background: #888; + border-radius: 3px; + } + + .overflow-auto::-webkit-scrollbar-thumb:hover { + background: #555; + } \ No newline at end of file diff --git a/apps/web/src/app/main/letter/progress/page.tsx b/apps/web/src/app/main/letter/progress/page.tsx index d055b7e..1f9042d 100755 --- a/apps/web/src/app/main/letter/progress/page.tsx +++ b/apps/web/src/app/main/letter/progress/page.tsx @@ -73,7 +73,7 @@ export default function LetterProgressPage() { {data && ( <> -
+
{ +export const TusUploader = ({ value = [], onChange, +}: TusUploaderProps) => { const { handleFileUpload, uploadProgress } = useTusUpload(); const [uploadingFiles, setUploadingFiles] = useState([]); const [completedFiles, setCompletedFiles] = useState( @@ -48,7 +49,6 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => { }, [onChange] ); - // 新增:处理删除上传中的失败文件 const handleRemoveUploadingFile = useCallback((fileKey: string) => { setUploadingFiles((prev) => prev.filter((f) => f.fileKey !== fileKey)); @@ -57,7 +57,6 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => { const handleBeforeUpload = useCallback( (file: File) => { const fileKey = `${file.name}-${Date.now()}`; - setUploadingFiles((prev) => [ ...prev, { @@ -107,7 +106,7 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => { return false; }, - [handleFileUpload, onChange] + [handleFileUpload, onChange, ] ); return ( @@ -123,7 +122,9 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {

点击或拖拽文件到此区域进行上传

-

支持单个或批量上传文件

+

支持单个或批量上传文件 + +

{uploadingFiles.map((file) => ( diff --git a/apps/web/src/components/layout/element/usermenu/usermenu.tsx b/apps/web/src/components/layout/element/usermenu/usermenu.tsx index 66d3dd6..26db68f 100755 --- a/apps/web/src/components/layout/element/usermenu/usermenu.tsx +++ b/apps/web/src/components/layout/element/usermenu/usermenu.tsx @@ -9,10 +9,12 @@ import React, { createContext, } from "react"; import { Avatar } from "../../../common/element/Avatar"; +import {api} from "@nice/client"; import { UserOutlined, SettingOutlined, LogoutOutlined, + VideoCameraOutlined, } from "@ant-design/icons"; import { FormInstance, Spin } from "antd"; import { useNavigate } from "react-router-dom"; @@ -76,6 +78,8 @@ export function UserMenu() { const canManageAnyStaff = useMemo(() => { return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF); }, [user]); + const [showUploadModal, setShowUploadModal] = useState(false); + const utils = api.useUtils(); const menuItems: MenuItemType[] = useMemo( () => [ @@ -226,20 +230,18 @@ export function UserMenu() { focus:ring-2 focus:ring-[#00538E]/20 group relative overflow-hidden active:scale-[0.99] - ${ - item.label === "注销" + ${item.label === "注销" ? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700" : "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]" - }`}> + }`}> + group-hover:translate-x-0.5 ${item.label === "注销" + ? "group-hover:text-red-600" + : "group-hover:text-[#003F6A]" + }`}> {item.icon} {item.label} diff --git a/apps/web/src/components/layout/main/Footer.tsx b/apps/web/src/components/layout/main/Footer.tsx index 48c19c5..5b0f9a7 100755 --- a/apps/web/src/components/layout/main/Footer.tsx +++ b/apps/web/src/components/layout/main/Footer.tsx @@ -11,7 +11,7 @@ import Logo from "../../common/element/Logo"; export function Footer() { return ( -