diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 5e8cd9a..2e3ab7c 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -11,7 +11,7 @@ import { env } from '@server/env'; import { redis } from '@server/utils/redis/redis.service'; import EventBus from '@server/utils/event-bus'; import { RoleMapService } from '@server/models/rbac/rolemap.service'; -import { Request } from "express" +import { Request } from 'express'; interface ProfileResult { staff: UserProfile | undefined; error?: string; @@ -22,9 +22,11 @@ interface TokenVerifyResult { error?: string; } export function extractTokenFromHeader(request: Request): string | undefined { - return extractTokenFromAuthorization(request.headers.authorization) + return extractTokenFromAuthorization(request.headers.authorization); } -export function extractTokenFromAuthorization(authorization: string): string | undefined { +export function extractTokenFromAuthorization( + authorization: string, +): string | undefined { const [type, token] = authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; } @@ -40,7 +42,7 @@ export class UserProfileService { this.jwtService = new JwtService(); this.departmentService = new DepartmentService(); this.roleMapService = new RoleMapService(this.departmentService); - EventBus.on("dataChanged", ({ type, data }) => { + EventBus.on('dataChanged', ({ type, data }) => { if (type === ObjectType.STAFF) { // 确保 data 是数组,如果不是则转换为数组 const dataArray = Array.isArray(data) ? data : [data]; @@ -51,7 +53,6 @@ export class UserProfileService { } } }); - } public getProfileCacheKey(id: string) { return `user-profile-${id}`; @@ -162,6 +163,7 @@ export class UserProfileService { showname: true, username: true, phoneNumber: true, + meta: true, }, })) as unknown as UserProfile; } @@ -174,9 +176,7 @@ export class UserProfileService { staff.deptId ? this.departmentService.getDescendantIdsInDomain(staff.deptId) : [], - staff.deptId - ? this.departmentService.getAncestorIds([staff.deptId]) - : [], + staff.deptId ? this.departmentService.getAncestorIds([staff.deptId]) : [], this.roleMapService.getPermsForObject({ domainId: staff.domainId, staffId: staff.id, diff --git a/apps/web/src/components/common/uploader/AvatarUploader.tsx b/apps/web/src/components/common/uploader/AvatarUploader.tsx index ae831a3..8cc6161 100644 --- a/apps/web/src/components/common/uploader/AvatarUploader.tsx +++ b/apps/web/src/components/common/uploader/AvatarUploader.tsx @@ -9,6 +9,7 @@ export interface AvatarUploaderProps { placeholder?: string; className?: string; onChange?: (value: string) => void; + compressed?: boolean; style?: React.CSSProperties; // 添加style属性 } @@ -18,12 +19,14 @@ interface UploadingFile { status: "uploading" | "done" | "error"; fileId?: string; url?: string; + compressedUrl?: string; fileKey?: string; } const AvatarUploader: React.FC = ({ value, onChange, + compressed = true, className, placeholder = "点击上传", style, // 解构style属性 @@ -32,6 +35,7 @@ const AvatarUploader: React.FC = ({ const [file, setFile] = useState(null); const [previewUrl, setPreviewUrl] = useState(value || ""); + const [url, setUrl] = useState(value || ""); const [uploading, setUploading] = useState(false); const inputRef = useRef(null); @@ -50,7 +54,7 @@ const AvatarUploader: React.FC = ({ setUploading(true); try { - const fileId = await new Promise((resolve, reject) => { + const uploadedUrl = await new Promise((resolve, reject) => { handleFileUpload( selectedFile, (result) => { @@ -59,10 +63,13 @@ const AvatarUploader: React.FC = ({ progress: 100, status: "done", fileId: result.fileId, - url: result?.url, + url: result.url, + compressedUrl: result.compressedUrl, })); - setPreviewUrl(result?.url); - resolve(result.fileId); + setUrl(result.url); + setPreviewUrl(result.compressedUrl); + // 直接使用 result 中的最新值 + resolve(compressed ? result.compressedUrl : result.url); }, (error) => { reject(error); @@ -70,7 +77,8 @@ const AvatarUploader: React.FC = ({ file?.fileKey ); }); - onChange?.(fileId); + // 使用 resolved 的最新值调用 onChange + onChange?.(uploadedUrl); message.success("头像上传成功"); } catch (error) { console.error("上传错误:", error); diff --git a/apps/web/src/components/common/uploader/TusUploader.tsx b/apps/web/src/components/common/uploader/TusUploader.tsx index 3aa7ad0..dd11898 100644 --- a/apps/web/src/components/common/uploader/TusUploader.tsx +++ b/apps/web/src/components/common/uploader/TusUploader.tsx @@ -1,11 +1,17 @@ -import { useCallback, useState } from "react"; +// TusUploader.tsx +import { + useCallback, + useState, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { UploadOutlined, CheckCircleOutlined, DeleteOutlined, } from "@ant-design/icons"; import { Upload, Progress, Button } from "antd"; -import type { UploadFile } from "antd"; import { useTusUpload } from "@web/src/hooks/useTusUpload"; import toast from "react-hot-toast"; @@ -14,6 +20,10 @@ export interface TusUploaderProps { onChange?: (value: string[]) => void; } +export interface TusUploaderRef { + reset: () => void; +} + interface UploadingFile { name: string; progress: number; @@ -22,164 +32,179 @@ interface UploadingFile { fileKey?: string; } -export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => { - const { handleFileUpload, uploadProgress } = useTusUpload(); - const [uploadingFiles, setUploadingFiles] = useState([]); - const [completedFiles, setCompletedFiles] = useState( - () => - value?.map((fileId) => ({ - name: `文件 ${fileId}`, - progress: 100, - status: "done" as const, - fileId, - })) || [] - ); - // 恢复使用 uploadResults 状态跟踪最新结果 - const [uploadResults, setUploadResults] = useState(value || []); - const handleRemoveFile = useCallback( - (fileId: string) => { - setCompletedFiles((prev) => - prev.filter((f) => f.fileId !== fileId) +export const TusUploader = forwardRef( + ({ value = [], onChange }, ref) => { + const { handleFileUpload, uploadProgress } = useTusUpload(); + const [uploadingFiles, setUploadingFiles] = useState( + [] + ); + const [completedFiles, setCompletedFiles] = useState( + [] + ); + + // 同步父组件value到completedFiles + useEffect(() => { + setCompletedFiles( + value.map((fileId) => ({ + name: `文件 ${fileId}`, + progress: 100, + status: "done" as const, + fileId, + })) ); - // 使用函数式更新保证获取最新状态 - setUploadResults((prev) => { - const newValue = prev.filter((id) => id !== fileId); - onChange?.(newValue); // 同步更新父组件 - return newValue; - }); - }, - [onChange] - ); + }, [value]); - const handleBeforeUpload = useCallback( - (file: File) => { - const fileKey = `${file.name}-${Date.now()}`; + // 暴露重置方法 + useImperativeHandle(ref, () => ({ + reset: () => { + setCompletedFiles([]); + setUploadingFiles([]); + }, + })); - setUploadingFiles((prev) => [ - ...prev, - { - name: file.name, - progress: 0, - status: "uploading", - fileKey, - }, - ]); + const handleRemoveFile = useCallback( + (fileId: string) => { + setCompletedFiles((prev) => + prev.filter((f) => f.fileId !== fileId) + ); + onChange?.(value.filter((id) => id !== fileId)); + }, + [onChange, value] + ); - handleFileUpload( - file, - (result) => { - setCompletedFiles((prev) => [ - ...prev, - { - name: file.name, - progress: 100, - status: "done", - fileId: result.fileId, - }, - ]); - - setUploadingFiles((prev) => - prev.filter((f) => f.fileKey !== fileKey) - ); - - // 正确的状态更新方式 - setUploadResults((prev) => { - const newValue = [...prev, result.fileId]; - onChange?.(newValue); // 传递值而非函数 - return newValue; - }); - }, - (error) => { - console.error("上传错误:", error); - toast.error( - `上传失败: ${ - error instanceof Error ? error.message : "未知错误" - }` - ); - setUploadingFiles((prev) => - prev.map((f) => - f.fileKey === fileKey - ? { ...f, status: "error" } - : f - ) - ); - }, - fileKey + const handleRemoveUploadingFile = useCallback((fileKey: string) => { + setUploadingFiles((prev) => + prev.filter((f) => f.fileKey !== fileKey) ); + }, []); - return false; - }, - [handleFileUpload, onChange] - ); + const handleBeforeUpload = useCallback( + (file: File) => { + const fileKey = `${file.name}-${Date.now()}`; - return ( -
- -

- -

-

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

-

支持单个或批量上传文件

+ setUploadingFiles((prev) => [ + ...prev, + { + name: file.name, + progress: 0, + status: "uploading", + fileKey, + }, + ]); - {/* 上传状态展示 */} -
- {/* 上传中的文件 */} - {uploadingFiles.map((file) => ( -
-
- {file.name} + handleFileUpload( + file, + (result) => { + const newValue = [...value, result.fileId]; + onChange?.(newValue); + setUploadingFiles((prev) => + prev.filter((f) => f.fileKey !== fileKey) + ); + }, + (error) => { + console.error("上传错误:", error); + toast.error( + `上传失败: ${error instanceof Error ? error.message : "未知错误"}` + ); + setUploadingFiles((prev) => + prev.map((f) => + f.fileKey === fileKey + ? { ...f, status: "error" } + : f + ) + ); + }, + fileKey + ); + + return false; + }, + [handleFileUpload, onChange, value] + ); + + return ( +
+ +

+ +

+

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

+

支持单个或批量上传文件

+ +
+ {uploadingFiles.map((file) => ( +
+
+ {file.name} +
+
+ + {file.status === "error" && ( +
- -
- ))} + ))} - {/* 已完成的文件 */} - {completedFiles.map((file) => ( -
-
- - {file.name} + {completedFiles.map((file) => ( +
+
+ + {file.name} +
+
-
- ))} -
-
-
- ); -}; + ))} +
+ +
+ ); + } +); diff --git a/apps/web/src/components/layout/element/usermenu/user-form.tsx b/apps/web/src/components/layout/element/usermenu/user-form.tsx index d4c508f..61eacc8 100644 --- a/apps/web/src/components/layout/element/usermenu/user-form.tsx +++ b/apps/web/src/components/layout/element/usermenu/user-form.tsx @@ -47,6 +47,7 @@ export default function StaffForm() { rank, office, } = values; + console.log("photoUrl", photoUrl); setFormLoading(true); try { if (data && user?.id) { @@ -120,6 +121,9 @@ export default function StaffForm() { { + console.log(value); + }} style={{ width: "120px", height: "150px", diff --git a/apps/web/src/components/layout/element/usermenu/usermenu.tsx b/apps/web/src/components/layout/element/usermenu/usermenu.tsx index e00965b..c7c9cdd 100644 --- a/apps/web/src/components/layout/element/usermenu/usermenu.tsx +++ b/apps/web/src/components/layout/element/usermenu/usermenu.tsx @@ -144,7 +144,7 @@ export function UserMenu() { {/* Avatar 容器,相对定位 */}
diff --git a/apps/web/src/components/models/post/detail/PostCommentEditor.tsx b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx index 7a65b1d..61c755d 100644 --- a/apps/web/src/components/models/post/detail/PostCommentEditor.tsx +++ b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useState } from "react"; +import React, { useContext, useRef, useState } from "react"; import { motion } from "framer-motion"; import { Button, Input, Tabs } from "antd"; import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; import { PostDetailContext } from "./context/PostDetailContext"; -import { useEntity } from "@nice/client"; +import { usePost } from "@nice/client"; import { PostType } from "@nice/common"; import toast from "react-hot-toast"; import { isContentEmpty } from "./utils"; @@ -18,7 +18,9 @@ export default function PostCommentEditor() { const [content, setContent] = useState(""); const [signature, setSignature] = useState(undefined); const [fileIds, setFileIds] = useState([]); - const { create } = useEntity("post") + const { create } = usePost(); + const uploaderRef = useRef<{ reset: () => void }>(null); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (isContentEmpty(content)) { @@ -45,7 +47,10 @@ export default function PostCommentEditor() { }); toast.success("发布成功!"); setContent(""); - setFileIds([]); + setFileIds([]); // 重置上传组件状态 + // if (uploaderRef.current) { + // uploaderRef.current.reset(); + // } } catch (error) { toast.error("发布失败,请稍后重试"); console.error("Error posting comment:", error); @@ -85,6 +90,8 @@ export default function PostCommentEditor() {
{ console.log("ids", value); setFileIds(value); @@ -98,14 +105,14 @@ export default function PostCommentEditor() {
{ setSignature(e.target.value); }} - showCount + // showCount placeholder="签名" />
diff --git a/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx index 5b3312b..f088c91 100644 --- a/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx +++ b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx @@ -102,11 +102,11 @@ export function LetterBasicForm() {
diff --git a/apps/web/src/hooks/useTusUpload.ts b/apps/web/src/hooks/useTusUpload.ts index a1ddf1d..f6fca26 100644 --- a/apps/web/src/hooks/useTusUpload.ts +++ b/apps/web/src/hooks/useTusUpload.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import * as tus from "tus-js-client"; import { env } from "../env"; - +import { getCompressedImageUrl } from "@nice/utils"; // useTusUpload.ts interface UploadProgress { fileId: string; @@ -9,6 +9,7 @@ interface UploadProgress { } interface UploadResult { + compressedUrl: string; url: string; fileId: string; } @@ -35,7 +36,7 @@ export function useTusUpload() { throw new Error("Invalid upload URL format"); } const resUrl = `http://${env.SERVER_IP}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`; - console.log(resUrl); + return resUrl; }; const handleFileUpload = async ( @@ -44,12 +45,12 @@ export function useTusUpload() { onError: (error: Error) => void, fileKey: string // 添加文件唯一标识 ) => { - if (!file || !file.name || !file.type) { - const error = new Error("不可上传该类型文件"); - setUploadError(error.message); - onError(error); - return; - } + // if (!file || !file.name || !file.type) { + // const error = new Error("不可上传该类型文件"); + // setUploadError(error.message); + // onError(error); + // return; + // } setIsUploading(true); setUploadProgress((prev) => ({ ...prev, [fileKey]: 0 })); @@ -84,6 +85,7 @@ export function useTusUpload() { [fileKey]: 100, })); onSuccess({ + compressedUrl: getCompressedImageUrl(url), url, fileId, }); diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts index 32271f6..4b3526a 100644 --- a/packages/common/src/select.ts +++ b/packages/common/src/select.ts @@ -29,6 +29,7 @@ export const postDetailSelect: Prisma.PostSelect = { name: true, }, }, + meta: true, }, }, receivers: { diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index de67869..ca8a3a9 100755 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -35,15 +35,16 @@ export type AppLocalSettings = { important?: number; exploreTime?: Date; }; +export type StaffMeta = { + photoUrl?: string; + office?: string; + email?: string; + rank?: string; +}; export type StaffDto = Staff & { domain?: Department; department?: Department; - meta?: { - photoUrl?: string; - office?: string; - email?: string; - rank?: string; - }; + meta?: StaffMeta; }; export interface AuthDto { token: string; @@ -57,6 +58,7 @@ export type UserProfile = Staff & { parentDeptIds: string[]; domain: Department; department: Department; + meta?: StaffMeta; }; export interface DataNode { @@ -132,22 +134,22 @@ export type PostComment = { }; export interface BaseMetadata { - size: number - filetype: string - filename: string - extension: string - modifiedAt: Date + size: number; + filetype: string; + filename: string; + extension: string; + modifiedAt: Date; } /** * 图片特有元数据接口 */ export interface ImageMetadata { - width: number; // 图片宽度(px) - height: number; // 图片高度(px) + width: number; // 图片宽度(px) + height: number; // 图片高度(px) compressedUrl?: string; - orientation?: number; // EXIF方向信息 - space?: string; // 色彩空间 (如: RGB, CMYK) - hasAlpha?: boolean; // 是否包含透明通道 + orientation?: number; // EXIF方向信息 + space?: string; // 色彩空间 (如: RGB, CMYK) + hasAlpha?: boolean; // 是否包含透明通道 } /** @@ -159,26 +161,28 @@ export interface VideoMetadata { duration?: number; videoCodec?: string; audioCodec?: string; - coverUrl?: string + coverUrl?: string; } /** * 音频特有元数据接口 */ export interface AudioMetadata { - duration: number; // 音频时长(秒) - bitrate?: number; // 比特率(bps) - sampleRate?: number; // 采样率(Hz) - channels?: number; // 声道数 - codec?: string; // 音频编码格式 + duration: number; // 音频时长(秒) + bitrate?: number; // 比特率(bps) + sampleRate?: number; // 采样率(Hz) + channels?: number; // 声道数 + codec?: string; // 音频编码格式 } - -export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata +export type FileMetadata = ImageMetadata & + VideoMetadata & + AudioMetadata & + BaseMetadata; export type ResourceDto = Resource & { - meta: FileMetadata -} + meta: FileMetadata; +}; export type PostDto = Post & { readed: boolean; liked: boolean; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b08b0b5..77aa06e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -4,33 +4,38 @@ * @returns 唯一ID字符串 */ export function generateUniqueId(prefix?: string): string { - // 获取当前时间戳 - const timestamp = Date.now(); + // 获取当前时间戳 + const timestamp = Date.now(); - // 生成随机数部分 - const randomPart = Math.random().toString(36).substring(2, 8); + // 生成随机数部分 + const randomPart = Math.random().toString(36).substring(2, 8); - // 获取环境特定的额外随机性 - const environmentPart = typeof window !== 'undefined' - ? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36) - : require('crypto').randomBytes(4).toString('hex'); + // 获取环境特定的额外随机性 + const environmentPart = + typeof window !== "undefined" + ? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36) + : require("crypto").randomBytes(4).toString("hex"); - // 组合所有部分 - const uniquePart = `${timestamp}${randomPart}${environmentPart}`; + // 组合所有部分 + const uniquePart = `${timestamp}${randomPart}${environmentPart}`; - // 如果提供了前缀,则添加前缀 - return prefix ? `${prefix}_${uniquePart}` : uniquePart; + // 如果提供了前缀,则添加前缀 + return prefix ? `${prefix}_${uniquePart}` : uniquePart; } export const formatFileSize = (bytes: number) => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; }; // 压缩图片路径生成函数 export const getCompressedImageUrl = (originalUrl: string): string => { - const cleanUrl = originalUrl.split(/[?#]/)[0] // 移除查询参数和哈希 - const lastSlashIndex = cleanUrl.lastIndexOf('/') - return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, '.webp')}` -} -export * from "./types" \ No newline at end of file + if (!originalUrl) { + return originalUrl; + } + const cleanUrl = originalUrl.split(/[?#]/)[0]; // 移除查询参数和哈希 + const lastSlashIndex = cleanUrl.lastIndexOf("/"); + return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, ".webp")}`; +}; +export * from "./types";