1.20升级
This commit is contained in:
parent
a99476794c
commit
a25ee2c9a6
@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ExternalLink, Edit, Trash2, Eye, EyeOff, Copy } from 'lucide-react';
|
||||
import DynamicIcon from '@/components/DynamicIcon';
|
||||
import type { TeamBookmark } from '../types';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
interface BookmarkCardProps {
|
||||
bookmark: TeamBookmark;
|
||||
categoryName?: string;
|
||||
isOwner: boolean;
|
||||
onEdit?: (bookmark: TeamBookmark) => void;
|
||||
onDelete?: (bookmark: TeamBookmark) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 书签卡片组件
|
||||
*/
|
||||
export const BookmarkCard: React.FC<BookmarkCardProps> = ({
|
||||
bookmark,
|
||||
categoryName,
|
||||
isOwner,
|
||||
onEdit,
|
||||
onDelete
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// 打开URL
|
||||
const handleOpenUrl = () => {
|
||||
window.open(bookmark.url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
// 复制账号或密码
|
||||
const handleCopy = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast({
|
||||
title: '复制成功',
|
||||
description: `已复制${label}到剪贴板`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="group hover:border-primary/40 transition-all flex flex-col h-full">
|
||||
<CardHeader className="pb-3 flex-shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{bookmark.icon && (
|
||||
<DynamicIcon name={bookmark.icon} className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
)}
|
||||
<h3 className="font-medium text-base truncate">{bookmark.title}</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="flex-shrink-0 text-xs">
|
||||
{categoryName || '未分类'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 flex-1 flex flex-col">
|
||||
{/* 描述 */}
|
||||
{bookmark.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{bookmark.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* URL */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline truncate flex-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpenUrl();
|
||||
}}
|
||||
>
|
||||
{bookmark.url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 中间内容区域 - 自动填充空间 */}
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* 账号密码 - 仅在 needAuth 为 true 时显示 */}
|
||||
{bookmark.needAuth && (bookmark.username || bookmark.password) && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
{bookmark.username && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground w-12">账号:</span>
|
||||
<code className="flex-1 truncate text-xs bg-muted px-2 py-1 rounded">
|
||||
{bookmark.username}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleCopy(bookmark.username!, '账号')}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.password && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground w-12">密码:</span>
|
||||
<code className="flex-1 truncate text-xs bg-muted px-2 py-1 rounded">
|
||||
{showPassword ? bookmark.password : '••••••••'}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleCopy(bookmark.password!, '密码')}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{bookmark.tags && bookmark.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{bookmark.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 - 固定在底部 */}
|
||||
<div className="flex items-center gap-2 pt-3 mt-auto border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenUrl}
|
||||
className="flex-1"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
|
||||
跳转
|
||||
</Button>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit?.(bookmark)}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete?.(bookmark)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,331 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogBody } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import DynamicIcon from '@/components/DynamicIcon';
|
||||
import LucideIconSelect from '@/components/LucideIconSelect';
|
||||
import type { TeamBookmark, TeamBookmarkCategory, BookmarkFormData } from '../types';
|
||||
|
||||
interface BookmarkFormProps {
|
||||
open: boolean;
|
||||
bookmark?: TeamBookmark | null;
|
||||
categories: TeamBookmarkCategory[];
|
||||
onSubmit: (data: BookmarkFormData) => Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 书签表单组件
|
||||
*/
|
||||
export const BookmarkForm: React.FC<BookmarkFormProps> = ({
|
||||
open,
|
||||
bookmark,
|
||||
categories,
|
||||
onSubmit,
|
||||
onOpenChange
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [iconSelectOpen, setIconSelectOpen] = useState(false);
|
||||
const [formData, setFormData] = useState<BookmarkFormData>({
|
||||
title: '',
|
||||
url: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
needAuth: false,
|
||||
username: '',
|
||||
password: '',
|
||||
categoryId: undefined,
|
||||
tags: [],
|
||||
sort: 0,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (bookmark) {
|
||||
setFormData({
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
icon: bookmark.icon || '',
|
||||
description: bookmark.description || '',
|
||||
needAuth: bookmark.needAuth || false,
|
||||
username: bookmark.username || '',
|
||||
password: bookmark.password || '',
|
||||
categoryId: bookmark.categoryId,
|
||||
tags: bookmark.tags || [],
|
||||
sort: bookmark.sort,
|
||||
enabled: bookmark.enabled
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
title: '',
|
||||
url: '',
|
||||
icon: '',
|
||||
description: '',
|
||||
needAuth: false,
|
||||
username: '',
|
||||
password: '',
|
||||
categoryId: undefined,
|
||||
tags: [],
|
||||
sort: 0,
|
||||
enabled: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [open, bookmark]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!formData.url.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
...formData,
|
||||
title: formData.title.trim(),
|
||||
url: formData.url.trim(),
|
||||
description: formData.description?.trim() || undefined,
|
||||
username: formData.username?.trim() || undefined,
|
||||
password: formData.password?.trim() || undefined,
|
||||
icon: formData.icon || undefined,
|
||||
tags: formData.tags?.filter(t => t.trim()) || undefined
|
||||
});
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagsChange = (value: string) => {
|
||||
const tags = value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
setFormData({ ...formData, tags });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{bookmark ? '编辑书签' : '新增书签'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<DialogBody className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 标题 */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="title">
|
||||
标题 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="如:Jenkins构建平台"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="url">
|
||||
URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分类 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">分类</Label>
|
||||
<Select
|
||||
value={formData.categoryId?.toString() || 'none'}
|
||||
onValueChange={(value) => setFormData({
|
||||
...formData,
|
||||
categoryId: value === 'none' ? undefined : Number(value)
|
||||
})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">未分类</SelectItem>
|
||||
{categories.filter(c => c.enabled).map((category) => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.icon && (
|
||||
<span className="inline-flex mr-2">
|
||||
<DynamicIcon name={category.icon} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 图标 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">图标</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setIconSelectOpen(true)}
|
||||
>
|
||||
{formData.icon ? (
|
||||
<>
|
||||
<DynamicIcon name={formData.icon} className="h-4 w-4 mr-2" />
|
||||
{formData.icon}
|
||||
</>
|
||||
) : (
|
||||
'选择图标'
|
||||
)}
|
||||
</Button>
|
||||
{formData.icon && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFormData({ ...formData, icon: '' })}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="简短描述这个书签..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 是否需要认证 */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needAuth"
|
||||
checked={formData.needAuth}
|
||||
onCheckedChange={(checked) => setFormData({
|
||||
...formData,
|
||||
needAuth: checked as boolean,
|
||||
// 取消选中时清空账号密码
|
||||
username: checked ? formData.username : '',
|
||||
password: checked ? formData.password : ''
|
||||
})}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="needAuth"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
需要账号密码认证
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号密码 - 仅在 needAuth 为 true 时显示 */}
|
||||
{formData.needAuth && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">账号</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="登录账号"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="text"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="登录密码"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="tags">标签</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={formData.tags?.join(', ') || ''}
|
||||
onChange={(e) => handleTagsChange(e.target.value)}
|
||||
placeholder="多个标签用逗号分隔,如:开发,测试"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort">排序</Label>
|
||||
<Input
|
||||
id="sort"
|
||||
type="number"
|
||||
value={formData.sort}
|
||||
onChange={(e) => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{bookmark ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 图标选择弹窗 */}
|
||||
<LucideIconSelect
|
||||
open={iconSelectOpen}
|
||||
onOpenChange={setIconSelectOpen}
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, Edit, Trash2, Loader2, FolderOpen, Palette } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import DynamicIcon from '@/components/DynamicIcon';
|
||||
import LucideIconSelect from '@/components/LucideIconSelect';
|
||||
import type { TeamBookmarkCategory, BookmarkCategoryFormData } from '../types';
|
||||
import {
|
||||
getBookmarkCategories,
|
||||
createBookmarkCategory,
|
||||
updateBookmarkCategory,
|
||||
deleteBookmarkCategory,
|
||||
} from '../service';
|
||||
|
||||
interface CategoryManageDialogProps {
|
||||
open: boolean;
|
||||
teamId: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 书签分类管理弹窗
|
||||
*/
|
||||
export const CategoryManageDialog: React.FC<CategoryManageDialogProps> = ({
|
||||
open,
|
||||
teamId,
|
||||
onOpenChange,
|
||||
onSuccess
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<TeamBookmarkCategory[]>([]);
|
||||
|
||||
const [formVisible, setFormVisible] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<TeamBookmarkCategory | null>(null);
|
||||
const [iconSelectOpen, setIconSelectOpen] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<BookmarkCategoryFormData>({
|
||||
name: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// 加载分类列表
|
||||
const loadCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getBookmarkCategories(teamId);
|
||||
setCategories(data || []);
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '加载失败',
|
||||
description: '无法加载分类列表',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadCategories();
|
||||
}
|
||||
}, [open, teamId]);
|
||||
|
||||
// 新增分类
|
||||
const handleAdd = () => {
|
||||
setEditingCategory(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
enabled: true
|
||||
});
|
||||
setFormVisible(true);
|
||||
};
|
||||
|
||||
// 编辑分类
|
||||
const handleEdit = (category: TeamBookmarkCategory) => {
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
name: category.name,
|
||||
icon: category.icon || '',
|
||||
sort: category.sort,
|
||||
enabled: category.enabled
|
||||
});
|
||||
setFormVisible(true);
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingCategory) {
|
||||
await updateBookmarkCategory(editingCategory.id, teamId, {
|
||||
...formData,
|
||||
name: formData.name.trim(),
|
||||
icon: formData.icon || undefined
|
||||
});
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '分类已更新',
|
||||
});
|
||||
} else {
|
||||
await createBookmarkCategory(teamId, {
|
||||
...formData,
|
||||
name: formData.name.trim(),
|
||||
icon: formData.icon || undefined
|
||||
});
|
||||
toast({
|
||||
title: '创建成功',
|
||||
description: '分类已添加',
|
||||
});
|
||||
}
|
||||
await loadCategories();
|
||||
setFormVisible(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('保存分类失败:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '保存失败',
|
||||
description: editingCategory ? '无法更新分类' : '无法创建分类',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除分类
|
||||
const handleDelete = async (category: TeamBookmarkCategory) => {
|
||||
if (!confirm(`确认删除分类「${category.name}」?\n该分类下的书签不会被删除,将变为未分类。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteBookmarkCategory(category.id, teamId);
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: '分类已删除',
|
||||
});
|
||||
await loadCategories();
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('删除分类失败:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '删除失败',
|
||||
description: '无法删除分类',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[70vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
管理书签分类
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{/* 新增表单 */}
|
||||
{formVisible && (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 mb-4 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="grid grid-cols-12 gap-3 items-end">
|
||||
<div className="col-span-6 space-y-1.5">
|
||||
<Label htmlFor="category-name" className="text-sm">
|
||||
分类名称 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:开发工具"
|
||||
className="h-9"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => setIconSelectOpen(true)}
|
||||
title="选择图标"
|
||||
>
|
||||
{formData.icon ? (
|
||||
<DynamicIcon name={formData.icon} className="h-4 w-4" />
|
||||
) : (
|
||||
<Palette className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label htmlFor="category-sort" className="text-sm">排序</Label>
|
||||
<Input
|
||||
id="category-sort"
|
||||
type="number"
|
||||
value={formData.sort}
|
||||
onChange={(e) => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||
className="h-9"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<Button type="submit" size="sm" className="h-9 flex-1">
|
||||
{editingCategory ? '保存' : '添加'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 flex-1"
|
||||
onClick={() => setFormVisible(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 分类列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<FolderOpen className="h-12 w-12 mb-4 opacity-20" />
|
||||
<p className="text-sm">暂无分类</p>
|
||||
{!formVisible && (
|
||||
<Button variant="link" onClick={handleAdd} className="mt-2">
|
||||
立即创建第一个分类
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{category.icon && (
|
||||
<DynamicIcon name={category.icon} className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
排序: {category.sort}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(category)}
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{!formVisible && (
|
||||
<Button variant="outline" onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增分类
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 图标选择弹窗 */}
|
||||
<LucideIconSelect
|
||||
open={iconSelectOpen}
|
||||
onOpenChange={setIconSelectOpen}
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,307 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, Plus, Settings, Loader2, Bookmark as BookmarkIcon } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { BookmarkCard } from './BookmarkCard';
|
||||
import { BookmarkForm } from './BookmarkForm';
|
||||
import type { TeamBookmark, TeamBookmarkCategory, BookmarkFormData } from '../types';
|
||||
import {
|
||||
getBookmarks,
|
||||
getBookmarkCategories,
|
||||
createBookmark,
|
||||
updateBookmark,
|
||||
deleteBookmark,
|
||||
} from '../service';
|
||||
|
||||
interface TeamBookmarkDialogProps {
|
||||
open: boolean;
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
isOwner: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onManageCategories?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队书签列表弹窗
|
||||
*/
|
||||
export const TeamBookmarkDialog: React.FC<TeamBookmarkDialogProps> = ({
|
||||
open,
|
||||
teamId,
|
||||
teamName,
|
||||
isOwner,
|
||||
onOpenChange,
|
||||
onManageCategories
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | undefined>();
|
||||
|
||||
const [categories, setCategories] = useState<TeamBookmarkCategory[]>([]);
|
||||
const [bookmarks, setBookmarks] = useState<TeamBookmark[]>([]);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingBookmark, setEditingBookmark] = useState<TeamBookmark | null>(null);
|
||||
const [deletingBookmarkId, setDeletingBookmarkId] = useState<number | null>(null);
|
||||
|
||||
// 加载分类列表
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await getBookmarkCategories(teamId);
|
||||
setCategories(data || []);
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载书签列表
|
||||
const loadBookmarks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getBookmarks({
|
||||
teamId,
|
||||
categoryId: selectedCategoryId
|
||||
});
|
||||
setBookmarks(data || []);
|
||||
} catch (error) {
|
||||
console.error('加载书签失败:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '加载失败',
|
||||
description: '无法加载书签列表',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听打开状态
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadCategories();
|
||||
loadBookmarks();
|
||||
} else {
|
||||
setSearch('');
|
||||
setSelectedCategoryId(undefined);
|
||||
}
|
||||
}, [open, teamId]);
|
||||
|
||||
// 监听分类变化
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadBookmarks();
|
||||
}
|
||||
}, [selectedCategoryId]);
|
||||
|
||||
// 过滤书签(根据搜索)
|
||||
const filteredBookmarks = useMemo(() => {
|
||||
if (!search.trim()) return bookmarks;
|
||||
|
||||
const keyword = search.toLowerCase();
|
||||
return bookmarks.filter(bookmark =>
|
||||
bookmark.title.toLowerCase().includes(keyword) ||
|
||||
bookmark.url.toLowerCase().includes(keyword) ||
|
||||
bookmark.description?.toLowerCase().includes(keyword) ||
|
||||
bookmark.tags?.some(tag => tag.toLowerCase().includes(keyword))
|
||||
);
|
||||
}, [bookmarks, search]);
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (categoryId?: number) => {
|
||||
if (!categoryId) return undefined;
|
||||
return categories.find(c => c.id === categoryId)?.name;
|
||||
};
|
||||
|
||||
// 新增书签
|
||||
const handleAdd = () => {
|
||||
setEditingBookmark(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
// 编辑书签
|
||||
const handleEdit = (bookmark: TeamBookmark) => {
|
||||
setEditingBookmark(bookmark);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (data: BookmarkFormData) => {
|
||||
try {
|
||||
if (editingBookmark) {
|
||||
await updateBookmark(editingBookmark.id, teamId, data);
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '书签已更新',
|
||||
});
|
||||
} else {
|
||||
await createBookmark(teamId, data);
|
||||
toast({
|
||||
title: '创建成功',
|
||||
description: '书签已添加',
|
||||
});
|
||||
}
|
||||
await loadBookmarks();
|
||||
} catch (error) {
|
||||
console.error('保存书签失败:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '保存失败',
|
||||
description: editingBookmark ? '无法更新书签' : '无法创建书签',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 删除书签
|
||||
const handleDelete = async (bookmark: TeamBookmark) => {
|
||||
if (!confirm(`确认删除书签「${bookmark.title}」?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingBookmarkId(bookmark.id);
|
||||
try {
|
||||
await deleteBookmark(bookmark.id, teamId);
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: '书签已删除',
|
||||
});
|
||||
await loadBookmarks();
|
||||
} catch (error) {
|
||||
console.error('删除书签失败:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '删除失败',
|
||||
description: '无法删除书签',
|
||||
});
|
||||
} finally {
|
||||
setDeletingBookmarkId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl h-[85vh] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<BookmarkIcon className="h-5 w-5" />
|
||||
团队书签 - {teamName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0 px-6">
|
||||
{/* 分类筛选 */}
|
||||
<Select
|
||||
value={selectedCategoryId?.toString() || 'all'}
|
||||
onValueChange={(value) => setSelectedCategoryId(value === 'all' ? undefined : Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="全部分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{categories.filter(c => c.enabled).map((category) => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索书签..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增书签
|
||||
</Button>
|
||||
{onManageCategories && (
|
||||
<Button variant="outline" onClick={onManageCategories}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
管理分类
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 书签列表 */}
|
||||
<DialogBody>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredBookmarks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<BookmarkIcon className="h-12 w-12 mb-4 opacity-20" />
|
||||
<p className="text-sm">
|
||||
{search ? '未找到匹配的书签' : '暂无书签数据'}
|
||||
</p>
|
||||
{isOwner && !search && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleAdd}
|
||||
className="mt-2"
|
||||
>
|
||||
立即添加第一个书签
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredBookmarks.map((bookmark) => (
|
||||
<BookmarkCard
|
||||
key={bookmark.id}
|
||||
bookmark={bookmark}
|
||||
categoryName={getCategoryName(bookmark.categoryId)}
|
||||
isOwner={isOwner}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 书签表单弹窗 */}
|
||||
<BookmarkForm
|
||||
open={formOpen}
|
||||
bookmark={editingBookmark}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
82
frontend/src/pages/Dashboard/Bookmark/service.ts
Normal file
82
frontend/src/pages/Dashboard/Bookmark/service.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import request from '@/utils/request';
|
||||
import type {
|
||||
TeamBookmarkCategory,
|
||||
TeamBookmark,
|
||||
BookmarkCategoryFormData,
|
||||
BookmarkFormData
|
||||
} from './types';
|
||||
|
||||
const BOOKMARK_URL = '/api/v1/team-bookmark';
|
||||
const CATEGORY_URL = '/api/v1/team-bookmark-category';
|
||||
|
||||
// ==================== 书签分类接口 ====================
|
||||
|
||||
/**
|
||||
* 获取团队书签分类列表
|
||||
*/
|
||||
export const getBookmarkCategories = (teamId: number) =>
|
||||
request.get<TeamBookmarkCategory[]>(`${CATEGORY_URL}/list`, {
|
||||
params: { teamId }
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建书签分类
|
||||
*/
|
||||
export const createBookmarkCategory = (teamId: number, data: BookmarkCategoryFormData) =>
|
||||
request.post<TeamBookmarkCategory>(CATEGORY_URL, {
|
||||
...data,
|
||||
teamId
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新书签分类
|
||||
*/
|
||||
export const updateBookmarkCategory = (id: number, teamId: number, data: BookmarkCategoryFormData) =>
|
||||
request.put<TeamBookmarkCategory>(`${CATEGORY_URL}/${id}`, {
|
||||
...data,
|
||||
teamId
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除书签分类
|
||||
*/
|
||||
export const deleteBookmarkCategory = (id: number, teamId: number) =>
|
||||
request.delete<void>(`${CATEGORY_URL}/${id}`, {
|
||||
params: { teamId }
|
||||
});
|
||||
|
||||
// ==================== 书签接口 ====================
|
||||
|
||||
/**
|
||||
* 获取团队书签列表
|
||||
*/
|
||||
export const getBookmarks = (params: { teamId: number; categoryId?: number }) =>
|
||||
request.get<TeamBookmark[]>(`${BOOKMARK_URL}/list`, { params });
|
||||
|
||||
/**
|
||||
* 创建书签
|
||||
*/
|
||||
export const createBookmark = (teamId: number, data: BookmarkFormData) =>
|
||||
request.post<TeamBookmark>(BOOKMARK_URL, {
|
||||
...data,
|
||||
teamId,
|
||||
isPublic: true // 默认团队公开
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新书签
|
||||
*/
|
||||
export const updateBookmark = (id: number, teamId: number, data: BookmarkFormData) =>
|
||||
request.put<TeamBookmark>(`${BOOKMARK_URL}/${id}`, {
|
||||
...data,
|
||||
teamId,
|
||||
isPublic: true
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除书签
|
||||
*/
|
||||
export const deleteBookmark = (id: number, teamId: number) =>
|
||||
request.delete<void>(`${BOOKMARK_URL}/${id}`, {
|
||||
params: { teamId }
|
||||
});
|
||||
71
frontend/src/pages/Dashboard/Bookmark/types.ts
Normal file
71
frontend/src/pages/Dashboard/Bookmark/types.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 团队书签分类
|
||||
*/
|
||||
export interface TeamBookmarkCategory {
|
||||
id: number;
|
||||
teamId: number;
|
||||
name: string;
|
||||
icon?: string; // Lucide 图标名称
|
||||
sort: number;
|
||||
enabled: boolean;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队书签
|
||||
*/
|
||||
export interface TeamBookmark {
|
||||
id: number;
|
||||
teamId: number;
|
||||
categoryId?: number;
|
||||
|
||||
// 基本信息
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: string; // Lucide 图标名称
|
||||
description?: string;
|
||||
|
||||
// 认证信息(可选)
|
||||
needAuth: boolean; // 是否需要认证(决定是否显示账号密码)
|
||||
username?: string;
|
||||
password?: string; // 后端加密
|
||||
|
||||
// 其他
|
||||
tags?: string[];
|
||||
sort: number;
|
||||
enabled: boolean;
|
||||
isPublic: boolean;
|
||||
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
createBy?: string;
|
||||
updateBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建/更新书签分类请求
|
||||
*/
|
||||
export interface BookmarkCategoryFormData {
|
||||
name: string;
|
||||
icon?: string;
|
||||
sort?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建/更新书签请求
|
||||
*/
|
||||
export interface BookmarkFormData {
|
||||
categoryId?: number;
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
needAuth?: boolean; // 是否需要认证
|
||||
username?: string;
|
||||
password?: string;
|
||||
tags?: string[];
|
||||
sort?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Users, ClipboardCheck } from "lucide-react";
|
||||
import { Users, ClipboardCheck, Bookmark } from "lucide-react";
|
||||
import type { DeployTeam } from '../types';
|
||||
|
||||
interface TeamSelectorProps {
|
||||
@ -11,6 +11,7 @@ interface TeamSelectorProps {
|
||||
showApprovalButton: boolean; // 是否显示待审批按钮(基于当前环境)
|
||||
onTeamChange: (teamId: string) => void;
|
||||
onApprovalClick: () => void;
|
||||
onBookmarkClick?: () => void; // 书签按钮点击回调
|
||||
}
|
||||
|
||||
/**
|
||||
@ -23,10 +24,22 @@ export const TeamSelector: React.FC<TeamSelectorProps> = React.memo(({
|
||||
pendingApprovalCount,
|
||||
showApprovalButton,
|
||||
onTeamChange,
|
||||
onApprovalClick
|
||||
onApprovalClick,
|
||||
onBookmarkClick
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 书签按钮 */}
|
||||
{onBookmarkClick && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBookmarkClick}
|
||||
>
|
||||
<Bookmark className="h-4 w-4 mr-2" />
|
||||
书签
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 待审批按钮 - 只在当前环境需要审批且用户有权限时显示 */}
|
||||
{showApprovalButton && (
|
||||
<Button
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useState, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '@/store';
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Package, Server } from "lucide-react";
|
||||
import { TeamSelector } from './components/TeamSelector';
|
||||
import { EnvironmentTabs } from './components/EnvironmentTabs';
|
||||
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
||||
import { TeamBookmarkDialog } from './Bookmark/components/TeamBookmarkDialog';
|
||||
import { CategoryManageDialog } from './Bookmark/components/CategoryManageDialog';
|
||||
import { useDeploymentData } from './hooks/useDeploymentData';
|
||||
import { usePendingApproval } from './hooks/usePendingApproval';
|
||||
import type { ApplicationConfig } from './types';
|
||||
@ -39,6 +43,10 @@ const LoadingState = () => (
|
||||
* 4. 智能轮询 - 根据部署状态动态调整轮询频率
|
||||
*/
|
||||
const Dashboard: React.FC = () => {
|
||||
// 书签弹窗状态
|
||||
const [bookmarkDialogOpen, setBookmarkDialogOpen] = useState(false);
|
||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||
|
||||
// 使用自定义 hooks 管理状态
|
||||
const deploymentData = useDeploymentData({
|
||||
onInitialLoadComplete: () => {
|
||||
@ -82,6 +90,21 @@ const Dashboard: React.FC = () => {
|
||||
return currentEnv?.requiresApproval === true && currentEnv?.canApprove === true;
|
||||
}, [deploymentData.currentTeam, deploymentData.currentEnvId]);
|
||||
|
||||
// 判断当前用户是否是团队负责人(后端返回)
|
||||
const isTeamOwner = deploymentData.currentTeam?.isOwner || false;
|
||||
|
||||
// 打开书签弹窗
|
||||
const handleBookmarkClick = useCallback(() => {
|
||||
if (deploymentData.currentTeam) {
|
||||
setBookmarkDialogOpen(true);
|
||||
}
|
||||
}, [deploymentData.currentTeam]);
|
||||
|
||||
// 打开分类管理弹窗
|
||||
const handleManageCategories = useCallback(() => {
|
||||
setCategoryDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 处理部署成功 - 使用 useCallback 避免重复创建
|
||||
const handleDeploy = useCallback(async (app: ApplicationConfig, remark: string) => {
|
||||
await deploymentData.handleDeploySuccess();
|
||||
@ -126,6 +149,7 @@ const Dashboard: React.FC = () => {
|
||||
showApprovalButton={shouldShowApprovalButton}
|
||||
onTeamChange={deploymentData.handleTeamChange}
|
||||
onApprovalClick={() => approvalData.setApprovalModalOpen(true)}
|
||||
onBookmarkClick={handleBookmarkClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -173,6 +197,35 @@ const Dashboard: React.FC = () => {
|
||||
teamId={deploymentData.currentTeamId || undefined}
|
||||
environmentId={deploymentData.currentEnvId || undefined}
|
||||
/>
|
||||
|
||||
{/* 书签弹窗 */}
|
||||
{deploymentData.currentTeam && (
|
||||
<TeamBookmarkDialog
|
||||
open={bookmarkDialogOpen}
|
||||
teamId={deploymentData.currentTeam.teamId}
|
||||
teamName={deploymentData.currentTeam.teamName}
|
||||
isOwner={isTeamOwner}
|
||||
onOpenChange={setBookmarkDialogOpen}
|
||||
onManageCategories={isTeamOwner ? handleManageCategories : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 分类管理弹窗 */}
|
||||
{deploymentData.currentTeam && isTeamOwner && (
|
||||
<CategoryManageDialog
|
||||
open={categoryDialogOpen}
|
||||
teamId={deploymentData.currentTeam.teamId}
|
||||
onOpenChange={setCategoryDialogOpen}
|
||||
onSuccess={() => {
|
||||
// 分类变更后,强制刷新书签弹窗(通过关闭再打开)
|
||||
// 这样可以重新加载分类列表
|
||||
if (bookmarkDialogOpen) {
|
||||
setBookmarkDialogOpen(false);
|
||||
setTimeout(() => setBookmarkDialogOpen(true), 100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -113,6 +113,7 @@ export interface DeployTeam {
|
||||
description?: string;
|
||||
ownerId: number;
|
||||
ownerName: string;
|
||||
isOwner: boolean; // 当前用户是否是团队负责人(后端返回)
|
||||
members: TeamMember[];
|
||||
environments: DeployEnvironment[];
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user