重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-28 18:16:45 +08:00
parent 36b04d50b8
commit c3325c379d
3 changed files with 259 additions and 114 deletions

View File

@ -48,8 +48,8 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
return ( return (
<div className="flex flex-col p-3 rounded-lg border hover:bg-accent/50 transition-colors"> <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" /> <Package className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{app.applicationName ? ( {app.applicationName ? (
@ -64,11 +64,16 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
) : ( ) : (
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20" />
)} )}
{app.applicationDesc && ( {/* 应用描述 - 固定单行显示,超出省略 */}
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-2"> <div className="mt-1 h-[14px]">
{app.applicationDesc} {app.applicationDesc ? (
</p> <p className="text-[10px] text-muted-foreground truncate">
)} {app.applicationDesc}
</p>
) : (
<Skeleton className="h-3 w-full" />
)}
</div>
</div> </div>
</div> </div>
@ -208,12 +213,12 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
const record = app.recentDeployRecords?.[index]; const record = app.recentDeployRecords?.[index];
if (record) { if (record) {
// 显示实际记录 // 显示实际记录 - 固定高度确保一致性
const { icon: StatusIcon, color } = getStatusIcon(record.status); const { icon: StatusIcon, color } = getStatusIcon(record.status);
return ( 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"> <div className="flex items-center gap-1">
<StatusIcon className={cn("h-3.5 w-3.5 shrink-0", color, record.status === 'RUNNING' && "animate-spin")} /> <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> <span className={cn("text-[10px] font-semibold", color)}>{getStatusText(record.status)}</span>
@ -237,52 +242,64 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
</div> </div>
{/* 第二行:时间信息(一行显示) */} {/* 第二行:时间信息(一行显示) */}
{(record.startTime || record.endTime || record.duration) && ( <div className="flex items-center gap-1.5 text-[10px] text-muted-foreground flex-nowrap overflow-hidden min-h-[16px]">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground flex-nowrap overflow-hidden"> {(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"> <Clock className="h-2.5 w-2.5 shrink-0" />
{record.startTime && ( <div className="flex items-center gap-1 flex-nowrap min-w-0">
<span className="whitespace-nowrap">{formatTime(record.startTime)}</span> {record.startTime && (
)} <span className="whitespace-nowrap">{formatTime(record.startTime)}</span>
{record.endTime && ( )}
<> {record.endTime && (
<span className="px-0.5 shrink-0"></span> <>
<span className="whitespace-nowrap">{formatTime(record.endTime)}</span> <span className="px-0.5 shrink-0"></span>
</> <span className="whitespace-nowrap">{formatTime(record.endTime)}</span>
)} </>
{record.duration && ( )}
<> {record.duration && (
<span className="px-0.5 text-muted-foreground/50 shrink-0"></span> <>
<span className="font-medium whitespace-nowrap">{formatDuration(record.duration)}</span> <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>
)} </>
) : (
<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 min-h-[18px]">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground pt-0.5 border-t border-muted/30"> {record.deployRemark ? (
<FileText className="h-2.5 w-2.5 shrink-0" /> <>
<span className="truncate">{record.deployRemark}</span> <FileText className="h-2.5 w-2.5 shrink-0" />
</div> <span className="truncate">{record.deployRemark}</span>
)} </>
) : (
<span className="text-muted-foreground/50">-</span>
)}
</div>
</div> </div>
); );
} else { } else {
// 显示骨架屏占位 - 3条线模拟实际记录的3行 // 显示骨架屏占位 - 固定高度与实际记录一致
return ( 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-3.5 w-16" />
<Skeleton className="h-4 w-12" /> <Skeleton className="h-4 w-12" />
<Skeleton className="h-3.5 w-12 ml-auto" /> <Skeleton className="h-3.5 w-12 ml-auto" />
</div> </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> </div>
); );
} }

View File

@ -1,7 +1,9 @@
import React from 'react'; import React, { useState, useMemo } from 'react';
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs"; 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 { ApplicationCard } from './ApplicationCard';
import type { DeployTeam, DeployEnvironment, ApplicationConfig } from '../types'; import type { DeployTeam, DeployEnvironment, ApplicationConfig } from '../types';
@ -24,8 +26,25 @@ export const EnvironmentTabs: React.FC<EnvironmentTabsProps> = React.memo(({
onEnvChange, onEnvChange,
onDeploy onDeploy
}) => { }) => {
const [searchValue, setSearchValue] = useState('');
const currentEnv = team.environments.find(e => e.environmentId === currentEnvId); 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 ( return (
<Tabs value={currentEnvId?.toString()} onValueChange={(value) => onEnvChange(Number(value))}> <Tabs value={currentEnvId?.toString()} onValueChange={(value) => onEnvChange(Number(value))}>
{/* 现代化 TAB 头部 */} {/* 现代化 TAB 头部 */}
@ -47,48 +66,74 @@ export const EnvironmentTabs: React.FC<EnvironmentTabsProps> = React.memo(({
</div> </div>
</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"> <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) => ( {team.environments.map((env) => {
<button const filteredEnv = filteredEnvironments.find(e => e.environmentId === env.environmentId);
key={env.environmentId} const displayCount = searchValue.trim() ? (filteredEnv?.applications.length || 0) : env.applications.length;
onClick={() => onEnvChange(env.environmentId)}
className={` return (
inline-flex items-center justify-center whitespace-nowrap rounded-md px-4 py-2 <button
text-sm font-medium ring-offset-background transition-all key={env.environmentId}
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 onClick={() => onEnvChange(env.environmentId)}
disabled:pointer-events-none disabled:opacity-50 className={`
${currentEnvId === env.environmentId inline-flex items-center justify-center whitespace-nowrap rounded-md px-4 py-2
? 'bg-background text-foreground shadow-sm' text-sm font-medium ring-offset-background transition-all
: 'hover:bg-background/50 hover:text-foreground' focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
} disabled:pointer-events-none disabled:opacity-50
`}
>
<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 ${currentEnvId === env.environmentId
? 'bg-primary/10 text-primary' ? 'bg-background text-foreground shadow-sm'
: 'bg-muted-foreground/10 text-muted-foreground' : 'hover:bg-background/50 hover:text-foreground'
} }
`}> `}
{env.applications.length} >
</span> <div className="flex items-center gap-2">
</div> {env.requiresApproval ? (
</button> <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> </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"> <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]"> <div className="p-6 min-h-[400px]">
{env.applications.length === 0 ? ( {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"> <div className="rounded-full bg-muted p-6 mb-4">
<Package className="h-12 w-12 text-muted-foreground" /> <Package className="h-12 w-12 text-muted-foreground" />
</div> </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"> <p className="text-sm text-muted-foreground">
{env.environmentName} {searchValue.trim()
? `没有找到包含"${searchValue}"的应用`
: `环境「${env.environmentName}」暂未配置任何应用`
}
</p> </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> </CardContent>
</Card> </Card>
) : ( ) : (

View File

@ -101,6 +101,8 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
const [loadingRepoProjects, setLoadingRepoProjects] = useState(false); const [loadingRepoProjects, setLoadingRepoProjects] = useState(false);
// 搜索和弹窗状态 // 搜索和弹窗状态
const [appSearchValue, setAppSearchValue] = useState('');
const [appPopoverOpen, setAppPopoverOpen] = useState(false);
const [branchSearchValue, setBranchSearchValue] = useState(''); const [branchSearchValue, setBranchSearchValue] = useState('');
const [branchPopoverOpen, setBranchPopoverOpen] = useState(false); const [branchPopoverOpen, setBranchPopoverOpen] = useState(false);
const [projectSearchValue, setProjectSearchValue] = useState(''); const [projectSearchValue, setProjectSearchValue] = useState('');
@ -362,39 +364,104 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
<Label> <Label>
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Select {mode === 'edit' ? (
value={formData.appId?.toString() || ''} // 编辑模式:显示只读输入框
onValueChange={(value) => handleAppChange(Number(value))} <>
disabled={mode === 'edit'} // 编辑时不可修改应用 <Input
> value={(() => {
<SelectTrigger> const app = applications.find(a => a.id === formData.appId);
<SelectValue placeholder="选择应用" /> if (!app) return '';
</SelectTrigger> const category = app.applicationCategory?.name || '未分类';
<SelectContent> return `${category}/${app.appCode}${app.appName}`;
{(() => { })()}
// 在新建模式下,过滤掉已添加的应用 disabled
const availableApps = mode === 'create' />
? applications.filter(app => !existingApplicationIds.includes(app.id)) <p className="text-xs text-muted-foreground">
: applications;
</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 ? ( if (filteredApps.length === 0) {
<div className="p-4 text-center text-sm text-muted-foreground"> return (
{mode === 'create' ? '暂无可添加的应用' : '暂无应用'} <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> </div>
) : ( </ScrollArea>
availableApps.map((app) => ( </PopoverContent>
<SelectItem key={app.id} value={app.id.toString()}> </Popover>
{app.appName}{app.appCode}
</SelectItem>
))
);
})()}
</SelectContent>
</Select>
{mode === 'edit' && (
<p className="text-xs text-muted-foreground">
</p>
)} )}
</div> </div>