01261605
This commit is contained in:
parent
6af10a87a2
commit
29748c5ee4
|
@ -24,7 +24,7 @@ export class AuthService {
|
|||
private readonly staffService: StaffService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
) { }
|
||||
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
|
||||
try {
|
||||
// 基础参数验证
|
||||
|
@ -169,11 +169,11 @@ export class AuthService {
|
|||
password,
|
||||
officerId,
|
||||
showname,
|
||||
department: {
|
||||
department: deptId && {
|
||||
connect: { id: deptId },
|
||||
},
|
||||
domain: {
|
||||
connect: { id: deptId },
|
||||
connect: deptId && { id: deptId },
|
||||
},
|
||||
// domainId: data.deptId,
|
||||
meta: {
|
||||
|
|
|
@ -51,7 +51,7 @@ export class ResourceProcessingPipeline {
|
|||
ResourceStatus.PROCESSED,
|
||||
);
|
||||
this.logger.log(
|
||||
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`,
|
||||
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.meta)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import path, { dirname } from "path";
|
||||
import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types";
|
||||
import { ResourceProcessor } from "../types";
|
||||
import { Resource, ResourceStatus, db } from "@nice/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import fs from 'fs/promises';
|
||||
|
@ -15,9 +15,9 @@ export abstract class BaseProcessor implements ResourceProcessor {
|
|||
subdirectory,
|
||||
);
|
||||
fs.mkdir(outputDir, { recursive: true }).catch(err => this.logger.error(`Failed to create directory: ${err.message}`));
|
||||
|
||||
|
||||
return outputDir;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
//
|
|
@ -1,6 +1,6 @@
|
|||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { FileMetadata, ImageMetadata, ResourceProcessor } from '../types';
|
||||
import { FileMetadata, ImageMetadata, ResourceDto } from '@nice/common';
|
||||
import { Resource, ResourceStatus, db } from '@nice/common';
|
||||
import { getUploadFilePath } from '@server/utils/file';
|
||||
import { BaseProcessor } from './BaseProcessor';
|
||||
|
@ -10,10 +10,10 @@ export class ImageProcessor extends BaseProcessor {
|
|||
super();
|
||||
}
|
||||
|
||||
async process(resource: Resource): Promise<Resource> {
|
||||
async process(resource: ResourceDto): Promise<Resource> {
|
||||
const { url } = resource;
|
||||
const filepath = getUploadFilePath(url);
|
||||
const originMeta = resource.metadata as unknown as FileMetadata;
|
||||
const originMeta = resource.meta as unknown as FileMetadata;
|
||||
if (!originMeta.filetype?.startsWith('image/')) {
|
||||
this.logger.log(`Skipping non-image resource: ${resource.id}`);
|
||||
return resource;
|
||||
|
@ -47,7 +47,7 @@ export class ImageProcessor extends BaseProcessor {
|
|||
const updatedResource = await db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
metadata: {
|
||||
meta: {
|
||||
...originMeta,
|
||||
...imageMeta,
|
||||
},
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import path, { dirname } from 'path';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types';
|
||||
import { Resource, ResourceStatus, db } from '@nice/common';
|
||||
import { Resource, ResourceStatus, db, FileMetadata, VideoMetadata, ResourceDto } from '@nice/common';
|
||||
import { getUploadFilePath } from '@server/utils/file';
|
||||
import fs from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
|
@ -11,14 +10,14 @@ export class VideoProcessor extends BaseProcessor {
|
|||
constructor() {
|
||||
super();
|
||||
}
|
||||
async process(resource: Resource): Promise<Resource> {
|
||||
async process(resource: ResourceDto): Promise<Resource> {
|
||||
const { url } = resource;
|
||||
const filepath = getUploadFilePath(url);
|
||||
this.logger.log(
|
||||
`Processing video for resource ID: ${resource.id}, File ID: ${url}`,
|
||||
);
|
||||
|
||||
const originMeta = resource.metadata as unknown as FileMetadata;
|
||||
const originMeta = resource.meta as unknown as FileMetadata;
|
||||
if (!originMeta.filetype?.startsWith('video/')) {
|
||||
this.logger.log(`Skipping non-video resource: ${resource.id}`);
|
||||
return resource;
|
||||
|
@ -39,7 +38,7 @@ export class VideoProcessor extends BaseProcessor {
|
|||
const updatedResource = await db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
metadata: {
|
||||
meta: {
|
||||
...originMeta,
|
||||
...videoMeta,
|
||||
},
|
||||
|
|
|
@ -9,47 +9,3 @@ export interface ProcessResult {
|
|||
error?: Error
|
||||
}
|
||||
|
||||
export interface BaseMetadata {
|
||||
size: number
|
||||
filetype: string
|
||||
filename: string
|
||||
extension: string
|
||||
modifiedAt: Date
|
||||
}
|
||||
/**
|
||||
* 图片特有元数据接口
|
||||
*/
|
||||
export interface ImageMetadata {
|
||||
width: number; // 图片宽度(px)
|
||||
height: number; // 图片高度(px)
|
||||
compressedUrl?: string;
|
||||
orientation?: number; // EXIF方向信息
|
||||
space?: string; // 色彩空间 (如: RGB, CMYK)
|
||||
hasAlpha?: boolean; // 是否包含透明通道
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频特有元数据接口
|
||||
*/
|
||||
export interface VideoMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频特有元数据接口
|
||||
*/
|
||||
export interface AudioMetadata {
|
||||
duration: number; // 音频时长(秒)
|
||||
bitrate?: number; // 比特率(bps)
|
||||
sampleRate?: number; // 采样率(Hz)
|
||||
channels?: number; // 声道数
|
||||
codec?: string; // 音频编码格式
|
||||
}
|
||||
|
||||
|
||||
export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata
|
|
@ -44,6 +44,7 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
|||
...data,
|
||||
password: await argon2.hash((data.password || '123456') as string),
|
||||
};
|
||||
|
||||
const result = await super.create({ ...args, data: createData });
|
||||
this.emitDataChangedEvent(result, CrudOperation.CREATED);
|
||||
return result;
|
||||
|
|
|
@ -68,13 +68,12 @@ export class TusService implements OnModuleInit {
|
|||
) {
|
||||
try {
|
||||
const fileId = this.getFileId(upload.id);
|
||||
|
||||
await this.resourceService.create({
|
||||
data: {
|
||||
title: getFilenameWithoutExt(upload.metadata.filename),
|
||||
fileId, // 移除最后的文件名
|
||||
url: upload.id,
|
||||
metadata: upload.metadata,
|
||||
meta: upload.metadata,
|
||||
status: ResourceStatus.UPLOADING,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
VITE_APP_VERSION: "$VITE_APP_VERSION",
|
||||
};
|
||||
</script>
|
||||
<title>fhmooc</title>
|
||||
<title>首长机关信箱</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
@ -48,10 +48,10 @@ const AuthPage: React.FC = () => {
|
|||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
>
|
||||
<div className="text-4xl text-white mb-4 font-serif">
|
||||
烽火建言
|
||||
首长机关信箱
|
||||
</div>
|
||||
<Paragraph className="text-lg mb-8 text-blue-100 leading-relaxed text-justify">
|
||||
用真心聆听每一位官兵的心声,第一时间回应大家的关切,把战友们的烦心事当作自己的心头事,用心用情解决每一个难题,全心全意办好每一件实事,让贴心服务成为军营最温暖的底色
|
||||
聆音于微,润心以答,纾难化雨,解忧惟勤
|
||||
</Paragraph>
|
||||
{showLogin && (
|
||||
<Button
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import LetterList from "@web/src/components/models/post/list/LetterList";
|
||||
import { Header } from "./Header";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function LetterListPage() {
|
||||
|
||||
const [params] = useSearchParams()
|
||||
const keyword = params.get("keyword")
|
||||
return (
|
||||
// 添加 flex flex-col 使其成为弹性布局容器
|
||||
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-slate-200 flex flex-col">
|
||||
<Header />
|
||||
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
|
||||
|
||||
<LetterList params={{
|
||||
<LetterList search={keyword} params={{
|
||||
where: {
|
||||
isPublic: true
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ export default function LetterProgressPage() {
|
|||
<div
|
||||
className=" shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||
<ProgressHeader></ProgressHeader>
|
||||
<main className="container mx-auto p-6 flex flex-col gap-4">
|
||||
<main className=" mx-auto p-6 flex flex-col gap-4">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
variant="filled"
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export default function LetterSearchPage() {
|
||||
return <>Search</>
|
||||
}
|
|
@ -36,6 +36,7 @@ export default function WriteLetterPage() {
|
|||
contains: searchQuery,
|
||||
},
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
orderBy: {
|
||||
|
@ -55,9 +56,8 @@ export default function WriteLetterPage() {
|
|||
return (
|
||||
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||
<WriteHeader term={getTerm(termId)} />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8 space-y-4">
|
||||
{/* Search and Filter Section */}
|
||||
<div className=" mx-auto p-6">
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center">
|
||||
<DepartmentSelect
|
||||
variant="filled"
|
||||
|
@ -80,7 +80,6 @@ export default function WriteLetterPage() {
|
|||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
message="加载失败"
|
||||
|
@ -90,7 +89,6 @@ export default function WriteLetterPage() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
|
|
|
@ -12,7 +12,7 @@ export function Footer() {
|
|||
创新高地 软件小组
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs italic">
|
||||
提供全天候技术支持
|
||||
提供技术支持
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,26 +1,27 @@
|
|||
import { Link, NavLink } from "react-router-dom";
|
||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import { memo } from "react";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import Navigation from "./navigation";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { UserOutlined } from "@ant-design/icons";
|
||||
import { UserMenu } from "../element/usermenu/usermenu";
|
||||
import SineWavesCanvas from "../../animation/sine-wave";
|
||||
interface HeaderProps {
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
export const Header = memo(function Header({ onSearch }: HeaderProps) {
|
||||
|
||||
export const Header = memo(function Header() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="py-3">
|
||||
<div className=" mx-auto px-4">
|
||||
<div className="py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className=" text-xl font-bold">烽火建言</div>
|
||||
<div className="flex-grow max-w-2xl">
|
||||
<SearchBar onSearch={onSearch} />
|
||||
<div className="">
|
||||
<span className="text-xl font-bold">首长机关信箱</span>
|
||||
<p className=" text-sm text-secondary-50">聆怀若水,应语如风;纾难化困,践诺成春</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex-grow max-w-2xl">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<div className="flex items-center ">
|
||||
{!isAuthenticated ? (
|
||||
<Link
|
||||
to="/auth"
|
||||
|
@ -33,6 +34,7 @@ focus:outline-none focus:ring-2
|
|||
focus:ring-[#8EADD4] focus:ring-offset-2
|
||||
focus:ring-offset-[#13294B]"
|
||||
aria-label="Login">
|
||||
|
||||
<UserOutlined
|
||||
className="h-5 w-5 transition-transform
|
||||
group-hover:scale-110 group-hover:rotate-12"></UserOutlined>
|
||||
|
|
|
@ -15,7 +15,7 @@ export function MainLayout() {
|
|||
|
||||
{/* 主要内容区域 */}
|
||||
<main className="min-h-screen bg-slate-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className=" mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
@ -1,23 +1,21 @@
|
|||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { useState, useCallback } from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
export const SearchBar = ({ onSearch }: SearchBarProps) => {
|
||||
export const SearchBar = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const navigate = useNavigate()
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((value: string) => {
|
||||
onSearch?.(value);
|
||||
navigate(`/letter-list?keyword=${value}`, { replace: true })
|
||||
}, 300),
|
||||
[onSearch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 max-w-xl mx-12">
|
||||
<div className="flex-1 max-w-xl ">
|
||||
<div className="group relative">
|
||||
<input
|
||||
type="search"
|
||||
|
@ -26,18 +24,18 @@ export const SearchBar = ({ onSearch }: SearchBarProps) => {
|
|||
setQuery(e.target.value);
|
||||
debouncedSearch(e.target.value);
|
||||
}}
|
||||
placeholder="Search letters, announcements, policies..."
|
||||
placeholder="搜索信件..."
|
||||
className="w-full rounded-lg border border-transparent bg-[#0B1A32]
|
||||
px-4 py-2.5 pl-10 text-white placeholder-[#8EADD4]
|
||||
transition-all duration-300 focus:border-[#8EADD4]
|
||||
focus:outline-none focus:ring-1 focus:ring-[#8EADD4]"
|
||||
aria-label="Search"
|
||||
aria-label="搜索"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
<MagnifyingGlassIcon
|
||||
className="absolute left-3 top-3 h-5 w-5 text-[#8EADD4]
|
||||
transition-colors group-hover:text-white"
|
||||
transition-colors group-hover:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
|
@ -4,13 +4,11 @@ import { DownloadOutlined } from "@ant-design/icons";
|
|||
import { PostDto } from "@nice/common";
|
||||
import { env } from "@web/src/env";
|
||||
import { getFileIcon } from "./utils";
|
||||
|
||||
import { formatFileSize } from '@nice/utils';
|
||||
export default function PostResources({ post }: { post: PostDto }) {
|
||||
const { resources } = useMemo(() => {
|
||||
if (!post?.resources) return { resources: [] };
|
||||
|
||||
const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
|
||||
|
||||
const sortedResources = post.resources
|
||||
.map((resource) => ({
|
||||
...resource,
|
||||
|
@ -93,8 +91,8 @@ export default function PostResources({ post }: { post: PostDto }) {
|
|||
{resource.url.split(".").pop()?.toUpperCase()}文件
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{resource.metadata.size &&
|
||||
`${(resource.metadata.size / 1024 / 1024).toFixed(1)}MB`}
|
||||
{resource.meta.size &&
|
||||
formatFileSize(resource.meta.size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,108 +7,114 @@ import { SearchOutlined } from "@ant-design/icons";
|
|||
import debounce from "lodash/debounce";
|
||||
import { postDetailSelect } from "@nice/common";
|
||||
export default function LetterList({
|
||||
params,
|
||||
params,
|
||||
search = ''
|
||||
}: {
|
||||
params: NonVoid<RouterInputs["post"]["findManyWithPagination"]>;
|
||||
search?: string,
|
||||
params: NonVoid<RouterInputs["post"]["findManyWithPagination"]>;
|
||||
}) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState<string | undefined>('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
useEffect(() => {
|
||||
|
||||
const { data, isLoading } = api.post.findManyWithPagination.useQuery({
|
||||
page: currentPage,
|
||||
pageSize: params.pageSize,
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: searchText,
|
||||
},
|
||||
},
|
||||
],
|
||||
...params?.where,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
select: {
|
||||
...postDetailSelect,
|
||||
...params.select,
|
||||
},
|
||||
});
|
||||
setKeyword(search || '')
|
||||
}, [search])
|
||||
const { data, isLoading } = api.post.findManyWithPagination.useQuery({
|
||||
page: currentPage,
|
||||
pageSize: params.pageSize,
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: keyword,
|
||||
},
|
||||
},
|
||||
],
|
||||
...params?.where,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
select: {
|
||||
...postDetailSelect,
|
||||
...params.select,
|
||||
},
|
||||
});
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setSearchText(value);
|
||||
setCurrentPage(1);
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, [debouncedSearch]);
|
||||
const handleSearch = (value: string) => {
|
||||
debouncedSearch(value);
|
||||
};
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setKeyword(value);
|
||||
setCurrentPage(1);
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, [debouncedSearch]);
|
||||
const handleSearch = (value: string) => {
|
||||
debouncedSearch(value);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
// Scroll to top when page changes
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
// Scroll to top when page changes
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search Bar */}
|
||||
<div className="p-6 transition-all ">
|
||||
<Input
|
||||
variant="filled"
|
||||
className="w-full"
|
||||
placeholder="搜索信件标题..."
|
||||
allowClear
|
||||
size="large"
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search Bar */}
|
||||
<div className="p-6 transition-all ">
|
||||
<Input
|
||||
value={keyword}
|
||||
variant="filled"
|
||||
className="w-full"
|
||||
placeholder="搜索信件标题..."
|
||||
allowClear
|
||||
size="large"
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-grow px-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center pt-6">
|
||||
<Spin size="large"></Spin>
|
||||
</div>
|
||||
) : data?.items.length ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-4 mb-6">
|
||||
{data.items.map((letter: any) => (
|
||||
<LetterCard key={letter.id} letter={letter} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center pb-6">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={data.totalCount}
|
||||
pageSize={params.pageSize}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center items-center pt-6">
|
||||
<Empty
|
||||
description={
|
||||
searchText ? "未找到相关信件" : "暂无信件"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Content Area */}
|
||||
<div className="flex-grow px-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center pt-6">
|
||||
<Spin size="large"></Spin>
|
||||
</div>
|
||||
) : data?.items.length ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-4 mb-6">
|
||||
{data.items.map((letter: any) => (
|
||||
<LetterCard key={letter.id} letter={letter} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center pb-6">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={data.totalCount}
|
||||
pageSize={params.pageSize}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center items-center pt-6">
|
||||
<Empty
|
||||
description={
|
||||
keyword ? "未找到相关信件" : "暂无信件"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ export function useTusUpload() {
|
|||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
size: file.size as any
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const progress = Number(
|
||||
|
|
|
@ -28,7 +28,7 @@ model Term {
|
|||
id String @id @default(cuid())
|
||||
name String
|
||||
// posts Post[]
|
||||
posts Post[] @relation("post_term")
|
||||
posts Post[] @relation("post_term")
|
||||
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
|
||||
taxonomyId String? @map("taxonomy_id")
|
||||
order Float? @map("order")
|
||||
|
@ -70,14 +70,13 @@ model TermAncestry {
|
|||
}
|
||||
|
||||
model Staff {
|
||||
id String @id @default(cuid())
|
||||
showname String? @map("showname")
|
||||
username String @unique @map("username")
|
||||
avatar String? @map("avatar")
|
||||
password String? @map("password")
|
||||
|
||||
phoneNumber String? @unique @map("phone_number")
|
||||
id String @id @default(cuid())
|
||||
showname String? @map("showname")
|
||||
username String @unique @map("username")
|
||||
avatar String? @map("avatar")
|
||||
password String? @map("password")
|
||||
|
||||
phoneNumber String? @unique @map("phone_number")
|
||||
|
||||
domainId String? @map("domain_id")
|
||||
deptId String? @map("dept_id")
|
||||
|
@ -189,23 +188,23 @@ model AppConfig {
|
|||
|
||||
model Post {
|
||||
// 字符串类型字段
|
||||
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
|
||||
type String? // 帖子类型,可为空
|
||||
state String? // 状态 : 未读、处理中、已回答
|
||||
title String? // 帖子标题,可为空
|
||||
content String? // 帖子内容,可为空
|
||||
domainId String? @map("domain_id")
|
||||
terms Term[] @relation("post_term")
|
||||
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
|
||||
type String? // 帖子类型,可为空
|
||||
state String? // 状态 : 未读、处理中、已回答
|
||||
title String? // 帖子标题,可为空
|
||||
content String? // 帖子内容,可为空
|
||||
domainId String? @map("domain_id")
|
||||
terms Term[] @relation("post_term")
|
||||
// 日期时间类型字段
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||
// 关系类型字段
|
||||
authorId String? @map("author_id")
|
||||
author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
|
||||
authorId String? @map("author_id")
|
||||
author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
|
||||
visits Visit[] // 访问记录,关联 Visit 模型
|
||||
views Int @default(0)
|
||||
likes Int @default(0)
|
||||
views Int @default(0)
|
||||
likes Int @default(0)
|
||||
receivers Staff[] @relation("post_receiver")
|
||||
parentId String? @map("parent_id")
|
||||
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
|
||||
|
@ -270,9 +269,7 @@ model Resource {
|
|||
type String? @map("type")
|
||||
fileId String? @unique
|
||||
url String?
|
||||
// 元数据
|
||||
metadata Json? @map("metadata")
|
||||
// 处理状态控制
|
||||
meta Json? @map("meta")
|
||||
status String?
|
||||
createdAt DateTime? @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
|
|
@ -130,6 +130,55 @@ export type PostComment = {
|
|||
avatar: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface BaseMetadata {
|
||||
size: number
|
||||
filetype: string
|
||||
filename: string
|
||||
extension: string
|
||||
modifiedAt: Date
|
||||
}
|
||||
/**
|
||||
* 图片特有元数据接口
|
||||
*/
|
||||
export interface ImageMetadata {
|
||||
width: number; // 图片宽度(px)
|
||||
height: number; // 图片高度(px)
|
||||
compressedUrl?: string;
|
||||
orientation?: number; // EXIF方向信息
|
||||
space?: string; // 色彩空间 (如: RGB, CMYK)
|
||||
hasAlpha?: boolean; // 是否包含透明通道
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频特有元数据接口
|
||||
*/
|
||||
export interface VideoMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频特有元数据接口
|
||||
*/
|
||||
export interface AudioMetadata {
|
||||
duration: number; // 音频时长(秒)
|
||||
bitrate?: number; // 比特率(bps)
|
||||
sampleRate?: number; // 采样率(Hz)
|
||||
channels?: number; // 声道数
|
||||
codec?: string; // 音频编码格式
|
||||
}
|
||||
|
||||
|
||||
export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata
|
||||
|
||||
export type ResourceDto = Resource & {
|
||||
meta: FileMetadata
|
||||
}
|
||||
export type PostDto = Post & {
|
||||
readed: boolean;
|
||||
liked: boolean;
|
||||
|
@ -138,7 +187,7 @@ export type PostDto = Post & {
|
|||
terms: TermDto[];
|
||||
author: StaffDto | undefined;
|
||||
receivers: StaffDto[];
|
||||
resources: Resource[];
|
||||
resources: ResourceDto[];
|
||||
perms?: {
|
||||
delete: boolean;
|
||||
// edit: boolean;
|
||||
|
|
|
@ -21,5 +21,11 @@ export function generateUniqueId(prefix?: string): string {
|
|||
// 如果提供了前缀,则添加前缀
|
||||
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`;
|
||||
};
|
||||
|
||||
export * from "./types"
|
Loading…
Reference in New Issue