生产管理系统前端 - 地块档案管理页面开发

This commit is contained in:
2025-10-29 14:13:51 +08:00
parent 5d34bc3643
commit df8e6bf515
11 changed files with 2633 additions and 44 deletions

View File

@@ -1,18 +0,0 @@
'use client';
import { Card } from '@/components/ui/card';
export default function EntryPage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/archive/entry
</p>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { Card } from '@/components/ui/card';
export function LandAttachments() {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2"></label>
<div className="mt-2 border-2 border-dashed rounded-lg p-6 text-center hover:border-green-500 transition-colors cursor-pointer">
<div className="flex flex-col items-center">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3">
<span className="text-gray-400">📷</span>
</div>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground mt-1">
JPGPNG 5MB
</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<label className="block text-sm font-medium mb-2"></label>
<div className="mt-2 space-y-2">
<div className="border-2 border-dashed rounded-lg p-4 text-center hover:border-green-500 transition-colors cursor-pointer">
<div className="flex flex-col items-center">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mb-2">
<span className="text-gray-400">📄</span>
</div>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground mt-1">
PDF
</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs text-blue-800">
💡
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, X } from 'lucide-react';
import { Land } from '@/app/(app)/land-information/archive/manage/page';
import { toast } from 'sonner';
interface LandAttributesProps {
formData: Partial<Land>;
onChange: (data: Partial<Land>) => void;
}
export function LandAttributes({ formData, onChange }: LandAttributesProps) {
const [tagInput, setTagInput] = useState('');
const handleFieldChange = (field: keyof Land, value: any) => {
onChange({ ...formData, [field]: value });
};
// 添加标签
const handleAddTag = () => {
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
handleFieldChange('tags', [...(formData.tags || []), tagInput.trim()]);
setTagInput('');
toast.success('标签已添加');
}
};
// 删除标签
const handleRemoveTag = (tag: string) => {
handleFieldChange('tags', formData.tags?.filter(t => t !== tag) || []);
};
// Enter键添加标签
const handleTagKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2"></label>
<Select
value={formData.soilType}
onValueChange={(value) => handleFieldChange('soilType', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="loam"></SelectItem>
<SelectItem value="sand"></SelectItem>
<SelectItem value="clay"></SelectItem>
<SelectItem value="silt"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Select
value={formData.landUseType || ''}
onValueChange={(value) => handleFieldChange('landUseType', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="farmland"></SelectItem>
<SelectItem value="garden"></SelectItem>
<SelectItem value="forestland"></SelectItem>
<SelectItem value="grassland"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Select
value={formData.plantingMode || ''}
onValueChange={(value) => handleFieldChange('plantingMode', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open-field">🌾 </SelectItem>
<SelectItem value="greenhouse">🏠 </SelectItem>
<SelectItem value="orchard">🍎 </SelectItem>
<SelectItem value="paddy">🌊 </SelectItem>
<SelectItem value="dryland">🌵 </SelectItem>
</SelectContent>
</Select>
</div>
<div className="border-t pt-4">
<label className="block text-sm font-medium mb-2"></label>
<div className="flex gap-2 mb-3">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={handleTagKeyPress}
className="flex-1"
/>
<Button size="sm" onClick={handleAddTag}>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
{formData.tags?.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<X
className="w-3 h-3 cursor-pointer hover:text-red-500"
onClick={() => handleRemoveTag(tag)}
/>
</Badge>
))}
{(!formData.tags || formData.tags.length === 0) && (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Land } from '@/app/(app)/land-information/archive/manage/page';
interface LandBasicInfoProps {
formData: Partial<Land>;
onChange: (data: Partial<Land>) => void;
isEdit?: boolean;
}
export function LandBasicInfo({ formData, onChange, isEdit = false }: LandBasicInfoProps) {
const handleFieldChange = (field: keyof Land, value: any) => {
onChange({ ...formData, [field]: value });
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2"></label>
<Input
value={formData.code || ''}
onChange={(e) => handleFieldChange('code', e.target.value)}
placeholder="系统自动生成"
disabled={isEdit}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"> *</label>
<Input
value={formData.name || ''}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder="如东大田1号地"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"> *</label>
<Input
value={formData.location || ''}
onChange={(e) => handleFieldChange('location', e.target.value)}
placeholder="如:江苏省南京市浦口区"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">()</label>
<Input
type="number"
step="0.1"
value={formData.area || ''}
onChange={(e) => handleFieldChange('area', parseFloat(e.target.value) || 0)}
placeholder="请输入面积"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">()</label>
<Input
type="number"
step="0.1"
value={formData.perimeter || ''}
onChange={(e) => handleFieldChange('perimeter', parseFloat(e.target.value) || 0)}
placeholder="请输入周长"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Select
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">使</SelectItem>
<SelectItem value="maintenance"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Textarea
value={formData.remarks || ''}
onChange={(e) => handleFieldChange('remarks', e.target.value)}
placeholder="其他说明信息"
rows={3}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Map,
Upload,
Pen,
Square,
Layers,
CheckCircle2,
FileType,
MapPin
} from 'lucide-react';
import { GeoCoordinate } from '@/app/(app)/land-information/archive/manage/page';
import { toast } from 'sonner';
interface LandMapContainerProps {
coordinates: GeoCoordinate[];
area: number;
perimeter: number;
onCoordinatesChange: (coordinates: GeoCoordinate[], area: number, perimeter: number) => void;
}
export function LandMapContainer({
coordinates,
area,
perimeter,
onCoordinatesChange
}: LandMapContainerProps) {
const [isMapLoaded, setIsMapLoaded] = useState(false);
const [mapLayerType, setMapLayerType] = useState<'satellite' | 'street'>('satellite');
const [drawMode, setDrawMode] = useState<'none' | 'polygon' | 'rectangle'>('none');
const mapContainerRef = useRef<HTMLDivElement>(null);
// 初始化地图
useEffect(() => {
// 模拟地图加载
setTimeout(() => {
setIsMapLoaded(true);
toast.info('演示地图模式:可通过文件导入或手动输入坐标数据', { duration: 3000 });
}, 1000);
}, []);
// 切换地图图层
const toggleMapLayer = () => {
const newLayerType = mapLayerType === 'satellite' ? 'street' : 'satellite';
setMapLayerType(newLayerType);
toast.success(`已切换到${newLayerType === 'satellite' ? '卫星图' : '电子地图'}`);
};
// 开始绘制
const startDrawing = (mode: 'polygon' | 'rectangle') => {
setDrawMode(mode);
toast.info(`开始绘制${mode === 'polygon' ? '多边形' : '矩形'},点击地图描点,双击结束`);
// 模拟绘制完成
setTimeout(() => {
const mockCoordinates = [
{ lat: 39.9042 + 0.01, lng: 116.4074 - 0.01 },
{ lat: 39.9042 + 0.01, lng: 116.4074 + 0.01 },
{ lat: 39.9042 - 0.01, lng: 116.4074 + 0.01 },
{ lat: 39.9042 - 0.01, lng: 116.4074 - 0.01 },
];
const mockArea = 120.5;
const mockPerimeter = 1380;
onCoordinatesChange(mockCoordinates, mockArea, mockPerimeter);
setDrawMode('none');
toast.success(`绘制完成!面积:${mockArea.toFixed(2)}亩,周长:${mockPerimeter.toFixed(0)}`);
}, 2000);
};
// 文件导入
const handleFileImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (!['kml', 'geojson', 'json', 'shp'].includes(fileExtension || '')) {
toast.error('不支持的文件格式。请上传 KML、GeoJSON 或 SHP 文件');
return;
}
try {
// 模拟文件解析
const mockCoordinates = [
{ lat: 39.9042 + 0.008, lng: 116.4074 - 0.008 },
{ lat: 39.9042 + 0.008, lng: 116.4074 + 0.008 },
{ lat: 39.9042 - 0.008, lng: 116.4074 + 0.008 },
{ lat: 39.9042 - 0.008, lng: 116.4074 - 0.008 },
];
const mockArea = 85.3;
const mockPerimeter = 1120;
onCoordinatesChange(mockCoordinates, mockArea, mockPerimeter);
toast.success(`成功导入${fileExtension.toUpperCase()}文件!面积:${mockArea.toFixed(2)}亩,${mockCoordinates.length}个点`);
} catch (error) {
console.error('文件导入错误:', error);
toast.error('文件导入失败,请检查文件格式');
}
// 清空input
event.target.value = '';
};
return (
<Card className="p-6">
<div className="space-y-4">
{/* 地图工具栏 */}
<div className="flex items-center justify-between">
<h3 className="flex items-center gap-2">
<Map className="w-5 h-5 text-green-600" />
</h3>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleMapLayer}
>
<Layers className="w-4 h-4 mr-2" />
{mapLayerType === 'satellite' ? '卫星图' : '电子地图'}
</Button>
<Button
variant={drawMode === 'polygon' ? 'default' : 'outline'}
size="sm"
onClick={() => startDrawing('polygon')}
>
<Pen className="w-4 h-4 mr-2" />
</Button>
<Button
variant={drawMode === 'rectangle' ? 'default' : 'outline'}
size="sm"
onClick={() => startDrawing('rectangle')}
>
<Square className="w-4 h-4 mr-2" />
</Button>
<div className="relative">
<Input
type="file"
accept=".kml,.geojson,.json,.shp"
onChange={handleFileImport}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
<Button
variant="outline"
size="sm"
className="bg-green-50 hover:bg-green-100 border-green-300"
>
<Upload className="w-4 h-4 mr-2 text-green-600" />
<span className="text-green-700"></span>
</Button>
</div>
</div>
</div>
{/* 地图容器 */}
<div
ref={mapContainerRef}
className="w-full h-[500px] rounded-lg border-2 border-dashed relative bg-gradient-to-br from-green-50 to-blue-50"
>
{isMapLoaded && (
<div className="absolute inset-0 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<Map className="w-16 h-16 mx-auto mb-4 text-green-600 opacity-50" />
<h3 className="text-green-800 mb-2"></h3>
<p className="text-sm text-muted-foreground mb-4">
使
</p>
<ul className="text-sm text-left text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0" />
<span> KML/GeoJSON/SHP </span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0" />
<span></span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0" />
<span></span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0" />
<span></span>
</li>
</ul>
<div className="mt-4 p-3 bg-blue-50 rounded-md">
<p className="text-xs text-blue-800">
💡 SDK
</p>
</div>
</div>
</div>
)}
{!isMapLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<MapPin className="w-12 h-12 mx-auto mb-3 text-green-600 animate-pulse" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
)}
</div>
{/* 地图信息 */}
{coordinates && coordinates.length > 0 && (
<Alert>
<CheckCircle2 className="w-4 h-4 text-green-600" />
<AlertDescription>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2 font-medium">{coordinates.length} </span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2 font-medium text-green-600">{area.toFixed(2)} </span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2 font-medium">{perimeter.toFixed(0)} </span>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 文件格式说明 */}
<Alert className="bg-blue-50 border-blue-200">
<FileType className="w-4 h-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800">
<strong></strong>
<ul className="mt-2 space-y-1 ml-4 list-disc">
<li>KML - Google Earth </li>
<li>GeoJSON - Web GIS </li>
<li>SHP - ArcGIS Shapefile </li>
</ul>
</AlertDescription>
</Alert>
</div>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Input } from '@/components/ui/input';
import { Land } from '@/app/(app)/land-information/archive/manage/page';
interface LandOwnershipInfoProps {
formData: Partial<Land>;
onChange: (data: Partial<Land>) => void;
}
export function LandOwnershipInfo({ formData, onChange }: LandOwnershipInfoProps) {
const handleFieldChange = (field: keyof Land, value: any) => {
onChange({ ...formData, [field]: value });
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2"> *</label>
<Input
value={formData.owner || ''}
onChange={(e) => handleFieldChange('owner', e.target.value)}
placeholder="姓名"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Input
type="tel"
value={formData.ownerPhone || ''}
onChange={(e) => handleFieldChange('ownerPhone', e.target.value)}
placeholder="手机号码"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Input
value={formData.certificateNumber || ''}
onChange={(e) => handleFieldChange('certificateNumber', e.target.value)}
placeholder="土地确权证书编号"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Input
type="number"
value={formData.contractPeriod || ''}
onChange={(e) => handleFieldChange('contractPeriod', Number(e.target.value))}
placeholder="如30"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Input
type="date"
value={formData.contractStartDate || ''}
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<Input
type="date"
value={formData.contractEndDate || ''}
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,513 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import {
History,
MapPin,
FileText,
Clock,
User,
RotateCcw,
Eye,
GitBranch,
ChevronRight,
CheckCircle2
} from 'lucide-react';
import { Land } from '../page';
import { toast } from 'sonner';
// 地块版本历史接口
interface LandVersion {
id: string;
landId: string;
version: number;
changeType: 'create' | 'update-boundary' | 'update-attributes' | 'merge' | 'split';
changes: LandVersionChange[];
coordinates?: { lat: number; lng: number }[]; // 历史边界
attributes?: Partial<Land>; // 历史属性
changedBy: string;
changedAt: string;
remarks?: string;
}
// 版本变更详情
interface LandVersionChange {
field: string; // 字段名
oldValue: any;
newValue: any;
}
interface LandVersionHistoryProps {
land: Land;
open: boolean;
onOpenChange: (open: boolean) => void;
onRestore?: (version: LandVersion) => void;
}
export function LandVersionHistory({ land, open, onOpenChange, onRestore }: LandVersionHistoryProps) {
const [versions, setVersions] = useState<LandVersion[]>([]);
const [selectedVersion, setSelectedVersion] = useState<LandVersion | null>(null);
const [showDetailDialog, setShowDetailDialog] = useState(false);
useEffect(() => {
if (open && land) {
loadVersions();
}
}, [open, land]);
const loadVersions = () => {
try {
const key = `land_versions_${land.id}`;
const data = localStorage.getItem(key);
if (data) {
const versionList = JSON.parse(data) as LandVersion[];
// 按版本号降序排列
setVersions(versionList.sort((a, b) => b.version - a.version));
} else {
// 生成示例版本数据
const sampleVersions: LandVersion[] = [
{
id: `version_${land.id}_1`,
landId: land.id,
version: 3,
changeType: 'update-attributes',
changes: [
{ field: 'tags', oldValue: ['有机种植'], newValue: ['有机种植', '高产示范'] },
{ field: 'soilType', oldValue: 'clay', newValue: 'loam' }
],
coordinates: land.coordinates,
attributes: land,
changedBy: '张管理员',
changedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
remarks: '更新标签和土壤类型信息'
},
{
id: `version_${land.id}_2`,
landId: land.id,
version: 2,
changeType: 'update-boundary',
changes: [
{ field: 'coordinates', oldValue: '4个边界点', newValue: '5个边界点' },
{ field: 'area', oldValue: 118.5, newValue: 120.5 }
],
coordinates: land.coordinates,
attributes: land,
changedBy: '李测量员',
changedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(),
remarks: '重新测量地块边界'
},
{
id: `version_${land.id}_3`,
landId: land.id,
version: 1,
changeType: 'create',
changes: [
{ field: 'name', oldValue: null, newValue: land.name },
{ field: 'code', oldValue: null, newValue: land.code },
{ field: 'area', oldValue: null, newValue: land.area },
{ field: 'location', oldValue: null, newValue: land.location }
],
coordinates: land.coordinates,
attributes: land,
changedBy: '王录入员',
changedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
remarks: '创建地块基础信息'
}
];
setVersions(sampleVersions);
}
} catch (error) {
console.error('加载版本历史失败:', error);
toast.error('加载版本历史失败');
}
};
const getChangeTypeLabel = (type: LandVersion['changeType']) => {
const labels = {
'create': '创建',
'update-boundary': '边界更新',
'update-attributes': '属性更新',
'merge': '合并',
'split': '分割',
};
return labels[type];
};
const getChangeTypeBadge = (type: LandVersion['changeType']) => {
const colors = {
'create': 'bg-green-100 text-green-700',
'update-boundary': 'bg-blue-100 text-blue-700',
'update-attributes': 'bg-purple-100 text-purple-700',
'merge': 'bg-orange-100 text-orange-700',
'split': 'bg-pink-100 text-pink-700',
};
return colors[type];
};
const getFieldLabel = (field: string) => {
const labels: Record<string, string> = {
name: '地块名称',
code: '地块编号',
area: '面积',
perimeter: '周长',
location: '位置',
soilType: '土壤类型',
landUseType: '土地利用类型',
plantingMode: '种植模式',
owner: '权属人',
ownerPhone: '联系电话',
contractPeriod: '承包期限',
contractStartDate: '承包开始',
contractEndDate: '承包结束',
certificateNumber: '确权证号',
tags: '标签',
status: '状态',
coordinates: '边界坐标',
};
return labels[field] || field;
};
const formatValue = (field: string, value: any) => {
if (value === null || value === undefined) return '-';
if (field === 'soilType') {
const labels: Record<string, string> = {
loam: '壤土',
sand: '沙土',
clay: '黏土',
silt: '粉土',
other: '其他',
};
return labels[value] || value;
}
if (field === 'landUseType') {
const labels: Record<string, string> = {
farmland: '耕地',
garden: '园地',
forestland: '林地',
grassland: '草地',
other: '其他',
};
return labels[value] || value;
}
if (field === 'plantingMode') {
const labels: Record<string, string> = {
'open-field': '露地',
'greenhouse': '大棚',
'orchard': '果园',
'paddy': '水田',
'dryland': '旱地',
};
return labels[value] || value;
}
if (field === 'status') {
return value === 'active' ? '正常' : '待确认';
}
if (field === 'area') {
return `${value}`;
}
if (field === 'perimeter') {
return `${value}`;
}
if (field === 'contractPeriod') {
return `${value}`;
}
if (field === 'tags' && Array.isArray(value)) {
return value.join(', ');
}
if (field === 'coordinates' && Array.isArray(value)) {
return `${value.length} 个点`;
}
return value;
};
const handleViewDetail = (version: LandVersion) => {
setSelectedVersion(version);
setShowDetailDialog(true);
};
const handleRestore = (version: LandVersion) => {
if (window.confirm(`确定要恢复到版本 ${version.version} 吗?这将创建一个新版本。`)) {
onRestore?.(version);
toast.success('版本已恢复');
onOpenChange(false);
}
};
const isCurrentVersion = (version: LandVersion) => {
return version.version === 3; // 假设当前版本是3
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="w-5 h-5 text-green-600" />
- {land.name}
</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{versions.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p></p>
</div>
) : (
<div className="relative">
{/* 时间线 */}
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-gray-200" />
<div className="space-y-6">
{versions.map((version, index) => (
<div key={version.id} className="relative pl-16">
{/* 时间线节点 */}
<div className={`absolute left-6 w-5 h-5 rounded-full border-4 border-white ${
isCurrentVersion(version)
? 'bg-green-600'
: 'bg-gray-400'
}`} />
<Card className={`p-4 ${isCurrentVersion(version) ? 'border-green-600 border-2' : ''}`}>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge className={getChangeTypeBadge(version.changeType)}>
{getChangeTypeLabel(version.changeType)}
</Badge>
<span className="text-sm text-muted-foreground">
{version.version}
</span>
{isCurrentVersion(version) && (
<Badge className="bg-green-600">
<CheckCircle2 className="w-3 h-3 mr-1" />
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{new Date(version.changedAt).toLocaleString('zh-CN')}
</div>
<div className="flex items-center gap-1">
<User className="w-4 h-4" />
{version.changedBy}
</div>
</div>
{version.remarks && (
<p className="mt-2 text-sm text-muted-foreground">
{version.remarks}
</p>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleViewDetail(version)}
>
<Eye className="w-4 h-4 mr-1" />
</Button>
{!isCurrentVersion(version) && (
<Button
size="sm"
variant="outline"
onClick={() => handleRestore(version)}
>
<RotateCcw className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
{/* 变更摘要 */}
{version.changes.length > 0 && (
<div className="mt-3 pt-3 border-t">
<div className="text-sm space-y-1">
{version.changes.slice(0, 3).map((change, idx) => (
<div key={idx} className="flex items-center gap-2 text-muted-foreground">
<ChevronRight className="w-4 h-4" />
<span>{getFieldLabel(change.field)}:</span>
<span className="line-through opacity-60">
{formatValue(change.field, change.oldValue)}
</span>
<span></span>
<span className="text-foreground">
{formatValue(change.field, change.newValue)}
</span>
</div>
))}
{version.changes.length > 3 && (
<div className="text-xs text-muted-foreground">
{version.changes.length - 3} ...
</div>
)}
</div>
</div>
)}
</Card>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 版本详情对话框 */}
{selectedVersion && (
<Dialog open={showDetailDialog} onOpenChange={setShowDetailDialog}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-green-600" />
- {selectedVersion.version}
</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 版本基本信息 */}
<Card className="p-4 bg-gray-50">
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<div className="mt-2">
<Badge className={getChangeTypeBadge(selectedVersion.changeType)}>
{getChangeTypeLabel(selectedVersion.changeType)}
</Badge>
</div>
</div>
<div>
<Label></Label>
<div className="mt-2"> {selectedVersion.version}</div>
</div>
<div>
<Label></Label>
<div className="mt-2">
{new Date(selectedVersion.changedAt).toLocaleString('zh-CN')}
</div>
</div>
<div>
<Label></Label>
<div className="mt-2">{selectedVersion.changedBy}</div>
</div>
{selectedVersion.remarks && (
<div className="col-span-2">
<Label></Label>
<div className="mt-2">{selectedVersion.remarks}</div>
</div>
)}
</div>
</Card>
{/* 变更详情 */}
<div>
<h4 className="mb-3 flex items-center gap-2">
<GitBranch className="w-4 h-4" />
</h4>
<div className="space-y-2">
{selectedVersion.changes.map((change, idx) => (
<Card key={idx} className="p-3">
<div className="flex items-start gap-4">
<div className="flex-1">
<div className="text-sm mb-2">
<Badge variant="outline">{getFieldLabel(change.field)}</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-muted-foreground mb-1"></div>
<div className="p-2 bg-red-50 rounded border border-red-200 line-through">
{formatValue(change.field, change.oldValue)}
</div>
</div>
<div>
<div className="text-muted-foreground mb-1"></div>
<div className="p-2 bg-green-50 rounded border border-green-200">
{formatValue(change.field, change.newValue)}
</div>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
</div>
{/* 边界信息 */}
{selectedVersion.coordinates && selectedVersion.coordinates.length > 0 && (
<div>
<h4 className="mb-3 flex items-center gap-2">
<MapPin className="w-4 h-4" />
</h4>
<Card className="p-4 bg-gray-50">
<div className="text-sm text-muted-foreground">
: {selectedVersion.coordinates.length}
</div>
</Card>
</div>
)}
</div>
<DialogFooter>
{!isCurrentVersion(selectedVersion) && (
<Button onClick={() => {
handleRestore(selectedVersion);
setShowDetailDialog(false);
}}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
)}
<Button variant="outline" onClick={() => setShowDetailDialog(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
);
}
const Label = ({ children }: { children: React.ReactNode }) => (
<label className="text-sm text-muted-foreground">
{children}
</label>
);

File diff suppressed because it is too large Load Diff