修复bug
This commit is contained in:
@@ -3,6 +3,7 @@ import {useEffect, useMemo, useState} from "react"
|
|||||||
import {Cesium_Model_Settings, type Cesium_Model_Settings_Schema} from "../Map/Cesium_Model_Settings.tsx"
|
import {Cesium_Model_Settings, type Cesium_Model_Settings_Schema} from "../Map/Cesium_Model_Settings.tsx"
|
||||||
import {AmisPanel} from "./AmisPanel.tsx"
|
import {AmisPanel} from "./AmisPanel.tsx"
|
||||||
import {ArchiveOperations, type Archive_Operations_Schema} from "./ArchiveOperations.tsx"
|
import {ArchiveOperations, type Archive_Operations_Schema} from "./ArchiveOperations.tsx"
|
||||||
|
import {BackendConfig, type Backend_Config_Schema} from "./BackendConfig.tsx"
|
||||||
type Composition_Node = {
|
type Composition_Node = {
|
||||||
kind: string
|
kind: string
|
||||||
slot?: string
|
slot?: string
|
||||||
@@ -40,6 +41,7 @@ function render_slot(name: string, slot: Slot_Contract) {
|
|||||||
if (slot.component_kind === "amis_schema") return <AmisPanel schema={slot.schema} />
|
if (slot.component_kind === "amis_schema") return <AmisPanel schema={slot.schema} />
|
||||||
if (slot.component_kind === "ecap_archive_operations") return <ArchiveOperations schema={slot.schema as unknown as Archive_Operations_Schema} />
|
if (slot.component_kind === "ecap_archive_operations") return <ArchiveOperations schema={slot.schema as unknown as Archive_Operations_Schema} />
|
||||||
if (slot.component_kind === "ecap_cesium_models") return <CesiumModelsSlot slot={slot} />
|
if (slot.component_kind === "ecap_cesium_models") return <CesiumModelsSlot slot={slot} />
|
||||||
|
if (slot.component_kind === "ecap_backend_config") return <BackendConfig schema={slot.schema as unknown as Backend_Config_Schema} />
|
||||||
return <div style={{padding: 16, color: "#ff4d4f"}}>未注册的 Adminive 前端组件:{name} ({slot.component_kind})</div>
|
return <div style={{padding: 16, color: "#ff4d4f"}}>未注册的 Adminive 前端组件:{name} ({slot.component_kind})</div>
|
||||||
}
|
}
|
||||||
export function Adminive_Settings() {
|
export function Adminive_Settings() {
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ export function ArchiveOperations({schema}: {schema: Archive_Operations_Schema})
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return <div style={{padding: 16}}>
|
return <div style={{padding: 16, width: "100%", display: "flex", flexDirection: "column", alignItems: "center"}}>
|
||||||
<h2>{schema.title ?? "导入导出"}</h2>
|
<h2 style={{textAlign: "center"}}>{schema.title ?? "导入导出"}</h2>
|
||||||
<Space direction="vertical" size={12} style={{width: "100%"}}>
|
<Space direction="vertical" align="center" size={12} style={{width: "100%"}}>
|
||||||
<Select value={format} options={schema.formats.map(item => ({label: item.label, value: item.value}))} onChange={set_format} style={{width: 200}} />
|
<Select value={format} options={schema.formats.map(item => ({label: item.label, value: item.value}))} onChange={set_format} style={{width: 200}} />
|
||||||
{schema.exports.map(item => <Space key={item.name} wrap>
|
{schema.exports.map(item => <Space key={item.name} wrap style={{justifyContent: "center"}}>
|
||||||
{item.allow_version_selection && <Input value={version} onChange={event => set_version(event.target.value)} placeholder="指定版本,留空导出当前版本" style={{width: 260}} />}
|
{item.allow_version_selection && <Input value={version} onChange={event => set_version(event.target.value)} placeholder="指定版本,留空导出当前版本" style={{width: 260}} />}
|
||||||
<Button icon={<DownloadOutlined />} loading={busy === `export:${item.name}`} disabled={Boolean(busy)} onClick={() => void export_archive(item)}>导出{item.name}</Button>
|
<Button icon={<DownloadOutlined />} loading={busy === `export:${item.name}`} disabled={Boolean(busy)} onClick={() => void export_archive(item)}>导出{item.name}</Button>
|
||||||
</Space>)}
|
</Space>)}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {Card, Spin, Tree, Typography} from "antd"
|
||||||
|
import type {DataNode} from "antd/es/tree"
|
||||||
|
import axios from "axios"
|
||||||
|
import {useEffect, useMemo, useState} from "react"
|
||||||
|
|
||||||
|
export type Backend_Config_Schema = {
|
||||||
|
title?: string
|
||||||
|
data_api: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Adminive_Response<T> = {
|
||||||
|
status: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
function value_text(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
if (value === null) return "null"
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tree_nodes(value: unknown, parent: string): DataNode[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item, index) => tree_node(`[${index}]`, item, `${parent}.${index}`))
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
return Object.entries(value).map(([key, item]) => tree_node(key, item, `${parent}.${key}`))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function tree_node(name: string, value: unknown, key: string): DataNode {
|
||||||
|
const children = tree_nodes(value, key)
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
title: children.length > 0
|
||||||
|
? name
|
||||||
|
: <span><Typography.Text strong>{name}</Typography.Text><Typography.Text type="secondary">:{value_text(value)}</Typography.Text></span>,
|
||||||
|
children: children.length > 0 ? children : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackendConfig({schema}: {schema: Backend_Config_Schema}) {
|
||||||
|
const [config, set_config] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [error, set_error] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
set_config(null)
|
||||||
|
set_error("")
|
||||||
|
axios.get<Adminive_Response<Record<string, unknown>>>(schema.data_api).then(response => {
|
||||||
|
set_config(response.data.data)
|
||||||
|
}).catch(reason => {
|
||||||
|
set_error(reason instanceof Error ? reason.message : String(reason))
|
||||||
|
})
|
||||||
|
}, [schema.data_api])
|
||||||
|
|
||||||
|
const sections = useMemo(() => Object.entries(config ?? {}), [config])
|
||||||
|
if (error) return <div style={{padding: 16, color: "#ff4d4f"}}>后端完整配置加载失败:{error}</div>
|
||||||
|
if (!config) return <div style={{padding: 24, textAlign: "center"}}><Spin /></div>
|
||||||
|
return (
|
||||||
|
<div style={{padding: 16, width: "100%"}}>
|
||||||
|
<h2 style={{textAlign: "center"}}>{schema.title ?? "后端完整配置(只读)"}</h2>
|
||||||
|
<div style={{display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 420px), 1fr))", gap: 12}}>
|
||||||
|
{sections.map(([name, value]) => (
|
||||||
|
<Card key={name} size="small" title={name} styles={{body: {overflowX: "auto"}}}>
|
||||||
|
<Tree showLine treeData={tree_nodes(value, name)} selectable={false} />
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {Checkbox, ColorPicker, Input, InputNumber, Select, Space} from "antd";
|
import {Card, Checkbox, ColorPicker, Input, InputNumber, Select, Space} from "antd";
|
||||||
|
|
||||||
export type Backend_Field_Descriptor = {
|
export type Backend_Field_Descriptor = {
|
||||||
name: string
|
name: string
|
||||||
@@ -107,12 +107,19 @@ export function Backend_Fields(props: {
|
|||||||
const controls = selected.filter(field => !booleans.includes(field));
|
const controls = selected.filter(field => !booleans.includes(field));
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={8} style={{width: "100%"}}>
|
<Space direction="vertical" size={8} style={{width: "100%"}}>
|
||||||
{booleans.length > 0 && <Space wrap>
|
{booleans.length > 0 && <div style={{display: "flex", flexWrap: "wrap", gap: 8}}>
|
||||||
{booleans.map(field => <Backend_Field_Control key={field.name} descriptor={field}
|
{booleans.map(field => (
|
||||||
value={object?.[field.name]}
|
<Card key={field.name} size="small" styles={{body: {padding: "6px 10px"}}}
|
||||||
disabled={field_disabled(props.root, field, props.rules || [])}
|
style={{flex: "0 0 auto", background: "#fafafa"}}>
|
||||||
onChange={value => update(field, value)}/>) }
|
<div style={{whiteSpace: "nowrap"}}>
|
||||||
</Space>}
|
<Backend_Field_Control descriptor={field}
|
||||||
|
value={object?.[field.name]}
|
||||||
|
disabled={field_disabled(props.root, field, props.rules || [])}
|
||||||
|
onChange={value => update(field, value)}/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>}
|
||||||
{controls.map(field => <Backend_Field_Control key={field.name} descriptor={field}
|
{controls.map(field => <Backend_Field_Control key={field.name} descriptor={field}
|
||||||
value={object?.[field.name]}
|
value={object?.[field.name]}
|
||||||
disabled={field_disabled(props.root, field, props.rules || [])}
|
disabled={field_disabled(props.root, field, props.rules || [])}
|
||||||
|
|||||||
+2
-1
@@ -64,6 +64,7 @@ export class Base_Drawer extends enhance.Base {
|
|||||||
const placement = props.placement || "right";
|
const placement = props.placement || "right";
|
||||||
const header = props.header;
|
const header = props.header;
|
||||||
const scrollKey = props.scrollKey || "Base_Drawer";
|
const scrollKey = props.scrollKey || "Base_Drawer";
|
||||||
|
const width = props.width || "auto";
|
||||||
|
|
||||||
// 按钮样式自动根据 placement 调整
|
// 按钮样式自动根据 placement 调整
|
||||||
const switchStyle = this.getSwitchStyle(placement, token);
|
const switchStyle = this.getSwitchStyle(placement, token);
|
||||||
@@ -83,7 +84,7 @@ export class Base_Drawer extends enhance.Base {
|
|||||||
mask={false}
|
mask={false}
|
||||||
closable={false}
|
closable={false}
|
||||||
getContainer={false}
|
getContainer={false}
|
||||||
width="auto"
|
width={width}
|
||||||
styles={header ? {body: {padding: 0}} : undefined}
|
styles={header ? {body: {padding: 0}} : undefined}
|
||||||
>
|
>
|
||||||
{header ? <div style={{height: "100%", display: "flex", flexDirection: "column"}}>
|
{header ? <div style={{height: "100%", display: "flex", flexDirection: "column"}}>
|
||||||
|
|||||||
@@ -411,15 +411,15 @@ export class Data_Source_Show extends enhance.Base {
|
|||||||
<Title level={5}>{schema.title ?? "数据源配置"}:{ds.key}</Title>
|
<Title level={5}>{schema.title ?? "数据源配置"}:{ds.key}</Title>
|
||||||
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
|
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
|
||||||
section={schema.common} rules={schema.rules} onChange={refresh}/>
|
section={schema.common} rules={schema.rules} onChange={refresh}/>
|
||||||
<div style={{marginTop: 12, paddingTop: 8, borderTop: "1px solid #eee"}}>
|
<div style={{marginTop: 12}}>
|
||||||
|
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
|
||||||
|
section={schema.position} rules={schema.rules} onChange={refresh}/>
|
||||||
|
</div>
|
||||||
|
<div style={{marginTop: 18, paddingTop: 12, borderTop: "1px solid #eee"}}>
|
||||||
<Title level={5}>{schema.display[mode].title}</Title>
|
<Title level={5}>{schema.display[mode].title}</Title>
|
||||||
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
|
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
|
||||||
section={schema.display[mode]} rules={schema.rules} onChange={refresh}/>
|
section={schema.display[mode]} rules={schema.rules} onChange={refresh}/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{marginTop: 16}}>
|
|
||||||
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
|
|
||||||
section={schema.position} rules={schema.rules} onChange={refresh}/>
|
|
||||||
</div>
|
|
||||||
{this.render_aircraft_monitor_config(ds, mode)}
|
{this.render_aircraft_monitor_config(ds, mode)}
|
||||||
<br/>
|
<br/>
|
||||||
<Button type="primary" autoInsertSpace onClick={() => {
|
<Button type="primary" autoInsertSpace onClick={() => {
|
||||||
@@ -453,7 +453,8 @@ export class Data_Source_Show extends enhance.Base {
|
|||||||
{this.render_header_tabs(mode, enabled_list)}
|
{this.render_header_tabs(mode, enabled_list)}
|
||||||
</div>;
|
</div>;
|
||||||
const scroll_key = this.active_tab_key === "source" ? `Data_Source_Show:source:${this.active_data_source_key}` : `Data_Source_Show:${mode}:${this.active_tab_key}`;
|
const scroll_key = this.active_tab_key === "source" ? `Data_Source_Show:source:${this.active_data_source_key}` : `Data_Source_Show:${mode}:${this.active_tab_key}`;
|
||||||
return <this.drawer.x header={header} scrollKey={scroll_key}>
|
return <this.drawer.x header={header} scrollKey={scroll_key}
|
||||||
|
width="min(640px, calc(100vw - 24px))">
|
||||||
{this.render_tab_content(mode, active_ds)}
|
{this.render_tab_content(mode, active_ds)}
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100px',
|
height: '100px',
|
||||||
|
|||||||
+18
-19
@@ -22,19 +22,15 @@ type Aircraft_Column_Model = {
|
|||||||
|
|
||||||
const onHeaderCell = (): HTMLProps<HTMLTableCellElement> => ({
|
const onHeaderCell = (): HTMLProps<HTMLTableCellElement> => ({
|
||||||
style: {
|
style: {
|
||||||
writingMode: "vertical-lr",
|
writingMode: "horizontal-tb",
|
||||||
textOrientation: "upright",
|
whiteSpace: "nowrap",
|
||||||
paddingLeft: 0,
|
paddingLeft: 8,
|
||||||
paddingRight: 0,
|
paddingRight: 8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function render_column_title(label: string) {
|
function render_column_title(label: string) {
|
||||||
return <div>{label.split(/(<[^>]+>)/g).filter(Boolean).map((part, index) =>
|
return <div style={{whiteSpace: "nowrap"}}>{label.replaceAll("<", "").replaceAll(">", "")}</div>;
|
||||||
part.startsWith("<") && part.endsWith(">")
|
|
||||||
? <span style={{writingMode: "horizontal-tb"}} key={index}>{part.slice(1, -1)}</span>
|
|
||||||
: <span key={index}>{part}</span>
|
|
||||||
)}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function compare_values(a: unknown, b: unknown): number {
|
function compare_values(a: unknown, b: unknown): number {
|
||||||
@@ -131,9 +127,10 @@ function create_columns(model: readonly Aircraft_Column_Model[]): TableColumnTyp
|
|||||||
dataIndex: column.key,
|
dataIndex: column.key,
|
||||||
key: column.key,
|
key: column.key,
|
||||||
onHeaderCell,
|
onHeaderCell,
|
||||||
|
width: column.type === "timestamp" ? 190 : Math.max(88, column.label.length * 18 + 44),
|
||||||
defaultSortOrder: normalized_sort_order(column.defaultSortOrder),
|
defaultSortOrder: normalized_sort_order(column.defaultSortOrder),
|
||||||
sorter: (a, b) => compare_values(a[column.key], b[column.key]),
|
sorter: (a, b) => compare_values(a[column.key], b[column.key]),
|
||||||
render: value => <div style={{textAlign: "center", whiteSpace: column.type === "timestamp" ? "pre-line" : undefined}}>
|
render: value => <div style={{textAlign: "center", whiteSpace: column.type === "timestamp" ? "pre" : "nowrap"}}>
|
||||||
{value == null ? "" : typeof value === "boolean" ? (value ? "是" : "否") : String(value)}
|
{value == null ? "" : typeof value === "boolean" ? (value ? "是" : "否") : String(value)}
|
||||||
</div>,
|
</div>,
|
||||||
};
|
};
|
||||||
@@ -222,8 +219,8 @@ export class Aircraft_List extends enhance.Base {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <Layout style={{height: "100%"}}>
|
return <Layout style={{height: "100%", minWidth: 0}}>
|
||||||
<Flex gap="middle" vertical>
|
<Flex gap="middle" vertical style={{width: "100%", minWidth: 0}}>
|
||||||
<Flex gap="middle">
|
<Flex gap="middle">
|
||||||
<Select prefix={<Prefix label="数据来源"/>}
|
<Select prefix={<Prefix label="数据来源"/>}
|
||||||
placeholder="请选择数据来源"
|
placeholder="请选择数据来源"
|
||||||
@@ -238,13 +235,15 @@ export class Aircraft_List extends enhance.Base {
|
|||||||
}}/>
|
}}/>
|
||||||
<this.refresh_btn.x/>
|
<this.refresh_btn.x/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Table<Aircraft_Row> dataSource={this.dataSource}
|
<div style={{width: "100%", minWidth: 0, overflowX: "auto"}}>
|
||||||
columns={this.columns}
|
<Table<Aircraft_Row> dataSource={this.dataSource}
|
||||||
showSorterTooltip={false}
|
columns={this.columns}
|
||||||
size="small"
|
showSorterTooltip={false}
|
||||||
scroll={{x: "max-content", y: "calc(100vh - 300px)"}}
|
size="small"
|
||||||
pagination={false}
|
scroll={{x: "max-content", y: "calc(100vh - 300px)"}}
|
||||||
style={{overflowX: "auto"}}/>
|
pagination={false}
|
||||||
|
style={{width: "100%"}}/>
|
||||||
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Layout>;
|
</Layout>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {Button, message, Space, Upload} from "antd";
|
import {Button, Card, message, Select, Space, Upload} from "antd";
|
||||||
import {UploadOutlined} from "@ant-design/icons";
|
import {UploadOutlined} from "@ant-design/icons";
|
||||||
import enhance from "../core/enhance.tsx";
|
import enhance from "../core/enhance.tsx";
|
||||||
import {col_style, row_style, setting_style} from "../Global.tsx";
|
import {col_style, row_style, setting_style} from "../Global.tsx";
|
||||||
@@ -46,6 +46,7 @@ export type Cesium_Model_Settings_Schema = {
|
|||||||
export class Cesium_Model_Settings extends enhance.Base {
|
export class Cesium_Model_Settings extends enhance.Base {
|
||||||
config: Map_Model_Config = empty_map_model_config;
|
config: Map_Model_Config = empty_map_model_config;
|
||||||
schema: Cesium_Model_Settings_Schema;
|
schema: Cesium_Model_Settings_Schema;
|
||||||
|
selected_collection_items: Record<string, string> = {};
|
||||||
|
|
||||||
constructor(schema: Cesium_Model_Settings_Schema) {
|
constructor(schema: Cesium_Model_Settings_Schema) {
|
||||||
super();
|
super();
|
||||||
@@ -96,21 +97,42 @@ export class Cesium_Model_Settings extends enhance.Base {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render_collection(collection: Model_Collection_Schema) {
|
||||||
|
const available = collection.items.filter(item => get_path(this.config, `${collection.path}.${item.key}`));
|
||||||
|
const selected = available.some(item => item.key === this.selected_collection_items[collection.path])
|
||||||
|
? this.selected_collection_items[collection.path]
|
||||||
|
: available[0]?.key;
|
||||||
|
if (!selected) return null;
|
||||||
|
const item = available.find(value => value.key === selected)!;
|
||||||
|
return (
|
||||||
|
<Card key={collection.path} size="small" title={collection.title} style={{width: "100%"}}>
|
||||||
|
<Space direction="vertical" size={12} style={{width: "100%"}}>
|
||||||
|
<Select
|
||||||
|
value={selected}
|
||||||
|
options={available.map(value => ({value: value.key, label: value.label}))}
|
||||||
|
style={{width: "100%", minWidth: 240}}
|
||||||
|
onChange={value => {
|
||||||
|
this.selected_collection_items[collection.path] = value;
|
||||||
|
this.flush();
|
||||||
|
}}/>
|
||||||
|
{this.render_model(item.label, `${collection.path}.${item.key}`, {
|
||||||
|
model_type: collection.upload_model_type,
|
||||||
|
label: collection.upload_label || "上传模型",
|
||||||
|
success_message: collection.upload_success_message
|
||||||
|
}, item.key)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render(props: any) {
|
render(props: any) {
|
||||||
return (
|
return (
|
||||||
<div style={setting_style}>
|
<div style={setting_style}>
|
||||||
<h2 style={row_style}>{this.schema.title ?? "Cesium模型设置"}</h2>
|
<h2 style={row_style}>{this.schema.title ?? "Cesium模型设置"}</h2>
|
||||||
<Space direction="vertical" size={12} style={col_style}>
|
<Space direction="vertical" size={12} style={{...col_style, width: "min(100%, 640px)"}}>
|
||||||
{(this.schema.groups || []).map(group =>
|
{(this.schema.groups || []).map(group =>
|
||||||
this.render_model(group.title, group.path, group.upload))}
|
this.render_model(group.title, group.path, group.upload))}
|
||||||
{(this.schema.collections || []).flatMap(collection => [
|
{(this.schema.collections || []).map(collection => this.render_collection(collection))}
|
||||||
<h3 key={`${collection.path}:title`} style={row_style}>{collection.title}</h3>,
|
|
||||||
...collection.items.map(item => this.render_model(item.label, `${collection.path}.${item.key}`, {
|
|
||||||
model_type: collection.upload_model_type,
|
|
||||||
label: collection.upload_label || "上传模型",
|
|
||||||
success_message: collection.upload_success_message
|
|
||||||
}, item.key))
|
|
||||||
])}
|
|
||||||
<Button type="primary" onClick={() => this.save()}>保存模型配置</Button>
|
<Button type="primary" onClick={() => this.save()}>保存模型配置</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user