This commit is contained in:
ditiqi 2025-01-25 00:46:59 +08:00
parent fe5750199a
commit 0d38c1f838
32 changed files with 1050 additions and 848 deletions

View File

@ -99,7 +99,7 @@ export async function updatePostState(id: string) {
}, },
}, },
}); });
if (post?.state === PostState.COMPLETED) { if (post?.state === PostState.RESOLVED) {
return; return;
} }
const postReceiverIds = post.receivers const postReceiverIds = post.receivers
@ -132,7 +132,7 @@ export async function updatePostState(id: string) {
if (receiverComments > 0) { if (receiverComments > 0) {
await db.post.update({ await db.post.update({
where: { id }, where: { id },
data: { state: PostState.COMPLETED }, data: { state: PostState.RESOLVED },
}); });
} }
} }

View File

@ -10,7 +10,7 @@ const pipeline = new ResourceProcessingPipeline()
.addProcessor(new VideoProcessor()); .addProcessor(new VideoProcessor());
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.FILE_PROCESS) { if (job.name === QueueJobType.FILE_PROCESS) {
console.log(job); // console.log(job);
const { resource } = job.data; const { resource } = job.data;
if (!resource) { if (!resource) {
throw new Error('No resource provided in job data'); throw new Error('No resource provided in job data');

View File

@ -89,6 +89,7 @@ export class TusService implements OnModuleInit {
upload: Upload, upload: Upload,
) { ) {
try { try {
console.log('upload.id', upload.id);
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 },

View File

@ -7,10 +7,15 @@ export interface UploadCompleteEvent {
} }
export type UploadEvent = { export type UploadEvent = {
uploadStart: { identifier: string; filename: string; totalSize: number, resuming?: boolean }; uploadStart: {
uploadComplete: UploadCompleteEvent identifier: string;
uploadError: { identifier: string; error: string, filename: string }; filename: string;
} totalSize: number;
resuming?: boolean;
};
uploadComplete: UploadCompleteEvent;
uploadError: { identifier: string; error: string; filename: string };
};
export interface UploadLock { export interface UploadLock {
clientId: string; clientId: string;
timestamp: number; timestamp: number;

View File

@ -13,10 +13,25 @@ import {
} 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';
import { ResourceService } from '@server/models/resource/resource.service';
@Controller('upload') @Controller('upload')
export class UploadController { export class UploadController {
constructor(private readonly tusService: TusService) {} constructor(
private readonly tusService: TusService,
private readonly resourceService: ResourceService,
) {}
@Get('resource/:fileId')
async getResourceByFileId(@Param('fileId') fileId: string) {
const resource = await this.resourceService.findFirst({
where: { fileId },
});
if (!resource) {
return { status: 'pending' };
}
return { status: 'ready', resource };
}
// @Post() // @Post()
// 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);

View File

@ -1,5 +1,5 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import LetterEditorLayout from "@web/src/components/models/post/LetterEditor/layout/LetterEditorLayout"; import LetterEditorLayout from "@web/src/components/models/post/editor/layout/LetterEditorLayout";
export default function EditorLetterPage() { export default function EditorLetterPage() {
return ( return (

View File

@ -1,9 +1,17 @@
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons'; import {
import { Button, Typography, Space, Tooltip } from 'antd'; EyeOutlined,
import toast from 'react-hot-toast'; LikeOutlined,
import { Letter } from './types'; LikeFilled,
import { getBadgeStyle } from './utils'; UserOutlined,
import { useState } from 'react'; BankOutlined,
CalendarOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { Button, Typography, Space, Tooltip } from "antd";
import toast from "react-hot-toast";
import { Letter } from "./types";
import { getBadgeStyle } from "./utils";
import { useState } from "react";
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
@ -18,27 +26,23 @@ export function LetterCard({ letter }: LetterCardProps) {
const handleLike = () => { const handleLike = () => {
if (!liked) { if (!liked) {
setLikes(prev => prev + 1); setLikes((prev) => prev + 1);
setLiked(true); setLiked(true);
toast.success('已点赞!', { toast.success("已点赞!", {
icon: <LikeFilled className="text-blue-500" />, icon: <LikeFilled className="text-blue-500" />,
className: 'custom-message', className: "custom-message",
}); });
} else { } else {
setLikes(prev => prev - 1); setLikes((prev) => prev - 1);
setLiked(false); setLiked(false);
toast('已取消点赞', { toast("已取消点赞", {
className: 'custom-message', className: "custom-message",
}); });
} }
}; };
return ( return (
<div <div className="w-full p-4 bg-white transition-all duration-300 ease-in-out group">
className="w-full p-4 bg-white transition-all duration-300 ease-in-out group"
>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Title & Priority */} {/* Title & Priority */}
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
@ -49,13 +53,16 @@ export function LetterCard({ letter }: LetterCardProps) {
className="text-primary transition-all duration-300 relative className="text-primary transition-all duration-300 relative
before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-primary-600 before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-primary-600
group-hover:before:w-full before:transition-all before:duration-300 group-hover:before:w-full before:transition-all before:duration-300
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md" group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md">
>
{letter.title} {letter.title}
</a> </a>
</Title> </Title>
{letter.priority && ( {letter.priority && (
<Badge type="priority" value={letter.priority} className="ml-2" /> <Badge
type="priority"
value={letter.priority}
className="ml-2"
/>
)} )}
</div> </div>
@ -84,8 +91,7 @@ export function LetterCard({ letter }: LetterCardProps) {
<FileTextOutlined className="text-gray-400 mt-1" /> <FileTextOutlined className="text-gray-400 mt-1" />
<Paragraph <Paragraph
ellipsis={{ rows: 2 }} ellipsis={{ rows: 2 }}
className="!mb-3 text-gray-600 flex-1" className="!mb-3 text-gray-600 flex-1">
>
{letter.content} {letter.content}
</Paragraph> </Paragraph>
</div> </div>
@ -104,11 +110,10 @@ export function LetterCard({ letter }: LetterCardProps) {
<span className="text-sm">{views}</span> <span className="text-sm">{views}</span>
</div> </div>
<Tooltip <Tooltip
title={liked ? '取消点赞' : '点赞'} title={liked ? "取消点赞" : "点赞"}
placement="top" placement="top">
>
<Button <Button
type={liked ? 'primary' : 'default'} type={liked ? "primary" : "default"}
shape="round" shape="round"
size="small" size="small"
icon={liked ? <LikeFilled /> : <LikeOutlined />} icon={liked ? <LikeFilled /> : <LikeOutlined />}
@ -116,10 +121,11 @@ export function LetterCard({ letter }: LetterCardProps) {
className={` className={`
flex items-center gap-1 px-3 transform transition-all duration-300 flex items-center gap-1 px-3 transform transition-all duration-300
hover:scale-105 hover:shadow-md hover:scale-105 hover:shadow-md
${liked ? 'bg-blue-500 hover:bg-blue-600' : 'hover:border-blue-500 hover:text-blue-500'} ${liked ? "bg-blue-500 hover:bg-blue-600" : "hover:border-blue-500 hover:text-blue-500"}
`} `}>
> <span className={liked ? "text-white" : ""}>
<span className={liked ? 'text-white' : ''}>{likes}</span> {likes}
</span>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@ -129,25 +135,27 @@ export function LetterCard({ letter }: LetterCardProps) {
); );
} }
function Badge({ export function Badge({
type, type,
value, value,
className = '' className = "",
}: { }: {
type: 'priority' | 'category' | 'status'; type: "priority" | "category" | "status";
value: string; value: string;
className?: string; className?: string;
}) { }) {
return ( return (
value && (
<span <span
className={` className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${getBadgeStyle(type, value)} ${getBadgeStyle(type, value)}
transition-all duration-200 ease-in-out transform hover:scale-105 transition-all duration-200 ease-in-out transform hover:scale-105
${className} ${className}
`} `}>
>
{value.toUpperCase()} {value?.toUpperCase()}
</span> </span>
)
); );
} }

View File

@ -1,6 +1,6 @@
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from "@ant-design/icons";
import { Form, Input, Select, Spin } from 'antd'; import { Form, Input, Select, Spin } from "antd";
import { useEffect } from 'react'; import { useEffect } from "react";
interface SearchFiltersProps { interface SearchFiltersProps {
searchTerm: string; searchTerm: string;
@ -13,9 +13,7 @@ interface SearchFiltersProps {
isLoading?: boolean; isLoading?: boolean;
} }
const LoadingIndicator = () => ( const LoadingIndicator = () => <Spin size="small" className="ml-2" />;
<Spin size="small" className="ml-2" />
);
export function SearchFilters({ export function SearchFilters({
searchTerm, searchTerm,
@ -25,7 +23,7 @@ export function SearchFilters({
filterStatus, filterStatus,
onStatusChange, onStatusChange,
className, className,
isLoading = false isLoading = false,
}: SearchFiltersProps) { }: SearchFiltersProps) {
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -33,7 +31,7 @@ export function SearchFilters({
const initialValues = { const initialValues = {
search: searchTerm, search: searchTerm,
category: filterCategory, category: filterCategory,
status: filterStatus status: filterStatus,
}; };
useEffect(() => { useEffect(() => {
@ -45,8 +43,7 @@ export function SearchFilters({
form={form} form={form}
layout="vertical" layout="vertical"
className={className} className={className}
initialValues={initialValues} initialValues={initialValues}>
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Form.Item name="search" noStyle> <Form.Item name="search" noStyle>
<Input <Input
@ -63,11 +60,11 @@ export function SearchFilters({
className="w-full" className="w-full"
onChange={onCategoryChange} onChange={onCategoryChange}
options={[ options={[
{ value: 'all', label: '所有分类' }, { value: "all", label: "所有分类" },
{ value: 'complaint', label: '投诉' }, { value: "complaint", label: "投诉" },
{ value: 'suggestion', label: '建议' }, { value: "suggestion", label: "建议" },
{ value: 'request', label: '请求' }, { value: "request", label: "请求" },
{ value: 'feedback', label: '反馈' } { value: "feedback", label: "反馈" },
]} ]}
/> />
</Form.Item> </Form.Item>
@ -77,10 +74,10 @@ export function SearchFilters({
className="w-full" className="w-full"
onChange={onStatusChange} onChange={onStatusChange}
options={[ options={[
{ value: 'all', label: '所有状态' }, { value: "all", label: "所有状态" },
{ value: 'pending', label: '待处理' }, { value: "pending", label: "待处理" },
{ value: 'in-progress', label: '处理中' }, { value: "processing", label: "处理中" },
{ value: 'resolved', label: '已解决' } { value: "resolved", label: "已解决" },
]} ]}
/> />
</Form.Item> </Form.Item>

View File

@ -2,55 +2,55 @@ import { Letter } from "./types";
export const letters: Letter[] = [ export const letters: Letter[] = [
{ {
id: '1', id: "1",
title: 'F-35 Maintenance Schedule Optimization Proposal', title: "F-35 Maintenance Schedule Optimization Proposal",
sender: 'John Doe', sender: "John Doe",
rank: 'TSgt', rank: "TSgt",
unit: '33d Fighter Wing', unit: "33d Fighter Wing",
date: '2025-01-22', date: "2025-01-22",
priority: 'high', priority: "high",
status: 'pending', status: "pending",
category: 'suggestion', category: "suggestion",
isStarred: false, isStarred: false,
content: 'Proposal for improving F-35 maintenance efficiency...' content: "Proposal for improving F-35 maintenance efficiency...",
}, },
{ {
id: '2', id: "2",
title: 'Base Housing Facility Improvement Request', title: "Base Housing Facility Improvement Request",
sender: 'Jane Smith', sender: "Jane Smith",
rank: 'SSgt', rank: "SSgt",
unit: '96th Test Wing', unit: "96th Test Wing",
date: '2025-01-21', date: "2025-01-21",
priority: 'medium', priority: "medium",
status: 'in-progress', status: "processing",
category: 'request', category: "request",
isStarred: true, isStarred: true,
content: 'Request for updating base housing facilities...' content: "Request for updating base housing facilities...",
}, },
{ {
id: '3', id: "3",
title: 'Training Program Enhancement Feedback', title: "Training Program Enhancement Feedback",
sender: 'Robert Johnson', sender: "Robert Johnson",
rank: 'Capt', rank: "Capt",
unit: '58th Special Operations Wing', unit: "58th Special Operations Wing",
date: '2025-01-20', date: "2025-01-20",
priority: 'medium', priority: "medium",
status: 'pending', status: "pending",
category: 'feedback', category: "feedback",
isStarred: false, isStarred: false,
content: 'Feedback regarding current training procedures...' content: "Feedback regarding current training procedures...",
}, },
{ {
id: '4', id: "4",
title: 'Cybersecurity Protocol Update Suggestion', title: "Cybersecurity Protocol Update Suggestion",
sender: 'Emily Wilson', sender: "Emily Wilson",
rank: 'MSgt', rank: "MSgt",
unit: '67th Cyberspace Wing', unit: "67th Cyberspace Wing",
date: '2025-01-19', date: "2025-01-19",
priority: 'high', priority: "high",
status: 'pending', status: "pending",
category: 'suggestion', category: "suggestion",
isStarred: true, isStarred: true,
content: 'Suggestions for improving base cybersecurity measures...' content: "Suggestions for improving base cybersecurity measures...",
} },
]; ];

View File

@ -1,6 +1,6 @@
export type Priority = 'high' | 'medium' | 'low'; export type Priority = "high" | "medium" | "low";
export type Category = 'complaint' | 'suggestion' | 'request' | 'feedback'; export type Category = "complaint" | "suggestion" | "request" | "feedback";
export type Status = 'pending' | 'in-progress' | 'resolved'; export type Status = "pending" | "processing" | "resolved";
export interface Letter { export interface Letter {
id: string; id: string;
title: string; title: string;

View File

@ -1,19 +1,21 @@
import { PostState } from "@nice/common";
export const BADGE_STYLES = { export const BADGE_STYLES = {
priority: { priority: {
high: 'bg-red-100 text-red-800', high: "bg-red-100 text-red-800",
medium: 'bg-yellow-100 text-yellow-800', medium: "bg-yellow-100 text-yellow-800",
low: 'bg-green-100 text-green-800', low: "bg-green-100 text-green-800",
}, },
category: { category: {
complaint: 'bg-orange-100 text-orange-800', complaint: "bg-orange-100 text-orange-800",
suggestion: 'bg-blue-100 text-blue-800', suggestion: "bg-blue-100 text-blue-800",
request: 'bg-purple-100 text-purple-800', request: "bg-purple-100 text-purple-800",
feedback: 'bg-teal-100 text-teal-800', feedback: "bg-teal-100 text-teal-800",
}, },
status: { status: {
pending: 'bg-yellow-100 text-yellow-800', [PostState.PENDING]: "bg-yellow-100 text-yellow-800",
'in-progress': 'bg-blue-100 text-blue-800', [PostState.PROCESSING]: "bg-blue-100 text-blue-800",
resolved: 'bg-green-100 text-green-800', [PostState.RESOLVED]: "bg-green-100 text-green-800",
}, },
} as const; } as const;
@ -21,5 +23,8 @@ export const getBadgeStyle = (
type: keyof typeof BADGE_STYLES, type: keyof typeof BADGE_STYLES,
value: string value: string
): string => { ): string => {
return BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || 'bg-gray-100 text-gray-800'; return (
BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] ||
"bg-gray-100 text-gray-800"
);
}; };

View File

@ -1,74 +1,80 @@
import { useState } from 'react' import { useState } from "react";
import { Input, Button, Card, Steps, Tag, Spin, message } from 'antd' import { Input, Button, Card, Steps, Tag, Spin, message } from "antd";
import { SearchOutlined, SafetyCertificateOutlined } from '@ant-design/icons' import { SearchOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
interface FeedbackStatus { interface FeedbackStatus {
status: 'pending' | 'in-progress' | 'resolved' status: "pending" | "processing" | "resolved";
ticketId: string ticketId: string;
submittedDate: string submittedDate: string;
lastUpdate: string lastUpdate: string;
title: string title: string;
} }
const { Step } = Steps const { Step } = Steps;
export default function LetterProgressPage() { export default function LetterProgressPage() {
const [feedbackId, setFeedbackId] = useState('') const [feedbackId, setFeedbackId] = useState("");
const [status, setStatus] = useState<FeedbackStatus | null>(null) const [status, setStatus] = useState<FeedbackStatus | null>(null);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const validateInput = () => { const validateInput = () => {
if (!feedbackId.trim()) { if (!feedbackId.trim()) {
setError('请输入有效的问题编号') setError("请输入有效的问题编号");
return false return false;
} }
if (!/^USAF-\d{4}-\d{4}$/.test(feedbackId)) { if (!/^USAF-\d{4}-\d{4}$/.test(feedbackId)) {
setError('问题编号格式不正确应为USAF-YYYY-NNNN') setError("问题编号格式不正确应为USAF-YYYY-NNNN");
return false return false;
}
setError('')
return true
} }
setError("");
return true;
};
const mockLookup = () => { const mockLookup = () => {
if (!validateInput()) return if (!validateInput()) return;
setLoading(true) setLoading(true);
setTimeout(() => { setTimeout(() => {
setStatus({ setStatus({
status: 'in-progress', status: "processing",
ticketId: feedbackId, ticketId: feedbackId,
submittedDate: '2025-01-15', submittedDate: "2025-01-15",
lastUpdate: '2025-01-21', lastUpdate: "2025-01-21",
title: 'Aircraft Maintenance Schedule Inquiry' title: "Aircraft Maintenance Schedule Inquiry",
}) });
setLoading(false) setLoading(false);
}, 1000) }, 1000);
} };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'pending': case "pending":
return 'orange' return "orange";
case 'in-progress': case "processing":
return 'blue' return "blue";
case 'resolved': case "resolved":
return 'green' return "green";
default: default:
return 'gray' return "gray";
}
} }
};
return ( return (
<div className="min-h-screen bg-white" style={{ fontFamily: 'Arial, sans-serif' }}> <div
className="min-h-screen bg-white"
style={{ fontFamily: "Arial, sans-serif" }}>
{/* Header */} {/* Header */}
<header className="bg-[#003366] p-8 text-white"> <header className="bg-[#003366] p-8 text-white">
<div className="container mx-auto flex items-center"> <div className="container mx-auto flex items-center">
<SafetyCertificateOutlined className="text-4xl mr-4" /> <SafetyCertificateOutlined className="text-4xl mr-4" />
<div> <div>
<h1 className="text-3xl font-bold mb-2">USAF Feedback Tracking System</h1> <h1 className="text-3xl font-bold mb-2">
<p className="text-lg">Enter your ticket ID to track progress</p> USAF Feedback Tracking System
</h1>
<p className="text-lg">
Enter your ticket ID to track progress
</p>
</div> </div>
</div> </div>
</header> </header>
@ -83,12 +89,14 @@ export default function LetterProgressPage() {
</label> </label>
<div className="flex gap-4"> <div className="flex gap-4">
<Input <Input
prefix={<SearchOutlined className="text-[#003366]" />} prefix={
<SearchOutlined className="text-[#003366]" />
}
size="large" size="large"
value={feedbackId} value={feedbackId}
onChange={(e) => setFeedbackId(e.target.value)} onChange={(e) => setFeedbackId(e.target.value)}
placeholder="e.g. USAF-2025-0123" placeholder="e.g. USAF-2025-0123"
status={error ? 'error' : ''} status={error ? "error" : ""}
className="border border-gray-300" className="border border-gray-300"
/> />
<Button <Button
@ -97,12 +105,16 @@ export default function LetterProgressPage() {
icon={<SearchOutlined />} icon={<SearchOutlined />}
loading={loading} loading={loading}
onClick={mockLookup} onClick={mockLookup}
style={{ backgroundColor: '#003366', borderColor: '#003366' }} style={{
> backgroundColor: "#003366",
borderColor: "#003366",
}}>
Track Track
</Button> </Button>
</div> </div>
{error && <p className="text-red-600 text-sm">{error}</p>} {error && (
<p className="text-red-600 text-sm">{error}</p>
)}
</div> </div>
</Card> </Card>
@ -117,8 +129,7 @@ export default function LetterProgressPage() {
</h2> </h2>
<Tag <Tag
color={getStatusColor(status.status)} color={getStatusColor(status.status)}
className="font-bold uppercase" className="font-bold uppercase">
>
{status.status} {status.status}
</Tag> </Tag>
</div> </div>
@ -126,29 +137,50 @@ export default function LetterProgressPage() {
{/* Details Grid */} {/* Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div> <div>
<p className="text-sm text-gray-600">Ticket ID</p> <p className="text-sm text-gray-600">
<p className="font-medium text-[#003366]">{status.ticketId}</p> Ticket ID
</p>
<p className="font-medium text-[#003366]">
{status.ticketId}
</p>
</div> </div>
<div> <div>
<p className="text-sm text-gray-600">Submitted Date</p> <p className="text-sm text-gray-600">
<p className="font-medium text-[#003366]">{status.submittedDate}</p> Submitted Date
</p>
<p className="font-medium text-[#003366]">
{status.submittedDate}
</p>
</div> </div>
<div> <div>
<p className="text-sm text-gray-600">Last Update</p> <p className="text-sm text-gray-600">
<p className="font-medium text-[#003366]">{status.lastUpdate}</p> Last Update
</p>
<p className="font-medium text-[#003366]">
{status.lastUpdate}
</p>
</div> </div>
<div> <div>
<p className="text-sm text-gray-600">Subject</p> <p className="text-sm text-gray-600">
<p className="font-medium text-[#003366]">{status.title}</p> Subject
</p>
<p className="font-medium text-[#003366]">
{status.title}
</p>
</div> </div>
</div> </div>
{/* Progress Timeline */} {/* Progress Timeline */}
<div className="mt-8"> <div className="mt-8">
<Steps <Steps
current={status.status === 'pending' ? 0 : status.status === 'in-progress' ? 1 : 2} current={
className="usa-progress" status.status === "pending"
> ? 0
: status.status === "processing"
? 1
: 2
}
className="usa-progress">
<Step <Step
title="Submitted" title="Submitted"
description="Ticket received" description="Ticket received"
@ -171,5 +203,5 @@ export default function LetterProgressPage() {
)} )}
</main> </main>
</div> </div>
) );
} }

View File

@ -13,7 +13,7 @@ import { useTusUpload } from "@web/src/hooks/useTusUpload";
interface FileUploaderProps { interface FileUploaderProps {
endpoint?: string; endpoint?: string;
onSuccess?: (url: string) => void; onSuccess?: (result: { url: string; fileId: string }) => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
maxSize?: number; maxSize?: number;
allowedTypes?: string[]; allowedTypes?: string[];
@ -44,7 +44,7 @@ const FileItem: React.FC<FileItemProps> = memo(
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-tertiary-300" /> <XMarkIcon className="w-5 h-5 text-gray-500" />
</button> </button>
</div> </div>
{!isUploaded && progress !== undefined && ( {!isUploaded && progress !== undefined && (
@ -57,7 +57,7 @@ const FileItem: React.FC<FileItemProps> = memo(
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
/> />
</div> </div>
<span className="text-xs text-tertiary-300 mt-1"> <span className="text-xs text-gray-500 mt-1">
{progress}% {progress}%
</span> </span>
</div> </div>
@ -74,7 +74,6 @@ const FileItem: React.FC<FileItemProps> = memo(
); );
const FileUploader: React.FC<FileUploaderProps> = ({ const FileUploader: React.FC<FileUploaderProps> = ({
endpoint = "",
onSuccess, onSuccess,
onError, onError,
maxSize = 100, maxSize = 100,
@ -125,7 +124,12 @@ const FileUploader: React.FC<FileUploaderProps> = ({
handleFileUpload( handleFileUpload(
file, file,
(upload) => { (upload) => {
onSuccess?.(upload.url || ""); console.log("Upload complete:", {
url: upload.url,
fileId: upload.fileId,
// resource: upload.resource
});
onSuccess?.(upload);
setFiles((prev) => setFiles((prev) =>
prev.map((f) => prev.map((f) =>
f.file.name === file.name f.file.name === file.name
@ -185,7 +189,8 @@ const FileUploader: React.FC<FileUploaderProps> = ({
relative flex flex-col items-center justify-center w-full h-32 relative flex flex-col items-center justify-center w-full h-32
border-2 border-dashed rounded-lg cursor-pointer border-2 border-dashed rounded-lg cursor-pointer
transition-colors duration-200 ease-in-out transition-colors duration-200 ease-in-out
${isDragging ${
isDragging
? "border-blue-500 bg-blue-50" ? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-500" : "border-gray-300 hover:border-blue-500"
} }
@ -198,8 +203,8 @@ const FileUploader: React.FC<FileUploaderProps> = ({
accept={allowedTypes.join(",")} accept={allowedTypes.join(",")}
className="hidden" className="hidden"
/> />
<CloudArrowUpIcon className="w-10 h-10 text-tertiary-300" /> <CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
<p className="mt-2 text-sm text-tertiary-300">{placeholder}</p> <p className="mt-2 text-sm text-gray-500">{placeholder}</p>
{isDragging && ( {isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg"> <div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
<p className="text-blue-500 font-medium"> <p className="text-blue-500 font-medium">

View File

@ -0,0 +1,7 @@
import React, { useEffect, useState } from "react";
import { UploadOutlined } from "@ant-design/icons";
import { Form, Upload, message } from "antd";
export const TusUploader = ({ value = [], onChange }) => {
return <Upload.Dragger></Upload.Dragger>;
};

View File

@ -7,6 +7,8 @@ import { useVisitor } from "@nice/client";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { PostDetailContext } from "./context/PostDetailContext"; import { PostDetailContext } from "./context/PostDetailContext";
import { LikeFilled, LikeOutlined } from "@ant-design/icons"; import { LikeFilled, LikeOutlined } from "@ant-design/icons";
import PostLikeButton from "./PostHeader/PostLikeButton";
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
export default function PostCommentCard({ export default function PostCommentCard({
post, post,
@ -55,27 +57,25 @@ export default function PostCommentCard({
<motion.div <motion.div
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4" className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
layout> layout>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3 gap-4">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Avatar <CustomAvatar
className="ring-2 ring-white hover:ring-[#00538E]/90
transition-all duration-200 ease-in-out shadow-md
hover:shadow-lg"
src={post.author?.avatar} src={post.author?.avatar}
size={40}> size={40}
{!post.author?.avatar && name={
(post.author?.showname || "匿名用户")} !post.author?.avatar && post.author?.showname
</Avatar> }></CustomAvatar>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div className="flex flex-1 justify-between">
className="flex items-center space-x-2" <div className="flex space-x-2" style={{ height: 40 }}>
style={{ height: 40 }}>
<span className="font-medium text-slate-900"> <span className="font-medium text-slate-900">
{post.author?.showname || "匿名用户"} {post.author?.showname || "匿名用户"}
</span> </span>
<span className="text-sm text-slate-500"> <span className="text-sm text-slate-500">
{dayjs(post?.createdAt).format("YYYY-MM-DD HH:mm")} {dayjs(post?.createdAt).format(
"YYYY-MM-DD HH:mm"
)}
</span> </span>
{isReceiverComment && ( {isReceiverComment && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800"> <span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
@ -83,6 +83,10 @@ export default function PostCommentCard({
</span> </span>
)} )}
</div> </div>
{/* 添加有帮助按钮 */}
<PostLikeButton post={post}></PostLikeButton>
</div>
<div <div
className="ql-editor text-slate-800" className="ql-editor text-slate-800"
style={{ style={{
@ -90,23 +94,6 @@ export default function PostCommentCard({
}} }}
dangerouslySetInnerHTML={{ __html: post.content || "" }} dangerouslySetInnerHTML={{ __html: post.content || "" }}
/> />
{/* 添加有帮助按钮 */}
<div className="mt-3 flex items-center">
<motion.button
onClick={likeThisPost}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={`inline-flex items-center space-x-1.5 px-3 py-1.5 rounded-full text-sm
${
liked
? "bg-blue-50 text-blue-600"
: "hover:bg-slate-50 text-slate-600"
} transition-colors duration-200`}>
{liked ? <LikeFilled /> : <LikeOutlined />}
<span>{likeCount} </span>
</motion.button>
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@ -1,14 +1,15 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { PaperAirplaneIcon } from "@heroicons/react/24/solid";
import { CommandLineIcon } from "@heroicons/react/24/outline"; import { Button } from "antd";
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
import { PostDetailContext } from "./context/PostDetailContext"; import { PostDetailContext } from "./context/PostDetailContext";
import { usePost } from "@nice/client"; import { usePost } from "@nice/client";
import { PostType } from "@nice/common"; import { PostType } from "@nice/common";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { isContentEmpty } from "./utils"; import { isContentEmpty } from "./utils";
import { SendOutlined } from "@ant-design/icons";
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
export default function PostCommentEditor() { export default function PostCommentEditor() {
const { post } = useContext(PostDetailContext); const { post } = useContext(PostDetailContext);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
@ -82,36 +83,21 @@ export default function PostCommentEditor() {
)} )}
</div> </div>
<div className="flex items-center justify-end"> <TusUploader></TusUploader>
{/* <motion.button
type="button"
onClick={() => setIsPreview(!isPreview)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`flex items-center space-x-1 px-3 py-1.5 rounded-md
transition-colors ${
isPreview
? "bg-blue-600 text-white"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}>
<CommandLineIcon className="w-5 h-5" />
<span>{isPreview ? "编辑" : "预览"}</span>
</motion.button> */}
<motion.button <div className="flex items-center justify-end">
type="submit" <motion.div
disabled={isContentEmpty(content)}
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}>
className={`flex items-center space-x-2 px-4 py-2 rounded-md <Button
${ type="primary"
!isContentEmpty(content) htmlType="submit"
? "bg-blue-600 text-white hover:bg-blue-700" disabled={isContentEmpty(content)}
: "bg-slate-100 text-slate-400 cursor-not-allowed" className="flex items-center space-x-2 bg-primary"
} transition-colors`}> icon={<SendOutlined />}>
<PaperAirplaneIcon className="w-4 h-4" />
<span></span> </Button>
</motion.button> </motion.div>
</div> </div>
</form> </form>
</motion.div> </motion.div>

View File

@ -11,14 +11,21 @@ import {
EyeIcon, EyeIcon,
ChatBubbleLeftIcon, ChatBubbleLeftIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { Button, Typography, Space, Tooltip } from "antd";
import { useVisitor } from "@nice/client"; import { useVisitor } from "@nice/client";
import { PostState, VisitType } from "@nice/common"; import { PostState, VisitType } from "@nice/common";
import { import {
CalendarOutlined,
ClockCircleOutlined,
CommentOutlined, CommentOutlined,
EyeOutlined, EyeOutlined,
FileTextOutlined,
FolderOutlined,
LikeFilled, LikeFilled,
LikeOutlined, LikeOutlined,
LockOutlined,
UnlockOutlined,
UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { TitleSection } from "./PostHeader/TitleSection"; import { TitleSection } from "./PostHeader/TitleSection";
@ -30,7 +37,9 @@ import {
VisibilityBadge, VisibilityBadge,
} from "./PostHeader/InfoBadge"; } from "./PostHeader/InfoBadge";
import { StatsSection } from "./PostHeader/StatsSection"; import { StatsSection } from "./PostHeader/StatsSection";
import { PostBadge } from "./badge/PostBadge";
const { Title, Paragraph, Text } = Typography;
export default function PostHeader() { export default function PostHeader() {
const { post, user } = useContext(PostDetailContext); const { post, user } = useContext(PostDetailContext);
const { like, unLike } = useVisitor(); const { like, unLike } = useVisitor();
@ -64,62 +73,76 @@ export default function PostHeader() {
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="relative bg-gradient-to-br from-[#E6E9F0] via-[#EDF0F8] to-[#D8E2EF] rounded-lg p-6 shadow-lg border border-[#97A9C4]/30"> className="relative bg-gradient-to-br from-primary-250 via-primary-150 to--primary-350 rounded-lg p-6 shadow-lg border border-[#97A9C4]/30">
{/* Corner Decorations */} {/* Corner Decorations */}
<div className="absolute top-0 left-0 w-5 h-5 border-t-2 border-l-2 border-[#97A9C4] rounded-tl-lg" /> <div className="absolute top-0 left-0 w-5 h-5 border-t-4 border-l-4 border-primary rounded-tl-lg" />
<div className="absolute bottom-0 right-0 w-5 h-5 border-b-2 border-r-2 border-[#97A9C4] rounded-br-lg" /> <div className="absolute bottom-0 right-0 w-5 h-5 border-b-4 border-r-4 border-primary rounded-br-lg" />
{/* Title Section */} {/* Title Section */}
<TitleSection <TitleSection></TitleSection>
title={post?.title}
state={post?.state as PostState}></TitleSection>
<div className="space-y-4"> <div className="space-y-4">
{/* First Row - Basic Info */} {/* 收件人信息行 */}
<div className="flex flex-wrap gap-4"> <Space>
{/* Author Info Badge */} <UserOutlined className="text-secondary-400" />
<AuthorBadge <span className="text-secondary-400"></span>
name={ <Text strong>
post?.author?.showname || "匿名用户" {post?.receivers?.map((receiver) => receiver?.showname)}
}></AuthorBadge> </Text>
</Space>
{/* First Row - Basic Info */}
<div className="flex flex-wrap items-center gap-1">
{/* Author Info Badge */}
<Space>
<UserOutlined className="text-secondary-400" />
<span className="text-secondary-400"></span>
<Text strong>
{post?.author?.showname || "匿名用户"}
</Text>
</Space>
<Text type="secondary">|</Text>
{/* Date Info Badge */} {/* Date Info Badge */}
{post?.createdAt && ( <Space>
<DateBadge <CalendarOutlined className="text-secondary-400" />
date={dayjs(post?.createdAt).format("YYYY-MM-DD")}
label="创建于:"></DateBadge> <Text>
)} :
{dayjs(post?.createdAt).format("YYYY-MM-DD")}
</Text>
</Space>
<Text type="secondary">|</Text>
{/* Last Updated Badge */} {/* Last Updated Badge */}
{post?.updatedAt && post.updatedAt !== post.createdAt && ( <Space>
<UpdatedBadge <ClockCircleOutlined className="text-secondary-400" />
date={dayjs(post?.updatedAt).format( <Text>
"YYYY-MM-DD" :
)}></UpdatedBadge> {dayjs(post?.updatedAt).format("YYYY-MM-DD")}
)} </Text>
</Space>
<Text type="secondary">|</Text>
{/* Visibility Status Badge */} {/* Visibility Status Badge */}
<VisibilityBadge <Space>
isPublic={post?.isPublic}></VisibilityBadge> {post?.isPublic ? (
<UnlockOutlined className="text-secondary-400" />
) : (
<LockOutlined className="text-secondary-400" />
)}
<Text>{post?.isPublic ? "公开" : "私信"}</Text>
</Space>
</div> </div>
{/* Second Row - Term and Tags */} {/* Second Row - Term and Tags */}
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-1">
{/* Term Badge */}
{post?.term?.name && (
<TermBadge term={post.term.name}></TermBadge>
)}
{/* Tags Badges */} {/* Tags Badges */}
{post?.meta?.tags && {post?.meta?.tags &&
post.meta.tags.length > 0 && post.meta.tags.length > 0 &&
post.meta.tags.map((tag, index) => ( post.meta.tags.map((tag, index) => (
<motion.span <Space key={index}>
key={index} <PostBadge
whileHover={{ scale: 1.05 }} type="tag"
className="inline-flex items-center bg-[#507AAF]/10 px-3 py-1.5 rounded border border-[#97A9C4]/50 shadow-md hover:bg-[#507AAF]/20"> value={`#${tag}`}></PostBadge>
<span className="text-sm text-[#2B4C7E]"> </Space>
#{tag}
</span>
</motion.span>
))} ))}
</div> </div>
</div> </div>
@ -129,20 +152,15 @@ export default function PostHeader() {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.6 }} transition={{ delay: 0.6 }}
className="mt-6 text-[#2B4C7E]"> className="mt-6 text-secondary-700">
<div <div
className="ql-editor space-y-4 leading-relaxed bg-white/60 p-4 rounded-md border border-[#97A9C4]/30 shadow-inner hover:bg-white/80 transition-colors duration-300" className="ql-editor p-0 space-y-4 leading-relaxed duration-300"
dangerouslySetInnerHTML={{ __html: post?.content || "" }} dangerouslySetInnerHTML={{ __html: post?.content || "" }}
/> />
</motion.div> </motion.div>
{/* Stats Section */} {/* Stats Section */}
<StatsSection <StatsSection></StatsSection>
likes={post?.likes}
views={post?.views}
commentsCount={post?.commentsCount}
liked={post?.liked}
onLikeClick={likeThisPost}></StatsSection>
</motion.div> </motion.div>
); );
} }

View File

@ -21,11 +21,10 @@ export function InfoBadge({ icon, text, delay = 0 }: InfoBadgeProps) {
<motion.div <motion.div
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay }}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-md border border-[#97A9C4]/50 shadow-md hover:bg-[#F8FAFC] transition-colors duration-300"> className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-md border border-secondary-350/50 shadow-md hover:bg-secondary-50 transition-colors duration-300">
{icon} {icon}
<span className="text-[#2B4C7E]">{text}</span> <span className="text-primary">{text}</span>
</motion.div> </motion.div>
); );
} }
@ -33,7 +32,7 @@ export function InfoBadge({ icon, text, delay = 0 }: InfoBadgeProps) {
export function AuthorBadge({ name }: { name: string }) { export function AuthorBadge({ name }: { name: string }) {
return ( return (
<InfoBadge <InfoBadge
icon={<UserCircleIcon className="h-5 w-5 text-[#2B4C7E]" />} icon={<UserCircleIcon className="h-5 w-5 text-primary" />}
text={name} text={name}
/> />
); );
@ -42,7 +41,7 @@ export function AuthorBadge({ name }: { name: string }) {
export function DateBadge({ date, label }: { date: string; label: string }) { export function DateBadge({ date, label }: { date: string; label: string }) {
return ( return (
<InfoBadge <InfoBadge
icon={<CalendarIcon className="h-5 w-5 text-[#2B4C7E]" />} icon={<CalendarIcon className="h-5 w-5 text-primary" />}
text={`${label}: ${dayjs(date).format("YYYY-MM-DD")}`} text={`${label}: ${dayjs(date).format("YYYY-MM-DD")}`}
/> />
); );
@ -51,7 +50,7 @@ export function DateBadge({ date, label }: { date: string; label: string }) {
export function UpdatedBadge({ date }: { date: string }) { export function UpdatedBadge({ date }: { date: string }) {
return ( return (
<InfoBadge <InfoBadge
icon={<ClockIcon className="h-5 w-5 text-[#2B4C7E]" />} icon={<ClockIcon className="h-5 w-5 text-primary" />}
text={`更新于: ${dayjs(date).format("YYYY-MM-DD")}`} text={`更新于: ${dayjs(date).format("YYYY-MM-DD")}`}
delay={0.45} delay={0.45}
/> />
@ -63,9 +62,9 @@ export function VisibilityBadge({ isPublic }: { isPublic: boolean }) {
<InfoBadge <InfoBadge
icon={ icon={
isPublic ? ( isPublic ? (
<LockOpenIcon className="h-5 w-5 text-[#2B4C7E]" /> <LockOpenIcon className="h-5 w-5 text-primary" />
) : ( ) : (
<LockClosedIcon className="h-5 w-5 text-[#2B4C7E]" /> <LockClosedIcon className="h-5 w-5 text-primary" />
) )
} }
text={isPublic ? "公开" : "私信"} text={isPublic ? "公开" : "私信"}
@ -77,7 +76,7 @@ export function VisibilityBadge({ isPublic }: { isPublic: boolean }) {
export function TermBadge({ term }: { term: string }) { export function TermBadge({ term }: { term: string }) {
return ( return (
<InfoBadge <InfoBadge
icon={<StarIcon className="h-5 w-5 text-[#2B4C7E]" />} icon={<StarIcon className="h-5 w-5 text-primary" />}
text={term} text={term}
delay={0.55} delay={0.55}
/> />

View File

@ -0,0 +1,54 @@
import { PostDto, VisitType } from "@nice/common";
import { useVisitor } from "@nice/client";
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { Button, Tooltip } from "antd";
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
export default function PostLikeButton({ post }: { post: PostDto }) {
const { user } = useContext(PostDetailContext);
const { like, unLike } = useVisitor();
function likeThisPost() {
if (!post?.liked) {
post.likes += 1;
post.liked = true;
like.mutateAsync({
data: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.LIKE,
},
});
} else {
post.likes -= 1;
post.liked = false;
unLike.mutateAsync({
where: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.LIKE,
},
});
}
}
return (
<Tooltip title={post?.liked ? "取消点赞" : "点赞"} placement="top">
<Button
type={post?.liked ? "primary" : "default"}
shape="round"
size="small"
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
onClick={likeThisPost}
className={`
flex items-center gap-1 px-3 transform transition-all duration-300
hover:scale-105 hover:shadow-md
${post?.liked ? "bg-blue-500 hover:bg-blue-600" : "hover:border-blue-500 hover:text-blue-500"}
`}>
<span className={post?.liked ? "text-white" : ""}>
{post?.likes}
</span>
</Button>
</Tooltip>
);
}

View File

@ -1,68 +1,37 @@
import React from "react"; import React, { useContext } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { LikeFilled, LikeOutlined, EyeOutlined, CommentOutlined } from "@ant-design/icons"; import {
LikeFilled,
LikeOutlined,
EyeOutlined,
CommentOutlined,
} from "@ant-design/icons";
import { Button, Tooltip } from "antd/lib";
import { PostDetailContext } from "../context/PostDetailContext";
import { useVisitor } from "@nice/client";
import { VisitType } from "packages/common/dist";
import PostLikeButton from "./PostLikeButton";
export function StatsSection() {
const { post, user } = useContext(PostDetailContext);
const { like, unLike } = useVisitor();
interface StatsButtonProps {
icon: React.ReactNode;
text: string;
onClick?: () => void;
isActive?: boolean;
}
export function StatsButton({ icon, text, onClick, isActive }: StatsButtonProps) {
return (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onClick}
className={`flex items-center gap-2 px-4 py-2 rounded-md ${
isActive
? "bg-[#507AAF] text-white"
: "bg-white text-[#2B4C7E] hover:bg-[#507AAF] hover:text-white"
} transition-all duration-300 shadow-md border border-[#97A9C4]/30`}
>
{icon}
<span className="font-medium">{text}</span>
</motion.button>
);
}
interface StatsSectionProps {
likes: number;
views: number;
commentsCount: number;
liked: boolean;
onLikeClick: () => void;
}
export function StatsSection({
likes,
views,
commentsCount,
liked,
onLikeClick,
}: StatsSectionProps) {
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }} transition={{ delay: 0.7 }}
className="mt-6 flex flex-wrap gap-4 justify-start items-center" className="mt-6 flex flex-wrap gap-4 justify-between items-center">
> <div className=" flex gap-2">
<StatsButton <div className="flex items-center gap-1 text-gray-500">
icon={liked ? <LikeFilled style={{ fontSize: 18 }} /> : <LikeOutlined style={{ fontSize: 18 }} />} <EyeOutlined className="text-lg" />
text={`${likes} 有帮助`} <span className="text-sm">{post?.views}</span>
onClick={onLikeClick} </div>
isActive={liked} <div className="flex items-center gap-1 text-gray-500">
/> <CommentOutlined className="text-lg" />
<StatsButton <span className="text-sm">{post?.commentsCount}</span>
icon={<EyeOutlined style={{ fontSize: 18 }} />} </div>
text={`${views} 浏览`} </div>
/> <PostLikeButton post={post}></PostLikeButton>
<StatsButton
icon={<CommentOutlined style={{ fontSize: 18 }} />}
text={`${commentsCount} 回复`}
/>
</motion.div> </motion.div>
); );
} }

View File

@ -1,30 +1,29 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Tag } from "antd"; import { Space, Tag } from "antd";
import { PostState } from "@nice/common"; import { PostState } from "@nice/common";
import { import {
ClockIcon, ClockIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationCircleIcon, ExclamationCircleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { Badge } from "@web/src/app/main/letter/list/LetterCard";
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { PostBadge } from "../badge/PostBadge";
interface TitleSectionProps { interface TitleSectionProps {
title: string; title: string;
state: PostState; state: PostState;
} }
const stateColors = {
[PostState.PENDING]: "orange",
[PostState.PROCESSING]: "blue",
[PostState.COMPLETED]: "green",
};
const stateLabels = { const stateLabels = {
[PostState.PENDING]: "待处理", [PostState.PENDING]: "待处理",
[PostState.PROCESSING]: "处理中", [PostState.PROCESSING]: "处理中",
[PostState.COMPLETED]: "已完成", [PostState.RESOLVED]: "已完成",
}; };
export function TitleSection({ title, state }: TitleSectionProps) { export function TitleSection() {
const { post, user } = useContext(PostDetailContext);
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -32,34 +31,17 @@ export function TitleSection({ title, state }: TitleSectionProps) {
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
className="relative mb-6 flex items-center gap-4"> className="relative mb-6 flex items-center gap-4">
{/* Decorative Line */} {/* Decorative Line */}
<div className="absolute -left-2 top-1/2 -translate-y-1/2 w-1 h-8 bg-[#97A9C4]" /> {/* <div className="absolute -left-2 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary" /> */}
{/* Title */} {/* Title */}
<h1 className="text-2xl font-bold text-[#2B4C7E] pl-4 tracking-wider uppercase"> <h1 className="text-xl font-bold text-primary tracking-wider uppercase">
{title} {post?.title}
</h1> </h1>
{/* State Tag */} <Space size="small" wrap className="flex-1">
{/* <motion.div <PostBadge type="category" value={post?.term?.name} />
initial={{ opacity: 0, x: -20 }} <PostBadge type="state" value={post?.state} />
animate={{ opacity: 1, x: 0 }} </Space>
// transition={{ delay: 0.45 }}
whileHover={{ scale: 1.05 }}
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-md border border-[#97A9C4]/50 shadow-md hover:bg-[#F8FAFC] transition-colors duration-300"> */}
<Tag
color={stateColors[state]}
className="flex items-center gap-1.5 px-3 py-1 shadow-md rounded-md text-sm font-medium border-none">
{state === PostState.PENDING && (
<ExclamationCircleIcon className="h-4 w-4" />
)}
{state === PostState.PROCESSING && (
<ClockIcon className="h-4 w-4" />
)}
{state === PostState.COMPLETED && (
<CheckCircleIcon className="h-4 w-4" />
)}
{stateLabels[state]}
</Tag>
{/* </motion.div> */} {/* </motion.div> */}
</motion.div> </motion.div>
); );

View File

@ -0,0 +1,62 @@
import { PostState ,PostStateLabels} from "@nice/common";
export function PostBadge({
type,
value,
className = "",
}: {
type: "priority" | "category" | "state" | "tag";
value: string;
className?: string;
}) {
return (
value && (
<span
className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${getBadgeStyle(type, value)}
transition-all duration-200 ease-in-out transform hover:scale-105
${className}
`}>
{type === "state" ? PostStateLabels?.[value] : value?.toUpperCase()}
</span>
)
);
}
const BADGE_STYLES = {
priority: {
high: "bg-red-100 text-red-800",
medium: "bg-yellow-100 text-yellow-800",
low: "bg-green-100 text-green-800",
},
category: {
complaint: "bg-orange-100 text-orange-800",
suggestion: "bg-blue-100 text-blue-800",
request: "bg-purple-100 text-purple-800",
feedback: "bg-teal-100 text-teal-800",
},
state: {
[PostState.PENDING]: "bg-yellow-100 text-yellow-800",
[PostState.PROCESSING]: "bg-blue-100 text-blue-800",
[PostState.RESOLVED]: "bg-green-100 text-green-800",
},
tag: {
_: "bg-primary-100 text-primary-800",
},
} as const;
const getBadgeStyle = (
type: keyof typeof BADGE_STYLES,
value: string
): string => {
if (type === "tag") {
return "bg-primary-100 text-primary";
}
return (
BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] ||
"bg-gray-100 text-gray-800"
);
};

View File

@ -8,8 +8,9 @@ import {
TagOutlined, TagOutlined,
FileTextOutlined, FileTextOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import FileUploader from "@web/src/components/common/uploader/FileUploader";
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
import { PostBadge } from "../../detail/badge/PostBadge";
export function LetterBasicForm() { export function LetterBasicForm() {
const { onSubmit, receiverId, termId, form } = useLetterEditor(); const { onSubmit, receiverId, termId, form } = useLetterEditor();
@ -42,15 +43,11 @@ export function LetterBasicForm() {
initialValues={{ meta: { tags: [] }, isPublic: true }}> initialValues={{ meta: { tags: [] }, isPublic: true }}>
{/* 收件人和板块信息行 */} {/* 收件人和板块信息行 */}
<div className="flex justify-start items-center gap-8 "> <div className="flex justify-start items-center gap-8 ">
<div className="flex items-center font-semibold text-[#00308F]"> <div className="flex items-center font-semibold text-primary">
<UserOutlined className="w-5 h-5 mr-2 text-[#00308F]" /> <UserOutlined className="w-5 h-5 mr-2 text-primary" />
<div>{receiver?.showname}</div> <div>{receiver?.showname}</div>
</div> </div>
<PostBadge type="category" value={term?.name}></PostBadge>
<div className="flex items-center font-semibold text-[#00308F]">
<FolderOutlined className="w-5 h-5 mr-2 text-[#00308F]" />
<div>{term?.name}</div>
</div>
</div> </div>
{/* 主题输入框 */} {/* 主题输入框 */}
@ -58,11 +55,13 @@ export function LetterBasicForm() {
<Form.Item <Form.Item
required={false} //不显示星号 required={false} //不显示星号
label={ label={
<span className="block mb-1"> <div className="mb-1 items-center">
<TagOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" /> <TagOutlined className="mr-2 text-primary" />
<span className="text-gray-400"></span> <span className="text-secondary-400">
</span> </span>
</div>
} }
name="title" name="title"
rules={[{ required: true, message: "请输入标题" }]} rules={[{ required: true, message: "请输入标题" }]}
@ -77,7 +76,7 @@ export function LetterBasicForm() {
<Form.Item <Form.Item
label={ label={
<span className="block mb-1"> <span className="block mb-1">
<TagOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" /> <TagOutlined className=" mr-2 text-primary" />
</span> </span>
} }
@ -108,9 +107,11 @@ export function LetterBasicForm() {
<Form.Item <Form.Item
label={ label={
<span className="block mb-1"> <span className="block mb-1">
<FileTextOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" /> <FileTextOutlined className=" mr-2 text-primary" />
<span className="text-gray-400"></span> <span className="text-secondary-400">
</span>
</span> </span>
} }
name="content" name="content"
@ -120,7 +121,7 @@ export function LetterBasicForm() {
wrapperCol={{ span: 24 }}> wrapperCol={{ span: 24 }}>
<div className="relative rounded-lg border border-slate-200 bg-white shadow-sm"> <div className="relative rounded-lg border border-slate-200 bg-white shadow-sm">
<QuillEditor <QuillEditor
maxLength={400} maxLength={1000}
placeholder="请输入内容" placeholder="请输入内容"
minRows={6} minRows={6}
maxRows={12} maxRows={12}
@ -132,9 +133,9 @@ export function LetterBasicForm() {
</Form.Item> </Form.Item>
</div> </div>
{/* <FileUploader /> */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-end gap-4 border-t border-gray-100 pt-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-end gap-4 border-t border-secondary-100 pt-4">
<Form.Item <Form.Item
name="isPublic" name="isPublic"
valuePropName="checked" valuePropName="checked"
@ -146,7 +147,7 @@ export function LetterBasicForm() {
<Button <Button
type="primary" type="primary"
onClick={() => form.submit()} onClick={() => form.submit()}
className="bg-[#00308F] hover:bg-[#041E42] w-full sm:w-auto" className="bg-primary hover:bg-primary-600 w-full sm:w-auto"
style={{ style={{
transform: "scale(1)", transform: "scale(1)",
transition: "all 0.2s", transition: "all 0.2s",

View File

@ -3,21 +3,22 @@ import { motion } from "framer-motion";
import { PaperAirplaneIcon } from "@heroicons/react/24/outline"; import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { LetterFormProvider } from "../context/LetterEditorContext"; import { LetterFormProvider } from "../context/LetterEditorContext";
import { LetterBasicForm } from "../form/LetterBasicForm"; import { LetterBasicForm } from "../form/LetterBasicForm";
import { useTheme } from "@nice/theme";
export default function LetterEditorLayout() { export default function LetterEditorLayout() {
const location = useLocation(); const location = useLocation();
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
// const {} = useTheme();
const receiverId = params.get("receiverId"); const receiverId = params.get("receiverId");
const termId = params.get("termId"); const termId = params.get("termId");
return ( return (
<motion.div <motion.div
className="min-h-screen rounded-xl overflow-hidden border border-gray-200 shadow-lg" // 添加圆角和溢出隐藏 className="min-h-screen overflow-hidden border border-gray-200 shadow-lg" // 添加圆角和溢出隐藏
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.8 }}> transition={{ duration: 0.8 }}>
<div className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white py-8"> <div className="bg-gradient-to-r from-primary to-primary-400 text-white py-8">
<div className="w-full px-4 max-w-7xl mx-auto"> <div className="w-full px-4 max-w-7xl mx-auto">
<motion.div <motion.div
className="flex items-center justify-center mb-6" className="flex items-center justify-center mb-6"
@ -28,7 +29,7 @@ export default function LetterEditorLayout() {
type: "spring", type: "spring",
stiffness: 100, stiffness: 100,
}}> }}>
<PaperAirplaneIcon className="h-12 w-12" /> {/* <PaperAirplaneIcon className="h-12 w-12" /> */}
<h1 className="text-3xl font-bold ml-4"></h1> <h1 className="text-3xl font-bold ml-4"></h1>
</motion.div> </motion.div>

View File

@ -0,0 +1,33 @@
import { Avatar } from "antd";
import { AvatarProps } from "antd/lib/avatar";
interface CustomAvatarProps extends Omit<AvatarProps, "children"> {
src?: string;
name?: string;
}
export function CustomAvatar({
src,
name,
className = "",
...props
}: CustomAvatarProps) {
// 获取名字的第一个字符,如果没有名字则显示"匿"
const firstChar = name ? name.charAt(0) : "匿";
return (
<Avatar
className={`ring-2 ring-primary/50
bg-primary-300
text-white
transition-all duration-200 ease-in-out shadow-md
hover:shadow-lg
${className}`}
shape="square"
src={src}
size={40}
{...props}>
{!src && firstChar}
</Avatar>
);
}

View File

@ -1,17 +1,27 @@
// useTusUpload.ts
import { useState } from "react"; import { useState } from "react";
import * as tus from "tus-js-client"; import * as tus from "tus-js-client";
interface UploadResult { interface UploadResult {
url?: string; url: string;
fileId: string;
// resource: any;
} }
export function useTusUpload() { export function useTusUpload() {
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const getFileId = (url: string) => {
const handleFileUpload = ( const parts = url.split("/");
// Find the index of the 'upload' segment
const uploadIndex = parts.findIndex((part) => part === "upload");
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
throw new Error("Invalid upload URL format");
}
// Get the date parts and file ID (4 segments after 'upload')
return parts.slice(uploadIndex + 1, uploadIndex + 5).join("/");
};
const handleFileUpload = async (
file: File, file: File,
onSuccess: (result: UploadResult) => void, onSuccess: (result: UploadResult) => void,
onError: (error: Error) => void onError: (error: Error) => void
@ -19,9 +29,8 @@ export function useTusUpload() {
setIsUploading(true); setIsUploading(true);
setProgress(0); setProgress(0);
setUploadError(null); setUploadError(null);
const upload = new tus.Upload(file, { const upload = new tus.Upload(file, {
endpoint: "http://localhost:3000/upload", // 替换为实际的上传端点 endpoint: "http://localhost:3000/upload",
retryDelays: [0, 1000, 3000, 5000], retryDelays: [0, 1000, 3000, 5000],
metadata: { metadata: {
filename: file.name, filename: file.name,
@ -34,10 +43,28 @@ export function useTusUpload() {
).toFixed(2); ).toFixed(2);
setProgress(Number(uploadProgress)); setProgress(Number(uploadProgress));
}, },
onSuccess: () => { onSuccess: async () => {
try {
if (upload.url) {
const fileId = getFileId(upload.url);
// const resource = await pollResourceStatus(fileId);
setIsUploading(false); setIsUploading(false);
setProgress(100); setProgress(100);
onSuccess({ url: upload.url }); onSuccess({
url: upload.url,
fileId,
// resource,
});
}
} catch (error) {
const err =
error instanceof Error
? error
: new Error("Unknown error");
setIsUploading(false);
setUploadError(err.message);
onError(err);
}
}, },
onError: (error) => { onError: (error) => {
setIsUploading(false); setIsUploading(false);
@ -45,10 +72,8 @@ export function useTusUpload() {
onError(error); onError(error);
}, },
}); });
upload.start(); upload.start();
}; };
return { return {
progress, progress,
isUploading, isUploading,

View File

@ -8,7 +8,7 @@ export enum PostType {
} }
export enum TaxonomySlug { export enum TaxonomySlug {
CATEGORY = "category", CATEGORY = "category",
TAG = "tag" TAG = "tag",
} }
export enum VisitType { export enum VisitType {
STAR = "star", STAR = "star",
@ -193,5 +193,10 @@ export const LessonTypeLabel = {
export enum PostState { export enum PostState {
PENDING = "pending", PENDING = "pending",
PROCESSING = "processing", PROCESSING = "processing",
COMPLETED = "completed", RESOLVED = "resolved",
} }
export const PostStateLabels = {
[PostState.PENDING]: "待处理",
[PostState.PROCESSING]: "处理中",
[PostState.RESOLVED]: "已解答",
};

View File

@ -15,7 +15,13 @@ export const postDetailSelect: Prisma.PostSelect = {
termId: true, termId: true,
term: { term: {
include: { include: {
taxonomy: true, taxonomy: {
select: {
id: true,
slug: true,
name: true,
},
},
}, },
}, },
authorId: true, authorId: true,

View File

@ -1,106 +1,105 @@
import type { Config } from 'tailwindcss' import type { Config } from "tailwindcss";
export const NiceTailwindConfig: Config = { export const NiceTailwindConfig: Config = {
content: [ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
// 主色调 - 空军蓝 // 主色调 - 空军蓝
primary: { primary: {
50: '#e8f2ff', 50: "#e8f2ff",
100: '#c5d9f7', 100: "#c5d9f7",
200: '#9fc0ef', 150: "#EDF0F8", // via color
300: '#78a7e7', 200: "#9fc0ef",
400: '#528edf', 250: "#E6E9F0", // from color
500: '#00308a', 300: "#78a7e7",
600: '#00256b', 350: "#D8E2EF", // to color
700: '#001a4c', 400: "#528edf",
800: '#000f2d', 500: "#00308a",
900: '#00040e', 600: "#00256b",
DEFAULT: '#00308a', 700: "#001a4c",
800: "#000f2d",
900: "#00040e",
DEFAULT: "#00308a",
}, },
// 辅助色 - 军事灰 // 辅助色 - 军事灰
secondary: { secondary: {
50: '#f5f5f5', 50: "#f5f5f5",
100: '#e0e0e0', 100: "#e0e0e0",
200: '#c2c2c2', 200: "#c2c2c2",
300: '#a3a3a3', 300: "#a3a3a3",
400: '#858585', 350: "#97A9C4", // New color inserted
500: '#666666', 400: "#858585",
600: '#4d4d4d', 500: "#666666",
700: '#333333', 600: "#4d4d4d",
800: '#1a1a1a', 700: "#333333",
900: '#0d0d0d', 800: "#1a1a1a",
DEFAULT: '#4d4d4d', 900: "#0d0d0d",
DEFAULT: "#4d4d4d",
}, },
// 强调色 - 军徽金 // 强调色 - 军徽金
accent: { accent: {
50: '#fff8e5', 50: "#fff8e5",
100: '#ffecb3', 100: "#ffecb3",
200: '#ffe080', 200: "#ffe080",
300: '#ffd44d', 300: "#ffd44d",
400: '#ffc81a', 400: "#ffc81a",
500: '#e6b400', 500: "#e6b400",
600: '#b38f00', 600: "#b38f00",
700: '#806a00', 700: "#806a00",
800: '#4d4000', 800: "#4d4000",
900: '#1a1500', 900: "#1a1500",
DEFAULT: '#e6b400', DEFAULT: "#e6b400",
}, },
// 功能色 // 功能色
success: '#28a745', success: "#28a745",
warning: '#ffc107', warning: "#ffc107",
danger: '#dc3545', danger: "#dc3545",
info: '#17a2b8', info: "#17a2b8",
}, },
fontFamily: { fontFamily: {
sans: ['Inter', 'sans-serif'], sans: ["Inter", "sans-serif"],
heading: ['Bebas Neue', 'sans-serif'], heading: ["Bebas Neue", "sans-serif"],
mono: ['Source Code Pro', 'monospace'], mono: ["Source Code Pro", "monospace"],
}, },
spacing: { spacing: {
'72': '18rem', "72": "18rem",
'84': '21rem', "84": "21rem",
'96': '24rem', "96": "24rem",
}, },
borderRadius: { borderRadius: {
'xl': '1rem', xl: "1rem",
'2xl': '2rem', "2xl": "2rem",
'3xl': '3rem', "3xl": "3rem",
}, },
boxShadow: { boxShadow: {
'outline': '0 0 0 3px rgba(0, 48, 138, 0.5)', outline: "0 0 0 3px rgba(0, 48, 138, 0.5)",
'solid': '2px 2px 0 0 rgba(0, 0, 0, 0.2)', solid: "2px 2px 0 0 rgba(0, 0, 0, 0.2)",
'glow': '0 0 8px rgba(230, 180, 0, 0.8)', glow: "0 0 8px rgba(230, 180, 0, 0.8)",
'inset': 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.15)', inset: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.15)",
'elevation-1': '0 1px 2px 0 rgba(0, 0, 0, 0.1)', "elevation-1": "0 1px 2px 0 rgba(0, 0, 0, 0.1)",
'elevation-2': '0 2px 4px 0 rgba(0, 0, 0, 0.15)', "elevation-2": "0 2px 4px 0 rgba(0, 0, 0, 0.15)",
'elevation-3': '0 4px 8px 0 rgba(0, 0, 0, 0.2)', "elevation-3": "0 4px 8px 0 rgba(0, 0, 0, 0.2)",
'elevation-4': '0 8px 16px 0 rgba(0, 0, 0, 0.25)', "elevation-4": "0 8px 16px 0 rgba(0, 0, 0, 0.25)",
'elevation-5': '0 16px 32px 0 rgba(0, 0, 0, 0.3)', "elevation-5": "0 16px 32px 0 rgba(0, 0, 0, 0.3)",
'panel': '0 4px 6px -1px rgba(0, 48, 138, 0.1), 0 2px 4px -2px rgba(0, 48, 138, 0.1)', panel: "0 4px 6px -1px rgba(0, 48, 138, 0.1), 0 2px 4px -2px rgba(0, 48, 138, 0.1)",
'button': '0 2px 4px rgba(0, 48, 138, 0.2), inset 0 1px 2px rgba(255, 255, 255, 0.1)', button: "0 2px 4px rgba(0, 48, 138, 0.2), inset 0 1px 2px rgba(255, 255, 255, 0.1)",
'card': '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)', card: "0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)",
'modal': '0 8px 32px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.15)', modal: "0 8px 32px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.15)",
}, },
animation: { animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
'spin-slow': 'spin 3s linear infinite', "spin-slow": "spin 3s linear infinite",
}, },
transitionDuration: { transitionDuration: {
'2000': '2000ms', "2000": "2000ms",
'3000': '3000ms', "3000": "3000ms",
}, },
screens: { screens: {
'3xl': '1920px', "3xl": "1920px",
'4xl': '2560px', "4xl": "2560px",
}, },
}, },
}, },
plugins: [ plugins: [],
};
],
}

View File

@ -62,7 +62,7 @@ export class PatchHandler extends BaseHandler {
try { try {
// 从请求中获取文件ID // 从请求中获取文件ID
const id = this.getFileIdFromRequest(req) const id = this.getFileIdFromRequest(req)
console.log('id', id) // console.log('id', id)
if (!id) { if (!id) {
throw ERRORS.FILE_NOT_FOUND throw ERRORS.FILE_NOT_FOUND
} }