311 lines
9.8 KiB
TypeScript
Executable File
311 lines
9.8 KiB
TypeScript
Executable File
'use client';
|
||
|
||
import { useRouter } from 'next/navigation';
|
||
import { Button } from '@nice/ui/components/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@nice/ui/components/card';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
DialogClose,
|
||
} from '@nice/ui/components/dialog';
|
||
import { Input } from '@nice/ui/components/input';
|
||
import { Label } from '@nice/ui/components/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nice/ui/components/select';
|
||
import { Separator } from '@nice/ui/components/separator';
|
||
import { Badge } from '@nice/ui/components/badge';
|
||
import { Textarea } from '@nice/ui/components/textarea';
|
||
import { Switch } from '@nice/ui/components/switch';
|
||
import { IconArrowLeft, IconCheck, IconClock, IconDeviceFloppy, IconSettings, IconCalendar } from '@tabler/icons-react';
|
||
import { DateTimePicker } from '@nice/ui/components/date-time-picker';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useTRPC } from '@fenghuo/client';
|
||
import dayjs from 'dayjs';
|
||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||
import 'dayjs/locale/zh-cn';
|
||
import { DeptSelect } from '../selector/dept-select';
|
||
import { TermSelect } from '../selector/term-select';
|
||
import { PostStatus, PostType } from '@fenghuo/common/post';
|
||
import { useEditorContext } from '@/components/articles/context';
|
||
import { useIsClient } from '@/hooks/use-is-client';
|
||
|
||
dayjs.extend(relativeTime);
|
||
dayjs.locale('zh-cn');
|
||
|
||
interface EditorSidebarProps {
|
||
className?: string;
|
||
}
|
||
|
||
export function EditorSidebar({ className }: EditorSidebarProps) {
|
||
const router = useRouter();
|
||
const trpc = useTRPC();
|
||
const isClient = useIsClient();
|
||
|
||
// 使用编辑器Context
|
||
const { state, updateField, save } = useEditorContext();
|
||
|
||
// 获取分类数据
|
||
const { data: taxonomiesData } = useQuery({
|
||
...trpc.taxonomy.findMany.queryOptions({
|
||
where: {
|
||
postTypes: {
|
||
has: PostType.ARTICLE,
|
||
},
|
||
},
|
||
}),
|
||
});
|
||
|
||
const handleBack = () => {
|
||
router.back();
|
||
};
|
||
|
||
const handleSave = (options?: { newStatus?: PostStatus; successMessage?: string }) => {
|
||
save(options);
|
||
};
|
||
|
||
const handleTermChange = (taxonomyId: string, termId: string | string[] | null) => {
|
||
// 只处理单选情况,如果是数组取第一个值,如果是空数组则为null
|
||
const finalTermId = Array.isArray(termId) ? (termId.length > 0 ? termId[0] : null) : termId;
|
||
const otherTerms = state.terms.filter((t) => t.taxonomyId !== taxonomyId);
|
||
const newTerms = finalTermId ? [...otherTerms, { id: finalTermId, taxonomyId }] : otherTerms;
|
||
updateField('terms', newTerms);
|
||
};
|
||
|
||
const formatTime = (date: Date) => {
|
||
return dayjs(date).fromNow();
|
||
};
|
||
|
||
const renderAutoSaveStatus = () => {
|
||
if (state.isSaving) {
|
||
return (
|
||
<div className="flex items-center gap-2 text-blue-600">
|
||
<IconClock className="h-4 w-4 animate-spin" />
|
||
<span className="text-sm">保存中...</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (state.lastSaved && !state.hasUnsavedChanges) {
|
||
return (
|
||
<div className="flex items-center gap-2 text-green-600">
|
||
<IconCheck className="h-4 w-4" />
|
||
<span className="text-sm">{isClient ? `已保存 ${formatTime(state.lastSaved)}` : '已保存'}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<span className="text-sm">{state.hasUnsavedChanges ? '有未保存的更改' : '未保存更改'}</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className={`w-96 bg-background border-r border-border flex flex-col ${className}`}>
|
||
<div className="p-4 border-b border-border">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 px-2">
|
||
<IconArrowLeft className="h-4 w-4 mr-2" />
|
||
返回列表
|
||
</Button>
|
||
<Badge variant={state.status === PostStatus.PUBLISHED ? 'default' : 'secondary'}>
|
||
{state.status === PostStatus.DRAFT ? '草稿' : state.status === PostStatus.PUBLISHED ? '已发布' : '已归档'}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="mb-4">{renderAutoSaveStatus()}</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button size="sm" variant="outline" onClick={() => handleSave()} className="flex-1" disabled={state.isSaving}>
|
||
<IconDeviceFloppy className="h-4 w-4 mr-2" />
|
||
保存
|
||
</Button>
|
||
{state.status === PostStatus.DRAFT && (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button size="sm" className="flex-1" disabled={state.isSaving}>
|
||
发布
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>确认发布</DialogTitle>
|
||
<DialogDescription>发布后,文章将对所有有权限的用户可见。您确定要发布吗?</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button variant="outline">取消</Button>
|
||
</DialogClose>
|
||
<DialogClose asChild>
|
||
<Button
|
||
onClick={() => {
|
||
handleSave({
|
||
newStatus: PostStatus.PUBLISHED,
|
||
successMessage: '文章已发布',
|
||
});
|
||
}}
|
||
>
|
||
确认发布
|
||
</Button>
|
||
</DialogClose>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||
<IconSettings className="h-4 w-4" />
|
||
文章设置
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="title" className="text-xs font-medium">
|
||
标题
|
||
</Label>
|
||
<Input
|
||
id="title"
|
||
value={state.title}
|
||
onChange={(e) => updateField('title', e.target.value)}
|
||
placeholder="请输入文章标题"
|
||
className="text-sm"
|
||
required={state.status === PostStatus.PUBLISHED || state.status === PostStatus.ARCHIVED}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="excerpt" className="text-xs font-medium">
|
||
摘要
|
||
</Label>
|
||
<Textarea
|
||
id="excerpt"
|
||
value={state.excerpt}
|
||
onChange={(e) => updateField('excerpt', e.target.value)}
|
||
placeholder="请输入文章摘要"
|
||
className="text-sm min-h-[80px] resize-none"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="space-y-4">
|
||
{isClient &&
|
||
taxonomiesData?.map((taxonomy) => {
|
||
const selectedTermId = state.terms.find((t) => t.taxonomyId === taxonomy.id)?.id;
|
||
|
||
return (
|
||
<div key={taxonomy.id} className="space-y-2">
|
||
<Label className="text-xs font-medium">{taxonomy.name}</Label>
|
||
<TermSelect
|
||
taxonomySlug={taxonomy.slug}
|
||
value={selectedTermId ?? ''}
|
||
onValueChange={(value) => handleTermChange(taxonomy.id, value)}
|
||
placeholder={`选择${taxonomy.name}`}
|
||
className="w-full"
|
||
allowClear
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium">
|
||
所属单位
|
||
{state.status !== PostStatus.DRAFT && <span className="text-red-500 ml-1">*</span>}
|
||
</Label>
|
||
<DeptSelect
|
||
value={state.organizationId}
|
||
onValueChange={(value) => updateField('organizationId', value as string)}
|
||
placeholder="选择单位"
|
||
className="w-full"
|
||
allowClear
|
||
/>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium">文章状态</Label>
|
||
<Select value={state.status} onValueChange={(value) => updateField('status', value as PostStatus)}>
|
||
<SelectTrigger className="text-sm">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={PostStatus.DRAFT}>草稿</SelectItem>
|
||
<SelectItem value={PostStatus.PUBLISHED}>已发布</SelectItem>
|
||
<SelectItem value={PostStatus.ARCHIVED}>已归档</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-medium flex items-center gap-2">
|
||
<IconCalendar className="h-3 w-3" />
|
||
发布时间
|
||
</Label>
|
||
<DateTimePicker
|
||
value={state.publishedAt || null}
|
||
onChange={(date) => updateField('publishedAt', date || undefined)}
|
||
placeholder="选择发布时间"
|
||
showTime={true}
|
||
showClear={true}
|
||
size="sm"
|
||
className="w-full text-sm"
|
||
minDate={isClient ? new Date() : undefined}
|
||
dateFormat="YYYY-MM-DD"
|
||
timeFormat="HH:mm"
|
||
/>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="sticky" className="text-xs font-medium">
|
||
置顶文章
|
||
</Label>
|
||
<Switch
|
||
id="sticky"
|
||
checked={state.order > 0}
|
||
onCheckedChange={(checked) => updateField('order', checked ? Date.now() : 0)}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{state.id && (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm font-medium">文章信息</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-muted-foreground">文章ID:</span>
|
||
<span className="font-mono">{state.id}</span>
|
||
</div>
|
||
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-muted-foreground">字数统计:</span>
|
||
<span>{state.content ? state.content.replace(/<[^>]*>/g, '').length : 0} 字</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|