1.20升级

This commit is contained in:
dengqichen 2025-12-11 16:43:51 +08:00
parent a99476794c
commit a25ee2c9a6
9 changed files with 1368 additions and 3 deletions

View File

@ -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>
);
};

View File

@ -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 })}
/>
</>
);
};

View File

@ -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 })}
/>
</>
);
};

View File

@ -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}
/>
</>
);
};

View 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 }
});

View 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;
}

View File

@ -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

View File

@ -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>
);
};

View File

@ -113,6 +113,7 @@ export interface DeployTeam {
description?: string;
ownerId: number;
ownerName: string;
isOwner: boolean; // 当前用户是否是团队负责人(后端返回)
members: TeamMember[];
environments: DeployEnvironment[];
}