Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
4dd07771c2
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ const pipeline = new ResourceProcessingPipeline()
|
|||
.addProcessor(new VideoProcessor());
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
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');
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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 {
|
|||
// 实现上传进度持久化,支持服务重启后恢复
|
||||
// 添加并发限制,防止系统资源耗尽
|
||||
// 实现文件去重功能,避免重复上传
|
||||
// 添加日志记录和监控机制
|
||||
// 添加日志记录和监控机制
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export function extractFileIdFromNginxUrl(url: string) {
|
||||
const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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...'
|
||||
}
|
||||
];
|
||||
{
|
||||
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...",
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
id: string;
|
||||
title: string;
|
||||
sender: string;
|
||||
rank: string;
|
||||
unit: string;
|
||||
date: string;
|
||||
priority: Priority;
|
||||
category: Category;
|
||||
status: Status;
|
||||
content?: string;
|
||||
isStarred: boolean;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<FeedbackStatus | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [feedbackId, setFeedbackId] = useState("");
|
||||
const [status, setStatus] = useState<FeedbackStatus | null>(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 (
|
||||
<div className="min-h-screen bg-white" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
{/* Header */}
|
||||
<header className="bg-[#003366] p-8 text-white">
|
||||
<div className="container mx-auto flex items-center">
|
||||
<SafetyCertificateOutlined className="text-4xl mr-4" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">USAF Feedback Tracking System</h1>
|
||||
<p className="text-lg">Enter your ticket ID to track progress</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-white"
|
||||
style={{ fontFamily: "Arial, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<header className="bg-[#003366] p-8 text-white">
|
||||
<div className="container mx-auto flex items-center">
|
||||
<SafetyCertificateOutlined className="text-4xl mr-4" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
USAF Feedback Tracking System
|
||||
</h1>
|
||||
<p className="text-lg">
|
||||
Enter your ticket ID to track progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Search Section */}
|
||||
<Card className="mb-8 border border-gray-200">
|
||||
<div className="space-y-4">
|
||||
<label className="block text-lg font-medium text-[#003366]">
|
||||
Ticket ID
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
prefix={<SearchOutlined className="text-[#003366]" />}
|
||||
size="large"
|
||||
value={feedbackId}
|
||||
onChange={(e) => setFeedbackId(e.target.value)}
|
||||
placeholder="e.g. USAF-2025-0123"
|
||||
status={error ? 'error' : ''}
|
||||
className="border border-gray-300"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SearchOutlined />}
|
||||
loading={loading}
|
||||
onClick={mockLookup}
|
||||
style={{ backgroundColor: '#003366', borderColor: '#003366' }}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Search Section */}
|
||||
<Card className="mb-8 border border-gray-200">
|
||||
<div className="space-y-4">
|
||||
<label className="block text-lg font-medium text-[#003366]">
|
||||
Ticket ID
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
prefix={
|
||||
<SearchOutlined className="text-[#003366]" />
|
||||
}
|
||||
size="large"
|
||||
value={feedbackId}
|
||||
onChange={(e) => setFeedbackId(e.target.value)}
|
||||
placeholder="e.g. USAF-2025-0123"
|
||||
status={error ? "error" : ""}
|
||||
className="border border-gray-300"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SearchOutlined />}
|
||||
loading={loading}
|
||||
onClick={mockLookup}
|
||||
style={{
|
||||
backgroundColor: "#003366",
|
||||
borderColor: "#003366",
|
||||
}}>
|
||||
Track
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Results Section */}
|
||||
{status && (
|
||||
<Card>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<h2 className="text-xl font-semibold text-[#003366]">
|
||||
Ticket Details
|
||||
</h2>
|
||||
<Tag
|
||||
color={getStatusColor(status.status)}
|
||||
className="font-bold uppercase"
|
||||
>
|
||||
{status.status}
|
||||
</Tag>
|
||||
</div>
|
||||
{/* Results Section */}
|
||||
{status && (
|
||||
<Card>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<h2 className="text-xl font-semibold text-[#003366]">
|
||||
Ticket Details
|
||||
</h2>
|
||||
<Tag
|
||||
color={getStatusColor(status.status)}
|
||||
className="font-bold uppercase">
|
||||
{status.status}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Ticket ID</p>
|
||||
<p className="font-medium text-[#003366]">{status.ticketId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Submitted Date</p>
|
||||
<p className="font-medium text-[#003366]">{status.submittedDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Update</p>
|
||||
<p className="font-medium text-[#003366]">{status.lastUpdate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Subject</p>
|
||||
<p className="font-medium text-[#003366]">{status.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Ticket ID
|
||||
</p>
|
||||
<p className="font-medium text-[#003366]">
|
||||
{status.ticketId}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Submitted Date
|
||||
</p>
|
||||
<p className="font-medium text-[#003366]">
|
||||
{status.submittedDate}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Last Update
|
||||
</p>
|
||||
<p className="font-medium text-[#003366]">
|
||||
{status.lastUpdate}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Subject
|
||||
</p>
|
||||
<p className="font-medium text-[#003366]">
|
||||
{status.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Timeline */}
|
||||
<div className="mt-8">
|
||||
<Steps
|
||||
current={status.status === 'pending' ? 0 : status.status === 'in-progress' ? 1 : 2}
|
||||
className="usa-progress"
|
||||
>
|
||||
<Step
|
||||
title="Submitted"
|
||||
description="Ticket received"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="In Progress"
|
||||
description="Under review"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Resolved"
|
||||
description="Ticket completed"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Steps>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
{/* Progress Timeline */}
|
||||
<div className="mt-8">
|
||||
<Steps
|
||||
current={
|
||||
status.status === "pending"
|
||||
? 0
|
||||
: status.status === "processing"
|
||||
? 1
|
||||
: 2
|
||||
}
|
||||
className="usa-progress">
|
||||
<Step
|
||||
title="Submitted"
|
||||
description="Ticket received"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="In Progress"
|
||||
description="Under review"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Resolved"
|
||||
description="Ticket completed"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Steps>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<FileItemProps> = 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}`}>
|
||||
<XMarkIcon className="w-5 h-5 text-tertiary-300" />
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{!isUploaded && progress !== undefined && (
|
||||
|
@ -57,7 +57,7 @@ const FileItem: React.FC<FileItemProps> = memo(
|
|||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-tertiary-300 mt-1">
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
@ -74,7 +74,6 @@ const FileItem: React.FC<FileItemProps> = memo(
|
|||
);
|
||||
|
||||
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
endpoint = "",
|
||||
onSuccess,
|
||||
onError,
|
||||
maxSize = 100,
|
||||
|
@ -125,7 +124,12 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
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<FileUploaderProps> = ({
|
|||
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"
|
||||
}
|
||||
`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
@ -198,8 +203,8 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
accept={allowedTypes.join(",")}
|
||||
className="hidden"
|
||||
/>
|
||||
<CloudArrowUpIcon className="w-10 h-10 text-tertiary-300" />
|
||||
<p className="mt-2 text-sm text-tertiary-300">{placeholder}</p>
|
||||
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
|
||||
<p className="text-blue-500 font-medium">
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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: <LikeFilled className="text-blue-500" />,
|
||||
className: 'custom-message',
|
||||
const handleLike = () => {
|
||||
if (!liked) {
|
||||
setLikes((prev) => prev + 1);
|
||||
setLiked(true);
|
||||
toast.success("已点赞!", {
|
||||
icon: <LikeFilled className="text-blue-500" />,
|
||||
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 (
|
||||
<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
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
};
|
|
@ -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",
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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]: "已解答",
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue