feat: 资源申请模块重构——清理816行死代码+提取共享helpers+修复类型标签bug
- 删除 CabinetView.tsx(422行) + ResourceModelManager.tsx(394行) 死代码 - 提取 approval/helpers.tsx 共享辅助函数(parseSteps/parseFields/renderFormData/ getProgressInfo/renderStepProgress/APPROVAL_TYPE_OPTIONS/getInstanceType) - 修复审批类型标签bug:instance无type字段,需从template.type取 (getApprovalTypeLabel/Color/Icon 全部改用 getInstanceType(instance)) - ApprovalFlow.tsx 771→666行(低于700限制) - ApprovalProcessTab.tsx 移除重复helpers+过期类型字典(deploy/server/permission→server_apply/app_deploy)
This commit is contained in:
1 parent
8c4b59510d
commit
f70feb360c
5 files changed
+155
-1020
No files matched your search
@@ -22,133 +22,28 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
GitBranch,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
Plus,
|
||||
X,
|
||||
GitCommit,
|
||||
Server,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { approvalApi } from '@/services/api';
|
||||
import { ApprovalTemplateTab } from './ApprovalTemplateTab';
|
||||
import { ApprovalProcessTab } from './ApprovalProcessTab';
|
||||
|
||||
// ---- Shared helpers (kept in main for dialog usage) ----
|
||||
|
||||
function parseSteps(steps: any) {
|
||||
if (Array.isArray(steps)) return steps;
|
||||
if (typeof steps === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(steps);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解析模板的 form_schema,返回字段定义数组
|
||||
function parseFields(formSchema: any): any[] {
|
||||
if (!formSchema) return [];
|
||||
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
|
||||
if (!schema) return [];
|
||||
return Array.isArray(schema.fields) ? schema.fields : [];
|
||||
}
|
||||
|
||||
// 在详情弹窗中渲染表单数据为键值对展示
|
||||
function renderFormData(formDataStr: string, template?: any) {
|
||||
if (!formDataStr) return null;
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = typeof formDataStr === 'string' ? JSON.parse(formDataStr) : formDataStr;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
// 优先使用模板的 form_schema 标签,否则退化为 key
|
||||
const fields = parseFields(template?.form_schema);
|
||||
const labelMap = new Map<string, string>();
|
||||
fields.forEach((f) => labelMap.set(f.key, f.label));
|
||||
|
||||
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined && v !== '');
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="min-w-0">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-0.5">{labelMap.get(key) || key}</p>
|
||||
<p className="text-sm text-[var(--dg-text-primary)] break-words">{String(value)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const APPROVAL_TYPE_OPTIONS = [
|
||||
{ value: 'server_apply', label: '云服务器申请', icon: Server, color: 'var(--dg-cyan)' },
|
||||
{ value: 'app_deploy', label: '应用部署申请', icon: GitCommit, color: 'var(--dg-blue)' },
|
||||
];
|
||||
|
||||
const getApprovalTypeLabel = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '通用审批';
|
||||
|
||||
const getApprovalTypeColor = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
|
||||
|
||||
function getProgressInfo(instance: any) {
|
||||
const steps = parseSteps(instance.steps);
|
||||
const currentIndex = instance.current_step ?? 0;
|
||||
const total = steps.length;
|
||||
const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { steps, currentIndex, total, completed, percentage };
|
||||
}
|
||||
|
||||
// 渲染审批进度(用于详情弹窗)
|
||||
function renderStepProgress(instance: any) {
|
||||
const { steps, currentIndex } = getProgressInfo(instance);
|
||||
if (steps.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{steps.map((step: any, index: number) => {
|
||||
const isCompleted = step.status === 'approved' || step.status === 'completed';
|
||||
const isCurrent = index === currentIndex;
|
||||
const isRejected = step.status === 'rejected';
|
||||
return (
|
||||
<div key={step.id || index} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs whitespace-nowrap',
|
||||
isCompleted && 'bg-emerald-500/10 text-emerald-400',
|
||||
isCurrent && !isRejected && 'bg-amber-500/10 text-amber-400 border border-amber-500/30',
|
||||
isRejected && 'bg-red-500/10 text-red-400',
|
||||
!isCompleted && !isCurrent && !isRejected && 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{isCompleted && <CheckCircle2 className="h-3 w-3" />}
|
||||
{isRejected && <XCircle className="h-3 w-3" />}
|
||||
{isCurrent && !isRejected && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import {
|
||||
parseFields,
|
||||
renderFormData,
|
||||
renderStepProgress,
|
||||
getProgressInfo,
|
||||
getApprovalTypeLabel,
|
||||
getApprovalTypeColor,
|
||||
getInstanceType,
|
||||
APPROVAL_TYPE_OPTIONS,
|
||||
} from './helpers';
|
||||
|
||||
export function ApprovalFlow() {
|
||||
const confirm = useConfirm();
|
||||
@@ -427,14 +322,14 @@ export function ApprovalFlow() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(instance.type) }}>
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-[var(--dg-text-primary)]">{instance.title}</h3>
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(instance.type) }}>
|
||||
{getApprovalTypeLabel(instance.type)}
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
|
||||
{getApprovalTypeLabel(getInstanceType(instance))}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
@@ -494,8 +389,8 @@ export function ApprovalFlow() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-[var(--dg-text-primary)]">{selectedInstance.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(selectedInstance.type) }}>
|
||||
{getApprovalTypeLabel(selectedInstance.type)}
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(getInstanceType(selectedInstance)) }}>
|
||||
{getApprovalTypeLabel(getInstanceType(selectedInstance))}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
申请人:{selectedInstance.applicant_name || selectedInstance.applicant || '--'}
|
||||
|
||||
@@ -9,40 +9,16 @@ import {
|
||||
XCircle,
|
||||
Eye,
|
||||
Loader2,
|
||||
GitCommit,
|
||||
Server,
|
||||
KeyRound,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
|
||||
function parseSteps(steps: any) {
|
||||
if (Array.isArray(steps)) return steps;
|
||||
if (typeof steps === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(steps);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 审批类型字典
|
||||
const APPROVAL_TYPE_OPTIONS = [
|
||||
{ value: 'deploy', label: '发布审批', icon: GitCommit, color: 'var(--dg-blue)' },
|
||||
{ value: 'server', label: '服务器操作', icon: Server, color: 'var(--dg-cyan)' },
|
||||
{ value: 'permission', label: '权限申请', icon: KeyRound, color: 'var(--dg-purple)' },
|
||||
];
|
||||
|
||||
const getApprovalTypeLabel = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '通用审批';
|
||||
|
||||
const getApprovalTypeColor = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
|
||||
|
||||
const getApprovalTypeIcon = (value: string): React.ElementType =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.icon || FileText;
|
||||
import {
|
||||
getProgressInfo,
|
||||
renderStepProgress,
|
||||
getApprovalTypeLabel,
|
||||
getApprovalTypeColor,
|
||||
getApprovalTypeIcon,
|
||||
getInstanceType,
|
||||
} from './helpers';
|
||||
|
||||
// 状态标签页定义
|
||||
const STATUS_TABS = [
|
||||
@@ -92,54 +68,6 @@ function getStatusBadge(status: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// 获取进度信息
|
||||
function getProgressInfo(instance: any) {
|
||||
const steps = parseSteps(instance.steps);
|
||||
const currentIndex = instance.current_step ?? 0;
|
||||
const total = steps.length;
|
||||
const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { steps, currentIndex, total, completed, percentage };
|
||||
}
|
||||
|
||||
// 渲染审批进度
|
||||
function renderStepProgress(instance: any) {
|
||||
const { steps, currentIndex } = getProgressInfo(instance);
|
||||
if (steps.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{steps.map((step: any, index: number) => {
|
||||
const isCompleted = step.status === 'approved' || step.status === 'completed';
|
||||
const isCurrent = index === currentIndex;
|
||||
const isRejected = step.status === 'rejected';
|
||||
return (
|
||||
<div key={step.id || index} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs whitespace-nowrap',
|
||||
isCompleted && 'bg-emerald-500/10 text-emerald-400',
|
||||
isCurrent && !isRejected && 'bg-amber-500/10 text-amber-400 border border-amber-500/30',
|
||||
isRejected && 'bg-red-500/10 text-red-400',
|
||||
!isCompleted && !isCurrent && !isRejected && 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{isCompleted && <CheckCircle2 className="h-3 w-3" />}
|
||||
{isRejected && <XCircle className="h-3 w-3" />}
|
||||
{isCurrent && !isRejected && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApprovalProcessTabProps {
|
||||
processes: any[];
|
||||
filteredInstances: any[];
|
||||
@@ -218,14 +146,14 @@ export function ApprovalProcessTab({
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{filteredInstances.map((instance) => {
|
||||
const Icon = getApprovalTypeIcon(instance.type);
|
||||
const Icon = getApprovalTypeIcon(getInstanceType(instance));
|
||||
const { percentage } = getProgressInfo(instance);
|
||||
return (
|
||||
<Card key={instance.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] hover:border-[var(--dg-border)] transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(instance.type) }}>
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -235,8 +163,8 @@ export function ApprovalProcessTab({
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(instance.type) }}>
|
||||
{getApprovalTypeLabel(instance.type)}
|
||||
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
|
||||
{getApprovalTypeLabel(getInstanceType(instance))}
|
||||
</Badge>
|
||||
</span>
|
||||
<span>申请人:{instance.applicant_name || instance.applicant || '--'}</span>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// 资源申请模块共享辅助函数(从 ApprovalFlow.tsx 提取) // Logan.Li
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Server,
|
||||
GitCommit,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 解析审批步骤数组(兼容 JSON 字符串) // Logan.Li
|
||||
export function parseSteps(steps: any) {
|
||||
if (Array.isArray(steps)) return steps;
|
||||
if (typeof steps === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(steps);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解析模板的 form_schema,返回字段定义数组 // Logan.Li
|
||||
export function parseFields(formSchema: any): any[] {
|
||||
if (!formSchema) return [];
|
||||
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
|
||||
if (!schema) return [];
|
||||
return Array.isArray(schema.fields) ? schema.fields : [];
|
||||
}
|
||||
|
||||
// 在详情弹窗中渲染表单数据为键值对展示 // Logan.Li
|
||||
export function renderFormData(formDataStr: string, template?: any) {
|
||||
if (!formDataStr) return null;
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = typeof formDataStr === 'string' ? JSON.parse(formDataStr) : formDataStr;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const fields = parseFields(template?.form_schema);
|
||||
const labelMap = new Map<string, string>();
|
||||
fields.forEach((f) => labelMap.set(f.key, f.label));
|
||||
|
||||
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined && v !== '');
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="min-w-0">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-0.5">{labelMap.get(key) || key}</p>
|
||||
<p className="text-sm text-[var(--dg-text-primary)] break-words">{String(value)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 审批类型选项 // Logan.Li
|
||||
export const APPROVAL_TYPE_OPTIONS = [
|
||||
{ value: 'server_apply', label: '云服务器申请', icon: Server, color: 'var(--dg-cyan)' },
|
||||
{ value: 'app_deploy', label: '应用部署申请', icon: GitCommit, color: 'var(--dg-blue)' },
|
||||
];
|
||||
|
||||
export const getApprovalTypeLabel = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '通用审批';
|
||||
|
||||
export const getApprovalTypeColor = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
|
||||
|
||||
// 根据审批类型返回对应图标组件 // Logan.Li
|
||||
export const getApprovalTypeIcon = (value: string) =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.icon || FileText;
|
||||
|
||||
// 获取审批实例的类型(优先从关联模板取) // Logan.Li
|
||||
export const getInstanceType = (instance: any): string =>
|
||||
instance?.template?.type || instance?.type || '';
|
||||
|
||||
// 计算审批进度信息 // Logan.Li
|
||||
export function getProgressInfo(instance: any) {
|
||||
const steps = parseSteps(instance.steps);
|
||||
const currentIndex = instance.current_step ?? 0;
|
||||
const total = steps.length;
|
||||
const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { steps, currentIndex, total, completed, percentage };
|
||||
}
|
||||
|
||||
// 渲染审批进度(用于详情弹窗) // Logan.Li
|
||||
export function renderStepProgress(instance: any) {
|
||||
const { steps, currentIndex } = getProgressInfo(instance);
|
||||
if (steps.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{steps.map((step: any, index: number) => {
|
||||
const isCompleted = step.status === 'approved' || step.status === 'completed';
|
||||
const isCurrent = index === currentIndex;
|
||||
const isRejected = step.status === 'rejected';
|
||||
return (
|
||||
<div key={step.id || index} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs whitespace-nowrap',
|
||||
isCompleted && 'bg-emerald-500/10 text-emerald-400',
|
||||
isCurrent && !isRejected && 'bg-amber-500/10 text-amber-400 border border-amber-500/30',
|
||||
isRejected && 'bg-red-500/10 text-red-400',
|
||||
!isCompleted && !isCurrent && !isRejected && 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{isCompleted && <CheckCircle2 className="h-3 w-3" />}
|
||||
{isRejected && <XCircle className="h-3 w-3" />}
|
||||
{isCurrent && !isRejected && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Search,
|
||||
Grid3X3,
|
||||
List,
|
||||
MapPin,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Eye,
|
||||
Edit,
|
||||
} from 'lucide-react';
|
||||
import { serverApi } from '@/services/api';
|
||||
import type { Server as ServerType } from '@/types';
|
||||
|
||||
export function CabinetView() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [servers, setServers] = useState<ServerType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedServer, setSelectedServer] = useState<ServerType | null>(null);
|
||||
|
||||
// 拉取服务器列表 // Logan.Li
|
||||
const fetchServers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await serverApi.getServers({ page: 1, page_size: 200 });
|
||||
setServers((resp?.list ?? []) as ServerType[]);
|
||||
} catch {
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, []);
|
||||
|
||||
const filteredServers = servers.filter((srv) => {
|
||||
const matchesStatus = statusFilter === 'all' || srv.status === statusFilter;
|
||||
const matchesSearch =
|
||||
srv.hostname?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
srv.ip?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
running: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
|
||||
stopped: 'border-red-500/30 text-red-400 bg-red-500/10',
|
||||
maintenance: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
|
||||
offline: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
|
||||
};
|
||||
return colors[status] ?? colors.offline;
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
maintenance: '维护中',
|
||||
offline: '离线',
|
||||
};
|
||||
return map[status] ?? status;
|
||||
};
|
||||
|
||||
const getUsageColor = (usage: number) =>
|
||||
usage > 80 ? 'bg-red-400' : usage > 60 ? 'bg-amber-400' : 'bg-emerald-400';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">机房机柜视图</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">数据中心服务器资产概览</p>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加设备
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">服务器总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{servers.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">运行中</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{servers.filter((s) => s.status === 'running').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Grid3X3 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">维护中</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{servers.filter((s) => s.status === 'maintenance').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<Cpu className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">离线</p>
|
||||
<p className="text-2xl font-bold text-slate-400">
|
||||
{servers.filter((s) => s.status === 'offline' || s.status === 'stopped').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-500/10 text-slate-400">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters & View Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索主机名或 IP..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="running">运行中</SelectItem>
|
||||
<SelectItem value="stopped">已停止</SelectItem>
|
||||
<SelectItem value="maintenance">维护中</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4 mr-1" />
|
||||
网格视图
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4 mr-1" />
|
||||
列表视图
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredServers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
|
||||
<MapPin className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无服务器数据</p>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredServers.map((srv) => (
|
||||
<Card
|
||||
key={srv.id}
|
||||
className="bg-slate-900 border-slate-800 cursor-pointer hover:border-slate-700 transition-all"
|
||||
onClick={() => setSelectedServer(srv)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||
<Server className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{srv.hostname || '--'}</p>
|
||||
<p className="text-xs text-slate-500">{srv.ip || '--'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={getStatusColor(srv.status)}>
|
||||
{getStatusText(srv.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" /> CPU
|
||||
</span>
|
||||
<span>{Math.round(srv.cpuUsage ?? 0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${getUsageColor(srv.cpuUsage ?? 0)}`}
|
||||
style={{ width: `${srv.cpuUsage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<MemoryStick className="h-3 w-3" /> 内存
|
||||
</span>
|
||||
<span>{Math.round(srv.memoryUsage ?? 0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${getUsageColor(srv.memoryUsage ?? 0)}`}
|
||||
style={{ width: `${srv.memoryUsage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" /> 磁盘
|
||||
</span>
|
||||
<span>{Math.round(srv.diskUsage ?? 0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${getUsageColor(srv.diskUsage ?? 0)}`}
|
||||
style={{ width: `${srv.diskUsage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
{srv.cpu ?? 0} 核
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
{srv.memory ?? 0} GB
|
||||
</Badge>
|
||||
{srv.region && (
|
||||
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
|
||||
{srv.region}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">主机名</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">IP地址</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">系统</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">区域</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">配置</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-slate-400">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{filteredServers.map((srv) => (
|
||||
<tr key={srv.id} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-200">{srv.hostname || '--'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{srv.ip || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{srv.os || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{srv.region || '--'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">
|
||||
{srv.cpu ?? 0}核 / {srv.memory ?? 0}GB
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className={getStatusColor(srv.status)}>
|
||||
{getStatusText(srv.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => setSelectedServer(srv)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Server Detail Dialog */}
|
||||
<Dialog open={!!selectedServer} onOpenChange={() => setSelectedServer(null)}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-blue-400" />
|
||||
{selectedServer?.hostname}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
{selectedServer?.ip} · {selectedServer?.os}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">IP 地址</p>
|
||||
<p className="text-sm text-slate-200">{selectedServer?.ip || '--'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">操作系统</p>
|
||||
<p className="text-sm text-slate-200">{selectedServer?.os || '--'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">配置</p>
|
||||
<p className="text-sm text-slate-200">
|
||||
{selectedServer?.cpu ?? 0} 核 / {selectedServer?.memory ?? 0} GB
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">状态</p>
|
||||
<Badge variant="outline" className={selectedServer ? getStatusColor(selectedServer.status) : ''}>
|
||||
{selectedServer ? getStatusText(selectedServer.status) : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Layers,
|
||||
GripVertical,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { resourceModelApi } from '@/services/api';
|
||||
|
||||
interface AttrDef {
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
unique?: boolean;
|
||||
}
|
||||
|
||||
const attrTypes = [
|
||||
{ value: 'string', label: '字符串' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'bool', label: '布尔' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'enum', label: '枚举' },
|
||||
];
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
normal: '普通模型',
|
||||
parent: '父模型',
|
||||
};
|
||||
|
||||
export function ResourceModelManager() {
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '', code: '', description: '', type: 'normal', icon: '',
|
||||
});
|
||||
const [attrs, setAttrs] = useState<AttrDef[]>([]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await resourceModelApi.list();
|
||||
const items = (res as any)?.list || [];
|
||||
setModels(items);
|
||||
} catch {
|
||||
setModels([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const filtered = models.filter(m =>
|
||||
m.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.code?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const parseAttrs = (attrsStr: string): AttrDef[] => {
|
||||
if (!attrsStr) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(attrsStr);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormData({ name: '', code: '', description: '', type: 'normal', icon: '' });
|
||||
setAttrs([]);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: any) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
name: item.name || '', code: item.code || '', description: item.description || '',
|
||||
type: item.type || 'normal', icon: item.icon || '',
|
||||
});
|
||||
setAttrs(parseAttrs(item.attributes));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const addAttr = () => {
|
||||
setAttrs([...attrs, { name: '', code: '', type: 'string', required: false }]);
|
||||
};
|
||||
|
||||
const updateAttr = (index: number, field: keyof AttrDef, value: any) => {
|
||||
const next = [...attrs];
|
||||
(next[index] as any)[field] = value;
|
||||
setAttrs(next);
|
||||
};
|
||||
|
||||
const removeAttr = (index: number) => {
|
||||
setAttrs(attrs.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.code.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = { ...formData, attributes: attrs.filter(a => a.name.trim() && a.code.trim()) };
|
||||
if (editingItem) {
|
||||
await resourceModelApi.update(editingItem.id, payload);
|
||||
} else {
|
||||
await resourceModelApi.create(payload);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
loadData();
|
||||
} catch {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const handleDelete = (id: number) => setDeleteId(id);
|
||||
const confirmDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
await resourceModelApi.delete(deleteId);
|
||||
if (selectedModel?.id === deleteId) setSelectedModel(null);
|
||||
setDeleteId(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const getAttrCount = (item: any) => parseAttrs(item.attributes).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索模型名称或编码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-20 text-slate-500">
|
||||
<Layers className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>暂无资源模型</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filtered.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors group cursor-pointer"
|
||||
onClick={() => setSelectedModel(selectedModel?.id === m.id ? null : m)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
|
||||
<Layers className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-100">{m.name}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">{m.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={(e) => { e.stopPropagation(); openEdit(m); }} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDelete(m.id); }} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{m.description && (
|
||||
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{m.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-slate-400">
|
||||
{typeLabels[m.type] || m.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-slate-400">
|
||||
{getAttrCount(m)} 个属性
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Expanded attributes */}
|
||||
{selectedModel?.id === m.id && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-700/50 space-y-1.5">
|
||||
{parseAttrs(m.attributes).map((attr, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-300">{attr.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-mono">{attr.code}</span>
|
||||
<span className="text-slate-600">{attr.type}</span>
|
||||
{attr.required && <span className="text-red-400">*</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{getAttrCount(m) === 0 && (
|
||||
<p className="text-xs text-slate-600">暂无属性定义</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? '编辑资源模型' : '添加资源模型'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>模型名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:服务器"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:server"
|
||||
className="bg-slate-800/50 border-slate-700 font-mono"
|
||||
disabled={!!editingItem}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>模型类型</Label>
|
||||
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">普通模型</SelectItem>
|
||||
<SelectItem value="parent">父模型</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="模型描述..."
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attributes editor */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>属性定义</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addAttr} className="h-7 text-xs gap-1">
|
||||
<Plus className="h-3 w-3" /> 添加属性
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{attrs.map((attr, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-slate-600 shrink-0" />
|
||||
<Input
|
||||
placeholder="属性名"
|
||||
value={attr.name}
|
||||
onChange={(e) => updateAttr(i, 'name', e.target.value)}
|
||||
className="h-8 bg-slate-800/50 border-slate-700 flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="编码"
|
||||
value={attr.code}
|
||||
onChange={(e) => updateAttr(i, 'code', e.target.value)}
|
||||
className="h-8 bg-slate-800/50 border-slate-700 flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Select value={attr.type} onValueChange={(v) => updateAttr(i, 'type', v)}>
|
||||
<SelectTrigger className="h-8 w-28 bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{attrTypes.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 shrink-0 ${attr.required ? 'text-red-400' : 'text-slate-500'}`}
|
||||
onClick={() => updateAttr(i, 'required', !attr.required)}
|
||||
title={attr.required ? '必填' : '选填'}
|
||||
>
|
||||
*
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-slate-500 hover:text-red-400"
|
||||
onClick={() => removeAttr(i)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{attrs.length === 0 && (
|
||||
<p className="text-sm text-slate-600 py-2">暂无属性,点击"添加属性"开始定义</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !formData.name.trim() || !formData.code.trim()}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
{editingItem ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={(v) => !v && setDeleteId(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-slate-400">确定删除此资源模型?此操作不可撤销。</p>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user