311 lines
9.8 KiB
TypeScript
311 lines
9.8 KiB
TypeScript
|
|
'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>
|
|||
|
|
);
|
|||
|
|
}
|