feat: add tenant-aware IM management console
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<n-modal v-model:show="showModal" preset="card" title="批次详情" class="batch-view-modal">
|
||||
<n-descriptions :column="4" bordered size="small">
|
||||
<n-descriptions-item label="批次ID">{{ detail.batchId || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="总数">{{ detail.quantity || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="有效">{{ detail.activeCount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="累计注册次数">{{ detail.usageCount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="已撤销">{{ detail.revokedCount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="已过期">{{ detail.expiredCount || 0 }}</n-descriptions-item>
|
||||
<n-descriptions-item label="到期时间">{{ detail.expiresAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ detail.createdAt || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-data-table :columns="columns" :data="detail.list || []" :pagination="{ pageSize: 10 }" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BatchView } from '@/api/im/registrationInvite';
|
||||
|
||||
const message = useMessage();
|
||||
const showModal = ref(false);
|
||||
const detail = ref<any>({});
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id' },
|
||||
{ title: '邀请码', key: 'code' },
|
||||
{ title: '状态', key: 'status' },
|
||||
{ title: '累计使用次数', key: 'usageCount' },
|
||||
{ title: '最后使用时间', key: 'lastUsedAt' },
|
||||
];
|
||||
|
||||
async function openModal(tenantId: number, batchId: number) {
|
||||
showModal.value = true;
|
||||
try {
|
||||
detail.value = await BatchView({ tenantId, batchId });
|
||||
} catch (error: any) {
|
||||
message.error(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openModal });
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.batch-view-modal {
|
||||
width: min(1000px, 92vw);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<n-modal v-model:show="showModal" preset="dialog" :title="isBatch ? '批量生成邀请码' : '生成邀请码'">
|
||||
<n-form ref="formRef" :model="formValue" :rules="rules" label-placement="left" :label-width="90">
|
||||
<n-form-item v-if="isBatch" label="生成数量" path="quantity">
|
||||
<n-input-number v-model:value="formValue.quantity" :min="1" :max="1000" />
|
||||
</n-form-item>
|
||||
<n-form-item label="到期时间" path="expiresAt">
|
||||
<n-date-picker v-model:value="formValue.expiresAt" type="datetime" clearable />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注" path="remark">
|
||||
<n-input v-model:value="formValue.remark" type="textarea" maxlength="255" show-count />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-space justify="end">
|
||||
<n-button @click="closeModal">取消</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="submit">确认</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BatchCreate, Create } from '@/api/im/registrationInvite';
|
||||
import { localRfc3339 } from './model';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
const message = useMessage();
|
||||
const showModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const formRef = ref();
|
||||
const tenantId = ref(0);
|
||||
const isBatch = ref(false);
|
||||
const formValue = reactive({
|
||||
quantity: 100,
|
||||
expiresAt: Date.now() + 7 * 24 * 3600 * 1000,
|
||||
remark: '',
|
||||
idempotencyKey: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
quantity: { required: true, type: 'number', min: 1, max: 1000, message: '数量必须在1到1000之间' },
|
||||
expiresAt: { required: true, type: 'number', message: '请选择到期时间' },
|
||||
};
|
||||
|
||||
function openModal(id: number, batch = false) {
|
||||
tenantId.value = id;
|
||||
isBatch.value = batch;
|
||||
formValue.quantity = 100;
|
||||
formValue.expiresAt = Date.now() + 7 * 24 * 3600 * 1000;
|
||||
formValue.remark = '';
|
||||
formValue.idempotencyKey = crypto.randomUUID();
|
||||
showModal.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value?.validate();
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
tenantId: tenantId.value,
|
||||
expiresAt: localRfc3339(formValue.expiresAt),
|
||||
remark: formValue.remark,
|
||||
idempotencyKey: formValue.idempotencyKey,
|
||||
};
|
||||
if (isBatch.value) {
|
||||
await BatchCreate({ ...params, quantity: formValue.quantity });
|
||||
} else {
|
||||
await Create(params);
|
||||
}
|
||||
message.success('生成成功');
|
||||
closeModal();
|
||||
emit('reload');
|
||||
} catch (error: any) {
|
||||
message.error(error.toString());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openModal });
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM注册邀请码"></n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="tableColumns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handleCreate(false)" v-if="hasPermission(['/imInvite/create'])">
|
||||
添加
|
||||
</n-button>
|
||||
<n-button type="primary" @click="handleCreate(true)" v-if="hasPermission(['/imInvite/batchCreate'])">
|
||||
批量生成
|
||||
</n-button>
|
||||
<n-button @click="handleExport" v-if="hasPermission(['/imInvite/export'])">导出</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
<CreateModal ref="createRef" @reload="reloadTable" />
|
||||
<BatchViewModal ref="batchViewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { NTag, useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { Export, List, Revoke } from '@/api/im/registrationInvite';
|
||||
import {
|
||||
columns,
|
||||
loadOptions,
|
||||
loadTenantOptions,
|
||||
schemas,
|
||||
State,
|
||||
statusLabel,
|
||||
statusOptions,
|
||||
tenantOptions,
|
||||
} from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import CreateModal from './CreateModal.vue';
|
||||
import BatchViewModal from './BatchViewModal.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const userStore = useUserStore();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const createRef = ref();
|
||||
const batchViewRef = ref();
|
||||
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
|
||||
|
||||
const tableColumns = computed(() =>
|
||||
columns.map((item: any) => {
|
||||
if (item.key !== 'status') {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
render(row: State) {
|
||||
const option: any = statusOptions.find((status) => status.value === row.status);
|
||||
return h(NTag as any, { type: option?.type || 'default', bordered: false }, () =>
|
||||
statusLabel(row.status)
|
||||
);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 220,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '批次',
|
||||
onClick: handleBatchView.bind(null, record),
|
||||
auth: ['/imInvite/batchView'],
|
||||
ifShow: () => !!record.batchId,
|
||||
},
|
||||
{
|
||||
label: '撤销',
|
||||
type: 'error',
|
||||
onClick: handleRevoke.bind(null, record),
|
||||
auth: ['/imInvite/revoke'],
|
||||
ifShow: () => record.status === 'active',
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
function selectedTenantId() {
|
||||
if (!isPlatformAdmin.value) {
|
||||
return 0;
|
||||
}
|
||||
return Number(searchFormRef.value?.formModel?.tenantId || 0);
|
||||
}
|
||||
|
||||
function handleCreate(batch: boolean) {
|
||||
const tenantId = selectedTenantId();
|
||||
if (isPlatformAdmin.value && !tenantId) {
|
||||
message.warning('请先选择租户');
|
||||
return;
|
||||
}
|
||||
createRef.value.openModal(tenantId, batch);
|
||||
}
|
||||
|
||||
function handleBatchView(record: State) {
|
||||
batchViewRef.value.openModal(Number(record.tenantId || selectedTenantId()), record.batchId);
|
||||
}
|
||||
|
||||
function handleRevoke(record: State) {
|
||||
dialog.warning({
|
||||
title: '确认撤销',
|
||||
content: '撤销后该邀请码不能继续使用,是否确认?',
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await Revoke({ tenantId: Number(record.tenantId || selectedTenantId()), id: record.id });
|
||||
message.success('撤销成功');
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const params = { ...searchFormRef.value?.formModel };
|
||||
const response: any = await Export(params);
|
||||
const blob = response?.data || response;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `registration-invite-codes-${Date.now()}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
if (isPlatformAdmin.value) {
|
||||
await loadTenantOptions();
|
||||
await setFieldsValue({ tenantId: null });
|
||||
if (tenantOptions.value.length === 0) {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
}
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,144 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const tenantOptions = ref<any[]>([]);
|
||||
|
||||
export class State {
|
||||
public id = '';
|
||||
public tenantId = '';
|
||||
public tenantName = '';
|
||||
public batchId = '';
|
||||
public code = '';
|
||||
public status = '';
|
||||
public expiresAt = '';
|
||||
public usageCount = 0;
|
||||
public lastUsedAt = '';
|
||||
public createdBy = '';
|
||||
public remark = '';
|
||||
public createdAt = '';
|
||||
}
|
||||
|
||||
export const statusOptions = [
|
||||
{ label: '有效', value: 'active', type: 'success' },
|
||||
{ label: '已过期', value: 'expired', type: 'warning' },
|
||||
{ label: '已撤销', value: 'revoked', type: 'error' },
|
||||
];
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'inviteCode',
|
||||
component: 'NInput',
|
||||
label: '邀请码',
|
||||
componentProps: {
|
||||
placeholder: '完整5位,区分大小写',
|
||||
maxlength: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: statusOptions,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'batchId',
|
||||
component: 'NInput',
|
||||
label: '批次ID',
|
||||
componentProps: {
|
||||
placeholder: '请输入批次ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdBy',
|
||||
component: 'NInput',
|
||||
label: '创建人',
|
||||
componentProps: {
|
||||
placeholder: '请输入创建人ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'expiresAt',
|
||||
component: 'NDatePicker',
|
||||
label: '到期时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const columns = [
|
||||
{ title: 'ID', key: 'id', align: 'left', width: 150 },
|
||||
{ title: '租户', key: 'tenantName', align: 'left', width: 160 },
|
||||
{ title: '邀请码', key: 'code', align: 'left', width: 120 },
|
||||
{ title: '状态', key: 'status', align: 'left', width: 110 },
|
||||
{ title: '批次ID', key: 'batchId', align: 'left', width: 150 },
|
||||
{ title: '到期时间', key: 'expiresAt', align: 'left', width: 190 },
|
||||
{ title: '累计使用次数', key: 'usageCount', align: 'left', width: 130 },
|
||||
{ title: '最后使用时间', key: 'lastUsedAt', align: 'left', width: 190 },
|
||||
{ title: '创建人', key: 'createdBy', align: 'left', width: 110 },
|
||||
{ title: '创建时间', key: 'createdAt', align: 'left', width: 190 },
|
||||
{ title: '备注', key: 'remark', align: 'left', width: 220 },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['sys_switch']);
|
||||
}
|
||||
|
||||
export async function loadTenantOptions() {
|
||||
const res = await TenantList({ page: 1, perPage: 20, state: 1 });
|
||||
tenantOptions.value = (res?.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.id,
|
||||
}));
|
||||
return tenantOptions.value;
|
||||
}
|
||||
|
||||
export function statusLabel(status: string) {
|
||||
return statusOptions.find((item) => item.value === status)?.label || status;
|
||||
}
|
||||
|
||||
export function localRfc3339(value: number | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
const pad = (n: number) => `${n}`.padStart(2, '0');
|
||||
const offset = -date.getTimezoneOffset();
|
||||
const sign = offset >= 0 ? '+' : '-';
|
||||
const hours = pad(Math.floor(Math.abs(offset) / 60));
|
||||
const minutes = pad(Math.abs(offset) % 60);
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${hours}:${minutes}`;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM审计日志"></n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="handleReset"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.tenantId + '-' + row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
<ViewModal ref="viewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { List } from '@/api/imAuditLog';
|
||||
import { columns, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import ViewModal from './view.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 100,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imAuditLog/view'] }],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (!params.tenantId) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
const defaults: Recordable = {};
|
||||
if (tenantOptions.value.length > 0) defaults.tenantId = tenantOptions.value[0].value;
|
||||
await setFieldsValue(defaults);
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
reloadTable();
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,119 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
export const tenantOptions = ref<any[]>([]);
|
||||
|
||||
export class State {
|
||||
public tenantId = 0;
|
||||
public tenantName = '';
|
||||
public id = 0;
|
||||
public actorUid = 0;
|
||||
public actorRole = '';
|
||||
public action = '';
|
||||
public targetType = '';
|
||||
public targetId = '';
|
||||
public reason = '';
|
||||
public requestId = '';
|
||||
public ip = '';
|
||||
public userAgent = '';
|
||||
public beforeSnapshot = '';
|
||||
public afterSnapshot = '';
|
||||
public metadata = '';
|
||||
public createdAt = '';
|
||||
|
||||
constructor(state?: Partial<State>) {
|
||||
if (state) Object.assign(this, state);
|
||||
}
|
||||
}
|
||||
|
||||
export function newState(state: State | Record<string, any> | null): State {
|
||||
if (state !== null) {
|
||||
if (state instanceof State) return cloneDeep(state);
|
||||
return new State(state);
|
||||
}
|
||||
return new State();
|
||||
}
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'actorUid',
|
||||
component: 'NInput',
|
||||
label: '操作人UID',
|
||||
componentProps: { placeholder: '请输入操作人UID' },
|
||||
},
|
||||
{
|
||||
field: 'action',
|
||||
component: 'NInput',
|
||||
label: '动作',
|
||||
componentProps: { placeholder: '请输入操作类型' },
|
||||
},
|
||||
{
|
||||
field: 'targetType',
|
||||
component: 'NInput',
|
||||
label: '目标类型',
|
||||
componentProps: { placeholder: '请输入目标类型' },
|
||||
},
|
||||
{
|
||||
field: 'targetId',
|
||||
component: 'NInput',
|
||||
label: '目标ID',
|
||||
componentProps: { placeholder: '请输入目标ID' },
|
||||
},
|
||||
{
|
||||
field: 'requestId',
|
||||
component: 'NInput',
|
||||
label: '请求ID',
|
||||
componentProps: { placeholder: '请输入请求ID' },
|
||||
},
|
||||
{
|
||||
field: 'keyword',
|
||||
component: 'NInput',
|
||||
label: '关键词',
|
||||
componentProps: { placeholder: '请输入动作/目标/原因关键词' },
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '操作时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const columns = [
|
||||
{ title: 'ID', key: 'id', align: 'left', width: 90 },
|
||||
{ title: '操作人UID', key: 'actorUid', align: 'left', width: 120 },
|
||||
{ title: '角色', key: 'actorRole', align: 'left', width: 120 },
|
||||
{ title: '动作', key: 'action', align: 'left', width: 190 },
|
||||
{ title: '目标类型', key: 'targetType', align: 'left', width: 120 },
|
||||
{ title: '目标ID', key: 'targetId', align: 'left', width: 170 },
|
||||
{ title: '原因', key: 'reason', align: 'left', width: 220 },
|
||||
{ title: '请求ID', key: 'requestId', align: 'left', width: 190 },
|
||||
{ title: 'IP', key: 'ip', align: 'left', width: 130 },
|
||||
{ title: '操作时间', key: 'createdAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export async function loadTenantOptions() {
|
||||
const res = await TenantList({ page: 1, perPage: 20 });
|
||||
tenantOptions.value = (res?.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.id,
|
||||
}));
|
||||
return tenantOptions.value;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="'审计详情 #' + formValue.id"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<n-scrollbar style="max-height: 87vh" class="pr-5">
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="操作时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="操作人UID">{{ formValue.actorUid }}</n-descriptions-item>
|
||||
<n-descriptions-item label="角色">{{ formValue.actorRole || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="动作">{{ formValue.action }}</n-descriptions-item>
|
||||
<n-descriptions-item label="请求ID">{{ formValue.requestId || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标类型">{{ formValue.targetType || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标ID">{{ formValue.targetId || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="IP">{{ formValue.ip || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="客户端">{{ formValue.userAgent || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="原因" :span="2">{{ formValue.reason || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="变更前" :span="2">
|
||||
<n-code :code="formValue.beforeSnapshot || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="变更后" :span="2">
|
||||
<n-code :code="formValue.afterSnapshot || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="元数据" :span="2">
|
||||
<n-code :code="formValue.metadata || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeForm">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { View } from '@/api/imAuditLog';
|
||||
import { newState, State } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(980));
|
||||
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
loading.value = true;
|
||||
View({ tenantId: state.tenantId, id: state.id })
|
||||
.then((res) => {
|
||||
formValue.value = newState(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="'群治理 ' + current.topic"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="settings" tab="治理配置">
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="群名称">{{ governance.name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群主ID">{{ governance.ownerUserId || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员数">{{ governance.memberCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="入群审批">{{ governance.joinApproval ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="全员禁言">{{ governance.muteAll ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员上传文件">{{ governance.memberUploadFiles ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员私聊">{{ governance.memberPrivateChat ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员邀请">{{ governance.memberInvite ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大成员数">{{ governance.maxMemberCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大管理员数">{{ governance.maxAdminCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="版本">{{ governance.version || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建人">{{ governance.createdBy || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ governance.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="解散人">{{ governance.dissolvedBy || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="解散时间">{{ governance.dissolvedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="简介" :span="2">{{ governance.description || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="joinRequests" tab="入群申请">
|
||||
<BasicTable
|
||||
ref="joinRequestRef"
|
||||
:columns="joinRequestColumns"
|
||||
:request="joinRequestData"
|
||||
:row-key="(row) => row.requestId"
|
||||
:scroll-x="joinRequestScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="invitations" tab="群邀请">
|
||||
<BasicTable
|
||||
ref="invitationRef"
|
||||
:columns="invitationColumns"
|
||||
:request="invitationData"
|
||||
:row-key="(row) => row.invitationId"
|
||||
:scroll-x="invitationScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="announcements" tab="群公告">
|
||||
<BasicTable
|
||||
ref="announcementRef"
|
||||
:columns="announcementColumns"
|
||||
:request="announcementData"
|
||||
:row-key="(row) => row.announcementId"
|
||||
:scroll-x="announcementScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mutes" tab="成员禁言">
|
||||
<BasicTable
|
||||
ref="muteRef"
|
||||
:columns="muteColumns"
|
||||
:request="muteData"
|
||||
:row-key="(row) => row.topic + '-' + row.userId"
|
||||
:scroll-x="muteScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="logs" tab="管理日志">
|
||||
<BasicTable
|
||||
ref="adminLogRef"
|
||||
:columns="adminLogColumns"
|
||||
:request="adminLogData"
|
||||
:row-key="(row) => row.logId"
|
||||
:scroll-x="adminLogScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeForm">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { BasicTable } from '@/components/Table';
|
||||
import {
|
||||
AdminLogList,
|
||||
AnnouncementList,
|
||||
Governance,
|
||||
InvitationList,
|
||||
JoinRequestList,
|
||||
MuteList,
|
||||
} from '@/api/imGroup';
|
||||
import {
|
||||
adminLogColumns,
|
||||
announcementColumns,
|
||||
GovernanceState,
|
||||
invitationColumns,
|
||||
joinRequestColumns,
|
||||
muteColumns,
|
||||
newState,
|
||||
State,
|
||||
} from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const showModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('settings');
|
||||
const current = ref<State>(newState(null));
|
||||
const governance = ref<GovernanceState>(new GovernanceState());
|
||||
const dialogWidth = computed(() => adaModalWidth(1120));
|
||||
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-');
|
||||
|
||||
const joinRequestRef = ref();
|
||||
const invitationRef = ref();
|
||||
const announcementRef = ref();
|
||||
const muteRef = ref();
|
||||
const adminLogRef = ref();
|
||||
|
||||
const joinRequestScrollX = computed(() => adaTableScrollX(joinRequestColumns, 0));
|
||||
const invitationScrollX = computed(() => adaTableScrollX(invitationColumns, 0));
|
||||
const announcementScrollX = computed(() => adaTableScrollX(announcementColumns, 0));
|
||||
const muteScrollX = computed(() => adaTableScrollX(muteColumns, 0));
|
||||
const adminLogScrollX = computed(() => adaTableScrollX(adminLogColumns, 0));
|
||||
|
||||
function baseParams(res) {
|
||||
return {
|
||||
tenantId: current.value.tenantId,
|
||||
topic: current.value.topic,
|
||||
...res,
|
||||
};
|
||||
}
|
||||
|
||||
async function joinRequestData(res) {
|
||||
return await JoinRequestList(baseParams(res));
|
||||
}
|
||||
|
||||
async function invitationData(res) {
|
||||
return await InvitationList(baseParams(res));
|
||||
}
|
||||
|
||||
async function announcementData(res) {
|
||||
return await AnnouncementList(baseParams(res));
|
||||
}
|
||||
|
||||
async function muteData(res) {
|
||||
return await MuteList(baseParams(res));
|
||||
}
|
||||
|
||||
async function adminLogData(res) {
|
||||
return await AdminLogList(baseParams(res));
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadGovernance() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await Governance({ tenantId: current.value.tenantId, topic: current.value.topic });
|
||||
governance.value = Object.assign(new GovernanceState(), res || {});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reloadActiveTable() {
|
||||
const refs = {
|
||||
joinRequests: joinRequestRef,
|
||||
invitations: invitationRef,
|
||||
announcements: announcementRef,
|
||||
mutes: muteRef,
|
||||
logs: adminLogRef,
|
||||
};
|
||||
refs[activeTab.value]?.value?.reload();
|
||||
}
|
||||
|
||||
function openModal(state: State) {
|
||||
current.value = newState(state);
|
||||
governance.value = new GovernanceState();
|
||||
activeTab.value = 'settings';
|
||||
showModal.value = true;
|
||||
loadGovernance();
|
||||
}
|
||||
|
||||
watch(activeTab, async (value) => {
|
||||
if (!showModal.value || value === 'settings') return;
|
||||
await nextTick();
|
||||
reloadActiveTable();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM群组管理"></n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.tenantId + '-' + row.topic"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
<ViewModal ref="viewRef" />
|
||||
<MembersModal ref="membersRef" />
|
||||
<GovernanceModal ref="governanceRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { List } from '@/api/imGroup';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import ViewModal from './view.vue';
|
||||
import MembersModal from './members.vue';
|
||||
import GovernanceModal from './governance.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
const membersRef = ref();
|
||||
const governanceRef = ref();
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 220,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '详情',
|
||||
onClick: handleView.bind(null, record),
|
||||
auth: ['/imGroup/view'],
|
||||
},
|
||||
{
|
||||
label: '成员',
|
||||
onClick: handleMembers.bind(null, record),
|
||||
auth: ['/imGroup/memberList'],
|
||||
},
|
||||
{
|
||||
label: '治理',
|
||||
onClick: handleGovernance.bind(null, record),
|
||||
auth: ['/imGroup/governance'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => {
|
||||
return adaTableScrollX(columns, actionColumn.width);
|
||||
});
|
||||
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
// 加载表格数据
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (!params.tenantId) {
|
||||
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
}
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
// 重新加载表格数据
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
function handleView(record: Recordable) {
|
||||
viewRef.value.openModal(record);
|
||||
}
|
||||
|
||||
// 查看成员
|
||||
function handleMembers(record: Recordable) {
|
||||
membersRef.value.openModal(record);
|
||||
}
|
||||
|
||||
// 查看群治理
|
||||
function handleGovernance(record: Recordable) {
|
||||
governanceRef.value.openModal(record);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
reloadTable();
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="'群成员 ' + current.topic"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="memberColumns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.userId"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeForm">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { BasicTable } from '@/components/Table';
|
||||
import { MemberList } from '@/api/imGroup';
|
||||
import { memberColumns, newState, State } from './model';
|
||||
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
|
||||
|
||||
const showModal = ref(false);
|
||||
const current = ref<State>(newState(null));
|
||||
const actionRef = ref();
|
||||
const dialogWidth = computed(() => adaModalWidth(980));
|
||||
const scrollX = computed(() => adaTableScrollX(memberColumns, 0));
|
||||
|
||||
// 加载表格数据
|
||||
const loadDataTable = async (res) => {
|
||||
if (!current.value.tenantId || !current.value.topic) {
|
||||
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
}
|
||||
return await MemberList({
|
||||
tenantId: current.value.tenantId,
|
||||
topic: current.value.topic,
|
||||
...res,
|
||||
});
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
}
|
||||
|
||||
// 打开弹窗
|
||||
function openModal(state: State) {
|
||||
current.value = newState(state);
|
||||
showModal.value = true;
|
||||
setTimeout(() => {
|
||||
actionRef.value?.reload();
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
@@ -0,0 +1,460 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const tenantOptions = ref<any[]>([]);
|
||||
|
||||
export class State {
|
||||
public tenantId = 0; // 租户ID
|
||||
public tenantName = ''; // 租户名称
|
||||
public topic = ''; // Topic
|
||||
public state = 0; // 状态
|
||||
public ownerId = 0; // 群主ID
|
||||
public seqId = 0; // 最新消息序号
|
||||
public delId = 0; // 删除序号
|
||||
public subCnt = 0; // 成员数
|
||||
public businessMemberCount = 0; // 业务成员数
|
||||
public touchedAt = ''; // 活跃时间
|
||||
public createdAt = ''; // 创建时间
|
||||
public updatedAt = ''; // 修改时间
|
||||
public publicJson = ''; // 公开资料
|
||||
public trustedJson = ''; // 可信资料
|
||||
public tagsJson = ''; // 标签
|
||||
public auxJson = ''; // 辅助资料
|
||||
public accessJson = ''; // 访问权限
|
||||
public useBt = 0; // 是否开启BT
|
||||
public groupName = ''; // 群名称
|
||||
public avatarFileId = ''; // 头像文件ID
|
||||
public description = ''; // 简介
|
||||
public groupStatus = ''; // 业务状态
|
||||
public joinApproval = 0; // 入群审批
|
||||
public muteAll = 0; // 全员禁言
|
||||
public memberUploadFiles = 0; // 成员上传文件
|
||||
public memberPrivateChat = 0; // 成员私聊
|
||||
public memberInvite = 0; // 成员邀请
|
||||
public maxMemberCount = 0; // 最大成员数
|
||||
public maxAdminCount = 0; // 最大管理员数
|
||||
public settingsVersion = 0; // 治理版本
|
||||
public settingsUpdatedAt = ''; // 治理更新时间
|
||||
|
||||
constructor(state?: Partial<State>) {
|
||||
if (state) {
|
||||
Object.assign(this, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberState {
|
||||
public userId = 0; // 用户ID
|
||||
public nickname = ''; // 昵称
|
||||
public state = 0; // 用户状态
|
||||
public modeWant = ''; // 期望权限
|
||||
public modeGiven = ''; // 授予权限
|
||||
public recvSeqId = 0; // 接收序号
|
||||
public readSeqId = 0; // 已读序号
|
||||
public delId = 0; // 删除序号
|
||||
public deletedAt = ''; // 删除时间
|
||||
public createdAt = ''; // 加入时间
|
||||
public updatedAt = ''; // 修改时间
|
||||
public privateJson = ''; // 私有资料
|
||||
public role = ''; // 业务角色
|
||||
public memberStatus = ''; // 业务状态
|
||||
public groupNickname = ''; // 群昵称
|
||||
public pinned = 0; // 置顶
|
||||
public muteNotifications = 0; // 免打扰
|
||||
public joinSource = ''; // 加入来源
|
||||
public joinedBy = 0; // 邀请/处理人
|
||||
public joinedAt = ''; // 业务加入时间
|
||||
public leftAt = ''; // 离开时间
|
||||
public removedBy = 0; // 移除人
|
||||
public version = 0; // 版本
|
||||
}
|
||||
|
||||
export class GovernanceState {
|
||||
public tenantId = 0;
|
||||
public tenantName = '';
|
||||
public topic = '';
|
||||
public ownerUserId = 0;
|
||||
public name = '';
|
||||
public avatarFileId = '';
|
||||
public description = '';
|
||||
public groupStatus = '';
|
||||
public joinApproval = 0;
|
||||
public muteAll = 0;
|
||||
public memberUploadFiles = 0;
|
||||
public memberPrivateChat = 0;
|
||||
public memberInvite = 0;
|
||||
public maxMemberCount = 0;
|
||||
public maxAdminCount = 0;
|
||||
public memberCount = 0;
|
||||
public version = 0;
|
||||
public createdBy = 0;
|
||||
public dissolvedBy = 0;
|
||||
public dissolvedAt = '';
|
||||
public createdAt = '';
|
||||
public updatedAt = '';
|
||||
}
|
||||
|
||||
export function newState(state: State | Record<string, any> | null): State {
|
||||
if (state !== null) {
|
||||
if (state instanceof State) {
|
||||
return cloneDeep(state);
|
||||
}
|
||||
return new State(state);
|
||||
}
|
||||
return new State();
|
||||
}
|
||||
|
||||
// 表格搜索表单
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'topic',
|
||||
component: 'NInput',
|
||||
label: 'Topic',
|
||||
componentProps: {
|
||||
placeholder: '请输入Topic',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'ownerId',
|
||||
component: 'NInput',
|
||||
label: '群主ID',
|
||||
componentProps: {
|
||||
placeholder: '请输入群主ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'state',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => dict.getOptions('ImGroupStateOptions')),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'groupStatus',
|
||||
component: 'NSelect',
|
||||
label: '业务状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => dict.getOptions('ImGroupStatusOptions')),
|
||||
placeholder: '请选择业务状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'touchedAt',
|
||||
component: 'NDatePicker',
|
||||
label: '活跃时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 表格列
|
||||
export const columns = [
|
||||
{
|
||||
title: 'Topic',
|
||||
key: 'topic',
|
||||
align: 'left',
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'state',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImGroupStateOptions', row.state);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '业务状态',
|
||||
key: 'groupStatus',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImGroupStatusOptions', row.groupStatus) || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '群名称',
|
||||
key: 'groupName',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '群主ID',
|
||||
key: 'ownerId',
|
||||
align: 'left',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '成员数',
|
||||
key: 'businessMemberCount',
|
||||
align: 'left',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '入群审批',
|
||||
key: 'joinApproval',
|
||||
align: 'left',
|
||||
width: 100,
|
||||
render(row: State) {
|
||||
return row.joinApproval ? '开启' : '关闭';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '全员禁言',
|
||||
key: 'muteAll',
|
||||
align: 'left',
|
||||
width: 100,
|
||||
render(row: State) {
|
||||
return row.muteAll ? '开启' : '关闭';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最新序号',
|
||||
key: 'seqId',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '活跃时间',
|
||||
key: 'touchedAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createdAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '修改时间',
|
||||
key: 'updatedAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
export const memberColumns = [
|
||||
{
|
||||
title: '用户ID',
|
||||
key: 'userId',
|
||||
align: 'left',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
key: 'nickname',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'memberStatus',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: MemberState) {
|
||||
return dict.getLabel('ImGroupMemberStatusOptions', row.memberStatus) || dict.getLabel('ImUserStateOptions', row.state);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
key: 'role',
|
||||
align: 'left',
|
||||
width: 100,
|
||||
render(row: MemberState) {
|
||||
return dict.getLabel('ImGroupRoleOptions', row.role);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '群昵称',
|
||||
key: 'groupNickname',
|
||||
align: 'left',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '授予权限',
|
||||
key: 'modeGiven',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '期望权限',
|
||||
key: 'modeWant',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '已读序号',
|
||||
key: 'readSeqId',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '接收序号',
|
||||
key: 'recvSeqId',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '删除时间',
|
||||
key: 'deletedAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '业务加入时间',
|
||||
key: 'joinedAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
export const joinRequestColumns = [
|
||||
{ title: '申请ID', key: 'requestId', align: 'left', width: 180 },
|
||||
{ title: '申请人ID', key: 'applicantUserId', align: 'left', width: 120 },
|
||||
{ title: '邀请人ID', key: 'inviterUserId', align: 'left', width: 120 },
|
||||
{ title: '来源', key: 'source', align: 'left', width: 100 },
|
||||
{ title: '申请消息', key: 'message', align: 'left', width: 220 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row) {
|
||||
return dict.getLabel('ImGroupRequestStatusOptions', row.status);
|
||||
},
|
||||
},
|
||||
{ title: '拒绝原因', key: 'rejectReason', align: 'left', width: 180 },
|
||||
{ title: '处理人', key: 'handledBy', align: 'left', width: 110 },
|
||||
{ title: '处理时间', key: 'handledAt', align: 'left', width: 180 },
|
||||
{ title: '过期时间', key: 'expiresAt', align: 'left', width: 180 },
|
||||
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export const invitationColumns = [
|
||||
{ title: '邀请ID', key: 'invitationId', align: 'left', width: 180 },
|
||||
{ title: '邀请人ID', key: 'inviterUserId', align: 'left', width: 120 },
|
||||
{ title: '目标用户ID', key: 'targetUserId', align: 'left', width: 120 },
|
||||
{ title: '邀请消息', key: 'message', align: 'left', width: 220 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row) {
|
||||
return dict.getLabel('ImGroupRequestStatusOptions', row.status);
|
||||
},
|
||||
},
|
||||
{ title: '结果', key: 'result', align: 'left', width: 110 },
|
||||
{ title: '入群申请ID', key: 'joinRequestId', align: 'left', width: 180 },
|
||||
{ title: '过期时间', key: 'expiresAt', align: 'left', width: 180 },
|
||||
{ title: '处理时间', key: 'handledAt', align: 'left', width: 180 },
|
||||
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export const announcementColumns = [
|
||||
{ title: '公告ID', key: 'announcementId', align: 'left', width: 180 },
|
||||
{ title: '发布人ID', key: 'authorUserId', align: 'left', width: 120 },
|
||||
{ title: '内容', key: 'content', align: 'left', width: 320 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row) {
|
||||
return dict.getLabel('ImGroupAnnouncementStatusOptions', row.status);
|
||||
},
|
||||
},
|
||||
{ title: '版本', key: 'version', align: 'left', width: 90 },
|
||||
{ title: '发布时间', key: 'publishedAt', align: 'left', width: 180 },
|
||||
{ title: '删除人', key: 'deletedBy', align: 'left', width: 110 },
|
||||
{ title: '删除时间', key: 'deletedAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export const muteColumns = [
|
||||
{ title: '用户ID', key: 'userId', align: 'left', width: 120 },
|
||||
{ title: '禁言人', key: 'mutedBy', align: 'left', width: 120 },
|
||||
{ title: '禁言时间', key: 'mutedAt', align: 'left', width: 180 },
|
||||
{ title: '过期时间', key: 'expiresAt', align: 'left', width: 180 },
|
||||
{ title: '原因', key: 'reason', align: 'left', width: 220 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row) {
|
||||
return dict.getLabel('ImGroupMuteStatusOptions', row.status);
|
||||
},
|
||||
},
|
||||
{ title: '解除人', key: 'revokedBy', align: 'left', width: 120 },
|
||||
{ title: '解除时间', key: 'revokedAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export const adminLogColumns = [
|
||||
{ title: '日志ID', key: 'logId', align: 'left', width: 180 },
|
||||
{ title: '操作人', key: 'operatorId', align: 'left', width: 120 },
|
||||
{ title: '目标用户', key: 'targetUserId', align: 'left', width: 120 },
|
||||
{ title: '动作', key: 'action', align: 'left', width: 160 },
|
||||
{ title: '对象类型', key: 'objectType', align: 'left', width: 110 },
|
||||
{ title: '对象ID', key: 'objectId', align: 'left', width: 160 },
|
||||
{ title: '请求ID', key: 'requestId', align: 'left', width: 180 },
|
||||
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
// 加载字典数据选项
|
||||
export function loadOptions() {
|
||||
dict.loadOptions([
|
||||
'ImGroupStateOptions',
|
||||
'ImUserStateOptions',
|
||||
'ImGroupStatusOptions',
|
||||
'ImGroupRoleOptions',
|
||||
'ImGroupMemberStatusOptions',
|
||||
'ImGroupRequestStatusOptions',
|
||||
'ImGroupMuteStatusOptions',
|
||||
'ImGroupAnnouncementStatusOptions',
|
||||
'ImGroupOutboxStatusOptions',
|
||||
]);
|
||||
}
|
||||
|
||||
export async function loadTenantOptions() {
|
||||
const res = await TenantList({ page: 1, perPage: 20 });
|
||||
tenantOptions.value = (res?.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.id,
|
||||
}));
|
||||
return tenantOptions.value;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="'IM群组详情 ' + formValue.topic"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<n-scrollbar style="max-height: 87vh" class="pr-5">
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="Topic">{{ formValue.topic }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群主ID">{{ formValue.ownerId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群名称">{{ formValue.groupName || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员数">{{ formValue.businessMemberCount || formValue.subCnt }}</n-descriptions-item>
|
||||
<n-descriptions-item label="入群审批">{{ formValue.joinApproval ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="全员禁言">{{ formValue.muteAll ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员上传文件">{{ formValue.memberUploadFiles ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员私聊">{{ formValue.memberPrivateChat ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员邀请">{{ formValue.memberInvite ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大成员数">{{ formValue.maxMemberCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大管理员数">{{ formValue.maxAdminCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="治理版本">{{ formValue.settingsVersion || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最新序号">{{ formValue.seqId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="删除序号">{{ formValue.delId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="BT">{{ formValue.useBt ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="活跃时间">{{ formValue.touchedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="治理更新时间">{{ formValue.settingsUpdatedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="简介" :span="2">{{ formValue.description || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="访问权限" :span="2">
|
||||
<n-code :code="formValue.accessJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Public资料" :span="2">
|
||||
<n-code :code="formValue.publicJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Trusted资料" :span="2">
|
||||
<n-code :code="formValue.trustedJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Tags" :span="2">
|
||||
<n-code :code="formValue.tagsJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Aux" :span="2">
|
||||
<n-code :code="formValue.auxJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeForm">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { View } from '@/api/imGroup';
|
||||
import { newState, State } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(960));
|
||||
const stateLabel = computed(() => dict.getLabel('ImGroupStateOptions', formValue.value.state));
|
||||
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', formValue.value.groupStatus) || '-');
|
||||
|
||||
// 关闭弹窗
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 打开弹窗
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
loading.value = true;
|
||||
View({ tenantId: state.tenantId, topic: state.topic })
|
||||
.then((res) => {
|
||||
formValue.value = newState(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM实名审核"></n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.tenantId + '-' + row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
<ViewModal ref="viewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { List, Review } from '@/api/imRealname';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import ViewModal from './view.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imRealname/view'] },
|
||||
{
|
||||
label: '通过',
|
||||
onClick: () => handleReview(record, 'verified'),
|
||||
ifShow: () => record.status === 'pending',
|
||||
auth: ['/imRealname/review'],
|
||||
},
|
||||
{
|
||||
label: '拒绝',
|
||||
onClick: () => handleReject(record),
|
||||
ifShow: () => record.status === 'pending',
|
||||
auth: ['/imRealname/review'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (!params.tenantId) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
function handleReview(record: State, status: string, reviewReason = '') {
|
||||
Review({ tenantId: record.tenantId, id: record.id, status, reviewReason }).then(() => {
|
||||
message.success('操作成功');
|
||||
reloadTable();
|
||||
});
|
||||
}
|
||||
|
||||
function handleReject(record: State) {
|
||||
dialog.warning({
|
||||
title: '拒绝实名审核',
|
||||
content: '确定拒绝该实名提交?拒绝原因将记录为“信息不符合要求”。',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => handleReview(record, 'rejected', '信息不符合要求'),
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
reloadTable();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const tenantOptions = ref<any[]>([]);
|
||||
|
||||
export class State {
|
||||
public tenantId = 0;
|
||||
public tenantName = '';
|
||||
public id = 0;
|
||||
public userId = 0;
|
||||
public submissionId = '';
|
||||
public status = '';
|
||||
public maskedName = '';
|
||||
public maskedIdNumber = '';
|
||||
public idType = '';
|
||||
public idNumberHash = '';
|
||||
public reviewReason = '';
|
||||
public verifiedAt = '';
|
||||
public createdAt = '';
|
||||
public updatedAt = '';
|
||||
|
||||
constructor(state?: Partial<State>) {
|
||||
if (state) Object.assign(this, state);
|
||||
}
|
||||
}
|
||||
|
||||
export function newState(state: State | Record<string, any> | null): State {
|
||||
if (state !== null) {
|
||||
if (state instanceof State) return cloneDeep(state);
|
||||
return new State(state);
|
||||
}
|
||||
return new State();
|
||||
}
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'userId',
|
||||
component: 'NInput',
|
||||
label: '用户ID',
|
||||
componentProps: { placeholder: '请输入用户ID' },
|
||||
},
|
||||
{
|
||||
field: 'submissionId',
|
||||
component: 'NInput',
|
||||
label: '提交ID',
|
||||
componentProps: { placeholder: '请输入提交ID' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => dict.getOptions('ImRealnameStatusOptions')),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const columns = [
|
||||
{ title: 'ID', key: 'id', align: 'left', width: 80 },
|
||||
{ title: '用户ID', key: 'userId', align: 'left', width: 120 },
|
||||
{ title: '提交ID', key: 'submissionId', align: 'left', width: 180 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImRealnameStatusOptions', row.status);
|
||||
},
|
||||
},
|
||||
{ title: '脱敏姓名', key: 'maskedName', align: 'left', width: 120 },
|
||||
{ title: '脱敏证件号', key: 'maskedIdNumber', align: 'left', width: 180 },
|
||||
{ title: '证件类型', key: 'idType', align: 'left', width: 120 },
|
||||
{ title: '审核原因', key: 'reviewReason', align: 'left', width: 220 },
|
||||
{ title: '通过时间', key: 'verifiedAt', align: 'left', width: 180 },
|
||||
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['ImRealnameStatusOptions']);
|
||||
}
|
||||
|
||||
export async function loadTenantOptions() {
|
||||
const res = await TenantList({ page: 1, perPage: 20 });
|
||||
tenantOptions.value = (res?.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.id,
|
||||
}));
|
||||
return tenantOptions.value;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<n-modal v-model:show="showModal" preset="dialog" :show-icon="false" :title="'实名审核详情 #' + formValue.id" :style="{ width: dialogWidth }">
|
||||
<n-spin :show="loading">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="用户ID">{{ formValue.userId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="提交ID">{{ formValue.submissionId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="脱敏姓名">{{ formValue.maskedName || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="脱敏证件">{{ formValue.maskedIdNumber || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="证件类型">{{ formValue.idType || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="证件哈希">{{ formValue.idNumberHash || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="通过时间">{{ formValue.verifiedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="审核原因" :span="2">{{ formValue.reviewReason || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
<template #action>
|
||||
<n-button @click="showModal = false">关闭</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { View } from '@/api/imRealname';
|
||||
import { newState, State } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const showModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(820));
|
||||
const statusLabel = computed(() => dict.getLabel('ImRealnameStatusOptions', formValue.value.status));
|
||||
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
loading.value = true;
|
||||
View({ tenantId: state.tenantId, id: state.id })
|
||||
.then((res) => {
|
||||
formValue.value = newState(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({ openModal });
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="formValue.id > 0 ? '编辑IM租户 #' + formValue.id : '添加IM租户'"
|
||||
:style="{
|
||||
width: dialogWidth,
|
||||
}"
|
||||
>
|
||||
<n-scrollbar style="max-height: 87vh" class="pr-5">
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formValue"
|
||||
:rules="rules"
|
||||
:label-placement="settingStore.isMobile ? 'top' : 'left'"
|
||||
:label-width="110"
|
||||
class="py-4"
|
||||
>
|
||||
<n-grid cols="1 s:1 m:2 l:2 xl:2 2xl:2" responsive="screen">
|
||||
<n-gi span="1">
|
||||
<n-form-item label="租户编码" path="code">
|
||||
<n-input placeholder="请输入租户编码" v-model:value="formValue.code" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-form-item label="租户名称" path="name">
|
||||
<n-input placeholder="请输入租户名称" v-model:value="formValue.name" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-form-item label="状态" path="state">
|
||||
<n-select
|
||||
v-model:value="formValue.state"
|
||||
:options="stateOptions"
|
||||
placeholder="请选择状态"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-form-item label="业务库名" path="dbName">
|
||||
<n-input placeholder="请输入业务库名" v-model:value="formValue.dbName" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="2">
|
||||
<n-form-item label="独立连接串" path="dbDsn">
|
||||
<n-input
|
||||
type="textarea"
|
||||
placeholder="为空时使用业务库名路由"
|
||||
v-model:value="formValue.dbDsn"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-form-item label="最大用户" path="maxUsers">
|
||||
<n-input-number
|
||||
placeholder="0 表示不限"
|
||||
:min="0"
|
||||
v-model:value="formValue.maxUsers"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-form-item label="最大群组" path="maxGroups">
|
||||
<n-input-number
|
||||
placeholder="0 表示不限"
|
||||
:min="0"
|
||||
v-model:value="formValue.maxGroups"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-form-item label="群成员上限" path="maxGroupMembers">
|
||||
<n-input-number
|
||||
placeholder="0 表示不限"
|
||||
:min="0"
|
||||
v-model:value="formValue.maxGroupMembers"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="2">
|
||||
<n-form-item label="租户描述" path="tenantDesc">
|
||||
<n-input
|
||||
type="textarea"
|
||||
placeholder="请输入租户描述"
|
||||
v-model:value="formValue.tenantDesc"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeForm">取消</n-button>
|
||||
<n-button type="info" :loading="formBtnLoading" @click="confirmForm">确定</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { Edit, View } from '@/api/imTenant';
|
||||
import { newState, rules, State } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
|
||||
const emit = defineEmits(['reloadTable']);
|
||||
const message = useMessage();
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const formRef = ref<any>({});
|
||||
const formBtnLoading = ref(false);
|
||||
const dialogWidth = computed(() => {
|
||||
return adaModalWidth(840);
|
||||
});
|
||||
const stateOptions = computed(() => dict.getOptions('ImTenantStateOptions'));
|
||||
|
||||
// 提交表单
|
||||
function confirmForm(e) {
|
||||
e.preventDefault();
|
||||
formRef.value.validate((errors) => {
|
||||
if (!errors) {
|
||||
formBtnLoading.value = true;
|
||||
Edit(formValue.value)
|
||||
.then((_res) => {
|
||||
message.success('操作成功');
|
||||
closeForm();
|
||||
emit('reloadTable');
|
||||
})
|
||||
.finally(() => {
|
||||
formBtnLoading.value = false;
|
||||
});
|
||||
} else {
|
||||
message.error('请填写完整信息');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭表单
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 打开模态框
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
|
||||
if (!state || state.id < 1) {
|
||||
formValue.value = newState(state);
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
View({ id: state.id })
|
||||
.then((res) => {
|
||||
formValue.value = newState(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM租户管理"></n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="addTable"
|
||||
class="min-left-space"
|
||||
v-if="hasPermission(['/imTenant/edit'])"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<PlusOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
添加
|
||||
</n-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
<Edit ref="editRef" @reloadTable="reloadTable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List, Status } from '@/api/imTenant';
|
||||
import { PlusOutlined } from '@vicons/antd';
|
||||
import { columns, loadOptions, schemas, State } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import Edit from './edit.vue';
|
||||
|
||||
const dict = useDictStore();
|
||||
const message = useMessage();
|
||||
const { hasPermission } = usePermission();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const editRef = ref();
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 180,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '编辑',
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: ['/imTenant/edit'],
|
||||
},
|
||||
{
|
||||
label: '停用',
|
||||
onClick: handleStatus.bind(null, record, 2),
|
||||
ifShow: () => record.state === 1,
|
||||
auth: ['/imTenant/status'],
|
||||
},
|
||||
{
|
||||
label: '启用',
|
||||
onClick: handleStatus.bind(null, record, 1),
|
||||
ifShow: () => record.state !== 1,
|
||||
auth: ['/imTenant/status'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => {
|
||||
return adaTableScrollX(columns, actionColumn.width);
|
||||
});
|
||||
|
||||
const [register, {}] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
// 加载表格数据
|
||||
const loadDataTable = async (res) => {
|
||||
return await List({ ...searchFormRef.value?.formModel, ...res });
|
||||
};
|
||||
|
||||
// 重新加载表格数据
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
// 添加数据
|
||||
function addTable() {
|
||||
editRef.value.openModal(null);
|
||||
}
|
||||
|
||||
// 编辑数据
|
||||
function handleEdit(record: Recordable) {
|
||||
editRef.value.openModal(record);
|
||||
}
|
||||
|
||||
// 修改状态
|
||||
function handleStatus(record: Recordable, state: number) {
|
||||
Status({ id: record.id, state }).then((_res) => {
|
||||
message.success('设为' + dict.getLabel('ImTenantStateOptions', state) + '成功');
|
||||
reloadTable();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,218 @@
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { NTag } from 'naive-ui';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { renderPopoverMemberSumma, MemberSumma } from '@/utils';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export class State {
|
||||
public id = 0; // ID
|
||||
public code = ''; // 租户编码
|
||||
public name = ''; // 租户名称
|
||||
public tenantDesc = ''; // 租户描述
|
||||
public dbName = ''; // 业务库名
|
||||
public dbDsn = ''; // 独立连接串
|
||||
public state = 1; // 状态
|
||||
public maxUsers = 0; // 最大用户数
|
||||
public maxGroups = 0; // 最大群组数
|
||||
public maxGroupMembers = 0; // 最大群成员数
|
||||
public dbDsnConfigured = false; // 是否配置独立连接串
|
||||
public createdBy = 0; // 创建者
|
||||
public createdBySumma?: null | MemberSumma = null; // 创建者摘要信息
|
||||
public updatedBy = 0; // 更新者
|
||||
public updatedBySumma?: null | MemberSumma = null; // 更新者摘要信息
|
||||
public createdAt = ''; // 创建时间
|
||||
public updatedAt = ''; // 修改时间
|
||||
|
||||
constructor(state?: Partial<State>) {
|
||||
if (state) {
|
||||
Object.assign(this, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function newState(state: State | Record<string, any> | null): State {
|
||||
if (state !== null) {
|
||||
if (state instanceof State) {
|
||||
return cloneDeep(state);
|
||||
}
|
||||
return new State(state);
|
||||
}
|
||||
return new State();
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
export const rules = {
|
||||
code: {
|
||||
required: true,
|
||||
trigger: ['blur', 'input'],
|
||||
type: 'string',
|
||||
message: '请输入租户编码',
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
trigger: ['blur', 'input'],
|
||||
type: 'string',
|
||||
message: '请输入租户名称',
|
||||
},
|
||||
state: {
|
||||
required: true,
|
||||
trigger: ['blur', 'change'],
|
||||
type: 'number',
|
||||
message: '请选择租户状态',
|
||||
},
|
||||
};
|
||||
|
||||
// 表格搜索表单
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'code',
|
||||
component: 'NInput',
|
||||
label: '租户编码',
|
||||
componentProps: {
|
||||
placeholder: '请输入租户编码',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
component: 'NInput',
|
||||
label: '租户名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入租户名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'dbName',
|
||||
component: 'NInput',
|
||||
label: '业务库名',
|
||||
componentProps: {
|
||||
placeholder: '请输入业务库名',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'state',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => dict.getOptions('ImTenantStateOptions')),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 表格列
|
||||
export const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
align: 'left',
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: '租户编码',
|
||||
key: 'code',
|
||||
align: 'left',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '租户名称',
|
||||
key: 'name',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'state',
|
||||
align: 'left',
|
||||
width: 120,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImTenantStateOptions', row.state);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '业务库名',
|
||||
key: 'dbName',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '独立连接串',
|
||||
key: 'dbDsnConfigured',
|
||||
align: 'left',
|
||||
width: 120,
|
||||
render(row: State) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: row.dbDsnConfigured ? 'success' : 'default', bordered: false },
|
||||
{ default: () => (row.dbDsnConfigured ? '已配置' : '未配置') }
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最大用户',
|
||||
key: 'maxUsers',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return row.maxUsers > 0 ? row.maxUsers : '不限';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最大群组',
|
||||
key: 'maxGroups',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return row.maxGroups > 0 ? row.maxGroups : '不限';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '群成员上限',
|
||||
key: 'maxGroupMembers',
|
||||
align: 'left',
|
||||
width: 120,
|
||||
render(row: State) {
|
||||
return row.maxGroupMembers > 0 ? row.maxGroupMembers : '不限';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建者',
|
||||
key: 'createdBy',
|
||||
align: 'left',
|
||||
width: 150,
|
||||
render(row: State) {
|
||||
return renderPopoverMemberSumma(row.createdBySumma);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createdAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '修改时间',
|
||||
key: 'updatedAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
// 加载字典数据选项
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['ImTenantStateOptions']);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<n-modal v-model:show="showModal" preset="dialog" title="重置IM用户密码" :mask-closable="false">
|
||||
<n-form ref="formRef" :model="formValue" :rules="rules" label-placement="left" :label-width="90">
|
||||
<n-form-item label="用户ID">
|
||||
<n-text>{{ userId }}</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="新密码" path="newPassword">
|
||||
<n-input
|
||||
v-model:value="formValue.newPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
maxlength="72"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="确认密码" path="confirmPassword">
|
||||
<n-input
|
||||
v-model:value="formValue.confirmPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
maxlength="72"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="submit">确认重置</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ResetPassword } from '@/api/imUser';
|
||||
import type { State } from './model';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
const message = useMessage();
|
||||
const formRef = ref();
|
||||
const showModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const tenantId = ref(0);
|
||||
const userId = ref('');
|
||||
const formValue = reactive({ newPassword: '', confirmPassword: '' });
|
||||
const rules = {
|
||||
newPassword: {
|
||||
required: true,
|
||||
validator: (_rule, value: string) => {
|
||||
if (!value || value.length < 3) return new Error('密码长度不能少于3位');
|
||||
if (new TextEncoder().encode(value).length > 72) return new Error('密码长度不能超过72字节');
|
||||
return true;
|
||||
},
|
||||
trigger: ['blur', 'input'],
|
||||
},
|
||||
confirmPassword: {
|
||||
required: true,
|
||||
validator: (_rule, value: string) =>
|
||||
value === formValue.newPassword ? true : new Error('两次输入的密码不一致'),
|
||||
trigger: ['blur', 'input'],
|
||||
},
|
||||
};
|
||||
|
||||
function openModal(record: State) {
|
||||
tenantId.value = record.tenantId;
|
||||
userId.value = record.userId;
|
||||
formValue.newPassword = '';
|
||||
formValue.confirmPassword = '';
|
||||
showModal.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value?.validate();
|
||||
loading.value = true;
|
||||
try {
|
||||
await ResetPassword({
|
||||
tenantId: tenantId.value,
|
||||
userId: userId.value,
|
||||
newPassword: formValue.newPassword,
|
||||
});
|
||||
message.success('密码已重置,用户原有登录状态已失效');
|
||||
showModal.value = false;
|
||||
emit('reload');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openModal });
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM用户管理"></n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.tenantId + '-' + row.userId"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
<ViewModal ref="viewRef" />
|
||||
<ResetPasswordModal ref="resetPasswordRef" @reload="reloadTable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { List } from '@/api/imUser';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import ViewModal from './view.vue';
|
||||
import ResetPasswordModal from './ResetPasswordModal.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const { hasPermission } = usePermission();
|
||||
const userStore = useUserStore();
|
||||
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
const resetPasswordRef = ref();
|
||||
|
||||
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
|
||||
if (tenantSchema) {
|
||||
tenantSchema.ifShow = () => isPlatformAdmin.value;
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 220,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '详情',
|
||||
onClick: handleView.bind(null, record),
|
||||
auth: ['/imUser/view'],
|
||||
},
|
||||
{
|
||||
label: '重置密码',
|
||||
type: 'warning',
|
||||
onClick: handleResetPassword.bind(null, record),
|
||||
auth: ['/imUser/resetPassword'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => {
|
||||
return adaTableScrollX(columns, actionColumn.width);
|
||||
});
|
||||
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
// 加载表格数据
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (isPlatformAdmin.value && !params.tenantId) {
|
||||
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
}
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
// 重新加载表格数据
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
function handleView(record: Recordable) {
|
||||
viewRef.value.openModal(record);
|
||||
}
|
||||
|
||||
function handleResetPassword(record: State) {
|
||||
resetPasswordRef.value.openModal(record);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
if (isPlatformAdmin.value) {
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
}
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,215 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const tenantOptions = ref<any[]>([]);
|
||||
|
||||
export class State {
|
||||
public tenantId = 0; // 租户ID
|
||||
public tenantName = ''; // 租户名称
|
||||
public userId = ''; // 用户ID
|
||||
public username = ''; // 登录账号
|
||||
public phone = ''; // 手机号
|
||||
public nickname = ''; // 昵称
|
||||
public publicAccount = ''; // 公开账号
|
||||
public gender = ''; // 性别
|
||||
public region = ''; // 地区
|
||||
public state = 0; // 账号状态
|
||||
public authVersion = 0; // 认证版本
|
||||
public passwordUpdatedAt = ''; // 密码更新时间
|
||||
public authNames = ''; // 登录标识
|
||||
public credentials = ''; // 凭证
|
||||
public lastSeen = ''; // 最后在线时间
|
||||
public userAgent = ''; // 客户端
|
||||
public createdAt = ''; // 创建时间
|
||||
public updatedAt = ''; // 修改时间
|
||||
public signature = ''; // 个性签名
|
||||
public avatarFileId = ''; // 头像文件ID
|
||||
public birthDate = ''; // 出生日期
|
||||
public profileJson = ''; // Tinode Public资料
|
||||
public trustedJson = ''; // Tinode Trusted资料
|
||||
public accessJson = ''; // 访问权限
|
||||
public profileVersion = 0; // 资料版本
|
||||
|
||||
constructor(state?: Partial<State>) {
|
||||
if (state) {
|
||||
Object.assign(this, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function newState(state: State | Record<string, any> | null): State {
|
||||
if (state !== null) {
|
||||
if (state instanceof State) {
|
||||
return cloneDeep(state);
|
||||
}
|
||||
return new State(state);
|
||||
}
|
||||
return new State();
|
||||
}
|
||||
|
||||
// 表格搜索表单
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'userId',
|
||||
component: 'NInput',
|
||||
label: '用户ID',
|
||||
componentProps: {
|
||||
placeholder: '请输入用户ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
component: 'NInput',
|
||||
label: '昵称',
|
||||
componentProps: {
|
||||
placeholder: '请输入昵称',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'authName',
|
||||
component: 'NInput',
|
||||
label: '登录标识',
|
||||
componentProps: {
|
||||
placeholder: '请输入登录名/手机号/邮箱',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'state',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => dict.getOptions('ImUserStateOptions')),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'lastSeen',
|
||||
component: 'NDatePicker',
|
||||
label: '最后在线',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 表格列
|
||||
export const columns = [
|
||||
{
|
||||
title: '用户ID',
|
||||
key: 'userId',
|
||||
align: 'left',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
key: 'nickname',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '登录账号',
|
||||
key: 'username',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
key: 'phone',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '公开账号',
|
||||
key: 'publicAccount',
|
||||
align: 'left',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'state',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImUserStateOptions', row.state);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '登录标识',
|
||||
key: 'authNames',
|
||||
align: 'left',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
title: '凭证',
|
||||
key: 'credentials',
|
||||
align: 'left',
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: '最后在线',
|
||||
key: 'lastSeen',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '客户端',
|
||||
key: 'userAgent',
|
||||
align: 'left',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createdAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '修改时间',
|
||||
key: 'updatedAt',
|
||||
align: 'left',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
// 加载字典数据选项
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['ImUserStateOptions']);
|
||||
}
|
||||
|
||||
export async function loadTenantOptions() {
|
||||
const res = await TenantList({ page: 1, perPage: 20 });
|
||||
tenantOptions.value = (res?.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.id,
|
||||
}));
|
||||
return tenantOptions.value;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="'IM用户详情 #' + formValue.userId"
|
||||
:style="{
|
||||
width: dialogWidth,
|
||||
}"
|
||||
>
|
||||
<n-scrollbar style="max-height: 87vh" class="pr-5">
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="用户ID">{{ formValue.userId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录账号">{{ formValue.username }}</n-descriptions-item>
|
||||
<n-descriptions-item label="手机号">{{ formValue.phone || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="昵称">{{ formValue.nickname || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="公开账号">{{ formValue.publicAccount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="认证版本">{{ formValue.authVersion }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录标识">{{ formValue.authNames || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="凭证">{{ formValue.credentials || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="性别">{{ formValue.gender || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="地区">{{ formValue.region || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="出生日期">{{ formValue.birthDate || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="密码更新时间">
|
||||
{{ formValue.passwordUpdatedAt || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="最后在线">{{ formValue.lastSeen || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="资料版本">{{ formValue.profileVersion }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="客户端" :span="2">
|
||||
{{ formValue.userAgent || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="个性签名" :span="2">
|
||||
{{ formValue.signature || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="头像文件ID" :span="2">
|
||||
{{ formValue.avatarFileId || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="访问权限" :span="2">
|
||||
<n-code :code="formValue.accessJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Public资料" :span="2">
|
||||
<n-code :code="formValue.profileJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="Trusted资料" :span="2">
|
||||
<n-code :code="formValue.trustedJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeForm">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { View } from '@/api/imUser';
|
||||
import { newState, State } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => {
|
||||
return adaModalWidth(960);
|
||||
});
|
||||
const stateLabel = computed(() => dict.getLabel('ImUserStateOptions', formValue.value.state));
|
||||
|
||||
// 关闭弹窗
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 打开弹窗
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
loading.value = true;
|
||||
View({ tenantId: state.tenantId, userId: state.userId })
|
||||
.then((res) => {
|
||||
formValue.value = newState(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM举报反馈"></n-card>
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="handleReset"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.tenantId + '-' + row.type + '-' + row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
<ViewModal ref="viewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { List, Status } from '@/api/imWorkOrder';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import ViewModal from './view.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 240,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imWorkOrder/view'] },
|
||||
{
|
||||
label: '处理中',
|
||||
onClick: () => handleStatus(record, 'processing'),
|
||||
ifShow: () => record.status === 'received',
|
||||
auth: ['/imWorkOrder/status'],
|
||||
},
|
||||
{
|
||||
label: '已解决',
|
||||
onClick: () => handleStatus(record, 'resolved'),
|
||||
ifShow: () => ['received', 'processing'].includes(record.status),
|
||||
auth: ['/imWorkOrder/status'],
|
||||
},
|
||||
{
|
||||
label: '驳回',
|
||||
onClick: () => handleStatus(record, 'rejected'),
|
||||
ifShow: () => ['received', 'processing'].includes(record.status),
|
||||
auth: ['/imWorkOrder/status'],
|
||||
},
|
||||
{
|
||||
label: '关闭',
|
||||
onClick: () => handleStatus(record, 'closed'),
|
||||
ifShow: () => !['closed'].includes(record.status),
|
||||
auth: ['/imWorkOrder/status'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (!params.tenantId || !params.type) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
const defaults: Recordable = { type: 'abuse' };
|
||||
if (tenantOptions.value.length > 0) defaults.tenantId = tenantOptions.value[0].value;
|
||||
await setFieldsValue(defaults);
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function handleStatus(record: State, status: string) {
|
||||
dialog.warning({
|
||||
title: '更新工单状态',
|
||||
content: '确定更新该工单状态?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
return Status({ tenantId: record.tenantId, type: record.type, id: record.id, status }).then(() => {
|
||||
message.success('操作成功');
|
||||
reloadTable();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value, type: 'abuse' });
|
||||
reloadTable();
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,149 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const tenantOptions = ref<any[]>([]);
|
||||
|
||||
export const workOrderTypeOptions = [
|
||||
{ label: '举报', value: 'abuse' },
|
||||
{ label: '反馈', value: 'feedback' },
|
||||
];
|
||||
|
||||
export class State {
|
||||
public tenantId = 0;
|
||||
public tenantName = '';
|
||||
public type = 'abuse';
|
||||
public id = 0;
|
||||
public orderId = '';
|
||||
public userId = 0;
|
||||
public targetType = '';
|
||||
public targetSummary = '';
|
||||
public category = '';
|
||||
public content = '';
|
||||
public status = '';
|
||||
public description = '';
|
||||
public contact = '';
|
||||
public evidenceJson = '';
|
||||
public snapshotJson = '';
|
||||
public clientContextJson = '';
|
||||
public includeDiagnostics = 0;
|
||||
public idempotencyKey = '';
|
||||
public createdAt = '';
|
||||
public updatedAt = '';
|
||||
|
||||
constructor(state?: Partial<State>) {
|
||||
if (state) Object.assign(this, state);
|
||||
}
|
||||
}
|
||||
|
||||
export function newState(state: State | Record<string, any> | null): State {
|
||||
if (state !== null) {
|
||||
if (state instanceof State) return cloneDeep(state);
|
||||
return new State(state);
|
||||
}
|
||||
return new State();
|
||||
}
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
component: 'NSelect',
|
||||
label: '类型',
|
||||
defaultValue: 'abuse',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: workOrderTypeOptions,
|
||||
placeholder: '请选择类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'userId',
|
||||
component: 'NInput',
|
||||
label: '提交人ID',
|
||||
componentProps: { placeholder: '请输入提交人ID' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => dict.getOptions('ImWorkOrderStatusOptions')),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'keyword',
|
||||
component: 'NInput',
|
||||
label: '关键词',
|
||||
componentProps: { placeholder: '请输入内容关键词' },
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
align: 'left',
|
||||
width: 90,
|
||||
render(row: State) {
|
||||
return row.type === 'abuse' ? '举报' : '反馈';
|
||||
},
|
||||
},
|
||||
{ title: 'ID', key: 'id', align: 'left', width: 80 },
|
||||
{ title: '工单ID', key: 'orderId', align: 'left', width: 180 },
|
||||
{ title: '提交人ID', key: 'userId', align: 'left', width: 120 },
|
||||
{ title: '目标类型', key: 'targetType', align: 'left', width: 110 },
|
||||
{ title: '目标摘要', key: 'targetSummary', align: 'left', width: 220 },
|
||||
{ title: '分类', key: 'category', align: 'left', width: 130 },
|
||||
{ title: '内容', key: 'content', align: 'left', width: 260 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImWorkOrderStatusOptions', row.status);
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
|
||||
{ title: '修改时间', key: 'updatedAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['ImWorkOrderStatusOptions']);
|
||||
}
|
||||
|
||||
export async function loadTenantOptions() {
|
||||
const res = await TenantList({ page: 1, perPage: 20 });
|
||||
tenantOptions.value = (res?.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.id,
|
||||
}));
|
||||
return tenantOptions.value;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="'工单详情 ' + formValue.orderId"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<n-scrollbar style="max-height: 87vh" class="pr-5">
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="类型">{{ typeLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="工单ID">{{ formValue.orderId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="提交人ID">{{ formValue.userId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="分类">{{ formValue.category || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标类型">{{ formValue.targetType || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标摘要">{{ formValue.targetSummary || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="联系方式">{{ formValue.contact || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="包含诊断">{{ formValue.includeDiagnostics ? '是' : '否' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="内容" :span="2">{{ formValue.content || formValue.description || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="证据/附件" :span="2">
|
||||
<n-code :code="formValue.evidenceJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="快照" :span="2">
|
||||
<n-code :code="formValue.snapshotJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="客户端上下文" :span="2">
|
||||
<n-code :code="formValue.clientContextJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="幂等键" :span="2">{{ formValue.idempotencyKey || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="closeForm">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { View } from '@/api/imWorkOrder';
|
||||
import { newState, State } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(980));
|
||||
const statusLabel = computed(() => dict.getLabel('ImWorkOrderStatusOptions', formValue.value.status));
|
||||
const typeLabel = computed(() => (formValue.value.type === 'abuse' ? '举报' : '反馈'));
|
||||
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
loading.value = true;
|
||||
View({ tenantId: state.tenantId, type: state.type, id: state.id })
|
||||
.then((res) => {
|
||||
formValue.value = newState(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
@@ -87,6 +87,17 @@
|
||||
class="py-4"
|
||||
>
|
||||
<n-grid x-gap="24" :cols="2">
|
||||
<n-gi v-if="hasPermission(['/imTenant/list'])">
|
||||
<n-form-item label="所属租户" path="tenantId">
|
||||
<n-select
|
||||
v-model:value="formParams.tenantId"
|
||||
:options="tenantOptions"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="平台账号可不选择租户"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="用户名" path="username">
|
||||
<n-input placeholder="请输入登录用户名" v-model:value="formParams.username" />
|
||||
@@ -237,7 +248,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { h, reactive, ref, computed } from 'vue';
|
||||
import { h, reactive, ref, computed, onMounted } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { ActionItem, BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm } from '@/components/Form/index';
|
||||
@@ -264,6 +275,7 @@
|
||||
import { LoginRoute } from '@/router';
|
||||
import { getNowUrl } from '@/utils/urlUtils';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
interface Props {
|
||||
type?: string;
|
||||
@@ -301,6 +313,18 @@
|
||||
name: '',
|
||||
qrUrl: '',
|
||||
});
|
||||
const tenantOptions = ref<any[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!hasPermission(['/imTenant/list'])) {
|
||||
return;
|
||||
}
|
||||
const res = await TenantList({ page: 1, perPage: 100, state: 1 });
|
||||
tenantOptions.value = (res?.list || []).map((item) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.id,
|
||||
}));
|
||||
});
|
||||
|
||||
const dialogWidth = computed(() => {
|
||||
return adaModalWidth();
|
||||
|
||||
@@ -54,6 +54,7 @@ export const addRules = {};
|
||||
|
||||
export const defaultState = {
|
||||
id: 0,
|
||||
tenantId: null,
|
||||
roleId: null,
|
||||
realName: '',
|
||||
username: '',
|
||||
@@ -73,6 +74,7 @@ export const defaultState = {
|
||||
|
||||
export interface State {
|
||||
id: number;
|
||||
tenantId: number | null;
|
||||
roleId: number | null;
|
||||
realName: string;
|
||||
username: string;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-form :label-width="120" :model="formValue" ref="formRef">
|
||||
<n-form-item label="注册短信发送" path="registerSmsSwitch">
|
||||
<n-radio-group v-model:value="formValue.registerSmsSwitch">
|
||||
<n-space>
|
||||
<n-radio
|
||||
v-for="item in dict.getOptionUnRef('sys_switch')"
|
||||
:key="item.value"
|
||||
:value="Number(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="更新时间">
|
||||
<n-text>{{ updatedAt || '-' }}</n-text>
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="formSubmit">保存更新</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import {
|
||||
GetRegistrationConfig,
|
||||
UpdateRegistrationConfig,
|
||||
} from '@/api/im/registrationInvite';
|
||||
|
||||
const dict = useDictStore();
|
||||
const message = useMessage();
|
||||
const loading = ref(false);
|
||||
const updatedAt = ref('');
|
||||
const formValue = ref({
|
||||
registerSmsSwitch: 1,
|
||||
});
|
||||
|
||||
function boolToSwitch(value: boolean) {
|
||||
return value ? 1 : 2;
|
||||
}
|
||||
|
||||
function switchToBool(value: number) {
|
||||
return value === 1;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await GetRegistrationConfig();
|
||||
formValue.value.registerSmsSwitch = boolToSwitch(!!res.registerSmsEnabled);
|
||||
updatedAt.value = res.updatedAt || '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function formSubmit() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await UpdateRegistrationConfig({
|
||||
registerSmsEnabled: switchToBool(formValue.value.registerSmsSwitch),
|
||||
});
|
||||
message.success('更新成功');
|
||||
await load();
|
||||
} catch (error: any) {
|
||||
message.error(error.toString());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
dict.loadOptions(['sys_switch']);
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
@@ -28,6 +28,7 @@
|
||||
<GeoSetting v-if="type === 9" />
|
||||
<PaySetting v-if="type === 10" />
|
||||
<WechatSetting v-if="type === 11" />
|
||||
<ImRegistrationSetting v-if="type === 12" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
@@ -70,6 +71,9 @@
|
||||
}),
|
||||
LoginSetting = defineAsyncComponent(() => {
|
||||
return import('./LoginSetting.vue');
|
||||
}),
|
||||
ImRegistrationSetting = defineAsyncComponent(() => {
|
||||
return import('./ImRegistrationSetting.vue');
|
||||
});
|
||||
const typeTabList = [
|
||||
{
|
||||
@@ -127,6 +131,11 @@
|
||||
desc: '公众号/开放平台/小程序配置等',
|
||||
key: 11,
|
||||
},
|
||||
{
|
||||
name: 'IM注册',
|
||||
desc: 'IM注册短信发送控制',
|
||||
key: 12,
|
||||
},
|
||||
];
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -141,6 +150,7 @@
|
||||
PaySetting,
|
||||
WechatSetting,
|
||||
LoginSetting,
|
||||
ImRegistrationSetting,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
|
||||
Reference in New Issue
Block a user