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 React from 'react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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';
|
import type { DeployTeam } from '../types';
|
||||||
|
|
||||||
interface TeamSelectorProps {
|
interface TeamSelectorProps {
|
||||||
@ -11,6 +11,7 @@ interface TeamSelectorProps {
|
|||||||
showApprovalButton: boolean; // 是否显示待审批按钮(基于当前环境)
|
showApprovalButton: boolean; // 是否显示待审批按钮(基于当前环境)
|
||||||
onTeamChange: (teamId: string) => void;
|
onTeamChange: (teamId: string) => void;
|
||||||
onApprovalClick: () => void;
|
onApprovalClick: () => void;
|
||||||
|
onBookmarkClick?: () => void; // 书签按钮点击回调
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,10 +24,22 @@ export const TeamSelector: React.FC<TeamSelectorProps> = React.memo(({
|
|||||||
pendingApprovalCount,
|
pendingApprovalCount,
|
||||||
showApprovalButton,
|
showApprovalButton,
|
||||||
onTeamChange,
|
onTeamChange,
|
||||||
onApprovalClick
|
onApprovalClick,
|
||||||
|
onBookmarkClick
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 书签按钮 */}
|
||||||
|
{onBookmarkClick && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBookmarkClick}
|
||||||
|
>
|
||||||
|
<Bookmark className="h-4 w-4 mr-2" />
|
||||||
|
书签
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 待审批按钮 - 只在当前环境需要审批且用户有权限时显示 */}
|
{/* 待审批按钮 - 只在当前环境需要审批且用户有权限时显示 */}
|
||||||
{showApprovalButton && (
|
{showApprovalButton && (
|
||||||
<Button
|
<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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Package, Server } from "lucide-react";
|
import { Package, Server } from "lucide-react";
|
||||||
import { TeamSelector } from './components/TeamSelector';
|
import { TeamSelector } from './components/TeamSelector';
|
||||||
import { EnvironmentTabs } from './components/EnvironmentTabs';
|
import { EnvironmentTabs } from './components/EnvironmentTabs';
|
||||||
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
||||||
|
import { TeamBookmarkDialog } from './Bookmark/components/TeamBookmarkDialog';
|
||||||
|
import { CategoryManageDialog } from './Bookmark/components/CategoryManageDialog';
|
||||||
import { useDeploymentData } from './hooks/useDeploymentData';
|
import { useDeploymentData } from './hooks/useDeploymentData';
|
||||||
import { usePendingApproval } from './hooks/usePendingApproval';
|
import { usePendingApproval } from './hooks/usePendingApproval';
|
||||||
import type { ApplicationConfig } from './types';
|
import type { ApplicationConfig } from './types';
|
||||||
@ -39,6 +43,10 @@ const LoadingState = () => (
|
|||||||
* 4. 智能轮询 - 根据部署状态动态调整轮询频率
|
* 4. 智能轮询 - 根据部署状态动态调整轮询频率
|
||||||
*/
|
*/
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
|
// 书签弹窗状态
|
||||||
|
const [bookmarkDialogOpen, setBookmarkDialogOpen] = useState(false);
|
||||||
|
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||||
|
|
||||||
// 使用自定义 hooks 管理状态
|
// 使用自定义 hooks 管理状态
|
||||||
const deploymentData = useDeploymentData({
|
const deploymentData = useDeploymentData({
|
||||||
onInitialLoadComplete: () => {
|
onInitialLoadComplete: () => {
|
||||||
@ -82,6 +90,21 @@ const Dashboard: React.FC = () => {
|
|||||||
return currentEnv?.requiresApproval === true && currentEnv?.canApprove === true;
|
return currentEnv?.requiresApproval === true && currentEnv?.canApprove === true;
|
||||||
}, [deploymentData.currentTeam, deploymentData.currentEnvId]);
|
}, [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 避免重复创建
|
// 处理部署成功 - 使用 useCallback 避免重复创建
|
||||||
const handleDeploy = useCallback(async (app: ApplicationConfig, remark: string) => {
|
const handleDeploy = useCallback(async (app: ApplicationConfig, remark: string) => {
|
||||||
await deploymentData.handleDeploySuccess();
|
await deploymentData.handleDeploySuccess();
|
||||||
@ -126,6 +149,7 @@ const Dashboard: React.FC = () => {
|
|||||||
showApprovalButton={shouldShowApprovalButton}
|
showApprovalButton={shouldShowApprovalButton}
|
||||||
onTeamChange={deploymentData.handleTeamChange}
|
onTeamChange={deploymentData.handleTeamChange}
|
||||||
onApprovalClick={() => approvalData.setApprovalModalOpen(true)}
|
onApprovalClick={() => approvalData.setApprovalModalOpen(true)}
|
||||||
|
onBookmarkClick={handleBookmarkClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -173,6 +197,35 @@ const Dashboard: React.FC = () => {
|
|||||||
teamId={deploymentData.currentTeamId || undefined}
|
teamId={deploymentData.currentTeamId || undefined}
|
||||||
environmentId={deploymentData.currentEnvId || 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -113,6 +113,7 @@ export interface DeployTeam {
|
|||||||
description?: string;
|
description?: string;
|
||||||
ownerId: number;
|
ownerId: number;
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
|
isOwner: boolean; // 当前用户是否是团队负责人(后端返回)
|
||||||
members: TeamMember[];
|
members: TeamMember[];
|
||||||
environments: DeployEnvironment[];
|
environments: DeployEnvironment[];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user