重构消息通知弹窗
This commit is contained in:
parent
36b04d50b8
commit
c3325c379d
@ -48,8 +48,8 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-3 rounded-lg border hover:bg-accent/50 transition-colors">
|
||||
{/* 应用基本信息 */}
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
{/* 应用基本信息 - 固定高度确保一致性 */}
|
||||
<div className="flex items-start gap-2 mb-3 min-h-[54px]">
|
||||
<Package className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{app.applicationName ? (
|
||||
@ -64,11 +64,16 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
) : (
|
||||
<Skeleton className="h-3 w-20" />
|
||||
)}
|
||||
{app.applicationDesc && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-2">
|
||||
{app.applicationDesc}
|
||||
</p>
|
||||
)}
|
||||
{/* 应用描述 - 固定单行显示,超出省略 */}
|
||||
<div className="mt-1 h-[14px]">
|
||||
{app.applicationDesc ? (
|
||||
<p className="text-[10px] text-muted-foreground truncate">
|
||||
{app.applicationDesc}
|
||||
</p>
|
||||
) : (
|
||||
<Skeleton className="h-3 w-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -208,12 +213,12 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
const record = app.recentDeployRecords?.[index];
|
||||
|
||||
if (record) {
|
||||
// 显示实际记录
|
||||
// 显示实际记录 - 固定高度确保一致性
|
||||
const { icon: StatusIcon, color } = getStatusIcon(record.status);
|
||||
return (
|
||||
<div key={record.id} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 space-y-1 h-[60px]">
|
||||
<div key={record.id} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 h-[68px] flex flex-col justify-between overflow-hidden">
|
||||
{/* 第一行:状态、编号、部署人 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap min-h-[18px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusIcon className={cn("h-3.5 w-3.5 shrink-0", color, record.status === 'RUNNING' && "animate-spin")} />
|
||||
<span className={cn("text-[10px] font-semibold", color)}>{getStatusText(record.status)}</span>
|
||||
@ -237,52 +242,64 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 第二行:时间信息(一行显示) */}
|
||||
{(record.startTime || record.endTime || record.duration) && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground flex-nowrap overflow-hidden">
|
||||
<Clock className="h-2.5 w-2.5 shrink-0" />
|
||||
<div className="flex items-center gap-1 flex-nowrap min-w-0">
|
||||
{record.startTime && (
|
||||
<span className="whitespace-nowrap">{formatTime(record.startTime)}</span>
|
||||
)}
|
||||
{record.endTime && (
|
||||
<>
|
||||
<span className="px-0.5 shrink-0">→</span>
|
||||
<span className="whitespace-nowrap">{formatTime(record.endTime)}</span>
|
||||
</>
|
||||
)}
|
||||
{record.duration && (
|
||||
<>
|
||||
<span className="px-0.5 text-muted-foreground/50 shrink-0">•</span>
|
||||
<span className="font-medium whitespace-nowrap">{formatDuration(record.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground flex-nowrap overflow-hidden min-h-[16px]">
|
||||
{(record.startTime || record.endTime || record.duration) ? (
|
||||
<>
|
||||
<Clock className="h-2.5 w-2.5 shrink-0" />
|
||||
<div className="flex items-center gap-1 flex-nowrap min-w-0">
|
||||
{record.startTime && (
|
||||
<span className="whitespace-nowrap">{formatTime(record.startTime)}</span>
|
||||
)}
|
||||
{record.endTime && (
|
||||
<>
|
||||
<span className="px-0.5 shrink-0">→</span>
|
||||
<span className="whitespace-nowrap">{formatTime(record.endTime)}</span>
|
||||
</>
|
||||
)}
|
||||
{record.duration && (
|
||||
<>
|
||||
<span className="px-0.5 text-muted-foreground/50 shrink-0">•</span>
|
||||
<span className="font-medium whitespace-nowrap">{formatDuration(record.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 第三行:备注 */}
|
||||
{record.deployRemark && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground pt-0.5 border-t border-muted/30">
|
||||
<FileText className="h-2.5 w-2.5 shrink-0" />
|
||||
<span className="truncate">{record.deployRemark}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 第三行:备注(固定高度,超出截断) */}
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground pt-0.5 border-t border-muted/30 min-h-[18px]">
|
||||
{record.deployRemark ? (
|
||||
<>
|
||||
<FileText className="h-2.5 w-2.5 shrink-0" />
|
||||
<span className="truncate">{record.deployRemark}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 显示骨架屏占位 - 3条线模拟实际记录的3行
|
||||
// 显示骨架屏占位 - 固定高度与实际记录一致
|
||||
return (
|
||||
<div key={`skeleton-${index}`} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 space-y-1 h-[60px]">
|
||||
<div key={`skeleton-${index}`} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 h-[68px] flex flex-col justify-between">
|
||||
{/* 第一行:状态、编号、部署人 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-h-[18px]">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-3.5 w-12 ml-auto" />
|
||||
</div>
|
||||
{/* 第二行:时间信息 */}
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<div className="min-h-[16px]">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
{/* 第三行:备注 */}
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<div className="min-h-[18px] pt-0.5 border-t border-muted/30">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Package, Shield, CheckCircle2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Package, Shield, CheckCircle2, Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ApplicationCard } from './ApplicationCard';
|
||||
import type { DeployTeam, DeployEnvironment, ApplicationConfig } from '../types';
|
||||
|
||||
@ -24,8 +26,25 @@ export const EnvironmentTabs: React.FC<EnvironmentTabsProps> = React.memo(({
|
||||
onEnvChange,
|
||||
onDeploy
|
||||
}) => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const currentEnv = team.environments.find(e => e.environmentId === currentEnvId);
|
||||
|
||||
// 过滤应用列表
|
||||
const filteredEnvironments = useMemo(() => {
|
||||
if (!searchValue.trim()) {
|
||||
return team.environments;
|
||||
}
|
||||
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
return team.environments.map(env => ({
|
||||
...env,
|
||||
applications: env.applications.filter(app =>
|
||||
app.applicationCode?.toLowerCase().includes(searchLower) ||
|
||||
app.applicationName?.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}));
|
||||
}, [team.environments, searchValue]);
|
||||
|
||||
return (
|
||||
<Tabs value={currentEnvId?.toString()} onValueChange={(value) => onEnvChange(Number(value))}>
|
||||
{/* 现代化 TAB 头部 */}
|
||||
@ -47,48 +66,74 @@ export const EnvironmentTabs: React.FC<EnvironmentTabsProps> = React.memo(({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-3">
|
||||
<div className="px-6 pb-3 space-y-3">
|
||||
<div className="inline-flex h-11 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground gap-1">
|
||||
{team.environments.map((env) => (
|
||||
<button
|
||||
key={env.environmentId}
|
||||
onClick={() => onEnvChange(env.environmentId)}
|
||||
className={`
|
||||
inline-flex items-center justify-center whitespace-nowrap rounded-md px-4 py-2
|
||||
text-sm font-medium ring-offset-background transition-all
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
${currentEnvId === env.environmentId
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-background/50 hover:text-foreground'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{env.requiresApproval ? (
|
||||
<Shield className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-amber-600' : 'text-amber-500/60'}`} />
|
||||
) : (
|
||||
<CheckCircle2 className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-green-600' : 'text-green-500/60'}`} />
|
||||
)}
|
||||
<span>{env.environmentName}</span>
|
||||
<span className={`
|
||||
ml-1 rounded-full px-2 py-0.5 text-xs font-semibold
|
||||
{team.environments.map((env) => {
|
||||
const filteredEnv = filteredEnvironments.find(e => e.environmentId === env.environmentId);
|
||||
const displayCount = searchValue.trim() ? (filteredEnv?.applications.length || 0) : env.applications.length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={env.environmentId}
|
||||
onClick={() => onEnvChange(env.environmentId)}
|
||||
className={`
|
||||
inline-flex items-center justify-center whitespace-nowrap rounded-md px-4 py-2
|
||||
text-sm font-medium ring-offset-background transition-all
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
${currentEnvId === env.environmentId
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-background/50 hover:text-foreground'
|
||||
}
|
||||
`}>
|
||||
{env.applications.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{env.requiresApproval ? (
|
||||
<Shield className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-amber-600' : 'text-amber-500/60'}`} />
|
||||
) : (
|
||||
<CheckCircle2 className={`h-3.5 w-3.5 ${currentEnvId === env.environmentId ? 'text-green-600' : 'text-green-500/60'}`} />
|
||||
)}
|
||||
<span>{env.environmentName}</span>
|
||||
<span className={`
|
||||
ml-1 rounded-full px-2 py-0.5 text-xs font-semibold
|
||||
${currentEnvId === env.environmentId
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
}
|
||||
`}>
|
||||
{displayCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索应用名称或编码..."
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="pl-9 pr-9 h-9"
|
||||
/>
|
||||
{searchValue && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
onClick={() => setSearchValue('')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{team.environments.map((env) => (
|
||||
{filteredEnvironments.map((env) => (
|
||||
<TabsContent key={env.environmentId} value={env.environmentId.toString()} className="mt-0 focus-visible:ring-0 focus-visible:ring-offset-0">
|
||||
<div className="p-6 min-h-[400px]">
|
||||
{env.applications.length === 0 ? (
|
||||
@ -97,10 +142,26 @@ export const EnvironmentTabs: React.FC<EnvironmentTabsProps> = React.memo(({
|
||||
<div className="rounded-full bg-muted p-6 mb-4">
|
||||
<Package className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">暂无可部署应用</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{searchValue.trim() ? '未找到匹配的应用' : '暂无可部署应用'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
环境「{env.environmentName}」暂未配置任何应用
|
||||
{searchValue.trim()
|
||||
? `没有找到包含"${searchValue}"的应用`
|
||||
: `环境「${env.environmentName}」暂未配置任何应用`
|
||||
}
|
||||
</p>
|
||||
{searchValue.trim() && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => setSearchValue('')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
清除搜索
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@ -101,6 +101,8 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
|
||||
const [loadingRepoProjects, setLoadingRepoProjects] = useState(false);
|
||||
|
||||
// 搜索和弹窗状态
|
||||
const [appSearchValue, setAppSearchValue] = useState('');
|
||||
const [appPopoverOpen, setAppPopoverOpen] = useState(false);
|
||||
const [branchSearchValue, setBranchSearchValue] = useState('');
|
||||
const [branchPopoverOpen, setBranchPopoverOpen] = useState(false);
|
||||
const [projectSearchValue, setProjectSearchValue] = useState('');
|
||||
@ -362,39 +364,104 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
|
||||
<Label>
|
||||
应用 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.appId?.toString() || ''}
|
||||
onValueChange={(value) => handleAppChange(Number(value))}
|
||||
disabled={mode === 'edit'} // 编辑时不可修改应用
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择应用" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(() => {
|
||||
// 在新建模式下,过滤掉已添加的应用
|
||||
const availableApps = mode === 'create'
|
||||
? applications.filter(app => !existingApplicationIds.includes(app.id))
|
||||
: applications;
|
||||
{mode === 'edit' ? (
|
||||
// 编辑模式:显示只读输入框
|
||||
<>
|
||||
<Input
|
||||
value={(() => {
|
||||
const app = applications.find(a => a.id === formData.appId);
|
||||
if (!app) return '';
|
||||
const category = app.applicationCategory?.name || '未分类';
|
||||
return `${category}/${app.appCode}(${app.appName})`;
|
||||
})()}
|
||||
disabled
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
编辑时不可修改应用,如需更换请删除后重新添加
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// 新建模式:使用 Popover + Command 组件
|
||||
<Popover open={appPopoverOpen} onOpenChange={setAppPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!formData.appId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{formData.appId ? (() => {
|
||||
const app = applications.find(a => a.id === formData.appId);
|
||||
if (!app) return '选择应用';
|
||||
const category = app.applicationCategory?.name || '未分类';
|
||||
return `${category}/${app.appCode}(${app.appName})`;
|
||||
})() : '选择应用'}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
placeholder="搜索应用..."
|
||||
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
value={appSearchValue}
|
||||
onChange={(e) => setAppSearchValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[200px]">
|
||||
<div className="p-1">
|
||||
{(() => {
|
||||
// 过滤掉已添加的应用
|
||||
const availableApps = applications.filter(app => !existingApplicationIds.includes(app.id));
|
||||
// 搜索过滤
|
||||
const filteredApps = availableApps.filter(app =>
|
||||
app.appName.toLowerCase().includes(appSearchValue.toLowerCase()) ||
|
||||
app.appCode.toLowerCase().includes(appSearchValue.toLowerCase()) ||
|
||||
(app.applicationCategory?.name || '').toLowerCase().includes(appSearchValue.toLowerCase())
|
||||
);
|
||||
|
||||
return availableApps.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{mode === 'create' ? '暂无可添加的应用' : '暂无应用'}
|
||||
if (filteredApps.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{availableApps.length === 0 ? '暂无可添加的应用' : '未找到应用'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return filteredApps.map((app) => {
|
||||
const category = app.applicationCategory?.name || '未分类';
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||
app.id === formData.appId && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
handleAppChange(app.id);
|
||||
setAppSearchValue('');
|
||||
setAppPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 truncate">
|
||||
<span className="text-muted-foreground">{category} / </span>
|
||||
<span className="font-medium">{app.appCode}</span>
|
||||
<span className="text-muted-foreground">({app.appName})</span>
|
||||
</div>
|
||||
{app.id === formData.appId && (
|
||||
<Check className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
availableApps.map((app) => (
|
||||
<SelectItem key={app.id} value={app.id.toString()}>
|
||||
{app.appName}({app.appCode})
|
||||
</SelectItem>
|
||||
))
|
||||
);
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mode === 'edit' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
编辑时不可修改应用,如需更换请删除后重新添加
|
||||
</p>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user