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

View File

@ -99,7 +99,7 @@ export async function updatePostState(id: string) {
},
},
});
if (post?.state === PostState.COMPLETED) {
if (post?.state === PostState.RESOLVED) {
return;
}
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 },
});
}
}

View File

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

View File

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

View File

@ -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 {
// 实现上传进度持久化,支持服务重启后恢复
// 添加并发限制,防止系统资源耗尽
// 实现文件去重功能,避免重复上传
// 添加日志记录和监控机制
// 添加日志记录和监控机制

View File

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

View File

@ -1,4 +1,4 @@
export function extractFileIdFromNginxUrl(url: string) {
const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/);
return match ? match[1] : '';
}
}

View File

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

View File

@ -1,153 +1,161 @@
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import { Button, Typography, Space, Tooltip } from 'antd';
import toast from 'react-hot-toast';
import { Letter } from './types';
import { getBadgeStyle } from './utils';
import { useState } from 'react';
import {
EyeOutlined,
LikeOutlined,
LikeFilled,
UserOutlined,
BankOutlined,
CalendarOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { Button, Typography, Space, Tooltip } from "antd";
import toast from "react-hot-toast";
import { Letter } from "./types";
import { getBadgeStyle } from "./utils";
import { useState } from "react";
const { Title, Paragraph, Text } = Typography;
interface LetterCardProps {
letter: Letter;
letter: Letter;
}
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"
>
{letter.title}
</a>
</Title>
{letter.priority && (
<Badge type="priority" value={letter.priority} className="ml-2" />
)}
</div>
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md">
{letter.title}
</a>
</Title>
{letter.priority && (
<Badge
type="priority"
value={letter.priority}
className="ml-2"
/>
)}
</div>
{/* Meta Info */}
<div className="flex justify-between items-center text-sm text-secondary">
<Space size="middle">
<Space>
<UserOutlined className="text-secondary-400" />
<Text strong>{letter.sender}</Text>
</Space>
<Text type="secondary">|</Text>
<Space>
<BankOutlined className="text-secondary-400" />
<Text>{letter.unit}</Text>
</Space>
</Space>
<Space>
<CalendarOutlined className="text-secondary-400" />
<Text type="secondary">{letter.date}</Text>
</Space>
</div>
{/* Meta Info */}
<div className="flex justify-between items-center text-sm text-secondary">
<Space size="middle">
<Space>
<UserOutlined className="text-secondary-400" />
<Text strong>{letter.sender}</Text>
</Space>
<Text type="secondary">|</Text>
<Space>
<BankOutlined className="text-secondary-400" />
<Text>{letter.unit}</Text>
</Space>
</Space>
<Space>
<CalendarOutlined className="text-secondary-400" />
<Text type="secondary">{letter.date}</Text>
</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">
<Space size="small" wrap className="flex-1">
<Badge type="category" value={letter.category} />
<Badge type="status" value={letter.status} />
</Space>
{/* Badges & Interactions */}
<div className="flex justify-between items-center">
<Space size="small" wrap className="flex-1">
<Badge type="category" value={letter.category} />
<Badge type="status" value={letter.status} />
</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>
)
);
}

View File

@ -1,90 +1,87 @@
import { SearchOutlined } from '@ant-design/icons';
import { Form, Input, Select, Spin } from 'antd';
import { useEffect } from 'react';
import { SearchOutlined } from "@ant-design/icons";
import { Form, Input, Select, Spin } from "antd";
import { useEffect } from "react";
interface SearchFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
filterCategory: string;
onCategoryChange: (value: string) => void;
filterStatus: string;
onStatusChange: (value: string) => void;
className?: string;
isLoading?: boolean;
searchTerm: string;
onSearchChange: (value: string) => void;
filterCategory: string;
onCategoryChange: (value: string) => void;
filterStatus: string;
onStatusChange: (value: string) => void;
className?: string;
isLoading?: boolean;
}
const LoadingIndicator = () => (
<Spin size="small" className="ml-2" />
);
const LoadingIndicator = () => <Spin size="small" className="ml-2" />;
export function SearchFilters({
searchTerm,
onSearchChange,
filterCategory,
onCategoryChange,
filterStatus,
onStatusChange,
className,
isLoading = false
searchTerm,
onSearchChange,
filterCategory,
onCategoryChange,
filterStatus,
onStatusChange,
className,
isLoading = false,
}: SearchFiltersProps) {
const [form] = Form.useForm();
const [form] = Form.useForm();
// 统一处理表单初始值
const initialValues = {
search: searchTerm,
category: filterCategory,
status: filterStatus
};
// 统一处理表单初始值
const initialValues = {
search: searchTerm,
category: filterCategory,
status: filterStatus,
};
useEffect(() => {
form.setFieldsValue(initialValues);
}, [searchTerm, filterCategory, filterStatus, form]);
useEffect(() => {
form.setFieldsValue(initialValues);
}, [searchTerm, filterCategory, filterStatus, form]);
return (
<Form
form={form}
layout="vertical"
className={className}
initialValues={initialValues}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Form.Item name="search" noStyle>
<Input
prefix={<SearchOutlined />}
placeholder="搜索关键词、发件人或单位..."
onChange={(e) => onSearchChange(e.target.value)}
allowClear
suffix={isLoading ? <LoadingIndicator /> : null}
/>
</Form.Item>
return (
<Form
form={form}
layout="vertical"
className={className}
initialValues={initialValues}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Form.Item name="search" noStyle>
<Input
prefix={<SearchOutlined />}
placeholder="搜索关键词、发件人或单位..."
onChange={(e) => onSearchChange(e.target.value)}
allowClear
suffix={isLoading ? <LoadingIndicator /> : null}
/>
</Form.Item>
<Form.Item name="category" noStyle>
<Select
className="w-full"
onChange={onCategoryChange}
options={[
{ value: 'all', label: '所有分类' },
{ value: 'complaint', label: '投诉' },
{ value: 'suggestion', label: '建议' },
{ value: 'request', label: '请求' },
{ value: 'feedback', label: '反馈' }
]}
/>
</Form.Item>
<Form.Item name="category" noStyle>
<Select
className="w-full"
onChange={onCategoryChange}
options={[
{ value: "all", label: "所有分类" },
{ value: "complaint", label: "投诉" },
{ value: "suggestion", label: "建议" },
{ value: "request", label: "请求" },
{ value: "feedback", label: "反馈" },
]}
/>
</Form.Item>
<Form.Item name="status" noStyle>
<Select
className="w-full"
onChange={onStatusChange}
options={[
{ value: 'all', label: '所有状态' },
{ value: 'pending', label: '待处理' },
{ value: 'in-progress', label: '处理中' },
{ value: 'resolved', label: '已解决' }
]}
/>
</Form.Item>
</div>
</Form>
);
<Form.Item name="status" noStyle>
<Select
className="w-full"
onChange={onStatusChange}
options={[
{ value: "all", label: "所有状态" },
{ value: "pending", label: "待处理" },
{ value: "processing", label: "处理中" },
{ value: "resolved", label: "已解决" },
]}
/>
</Form.Item>
</div>
</Form>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,8 @@ import { useVisitor } from "@nice/client";
import { useContext, useState } from "react";
import { 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>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,68 +1,37 @@
import React from "react";
import React, { useContext } from "react";
import { motion } from "framer-motion";
import { 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>
);
}

View File

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

View File

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

View File

@ -8,8 +8,9 @@ import {
TagOutlined,
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",

View File

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

View File

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

View File

@ -1,17 +1,27 @@
// useTusUpload.ts
import { useState } from "react";
import * 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,

View File

@ -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]: "已解答",
};

View File

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

View File

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

View File

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