This commit is contained in:
longdayi 2025-01-22 23:20:29 +08:00
commit f6d85dc644
25 changed files with 1745 additions and 646 deletions

View File

@ -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;
} }
} }

View File

@ -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({

View File

@ -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,

View File

@ -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);
}), }),
}); });
} }

View File

@ -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;
// } // }
} }

View File

@ -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);
} }
} }

View File

@ -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);
} }

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

@ -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",
}, },
]; ];

View File

@ -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;

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,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>
); );

View File

@ -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}

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,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;

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

@ -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 { 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) => {

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

@ -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>
] ),
} },
],
},
], ],
}, },
{ {

View File

@ -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]) // 类型和域组合查询