Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
f6d85dc644
|
@ -20,18 +20,13 @@ export class AuthGuard implements CanActivate {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload: JwtPayload = await this.jwtService.verifyAsync(
|
const payload: JwtPayload = await this.jwtService.verifyAsync(token, {
|
||||||
token,
|
secret: env.JWT_SECRET,
|
||||||
{
|
});
|
||||||
secret: env.JWT_SECRET
|
|
||||||
}
|
|
||||||
);
|
|
||||||
request['user'] = payload;
|
request['user'] = payload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -35,6 +35,7 @@ export class PostRouter {
|
||||||
} as Prisma.InputJsonObject; // 明确指定类型
|
} as Prisma.InputJsonObject; // 明确指定类型
|
||||||
return await this.postService.create(input, { staff });
|
return await this.postService.create(input, { staff });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
softDeleteByIds: this.trpc.protectProcedure
|
softDeleteByIds: this.trpc.protectProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
|
@ -27,6 +27,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
||||||
) {
|
) {
|
||||||
args.data.authorId = params?.staff?.id;
|
args.data.authorId = params?.staff?.id;
|
||||||
|
// args.data.resources
|
||||||
const result = await super.create(args);
|
const result = await super.create(args);
|
||||||
EventBus.emit('dataChanged', {
|
EventBus.emit('dataChanged', {
|
||||||
type: ObjectType.POST,
|
type: ObjectType.POST,
|
||||||
|
|
|
@ -17,7 +17,7 @@ export class StaffRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly staffService: StaffService,
|
private readonly staffService: StaffService,
|
||||||
private readonly staffRowService: StaffRowService
|
private readonly staffRowService: StaffRowService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
|
@ -35,7 +35,7 @@ export class StaffRouter {
|
||||||
updateUserDomain: this.trpc.protectProcedure
|
updateUserDomain: this.trpc.protectProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
domainId: z.string()
|
domainId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
@ -72,8 +72,10 @@ export class StaffRouter {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
return await this.staffService.findFirst(input);
|
return await this.staffService.findFirst(input);
|
||||||
}),
|
}),
|
||||||
updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => {
|
updateOrder: this.trpc.protectProcedure
|
||||||
return this.staffService.updateOrder(input)
|
.input(UpdateOrderSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return this.staffService.updateOrder(input);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,6 @@ import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||||
|
|
||||||
constructor(private readonly departmentService: DepartmentService) {
|
constructor(private readonly departmentService: DepartmentService) {
|
||||||
super(db, ObjectType.STAFF, true);
|
super(db, ObjectType.STAFF, true);
|
||||||
}
|
}
|
||||||
|
@ -25,7 +24,10 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||||
*/
|
*/
|
||||||
async findByDept(data: z.infer<typeof StaffMethodSchema.findByDept>) {
|
async findByDept(data: z.infer<typeof StaffMethodSchema.findByDept>) {
|
||||||
const { deptId, domainId } = data;
|
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({
|
const result = await db.staff.findMany({
|
||||||
where: {
|
where: {
|
||||||
deptId: { in: childDepts },
|
deptId: { in: childDepts },
|
||||||
|
@ -50,7 +52,9 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||||
await this.validateUniqueFields(data, where.id);
|
await this.validateUniqueFields(data, where.id);
|
||||||
const updateData = {
|
const updateData = {
|
||||||
...data,
|
...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 });
|
const result = await super.update({ ...args, data: updateData });
|
||||||
this.emitDataChangedEvent(result, CrudOperation.UPDATED);
|
this.emitDataChangedEvent(result, CrudOperation.UPDATED);
|
||||||
|
@ -58,17 +62,26 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||||
}
|
}
|
||||||
private async validateUniqueFields(data: any, excludeId?: string) {
|
private async validateUniqueFields(data: any, excludeId?: string) {
|
||||||
const uniqueFields = [
|
const uniqueFields = [
|
||||||
{ field: 'officerId', errorMsg: (val: string) => `证件号为${val}的用户已存在` },
|
{
|
||||||
{ field: 'phoneNumber', errorMsg: (val: string) => `手机号为${val}的用户已存在` },
|
field: 'officerId',
|
||||||
{ field: 'username', errorMsg: (val: string) => `帐号为${val}的用户已存在` }
|
errorMsg: (val: string) => `证件号为${val}的用户已存在`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'phoneNumber',
|
||||||
|
errorMsg: (val: string) => `手机号为${val}的用户已存在`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'username',
|
||||||
|
errorMsg: (val: string) => `帐号为${val}的用户已存在`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for (const { field, errorMsg } of uniqueFields) {
|
for (const { field, errorMsg } of uniqueFields) {
|
||||||
if (data[field]) {
|
if (data[field]) {
|
||||||
const count = await db.staff.count({
|
const count = await db.staff.count({
|
||||||
where: {
|
where: {
|
||||||
[field]: data[field],
|
[field]: data[field],
|
||||||
...(excludeId && { id: { not: excludeId } })
|
...(excludeId && { id: { not: excludeId } }),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
throw new Error(errorMsg(data[field]));
|
throw new Error(errorMsg(data[field]));
|
||||||
|
@ -77,9 +90,8 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private emitDataChangedEvent(data: any, operation: CrudOperation) {
|
private emitDataChangedEvent(data: any, operation: CrudOperation) {
|
||||||
EventBus.emit("dataChanged", {
|
EventBus.emit('dataChanged', {
|
||||||
type: this.objectType,
|
type: this.objectType,
|
||||||
operation,
|
operation,
|
||||||
data,
|
data,
|
||||||
|
@ -107,7 +119,6 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
// * 根据关键词或ID集合查找员工
|
// * 根据关键词或ID集合查找员工
|
||||||
// * @param data 包含关键词、域ID和ID集合的对象
|
// * @param data 包含关键词、域ID和ID集合的对象
|
||||||
|
@ -176,5 +187,4 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||||
|
|
||||||
// return combinedResults;
|
// return combinedResults;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
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 { FileStore } from '@nice/tus';
|
||||||
import { Request, Response } from "express"
|
import { Request, Response } from 'express';
|
||||||
import { db, ResourceStatus } from '@nice/common';
|
import { db, ResourceStatus } from '@nice/common';
|
||||||
import { getFilenameWithoutExt } from '@server/utils/file';
|
import { getFilenameWithoutExt } from '@server/utils/file';
|
||||||
import { ResourceService } from '@server/models/resource/resource.service';
|
import { ResourceService } from '@server/models/resource/resource.service';
|
||||||
|
@ -14,14 +14,15 @@ import { slugify } from 'transliteration';
|
||||||
const FILE_UPLOAD_CONFIG = {
|
const FILE_UPLOAD_CONFIG = {
|
||||||
directory: process.env.UPLOAD_DIR,
|
directory: process.env.UPLOAD_DIR,
|
||||||
maxSizeBytes: 20_000_000_000, // 20GB
|
maxSizeBytes: 20_000_000_000, // 20GB
|
||||||
expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours
|
expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TusService implements OnModuleInit {
|
export class TusService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(TusService.name);
|
private readonly logger = new Logger(TusService.name);
|
||||||
private tusServer: Server;
|
private tusServer: Server;
|
||||||
constructor(private readonly resourceService: ResourceService,
|
constructor(
|
||||||
@InjectQueue("file-queue") private fileQueue: Queue
|
private readonly resourceService: ResourceService,
|
||||||
|
@InjectQueue('file-queue') private fileQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
this.initializeTusServer();
|
this.initializeTusServer();
|
||||||
|
@ -41,50 +42,62 @@ export class TusService implements OnModuleInit {
|
||||||
path: '/upload',
|
path: '/upload',
|
||||||
datastore: new FileStore({
|
datastore: new FileStore({
|
||||||
directory: FILE_UPLOAD_CONFIG.directory,
|
directory: FILE_UPLOAD_CONFIG.directory,
|
||||||
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod
|
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod,
|
||||||
}),
|
}),
|
||||||
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
|
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
|
||||||
postReceiveInterval: 1000,
|
postReceiveInterval: 1000,
|
||||||
getFileIdFromRequest: (req, lastPath) => {
|
getFileIdFromRequest: (req, lastPath) => {
|
||||||
const match = req.url.match(/\/upload\/(.+)/);
|
const match = req.url.match(/\/upload\/(.+)/);
|
||||||
return match ? match[1] : lastPath;
|
return match ? match[1] : lastPath;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupTusEventHandlers() {
|
private setupTusEventHandlers() {
|
||||||
this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this));
|
this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this));
|
||||||
this.tusServer.on("POST_FINISH", this.handleUploadFinish.bind(this));
|
this.tusServer.on('POST_FINISH', this.handleUploadFinish.bind(this));
|
||||||
}
|
}
|
||||||
private getFileId(uploadId: string) {
|
private getFileId(uploadId: string) {
|
||||||
return uploadId.replace(/\/[^/]+$/, '')
|
return uploadId.replace(/\/[^/]+$/, '');
|
||||||
}
|
}
|
||||||
private async handleUploadCreate(req: Request, res: Response, upload: Upload, url: string) {
|
private async handleUploadCreate(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
upload: Upload,
|
||||||
|
url: string,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
const fileId = this.getFileId(upload.id);
|
||||||
const fileId = this.getFileId(upload.id)
|
const filename = upload.metadata.filename;
|
||||||
const filename = upload.metadata.filename
|
|
||||||
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,
|
metadata: upload.metadata,
|
||||||
status: ResourceStatus.UPLOADING
|
status: ResourceStatus.UPLOADING,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to create resource during upload', error);
|
this.logger.error('Failed to create resource during upload', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleUploadFinish(req: Request, res: Response, upload: Upload) {
|
private async handleUploadFinish(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
upload: Upload,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const resource = await this.resourceService.update({
|
const resource = await this.resourceService.update({
|
||||||
where: { fileId: this.getFileId(upload.id) },
|
where: { fileId: this.getFileId(upload.id) },
|
||||||
data: { status: ResourceStatus.UPLOADED }
|
data: { status: ResourceStatus.UPLOADED },
|
||||||
});
|
});
|
||||||
this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id })
|
this.fileQueue.add(
|
||||||
|
QueueJobType.FILE_PROCESS,
|
||||||
|
{ resource },
|
||||||
|
{ jobId: resource.id },
|
||||||
|
);
|
||||||
this.logger.log(`Upload finished ${resource.url}`);
|
this.logger.log(`Upload finished ${resource.url}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to update resource after upload', error);
|
this.logger.error('Failed to update resource after upload', error);
|
||||||
|
@ -97,19 +110,22 @@ export class TusService implements OnModuleInit {
|
||||||
// Delete incomplete uploads older than 24 hours
|
// Delete incomplete uploads older than 24 hours
|
||||||
const deletedResources = await db.resource.deleteMany({
|
const deletedResources = await db.resource.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod) },
|
createdAt: {
|
||||||
status: ResourceStatus.UPLOADING
|
lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod),
|
||||||
}
|
},
|
||||||
|
status: ResourceStatus.UPLOADING,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
|
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
|
||||||
this.logger.log(`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`);
|
this.logger.log(
|
||||||
|
`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Expired uploads cleanup failed', error);
|
this.logger.error('Expired uploads cleanup failed', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleTus(req: Request, res: Response) {
|
async handleTus(req: Request, res: Response) {
|
||||||
|
|
||||||
return this.tusServer.handle(req, res);
|
return this.tusServer.handle(req, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -11,7 +11,7 @@ import {
|
||||||
Head,
|
Head,
|
||||||
Options,
|
Options,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request, Response } from "express"
|
import { Request, Response } from 'express';
|
||||||
import { TusService } from './tus.service';
|
import { TusService } from './tus.service';
|
||||||
|
|
||||||
@Controller('upload')
|
@Controller('upload')
|
||||||
|
@ -22,7 +22,6 @@ export class UploadController {
|
||||||
// return this.tusService.handleTus(req, res);
|
// return this.tusService.handleTus(req, res);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
|
||||||
@Options()
|
@Options()
|
||||||
async handleOptions(@Req() req: Request, @Res() res: Response) {
|
async handleOptions(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
|
@ -37,12 +36,12 @@ export class UploadController {
|
||||||
async handlePost(@Req() req: Request, @Res() res: Response) {
|
async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
}
|
}
|
||||||
@Get("/*")
|
@Get('/*')
|
||||||
async handleGet(@Req() req: Request, @Res() res: Response) {
|
async handleGet(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("/*")
|
@Patch('/*')
|
||||||
async handlePatch(@Req() req: Request, @Res() res: Response) {
|
async handlePatch(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function LetterDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{id}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,27 +2,30 @@ import { Leader } from "./types";
|
||||||
|
|
||||||
export const leaders: Leader[] = [
|
export const leaders: Leader[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: "1",
|
||||||
name: 'John Mitchell',
|
name: "John Mitchell",
|
||||||
rank: 'General',
|
rank: "General",
|
||||||
division: 'Air Combat Command',
|
division: "Air Combat Command",
|
||||||
imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain',
|
imageUrl:
|
||||||
email: 'j.mitchell@af.mil',
|
"https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain",
|
||||||
phone: '(555) 123-4567',
|
email: "j.mitchell@af.mil",
|
||||||
office: 'Pentagon, Wing A-123',
|
phone: "(555) 123-4567",
|
||||||
|
office: "Pentagon, Wing A-123",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: "2",
|
||||||
name: 'Sarah Williams',
|
name: "Sarah Williams",
|
||||||
rank: 'Colonel',
|
rank: "Colonel",
|
||||||
division: 'Air Force Space Command',
|
division: "Air Force Space Command",
|
||||||
imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain',
|
imageUrl:
|
||||||
|
"https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: "3",
|
||||||
name: 'Michael Roberts',
|
name: "Michael Roberts",
|
||||||
rank: 'Major General',
|
rank: "Major General",
|
||||||
division: 'Air Mobility Command',
|
division: "Air Mobility Command",
|
||||||
imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain',
|
imageUrl:
|
||||||
|
"https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain",
|
||||||
},
|
},
|
||||||
];
|
];
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from "react";
|
||||||
interface QuillCharCounterProps {
|
interface QuillCharCounterProps {
|
||||||
currentCount: number;
|
currentCount: number;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
@ -7,16 +8,17 @@ interface QuillCharCounterProps {
|
||||||
const QuillCharCounter: React.FC<QuillCharCounterProps> = ({
|
const QuillCharCounter: React.FC<QuillCharCounterProps> = ({
|
||||||
currentCount,
|
currentCount,
|
||||||
maxLength,
|
maxLength,
|
||||||
minLength = 0
|
minLength = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const getStatusColor = () => {
|
const getStatusColor = () => {
|
||||||
if (currentCount > (maxLength || Infinity)) return 'text-red-500';
|
if (currentCount > (maxLength || Infinity)) return "text-red-500";
|
||||||
if (currentCount < minLength) return 'text-amber-500';
|
if (currentCount < minLength) return "text-amber-500";
|
||||||
return 'text-gray-500';
|
return "text-gray-500";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`
|
<div
|
||||||
|
className={`
|
||||||
flex items-center justify-end gap-1
|
flex items-center justify-end gap-1
|
||||||
px-3 py-1.5 text-sm
|
px-3 py-1.5 text-sm
|
||||||
${getStatusColor()}
|
${getStatusColor()}
|
||||||
|
@ -39,4 +41,4 @@ const QuillCharCounter: React.FC<QuillCharCounterProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default QuillCharCounter
|
export default QuillCharCounter;
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,20 +1,34 @@
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from "react-hook-form";
|
||||||
import { useRef, useState } from 'react';
|
import React, { useRef, useState } from "react";
|
||||||
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { CheckIcon, PencilIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import FormError from './FormError';
|
import FormError from "./FormError";
|
||||||
import { Button } from '../element/Button';
|
import { Button } from "../element/Button";
|
||||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
export interface FormInputProps
|
||||||
|
extends Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
"type"
|
||||||
|
> {
|
||||||
name: string;
|
name: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
|
type?:
|
||||||
|
| "text"
|
||||||
|
| "textarea"
|
||||||
|
| "password"
|
||||||
|
| "email"
|
||||||
|
| "number"
|
||||||
|
| "tel"
|
||||||
|
| "url"
|
||||||
|
| "search"
|
||||||
|
| "date"
|
||||||
|
| "time"
|
||||||
|
| "datetime-local";
|
||||||
rows?: number;
|
rows?: number;
|
||||||
viewMode?: boolean;
|
viewMode?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
export function FormInput({
|
export function FormInput({
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
type = 'text',
|
type = "text",
|
||||||
rows = 4,
|
rows = 4,
|
||||||
className,
|
className,
|
||||||
viewMode = false, // 默认为编辑模式
|
viewMode = false, // 默认为编辑模式
|
||||||
|
@ -34,9 +48,8 @@ export function FormInput({
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
await trigger(name); // Trigger validation for this field
|
await trigger(name); // Trigger validation for this field
|
||||||
if (viewMode) {
|
if (viewMode) {
|
||||||
setIsEditing(false)
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
const value = watch(name);
|
const value = watch(name);
|
||||||
const error = errors[name]?.message as string;
|
const error = errors[name]?.message as string;
|
||||||
|
@ -44,12 +57,13 @@ export function FormInput({
|
||||||
const inputClasses = `
|
const inputClasses = `
|
||||||
w-full rounded-lg border bg-white px-4 py-2 outline-none
|
w-full rounded-lg border bg-white px-4 py-2 outline-none
|
||||||
transition-all duration-200 ease-out placeholder:text-gray-400
|
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'
|
error
|
||||||
: 'border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100'
|
? "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' : ''}
|
${isFocused ? "ring-2 ring-opacity-50" : ""}
|
||||||
${className || ''}
|
${className || ""}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const viewModeClasses = `
|
const viewModeClasses = `
|
||||||
|
@ -58,13 +72,10 @@ export function FormInput({
|
||||||
transition-all duration-200 ease-out select-none
|
transition-all duration-200 ease-out select-none
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const InputElement = type === 'textarea' ? 'textarea' : 'input';
|
const InputElement = type === "textarea" ? "textarea" : "input";
|
||||||
|
|
||||||
const renderViewMode = () => (
|
const renderViewMode = () => (
|
||||||
<div
|
<div className={viewModeClasses} onClick={() => setIsEditing(true)}>
|
||||||
className={viewModeClasses}
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
<span className="font-medium ">
|
<span className="font-medium ">
|
||||||
{value || <span className="text-gray-400">点击编辑</span>}
|
{value || <span className="text-gray-400">点击编辑</span>}
|
||||||
</span>
|
</span>
|
||||||
|
@ -73,13 +84,10 @@ export function FormInput({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
size='xs'
|
size="xs"
|
||||||
variant='ghost'
|
variant="ghost"
|
||||||
leftIcon={<PencilIcon />}
|
leftIcon={<PencilIcon />}
|
||||||
className="absolute -right-10 opacity-0 group-hover:opacity-100 "
|
className="absolute -right-10 opacity-0 group-hover:opacity-100 "></Button>
|
||||||
>
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -87,8 +95,8 @@ export function FormInput({
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InputElement
|
<InputElement
|
||||||
{...register(name)}
|
{...register(name)}
|
||||||
type={type !== 'textarea' ? type : undefined}
|
type={type !== "textarea" ? type : undefined}
|
||||||
rows={type === 'textarea' ? rows : undefined}
|
rows={type === "textarea" ? rows : undefined}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
@ -96,7 +104,8 @@ export function FormInput({
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="
|
<div
|
||||||
|
className="
|
||||||
absolute right-3 top-1/2 -translate-y-1/2
|
absolute right-3 top-1/2 -translate-y-1/2
|
||||||
flex items-center space-x-2
|
flex items-center space-x-2
|
||||||
">
|
">
|
||||||
|
@ -109,18 +118,19 @@ export function FormInput({
|
||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
"
|
"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => setValue(name, '')}
|
onClick={() => setValue(name, "")}
|
||||||
aria-label={`清除${label}`}
|
aria-label={`清除${label}`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}>
|
||||||
>
|
|
||||||
<XMarkIcon className="w-4 h-4" />
|
<XMarkIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isValid && (
|
{isValid && (
|
||||||
<CheckIcon className="
|
<CheckIcon
|
||||||
|
className="
|
||||||
text-green-500 w-4 h-4
|
text-green-500 w-4 h-4
|
||||||
animate-fade-in duration-200
|
animate-fade-in duration-200
|
||||||
" />
|
"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{error && <FormError error={error} />}
|
{error && <FormError error={error} />}
|
||||||
</div>
|
</div>
|
||||||
|
@ -128,8 +138,10 @@ export function FormInput({
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div ref={inputWrapper} className="flex flex-col gap-2">
|
<div ref={inputWrapper} className="flex flex-col gap-2">
|
||||||
{label && <div className="flex justify-between items-center px-0.5">
|
{label && (
|
||||||
<label className="
|
<div className="flex justify-between items-center px-0.5">
|
||||||
|
<label
|
||||||
|
className="
|
||||||
text-sm font-medium text-gray-700
|
text-sm font-medium text-gray-700
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
group-focus-within:text-blue-600
|
group-focus-within:text-blue-600
|
||||||
|
@ -141,7 +153,8 @@ export function FormInput({
|
||||||
{value?.length || 0}/{restProps.maxLength}
|
{value?.length || 0}/{restProps.maxLength}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>}
|
</div>
|
||||||
|
)}
|
||||||
{viewMode && !isEditing ? renderViewMode() : renderEditMode()}
|
{viewMode && !isEditing ? renderViewMode() : renderEditMode()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { useFormContext, Controller } from 'react-hook-form';
|
import { useFormContext, Controller } from "react-hook-form";
|
||||||
import FormError from './FormError';
|
import FormError from "./FormError";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import QuillEditor from '../editor/quill/QuillEditor';
|
import QuillEditor from "../editor/quill/QuillEditor";
|
||||||
|
|
||||||
export interface FormQuillInputProps {
|
export interface FormQuillInputProps {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
|
@ -24,7 +24,7 @@ export function FormQuillInput({
|
||||||
className,
|
className,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
maxRows = 10,
|
maxRows = 10,
|
||||||
minRows = 4
|
minRows = 4,
|
||||||
}: FormQuillInputProps) {
|
}: FormQuillInputProps) {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const {
|
const {
|
||||||
|
@ -36,23 +36,27 @@ export function FormQuillInput({
|
||||||
const error = errors[name]?.message as string;
|
const error = errors[name]?.message as string;
|
||||||
|
|
||||||
const handleBlur = async () => {
|
const handleBlur = async () => {
|
||||||
|
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
await trigger(name);
|
await trigger(name);
|
||||||
};
|
};
|
||||||
console.log(isFocused)
|
console.log(isFocused);
|
||||||
const containerClasses = `
|
const containerClasses = `
|
||||||
w-full rounded-md border bg-white shadow-sm
|
w-full rounded-md border bg-white shadow-sm
|
||||||
transition-all duration-300 ease-out
|
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'}`
|
isFocused
|
||||||
: 'border-gray-300'
|
? `ring-2 ring-opacity-50 ${error ? "ring-red-200 border-red-500" : "ring-blue-200 border-blue-500"}`
|
||||||
|
: "border-gray-300"
|
||||||
}
|
}
|
||||||
${className}
|
${className}
|
||||||
`.trim()
|
`.trim();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
<Controller
|
<Controller
|
||||||
name={name}
|
name={name}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,41 +1,53 @@
|
||||||
import { useState, useCallback, useRef, memo } from 'react'
|
// FileUploader.tsx
|
||||||
import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
import React, { useRef, memo, useState } from "react";
|
||||||
import * as tus from 'tus-js-client'
|
import {
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
CloudArrowUpIcon,
|
||||||
import { toast } from 'react-hot-toast'
|
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 {
|
interface FileUploaderProps {
|
||||||
endpoint?: string
|
endpoint?: string;
|
||||||
onSuccess?: (url: string) => void
|
onSuccess?: (url: string) => void;
|
||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void;
|
||||||
maxSize?: number
|
maxSize?: number;
|
||||||
allowedTypes?: string[]
|
allowedTypes?: string[];
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileItem = memo(({ file, progress, onRemove }: {
|
interface FileItemProps {
|
||||||
file: File
|
file: File;
|
||||||
progress?: number
|
progress?: number;
|
||||||
onRemove: (name: string) => void
|
onRemove: (name: string) => void;
|
||||||
}) => (
|
isUploaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileItem: React.FC<FileItemProps> = memo(
|
||||||
|
({ file, progress, onRemove, isUploaded }) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, x: -20 }}
|
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"
|
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" />
|
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
||||||
<div className="ml-3 flex-1">
|
<div className="ml-3 flex-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p>
|
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemove(file.name)}
|
onClick={() => onRemove(file.name)}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
||||||
aria-label={`Remove ${file.name}`}
|
aria-label={`Remove ${file.name}`}>
|
||||||
>
|
|
||||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{progress !== undefined && (
|
{!isUploaded && progress !== undefined && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -45,167 +57,181 @@ const FileItem = memo(({ file, progress, onRemove }: {
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-500 mt-1">{progress}%</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))
|
)
|
||||||
|
);
|
||||||
|
|
||||||
export default function FileUploader({
|
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||||
endpoint = '',
|
endpoint = "",
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
maxSize = 100,
|
maxSize = 100,
|
||||||
placeholder = '点击或拖拽文件到这里上传',
|
placeholder = "点击或拖拽文件到这里上传",
|
||||||
allowedTypes = ['*/*']
|
allowedTypes = ["*/*"],
|
||||||
}: FileUploaderProps) {
|
}) => {
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<
|
||||||
const [progress, setProgress] = useState<{ [key: string]: number }>({})
|
Array<{ file: File; isUploaded: boolean }>
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleError = useCallback((error: Error) => {
|
const { progress, isUploading, uploadError, handleFileUpload } =
|
||||||
toast.error(error.message)
|
useTusUpload();
|
||||||
onError?.(error)
|
|
||||||
}, [onError])
|
|
||||||
|
|
||||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
const handleError = (error: Error) => {
|
||||||
e.preventDefault()
|
toast.error(error.message);
|
||||||
e.stopPropagation()
|
onError?.(error);
|
||||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
};
|
||||||
setIsDragging(true)
|
|
||||||
} else if (e.type === 'dragleave') {
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
setIsDragging(false)
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setIsDragging(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setIsDragging(false);
|
||||||
}
|
}
|
||||||
}, [])
|
};
|
||||||
|
|
||||||
const validateFile = useCallback((file: File) => {
|
const validateFile = (file: File) => {
|
||||||
if (file.size > maxSize * 1024 * 1024) {
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
throw new Error(`文件大小不能超过 ${maxSize}MB`)
|
throw new Error(`文件大小不能超过 ${maxSize}MB`);
|
||||||
}
|
}
|
||||||
if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) {
|
if (
|
||||||
throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`)
|
!allowedTypes.includes("*/*") &&
|
||||||
|
!allowedTypes.includes(file.type)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [maxSize, allowedTypes])
|
};
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
const uploadFile = (file: File) => {
|
||||||
try {
|
try {
|
||||||
validateFile(file)
|
validateFile(file);
|
||||||
|
handleFileUpload(
|
||||||
const upload = new tus.Upload(file, {
|
file,
|
||||||
endpoint,
|
(upload) => {
|
||||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
onSuccess?.(upload.url || "");
|
||||||
metadata: {
|
setFiles((prev) =>
|
||||||
filename: file.name,
|
prev.map((f) =>
|
||||||
filetype: file.type
|
f.file.name === file.name
|
||||||
|
? { ...f, isUploaded: true }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: handleError,
|
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
upload.start()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error as Error)
|
handleError(error as Error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setIsDragging(false)
|
setIsDragging(false);
|
||||||
|
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
setFiles(prev => [...prev, ...droppedFiles])
|
setFiles((prev) => [
|
||||||
droppedFiles.forEach(uploadFile)
|
...prev,
|
||||||
}, [])
|
...droppedFiles.map((file) => ({ file, isUploaded: false })),
|
||||||
|
]);
|
||||||
|
droppedFiles.forEach(uploadFile);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
const selectedFiles = Array.from(e.target.files)
|
const selectedFiles = Array.from(e.target.files);
|
||||||
setFiles(prev => [...prev, ...selectedFiles])
|
setFiles((prev) => [
|
||||||
selectedFiles.forEach(uploadFile)
|
...prev,
|
||||||
}
|
...selectedFiles.map((file) => ({ file, isUploaded: false })),
|
||||||
|
]);
|
||||||
|
selectedFiles.forEach(uploadFile);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeFile = (fileName: string) => {
|
const removeFile = (fileName: string) => {
|
||||||
setFiles(prev => prev.filter(file => file.name !== fileName))
|
setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
|
||||||
setProgress(prev => {
|
};
|
||||||
const newProgress = { ...prev }
|
|
||||||
delete newProgress[fileName]
|
const handleClick = () => {
|
||||||
return newProgress
|
fileInputRef.current?.click();
|
||||||
})
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
<motion.div
|
<div
|
||||||
className={`relative border-2 border-dashed rounded-xl p-8 transition-all
|
onClick={handleClick}
|
||||||
${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}
|
onDragEnter={handleDrag}
|
||||||
onDragLeave={handleDrag}
|
onDragLeave={handleDrag}
|
||||||
onDragOver={handleDrag}
|
onDragOver={handleDrag}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
role="button"
|
className={`
|
||||||
tabIndex={0}
|
relative flex flex-col items-center justify-center w-full h-32
|
||||||
onClick={() => fileInputRef.current?.click()}
|
border-2 border-dashed rounded-lg cursor-pointer
|
||||||
aria-label="文件上传区域"
|
transition-colors duration-200 ease-in-out
|
||||||
>
|
${
|
||||||
|
isDragging
|
||||||
|
? "border-blue-500 bg-blue-50"
|
||||||
|
: "border-gray-300 hover:border-blue-500"
|
||||||
|
}
|
||||||
|
`}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
className="hidden"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
accept={allowedTypes.join(',')}
|
accept={allowedTypes.join(",")}
|
||||||
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
|
||||||
<div className="flex flex-col items-center justify-center space-y-4">
|
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
|
||||||
<motion.div
|
{isDragging && (
|
||||||
animate={{ y: isDragging ? -10 : 0 }}
|
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
<p className="text-blue-500 font-medium">
|
||||||
>
|
释放文件以上传
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{files.map(file => (
|
{files.map(({ file, isUploaded }) => (
|
||||||
<FileItem
|
<FileItem
|
||||||
key={file.name}
|
key={file.name}
|
||||||
file={file}
|
file={file}
|
||||||
progress={progress[file.name]}
|
progress={isUploaded ? 100 : progress}
|
||||||
onRemove={removeFile}
|
onRemove={removeFile}
|
||||||
|
isUploaded={isUploaded}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AnimatePresence>
|
</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>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUploader;
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||||
import { Button, Select, Spin } from "antd";
|
import { Button, Select, Spin } from "antd";
|
||||||
import type { SelectProps } from "antd";
|
import type { SelectProps } from "antd";
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
|
import React from "react";
|
||||||
interface StaffSelectProps {
|
interface StaffSelectProps {
|
||||||
value?: string | string[];
|
value?: string | string[];
|
||||||
onChange?: (value: string | string[]) => void;
|
onChange?: (value: string | string[]) => void;
|
||||||
|
@ -42,16 +43,15 @@ export default function StaffSelect({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
in: ids
|
in: ids,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
domainId,
|
domainId,
|
||||||
|
|
||||||
},
|
},
|
||||||
select: { id: true, showname: true, username: true },
|
select: { id: true, showname: true, username: true },
|
||||||
take: 30,
|
take: 30,
|
||||||
orderBy: { order: "asc" }
|
orderBy: { order: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
const handleSearch = (value: string) => {
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -24,6 +24,11 @@ import LetterListPage from "../app/main/letter/list/page";
|
||||||
import LetterProgressPage from "../app/main/letter/progress/page";
|
import LetterProgressPage from "../app/main/letter/progress/page";
|
||||||
import HelpPage from "../app/main/help/page";
|
import HelpPage from "../app/main/help/page";
|
||||||
import AuthPage from "../app/auth/page";
|
import AuthPage from "../app/auth/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 {
|
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -66,6 +71,14 @@ export const routes: CustomRouteObject[] = [
|
||||||
index: true,
|
index: true,
|
||||||
element: <HomePage />,
|
element: <HomePage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ":id?/detail",
|
||||||
|
element: <LetterDetailPage></LetterDetailPage>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "editor",
|
||||||
|
element: <EditorLetterPage></EditorLetterPage>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "write-letter",
|
path: "write-letter",
|
||||||
element: <WriteLetterPage></WriteLetterPage>,
|
element: <WriteLetterPage></WriteLetterPage>,
|
||||||
|
@ -84,30 +97,36 @@ export const routes: CustomRouteObject[] = [
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "course",
|
path: "course",
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: ":id?/editor",
|
path: ":id?/editor",
|
||||||
element: <CourseEditorLayout></CourseEditorLayout>,
|
element: <CourseEditorLayout></CourseEditorLayout>,
|
||||||
children: [{
|
children: [
|
||||||
|
{
|
||||||
index: true,
|
index: true,
|
||||||
element: <CourseBasicForm></CourseBasicForm>
|
element: <CourseBasicForm></CourseBasicForm>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'goal',
|
path: "goal",
|
||||||
element: <CourseGoalForm></CourseGoalForm>
|
element: <CourseGoalForm></CourseGoalForm>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'content',
|
path: "content",
|
||||||
element: <CourseContentForm></CourseContentForm>
|
element: (
|
||||||
|
<CourseContentForm></CourseContentForm>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'setting',
|
path: "setting",
|
||||||
element: <CourseSettingForm></CourseSettingForm>
|
element: (
|
||||||
}
|
<CourseSettingForm></CourseSettingForm>
|
||||||
]
|
),
|
||||||
}
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -27,11 +27,13 @@ model Taxonomy {
|
||||||
model Term {
|
model Term {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
posts Post[]
|
||||||
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")
|
||||||
description String?
|
description String?
|
||||||
parentId String? @map("parent_id")
|
parentId String? @map("parent_id")
|
||||||
|
|
||||||
parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade)
|
parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
children Term[] @relation("ChildParent")
|
children Term[] @relation("ChildParent")
|
||||||
ancestors TermAncestry[] @relation("DescendantToAncestor")
|
ancestors TermAncestry[] @relation("DescendantToAncestor")
|
||||||
|
@ -188,6 +190,8 @@ model Post {
|
||||||
title String? // 帖子标题,可为空
|
title String? // 帖子标题,可为空
|
||||||
content String? // 帖子内容,可为空
|
content String? // 帖子内容,可为空
|
||||||
domainId String? @map("domain_id")
|
domainId String? @map("domain_id")
|
||||||
|
term Term? @relation(fields: [termId], references: [id])
|
||||||
|
termId String? @map("term_id")
|
||||||
// 日期时间类型字段
|
// 日期时间类型字段
|
||||||
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")
|
||||||
|
@ -202,7 +206,7 @@ model Post {
|
||||||
children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型
|
children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型
|
||||||
resources Resource[] // 附件列表
|
resources Resource[] // 附件列表
|
||||||
isPublic Boolean? @default(false) @map("is_public")
|
isPublic Boolean? @default(false) @map("is_public")
|
||||||
meta Json? // 签名 和 IP
|
meta Json? // 签名 和 IP 和 tags
|
||||||
|
|
||||||
// 复合索引
|
// 复合索引
|
||||||
@@index([type, domainId]) // 类型和域组合查询
|
@@index([type, domainId]) // 类型和域组合查询
|
||||||
|
|
Loading…
Reference in New Issue