feat: 添加项目初始化选择功能,支持多项目选择和URL状态同步
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon, PlayCircleIcon, StopCircleIcon, FolderIcon, AlertCircleIcon, CircleOffIcon, DownloadIcon, ListTodoIcon, CheckSquareIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -7,8 +7,189 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCodeGraphStore } from '../store';
|
||||
|
||||
// 初始化项目弹窗组件
|
||||
function ProjectInitDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { initProject, fetchProjectFiles } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
initProject: s.initProject,
|
||||
fetchProjectFiles: s.fetchProjectFiles,
|
||||
})),
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
|
||||
const [rootPath, setRootPath] = useState('/workspace/projects');
|
||||
|
||||
// 加载文件列表
|
||||
const loadFiles = async () => {
|
||||
const data = await fetchProjectFiles(rootPath);
|
||||
setFiles(data);
|
||||
};
|
||||
|
||||
// 打开时加载数据
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadFiles();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 切换选中状态
|
||||
const toggleSelection = (path: string) => {
|
||||
setSelectedPaths((prev) =>
|
||||
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path]
|
||||
);
|
||||
};
|
||||
|
||||
// 全选
|
||||
const selectAll = () => setSelectedPaths([...files]);
|
||||
|
||||
// 取消全选
|
||||
const deselectAll = () => setSelectedPaths([]);
|
||||
|
||||
// 确认初始化
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
await initProject(selectedPaths.length > 0 ? selectedPaths : undefined);
|
||||
setLoading(false);
|
||||
setSelectedPaths([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
setSelectedPaths([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const allSelected = files.length > 0 && selectedPaths.length === files.length;
|
||||
const isIndeterminate = selectedPaths.length > 0 && selectedPaths.length < files.length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className='sm:max-w-2xl max-h-[80vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
|
||||
<DialogHeader className='text-center'>
|
||||
<div className='flex justify-center mb-3'>
|
||||
<div className='w-12 h-12 rounded-full flex items-center justify-center bg-indigo-500/20'>
|
||||
<DownloadIcon className='w-6 h-6 text-indigo-400' />
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className='text-lg font-semibold'>
|
||||
初始化项目
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-slate-400'>
|
||||
选择要初始化的项目,选中后点击确认开始初始化
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 路径输入 */}
|
||||
<div className='px-6'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<FolderIcon className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500' />
|
||||
<Input
|
||||
value={rootPath}
|
||||
onChange={(e) => setRootPath(e.target.value)}
|
||||
placeholder='项目根目录'
|
||||
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm pl-10 pr-4 focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={loadFiles}
|
||||
className='bg-slate-700 hover:bg-slate-600 shrink-0'>
|
||||
加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<div className='flex-1 overflow-hidden px-6 py-2'>
|
||||
<Card className='bg-slate-800/50 border-white/10 h-full'>
|
||||
<CardContent className='p-3 h-[280px] overflow-y-auto scrollbar'>
|
||||
{files.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center h-full text-slate-500 text-sm gap-2'>
|
||||
<FolderIcon className='w-8 h-8 text-slate-600' />
|
||||
<span>暂无项目,点击加载获取项目列表</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
{files.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
onClick={() => toggleSelection(path)}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer transition-all ${selectedPaths.includes(path)
|
||||
? 'bg-indigo-500/20 border border-indigo-500/50'
|
||||
: 'hover:bg-white/5 border border-transparent'
|
||||
}`}>
|
||||
<Checkbox
|
||||
checked={selectedPaths.includes(path)}
|
||||
onCheckedChange={() => toggleSelection(path)}
|
||||
className='border-slate-500 data-[checked]:bg-indigo-500 data-[checked]:border-indigo-500'
|
||||
/>
|
||||
<FolderIcon className='w-4 h-4 text-slate-400 shrink-0' />
|
||||
<span className='text-sm text-slate-200 truncate'>{path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center justify-between px-6 pb-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={isIndeterminate ? selectAll : allSelected ? deselectAll : selectAll}
|
||||
disabled={files.length === 0}
|
||||
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30'>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
<span className='text-xs text-slate-400'>
|
||||
已选择 {selectedPaths.length}/{files.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30'>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
className='bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 disabled:opacity-50'>
|
||||
{loading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
|
||||
初始化中…
|
||||
</span>
|
||||
) : (
|
||||
'确认初始化'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDialog() {
|
||||
const {
|
||||
projectDialogOpen,
|
||||
@@ -19,7 +200,6 @@ export function ProjectDialog() {
|
||||
addProject,
|
||||
removeProject,
|
||||
toggleProjectStatus,
|
||||
initProject,
|
||||
} = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
projectDialogOpen: s.projectDialogOpen,
|
||||
@@ -30,7 +210,6 @@ export function ProjectDialog() {
|
||||
addProject: s.addProject,
|
||||
removeProject: s.removeProject,
|
||||
toggleProjectStatus: s.toggleProjectStatus,
|
||||
initProject: s.initProject,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -53,9 +232,8 @@ export function ProjectDialog() {
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [pendingDeleteProject, setPendingDeleteProject] = useState<{ path: string; name?: string } | null>(null);
|
||||
|
||||
// 初始化确认弹窗
|
||||
// 初始化弹窗
|
||||
const [initConfirmOpen, setInitConfirmOpen] = useState(false);
|
||||
const [initLoading, setInitLoading] = useState(false);
|
||||
|
||||
// 多选模式
|
||||
const [multiSelectMode, setMultiSelectMode] = useState(false);
|
||||
@@ -175,19 +353,6 @@ export function ProjectDialog() {
|
||||
setPendingDeleteProject(null);
|
||||
};
|
||||
|
||||
// 确认初始化
|
||||
const handleConfirmInit = async () => {
|
||||
setInitLoading(true);
|
||||
await initProject();
|
||||
setInitLoading(false);
|
||||
setInitConfirmOpen(false);
|
||||
};
|
||||
|
||||
// 取消初始化
|
||||
const handleCancelInit = () => {
|
||||
setInitConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={projectDialogOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className='w-[calc(100%-2rem)] sm:max-w-lg max-h-[85vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl overflow-hidden'>
|
||||
@@ -229,8 +394,7 @@ export function ProjectDialog() {
|
||||
setNewPath('/workspace/projects');
|
||||
}
|
||||
}}
|
||||
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${
|
||||
projectType === 'filepath'
|
||||
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${projectType === 'filepath'
|
||||
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
|
||||
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
|
||||
}`}>
|
||||
@@ -242,8 +406,7 @@ export function ProjectDialog() {
|
||||
setProjectType('cnb-repo');
|
||||
setNewPath('');
|
||||
}}
|
||||
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${
|
||||
projectType === 'cnb-repo'
|
||||
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${projectType === 'cnb-repo'
|
||||
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
|
||||
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
|
||||
}`}>
|
||||
@@ -322,7 +485,7 @@ export function ProjectDialog() {
|
||||
<div
|
||||
onClick={() => setInitConfirmOpen(true)}
|
||||
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'>
|
||||
<DownloadIcon className={`w-4 h-4 ${initLoading ? 'animate-spin' : ''}`} />
|
||||
<DownloadIcon className='w-4 h-4' />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -440,8 +603,7 @@ export function ProjectDialog() {
|
||||
{multiSelectMode && (
|
||||
<button
|
||||
onClick={() => toggleProjectSelection(p.path)}
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
selectedProjects.includes(p.path)
|
||||
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${selectedProjects.includes(p.path)
|
||||
? 'bg-indigo-500 border-indigo-500 text-white'
|
||||
: 'border-slate-500 hover:border-indigo-400'
|
||||
}`}>
|
||||
@@ -570,44 +732,8 @@ export function ProjectDialog() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 初始化确认弹窗 */}
|
||||
<Dialog open={initConfirmOpen} onOpenChange={(open) => !open && handleCancelInit()}>
|
||||
<DialogContent className='sm:max-w-md bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
|
||||
<DialogHeader>
|
||||
<div className='w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3 bg-indigo-500/20'>
|
||||
<DownloadIcon className='w-6 h-6 text-indigo-400' />
|
||||
</div>
|
||||
<DialogTitle className='text-center text-lg font-semibold'>
|
||||
初始化项目
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-center text-slate-400'>
|
||||
确定要初始化 workspace/projects 仓库吗?初始化过程可能需要一些时间。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className='gap-2 sm:justify-center'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCancelInit}
|
||||
disabled={initLoading}
|
||||
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30 flex-1'>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmInit}
|
||||
disabled={initLoading}
|
||||
className='bg-indigo-600 hover:bg-indigo-500 text-white flex-1 shadow-lg shadow-indigo-500/25 disabled:opacity-50'>
|
||||
{initLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
|
||||
初始化中…
|
||||
</span>
|
||||
) : (
|
||||
'确认初始化'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 初始化弹窗 */}
|
||||
<ProjectInitDialog open={initConfirmOpen} onOpenChange={setInitConfirmOpen} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,17 +8,16 @@ import { useBotHelperStore } from '../store/bot-helper';
|
||||
|
||||
interface ProjectPanelProps {
|
||||
onProjectClick?: (projectPath: string, files: FileProjectData[]) => void;
|
||||
onOpenCodePod?: (projectPath: string) => void;
|
||||
onStopProject?: (projectPath: string) => void;
|
||||
}
|
||||
|
||||
export function ProjectPanel({
|
||||
onProjectClick,
|
||||
onOpenCodePod,
|
||||
onStopProject,
|
||||
}: ProjectPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 20, y: 100 });
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -83,16 +82,47 @@ export function ProjectPanel({
|
||||
|
||||
const handleProjectClick = useCallback(
|
||||
(projectPath: string) => {
|
||||
// 如果点击的是已选中的项目,则取消选中
|
||||
if (selectedProject === projectPath) {
|
||||
setSelectedProject(null);
|
||||
window.location.hash = '';
|
||||
useCodeGraphStore.getState().fetchProjects();
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 projects 列表中查找对应项目的 repo
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
const repo = project?.repo;
|
||||
|
||||
if (repo) {
|
||||
// 设置 hash 并刷新
|
||||
window.location.hash = `repo=${repo}`;
|
||||
useCodeGraphStore.getState().fetchProjects();
|
||||
}
|
||||
|
||||
setSelectedProject(projectPath);
|
||||
|
||||
if (project && onProjectClick) {
|
||||
onProjectClick(projectPath, files);
|
||||
}
|
||||
},
|
||||
[projects, files, onProjectClick],
|
||||
[projects, files, onProjectClick, selectedProject],
|
||||
);
|
||||
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
|
||||
// 初始化选中状态
|
||||
useEffect(() => {
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const repo = hashParams.get('repo');
|
||||
if (repo && projects.length > 0) {
|
||||
const projectItem = projects.find((p) => p.repo === repo);
|
||||
if (projectItem) {
|
||||
setSelectedProject(projectItem.path);
|
||||
}
|
||||
}
|
||||
}, [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreen = () => {
|
||||
setIsLargeScreen(window.innerWidth >= 1024);
|
||||
@@ -143,7 +173,11 @@ export function ProjectPanel({
|
||||
return (
|
||||
<div
|
||||
key={project.path}
|
||||
className='flex items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors border-l-2 border-transparent hover:border-indigo-500 group'
|
||||
className={`flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors border-l-2 group ${
|
||||
selectedProject === project.path
|
||||
? 'bg-indigo-500/20 text-white border-indigo-500'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white border-transparent hover:border-indigo-500'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleProjectClick(project.path)}
|
||||
|
||||
@@ -97,19 +97,6 @@ export default function CodeGraphPage() {
|
||||
<Code3DGraph files={files} className='h-full' onProjectFocus={projectFocus ?? undefined} />
|
||||
<ProjectPanel
|
||||
onProjectClick={handleProjectClick}
|
||||
onOpenCodePod={(projectPath) => {
|
||||
setCodePodAttrs({
|
||||
label: projectPath.split('/').pop() || projectPath,
|
||||
size: 0,
|
||||
color: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fullPath: projectPath,
|
||||
projectPath,
|
||||
kind: 'dir',
|
||||
});
|
||||
setCodePodOpen(true);
|
||||
}}
|
||||
onStopProject={(projectPath) => toggleProjectStatus(projectPath)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -53,7 +53,9 @@ type State = {
|
||||
addProject: (filepath: string, name?: string, type?: 'filepath' | 'cnb-repo') => Promise<boolean>;
|
||||
removeProject: (path: string) => Promise<void>;
|
||||
toggleProjectStatus: (path: string) => Promise<void>;
|
||||
initProject: () => Promise<void>;
|
||||
initProject: (projectPaths?: string[]) => Promise<void>;
|
||||
// 获取项目文件列表
|
||||
fetchProjectFiles: (rootPath?: string) => Promise<string[]>;
|
||||
// NodeInfo 弹窗
|
||||
nodeInfoOpen: boolean;
|
||||
setNodeInfoOpen: (open: boolean) => void;
|
||||
@@ -69,6 +71,8 @@ type State = {
|
||||
q?: string; // 可选的搜索关键词
|
||||
projectPath?: string; // 项目路径,必填
|
||||
getContent?: boolean; // 是否获取文件内容,默认为 false
|
||||
repo?: string; // 仓库地址
|
||||
projects?: string[]
|
||||
}) => Promise<Result<{ list: FileProjectData[] }>>;
|
||||
createQuestion: (opts: { question: string, projectPath?: string, filePath?: string, engine?: 'openclaw' | 'opencode', sessionId?: string }) => any;
|
||||
saveFile: (filepath: string, content: string) => Promise<void>;
|
||||
@@ -143,11 +147,11 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
initProject: async () => {
|
||||
initProject: async (projectPaths) => {
|
||||
const loadingToast = toast.loading('初始化项目中...');
|
||||
try {
|
||||
const url = get().url || API_URL;
|
||||
const res = await projectApi.project.init(undefined, { url });
|
||||
const res = await projectApi.project.init({ projectPaths }, { url });
|
||||
toast.dismiss(loadingToast);
|
||||
if (res.code === 200) {
|
||||
toast.success('项目初始化成功');
|
||||
@@ -160,6 +164,15 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
toast.error('项目初始化失败');
|
||||
}
|
||||
},
|
||||
fetchProjectFiles: async (rootPath) => {
|
||||
const url = get().url || API_URL;
|
||||
const res = await projectApi['project']['project-files']({ rootPath }, { url });
|
||||
if (res.code === 200) {
|
||||
const data = res.data as { projectFiles?: string[]; list?: string[] };
|
||||
return data?.projectFiles ?? data?.list ?? [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
removeProject: async (path) => {
|
||||
const loadingToast = toast.loading('移除项目中...');
|
||||
try {
|
||||
@@ -241,7 +254,12 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
},
|
||||
fetchProjects: async () => {
|
||||
get().loadProjects();
|
||||
const res = await get().getFiles();
|
||||
// 从hash中获取repo参数 (#repo=kevisual/cnb 格式)
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const repo = hashParams.get('repo');
|
||||
const res = await get().getFiles({
|
||||
repo: repo || undefined,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
set({ files: res.data!.list });
|
||||
} else {
|
||||
@@ -253,6 +271,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
q?: string; // 可选的搜索关键词
|
||||
projectPath?: string; // 项目路径,必填
|
||||
getContent?: boolean; // 是否获取文件内容,默认为 false
|
||||
projects?: string[];
|
||||
repo?: string; // 仓库地址
|
||||
}) => {
|
||||
const url = get().url
|
||||
const res = await projectApi["project-search"].files({
|
||||
|
||||
Reference in New Issue
Block a user