This commit is contained in:
longdayi 2025-01-26 16:05:24 +08:00
parent 6af10a87a2
commit 29748c5ee4
24 changed files with 240 additions and 231 deletions

View File

@ -24,7 +24,7 @@ export class AuthService {
private readonly staffService: StaffService, private readonly staffService: StaffService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
) {} ) { }
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> { async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
try { try {
// 基础参数验证 // 基础参数验证
@ -169,11 +169,11 @@ export class AuthService {
password, password,
officerId, officerId,
showname, showname,
department: { department: deptId && {
connect: { id: deptId }, connect: { id: deptId },
}, },
domain: { domain: {
connect: { id: deptId }, connect: deptId && { id: deptId },
}, },
// domainId: data.deptId, // domainId: data.deptId,
meta: { meta: {

View File

@ -51,7 +51,7 @@ export class ResourceProcessingPipeline {
ResourceStatus.PROCESSED, ResourceStatus.PROCESSED,
); );
this.logger.log( this.logger.log(
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`, `资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.meta)}`,
); );
return { return {

View File

@ -1,5 +1,5 @@
import path, { dirname } from "path"; import path, { dirname } from "path";
import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types"; import { ResourceProcessor } from "../types";
import { Resource, ResourceStatus, db } from "@nice/common"; import { Resource, ResourceStatus, db } from "@nice/common";
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import fs from 'fs/promises'; import fs from 'fs/promises';
@ -15,9 +15,9 @@ export abstract class BaseProcessor implements ResourceProcessor {
subdirectory, subdirectory,
); );
fs.mkdir(outputDir, { recursive: true }).catch(err => this.logger.error(`Failed to create directory: ${err.message}`)); fs.mkdir(outputDir, { recursive: true }).catch(err => this.logger.error(`Failed to create directory: ${err.message}`));
return outputDir; return outputDir;
} }
} }
// //

View File

@ -1,6 +1,6 @@
import path from 'path'; import path from 'path';
import sharp from 'sharp'; 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 { Resource, ResourceStatus, db } from '@nice/common';
import { getUploadFilePath } from '@server/utils/file'; import { getUploadFilePath } from '@server/utils/file';
import { BaseProcessor } from './BaseProcessor'; import { BaseProcessor } from './BaseProcessor';
@ -10,10 +10,10 @@ export class ImageProcessor extends BaseProcessor {
super(); super();
} }
async process(resource: Resource): Promise<Resource> { async process(resource: ResourceDto): Promise<Resource> {
const { url } = resource; const { url } = resource;
const filepath = getUploadFilePath(url); 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/')) { if (!originMeta.filetype?.startsWith('image/')) {
this.logger.log(`Skipping non-image resource: ${resource.id}`); this.logger.log(`Skipping non-image resource: ${resource.id}`);
return resource; return resource;
@ -47,7 +47,7 @@ export class ImageProcessor extends BaseProcessor {
const updatedResource = await db.resource.update({ const updatedResource = await db.resource.update({
where: { id: resource.id }, where: { id: resource.id },
data: { data: {
metadata: { meta: {
...originMeta, ...originMeta,
...imageMeta, ...imageMeta,
}, },

View File

@ -1,7 +1,6 @@
import path, { dirname } from 'path'; import path, { dirname } from 'path';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types'; import { Resource, ResourceStatus, db, FileMetadata, VideoMetadata, ResourceDto } from '@nice/common';
import { Resource, ResourceStatus, db } from '@nice/common';
import { getUploadFilePath } from '@server/utils/file'; import { getUploadFilePath } from '@server/utils/file';
import fs from 'fs/promises'; import fs from 'fs/promises';
import sharp from 'sharp'; import sharp from 'sharp';
@ -11,14 +10,14 @@ export class VideoProcessor extends BaseProcessor {
constructor() { constructor() {
super(); super();
} }
async process(resource: Resource): Promise<Resource> { async process(resource: ResourceDto): Promise<Resource> {
const { url } = resource; const { url } = resource;
const filepath = getUploadFilePath(url); const filepath = getUploadFilePath(url);
this.logger.log( this.logger.log(
`Processing video for resource ID: ${resource.id}, File ID: ${url}`, `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/')) { if (!originMeta.filetype?.startsWith('video/')) {
this.logger.log(`Skipping non-video resource: ${resource.id}`); this.logger.log(`Skipping non-video resource: ${resource.id}`);
return resource; return resource;
@ -39,7 +38,7 @@ export class VideoProcessor extends BaseProcessor {
const updatedResource = await db.resource.update({ const updatedResource = await db.resource.update({
where: { id: resource.id }, where: { id: resource.id },
data: { data: {
metadata: { meta: {
...originMeta, ...originMeta,
...videoMeta, ...videoMeta,
}, },

View File

@ -9,47 +9,3 @@ export interface ProcessResult {
error?: Error 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

View File

@ -44,6 +44,7 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
...data, ...data,
password: await argon2.hash((data.password || '123456') as string), password: await argon2.hash((data.password || '123456') as string),
}; };
const result = await super.create({ ...args, data: createData }); const result = await super.create({ ...args, data: createData });
this.emitDataChangedEvent(result, CrudOperation.CREATED); this.emitDataChangedEvent(result, CrudOperation.CREATED);
return result; return result;

View File

@ -68,13 +68,12 @@ export class TusService implements OnModuleInit {
) { ) {
try { try {
const fileId = this.getFileId(upload.id); const fileId = this.getFileId(upload.id);
await this.resourceService.create({ await this.resourceService.create({
data: { data: {
title: getFilenameWithoutExt(upload.metadata.filename), title: getFilenameWithoutExt(upload.metadata.filename),
fileId, // 移除最后的文件名 fileId, // 移除最后的文件名
url: upload.id, url: upload.id,
metadata: upload.metadata, meta: upload.metadata,
status: ResourceStatus.UPLOADING, status: ResourceStatus.UPLOADING,
}, },
}); });

View File

@ -12,7 +12,7 @@
VITE_APP_VERSION: "$VITE_APP_VERSION", VITE_APP_VERSION: "$VITE_APP_VERSION",
}; };
</script> </script>
<title>fhmooc</title> <title>首长机关信箱</title>
</head> </head>
<body> <body>

View File

@ -48,10 +48,10 @@ const AuthPage: React.FC = () => {
transition={{ delay: 0.2, duration: 0.5 }} transition={{ delay: 0.2, duration: 0.5 }}
> >
<div className="text-4xl text-white mb-4 font-serif"> <div className="text-4xl text-white mb-4 font-serif">
</div> </div>
<Paragraph className="text-lg mb-8 text-blue-100 leading-relaxed text-justify"> <Paragraph className="text-lg mb-8 text-blue-100 leading-relaxed text-justify">
</Paragraph> </Paragraph>
{showLogin && ( {showLogin && (
<Button <Button

View File

@ -1,15 +1,17 @@
import LetterList from "@web/src/components/models/post/list/LetterList"; import LetterList from "@web/src/components/models/post/list/LetterList";
import { Header } from "./Header"; import { Header } from "./Header";
import { useSearchParams } from "react-router-dom";
export default function LetterListPage() { export default function LetterListPage() {
const [params] = useSearchParams()
const keyword = params.get("keyword")
return ( return (
// 添加 flex flex-col 使其成为弹性布局容器 // 添加 flex flex-col 使其成为弹性布局容器
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-slate-200 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 /> <Header />
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */} {/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
<LetterList params={{ <LetterList search={keyword} params={{
where: { where: {
isPublic: true isPublic: true
} }

View File

@ -30,7 +30,7 @@ export default function LetterProgressPage() {
<div <div
className=" shadow-elegant border-2 border-white rounded-xl bg-slate-200"> className=" shadow-elegant border-2 border-white rounded-xl bg-slate-200">
<ProgressHeader></ProgressHeader> <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"> <div className="flex gap-4">
<Input <Input
variant="filled" variant="filled"

View File

@ -1,3 +0,0 @@
export default function LetterSearchPage() {
return <>Search</>
}

View File

@ -36,6 +36,7 @@ export default function WriteLetterPage() {
contains: searchQuery, contains: searchQuery,
}, },
}, },
], ],
}, },
orderBy: { orderBy: {
@ -55,9 +56,8 @@ export default function WriteLetterPage() {
return ( return (
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200"> <div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
<WriteHeader term={getTerm(termId)} /> <WriteHeader term={getTerm(termId)} />
<div className="container mx-auto px-4 py-8"> <div className=" mx-auto p-6">
<div className="mb-8 space-y-4"> <div className="mb-4 space-y-4">
{/* Search and Filter Section */}
<div className="flex flex-col md:flex-row gap-4 items-center"> <div className="flex flex-col md:flex-row gap-4 items-center">
<DepartmentSelect <DepartmentSelect
variant="filled" variant="filled"
@ -80,7 +80,6 @@ export default function WriteLetterPage() {
size="large" size="large"
/> />
</div> </div>
{error && ( {error && (
<Alert <Alert
message="加载失败" message="加载失败"
@ -90,7 +89,6 @@ export default function WriteLetterPage() {
/> />
)} )}
</div> </div>
<AnimatePresence> <AnimatePresence>
{isLoading ? ( {isLoading ? (
<div className="flex justify-center items-center py-12"> <div className="flex justify-center items-center py-12">

View File

@ -12,7 +12,7 @@ export function Footer() {
</h3> </h3>
<p className="text-gray-400 text-xs italic"> <p className="text-gray-400 text-xs italic">
</p> </p>
</div> </div>

View File

@ -1,26 +1,27 @@
import { Link, NavLink } from "react-router-dom"; import { Link, NavLink, useNavigate } from "react-router-dom";
import { memo } from "react"; import { memo } from "react";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
import Navigation from "./navigation"; import Navigation from "./navigation";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { UserOutlined } from "@ant-design/icons"; import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu/usermenu"; import { UserMenu } from "../element/usermenu/usermenu";
import SineWavesCanvas from "../../animation/sine-wave";
interface HeaderProps { export const Header = memo(function Header() {
onSearch?: (query: string) => void;
}
export const Header = memo(function Header({ onSearch }: HeaderProps) {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
return ( return (
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg"> <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=" mx-auto px-4">
<div className="py-3"> <div className="py-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className=" text-xl font-bold"></div> <div className="">
<div className="flex-grow max-w-2xl"> <span className="text-xl font-bold"></span>
<SearchBar onSearch={onSearch} /> <p className=" text-sm text-secondary-50">怀</p>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex-grow max-w-2xl">
<SearchBar />
</div>
<div className="flex items-center ">
{!isAuthenticated ? ( {!isAuthenticated ? (
<Link <Link
to="/auth" to="/auth"
@ -33,6 +34,7 @@ focus:outline-none focus:ring-2
focus:ring-[#8EADD4] focus:ring-offset-2 focus:ring-[#8EADD4] focus:ring-offset-2
focus:ring-offset-[#13294B]" focus:ring-offset-[#13294B]"
aria-label="Login"> aria-label="Login">
<UserOutlined <UserOutlined
className="h-5 w-5 transition-transform className="h-5 w-5 transition-transform
group-hover:scale-110 group-hover:rotate-12"></UserOutlined> group-hover:scale-110 group-hover:rotate-12"></UserOutlined>

View File

@ -15,7 +15,7 @@ export function MainLayout() {
{/* 主要内容区域 */} {/* 主要内容区域 */}
<main className="min-h-screen bg-slate-50"> <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 /> <Outlet />
</div> </div>
</main> </main>

View File

@ -1,23 +1,21 @@
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import debounce from "lodash/debounce"; 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 [query, setQuery] = useState("");
const navigate = useNavigate()
const debouncedSearch = useCallback( const debouncedSearch = useCallback(
debounce((value: string) => { debounce((value: string) => {
onSearch?.(value); navigate(`/letter-list?keyword=${value}`, { replace: true })
}, 300), }, 300),
[onSearch] []
); );
return ( return (
<div className="flex-1 max-w-xl mx-12"> <div className="flex-1 max-w-xl ">
<div className="group relative"> <div className="group relative">
<input <input
type="search" type="search"
@ -26,18 +24,18 @@ export const SearchBar = ({ onSearch }: SearchBarProps) => {
setQuery(e.target.value); setQuery(e.target.value);
debouncedSearch(e.target.value); debouncedSearch(e.target.value);
}} }}
placeholder="Search letters, announcements, policies..." placeholder="搜索信件..."
className="w-full rounded-lg border border-transparent bg-[#0B1A32] className="w-full rounded-lg border border-transparent bg-[#0B1A32]
px-4 py-2.5 pl-10 text-white placeholder-[#8EADD4] px-4 py-2.5 pl-10 text-white placeholder-[#8EADD4]
transition-all duration-300 focus:border-[#8EADD4] transition-all duration-300 focus:border-[#8EADD4]
focus:outline-none focus:ring-1 focus:ring-[#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] 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>
</div> </div>
); );
}; };

View File

@ -4,13 +4,11 @@ import { DownloadOutlined } from "@ant-design/icons";
import { PostDto } from "@nice/common"; import { PostDto } from "@nice/common";
import { env } from "@web/src/env"; import { env } from "@web/src/env";
import { getFileIcon } from "./utils"; import { getFileIcon } from "./utils";
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,
@ -93,8 +91,8 @@ export default function PostResources({ post }: { post: PostDto }) {
{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.metadata.size && {resource.meta.size &&
`${(resource.metadata.size / 1024 / 1024).toFixed(1)}MB`} formatFileSize(resource.meta.size)}
</span> </span>
</div> </div>
</div> </div>

View File

@ -7,108 +7,114 @@ import { SearchOutlined } from "@ant-design/icons";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { postDetailSelect } from "@nice/common"; import { postDetailSelect } from "@nice/common";
export default function LetterList({ export default function LetterList({
params, params,
search = ''
}: { }: {
params: NonVoid<RouterInputs["post"]["findManyWithPagination"]>; search?: string,
params: NonVoid<RouterInputs["post"]["findManyWithPagination"]>;
}) { }) {
const [searchText, setSearchText] = useState(""); const [keyword, setKeyword] = useState<string | undefined>('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
const { data, isLoading } = api.post.findManyWithPagination.useQuery({ setKeyword(search || '')
page: currentPage, }, [search])
pageSize: params.pageSize, const { data, isLoading } = api.post.findManyWithPagination.useQuery({
where: { page: currentPage,
OR: [ pageSize: params.pageSize,
{ where: {
title: { OR: [
contains: searchText, {
}, title: {
}, contains: keyword,
], },
...params?.where, },
}, ],
orderBy: { ...params?.where,
updatedAt: "desc", },
}, orderBy: {
select: { updatedAt: "desc",
...postDetailSelect, },
...params.select, select: {
}, ...postDetailSelect,
}); ...params.select,
},
});
const debouncedSearch = useMemo( const debouncedSearch = useMemo(
() => () =>
debounce((value: string) => { debounce((value: string) => {
setSearchText(value); setKeyword(value);
setCurrentPage(1); setCurrentPage(1);
}, 300), }, 300),
[] []
); );
// Cleanup debounce on unmount // Cleanup debounce on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
debouncedSearch.cancel(); debouncedSearch.cancel();
}; };
}, [debouncedSearch]); }, [debouncedSearch]);
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
debouncedSearch(value); debouncedSearch(value);
}; };
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
// Scroll to top when page changes // Scroll to top when page changes
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Search Bar */} {/* Search Bar */}
<div className="p-6 transition-all "> <div className="p-6 transition-all ">
<Input <Input
variant="filled" value={keyword}
className="w-full" variant="filled"
placeholder="搜索信件标题..." className="w-full"
allowClear placeholder="搜索信件标题..."
size="large" allowClear
onChange={(e) => handleSearch(e.target.value)} size="large"
prefix={<SearchOutlined className="text-gray-400" />} onChange={(e) => handleSearch(e.target.value)}
/> prefix={<SearchOutlined className="text-gray-400" />}
</div> />
</div>
{/* Content Area */} {/* Content Area */}
<div className="flex-grow px-6"> <div className="flex-grow px-6">
{isLoading ? ( {isLoading ? (
<div className="flex justify-center items-center pt-6"> <div className="flex justify-center items-center pt-6">
<Spin size="large"></Spin> <Spin size="large"></Spin>
</div> </div>
) : data?.items.length ? ( ) : data?.items.length ? (
<> <>
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-4 mb-6">
{data.items.map((letter: any) => ( {data.items.map((letter: any) => (
<LetterCard key={letter.id} letter={letter} /> <LetterCard key={letter.id} letter={letter} />
))} ))}
</div> </div>
<div className="flex justify-center pb-6"> <div className="flex justify-center pb-6">
<Pagination <Pagination
current={currentPage} current={currentPage}
total={data.totalCount} total={data.totalCount}
pageSize={params.pageSize} pageSize={params.pageSize}
onChange={handlePageChange} onChange={handlePageChange}
showSizeChanger={false} showSizeChanger={false}
showQuickJumper showQuickJumper
/> />
</div> </div>
</> </>
) : ( ) : (
<div className="flex flex-col justify-center items-center pt-6"> <div className="flex flex-col justify-center items-center pt-6">
<Empty <Empty
description={ description={
searchText ? "未找到相关信件" : "暂无信件" keyword ? "未找到相关信件" : "暂无信件"
} }
/> />
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@ -52,6 +52,7 @@ export function useTusUpload() {
metadata: { metadata: {
filename: file.name, filename: file.name,
filetype: file.type, filetype: file.type,
size: file.size as any
}, },
onProgress: (bytesUploaded, bytesTotal) => { onProgress: (bytesUploaded, bytesTotal) => {
const progress = Number( const progress = Number(

View File

@ -28,7 +28,7 @@ model Term {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
// posts Post[] // posts Post[]
posts Post[] @relation("post_term") posts Post[] @relation("post_term")
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id]) taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
taxonomyId String? @map("taxonomy_id") taxonomyId String? @map("taxonomy_id")
order Float? @map("order") order Float? @map("order")
@ -70,14 +70,13 @@ 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")
domainId String? @map("domain_id") domainId String? @map("domain_id")
deptId String? @map("dept_id") deptId String? @map("dept_id")
@ -189,23 +188,23 @@ model AppConfig {
model Post { model Post {
// 字符串类型字段 // 字符串类型字段
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值 id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
type String? // 帖子类型,可为空 type String? // 帖子类型,可为空
state String? // 状态 未读、处理中、已回答 state String? // 状态 未读、处理中、已回答
title String? // 帖子标题,可为空 title String? // 帖子标题,可为空
content String? // 帖子内容,可为空 content String? // 帖子内容,可为空
domainId String? @map("domain_id") domainId String? @map("domain_id")
terms Term[] @relation("post_term") terms Term[] @relation("post_term")
// 日期时间类型字段 // 日期时间类型字段
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @map("updated_at") updatedAt DateTime @map("updated_at")
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空 deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
// 关系类型字段 // 关系类型字段
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)
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 模型
@ -270,9 +269,7 @@ model Resource {
type String? @map("type") type String? @map("type")
fileId String? @unique fileId String? @unique
url String? url String?
// 元数据 meta Json? @map("meta")
metadata Json? @map("metadata")
// 处理状态控制
status String? status String?
createdAt DateTime? @default(now()) @map("created_at") createdAt DateTime? @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at") updatedAt DateTime? @updatedAt @map("updated_at")

View File

@ -130,6 +130,55 @@ export type PostComment = {
avatar: string; 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 & { export type PostDto = Post & {
readed: boolean; readed: boolean;
liked: boolean; liked: boolean;
@ -138,7 +187,7 @@ export type PostDto = Post & {
terms: TermDto[]; terms: TermDto[];
author: StaffDto | undefined; author: StaffDto | undefined;
receivers: StaffDto[]; receivers: StaffDto[];
resources: Resource[]; resources: ResourceDto[];
perms?: { perms?: {
delete: boolean; delete: boolean;
// edit: boolean; // edit: boolean;

View File

@ -21,5 +21,11 @@ export function generateUniqueId(prefix?: string): string {
// 如果提供了前缀,则添加前缀 // 如果提供了前缀,则添加前缀
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`;
};
export * from "./types" export * from "./types"