mirror of
https://github.com/continew-org/continew-admin-ui.git
synced 2025-09-10 20:57:10 +08:00
refactor(system/storage): 重构存储管理页面,分页列表 => 无分页卡片
This commit is contained in:
@@ -6,8 +6,8 @@ export type * from './type'
|
||||
const BASE_URL = '/system/storage'
|
||||
|
||||
/** @desc 查询存储列表 */
|
||||
export function listStorage(query: T.StoragePageQuery) {
|
||||
return http.get<PageRes<T.StorageResp[]>>(`${BASE_URL}`, query)
|
||||
export function listStorage(query: T.StorageQuery) {
|
||||
return http.get<T.StorageResp[]>(`${BASE_URL}/list`, query)
|
||||
}
|
||||
|
||||
/** @desc 查询存储详情 */
|
||||
@@ -29,3 +29,13 @@ export function updateStorage(data: any, id: string) {
|
||||
export function deleteStorage(id: string) {
|
||||
return http.del(`${BASE_URL}/${id}`)
|
||||
}
|
||||
|
||||
/** @desc 修改存储状态 */
|
||||
export function updateStorageStatus(data: any, id: string) {
|
||||
return http.put(`${BASE_URL}/${id}/status`, data)
|
||||
}
|
||||
|
||||
/** @desc 设置默认存储 */
|
||||
export function setDefaultStorage(id: string) {
|
||||
return http.put(`${BASE_URL}/${id}/default`)
|
||||
}
|
||||
|
@@ -253,11 +253,9 @@ export interface StorageResp {
|
||||
}
|
||||
export interface StorageQuery {
|
||||
description?: string
|
||||
status?: number
|
||||
type?: number
|
||||
sort: Array<string>
|
||||
}
|
||||
export interface StoragePageQuery extends StorageQuery, PageQuery {
|
||||
}
|
||||
|
||||
/** 终端类型 */
|
||||
export interface ClientResp {
|
||||
|
@@ -142,9 +142,9 @@ const dicData: Record<string, any> = reactive({})
|
||||
const getComponentBindProps = (item: ColumnItem) => {
|
||||
// 组件默认配置映射表
|
||||
const ConfigMap = new Map<ColumnItem['type'], Partial<ColumnItem['props'] & { placeholder: string }>>([
|
||||
['input', { allowClear: true, placeholder: `请输入${item.label}`, maxLength: 255 }],
|
||||
['input', { allowClear: true, placeholder: `请输入${item.label}`, maxLength: 255, showWordLimit: true }],
|
||||
['input-number', { placeholder: `请输入${item.label}` }],
|
||||
['textarea', { allowClear: false, placeholder: `请输入${item.label}`, maxLength: 200 }],
|
||||
['textarea', { allowClear: false, placeholder: `请输入${item.label}`, maxLength: 200, showWordLimit: true, autoSize: { minRows: 3, maxRows: 5 } }],
|
||||
['input-tag', { allowClear: true, placeholder: `请输入${item.label}` }],
|
||||
['mention', { allowClear: true, placeholder: `请输入${item.label}` }],
|
||||
['select', { allowClear: true, placeholder: `请选择${item.label}`, options: dicData[item.field] || [] }],
|
||||
@@ -169,7 +169,8 @@ const valueChange = (value: any, field: string) => {
|
||||
/** 表单项校验规则 */
|
||||
const getFormItemRules = (item: ColumnItem) => {
|
||||
if (item.required) {
|
||||
return [{ required: true, message: `${item.label}为必填项` }, ...(Array.isArray(item.rules) ? item.rules : [])]
|
||||
const defaultProps = getComponentBindProps(item)
|
||||
return [{ required: true, message: defaultProps.placeholder || `请输入${item.label}` }, ...(Array.isArray(item.rules) ? item.rules : [])]
|
||||
}
|
||||
return item.rules
|
||||
}
|
||||
|
@@ -213,16 +213,19 @@
|
||||
|
||||
// 卡片标题,标题左侧的伪类样式
|
||||
.gi_card_title {
|
||||
.arco-card-header-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 50%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: rgb(var(--warning-5));
|
||||
border-radius: 0 4px 4px 0;
|
||||
> .arco-card-header {
|
||||
border-bottom: none;
|
||||
.arco-card-header-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 50%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: rgb(var(--primary-5));
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -57,7 +57,12 @@ const change = (key: string | number) => {
|
||||
<style scoped lang="scss">
|
||||
.gi_table_page {
|
||||
overflow-y: auto;
|
||||
|
||||
:deep(.arco-tabs) {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arco-tabs .arco-tabs-nav-type-card-gutter .arco-tabs-tab-active) {
|
||||
box-shadow: inset 0 2px 0 rgb(var(--primary-6)), inset -1px 0 0 var(--color-border-2),
|
||||
inset 1px 0 0 var(--color-border-2);
|
||||
@@ -77,10 +82,6 @@ const change = (key: string | number) => {
|
||||
right: -20px;
|
||||
}
|
||||
|
||||
:deep(.arco-tabs) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-nav) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
@@ -1,198 +0,0 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:visible="visible"
|
||||
:title="title"
|
||||
:mask-closable="false"
|
||||
:esc-to-close="false"
|
||||
:width="width >= 500 ? 500 : '100%'"
|
||||
@before-ok="save"
|
||||
@close="reset"
|
||||
>
|
||||
<a-form ref="formRef" :model="form" :rules="rules" size="large" auto-label-width>
|
||||
<a-form-item label="名称" field="name">
|
||||
<a-input v-model.trim="form.name" placeholder="请输入名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="编码" field="code">
|
||||
<a-input v-model.trim="form.code" placeholder="请输入编码" :disabled="isUpdate" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类型" field="type">
|
||||
<a-select v-model.trim="form.type" :options="storage_type_enum" placeholder="请选择类型" :disabled="isUpdate" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.type === 1" label="访问密钥" field="accessKey">
|
||||
<a-input v-model.trim="form.accessKey" placeholder="请输入访问密钥" :max-length="255" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.type === 1" label="私有密钥" field="secretKey">
|
||||
<a-input
|
||||
v-model.trim="form.secretKey"
|
||||
placeholder="请输入私有密钥"
|
||||
:max-length="255"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.type === 1" label="终端节点" field="endpoint">
|
||||
<a-input v-model.trim="form.endpoint" placeholder="请输入终端节点" />
|
||||
</a-form-item>
|
||||
<a-form-item label="桶名称" field="bucketName">
|
||||
<a-input v-model.trim="form.bucketName" placeholder="请输入桶名称" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.type === 1" label="域名" field="domain">
|
||||
<a-input v-model.trim="form.domain" placeholder="请输入域名" />
|
||||
<template #extra>
|
||||
<div v-if="defaultDomain">
|
||||
<span>留空默认域名:{{ defaultDomain }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="form.type === 2"
|
||||
label="域名"
|
||||
field="domain"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入域名',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<a-input v-model.trim="form.domain" placeholder="请输入域名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序" field="sort">
|
||||
<a-input-number v-model="form.sort" placeholder="请输入排序" :min="1" mode="button" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述" field="description">
|
||||
<a-textarea
|
||||
v-model.trim="form.description"
|
||||
placeholder="请输入描述"
|
||||
show-word-limit
|
||||
:max-length="200"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="默认存储" field="isDefault">
|
||||
<a-switch
|
||||
v-model="form.isDefault"
|
||||
type="round"
|
||||
:checked-value="true"
|
||||
:unchecked-value="false"
|
||||
checked-text="是"
|
||||
unchecked-text="否"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" field="status">
|
||||
<a-switch
|
||||
v-model="form.status"
|
||||
type="round"
|
||||
:checked-value="1"
|
||||
:unchecked-value="2"
|
||||
checked-text="启用"
|
||||
unchecked-text="禁用"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type FormInstance, Message } from '@arco-design/web-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { addStorage, getStorage, updateStorage } from '@/apis/system/storage'
|
||||
import { useResetReactive } from '@/hooks'
|
||||
import { useDict } from '@/hooks/app'
|
||||
import { encryptByRsa } from '@/utils/encrypt'
|
||||
import { isIPv4 } from '@/utils/validate'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const dataId = ref('')
|
||||
const visible = ref(false)
|
||||
const isUpdate = computed(() => !!dataId.value)
|
||||
const title = computed(() => (isUpdate.value ? '修改存储' : '新增存储'))
|
||||
const formRef = ref<FormInstance>()
|
||||
const { storage_type_enum } = useDict('storage_type_enum')
|
||||
|
||||
const rules: FormInstance['rules'] = {
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
code: [{ required: true, message: '请输入编码' }],
|
||||
type: [{ required: true, message: '请选择类型' }],
|
||||
accessKey: [{ required: true, message: '请输入访问密钥' }],
|
||||
secretKey: [{ required: true, message: '请输入私有密钥' }],
|
||||
endpoint: [{ required: true, message: '请输入终端节点' }],
|
||||
bucketName: [{ required: true, message: '请输入桶名称' }],
|
||||
}
|
||||
|
||||
const [form, resetForm] = useResetReactive({
|
||||
type: 2,
|
||||
isDefault: false,
|
||||
sort: 999,
|
||||
status: 1,
|
||||
})
|
||||
/** 获取url的protocol和endpoint */
|
||||
const stripProtocol = (url: string): { endpoint: string, protocol: string } => {
|
||||
if (url.startsWith('http://')) {
|
||||
return { endpoint: url.substring(7), protocol: 'http://' }
|
||||
} else if (url.startsWith('https://')) {
|
||||
return { endpoint: url.substring(8), protocol: 'https://' }
|
||||
}
|
||||
return { endpoint: url, protocol: 'http://' }
|
||||
}
|
||||
/** 按规则拼接当前默认domain */
|
||||
const defaultDomain = computed(() => {
|
||||
const { endpoint: initialEndpoint, bucketName, domain, type } = form
|
||||
if (domain || type !== 1 || !initialEndpoint || !bucketName) {
|
||||
return
|
||||
}
|
||||
const { endpoint, protocol } = stripProtocol(initialEndpoint)
|
||||
return isIPv4(endpoint) ? `${protocol}${endpoint}/${bucketName}/` : `${protocol}${bucketName}.${endpoint}/`
|
||||
})
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
formRef.value?.resetFields()
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 保存
|
||||
const save = async () => {
|
||||
try {
|
||||
const isInvalid = await formRef.value?.validate()
|
||||
if (isInvalid) return false
|
||||
const data = {
|
||||
...form,
|
||||
secretKey: form.type === 1 && !form.secretKey.includes('*') ? encryptByRsa(form.secretKey) : null,
|
||||
domain: form.domain || defaultDomain.value,
|
||||
}
|
||||
if (isUpdate.value) {
|
||||
await updateStorage(data, dataId.value)
|
||||
Message.success('修改成功')
|
||||
} else {
|
||||
await addStorage(data)
|
||||
Message.success('新增成功')
|
||||
}
|
||||
emit('save-success')
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 新增
|
||||
const onAdd = () => {
|
||||
reset()
|
||||
dataId.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 修改
|
||||
const onUpdate = async (id: string) => {
|
||||
reset()
|
||||
dataId.value = id
|
||||
const { data } = await getStorage(id)
|
||||
Object.assign(form, data)
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
defineExpose({ onAdd, onUpdate })
|
||||
</script>
|
208
src/views/system/storage/StorageAddModal.vue
Normal file
208
src/views/system/storage/StorageAddModal.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:title="title"
|
||||
:mask-closable="false"
|
||||
:esc-to-close="false"
|
||||
:width="width >= 500 ? 500 : '100%'"
|
||||
@before-ok="save"
|
||||
@close="reset"
|
||||
>
|
||||
<GiForm ref="formRef" v-model="form" :columns="columns" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { addStorage, getStorage, updateStorage } from '@/apis/system/storage'
|
||||
import { type ColumnItem, GiForm } from '@/components/GiForm'
|
||||
import { useResetReactive } from '@/hooks'
|
||||
import { useDict } from '@/hooks/app'
|
||||
import { encryptByRsa } from '@/utils/encrypt'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const dataId = ref('')
|
||||
const visible = ref(false)
|
||||
const isUpdate = computed(() => !!dataId.value)
|
||||
const storageType = ref('')
|
||||
const title = computed(() => (isUpdate.value ? `修改${storageType.value}` : `新增${storageType.value}`))
|
||||
const formRef = ref<InstanceType<typeof GiForm>>()
|
||||
const { storage_type_enum } = useDict('storage_type_enum')
|
||||
|
||||
const [form, resetForm] = useResetReactive({
|
||||
type: 2,
|
||||
isDefault: false,
|
||||
sort: 999,
|
||||
status: 2,
|
||||
})
|
||||
|
||||
const columns: ColumnItem[] = reactive([
|
||||
{
|
||||
label: '名称',
|
||||
field: 'name',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
props: {
|
||||
maxLength: 100,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: '编码',
|
||||
field: 'code',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
props: {
|
||||
maxLength: 30,
|
||||
},
|
||||
required: true,
|
||||
disabled: () => isUpdate.value,
|
||||
},
|
||||
{
|
||||
label: 'Access Key',
|
||||
field: 'accessKey',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
required: true,
|
||||
show: () => form.type === 2,
|
||||
},
|
||||
{
|
||||
label: 'Secret Key',
|
||||
field: 'secretKey',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
required: true,
|
||||
show: () => form.type === 2,
|
||||
},
|
||||
{
|
||||
label: 'Endpoint',
|
||||
field: 'endpoint',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
required: true,
|
||||
show: () => form.type === 2,
|
||||
},
|
||||
{
|
||||
label: 'Bucket',
|
||||
field: 'bucketName',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
required: true,
|
||||
show: () => form.type === 2,
|
||||
},
|
||||
{
|
||||
label: '域名',
|
||||
field: 'domain',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
required: true,
|
||||
show: () => form.type === 2,
|
||||
},
|
||||
{
|
||||
label: '存储路径',
|
||||
field: 'bucketName',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
required: true,
|
||||
show: () => form.type === 1,
|
||||
},
|
||||
{
|
||||
label: '访问路径',
|
||||
field: 'domain',
|
||||
type: 'input',
|
||||
span: 24,
|
||||
required: true,
|
||||
show: () => form.type === 1,
|
||||
},
|
||||
{
|
||||
label: '排序',
|
||||
field: 'sort',
|
||||
type: 'input-number',
|
||||
span: 24,
|
||||
props: {
|
||||
min: 1,
|
||||
mode: 'button',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '描述',
|
||||
field: 'description',
|
||||
type: 'textarea',
|
||||
span: 24,
|
||||
props: {
|
||||
maxLength: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
field: 'status',
|
||||
type: 'switch',
|
||||
span: 24,
|
||||
props: {
|
||||
type: 'round',
|
||||
checkedValue: 1,
|
||||
uncheckedValue: 2,
|
||||
checkedText: '启用',
|
||||
uncheckedText: '禁用',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
formRef.value?.formRef?.resetFields()
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 保存
|
||||
const save = async () => {
|
||||
try {
|
||||
const isInvalid = await formRef.value?.formRef?.validate()
|
||||
if (isInvalid) return false
|
||||
if (isUpdate.value) {
|
||||
await updateStorage({
|
||||
...form,
|
||||
secretKey: form.type === 2 && !form.secretKey.includes('*') ? encryptByRsa(form.secretKey) || '' : null,
|
||||
}, dataId.value)
|
||||
Message.success('修改成功')
|
||||
} else {
|
||||
await addStorage({
|
||||
...form,
|
||||
secretKey: form.type === 2 ? encryptByRsa(form.secretKey) || '' : form.secretKey,
|
||||
})
|
||||
Message.success('新增成功')
|
||||
}
|
||||
emit('save-success')
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 新增
|
||||
const onAdd = (type: number) => {
|
||||
reset()
|
||||
dataId.value = ''
|
||||
form.type = type
|
||||
storageType.value = storage_type_enum.value.find((item) => item.value === type)?.label || '本地存储'
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 修改
|
||||
const onUpdate = async (id: string) => {
|
||||
reset()
|
||||
dataId.value = id
|
||||
const { data } = await getStorage(id)
|
||||
Object.assign(form, data)
|
||||
storageType.value = storage_type_enum.value.find((item) => item.value === form.type)?.label || '本地存储'
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
defineExpose({ onAdd, onUpdate })
|
||||
</script>
|
78
src/views/system/storage/StorageLocal.vue
Normal file
78
src/views/system/storage/StorageLocal.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="list-wrap">
|
||||
<a-row class="list-row" :gutter="24">
|
||||
<a-col
|
||||
v-if="has.hasPermOr(['system:storage:add'])"
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="12"
|
||||
:lg="8"
|
||||
:xl="8"
|
||||
:xxl="6"
|
||||
class="list-col"
|
||||
style="min-height: 162px"
|
||||
>
|
||||
<CardAdd :type="1" @save-success="search" />
|
||||
</a-col>
|
||||
<a-empty v-if="!data.length && !has.hasPermOr(['system:storage:add'])" />
|
||||
<a-col
|
||||
v-for="item in data"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="12"
|
||||
:lg="8"
|
||||
:xl="8"
|
||||
:xxl="6"
|
||||
class="list-col"
|
||||
style="min-height: 162px"
|
||||
>
|
||||
<CardBlock
|
||||
:loading="loading"
|
||||
:data="item"
|
||||
@save-success="search"
|
||||
>
|
||||
<template #content>
|
||||
<a-skeleton v-if="loading" :animation="true">
|
||||
<a-skeleton-line :widths="['60%']" :rows="2" />
|
||||
</a-skeleton>
|
||||
<a-descriptions v-else :column="1">
|
||||
<a-descriptions-item label="存储路径">{{ item.bucketName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="访问路径">{{ item.domain }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</CardBlock>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CardAdd from './components/CardAdd.vue'
|
||||
import CardBlock from './components/CardBlock.vue'
|
||||
import type { StorageResp } from '@/apis'
|
||||
import has from '@/utils/has'
|
||||
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
data: {
|
||||
type: Array<StorageResp>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const search = () => {
|
||||
emit('save-success')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
80
src/views/system/storage/StorageOss.vue
Normal file
80
src/views/system/storage/StorageOss.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="list-wrap">
|
||||
<a-row class="list-row" :gutter="24">
|
||||
<a-col
|
||||
v-if="has.hasPermOr(['system:storage:add'])"
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="12"
|
||||
:lg="8"
|
||||
:xl="8"
|
||||
:xxl="6"
|
||||
class="list-col"
|
||||
style="min-height: 162px"
|
||||
>
|
||||
<CardAdd :type="2" @save-success="search" />
|
||||
</a-col>
|
||||
<a-empty v-if="!data.length && !has.hasPermOr(['system:storage:add'])" />
|
||||
<a-col
|
||||
v-for="item in data"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="12"
|
||||
:lg="8"
|
||||
:xl="8"
|
||||
:xxl="6"
|
||||
class="list-col"
|
||||
style="min-height: 162px"
|
||||
>
|
||||
<CardBlock
|
||||
:loading="loading"
|
||||
:data="item"
|
||||
@save-success="search"
|
||||
>
|
||||
<template #content>
|
||||
<a-skeleton v-if="loading" :animation="true">
|
||||
<a-skeleton-line :widths="['60%']" :rows="3" />
|
||||
</a-skeleton>
|
||||
<a-descriptions v-else :column="1">
|
||||
<a-descriptions-item label="Access Key"><CellCopy :content="item.accessKey" /></a-descriptions-item>
|
||||
<a-descriptions-item label="Endpoint">{{ item.endpoint }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Bucket">{{ item.bucketName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="自定义域名">{{ item.domain }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</CardBlock>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CardAdd from './components/CardAdd.vue'
|
||||
import CardBlock from './components/CardBlock.vue'
|
||||
import type { StorageResp } from '@/apis'
|
||||
import has from '@/utils/has'
|
||||
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
data: {
|
||||
type: Array<StorageResp>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const search = () => {
|
||||
emit('save-success')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
92
src/views/system/storage/components/CardAdd.vue
Normal file
92
src/views/system/storage/components/CardAdd.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<a-card
|
||||
:bordered="true"
|
||||
size="small"
|
||||
class="card-block add-card"
|
||||
:class="{ 'card-large': type === 2 }"
|
||||
@click="onAdd"
|
||||
>
|
||||
<div class="content">
|
||||
<div class="add-icon">
|
||||
<icon-plus />
|
||||
</div>
|
||||
<div class="description">点击创建{{ type === 1 ? '本地存储' : '对象存储' }}</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<StorageAddModal ref="StorageAddModalRef" @save-success="search" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import StorageAddModal from '../StorageAddModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const search = () => {
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
const StorageAddModalRef = ref<InstanceType<typeof StorageAddModal>>()
|
||||
// 新增
|
||||
const onAdd = () => {
|
||||
StorageAddModalRef.value?.onAdd(props.type)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.card-block {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: none;
|
||||
height: auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-descriptions-item-value) {
|
||||
color: var(--color-text-2);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-card {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
||||
.add-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 16px;
|
||||
color: var(--color-text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
:deep(.arco-card-body) {
|
||||
padding-top: 45px;
|
||||
padding-bottom: 63px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-large {
|
||||
:deep(.arco-card-body) {
|
||||
padding-top: 65px;
|
||||
padding-bottom: 73px;
|
||||
}
|
||||
}
|
||||
</style>
|
283
src/views/system/storage/components/CardBlock.vue
Normal file
283
src/views/system/storage/components/CardBlock.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<a-card
|
||||
:bordered="true"
|
||||
size="small"
|
||||
class="card-block"
|
||||
>
|
||||
<template v-if="loading" #title>
|
||||
<a-skeleton :animation="true" class="card-block-skeleton">
|
||||
<a-skeleton-line :widths="['40%']" :rows="1" />
|
||||
</a-skeleton>
|
||||
</template>
|
||||
<template v-else #title>
|
||||
<div class="title">
|
||||
{{ data.name }} ({{ data.code }})
|
||||
<div v-if="data.isDefault" class="status">
|
||||
<a-tag size="small" color="arcoblue">
|
||||
<template #icon>
|
||||
<icon-check-circle-fill />
|
||||
</template>
|
||||
<span>默认存储</span>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="has.hasPermOr(['system:storage:setDefault', 'system:storage:update', 'system:storage:delete'])" class="more">
|
||||
<a-dropdown>
|
||||
<icon-more />
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-permission="['system:storage:setDefault']"
|
||||
:disabled="data.isDefault"
|
||||
:title="data.isDefault ? '该存储已设为默认存储' : ''"
|
||||
@click="onSetDefault(data)"
|
||||
>
|
||||
<icon-check-circle />
|
||||
设为默认
|
||||
</a-doption>
|
||||
<a-doption v-permission="['system:storage:update']" @click="onUpdate(data)">
|
||||
<icon-edit />
|
||||
修改
|
||||
</a-doption>
|
||||
<a-doption
|
||||
v-permission="['system:storage:delete']"
|
||||
class="danger"
|
||||
:disabled="data.isDefault"
|
||||
:title="data.isDefault ? '不允许删除默认存储' : ''"
|
||||
@click="onDelete(data)"
|
||||
>
|
||||
<icon-delete />
|
||||
删除
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time">{{ data.createTime }}</div>
|
||||
</template>
|
||||
<div :class="data.type === 1 ? 'content' : 'content-large'">
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
<div class="extra">
|
||||
<a-skeleton v-if="loading" :animation="true">
|
||||
<a-skeleton-line :rows="2" />
|
||||
</a-skeleton>
|
||||
<a-switch
|
||||
v-else
|
||||
v-model="status"
|
||||
:disabled="!has.hasPermOr(['system:storage:updateStatus']) || data.isDefault"
|
||||
:title="data.isDefault ? '不允许禁用默认存储' : ''"
|
||||
:loading="switchLoading"
|
||||
:checked-value="1"
|
||||
:unchecked-value="2"
|
||||
:before-change="onUpdateStatus"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<StorageAddModal ref="StorageAddModalRef" @save-success="search" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import StorageAddModal from '../StorageAddModal.vue'
|
||||
import has from '@/utils/has'
|
||||
import { type StorageResp, deleteStorage, setDefaultStorage, updateStorageStatus } from '@/apis/system'
|
||||
import { useDict } from '@/hooks/app'
|
||||
|
||||
interface Props {
|
||||
loading: boolean
|
||||
data: StorageResp
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const search = () => {
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
const { storage_type_enum } = useDict('storage_type_enum')
|
||||
const storageType = computed(() => {
|
||||
return storage_type_enum.value.find((item) => item.value === props.data.type)?.label || '本地存储'
|
||||
})
|
||||
|
||||
const status = ref(props.data.status)
|
||||
const switchLoading = ref(false)
|
||||
// 更新状态
|
||||
const onUpdateStatus = async (newValue: number) => {
|
||||
const tip = newValue === 1 ? '启用' : '禁用'
|
||||
switchLoading.value = true
|
||||
Modal.warning({
|
||||
title: '提示',
|
||||
content: `是否确定${tip}${storageType.value}「${props.data.name}(${props.data.code})]?`,
|
||||
hideCancel: false,
|
||||
maskClosable: false,
|
||||
onCancel: async () => {
|
||||
switchLoading.value = false
|
||||
status.value = newValue === 1 ? 2 : 1
|
||||
},
|
||||
onBeforeOk: async () => {
|
||||
try {
|
||||
const res = await updateStorageStatus({
|
||||
status: newValue,
|
||||
}, props.data.id)
|
||||
if (res.success) {
|
||||
Message.success(`${tip}成功`)
|
||||
search()
|
||||
}
|
||||
return res.success
|
||||
} catch (error) {
|
||||
status.value = newValue === 1 ? 2 : 1
|
||||
return false
|
||||
} finally {
|
||||
switchLoading.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 设为默认
|
||||
const onSetDefault = (record: StorageResp) => {
|
||||
Modal.warning({
|
||||
title: '提示',
|
||||
content: `是否确定将${storageType.value}「${record.name}(${record.code})」设为默认存储?`,
|
||||
hideCancel: false,
|
||||
maskClosable: false,
|
||||
onBeforeOk: async () => {
|
||||
try {
|
||||
const res = await setDefaultStorage(record.id)
|
||||
if (res.success) {
|
||||
Message.success('设置成功')
|
||||
search()
|
||||
}
|
||||
return res.success
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
const onDelete = (record: StorageResp) => {
|
||||
Modal.warning({
|
||||
title: '提示',
|
||||
content: `是否确定删除存储「${record.name}(${record.code})」?`,
|
||||
okButtonProps: { status: 'danger' },
|
||||
hideCancel: false,
|
||||
maskClosable: false,
|
||||
onBeforeOk: async () => {
|
||||
try {
|
||||
const res = await deleteStorage(record.id)
|
||||
if (res.success) {
|
||||
Message.success('删除成功')
|
||||
search()
|
||||
}
|
||||
return res.success
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const StorageAddModalRef = ref<InstanceType<typeof StorageAddModal>>()
|
||||
|
||||
// 修改
|
||||
const onUpdate = (record: StorageResp) => {
|
||||
StorageAddModalRef.value?.onUpdate(record.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.card-block {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: none;
|
||||
height: auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
line-height: 24px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
.icon {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
color: var(--color-white);
|
||||
background: #626aea;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.more {
|
||||
color: var(--color-text-4);
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.time,
|
||||
.content > :deep(.arco-typography),
|
||||
:deep(.arco-descriptions-item-label),
|
||||
:deep(.arco-descriptions-item-value) {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
padding: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
:deep(.arco-descriptions-item-value) {
|
||||
color: var(--color-text-2);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.content-large {
|
||||
height: 78px;
|
||||
}
|
||||
|
||||
.extra {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&-skeleton {
|
||||
:deep(.arco-skeleton-content .arco-skeleton-text-row:not(:last-child)) {
|
||||
height: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-block:hover {
|
||||
box-shadow: 4px 4px 10px rgba(0, 0, 0, 10%);
|
||||
|
||||
.title {
|
||||
.more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,153 +1,88 @@
|
||||
<template>
|
||||
<div class="gi_table_page">
|
||||
<GiTable
|
||||
title=""
|
||||
row-key="id"
|
||||
:data="dataList"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:scroll="{ x: '100%', y: '100%', minWidth: 1300 }"
|
||||
:pagination="pagination"
|
||||
:disabled-tools="['size']"
|
||||
:disabled-column-keys="['name']"
|
||||
@refresh="search"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-input-search v-model="queryForm.description" placeholder="搜索名称/编码/描述" allow-clear @search="search" />
|
||||
<a-select
|
||||
v-model="queryForm.status"
|
||||
:options="DisEnableStatusList"
|
||||
placeholder="请选择状态"
|
||||
<a-tabs v-model:active-key="activeKey" type="rounded" @change="change">
|
||||
<a-tab-pane key="all">
|
||||
<template #title>全部</template>
|
||||
<a-card title="本地存储" :bordered="false" class="gi_card_title">
|
||||
<StorageLocal :data="dataMap['1']" :loading="loading" @save-success="getDataList" />
|
||||
</a-card>
|
||||
<a-card title="对象存储" :bordered="false" class="gi_card_title">
|
||||
<StorageOss :data="dataMap['2']" :loading="loading" @save-success="getDataList" />
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="1">
|
||||
<template #title>本地存储</template>
|
||||
<StorageLocal :data="dataMap['1']" :loading="loading" @save-success="getDataList" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="2">
|
||||
<template #title>对象存储</template>
|
||||
<StorageOss :data="dataMap['2']" :loading="loading" @save-success="getDataList" />
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-input-search
|
||||
v-model="queryForm.description"
|
||||
placeholder="搜索名称/编码"
|
||||
style="width: 240px;"
|
||||
allow-clear
|
||||
style="width: 150px"
|
||||
@change="search"
|
||||
@search="getDataList"
|
||||
/>
|
||||
<a-button @click="reset">
|
||||
<template #icon><icon-refresh /></template>
|
||||
<template #default>重置</template>
|
||||
</a-button>
|
||||
</template>
|
||||
<template #toolbar-right>
|
||||
<a-button v-permission="['system:storage:add']" type="primary" @click="onAdd">
|
||||
<template #icon><icon-plus /></template>
|
||||
<template #default>新增</template>
|
||||
</a-button>
|
||||
</template>
|
||||
<template #name="{ record }">
|
||||
<a-space fill>
|
||||
<span>{{ record.name }}</span>
|
||||
<a-tag v-if="record.isDefault" color="arcoblue" size="small" class="gi_round">
|
||||
<template #default>默认</template>
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #type="{ record }">
|
||||
<GiCellTag :value="record.type" :dict="storage_type_enum" />
|
||||
</template>
|
||||
<template #status="{ record }">
|
||||
<GiCellStatus :status="record.status" />
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<a-space>
|
||||
<a-link v-permission="['system:storage:update']" title="修改" @click="onUpdate(record)">修改</a-link>
|
||||
<a-link
|
||||
v-permission="['system:storage:delete']"
|
||||
status="danger"
|
||||
:disabled="record.isDefault"
|
||||
:title="record.isDefault ? '默认存储不能删除' : '删除'"
|
||||
@click="onDelete(record)"
|
||||
>
|
||||
删除
|
||||
</a-link>
|
||||
</a-space>
|
||||
</template>
|
||||
</GiTable>
|
||||
|
||||
<StorageAddDrawer ref="StorageAddDrawerRef" @save-success="search" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StorageAddDrawer from './StorageAddDrawer.vue'
|
||||
import { type StorageQuery, type StorageResp, deleteStorage, listStorage } from '@/apis/system/storage'
|
||||
import type { TableInstanceColumns } from '@/components/GiTable/type'
|
||||
import { DisEnableStatusList } from '@/constant/common'
|
||||
import { useTable } from '@/hooks'
|
||||
import { useDict } from '@/hooks/app'
|
||||
import { isMobile } from '@/utils'
|
||||
import has from '@/utils/has'
|
||||
import { groupBy } from 'xe-utils'
|
||||
import StorageLocal from './StorageLocal.vue'
|
||||
import StorageOss from './StorageOss.vue'
|
||||
import { type StorageQuery, type StorageResp, listStorage } from '@/apis'
|
||||
|
||||
defineOptions({ name: 'SystemStorage' })
|
||||
|
||||
const { storage_type_enum } = useDict('storage_type_enum')
|
||||
|
||||
const queryForm = reactive<StorageQuery>({
|
||||
sort: ['createTime,desc'],
|
||||
})
|
||||
|
||||
const {
|
||||
tableData: dataList,
|
||||
loading,
|
||||
pagination,
|
||||
search,
|
||||
handleDelete,
|
||||
} = useTable((page) => listStorage({ ...queryForm, ...page }), { immediate: true })
|
||||
const columns: TableInstanceColumns[] = [
|
||||
{
|
||||
title: '序号',
|
||||
width: 66,
|
||||
align: 'center',
|
||||
render: ({ rowIndex }) => h('span', {}, rowIndex + 1 + (pagination.current - 1) * pagination.pageSize),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', slotName: 'name', width: 140, ellipsis: true, tooltip: true },
|
||||
{ title: '编码', dataIndex: 'code', ellipsis: true, tooltip: true },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', align: 'center' },
|
||||
{ title: '类型', dataIndex: 'type', slotName: 'type', align: 'center', ellipsis: true, tooltip: true },
|
||||
{ title: '访问密钥', dataIndex: 'accessKey', ellipsis: true, tooltip: true },
|
||||
{ title: '终端节点', dataIndex: 'endpoint', ellipsis: true, tooltip: true },
|
||||
{ title: '桶名称', dataIndex: 'bucketName', ellipsis: true, tooltip: true },
|
||||
{ title: '域名', dataIndex: 'domain', ellipsis: true, tooltip: true },
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true, tooltip: true },
|
||||
{ title: '创建人', dataIndex: 'createUserString', ellipsis: true, tooltip: true, show: false },
|
||||
{ title: '创建时间', dataIndex: 'createTime', width: 180 },
|
||||
{ title: '修改人', dataIndex: 'updateUserString', ellipsis: true, tooltip: true, show: false },
|
||||
{ title: '修改时间', dataIndex: 'updateTime', width: 180, show: false },
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
slotName: 'action',
|
||||
width: 130,
|
||||
align: 'center',
|
||||
fixed: !isMobile() ? 'right' : undefined,
|
||||
show: has.hasPermOr(['system:storage:update', 'system:storage:delete']),
|
||||
},
|
||||
]
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
queryForm.description = undefined
|
||||
queryForm.status = undefined
|
||||
search()
|
||||
const loading = ref(false)
|
||||
const dataMap = ref<Record<string, StorageResp[]>>({})
|
||||
// 查询列表
|
||||
const getDataList = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { data } = await listStorage(queryForm)
|
||||
dataMap.value = groupBy(data, 'type')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const onDelete = (record: StorageResp) => {
|
||||
return handleDelete(() => deleteStorage(record.id), {
|
||||
content: `是否确定删除存储「${record.name}」?`,
|
||||
showModal: true,
|
||||
})
|
||||
const activeKey = ref('all')
|
||||
const change = (key: string | number) => {
|
||||
activeKey.value = key as string
|
||||
queryForm.type = key === 'all' ? undefined : key
|
||||
getDataList()
|
||||
}
|
||||
|
||||
const StorageAddDrawerRef = ref<InstanceType<typeof StorageAddDrawer>>()
|
||||
// 新增
|
||||
const onAdd = () => {
|
||||
StorageAddDrawerRef.value?.onAdd()
|
||||
}
|
||||
|
||||
// 修改
|
||||
const onUpdate = (record: StorageResp) => {
|
||||
StorageAddDrawerRef.value?.onUpdate(record.id)
|
||||
}
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.gi_table_page {
|
||||
overflow-y: auto;
|
||||
|
||||
:deep(.arco-tabs) {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.gi_card_title > .arco-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,14 +0,0 @@
|
||||
export interface StorageReq {
|
||||
name: string
|
||||
code: string
|
||||
type: number
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
endpoint: string
|
||||
bucketName: string
|
||||
domain: string
|
||||
sort: number
|
||||
description: string
|
||||
isDefault: boolean
|
||||
status: 1 | 2
|
||||
}
|
Reference in New Issue
Block a user