diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 2f62a69..57a4a2c 100644 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -99,7 +99,7 @@ export async function updatePostState(id: string) { }, }, }); - if (post?.state === PostState.COMPLETED) { + if (post?.state === PostState.RESOLVED) { return; } const postReceiverIds = post.receivers @@ -132,7 +132,7 @@ export async function updatePostState(id: string) { if (receiverComments > 0) { await db.post.update({ where: { id }, - data: { state: PostState.COMPLETED }, + data: { state: PostState.RESOLVED }, }); } } diff --git a/apps/server/src/queue/worker/file.processor.ts b/apps/server/src/queue/worker/file.processor.ts index a37c266..0fcc3e3 100644 --- a/apps/server/src/queue/worker/file.processor.ts +++ b/apps/server/src/queue/worker/file.processor.ts @@ -10,7 +10,7 @@ const pipeline = new ResourceProcessingPipeline() .addProcessor(new VideoProcessor()); export default async function processJob(job: Job) { if (job.name === QueueJobType.FILE_PROCESS) { - console.log(job); + // console.log(job); const { resource } = job.data; if (!resource) { throw new Error('No resource provided in job data'); diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 5b6961f..a9805d7 100644 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -89,6 +89,7 @@ export class TusService implements OnModuleInit { upload: Upload, ) { try { + console.log('upload.id', upload.id); const resource = await this.resourceService.update({ where: { fileId: this.getFileId(upload.id) }, data: { status: ResourceStatus.UPLOADED }, diff --git a/apps/server/src/upload/types.ts b/apps/server/src/upload/types.ts index ef60aaa..2140ebc 100644 --- a/apps/server/src/upload/types.ts +++ b/apps/server/src/upload/types.ts @@ -1,19 +1,24 @@ export interface UploadCompleteEvent { - identifier: string; - filename: string; - size: number; - hash: string; - integrityVerified: boolean; + identifier: string; + filename: string; + size: number; + hash: string; + integrityVerified: boolean; } export type UploadEvent = { - uploadStart: { identifier: string; filename: string; totalSize: number, resuming?: boolean }; - uploadComplete: UploadCompleteEvent - uploadError: { identifier: string; error: string, filename: string }; -} + uploadStart: { + identifier: string; + filename: string; + totalSize: number; + resuming?: boolean; + }; + uploadComplete: UploadCompleteEvent; + uploadError: { identifier: string; error: string; filename: string }; +}; export interface UploadLock { - clientId: string; - timestamp: number; + clientId: string; + timestamp: number; } // 添加重试机制,处理临时网络问题 // 实现定期清理过期的临时文件 @@ -21,4 +26,4 @@ export interface UploadLock { // 实现上传进度持久化,支持服务重启后恢复 // 添加并发限制,防止系统资源耗尽 // 实现文件去重功能,避免重复上传 -// 添加日志记录和监控机制 \ No newline at end of file +// 添加日志记录和监控机制 diff --git a/apps/server/src/upload/upload.controller.ts b/apps/server/src/upload/upload.controller.ts index f014c42..d81dec2 100644 --- a/apps/server/src/upload/upload.controller.ts +++ b/apps/server/src/upload/upload.controller.ts @@ -13,10 +13,25 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; import { TusService } from './tus.service'; +import { ResourceService } from '@server/models/resource/resource.service'; @Controller('upload') 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() // async handlePost(@Req() req: Request, @Res() res: Response) { // return this.tusService.handleTus(req, res); diff --git a/apps/server/src/upload/utils.ts b/apps/server/src/upload/utils.ts index a171d7f..a7c189f 100644 --- a/apps/server/src/upload/utils.ts +++ b/apps/server/src/upload/utils.ts @@ -1,4 +1,4 @@ export function extractFileIdFromNginxUrl(url: string) { const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/); return match ? match[1] : ''; -} \ No newline at end of file +} diff --git a/apps/web/src/app/main/letter/editor/page.tsx b/apps/web/src/app/main/letter/editor/page.tsx index bece392..e2ff2f9 100644 --- a/apps/web/src/app/main/letter/editor/page.tsx +++ b/apps/web/src/app/main/letter/editor/page.tsx @@ -1,5 +1,5 @@ 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() { return ( diff --git a/apps/web/src/app/main/letter/list/constants.ts b/apps/web/src/app/main/letter/list/constants.ts index 7858fc6..df76d37 100644 --- a/apps/web/src/app/main/letter/list/constants.ts +++ b/apps/web/src/app/main/letter/list/constants.ts @@ -1,56 +1,56 @@ import { Letter } from "./types"; export const letters: Letter[] = [ - { - id: '1', - title: 'F-35 Maintenance Schedule Optimization Proposal', - sender: 'John Doe', - rank: 'TSgt', - unit: '33d Fighter Wing', - date: '2025-01-22', - priority: 'high', - status: 'pending', - category: 'suggestion', - isStarred: false, - content: 'Proposal for improving F-35 maintenance efficiency...' - }, - { - id: '2', - title: 'Base Housing Facility Improvement Request', - sender: 'Jane Smith', - rank: 'SSgt', - unit: '96th Test Wing', - date: '2025-01-21', - priority: 'medium', - status: 'in-progress', - category: 'request', - isStarred: true, - content: 'Request for updating base housing facilities...' - }, - { - id: '3', - title: 'Training Program Enhancement Feedback', - sender: 'Robert Johnson', - rank: 'Capt', - unit: '58th Special Operations Wing', - date: '2025-01-20', - priority: 'medium', - status: 'pending', - category: 'feedback', - isStarred: false, - content: 'Feedback regarding current training procedures...' - }, - { - id: '4', - title: 'Cybersecurity Protocol Update Suggestion', - sender: 'Emily Wilson', - rank: 'MSgt', - unit: '67th Cyberspace Wing', - date: '2025-01-19', - priority: 'high', - status: 'pending', - category: 'suggestion', - isStarred: true, - content: 'Suggestions for improving base cybersecurity measures...' - } -]; \ No newline at end of file + { + id: "1", + title: "F-35 Maintenance Schedule Optimization Proposal", + sender: "John Doe", + rank: "TSgt", + unit: "33d Fighter Wing", + date: "2025-01-22", + priority: "high", + status: "pending", + category: "suggestion", + isStarred: false, + content: "Proposal for improving F-35 maintenance efficiency...", + }, + { + id: "2", + title: "Base Housing Facility Improvement Request", + sender: "Jane Smith", + rank: "SSgt", + unit: "96th Test Wing", + date: "2025-01-21", + priority: "medium", + status: "processing", + category: "request", + isStarred: true, + content: "Request for updating base housing facilities...", + }, + { + id: "3", + title: "Training Program Enhancement Feedback", + sender: "Robert Johnson", + rank: "Capt", + unit: "58th Special Operations Wing", + date: "2025-01-20", + priority: "medium", + status: "pending", + category: "feedback", + isStarred: false, + content: "Feedback regarding current training procedures...", + }, + { + id: "4", + title: "Cybersecurity Protocol Update Suggestion", + sender: "Emily Wilson", + rank: "MSgt", + unit: "67th Cyberspace Wing", + date: "2025-01-19", + priority: "high", + status: "pending", + category: "suggestion", + isStarred: true, + content: "Suggestions for improving base cybersecurity measures...", + }, +]; diff --git a/apps/web/src/app/main/letter/list/types.ts b/apps/web/src/app/main/letter/list/types.ts index d6c1a4a..e207683 100644 --- a/apps/web/src/app/main/letter/list/types.ts +++ b/apps/web/src/app/main/letter/list/types.ts @@ -1,16 +1,16 @@ -export type Priority = 'high' | 'medium' | 'low'; -export type Category = 'complaint' | 'suggestion' | 'request' | 'feedback'; -export type Status = 'pending' | 'in-progress' | 'resolved'; +export type Priority = "high" | "medium" | "low"; +export type Category = "complaint" | "suggestion" | "request" | "feedback"; +export type Status = "pending" | "processing" | "resolved"; export interface Letter { - id: string; - title: string; - sender: string; - rank: string; - unit: string; - date: string; - priority: Priority; - category: Category; - status: Status; - content?: string; - isStarred: boolean; -} \ No newline at end of file + id: string; + title: string; + sender: string; + rank: string; + unit: string; + date: string; + priority: Priority; + category: Category; + status: Status; + content?: string; + isStarred: boolean; +} diff --git a/apps/web/src/app/main/letter/list/utils.ts b/apps/web/src/app/main/letter/list/utils.ts index 173cfc5..55548e1 100644 --- a/apps/web/src/app/main/letter/list/utils.ts +++ b/apps/web/src/app/main/letter/list/utils.ts @@ -1,25 +1,30 @@ +import { PostState } from "@nice/common"; + export 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', - }, - status: { - pending: 'bg-yellow-100 text-yellow-800', - 'in-progress': 'bg-blue-100 text-blue-800', - resolved: 'bg-green-100 text-green-800', - }, + 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", + }, + status: { + [PostState.PENDING]: "bg-yellow-100 text-yellow-800", + [PostState.PROCESSING]: "bg-blue-100 text-blue-800", + [PostState.RESOLVED]: "bg-green-100 text-green-800", + }, } as const; export const getBadgeStyle = ( - type: keyof typeof BADGE_STYLES, - value: string + type: keyof typeof BADGE_STYLES, + value: 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" + ); }; diff --git a/apps/web/src/app/main/letter/progress/page.tsx b/apps/web/src/app/main/letter/progress/page.tsx index e8d0145..c9e03ad 100644 --- a/apps/web/src/app/main/letter/progress/page.tsx +++ b/apps/web/src/app/main/letter/progress/page.tsx @@ -1,175 +1,207 @@ -import { useState } from 'react' -import { Input, Button, Card, Steps, Tag, Spin, message } from 'antd' -import { SearchOutlined, SafetyCertificateOutlined } from '@ant-design/icons' +import { useState } from "react"; +import { Input, Button, Card, Steps, Tag, Spin, message } from "antd"; +import { SearchOutlined, SafetyCertificateOutlined } from "@ant-design/icons"; interface FeedbackStatus { - status: 'pending' | 'in-progress' | 'resolved' - ticketId: string - submittedDate: string - lastUpdate: string - title: string + status: "pending" | "processing" | "resolved"; + ticketId: string; + submittedDate: string; + lastUpdate: string; + title: string; } -const { Step } = Steps +const { Step } = Steps; export default function LetterProgressPage() { - const [feedbackId, setFeedbackId] = useState('') - const [status, setStatus] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') + const [feedbackId, setFeedbackId] = useState(""); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); - const validateInput = () => { - if (!feedbackId.trim()) { - setError('请输入有效的问题编号') - return false - } - if (!/^USAF-\d{4}-\d{4}$/.test(feedbackId)) { - setError('问题编号格式不正确,应为USAF-YYYY-NNNN') - return false - } - setError('') - return true - } + const validateInput = () => { + if (!feedbackId.trim()) { + setError("请输入有效的问题编号"); + return false; + } + if (!/^USAF-\d{4}-\d{4}$/.test(feedbackId)) { + setError("问题编号格式不正确,应为USAF-YYYY-NNNN"); + return false; + } + setError(""); + return true; + }; - const mockLookup = () => { - if (!validateInput()) return + const mockLookup = () => { + if (!validateInput()) return; - setLoading(true) - setTimeout(() => { - setStatus({ - status: 'in-progress', - ticketId: feedbackId, - submittedDate: '2025-01-15', - lastUpdate: '2025-01-21', - title: 'Aircraft Maintenance Schedule Inquiry' - }) - setLoading(false) - }, 1000) - } + setLoading(true); + setTimeout(() => { + setStatus({ + status: "processing", + ticketId: feedbackId, + submittedDate: "2025-01-15", + lastUpdate: "2025-01-21", + title: "Aircraft Maintenance Schedule Inquiry", + }); + setLoading(false); + }, 1000); + }; - const getStatusColor = (status: string) => { - switch (status) { - case 'pending': - return 'orange' - case 'in-progress': - return 'blue' - case 'resolved': - return 'green' - default: - return 'gray' - } - } + const getStatusColor = (status: string) => { + switch (status) { + case "pending": + return "orange"; + case "processing": + return "blue"; + case "resolved": + return "green"; + default: + return "gray"; + } + }; - return ( -
- {/* Header */} -
-
- -
-

USAF Feedback Tracking System

-

Enter your ticket ID to track progress

-
-
-
+ return ( +
+ {/* Header */} +
+
+ +
+

+ USAF Feedback Tracking System +

+

+ Enter your ticket ID to track progress +

+
+
+
- {/* Main Content */} -
- {/* Search Section */} - -
- -
- } - size="large" - value={feedbackId} - onChange={(e) => setFeedbackId(e.target.value)} - placeholder="e.g. USAF-2025-0123" - status={error ? 'error' : ''} - className="border border-gray-300" - /> - -
- {error &&

{error}

} -
-
+ {/* Main Content */} +
+ {/* Search Section */} + +
+ +
+ + } + size="large" + value={feedbackId} + onChange={(e) => setFeedbackId(e.target.value)} + placeholder="e.g. USAF-2025-0123" + status={error ? "error" : ""} + className="border border-gray-300" + /> + +
+ {error && ( +

{error}

+ )} +
+
- {/* Results Section */} - {status && ( - -
- {/* Header */} -
-

- Ticket Details -

- - {status.status} - -
+ {/* Results Section */} + {status && ( + +
+ {/* Header */} +
+

+ Ticket Details +

+ + {status.status} + +
- {/* Details Grid */} -
-
-

Ticket ID

-

{status.ticketId}

-
-
-

Submitted Date

-

{status.submittedDate}

-
-
-

Last Update

-

{status.lastUpdate}

-
-
-

Subject

-

{status.title}

-
-
+ {/* Details Grid */} +
+
+

+ Ticket ID +

+

+ {status.ticketId} +

+
+
+

+ Submitted Date +

+

+ {status.submittedDate} +

+
+
+

+ Last Update +

+

+ {status.lastUpdate} +

+
+
+

+ Subject +

+

+ {status.title} +

+
+
- {/* Progress Timeline */} -
- - } - /> - } - /> - } - /> - -
-
-
- )} -
-
- ) + {/* Progress Timeline */} +
+ + } + /> + } + /> + } + /> + +
+
+ + )} + + + ); } diff --git a/apps/web/src/components/common/uploader/FileUploader.tsx b/apps/web/src/components/common/uploader/FileUploader.tsx index 4bbf236..99bb2af 100644 --- a/apps/web/src/components/common/uploader/FileUploader.tsx +++ b/apps/web/src/components/common/uploader/FileUploader.tsx @@ -13,7 +13,7 @@ import { useTusUpload } from "@web/src/hooks/useTusUpload"; interface FileUploaderProps { endpoint?: string; - onSuccess?: (url: string) => void; + onSuccess?: (result: { url: string; fileId: string }) => void; onError?: (error: Error) => void; maxSize?: number; allowedTypes?: string[]; @@ -44,7 +44,7 @@ const FileItem: React.FC = memo( onClick={() => onRemove(file.name)} className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full" aria-label={`Remove ${file.name}`}> - + {!isUploaded && progress !== undefined && ( @@ -57,7 +57,7 @@ const FileItem: React.FC = memo( transition={{ duration: 0.3 }} /> - + {progress}% @@ -74,7 +74,6 @@ const FileItem: React.FC = memo( ); const FileUploader: React.FC = ({ - endpoint = "", onSuccess, onError, maxSize = 100, @@ -125,7 +124,12 @@ const FileUploader: React.FC = ({ handleFileUpload( file, (upload) => { - onSuccess?.(upload.url || ""); + console.log("Upload complete:", { + url: upload.url, + fileId: upload.fileId, + // resource: upload.resource + }); + onSuccess?.(upload); setFiles((prev) => prev.map((f) => f.file.name === file.name @@ -185,10 +189,11 @@ const FileUploader: React.FC = ({ relative flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer transition-colors duration-200 ease-in-out - ${isDragging - ? "border-blue-500 bg-blue-50" - : "border-gray-300 hover:border-blue-500" - } + ${ + isDragging + ? "border-blue-500 bg-blue-50" + : "border-gray-300 hover:border-blue-500" + } `}> = ({ accept={allowedTypes.join(",")} className="hidden" /> - -

{placeholder}

+ +

{placeholder}

{isDragging && (

diff --git a/apps/web/src/components/common/uploader/TusUploader.tsx b/apps/web/src/components/common/uploader/TusUploader.tsx new file mode 100644 index 0000000..4211322 --- /dev/null +++ b/apps/web/src/components/common/uploader/TusUploader.tsx @@ -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 ; +}; diff --git a/apps/web/src/components/models/post/LetterCard.tsx b/apps/web/src/components/models/post/LetterCard.tsx index e6232e5..9647bfc 100644 --- a/apps/web/src/components/models/post/LetterCard.tsx +++ b/apps/web/src/components/models/post/LetterCard.tsx @@ -12,41 +12,37 @@ interface LetterCardProps { } export function LetterCard({ letter }: LetterCardProps) { - const [likes, setLikes] = useState(0); - const [liked, setLiked] = useState(false); - const [views] = useState(Math.floor(Math.random() * 100)); // 模拟浏览量数据 + const [likes, setLikes] = useState(0); + const [liked, setLiked] = useState(false); + const [views] = useState(Math.floor(Math.random() * 100)); // 模拟浏览量数据 - const handleLike = () => { - if (!liked) { - setLikes(prev => prev + 1); - setLiked(true); - toast.success('已点赞!', { - icon: , - className: 'custom-message', + const handleLike = () => { + if (!liked) { + setLikes((prev) => prev + 1); + setLiked(true); + toast.success("已点赞!", { + icon: , + className: "custom-message", + }); + } else { + setLikes((prev) => prev - 1); + setLiked(false); + toast("已取消点赞", { + className: "custom-message", + }); + } + }; - }); - } else { - setLikes(prev => prev - 1); - setLiked(false); - toast('已取消点赞', { - className: 'custom-message', - - }); - } - }; - - return ( -

-
- {/* Title & Priority */} -
- - <a - href={`/letters/${letter.id}`} - target="_blank" - className="text-primary transition-all duration-300 relative + return ( + <div className="w-full p-4 bg-white transition-all duration-300 ease-in-out group"> + <div className="flex flex-col gap-3"> + {/* Title & Priority */} + <div className="flex justify-between items-start"> + <Title level={4} className="!mb-0 flex-1"> + <a + href={`/letters/${letter.id}`} + target="_blank" + 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 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" @@ -76,18 +72,17 @@ export function LetterCard({ letter }: LetterCardProps) { </Space> </div> - {/* Content Preview */} - {letter.content && ( - <div className="flex items-start gap-2"> - <FileTextOutlined className="text-gray-400 mt-1" /> - <Paragraph - ellipsis={{ rows: 2 }} - className="!mb-3 text-gray-600 flex-1" - > - {letter.content} - </Paragraph> - </div> - )} + {/* Content Preview */} + {letter.content && ( + <div className="flex items-start gap-2"> + <FileTextOutlined className="text-gray-400 mt-1" /> + <Paragraph + ellipsis={{ rows: 2 }} + className="!mb-3 text-gray-600 flex-1"> + {letter.content} + </Paragraph> + </div> + )} {/* Badges & Interactions */} <div className="flex justify-between items-center"> @@ -96,56 +91,58 @@ export function LetterCard({ letter }: LetterCardProps) { <Badge type="status" value={'22'} /> </Space> - <div className="flex items-center gap-4"> - <div className="flex items-center gap-1 text-gray-500"> - <EyeOutlined className="text-lg" /> - <span className="text-sm">{views}</span> - </div> - <Tooltip - title={liked ? '取消点赞' : '点赞'} - placement="top" - > - <Button - type={liked ? 'primary' : 'default'} - shape="round" - size="small" - icon={liked ? <LikeFilled /> : <LikeOutlined />} - onClick={handleLike} - className={` + <div className="flex items-center gap-4"> + <div className="flex items-center gap-1 text-gray-500"> + <EyeOutlined className="text-lg" /> + <span className="text-sm">{views}</span> + </div> + <Tooltip + title={liked ? "取消点赞" : "点赞"} + placement="top"> + <Button + type={liked ? "primary" : "default"} + shape="round" + size="small" + icon={liked ? <LikeFilled /> : <LikeOutlined />} + onClick={handleLike} + className={` flex items-center gap-1 px-3 transform transition-all duration-300 hover:scale-105 hover:shadow-md - ${liked ? 'bg-blue-500 hover:bg-blue-600' : 'hover:border-blue-500 hover:text-blue-500'} - `} - > - <span className={liked ? 'text-white' : ''}>{likes}</span> - </Button> - </Tooltip> - </div> - </div> - </div> - </div> - ); + ${liked ? "bg-blue-500 hover:bg-blue-600" : "hover:border-blue-500 hover:text-blue-500"} + `}> + <span className={liked ? "text-white" : ""}> + {likes} + </span> + </Button> + </Tooltip> + </div> + </div> + </div> + </div> + ); } -function Badge({ - type, - value, - className = '' +export function Badge({ + type, + value, + className = "", }: { - type: 'priority' | 'category' | 'status'; - value: string; - className?: string; + type: "priority" | "category" | "status"; + value: string; + className?: string; }) { - return ( - <span - className={` + 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} - `} - > - {value.toUpperCase()} - </span> - ); + `}> + + {value?.toUpperCase()} + </span> + ) + ); } diff --git a/apps/web/src/components/models/post/detail/PostCommentCard.tsx b/apps/web/src/components/models/post/detail/PostCommentCard.tsx index 3cd2422..828cbaf 100644 --- a/apps/web/src/components/models/post/detail/PostCommentCard.tsx +++ b/apps/web/src/components/models/post/detail/PostCommentCard.tsx @@ -7,6 +7,8 @@ import { useVisitor } from "@nice/client"; import { useContext, useState } from "react"; import { PostDetailContext } from "./context/PostDetailContext"; import { LikeFilled, LikeOutlined } from "@ant-design/icons"; +import PostLikeButton from "./PostHeader/PostLikeButton"; +import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar"; export default function PostCommentCard({ post, @@ -55,34 +57,36 @@ export default function PostCommentCard({ <motion.div className="bg-white rounded-lg shadow-sm border border-slate-200 p-4" layout> - <div className="flex items-start space-x-3"> + <div className="flex items-start space-x-3 gap-4"> <div className="flex-shrink-0"> - <Avatar - className="ring-2 ring-white hover:ring-[#00538E]/90 - transition-all duration-200 ease-in-out shadow-md - hover:shadow-lg" + <CustomAvatar src={post.author?.avatar} - size={40}> - {!post.author?.avatar && - (post.author?.showname || "匿名用户")} - </Avatar> + size={40} + name={ + !post.author?.avatar && post.author?.showname + }></CustomAvatar> </div> <div className="flex-1 min-w-0"> - <div - className="flex items-center space-x-2" - style={{ height: 40 }}> - <span className="font-medium text-slate-900"> - {post.author?.showname || "匿名用户"} - </span> - <span className="text-sm text-slate-500"> - {dayjs(post?.createdAt).format("YYYY-MM-DD HH:mm")} - </span> - {isReceiverComment && ( - <span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800"> - 官方回答 + <div className="flex flex-1 justify-between"> + <div className="flex space-x-2" style={{ height: 40 }}> + <span className="font-medium text-slate-900"> + {post.author?.showname || "匿名用户"} </span> - )} + <span className="text-sm text-slate-500"> + {dayjs(post?.createdAt).format( + "YYYY-MM-DD HH:mm" + )} + </span> + {isReceiverComment && ( + <span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800"> + 官方回答 + </span> + )} + </div> + {/* 添加有帮助按钮 */} + <PostLikeButton post={post}></PostLikeButton> </div> + <div className="ql-editor text-slate-800" style={{ @@ -90,23 +94,6 @@ export default function PostCommentCard({ }} 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> </motion.div> diff --git a/apps/web/src/components/models/post/detail/PostCommentEditor.tsx b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx index 0361702..292a883 100644 --- a/apps/web/src/components/models/post/detail/PostCommentEditor.tsx +++ b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx @@ -1,14 +1,15 @@ import React, { useContext, useState } from "react"; 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 { PostDetailContext } from "./context/PostDetailContext"; import { usePost } from "@nice/client"; import { PostType } from "@nice/common"; import toast from "react-hot-toast"; import { isContentEmpty } from "./utils"; - +import { SendOutlined } from "@ant-design/icons"; +import { TusUploader } from "@web/src/components/common/uploader/TusUploader"; export default function PostCommentEditor() { const { post } = useContext(PostDetailContext); const [content, setContent] = useState(""); @@ -82,36 +83,21 @@ export default function PostCommentEditor() { )} </div> - <div className="flex items-center justify-end"> - {/* <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> */} + <TusUploader></TusUploader> - <motion.button - type="submit" - disabled={isContentEmpty(content)} + <div className="flex items-center justify-end"> + <motion.div whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - className={`flex items-center space-x-2 px-4 py-2 rounded-md - ${ - !isContentEmpty(content) - ? "bg-blue-600 text-white hover:bg-blue-700" - : "bg-slate-100 text-slate-400 cursor-not-allowed" - } transition-colors`}> - <PaperAirplaneIcon className="w-4 h-4" /> - <span>提交</span> - </motion.button> + whileTap={{ scale: 0.98 }}> + <Button + type="primary" + htmlType="submit" + disabled={isContentEmpty(content)} + className="flex items-center space-x-2 bg-primary" + icon={<SendOutlined />}> + 提交 + </Button> + </motion.div> </div> </form> </motion.div> diff --git a/apps/web/src/components/models/post/detail/PostHeader.tsx b/apps/web/src/components/models/post/detail/PostHeader.tsx index 55b1f21..8daf65d 100644 --- a/apps/web/src/components/models/post/detail/PostHeader.tsx +++ b/apps/web/src/components/models/post/detail/PostHeader.tsx @@ -11,14 +11,21 @@ import { EyeIcon, ChatBubbleLeftIcon, } from "@heroicons/react/24/outline"; - +import { Button, Typography, Space, Tooltip } from "antd"; import { useVisitor } from "@nice/client"; import { PostState, VisitType } from "@nice/common"; import { + CalendarOutlined, + ClockCircleOutlined, CommentOutlined, EyeOutlined, + FileTextOutlined, + FolderOutlined, LikeFilled, LikeOutlined, + LockOutlined, + UnlockOutlined, + UserOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; import { TitleSection } from "./PostHeader/TitleSection"; @@ -30,7 +37,9 @@ import { VisibilityBadge, } from "./PostHeader/InfoBadge"; import { StatsSection } from "./PostHeader/StatsSection"; +import { PostBadge } from "./badge/PostBadge"; +const { Title, Paragraph, Text } = Typography; export default function PostHeader() { const { post, user } = useContext(PostDetailContext); const { like, unLike } = useVisitor(); @@ -64,62 +73,76 @@ export default function PostHeader() { initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} 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 */} - <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 bottom-0 right-0 w-5 h-5 border-b-2 border-r-2 border-[#97A9C4] rounded-br-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-4 border-r-4 border-primary rounded-br-lg" /> {/* Title Section */} - <TitleSection - title={post?.title} - state={post?.state as PostState}></TitleSection> + <TitleSection></TitleSection> <div className="space-y-4"> - {/* First Row - Basic Info */} - <div className="flex flex-wrap gap-4"> - {/* Author Info Badge */} - <AuthorBadge - name={ - post?.author?.showname || "匿名用户" - }></AuthorBadge> + {/* 收件人信息行 */} + <Space> + <UserOutlined className="text-secondary-400" /> + <span className="text-secondary-400">收件人:</span> + <Text strong> + {post?.receivers?.map((receiver) => receiver?.showname)} + </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 */} - {post?.createdAt && ( - <DateBadge - date={dayjs(post?.createdAt).format("YYYY-MM-DD")} - label="创建于:"></DateBadge> - )} + <Space> + <CalendarOutlined className="text-secondary-400" /> + + <Text> + 创建于: + {dayjs(post?.createdAt).format("YYYY-MM-DD")} + </Text> + </Space> + <Text type="secondary">|</Text> {/* Last Updated Badge */} - {post?.updatedAt && post.updatedAt !== post.createdAt && ( - <UpdatedBadge - date={dayjs(post?.updatedAt).format( - "YYYY-MM-DD" - )}></UpdatedBadge> - )} + <Space> + <ClockCircleOutlined className="text-secondary-400" /> + <Text> + 更新于: + {dayjs(post?.updatedAt).format("YYYY-MM-DD")} + </Text> + </Space> + <Text type="secondary">|</Text> {/* Visibility Status Badge */} - <VisibilityBadge - isPublic={post?.isPublic}></VisibilityBadge> + <Space> + {post?.isPublic ? ( + <UnlockOutlined className="text-secondary-400" /> + ) : ( + <LockOutlined className="text-secondary-400" /> + )} + <Text>{post?.isPublic ? "公开" : "私信"}</Text> + </Space> </div> {/* Second Row - Term and Tags */} - <div className="flex flex-wrap gap-4"> - {/* Term Badge */} - {post?.term?.name && ( - <TermBadge term={post.term.name}></TermBadge> - )} - + <div className="flex flex-wrap gap-1"> {/* Tags Badges */} {post?.meta?.tags && post.meta.tags.length > 0 && post.meta.tags.map((tag, index) => ( - <motion.span - key={index} - whileHover={{ scale: 1.05 }} - className="inline-flex items-center bg-[#507AAF]/10 px-3 py-1.5 rounded border border-[#97A9C4]/50 shadow-md hover:bg-[#507AAF]/20"> - <span className="text-sm text-[#2B4C7E]"> - #{tag} - </span> - </motion.span> + <Space key={index}> + <PostBadge + type="tag" + value={`#${tag}`}></PostBadge> + </Space> ))} </div> </div> @@ -129,20 +152,15 @@ export default function PostHeader() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }} - className="mt-6 text-[#2B4C7E]"> + className="mt-6 text-secondary-700"> <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 || "" }} /> </motion.div> {/* Stats Section */} - <StatsSection - likes={post?.likes} - views={post?.views} - commentsCount={post?.commentsCount} - liked={post?.liked} - onLikeClick={likeThisPost}></StatsSection> + <StatsSection></StatsSection> </motion.div> ); } diff --git a/apps/web/src/components/models/post/detail/PostHeader/InfoBadge.tsx b/apps/web/src/components/models/post/detail/PostHeader/InfoBadge.tsx index 6a1931d..41d95a3 100644 --- a/apps/web/src/components/models/post/detail/PostHeader/InfoBadge.tsx +++ b/apps/web/src/components/models/post/detail/PostHeader/InfoBadge.tsx @@ -21,11 +21,10 @@ export function InfoBadge({ icon, text, delay = 0 }: InfoBadgeProps) { <motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} - transition={{ delay }} 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} - <span className="text-[#2B4C7E]">{text}</span> + <span className="text-primary">{text}</span> </motion.div> ); } @@ -33,7 +32,7 @@ export function InfoBadge({ icon, text, delay = 0 }: InfoBadgeProps) { export function AuthorBadge({ name }: { name: string }) { return ( <InfoBadge - icon={<UserCircleIcon className="h-5 w-5 text-[#2B4C7E]" />} + icon={<UserCircleIcon className="h-5 w-5 text-primary" />} text={name} /> ); @@ -42,7 +41,7 @@ export function AuthorBadge({ name }: { name: string }) { export function DateBadge({ date, label }: { date: string; label: string }) { return ( <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")}`} /> ); @@ -51,7 +50,7 @@ export function DateBadge({ date, label }: { date: string; label: string }) { export function UpdatedBadge({ date }: { date: string }) { return ( <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")}`} delay={0.45} /> @@ -63,9 +62,9 @@ export function VisibilityBadge({ isPublic }: { isPublic: boolean }) { <InfoBadge icon={ 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 ? "公开" : "私信"} @@ -77,7 +76,7 @@ export function VisibilityBadge({ isPublic }: { isPublic: boolean }) { export function TermBadge({ term }: { term: string }) { return ( <InfoBadge - icon={<StarIcon className="h-5 w-5 text-[#2B4C7E]" />} + icon={<StarIcon className="h-5 w-5 text-primary" />} text={term} delay={0.55} /> diff --git a/apps/web/src/components/models/post/detail/PostHeader/PostLikeButton.tsx b/apps/web/src/components/models/post/detail/PostHeader/PostLikeButton.tsx new file mode 100644 index 0000000..419d7be --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostHeader/PostLikeButton.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx b/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx index ecf9232..176dd77 100644 --- a/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx +++ b/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx @@ -1,68 +1,37 @@ -import React from "react"; +import React, { useContext } from "react"; 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 ( - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.7 }} - className="mt-6 flex flex-wrap gap-4 justify-start items-center" - > - <StatsButton - icon={liked ? <LikeFilled style={{ fontSize: 18 }} /> : <LikeOutlined style={{ fontSize: 18 }} />} - text={`${likes} 有帮助`} - onClick={onLikeClick} - isActive={liked} - /> - <StatsButton - icon={<EyeOutlined style={{ fontSize: 18 }} />} - text={`${views} 浏览`} - /> - <StatsButton - icon={<CommentOutlined style={{ fontSize: 18 }} />} - text={`${commentsCount} 回复`} - /> - </motion.div> - ); + return ( + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.7 }} + className="mt-6 flex flex-wrap gap-4 justify-between items-center"> + <div className=" flex gap-2"> + <div className="flex items-center gap-1 text-gray-500"> + <EyeOutlined className="text-lg" /> + <span className="text-sm">{post?.views}</span> + </div> + <div className="flex items-center gap-1 text-gray-500"> + <CommentOutlined className="text-lg" /> + <span className="text-sm">{post?.commentsCount}</span> + </div> + </div> + <PostLikeButton post={post}></PostLikeButton> + </motion.div> + ); } diff --git a/apps/web/src/components/models/post/detail/PostHeader/TitleSection.tsx b/apps/web/src/components/models/post/detail/PostHeader/TitleSection.tsx index e8d5d70..4de55f6 100644 --- a/apps/web/src/components/models/post/detail/PostHeader/TitleSection.tsx +++ b/apps/web/src/components/models/post/detail/PostHeader/TitleSection.tsx @@ -1,30 +1,29 @@ import { motion } from "framer-motion"; -import { Tag } from "antd"; +import { Space, Tag } from "antd"; import { PostState } from "@nice/common"; import { ClockIcon, CheckCircleIcon, ExclamationCircleIcon, } 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 { title: string; state: PostState; } -const stateColors = { - [PostState.PENDING]: "orange", - [PostState.PROCESSING]: "blue", - [PostState.COMPLETED]: "green", -}; - const stateLabels = { [PostState.PENDING]: "待处理", [PostState.PROCESSING]: "处理中", - [PostState.COMPLETED]: "已完成", + [PostState.RESOLVED]: "已完成", }; -export function TitleSection({ title, state }: TitleSectionProps) { +export function TitleSection() { + const { post, user } = useContext(PostDetailContext); return ( <motion.div initial={{ opacity: 0 }} @@ -32,34 +31,17 @@ export function TitleSection({ title, state }: TitleSectionProps) { transition={{ delay: 0.2 }} className="relative mb-6 flex items-center gap-4"> {/* 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 */} - <h1 className="text-2xl font-bold text-[#2B4C7E] pl-4 tracking-wider uppercase"> - {title} + <h1 className="text-xl font-bold text-primary tracking-wider uppercase"> + {post?.title} </h1> - {/* State Tag */} - {/* <motion.div - initial={{ opacity: 0, x: -20 }} - animate={{ opacity: 1, x: 0 }} - // 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> + <Space size="small" wrap className="flex-1"> + <PostBadge type="category" value={post?.term?.name} /> + <PostBadge type="state" value={post?.state} /> + </Space> {/* </motion.div> */} </motion.div> ); diff --git a/apps/web/src/components/models/post/detail/badge/PostBadge.tsx b/apps/web/src/components/models/post/detail/badge/PostBadge.tsx new file mode 100644 index 0000000..fb9565b --- /dev/null +++ b/apps/web/src/components/models/post/detail/badge/PostBadge.tsx @@ -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" + ); +}; diff --git a/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx b/apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx similarity index 100% rename from apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx rename to apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx diff --git a/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx similarity index 79% rename from apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx rename to apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx index d6fc1c0..f5327b7 100644 --- a/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx +++ b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx @@ -8,8 +8,9 @@ import { TagOutlined, FileTextOutlined, } from "@ant-design/icons"; -import FileUploader from "@web/src/components/common/uploader/FileUploader"; + import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; +import { PostBadge } from "../../detail/badge/PostBadge"; export function LetterBasicForm() { const { onSubmit, receiverId, termId, form } = useLetterEditor(); @@ -42,15 +43,11 @@ export function LetterBasicForm() { initialValues={{ meta: { tags: [] }, isPublic: true }}> {/* 收件人和板块信息行 */} <div className="flex justify-start items-center gap-8 "> - <div className="flex items-center font-semibold text-[#00308F]"> - <UserOutlined className="w-5 h-5 mr-2 text-[#00308F]" /> + <div className="flex items-center font-semibold text-primary"> + <UserOutlined className="w-5 h-5 mr-2 text-primary" /> <div>收件人:{receiver?.showname}</div> </div> - - <div className="flex items-center font-semibold text-[#00308F]"> - <FolderOutlined className="w-5 h-5 mr-2 text-[#00308F]" /> - <div>板块:{term?.name}</div> - </div> + <PostBadge type="category" value={term?.name}></PostBadge> </div> {/* 主题输入框 */} @@ -58,11 +55,13 @@ export function LetterBasicForm() { <Form.Item required={false} //不显示星号 label={ - <span className="block mb-1"> - <TagOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" /> + <div className="mb-1 items-center"> + <TagOutlined className="mr-2 text-primary" /> 标题 - <span className="text-gray-400">(必选)</span> - </span> + <span className="text-secondary-400"> + (必选) + </span> + </div> } name="title" rules={[{ required: true, message: "请输入标题" }]} @@ -77,7 +76,7 @@ export function LetterBasicForm() { <Form.Item label={ <span className="block mb-1"> - <TagOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" /> + <TagOutlined className=" mr-2 text-primary" /> 标签 </span> } @@ -108,9 +107,11 @@ export function LetterBasicForm() { <Form.Item label={ <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> } name="content" @@ -120,7 +121,7 @@ export function LetterBasicForm() { wrapperCol={{ span: 24 }}> <div className="relative rounded-lg border border-slate-200 bg-white shadow-sm"> <QuillEditor - maxLength={400} + maxLength={1000} placeholder="请输入内容" minRows={6} maxRows={12} @@ -132,9 +133,9 @@ export function LetterBasicForm() { </Form.Item> </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 name="isPublic" valuePropName="checked" @@ -146,7 +147,7 @@ export function LetterBasicForm() { <Button type="primary" 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={{ transform: "scale(1)", transition: "all 0.2s", diff --git a/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx b/apps/web/src/components/models/post/editor/layout/LetterEditorLayout.tsx similarity index 91% rename from apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx rename to apps/web/src/components/models/post/editor/layout/LetterEditorLayout.tsx index f3a5c38..9f083b6 100644 --- a/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx +++ b/apps/web/src/components/models/post/editor/layout/LetterEditorLayout.tsx @@ -3,21 +3,22 @@ import { motion } from "framer-motion"; import { PaperAirplaneIcon } from "@heroicons/react/24/outline"; import { LetterFormProvider } from "../context/LetterEditorContext"; import { LetterBasicForm } from "../form/LetterBasicForm"; +import { useTheme } from "@nice/theme"; export default function LetterEditorLayout() { const location = useLocation(); const params = new URLSearchParams(location.search); - + // const {} = useTheme(); 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" // 添加圆角和溢出隐藏 + className="min-h-screen 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="bg-gradient-to-r from-primary to-primary-400 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" @@ -28,7 +29,7 @@ export default function LetterEditorLayout() { type: "spring", stiffness: 100, }}> - <PaperAirplaneIcon className="h-12 w-12" /> + {/* <PaperAirplaneIcon className="h-12 w-12" /> */} <h1 className="text-3xl font-bold ml-4">撰写信件</h1> </motion.div> diff --git a/apps/web/src/components/presentation/CustomAvatar.tsx b/apps/web/src/components/presentation/CustomAvatar.tsx new file mode 100644 index 0000000..5d92b4b --- /dev/null +++ b/apps/web/src/components/presentation/CustomAvatar.tsx @@ -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> + ); +} diff --git a/apps/web/src/hooks/useTusUpload.ts b/apps/web/src/hooks/useTusUpload.ts index c9afb1f..6b3924b 100644 --- a/apps/web/src/hooks/useTusUpload.ts +++ b/apps/web/src/hooks/useTusUpload.ts @@ -1,17 +1,27 @@ -// useTusUpload.ts import { useState } from "react"; import * as tus from "tus-js-client"; interface UploadResult { - url?: string; + url: string; + fileId: string; + // resource: any; } export function useTusUpload() { const [progress, setProgress] = useState(0); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState<string | null>(null); - - const handleFileUpload = ( + const getFileId = (url: string) => { + 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, onSuccess: (result: UploadResult) => void, onError: (error: Error) => void @@ -19,9 +29,8 @@ export function useTusUpload() { setIsUploading(true); setProgress(0); setUploadError(null); - const upload = new tus.Upload(file, { - endpoint: "http://localhost:3000/upload", // 替换为实际的上传端点 + endpoint: "http://localhost:3000/upload", retryDelays: [0, 1000, 3000, 5000], metadata: { filename: file.name, @@ -34,10 +43,28 @@ export function useTusUpload() { ).toFixed(2); setProgress(Number(uploadProgress)); }, - onSuccess: () => { - setIsUploading(false); - setProgress(100); - onSuccess({ url: upload.url }); + onSuccess: async () => { + try { + if (upload.url) { + const fileId = getFileId(upload.url); + // const resource = await pollResourceStatus(fileId); + setIsUploading(false); + setProgress(100); + 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) => { setIsUploading(false); @@ -45,10 +72,8 @@ export function useTusUpload() { onError(error); }, }); - upload.start(); }; - return { progress, isUploading, diff --git a/packages/common/src/enum.ts b/packages/common/src/enum.ts index 851f64b..40733e5 100755 --- a/packages/common/src/enum.ts +++ b/packages/common/src/enum.ts @@ -8,7 +8,7 @@ export enum PostType { } export enum TaxonomySlug { CATEGORY = "category", - TAG = "tag" + TAG = "tag", } export enum VisitType { STAR = "star", @@ -193,5 +193,10 @@ export const LessonTypeLabel = { export enum PostState { PENDING = "pending", PROCESSING = "processing", - COMPLETED = "completed", + RESOLVED = "resolved", } +export const PostStateLabels = { + [PostState.PENDING]: "待处理", + [PostState.PROCESSING]: "处理中", + [PostState.RESOLVED]: "已解答", +}; diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts index b9d2045..8208654 100644 --- a/packages/common/src/select.ts +++ b/packages/common/src/select.ts @@ -15,7 +15,13 @@ export const postDetailSelect: Prisma.PostSelect = { termId: true, term: { include: { - taxonomy: true, + taxonomy: { + select: { + id: true, + slug: true, + name: true, + }, + }, }, }, authorId: true, diff --git a/packages/theme/src/tailwind.ts b/packages/theme/src/tailwind.ts index 7feafd8..28d0afa 100644 --- a/packages/theme/src/tailwind.ts +++ b/packages/theme/src/tailwind.ts @@ -1,106 +1,105 @@ -import type { Config } from 'tailwindcss' +import type { Config } from "tailwindcss"; export const NiceTailwindConfig: Config = { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: { - colors: { - // 主色调 - 空军蓝 - primary: { - 50: '#e8f2ff', - 100: '#c5d9f7', - 200: '#9fc0ef', - 300: '#78a7e7', - 400: '#528edf', - 500: '#00308a', - 600: '#00256b', - 700: '#001a4c', - 800: '#000f2d', - 900: '#00040e', - DEFAULT: '#00308a', - }, - // 辅助色 - 军事灰 - secondary: { - 50: '#f5f5f5', - 100: '#e0e0e0', - 200: '#c2c2c2', - 300: '#a3a3a3', - 400: '#858585', - 500: '#666666', - 600: '#4d4d4d', - 700: '#333333', - 800: '#1a1a1a', - 900: '#0d0d0d', - DEFAULT: '#4d4d4d', - }, - // 强调色 - 军徽金 - accent: { - 50: '#fff8e5', - 100: '#ffecb3', - 200: '#ffe080', - 300: '#ffd44d', - 400: '#ffc81a', - 500: '#e6b400', - 600: '#b38f00', - 700: '#806a00', - 800: '#4d4000', - 900: '#1a1500', - DEFAULT: '#e6b400', - }, - // 功能色 - success: '#28a745', - warning: '#ffc107', - danger: '#dc3545', - info: '#17a2b8', - }, - fontFamily: { - sans: ['Inter', 'sans-serif'], - heading: ['Bebas Neue', 'sans-serif'], - mono: ['Source Code Pro', 'monospace'], - }, - spacing: { - '72': '18rem', - '84': '21rem', - '96': '24rem', - }, - borderRadius: { - 'xl': '1rem', - '2xl': '2rem', - '3xl': '3rem', - }, - boxShadow: { - 'outline': '0 0 0 3px rgba(0, 48, 138, 0.5)', - 'solid': '2px 2px 0 0 rgba(0, 0, 0, 0.2)', - 'glow': '0 0 8px rgba(230, 180, 0, 0.8)', - '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-2': '0 2px 4px 0 rgba(0, 0, 0, 0.15)', - '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-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)', - '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)', - 'modal': '0 8px 32px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.15)', - }, - animation: { - 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', - 'spin-slow': 'spin 3s linear infinite', - }, - transitionDuration: { - '2000': '2000ms', - '3000': '3000ms', - }, - screens: { - '3xl': '1920px', - '4xl': '2560px', - }, - }, - }, - plugins: [ - - ], -} + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + // 主色调 - 空军蓝 + primary: { + 50: "#e8f2ff", + 100: "#c5d9f7", + 150: "#EDF0F8", // via color + 200: "#9fc0ef", + 250: "#E6E9F0", // from color + 300: "#78a7e7", + 350: "#D8E2EF", // to color + 400: "#528edf", + 500: "#00308a", + 600: "#00256b", + 700: "#001a4c", + 800: "#000f2d", + 900: "#00040e", + DEFAULT: "#00308a", + }, + // 辅助色 - 军事灰 + secondary: { + 50: "#f5f5f5", + 100: "#e0e0e0", + 200: "#c2c2c2", + 300: "#a3a3a3", + 350: "#97A9C4", // New color inserted + 400: "#858585", + 500: "#666666", + 600: "#4d4d4d", + 700: "#333333", + 800: "#1a1a1a", + 900: "#0d0d0d", + DEFAULT: "#4d4d4d", + }, + // 强调色 - 军徽金 + accent: { + 50: "#fff8e5", + 100: "#ffecb3", + 200: "#ffe080", + 300: "#ffd44d", + 400: "#ffc81a", + 500: "#e6b400", + 600: "#b38f00", + 700: "#806a00", + 800: "#4d4000", + 900: "#1a1500", + DEFAULT: "#e6b400", + }, + // 功能色 + success: "#28a745", + warning: "#ffc107", + danger: "#dc3545", + info: "#17a2b8", + }, + fontFamily: { + sans: ["Inter", "sans-serif"], + heading: ["Bebas Neue", "sans-serif"], + mono: ["Source Code Pro", "monospace"], + }, + spacing: { + "72": "18rem", + "84": "21rem", + "96": "24rem", + }, + borderRadius: { + xl: "1rem", + "2xl": "2rem", + "3xl": "3rem", + }, + boxShadow: { + outline: "0 0 0 3px rgba(0, 48, 138, 0.5)", + solid: "2px 2px 0 0 rgba(0, 0, 0, 0.2)", + glow: "0 0 8px rgba(230, 180, 0, 0.8)", + 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-2": "0 2px 4px 0 rgba(0, 0, 0, 0.15)", + "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-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)", + 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)", + modal: "0 8px 32px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.15)", + }, + animation: { + "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "spin-slow": "spin 3s linear infinite", + }, + transitionDuration: { + "2000": "2000ms", + "3000": "3000ms", + }, + screens: { + "3xl": "1920px", + "4xl": "2560px", + }, + }, + }, + plugins: [], +}; diff --git a/packages/tus/src/handlers/PatchHandler.ts b/packages/tus/src/handlers/PatchHandler.ts index 4197007..72da508 100644 --- a/packages/tus/src/handlers/PatchHandler.ts +++ b/packages/tus/src/handlers/PatchHandler.ts @@ -62,7 +62,7 @@ export class PatchHandler extends BaseHandler { try { // 从请求中获取文件ID const id = this.getFileIdFromRequest(req) - console.log('id', id) + // console.log('id', id) if (!id) { throw ERRORS.FILE_NOT_FOUND }