This commit is contained in:
ditiqi 2025-01-22 23:19:51 +08:00
parent 1be08ce242
commit ce65bea0ed
25 changed files with 1717 additions and 777 deletions

View File

@ -1,8 +1,8 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { env } from '@server/env';
@ -12,26 +12,21 @@ import { extractTokenFromHeader } from './utils';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload: JwtPayload = await this.jwtService.verifyAsync(
token,
{
secret: env.JWT_SECRET
}
);
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
}
try {
const payload: JwtPayload = await this.jwtService.verifyAsync(token, {
secret: env.JWT_SECRET,
});
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
}

View File

@ -17,8 +17,8 @@ export class StaffRouter {
constructor(
private readonly trpc: TrpcService,
private readonly staffService: StaffService,
private readonly staffRowService: StaffRowService
) { }
private readonly staffRowService: StaffRowService,
) {}
router = this.trpc.router({
create: this.trpc.procedure
@ -35,7 +35,7 @@ export class StaffRouter {
updateUserDomain: this.trpc.protectProcedure
.input(
z.object({
domainId: z.string()
domainId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
@ -72,8 +72,10 @@ export class StaffRouter {
const { staff } = ctx;
return await this.staffService.findFirst(input);
}),
updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => {
return this.staffService.updateOrder(input)
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.staffService.updateOrder(input);
}),
});
}

View File

@ -14,7 +14,6 @@ import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable()
export class StaffService extends BaseService<Prisma.StaffDelegate> {
constructor(private readonly departmentService: DepartmentService) {
super(db, ObjectType.STAFF, true);
}
@ -25,7 +24,10 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
*/
async findByDept(data: z.infer<typeof StaffMethodSchema.findByDept>) {
const { deptId, domainId } = data;
const childDepts = await this.departmentService.getDescendantIds(deptId, true);
const childDepts = await this.departmentService.getDescendantIds(
deptId,
true,
);
const result = await db.staff.findMany({
where: {
deptId: { in: childDepts },
@ -50,7 +52,9 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
await this.validateUniqueFields(data, where.id);
const updateData = {
...data,
...(data.password && { password: await argon2.hash(data.password as string) })
...(data.password && {
password: await argon2.hash(data.password as string),
}),
};
const result = await super.update({ ...args, data: updateData });
this.emitDataChangedEvent(result, CrudOperation.UPDATED);
@ -58,17 +62,26 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
}
private async validateUniqueFields(data: any, excludeId?: string) {
const uniqueFields = [
{ field: 'officerId', errorMsg: (val: string) => `证件号为${val}的用户已存在` },
{ field: 'phoneNumber', errorMsg: (val: string) => `手机号为${val}的用户已存在` },
{ field: 'username', errorMsg: (val: string) => `帐号为${val}的用户已存在` }
{
field: 'officerId',
errorMsg: (val: string) => `证件号为${val}的用户已存在`,
},
{
field: 'phoneNumber',
errorMsg: (val: string) => `手机号为${val}的用户已存在`,
},
{
field: 'username',
errorMsg: (val: string) => `帐号为${val}的用户已存在`,
},
];
for (const { field, errorMsg } of uniqueFields) {
if (data[field]) {
const count = await db.staff.count({
where: {
[field]: data[field],
...(excludeId && { id: { not: excludeId } })
}
...(excludeId && { id: { not: excludeId } }),
},
});
if (count > 0) {
throw new Error(errorMsg(data[field]));
@ -77,9 +90,8 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
}
}
private emitDataChangedEvent(data: any, operation: CrudOperation) {
EventBus.emit("dataChanged", {
EventBus.emit('dataChanged', {
type: this.objectType,
operation,
data,
@ -87,10 +99,10 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
}
/**
* DomainId
* @param data domainId对象
* @returns
*/
* DomainId
* @param data domainId对象
* @returns
*/
async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) {
let { domainId } = data;
if (staff.domainId !== domainId) {
@ -107,7 +119,6 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
}
}
// /**
// * 根据关键词或ID集合查找员工
// * @param data 包含关键词、域ID和ID集合的对象
@ -176,5 +187,4 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
// return combinedResults;
// }
}

View File

@ -1,7 +1,7 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { Server, Uid, Upload } from "@nice/tus"
import { Server, Uid, Upload } from '@nice/tus';
import { FileStore } from '@nice/tus';
import { Request, Response } from "express"
import { Request, Response } from 'express';
import { db, ResourceStatus } from '@nice/common';
import { getFilenameWithoutExt } from '@server/utils/file';
import { ResourceService } from '@server/models/resource/resource.service';
@ -12,104 +12,120 @@ import { QueueJobType } from '@server/queue/types';
import { nanoid } from 'nanoid-cjs';
import { slugify } from 'transliteration';
const FILE_UPLOAD_CONFIG = {
directory: process.env.UPLOAD_DIR,
maxSizeBytes: 20_000_000_000, // 20GB
expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours
directory: process.env.UPLOAD_DIR,
maxSizeBytes: 20_000_000_000, // 20GB
expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours
};
@Injectable()
export class TusService implements OnModuleInit {
private readonly logger = new Logger(TusService.name);
private tusServer: Server;
constructor(private readonly resourceService: ResourceService,
@InjectQueue("file-queue") private fileQueue: Queue
) { }
onModuleInit() {
this.initializeTusServer();
this.setupTusEventHandlers();
}
private initializeTusServer() {
this.tusServer = new Server({
namingFunction(req, metadata) {
const safeFilename = slugify(metadata.filename);
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const uniqueId = nanoid(10);
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
},
path: '/upload',
datastore: new FileStore({
directory: FILE_UPLOAD_CONFIG.directory,
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod
}),
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
postReceiveInterval: 1000,
getFileIdFromRequest: (req, lastPath) => {
const match = req.url.match(/\/upload\/(.+)/);
return match ? match[1] : lastPath;
}
});
}
private readonly logger = new Logger(TusService.name);
private tusServer: Server;
constructor(
private readonly resourceService: ResourceService,
@InjectQueue('file-queue') private fileQueue: Queue,
) {}
onModuleInit() {
this.initializeTusServer();
this.setupTusEventHandlers();
}
private initializeTusServer() {
this.tusServer = new Server({
namingFunction(req, metadata) {
const safeFilename = slugify(metadata.filename);
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const uniqueId = nanoid(10);
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
},
path: '/upload',
datastore: new FileStore({
directory: FILE_UPLOAD_CONFIG.directory,
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod,
}),
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
postReceiveInterval: 1000,
getFileIdFromRequest: (req, lastPath) => {
const match = req.url.match(/\/upload\/(.+)/);
return match ? match[1] : lastPath;
},
});
}
private setupTusEventHandlers() {
this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this));
this.tusServer.on("POST_FINISH", this.handleUploadFinish.bind(this));
private setupTusEventHandlers() {
this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this));
this.tusServer.on('POST_FINISH', this.handleUploadFinish.bind(this));
}
private getFileId(uploadId: string) {
return uploadId.replace(/\/[^/]+$/, '');
}
private async handleUploadCreate(
req: Request,
res: Response,
upload: Upload,
url: string,
) {
try {
const fileId = this.getFileId(upload.id);
const filename = upload.metadata.filename;
await this.resourceService.create({
data: {
title: getFilenameWithoutExt(upload.metadata.filename),
fileId, // 移除最后的文件名
url: upload.id,
metadata: upload.metadata,
status: ResourceStatus.UPLOADING,
},
});
} catch (error) {
this.logger.error('Failed to create resource during upload', error);
}
private getFileId(uploadId: string) {
return uploadId.replace(/\/[^/]+$/, '')
}
private async handleUploadCreate(req: Request, res: Response, upload: Upload, url: string) {
try {
}
const fileId = this.getFileId(upload.id)
const filename = upload.metadata.filename
await this.resourceService.create({
data: {
title: getFilenameWithoutExt(upload.metadata.filename),
fileId, // 移除最后的文件名
url: upload.id,
metadata: upload.metadata,
status: ResourceStatus.UPLOADING
}
});
} catch (error) {
this.logger.error('Failed to create resource during upload', error);
}
private async handleUploadFinish(
req: Request,
res: Response,
upload: Upload,
) {
try {
const resource = await this.resourceService.update({
where: { fileId: this.getFileId(upload.id) },
data: { status: ResourceStatus.UPLOADED },
});
this.fileQueue.add(
QueueJobType.FILE_PROCESS,
{ resource },
{ jobId: resource.id },
);
this.logger.log(`Upload finished ${resource.url}`);
} catch (error) {
this.logger.error('Failed to update resource after upload', error);
}
}
private async handleUploadFinish(req: Request, res: Response, upload: Upload) {
try {
const resource = await this.resourceService.update({
where: { fileId: this.getFileId(upload.id) },
data: { status: ResourceStatus.UPLOADED }
});
this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id })
this.logger.log(`Upload finished ${resource.url}`);
} catch (error) {
this.logger.error('Failed to update resource after upload', error);
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupExpiredUploads() {
try {
// Delete incomplete uploads older than 24 hours
const deletedResources = await db.resource.deleteMany({
where: {
createdAt: {
lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod),
},
status: ResourceStatus.UPLOADING,
},
});
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
this.logger.log(
`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`,
);
} catch (error) {
this.logger.error('Expired uploads cleanup failed', error);
}
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupExpiredUploads() {
try {
// Delete incomplete uploads older than 24 hours
const deletedResources = await db.resource.deleteMany({
where: {
createdAt: { lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod) },
status: ResourceStatus.UPLOADING
}
});
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
this.logger.log(`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`);
} catch (error) {
this.logger.error('Expired uploads cleanup failed', error);
}
}
async handleTus(req: Request, res: Response) {
return this.tusServer.handle(req, res);
}
}
async handleTus(req: Request, res: Response) {
return this.tusServer.handle(req, res);
}
}

View File

@ -1,55 +1,54 @@
import {
Controller,
All,
Req,
Res,
Get,
Post,
Patch,
Param,
Delete,
Head,
Options,
Controller,
All,
Req,
Res,
Get,
Post,
Patch,
Param,
Delete,
Head,
Options,
} from '@nestjs/common';
import { Request, Response } from "express"
import { Request, Response } from 'express';
import { TusService } from './tus.service';
@Controller('upload')
export class UploadController {
constructor(private readonly tusService: TusService) { }
// @Post()
// async handlePost(@Req() req: Request, @Res() res: Response) {
// return this.tusService.handleTus(req, res);
// }
constructor(private readonly tusService: TusService) {}
// @Post()
// async handlePost(@Req() req: Request, @Res() res: Response) {
// return this.tusService.handleTus(req, res);
// }
@Options()
async handleOptions(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Options()
async handleOptions(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Head()
async handleHead(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Head()
async handleHead(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Post()
async handlePost(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Get('/*')
async handleGet(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Post()
async handlePost(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Get("/*")
async handleGet(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Patch('/*')
async handlePatch(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Patch("/*")
async handlePatch(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
// Keeping the catch-all method as a fallback
@All()
async handleUpload(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
}
// Keeping the catch-all method as a fallback
@All()
async handleUpload(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
}

View File

@ -0,0 +1,11 @@
import { useParams } from "react-router-dom";
export default function LetterDetailPage() {
const { id } = useParams();
return (
<>
<div>{id}</div>
</>
);
}

View File

@ -0,0 +1,16 @@
import { motion } from "framer-motion";
import LetterEditorLayout from "@web/src/components/models/post/LetterEditor/layout/LetterEditorLayout";
export default function EditorLetterPage() {
return (
<div className="min-h-screen ">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6, ease: "easeOut" }}>
<LetterEditorLayout />
</motion.div>
</div>
);
}

View File

@ -1,28 +1,31 @@
import { Leader } from "./types";
export const leaders: Leader[] = [
{
id: '1',
name: 'John Mitchell',
rank: 'General',
division: 'Air Combat Command',
imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain',
email: 'j.mitchell@af.mil',
phone: '(555) 123-4567',
office: 'Pentagon, Wing A-123',
},
{
id: '2',
name: 'Sarah Williams',
rank: 'Colonel',
division: 'Air Force Space Command',
imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain',
},
{
id: '3',
name: 'Michael Roberts',
rank: 'Major General',
division: 'Air Mobility Command',
imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain',
},
];
{
id: "1",
name: "John Mitchell",
rank: "General",
division: "Air Combat Command",
imageUrl:
"https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain",
email: "j.mitchell@af.mil",
phone: "(555) 123-4567",
office: "Pentagon, Wing A-123",
},
{
id: "2",
name: "Sarah Williams",
rank: "Colonel",
division: "Air Force Space Command",
imageUrl:
"https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain",
},
{
id: "3",
name: "Michael Roberts",
rank: "Major General",
division: "Air Mobility Command",
imageUrl:
"https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain",
},
];

View File

@ -1,42 +1,44 @@
import React from "react";
interface QuillCharCounterProps {
currentCount: number;
maxLength?: number;
minLength?: number;
currentCount: number;
maxLength?: number;
minLength?: number;
}
const QuillCharCounter: React.FC<QuillCharCounterProps> = ({
currentCount,
maxLength,
minLength = 0
currentCount,
maxLength,
minLength = 0,
}) => {
const getStatusColor = () => {
if (currentCount > (maxLength || Infinity)) return 'text-red-500';
if (currentCount < minLength) return 'text-amber-500';
return 'text-gray-500';
};
const getStatusColor = () => {
if (currentCount > (maxLength || Infinity)) return "text-red-500";
if (currentCount < minLength) return "text-amber-500";
return "text-gray-500";
};
return (
<div className={`
return (
<div
className={`
flex items-center justify-end gap-1
px-3 py-1.5 text-sm
${getStatusColor()}
transition-colors duration-200
`}>
<span className="font-medium tabular-nums">{currentCount}</span>
{maxLength && (
<>
<span>/</span>
<span className="tabular-nums">{maxLength}</span>
</>
)}
<span></span>
{minLength > 0 && currentCount < minLength && (
<span className="ml-2 text-amber-500">
{minLength}
</span>
)}
</div>
);
<span className="font-medium tabular-nums">{currentCount}</span>
{maxLength && (
<>
<span>/</span>
<span className="tabular-nums">{maxLength}</span>
</>
)}
<span></span>
{minLength > 0 && currentCount < minLength && (
<span className="ml-2 text-amber-500">
{minLength}
</span>
)}
</div>
);
};
export default QuillCharCounter
export default QuillCharCounter;

View File

@ -0,0 +1,127 @@
import { useFormContext } from "react-hook-form";
import React, { useState } from "react";
import { motion } from "framer-motion";
import { CheckIcon } from "@heroicons/react/24/outline";
export interface FormCheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
name: string;
label?: string;
viewMode?: boolean;
}
export function FormCheckbox({
name,
label,
className,
viewMode = false,
...restProps
}: FormCheckboxProps) {
const [isHovered, setIsHovered] = useState(false);
const {
register,
formState: { errors },
watch,
setValue,
} = useFormContext();
const value = watch(name);
const error = errors[name]?.message as string;
const handleToggle = () => {
setValue(name, !value);
};
const checkboxClasses = `
w-6 h-6 rounded border
transition-all duration-200 ease-out
flex items-center justify-center
cursor-pointer
${
value
? "bg-[#00308F] border-[#00308F]"
: "bg-white border-gray-200 hover:border-gray-300"
}
${error ? "border-red-500" : ""}
${className || ""}
`;
const labelClasses = `
text-sm font-medium
${error ? "text-red-500" : "text-gray-700"}
${value ? "text-[#00308F]" : ""}
transition-colors duration-200
`;
const viewModeClasses = `
w-full text-gray-700 hover:text-[#00308F] min-h-[48px]
flex items-center gap-2 relative cursor-pointer group
transition-all duration-200 ease-out select-none
`;
const renderViewMode = () => (
<div className={viewModeClasses}>
<div className={checkboxClasses}>
{value && <CheckIcon className="w-4 h-4 text-white" />}
</div>
<span className="font-medium">{label}</span>
</div>
);
const renderEditMode = () => (
<motion.div
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="relative inline-flex items-center gap-3">
<input
type="checkbox"
className="sr-only"
{...register(name)}
{...restProps}
/>
<motion.div
onClick={handleToggle}
className={checkboxClasses}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}>
{value && (
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{
duration: 0.2,
ease: [0.4, 0, 0.2, 1], // 添加缓动函数使动画更流畅
}}>
<CheckIcon className="w-4 h-4 text-white" />
</motion.div>
)}
</motion.div>
{label && (
<motion.label
onClick={handleToggle}
className={`${labelClasses} cursor-pointer`}
whileHover={{ x: 2 }}>
{label}
</motion.label>
)}
{error && (
<motion.span
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute left-0 top-full mt-1 text-xs text-red-500">
{error}
</motion.span>
)}
</motion.div>
);
return (
<div className="flex flex-col gap-2">
{viewMode ? renderViewMode() : renderEditMode()}
</div>
);
}

View File

@ -1,148 +1,161 @@
import { useFormContext } from 'react-hook-form';
import { useRef, useState } from 'react';
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline';
import FormError from './FormError';
import { Button } from '../element/Button';
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
name: string;
label?: string;
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
rows?: number;
viewMode?: boolean;
import { useFormContext } from "react-hook-form";
import React, { useRef, useState } from "react";
import { CheckIcon, PencilIcon, XMarkIcon } from "@heroicons/react/24/outline";
import FormError from "./FormError";
import { Button } from "../element/Button";
export interface FormInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"type"
> {
name: string;
label?: string;
type?:
| "text"
| "textarea"
| "password"
| "email"
| "number"
| "tel"
| "url"
| "search"
| "date"
| "time"
| "datetime-local";
rows?: number;
viewMode?: boolean;
}
export function FormInput({
name,
label,
type = 'text',
rows = 4,
className,
viewMode = false, // 默认为编辑模式
...restProps
name,
label,
type = "text",
rows = 4,
className,
viewMode = false, // 默认为编辑模式
...restProps
}: FormInputProps) {
const [isFocused, setIsFocused] = useState(false);
const [isEditing, setIsEditing] = useState(!viewMode);
const inputWrapper = useRef<HTMLDivElement>(null);
const {
register,
formState: { errors },
watch,
setValue,
trigger, // Add trigger from useFormContext
} = useFormContext();
const handleBlur = async () => {
setIsFocused(false);
await trigger(name); // Trigger validation for this field
if (viewMode) {
setIsEditing(false)
}
};
const value = watch(name);
const error = errors[name]?.message as string;
const isValid = value && !error;
const inputClasses = `
const [isFocused, setIsFocused] = useState(false);
const [isEditing, setIsEditing] = useState(!viewMode);
const inputWrapper = useRef<HTMLDivElement>(null);
const {
register,
formState: { errors },
watch,
setValue,
trigger, // Add trigger from useFormContext
} = useFormContext();
const handleBlur = async () => {
setIsFocused(false);
await trigger(name); // Trigger validation for this field
if (viewMode) {
setIsEditing(false);
}
};
const value = watch(name);
const error = errors[name]?.message as string;
const isValid = value && !error;
const inputClasses = `
w-full rounded-lg border bg-white px-4 py-2 outline-none
transition-all duration-200 ease-out placeholder:text-gray-400
${error
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200'
: 'border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100'
}
${isFocused ? 'ring-2 ring-opacity-50' : ''}
${className || ''}
${
error
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200"
: "border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
}
${isFocused ? "ring-2 ring-opacity-50" : ""}
${className || ""}
`;
const viewModeClasses = `
const viewModeClasses = `
w-full text-gray-700 hover:text-blue-600 min-h-[48px]
flex items-center gap-2 relative cursor-pointer group
transition-all duration-200 ease-out select-none
`;
const InputElement = type === 'textarea' ? 'textarea' : 'input';
const InputElement = type === "textarea" ? "textarea" : "input";
const renderViewMode = () => (
<div
className={viewModeClasses}
onClick={() => setIsEditing(true)}
>
<span className="font-medium ">
{value || <span className="text-gray-400"></span>}
</span>
<Button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
size='xs'
variant='ghost'
leftIcon={<PencilIcon />}
className="absolute -right-10 opacity-0 group-hover:opacity-100 "
>
const renderViewMode = () => (
<div className={viewModeClasses} onClick={() => setIsEditing(true)}>
<span className="font-medium ">
{value || <span className="text-gray-400"></span>}
</span>
<Button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
size="xs"
variant="ghost"
leftIcon={<PencilIcon />}
className="absolute -right-10 opacity-0 group-hover:opacity-100 "></Button>
</div>
);
</Button>
</div>
);
const renderEditMode = () => (
<div className="relative">
<InputElement
{...register(name)}
type={type !== 'textarea' ? type : undefined}
rows={type === 'textarea' ? rows : undefined}
{...restProps}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
className={inputClasses}
aria-label={label}
autoFocus
/>
<div className="
const renderEditMode = () => (
<div className="relative">
<InputElement
{...register(name)}
type={type !== "textarea" ? type : undefined}
rows={type === "textarea" ? rows : undefined}
{...restProps}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
className={inputClasses}
aria-label={label}
autoFocus
/>
<div
className="
absolute right-3 top-1/2 -translate-y-1/2
flex items-center space-x-2
">
{value && isFocused && (
<button
type="button"
className="
{value && isFocused && (
<button
type="button"
className="
p-1.5 rounded-full text-gray-400
hover:text-gray-600 hover:bg-gray-100
transition-all duration-200 ease-out
"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setValue(name, '')}
aria-label={`清除${label}`}
tabIndex={-1}
>
<XMarkIcon className="w-4 h-4" />
</button>
)}
{isValid && (
<CheckIcon className="
onMouseDown={(e) => e.preventDefault()}
onClick={() => setValue(name, "")}
aria-label={`清除${label}`}
tabIndex={-1}>
<XMarkIcon className="w-4 h-4" />
</button>
)}
{isValid && (
<CheckIcon
className="
text-green-500 w-4 h-4
animate-fade-in duration-200
" />
)}
{error && <FormError error={error} />}
</div>
</div>
);
return (
<div ref={inputWrapper} className="flex flex-col gap-2">
{label && <div className="flex justify-between items-center px-0.5">
<label className="
"
/>
)}
{error && <FormError error={error} />}
</div>
</div>
);
return (
<div ref={inputWrapper} className="flex flex-col gap-2">
{label && (
<div className="flex justify-between items-center px-0.5">
<label
className="
text-sm font-medium text-gray-700
transition-colors duration-200
group-focus-within:text-blue-600
">
{label}
</label>
{restProps.maxLength && (
<span className="text-sm text-gray-500">
{value?.length || 0}/{restProps.maxLength}
</span>
)}
</div>}
{viewMode && !isEditing ? renderViewMode() : renderEditMode()}
</div>
);
}
{label}
</label>
{restProps.maxLength && (
<span className="text-sm text-gray-500">
{value?.length || 0}/{restProps.maxLength}
</span>
)}
</div>
)}
{viewMode && !isEditing ? renderViewMode() : renderEditMode()}
</div>
);
}

View File

@ -1,79 +1,83 @@
import { useFormContext, Controller } from 'react-hook-form';
import FormError from './FormError';
import { useState } from 'react';
import QuillEditor from '../editor/quill/QuillEditor';
import { useFormContext, Controller } from "react-hook-form";
import FormError from "./FormError";
import { useState } from "react";
import QuillEditor from "../editor/quill/QuillEditor";
export interface FormQuillInputProps {
name: string;
label: string;
placeholder?: string;
maxLength?: number;
minLength?: number;
className?: string;
readOnly?: boolean;
maxRows?: number;
minRows?: number;
name: string;
label?: string;
placeholder?: string;
maxLength?: number;
minLength?: number;
className?: string;
readOnly?: boolean;
maxRows?: number;
minRows?: number;
}
export function FormQuillInput({
name,
label,
placeholder,
maxLength,
minLength,
className,
readOnly = false,
maxRows = 10,
minRows = 4
name,
label,
placeholder,
maxLength,
minLength,
className,
readOnly = false,
maxRows = 10,
minRows = 4,
}: FormQuillInputProps) {
const [isFocused, setIsFocused] = useState(false);
const {
control,
formState: { errors },
trigger,
} = useFormContext();
const [isFocused, setIsFocused] = useState(false);
const {
control,
formState: { errors },
trigger,
} = useFormContext();
const error = errors[name]?.message as string;
const error = errors[name]?.message as string;
const handleBlur = async () => {
setIsFocused(false);
await trigger(name);
};
console.log(isFocused)
const containerClasses = `
const handleBlur = async () => {
setIsFocused(false);
await trigger(name);
};
console.log(isFocused);
const containerClasses = `
w-full rounded-md border bg-white shadow-sm
transition-all duration-300 ease-out
${isFocused
? `ring-2 ring-opacity-50 ${error ? 'ring-red-200 border-red-500' : 'ring-blue-200 border-blue-500'}`
: 'border-gray-300'
}
${
isFocused
? `ring-2 ring-opacity-50 ${error ? "ring-red-200 border-red-500" : "ring-blue-200 border-blue-500"}`
: "border-gray-300"
}
${className}
`.trim()
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<div className={containerClasses}>
<Controller
name={name}
control={control}
render={({ field: { value, onChange } }) => (
<QuillEditor
minRows={minRows}
maxRows={maxRows}
value={value}
onChange={onChange}
placeholder={placeholder}
maxLength={maxLength}
minLength={minLength}
readOnly={readOnly}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
/>
)}
/>
</div>
<FormError error={error} />
</div>
);
}
`.trim();
return (
<div className="space-y-2">
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<div className={containerClasses}>
<Controller
name={name}
control={control}
render={({ field: { value, onChange } }) => (
<QuillEditor
minRows={minRows}
maxRows={maxRows}
value={value}
onChange={onChange}
placeholder={placeholder}
maxLength={maxLength}
minLength={minLength}
readOnly={readOnly}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
/>
)}
/>
</div>
<FormError error={error} />
</div>
);
}

View File

@ -0,0 +1,158 @@
import { useFormContext } from "react-hook-form";
import { motion } from "framer-motion";
import {
PencilSquareIcon,
CheckIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import React, { useState } from "react";
export interface FormSignatureProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"type"
> {
name: string;
label?: string;
type?:
| "text"
| "textarea"
| "password"
| "email"
| "number"
| "tel"
| "url"
| "search"
| "date"
| "time"
| "datetime-local";
viewMode?: boolean;
width?: string; // 新添加的属性
}
export function FormSignature({
name,
label,
type = "text",
className,
viewMode = true,
placeholder = "添加您的个性签名...",
maxLength = 50,
width,
...restProps
}: FormSignatureProps) {
const [isEditing, setIsEditing] = useState(!viewMode);
const {
register,
formState: { errors },
watch,
setValue,
trigger,
} = useFormContext();
const value = watch(name);
const error = errors[name]?.message as string;
const handleBlur = async () => {
await trigger(name);
if (viewMode) {
setIsEditing(false);
}
};
const inputClasses = `
w-full h-8 px-3 text-sm bg-gray-50
rounded-md border border-gray-200
focus:outline-none focus:ring-2 focus:ring-[#00308F]/20
focus:border-[#00308F] transition-all duration-200
${className || ""}
`;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-3">
{
<div className="flex items-center gap-2 text-gray-700">
<PencilSquareIcon className="w-5 h-5 text-[#00308F]" />
{label && (
<span className="text-sm font-medium">{label}</span>
)}
</div>
}
<div className={`relative ${width || "w-64"}`}>
{viewMode && !isEditing ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={() => setIsEditing(true)}
className="group flex items-center h-8 px-3 cursor-pointer
rounded-md border border-transparent hover:border-gray-200
transition-all duration-200">
{value ? (
<span className="text-sm text-gray-700">
{value}
</span>
) : (
<span className="text-sm text-gray-400">
{placeholder}
</span>
)}
<motion.div
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
className="ml-2 text-gray-400">
<PencilSquareIcon className="w-4 h-4" />
</motion.div>
</motion.div>
) : (
<div className="relative">
<input
{...register(name)}
type={type}
className={inputClasses}
placeholder={placeholder}
maxLength={maxLength}
onBlur={handleBlur}
autoFocus={viewMode}
{...restProps}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{value && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
type="button"
onClick={() => setValue(name, "")}
className="p-1 rounded-full hover:bg-gray-200 text-gray-400
hover:text-gray-600 transition-colors">
<XMarkIcon className="w-4 h-4" />
</motion.button>
)}
{value && !error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-green-500">
<CheckIcon className="w-4 h-4" />
</motion.div>
)}
</div>
</div>
)}
</div>
{error && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-xs text-red-500">
{error}
</motion.span>
)}
</motion.div>
);
}

View File

@ -0,0 +1,187 @@
import { useFormContext } from "react-hook-form";
import React, { useRef, useState, KeyboardEvent } from "react";
import {
CheckIcon,
PencilIcon,
XMarkIcon,
PlusCircleIcon,
} from "@heroicons/react/24/outline";
import FormError from "./FormError";
import { Button } from "../element/Button";
export interface FormTagsProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
name: string;
label?: string;
viewMode?: boolean;
maxTags?: number;
}
export function FormTags({
name,
label,
className,
viewMode = false,
maxTags = 10,
...restProps
}: FormTagsProps) {
const [isFocused, setIsFocused] = useState(false);
const [isEditing, setIsEditing] = useState(!viewMode);
const [inputValue, setInputValue] = useState("");
const inputWrapper = useRef<HTMLDivElement>(null);
const {
register,
formState: { errors },
watch,
setValue,
trigger,
} = useFormContext();
const tags = watch(name) || [];
const error = errors[name]?.message as string;
const isValid = tags.length > 0 && !error;
const handleAddTag = () => {
if (inputValue.trim() && tags.length < maxTags) {
const newTags = [...tags, inputValue.trim()];
setValue(name, newTags);
setInputValue("");
trigger(name);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
};
const handleRemoveTag = (indexToRemove: number) => {
const newTags = tags.filter((_, index) => index !== indexToRemove);
setValue(name, newTags);
trigger(name);
};
const inputClasses = `
w-full rounded-lg border bg-white px-4 py-2 outline-none
transition-all duration-200 ease-out placeholder:text-gray-400
${
error
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200"
: "border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
}
${isFocused ? "ring-2 ring-opacity-50" : ""}
${className || ""}
`;
const viewModeClasses = `
w-full text-gray-700 hover:text-blue-600 min-h-[48px]
flex items-center gap-2 relative cursor-pointer group
transition-all duration-200 ease-out select-none
`;
const renderTags = () => (
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((tag: string, index: number) => (
<span
key={index}
className="
inline-flex items-center gap-1 px-3 py-1
bg-blue-50 text-blue-700 rounded-full
text-sm font-medium
">
{tag}
{!viewMode && (
<XMarkIcon
className="w-4 h-4 cursor-pointer hover:text-blue-900"
onClick={() => handleRemoveTag(index)}
/>
)}
</span>
))}
</div>
);
const renderViewMode = () => (
<div className={viewModeClasses} onClick={() => setIsEditing(true)}>
{tags.length > 0 ? (
renderTags()
) : (
<span className="text-gray-400"></span>
)}
<Button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
size="xs"
variant="ghost"
leftIcon={<PencilIcon />}
className="absolute -right-10 opacity-0 group-hover:opacity-100"
/>
</div>
);
const renderEditMode = () => (
<div className="space-y-2">
{renderTags()}
<div className="relative">
<input
{...register(name)}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => {
setIsFocused(false);
if (viewMode) setIsEditing(false);
}}
className={inputClasses}
placeholder="输入标签后按回车添加"
aria-label={label}
{...restProps}
/>
<div
className="
absolute right-3 top-1/2 -translate-y-1/2
flex items-center space-x-2
">
{inputValue && (
<button
type="button"
className="
p-1.5 rounded-full text-gray-400
hover:text-gray-600 hover:bg-gray-100
transition-all duration-200 ease-out
"
onClick={() => setInputValue("")}
aria-label="清除输入"
tabIndex={-1}>
<XMarkIcon className="w-4 h-4" />
</button>
)}
{isValid && (
<CheckIcon className="text-green-500 w-4 h-4 animate-fade-in duration-200" />
)}
{error && <FormError error={error} />}
</div>
</div>
<div className="text-sm text-gray-500">
{tags.length}/{maxTags}
</div>
</div>
);
return (
<div ref={inputWrapper} className="flex flex-col gap-2">
{label && (
<label className="text-sm font-medium text-gray-700 px-0.5">
{label}
</label>
)}
{viewMode && !isEditing ? renderViewMode() : renderEditMode()}
</div>
);
}

View File

@ -1,211 +1,237 @@
import { useState, useCallback, useRef, memo } from 'react'
import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'
import * as tus from 'tus-js-client'
import { motion, AnimatePresence } from 'framer-motion'
import { toast } from 'react-hot-toast'
// FileUploader.tsx
import React, { useRef, memo, useState } from "react";
import {
CloudArrowUpIcon,
XMarkIcon,
DocumentIcon,
ExclamationCircleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "react-hot-toast";
import { useTusUpload } from "@web/src/hooks/useTusUpload";
interface FileUploaderProps {
endpoint?: string
onSuccess?: (url: string) => void
onError?: (error: Error) => void
maxSize?: number
allowedTypes?: string[]
placeholder?: string
endpoint?: string;
onSuccess?: (url: string) => void;
onError?: (error: Error) => void;
maxSize?: number;
allowedTypes?: string[];
placeholder?: string;
}
const FileItem = memo(({ file, progress, onRemove }: {
file: File
progress?: number
onRemove: (name: string) => void
}) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200"
>
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
<div className="ml-3 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p>
<button
onClick={() => onRemove(file.name)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
aria-label={`Remove ${file.name}`}
>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{progress !== undefined && (
<div className="mt-2">
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div
className="bg-blue-500 h-1.5 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className="text-xs text-gray-500 mt-1">{progress}%</span>
</div>
)}
</div>
</motion.div>
))
interface FileItemProps {
file: File;
progress?: number;
onRemove: (name: string) => void;
isUploaded: boolean;
}
export default function FileUploader({
endpoint = '',
onSuccess,
onError,
maxSize = 100,
placeholder = '点击或拖拽文件到这里上传',
allowedTypes = ['*/*']
}: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [progress, setProgress] = useState<{ [key: string]: number }>({})
const fileInputRef = useRef<HTMLInputElement>(null)
const FileItem: React.FC<FileItemProps> = memo(
({ file, progress, onRemove, isUploaded }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
<div className="ml-3 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
{file.name}
</p>
<button
onClick={() => onRemove(file.name)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
aria-label={`Remove ${file.name}`}>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{!isUploaded && progress !== undefined && (
<div className="mt-2">
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div
className="bg-blue-500 h-1.5 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className="text-xs text-gray-500 mt-1">
{progress}%
</span>
</div>
)}
{isUploaded && (
<div className="mt-2 flex items-center text-green-500">
<CheckCircleIcon className="w-4 h-4 mr-1" />
<span className="text-xs"></span>
</div>
)}
</div>
</motion.div>
)
);
const handleError = useCallback((error: Error) => {
toast.error(error.message)
onError?.(error)
}, [onError])
const FileUploader: React.FC<FileUploaderProps> = ({
endpoint = "",
onSuccess,
onError,
maxSize = 100,
placeholder = "点击或拖拽文件到这里上传",
allowedTypes = ["*/*"],
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<
Array<{ file: File; isUploaded: boolean }>
>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === 'dragenter' || e.type === 'dragover') {
setIsDragging(true)
} else if (e.type === 'dragleave') {
setIsDragging(false)
}
}, [])
const { progress, isUploading, uploadError, handleFileUpload } =
useTusUpload();
const validateFile = useCallback((file: File) => {
if (file.size > maxSize * 1024 * 1024) {
throw new Error(`文件大小不能超过 ${maxSize}MB`)
}
if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) {
throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`)
}
}, [maxSize, allowedTypes])
const handleError = (error: Error) => {
toast.error(error.message);
onError?.(error);
};
const uploadFile = async (file: File) => {
try {
validateFile(file)
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setIsDragging(true);
} else if (e.type === "dragleave") {
setIsDragging(false);
}
};
const upload = new tus.Upload(file, {
endpoint,
retryDelays: [0, 3000, 5000, 10000, 20000],
metadata: {
filename: file.name,
filetype: file.type
},
onError: handleError,
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2)
setProgress(prev => ({
...prev,
[file.name]: parseFloat(percentage)
}))
},
onSuccess: () => {
onSuccess?.(upload.url || '')
setProgress(prev => {
const newProgress = { ...prev }
delete newProgress[file.name]
return newProgress
})
}
})
const validateFile = (file: File) => {
if (file.size > maxSize * 1024 * 1024) {
throw new Error(`文件大小不能超过 ${maxSize}MB`);
}
if (
!allowedTypes.includes("*/*") &&
!allowedTypes.includes(file.type)
) {
throw new Error(
`不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}`
);
}
};
upload.start()
} catch (error) {
handleError(error as Error)
}
}
const uploadFile = (file: File) => {
try {
validateFile(file);
handleFileUpload(
file,
(upload) => {
onSuccess?.(upload.url || "");
setFiles((prev) =>
prev.map((f) =>
f.file.name === file.name
? { ...f, isUploaded: true }
: f
)
);
},
handleError
);
} catch (error) {
handleError(error as Error);
}
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files)
setFiles(prev => [...prev, ...droppedFiles])
droppedFiles.forEach(uploadFile)
}, [])
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles((prev) => [
...prev,
...droppedFiles.map((file) => ({ file, isUploaded: false })),
]);
droppedFiles.forEach(uploadFile);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
setFiles(prev => [...prev, ...selectedFiles])
selectedFiles.forEach(uploadFile)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
setFiles((prev) => [
...prev,
...selectedFiles.map((file) => ({ file, isUploaded: false })),
]);
selectedFiles.forEach(uploadFile);
}
};
const removeFile = (fileName: string) => {
setFiles(prev => prev.filter(file => file.name !== fileName))
setProgress(prev => {
const newProgress = { ...prev }
delete newProgress[fileName]
return newProgress
})
}
const removeFile = (fileName: string) => {
setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
};
return (
<div className="w-full space-y-4">
<motion.div
className={`relative border-2 border-dashed rounded-xl p-8 transition-all
${isDragging
? 'border-blue-500 bg-blue-50/50 ring-4 ring-blue-100'
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
aria-label="文件上传区域"
>
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileSelect}
accept={allowedTypes.join(',')}
/>
const handleClick = () => {
fileInputRef.current?.click();
};
<div className="flex flex-col items-center justify-center space-y-4">
<motion.div
animate={{ y: isDragging ? -10 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<CloudArrowUpIcon className="w-16 h-16 text-blue-500/80" />
</motion.div>
<div className="text-center">
<p className="text-gray-500">{placeholder}</p>
</div>
<p className="text-xs text-gray-400 flex items-center gap-1">
<ExclamationCircleIcon className="w-4 h-4" />
: {allowedTypes.join(', ')} · : {maxSize}MB
</p>
</div>
</motion.div>
return (
<div className="w-full space-y-4">
<div
onClick={handleClick}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`
relative flex flex-col items-center justify-center w-full h-32
border-2 border-dashed rounded-lg cursor-pointer
transition-colors duration-200 ease-in-out
${
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-500"
}
`}>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
accept={allowedTypes.join(",")}
className="hidden"
/>
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
<p className="text-blue-500 font-medium">
</p>
</div>
)}
</div>
<AnimatePresence>
<div className="space-y-3">
{files.map(file => (
<FileItem
key={file.name}
file={file}
progress={progress[file.name]}
onRemove={removeFile}
/>
))}
</div>
</AnimatePresence>
</div>
)
}
<AnimatePresence>
<div className="space-y-3">
{files.map(({ file, isUploaded }) => (
<FileItem
key={file.name}
file={file}
progress={isUploaded ? 100 : progress}
onRemove={removeFile}
isUploaded={isUploaded}
/>
))}
</div>
</AnimatePresence>
{uploadError && (
<div className="flex items-center text-red-500 text-sm">
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
<span>{uploadError}</span>
</div>
)}
</div>
);
};
export default FileUploader;

View File

@ -0,0 +1,115 @@
import { createContext, useContext, ReactNode, useEffect } from "react";
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api, usePost } from "@nice/client";
// import { PostDto, PostLevel, PostStatus } from "@nice/common";
// import { api, usePost } from "@nice/client";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { Post, PostType } from "@nice/common";
// 定义帖子表单验证 Schema
const letterSchema = z.object({
title: z.string().min(1, "标题不能为空"),
content: z.string().min(1, "内容不能为空"),
resources: z.array(z.string()).nullish(),
isPublic: z.boolean().nullish(),
signature: z.string().nullish(),
meta: z
.object({
tags: z.array(z.string()).default([]),
signature: z.string().nullish(),
})
.default({
tags: [],
signature: null,
}),
});
// 定义课程表单验证 Schema
export type LetterFormData = z.infer<typeof letterSchema>;
interface LetterEditorContextType {
onSubmit: SubmitHandler<LetterFormData>;
receiverId?: string;
termId?: string;
part?: string;
// course?: PostDto;
}
interface LetterFormProviderProps {
children: ReactNode;
receiverId?: string;
termId?: string;
part?: string;
}
const LetterEditorContext = createContext<LetterEditorContextType | null>(null);
export function LetterFormProvider({
children,
receiverId,
termId,
// editId,
}: LetterFormProviderProps) {
const { create } = usePost();
const navigate = useNavigate();
const methods = useForm<LetterFormData>({
resolver: zodResolver(letterSchema),
defaultValues: {
resources: [],
meta: {
tags: [],
},
},
});
const onSubmit: SubmitHandler<LetterFormData> = async (
data: LetterFormData
) => {
try {
const result = await create.mutateAsync({
data: {
type: PostType.POST,
termId: termId,
receivers: {
connect: [receiverId].filter(Boolean).map((id) => ({
id,
})),
},
...data,
resources: data.resources?.length
? {
connect: data.resources.map((id) => ({
id,
})),
}
: undefined,
},
});
// navigate(`/course/${result.id}/editor`, { replace: true });
toast.success("发送成功!");
methods.reset(data);
} catch (error) {
console.error("Error submitting form:", error);
toast.error("操作失败,请重试!");
}
};
return (
<LetterEditorContext.Provider
value={{
onSubmit,
receiverId,
termId,
}}>
<FormProvider {...methods}>{children}</FormProvider>
</LetterEditorContext.Provider>
);
}
export const useLetterEditor = () => {
const context = useContext(LetterEditorContext);
if (!context) {
throw new Error(
"useLetterEditor must be used within LetterFormProvider"
);
}
return context;
};

View File

@ -0,0 +1,178 @@
import { useFormContext } from "react-hook-form";
import { motion } from "framer-motion";
import {
LetterFormData,
useLetterEditor,
} from "../context/LetterEditorContext";
import { FormInput } from "@web/src/components/common/form/FormInput";
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
import { api } from "@nice/client";
import {
UserIcon,
FolderIcon,
HashtagIcon,
DocumentTextIcon,
} from "@heroicons/react/24/outline";
import FileUploader from "@web/src/components/common/uploader/FileUploader";
import { FormTags } from "@web/src/components/common/form/FormTags";
import { FormSignature } from "@web/src/components/common/form/FormSignature";
import { FormCheckbox } from "@web/src/components/common/form/FormCheckbox";
export function LetterBasicForm() {
const {
handleSubmit,
getValues,
formState: { errors },
} = useFormContext<LetterFormData>();
const { onSubmit, receiverId, termId } = useLetterEditor();
const { data: receiver } = api.staff.findFirst.useQuery(
{
where: {
id: receiverId,
},
},
{
enabled: !!receiverId,
}
);
const { data: term } = api.term.findFirst.useQuery(
{
where: { id: termId },
},
{ enabled: !!termId }
);
const formControls = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.2,
duration: 0.5,
},
}),
};
return (
<motion.form
className="w-full space-y-6 p-8 "
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}>
{/* 收件人 */}
{
<motion.div
custom={0}
initial="hidden"
animate="visible"
variants={formControls}
className="flex items-center text-lg font-semibold text-[#00308F]">
<UserIcon className="w-5 h-5 mr-2 text-[#00308F]" />
<div>{receiver?.showname}</div>
</motion.div>
}
{/* 选择板块 */}
{
<motion.div
custom={1}
initial="hidden"
animate="visible"
variants={formControls}
className="flex items-center text-lg font-semibold text-[#00308F]">
<FolderIcon className="w-5 h-5 mr-2 text-[#00308F]" />
<div>{term?.name}</div>
</motion.div>
}
{/* 主题输入框 */}
<motion.div
custom={2}
initial="hidden"
animate="visible"
variants={formControls}>
<label className="block text-sm font-medium text-gray-700 mb-2">
<HashtagIcon className="w-5 h-5 inline mr-2 text-[#00308F]" />
</label>
<FormInput
maxLength={20}
name="title"
placeholder="请输入主题"
/>
</motion.div>
{/* 标签输入 */}
<motion.div
custom={4}
initial="hidden"
animate="visible"
variants={formControls}
className="space-y-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
<HashtagIcon className="w-5 h-5 inline mr-2 text-[#00308F]" />
</label>
<FormTags
name="meta.tags"
placeholder="输入标签后按回车添加"
maxTags={20}
/>
</motion.div>
{/* 内容输入框 */}
<motion.div
custom={3}
initial="hidden"
animate="visible"
variants={formControls}>
<label className="block text-sm font-medium text-gray-700 mb-2">
<DocumentTextIcon className="w-5 h-5 inline mr-2 text-[#00308F]" />
</label>
<FormQuillInput
maxLength={400}
name="content"
placeholder="请输入内容"
/>
</motion.div>
<FileUploader></FileUploader>
<motion.div className="flex items-center justify-end gap-8 border-t border-gray-100 pt-2">
<motion.div
custom={3}
initial="hidden"
animate="visible"
variants={formControls}
className="flex justify-end">
<FormCheckbox
name="isPublic"
label="公开信件"
defaultChecked
/>
</motion.div>
<motion.div
custom={5}
initial="hidden"
animate="visible"
variants={formControls}
className="flex justify-end">
<FormSignature
name="meta.signature"
width="w-32"
placeholder="添加个性签名"
maxLength={20}
viewMode={false}
/>
</motion.div>
<motion.button
onClick={handleSubmit(onSubmit)}
className="px-4 py-2 bg-[#00308F] hover:bg-[#041E42] text-white font-bold rounded-lg
transform transition-all duration-200 hover:scale-105 focus:ring-2 focus:ring-[#00308F]/50"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}>
</motion.button>
</motion.div>
</motion.form>
);
}

View File

@ -0,0 +1,100 @@
import { useLocation, useParams } from "react-router-dom";
import { motion } from "framer-motion";
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { LetterFormProvider } from "../context/LetterEditorContext";
import { LetterBasicForm } from "../form/LetterBasicForm";
export default function LetterEditorLayout() {
const location = useLocation();
const params = new URLSearchParams(location.search);
const receiverId = params.get("receiverId");
const termId = params.get("termId");
return (
<motion.div
className="min-h-screen rounded-xl overflow-hidden border border-gray-200 shadow-lg" // 添加圆角和溢出隐藏
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8 }}>
<div className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white py-8">
<div className="w-full px-4 max-w-7xl mx-auto">
<motion.div
className="flex items-center justify-center mb-6"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
delay: 0.2,
type: "spring",
stiffness: 100,
}}>
<PaperAirplaneIcon className="h-12 w-12" />
<h1 className="text-3xl font-bold ml-4"></h1>
</motion.div>
{/* 隐私保护说明 */}
<div className="flex flex-wrap gap-6 text-sm justify-center mb-4">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
/>
</svg>
<span></span>
</div>
</div>
{/* 隐私承诺 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4 max-w-4xl mx-auto text-center">
<p>
</p>
</div>
</div>
</div>
<div className="w-full px-2 py-2">
<LetterFormProvider receiverId={receiverId} termId={termId}>
<LetterBasicForm />
</LetterFormProvider>
</div>
</motion.div>
);
}

View File

@ -1,113 +0,0 @@
import { createContext, useContext, ReactNode, useEffect } from "react";
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api, usePost } from "@nice/client";
// import { PostDto, PostLevel, PostStatus } from "@nice/common";
// import { api, usePost } from "@nice/client";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { Post, PostType } from "@nice/common";
// 定义帖子表单验证 Schema
const postSchema = z.object({
title: z.string().min(1, "标题不能为空"),
content: z.string().min(1, "内容不能为空"),
resources: z.array(z.string()).nullish(),
isPublic: z.boolean(),
signature: z.string().nullish(),
});
// 定义课程表单验证 Schema
export type PostFormData = z.infer<typeof postSchema>;
interface PostEditorContextType {
onSubmit: SubmitHandler<PostFormData>;
editId?: string; // 添加 editId
part?: string;
// course?: PostDto;
}
interface PostFormProviderProps {
children: ReactNode;
editId?: string; // 添加 editId 参数
part?: string;
}
const PostEditorContext = createContext<PostEditorContextType | null>(null);
export function PostFormProvider({ children, editId }: PostFormProviderProps) {
const { create, update } = usePost();
const { data: post }: { data: Post } = api.post.findById.useQuery(
{
id: editId,
},
{ enabled: !!editId }
);
const navigate = useNavigate();
const methods = useForm<PostFormData>({
resolver: zodResolver(postSchema),
defaultValues: {
resources: [],
},
});
useEffect(() => {
if (post) {
const formData = {
title: post.title,
content: post.content,
signature: (post.meta as any)?.signature,
};
methods.reset(formData as any);
}
}, [post, methods]);
const onSubmit: SubmitHandler<PostFormData> = async (
data: PostFormData
) => {
try {
if (editId) {
// await update.mutateAsync({
// where: { id: editId },
// data: {
// ...data
// }
// })
toast.success("课程更新成功!");
} else {
const result = await create.mutateAsync({
data: {
type: PostType.POST,
...data,
resources: data.resources?.length
? {
connect: data.resources.map((id) => ({
id,
})),
}
: undefined,
},
});
// navigate(`/course/${result.id}/editor`, { replace: true });
toast.success("发送成功!");
}
methods.reset(data);
} catch (error) {
console.error("Error submitting form:", error);
toast.error("操作失败,请重试!");
}
};
return (
<PostEditorContext.Provider
value={{
onSubmit,
editId,
// course
}}>
<FormProvider {...methods}>{children}</FormProvider>
</PostEditorContext.Provider>
);
}
export const usePostEditor = () => {
const context = useContext(PostEditorContext);
if (!context) {
throw new Error("usePostEditor must be used within PostFormProvider");
}
return context;
};

View File

@ -1,22 +0,0 @@
import { ReactNode, useEffect, useState } from "react";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { motion } from "framer-motion";
import { NavItem } from "@nice/client";
import { PostFormProvider } from "../context/PostEditorContext";
export default function PostEditorLayout() {
const { id } = useParams();
return (
<>
<PostFormProvider editId={id}>
<div className="min-h-screen bg-gray-50">
<div className="p-6">
<Outlet></Outlet>
</div>
</div>
</PostFormProvider>
</>
);
}

View File

@ -0,0 +1,54 @@
import { api, usePost } from "@nice/client";
import {} from "@nice/common";
import React, { createContext, ReactNode, useState } from "react";
import { string } from "zod";
interface PostDetailContextType {
editId?: string; // 添加 editId
course?: CourseDto;
selectedLectureId?: string | undefined;
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
isLoading?: boolean;
isHeaderVisible: boolean; // 新增
setIsHeaderVisible: (visible: boolean) => void; // 新增
}
interface CourseFormProviderProps {
children: ReactNode;
editId?: string; // 添加 editId 参数
}
export const CourseDetailContext =
createContext<PostDetailContextType | null>(null);
export function CourseDetailProvider({
children,
editId,
}: CourseFormProviderProps) {
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
api.course.findFirst.useQuery(
{
where: { id: editId },
include: {
sections: { include: { lectures: true } },
enrollments: true,
},
},
{ enabled: Boolean(editId) }
);
const [selectedLectureId, setSelectedLectureId] = useState<
string | undefined
>(undefined);
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
return (
<CourseDetailContext.Provider
value={{
editId,
course,
selectedLectureId,
setSelectedLectureId,
isLoading,
isHeaderVisible,
setIsHeaderVisible,
}}>
{children}
</CourseDetailContext.Provider>
);
}

View File

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { Button, Select, Spin } from "antd";
import type { SelectProps } from "antd";
import { api } from "@nice/client";
import React from "react";
interface StaffSelectProps {
value?: string | string[];
onChange?: (value: string | string[]) => void;
@ -42,16 +43,15 @@ export default function StaffSelect({
},
{
id: {
in: ids
}
}
in: ids,
},
},
],
domainId,
},
select: { id: true, showname: true, username: true },
take: 30,
orderBy: { order: "asc" }
orderBy: { order: "asc" },
});
const handleSearch = (value: string) => {

View File

@ -0,0 +1,58 @@
// useTusUpload.ts
import { useState } from "react";
import * as tus from "tus-js-client";
interface UploadResult {
url?: string;
}
export function useTusUpload() {
const [progress, setProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const handleFileUpload = (
file: File,
onSuccess: (result: UploadResult) => void,
onError: (error: Error) => void
) => {
setIsUploading(true);
setProgress(0);
setUploadError(null);
const upload = new tus.Upload(file, {
endpoint: "http://localhost:3000/upload", // 替换为实际的上传端点
retryDelays: [0, 1000, 3000, 5000],
metadata: {
filename: file.name,
filetype: file.type,
},
onProgress: (bytesUploaded, bytesTotal) => {
const uploadProgress = (
(bytesUploaded / bytesTotal) *
100
).toFixed(2);
setProgress(Number(uploadProgress));
},
onSuccess: () => {
setIsUploading(false);
setProgress(100);
onSuccess({ url: upload.url });
},
onError: (error) => {
setIsUploading(false);
setUploadError(error.message);
onError(error);
},
});
upload.start();
};
return {
progress,
isUploading,
uploadError,
handleFileUpload,
};
}

View File

@ -20,10 +20,13 @@ import CourseContentForm from "../components/models/course/editor/form/CourseCon
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
import PostEditorLayout from "../components/models/post/PostEditor/layout/PostEditorLayout";
import WriteLetterPage from "../app/main/letter/write/page";
import LetterListPage from "../app/main/letter/list/page";
import React from "react";
import LetterEditorLayout from "../components/models/post/LetterEditor/layout/LetterEditorLayout";
import { LetterBasicForm } from "../components/models/post/LetterEditor/form/LetterBasicForm";
import EditorLetterPage from "../app/main/letter/editor/page";
import LetterDetailPage from "../app/main/letter/detail/page";
interface CustomIndexRouteObject extends IndexRouteObject {
name?: string;
@ -66,20 +69,13 @@ export const routes: CustomRouteObject[] = [
index: true,
element: <HomePage />,
},
],
},
{
path: "post",
children: [
{
path: ":id?/editor",
element: <PostEditorLayout></PostEditorLayout>,
children: [
{
index: true,
element: <CourseBasicForm></CourseBasicForm>,
},
],
path: ":id?/detail",
element: <LetterDetailPage></LetterDetailPage>,
},
{
path: "editor",
element: <EditorLetterPage></EditorLetterPage>,
},
{
path: "write-letter",
@ -91,6 +87,7 @@ export const routes: CustomRouteObject[] = [
},
],
},
{
path: "course",
children: [

View File

@ -27,11 +27,13 @@ model Taxonomy {
model Term {
id String @id @default(cuid())
name String
posts Post[]
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
taxonomyId String? @map("taxonomy_id")
order Float? @map("order")
description String?
parentId String? @map("parent_id")
parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade)
children Term[] @relation("ChildParent")
ancestors TermAncestry[] @relation("DescendantToAncestor")
@ -188,6 +190,8 @@ model Post {
title String? // 帖子标题,可为空
content String? // 帖子内容,可为空
domainId String? @map("domain_id")
term Term? @relation(fields: [termId], references: [id])
termId String? @map("term_id")
// 日期时间类型字段
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -202,7 +206,7 @@ model Post {
children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型
resources Resource[] // 附件列表
isPublic Boolean? @default(false) @map("is_public")
meta Json? // 签名 和 IP
meta Json? // 签名 和 IP 和 tags
// 复合索引
@@index([type, domainId]) // 类型和域组合查询