Day5: 机构管理模块优化 — 隐藏内部租户+后端搜索+快捷操作+新建引导
- 后端过滤系统内部租户(super/public/school等),列表只展示真实机构 - 搜索改为后端分页查询(keyword+tenantType参数),去掉前端过滤 - 表格新增登录地址列,一键复制完整URL - 新增停用/启用快捷按钮(PATCH /tenants/:id/status) - 新建机构成功后弹出引导,可直接跳转创建管理员账号 - 修复编辑弹窗因模板访问window导致的渲染崩溃 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f246b38fc1
commit
83f007d20e
@ -35,10 +35,17 @@ export class TenantsController {
|
|||||||
@RequirePermission('tenant:read')
|
@RequirePermission('tenant:read')
|
||||||
findAll(
|
findAll(
|
||||||
@Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
|
@Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
|
||||||
@Query('pageSize', new ParseIntPipe({ optional: true }))
|
@Query('pageSize', new ParseIntPipe({ optional: true })) pageSize: number = 10,
|
||||||
pageSize: number = 10,
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('tenantType') tenantType?: string,
|
||||||
) {
|
) {
|
||||||
return this.tenantsService.findAll(page, pageSize);
|
return this.tenantsService.findAll({ page, pageSize, keyword, tenantType });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePermission('tenant:update')
|
||||||
|
toggleStatus(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
|
return this.tenantsService.toggleStatus(id, req.user?.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@ -84,18 +84,33 @@ export class TenantsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(page: number = 1, pageSize: number = 10) {
|
// 系统内部租户编码(不在机构列表中展示)
|
||||||
|
private readonly INTERNAL_TENANT_CODES = ['super', 'public', 'school', 'teacher', 'student', 'judge'];
|
||||||
|
|
||||||
|
async findAll(params: { page?: number; pageSize?: number; keyword?: string; tenantType?: string } = {}) {
|
||||||
|
const { page = 1, pageSize = 10, keyword, tenantType } = params;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: any = {
|
||||||
|
code: { notIn: this.INTERNAL_TENANT_CODES },
|
||||||
|
validState: { not: undefined },
|
||||||
|
};
|
||||||
|
if (keyword) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: keyword } },
|
||||||
|
{ code: { contains: keyword } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (tenantType) {
|
||||||
|
where.tenantType = tenantType;
|
||||||
|
}
|
||||||
|
|
||||||
const [list, total] = await Promise.all([
|
const [list, total] = await Promise.all([
|
||||||
this.prisma.tenant.findMany({
|
this.prisma.tenant.findMany({
|
||||||
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
menus: {
|
|
||||||
include: {
|
|
||||||
menu: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
users: true,
|
users: true,
|
||||||
@ -107,15 +122,23 @@ export class TenantsService {
|
|||||||
createTime: 'desc',
|
createTime: 'desc',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.tenant.count(),
|
this.prisma.tenant.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return { list, total, page, pageSize };
|
||||||
list,
|
}
|
||||||
total,
|
|
||||||
page,
|
/** 切换租户启用/停用状态 */
|
||||||
pageSize,
|
async toggleStatus(id: number, currentTenantId?: number) {
|
||||||
};
|
await this.checkSuperTenant(currentTenantId);
|
||||||
|
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
|
||||||
|
if (!tenant) throw new NotFoundException('租户不存在');
|
||||||
|
if (tenant.isSuper === 1) throw new BadRequestException('不能停用超级租户');
|
||||||
|
|
||||||
|
return this.prisma.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { validState: tenant.validState === 1 ? 2 : 1 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
| [评审进度优化](./super-admin/review-progress-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
| [评审进度优化](./super-admin/review-progress-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
||||||
| [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
| [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
||||||
| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 |
|
| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 |
|
||||||
|
| [机构管理优化](./super-admin/org-management.md) | 机构管理 | 已优化 | 2026-03-31 |
|
||||||
|
|
||||||
## 机构管理端
|
## 机构管理端
|
||||||
|
|
||||||
|
|||||||
42
docs/design/super-admin/org-management.md
Normal file
42
docs/design/super-admin/org-management.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# 超管端机构管理 — 优化记录
|
||||||
|
|
||||||
|
> 所属端:超管端
|
||||||
|
> 状态:已优化
|
||||||
|
> 创建日期:2026-03-31
|
||||||
|
> 最后更新:2026-03-31
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块说明
|
||||||
|
|
||||||
|
超管端「机构管理」菜单,管理平台接入的外部机构(图书馆、学校、幼儿园等)。每个机构对应一个租户,拥有独立的用户、角色、菜单权限。
|
||||||
|
|
||||||
|
## Day5 (2026-03-31) — 优化内容
|
||||||
|
|
||||||
|
### 1. 隐藏系统内部租户
|
||||||
|
- 后端列表查询过滤 super/public/school/teacher/student/judge 等系统内部编码
|
||||||
|
- 列表只展示真实外部机构
|
||||||
|
|
||||||
|
### 2. 搜索改为后端分页
|
||||||
|
- keyword(名称/编码)和 tenantType 参数传后端查询
|
||||||
|
- 去掉前端 computed 过滤,支持大数据量
|
||||||
|
- 类型下拉选择后自动触发查询
|
||||||
|
|
||||||
|
### 3. 新增登录地址列
|
||||||
|
- 表格新增「登录地址」列,显示 `/:code/login`
|
||||||
|
- 旁边有复制按钮,一键复制完整 URL,方便运营发给机构管理员
|
||||||
|
|
||||||
|
### 4. 停用/启用快捷操作
|
||||||
|
- 操作列新增停用/启用按钮(二次确认提示影响)
|
||||||
|
- 后端新增 `PATCH /tenants/:id/status` 接口
|
||||||
|
|
||||||
|
### 5. 新建后引导
|
||||||
|
- 创建机构成功后弹出引导弹窗
|
||||||
|
- 提供「为该机构创建管理员账号」按钮,跳转用户管理页
|
||||||
|
- 避免创建机构后不知道下一步做什么
|
||||||
|
|
||||||
|
### 新增 API
|
||||||
|
```
|
||||||
|
PATCH /api/tenants/:id/status — 切换租户启用/停用状态
|
||||||
|
GET /api/tenants (新增参数) — keyword 关键词搜索 + tenantType 类型筛选
|
||||||
|
```
|
||||||
@ -87,6 +87,11 @@ export async function getTenantMenus(id: number): Promise<Menu[]> {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换租户启用/停用
|
||||||
|
export async function toggleTenantStatus(id: number): Promise<Tenant> {
|
||||||
|
return await request.patch<any, Tenant>(`/tenants/${id}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
// 兼容性导出:保留 tenantsApi 对象
|
// 兼容性导出:保留 tenantsApi 对象
|
||||||
export const tenantsApi = {
|
export const tenantsApi = {
|
||||||
getList: getTenantsList,
|
getList: getTenantsList,
|
||||||
@ -95,4 +100,5 @@ export const tenantsApi = {
|
|||||||
update: updateTenant,
|
update: updateTenant,
|
||||||
delete: deleteTenant,
|
delete: deleteTenant,
|
||||||
getTenantMenus: getTenantMenus,
|
getTenantMenus: getTenantMenus,
|
||||||
|
toggleStatus: toggleTenantStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tenants-page">
|
<div class="tenants-page">
|
||||||
<a-card class="mb-4">
|
<a-card class="title-card">
|
||||||
<template #title>机构管理</template>
|
<template #title>机构管理</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-button v-permission="'tenant:create'" type="primary" @click="handleAdd">
|
<a-button v-permission="'tenant:create'" type="primary" @click="handleAdd">
|
||||||
@ -11,31 +11,37 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<a-form layout="inline" class="search-form" @finish="handleSearch">
|
<div class="filter-bar">
|
||||||
<a-form-item label="机构名称">
|
<a-form layout="inline" @finish="handleSearch">
|
||||||
<a-input v-model:value="searchKeyword" placeholder="搜索机构名称或编码" allow-clear style="width: 200px"
|
<a-form-item label="机构名称">
|
||||||
@press-enter="handleSearch" />
|
<a-input v-model:value="searchKeyword" placeholder="搜索机构名称或编码" allow-clear style="width: 200px" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="机构类型">
|
<a-form-item label="机构类型">
|
||||||
<a-select v-model:value="searchType" placeholder="全部类型" allow-clear style="width: 130px">
|
<a-select v-model:value="searchType" style="width: 130px" @change="handleSearch">
|
||||||
<a-select-option value="library">图书馆</a-select-option>
|
<a-select-option value="">全部类型</a-select-option>
|
||||||
<a-select-option value="kindergarten">幼儿园</a-select-option>
|
<a-select-option value="library">图书馆</a-select-option>
|
||||||
<a-select-option value="school">学校</a-select-option>
|
<a-select-option value="kindergarten">幼儿园</a-select-option>
|
||||||
<a-select-option value="institution">社会机构</a-select-option>
|
<a-select-option value="school">学校</a-select-option>
|
||||||
<a-select-option value="other">其他</a-select-option>
|
<a-select-option value="institution">社会机构</a-select-option>
|
||||||
</a-select>
|
<a-select-option value="other">其他</a-select-option>
|
||||||
</a-form-item>
|
</a-select>
|
||||||
<a-form-item>
|
</a-form-item>
|
||||||
<a-space>
|
<a-form-item>
|
||||||
<a-button type="primary" html-type="submit">搜索</a-button>
|
<a-space>
|
||||||
<a-button @click="handleResetSearch">重置</a-button>
|
<a-button type="primary" html-type="submit">
|
||||||
</a-space>
|
<template #icon><search-outlined /></template>搜索
|
||||||
</a-form-item>
|
</a-button>
|
||||||
</a-form>
|
<a-button @click="handleResetSearch">
|
||||||
|
<template #icon><reload-outlined /></template>重置
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 机构列表 -->
|
<!-- 机构列表 -->
|
||||||
<a-table :columns="columns" :data-source="filteredData" :loading="loading" :pagination="pagination"
|
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination"
|
||||||
row-key="id" @change="handleTableChange">
|
row-key="id" @change="handleTableChange" class="data-table">
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'nameInfo'">
|
<template v-if="column.key === 'nameInfo'">
|
||||||
<div class="org-cell">
|
<div class="org-cell">
|
||||||
@ -50,16 +56,22 @@
|
|||||||
{{ tenantTypeLabel(record.tenantType) }}
|
{{ tenantTypeLabel(record.tenantType) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'loginUrl'">
|
||||||
|
<div class="login-url-cell">
|
||||||
|
<span class="url-text">/{{ record.code }}/login</span>
|
||||||
|
<a-button type="link" size="small" class="copy-btn" @click.stop="copyLoginUrl(record.code)">
|
||||||
|
<copy-outlined />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'statistics'">
|
<template v-else-if="column.key === 'statistics'">
|
||||||
<a-space :size="12">
|
<a-space :size="12">
|
||||||
<span class="stat-item">
|
<a-tooltip title="用户数">
|
||||||
<user-outlined />
|
<span class="stat-item"><user-outlined /> {{ record._count?.users || 0 }}</span>
|
||||||
{{ record._count?.users || 0 }}
|
</a-tooltip>
|
||||||
</span>
|
<a-tooltip title="角色数">
|
||||||
<span class="stat-item">
|
<span class="stat-item"><safety-outlined /> {{ record._count?.roles || 0 }}</span>
|
||||||
<safety-outlined />
|
</a-tooltip>
|
||||||
{{ record._count?.roles || 0 }}
|
|
||||||
</span>
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'validState'">
|
<template v-else-if="column.key === 'validState'">
|
||||||
@ -75,7 +87,12 @@
|
|||||||
@click="handleEdit(record)">
|
@click="handleEdit(record)">
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button v-permission="'tenant:delete'" v-if="record.isSuper !== 1 && record.code !== 'public'"
|
<a-button v-permission="'tenant:update'" type="link" size="small"
|
||||||
|
:style="{ color: record.validState === 1 ? '#f59e0b' : '#10b981' }"
|
||||||
|
@click="handleToggleStatus(record)">
|
||||||
|
{{ record.validState === 1 ? '停用' : '启用' }}
|
||||||
|
</a-button>
|
||||||
|
<a-button v-permission="'tenant:delete'" v-if="record.isSuper !== 1"
|
||||||
type="link" size="small" danger @click="handleDelete(record)">
|
type="link" size="small" danger @click="handleDelete(record)">
|
||||||
删除
|
删除
|
||||||
</a-button>
|
</a-button>
|
||||||
@ -155,14 +172,27 @@
|
|||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 新建成功引导弹窗 -->
|
||||||
|
<a-modal v-model:open="guideVisible" title="机构创建成功" :footer="null" width="460px">
|
||||||
|
<a-result status="success" :title="`「${lastCreatedName}」创建成功`" sub-title="接下来你可以:">
|
||||||
|
<template #extra>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; align-items: center">
|
||||||
|
<a-button type="primary" @click="goCreateAdmin">为该机构创建管理员账号</a-button>
|
||||||
|
<a-button @click="guideVisible = false">稍后再说</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, nextTick, onMounted, computed } from 'vue'
|
import { ref, reactive, nextTick, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import type { TableColumnsType, FormInstance } from 'ant-design-vue'
|
import type { TableColumnsType, FormInstance } from 'ant-design-vue'
|
||||||
import { PlusOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined, UserOutlined, SafetyOutlined, CopyOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||||
import {
|
import {
|
||||||
tenantsApi,
|
tenantsApi,
|
||||||
type Tenant,
|
type Tenant,
|
||||||
@ -170,11 +200,15 @@ import {
|
|||||||
type UpdateTenantForm,
|
type UpdateTenantForm,
|
||||||
} from '@/api/tenants'
|
} from '@/api/tenants'
|
||||||
import { menusApi, type Menu } from '@/api/menus'
|
import { menusApi, type Menu } from '@/api/menus'
|
||||||
import { useListRequest } from '@/composables/useListRequest'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<Tenant[]>([])
|
||||||
|
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
|
||||||
|
|
||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const menusLoading = ref(false)
|
const menusLoading = ref(false)
|
||||||
@ -184,44 +218,19 @@ const formRef = ref<FormInstance>()
|
|||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const activeTab = ref('basic')
|
const activeTab = ref('basic')
|
||||||
|
|
||||||
// 搜索
|
// 搜索(#2 #6 后端分页搜索)
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searchType = ref<string | undefined>(undefined)
|
const searchType = ref('')
|
||||||
|
|
||||||
|
// 新建成功引导(#6)
|
||||||
|
const guideVisible = ref(false)
|
||||||
|
const lastCreatedName = ref('')
|
||||||
|
const lastCreatedId = ref<number | null>(null)
|
||||||
|
|
||||||
// 菜单相关
|
// 菜单相关
|
||||||
const allMenus = ref<Menu[]>([])
|
const allMenus = ref<Menu[]>([])
|
||||||
const topLevelMenus = computed(() => buildMenuTree(allMenus.value))
|
const topLevelMenus = computed(() => buildMenuTree(allMenus.value))
|
||||||
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
dataSource,
|
|
||||||
pagination,
|
|
||||||
handleTableChange,
|
|
||||||
refresh: refreshList,
|
|
||||||
} = useListRequest<Tenant>({
|
|
||||||
requestFn: tenantsApi.getList,
|
|
||||||
errorMessage: '获取机构列表失败',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 前端过滤(搜索 + 类型筛选)
|
|
||||||
const filteredData = computed(() => {
|
|
||||||
let result = dataSource.value
|
|
||||||
if (searchKeyword.value) {
|
|
||||||
const kw = searchKeyword.value.toLowerCase()
|
|
||||||
result = result.filter(
|
|
||||||
(t) =>
|
|
||||||
t.name.toLowerCase().includes(kw) ||
|
|
||||||
t.code.toLowerCase().includes(kw),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (searchType.value) {
|
|
||||||
result = result.filter((t: any) => t.tenantType === searchType.value)
|
|
||||||
}
|
|
||||||
// 隐藏公众用户租户(系统内部使用,不应在机构列表展示)
|
|
||||||
result = result.filter((t) => t.code !== 'public')
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = reactive<CreateTenantForm & { validState?: number; menuIds?: number[]; tenantType?: string }>({
|
const form = reactive<CreateTenantForm & { validState?: number; menuIds?: number[]; tenantType?: string }>({
|
||||||
name: '',
|
name: '',
|
||||||
code: '',
|
code: '',
|
||||||
@ -248,13 +257,15 @@ const rules = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #3 登录地址列 + #6 描述预览
|
||||||
const columns: TableColumnsType = [
|
const columns: TableColumnsType = [
|
||||||
{ title: '机构信息', key: 'nameInfo', width: 260 },
|
{ title: '机构信息', key: 'nameInfo', width: 220 },
|
||||||
{ title: '类型', key: 'tenantType', width: 100 },
|
{ title: '类型', key: 'tenantType', width: 90 },
|
||||||
{ title: '用户/角色', key: 'statistics', width: 120 },
|
{ title: '登录地址', key: 'loginUrl', width: 160 },
|
||||||
|
{ title: '用户/角色', key: 'statistics', width: 110 },
|
||||||
{ title: '状态', key: 'validState', width: 80 },
|
{ title: '状态', key: 'validState', width: 80 },
|
||||||
{ title: '创建时间', key: 'createTime', width: 160 },
|
{ title: '创建时间', key: 'createTime', width: 120 },
|
||||||
{ title: '操作', key: 'action', width: 140, fixed: 'right' },
|
{ title: '操作', key: 'action', width: 180, fixed: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const tenantTypeLabel = (type: string) => {
|
const tenantTypeLabel = (type: string) => {
|
||||||
@ -275,18 +286,63 @@ const tenantTypeColor = (type: string) => {
|
|||||||
|
|
||||||
const formatDate = (dateStr?: string) => {
|
const formatDate = (dateStr?: string) => {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
const date = new Date(dateStr)
|
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||||
return date.toLocaleDateString('zh-CN')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => { /* 前端过滤,computed 自动触发 */ }
|
// #3 复制登录地址
|
||||||
|
const copyLoginUrl = (code: string) => {
|
||||||
const handleResetSearch = () => {
|
const url = `${window.location.origin}/${code}/login`
|
||||||
searchKeyword.value = ''
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
searchType.value = undefined
|
message.success('登录地址已复制')
|
||||||
|
}).catch(() => {
|
||||||
|
message.info(`登录地址:${url}`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 菜单相关逻辑(保持不变) ==========
|
// #2 后端搜索
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await tenantsApi.getList({
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
tenantType: searchType.value || undefined,
|
||||||
|
} as any)
|
||||||
|
dataSource.value = res.list
|
||||||
|
pagination.total = res.total
|
||||||
|
} catch {
|
||||||
|
message.error('获取机构列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => { pagination.current = 1; fetchList() }
|
||||||
|
const handleResetSearch = () => { searchKeyword.value = ''; searchType.value = ''; pagination.current = 1; fetchList() }
|
||||||
|
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
|
||||||
|
|
||||||
|
// #4 快捷停用/启用
|
||||||
|
const handleToggleStatus = (record: Tenant) => {
|
||||||
|
const action = record.validState === 1 ? '停用' : '启用'
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确定${action}?`,
|
||||||
|
content: record.validState === 1
|
||||||
|
? `停用后「${record.name}」的所有用户将无法登录`
|
||||||
|
: `启用后「${record.name}」的用户将恢复登录`,
|
||||||
|
okText: `确定${action}`,
|
||||||
|
okType: record.validState === 1 ? 'danger' : 'primary',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await tenantsApi.toggleStatus(record.id)
|
||||||
|
message.success(`已${action}`)
|
||||||
|
fetchList()
|
||||||
|
} catch { message.error('操作失败') }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 菜单相关逻辑 ==========
|
||||||
|
|
||||||
const fetchAllMenus = async () => {
|
const fetchAllMenus = async () => {
|
||||||
menusLoading.value = true
|
menusLoading.value = true
|
||||||
@ -303,13 +359,11 @@ const getTenantMenuIds = (): Set<number> => {
|
|||||||
const tenantMenuIds = new Set<number>()
|
const tenantMenuIds = new Set<number>()
|
||||||
const tenantMenu = findMenuInTree(allMenus.value, '租户管理')
|
const tenantMenu = findMenuInTree(allMenus.value, '租户管理')
|
||||||
if (tenantMenu) collectMenuIds(tenantMenu, tenantMenuIds)
|
if (tenantMenu) collectMenuIds(tenantMenu, tenantMenuIds)
|
||||||
// 也过滤掉"机构管理"相关菜单
|
|
||||||
const orgMenu = findMenuInTree(allMenus.value, '机构管理')
|
const orgMenu = findMenuInTree(allMenus.value, '机构管理')
|
||||||
if (orgMenu) collectMenuIds(orgMenu, tenantMenuIds)
|
if (orgMenu) collectMenuIds(orgMenu, tenantMenuIds)
|
||||||
return tenantMenuIds
|
return tenantMenuIds
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存编辑时被排除但原本已分配的菜单 ID,保存时合并回去
|
|
||||||
const preservedExcludedMenuIds = ref<number[]>([])
|
const preservedExcludedMenuIds = ref<number[]>([])
|
||||||
|
|
||||||
const fetchTenantMenus = async (tenantId: number) => {
|
const fetchTenantMenus = async (tenantId: number) => {
|
||||||
@ -325,7 +379,6 @@ const fetchTenantMenus = async (tenantId: number) => {
|
|||||||
}
|
}
|
||||||
const allMenuIds = extractMenuIds(tenantMenus)
|
const allMenuIds = extractMenuIds(tenantMenus)
|
||||||
const excludeIds = getTenantMenuIds()
|
const excludeIds = getTenantMenuIds()
|
||||||
// 记录被排除但原本已分配的菜单(如机构管理、用户中心等),保存时需要保留
|
|
||||||
preservedExcludedMenuIds.value = allMenuIds.filter((id) => excludeIds.has(id))
|
preservedExcludedMenuIds.value = allMenuIds.filter((id) => excludeIds.has(id))
|
||||||
form.menuIds = allMenuIds.filter((id) => !excludeIds.has(id))
|
form.menuIds = allMenuIds.filter((id) => !excludeIds.has(id))
|
||||||
} catch {
|
} catch {
|
||||||
@ -365,7 +418,6 @@ const collectMenuIds = (menu: Menu, ids: Set<number>) => {
|
|||||||
const buildMenuTree = (menus: Menu[]): Menu[] => {
|
const buildMenuTree = (menus: Menu[]): Menu[] => {
|
||||||
const flatMenus = flattenMenus(menus)
|
const flatMenus = flattenMenus(menus)
|
||||||
const excludeIds = getTenantMenuIds()
|
const excludeIds = getTenantMenuIds()
|
||||||
// 也排除用户中心的菜单(超管专属)
|
|
||||||
const userCenterMenu = findMenuInTree(menus, '用户中心')
|
const userCenterMenu = findMenuInTree(menus, '用户中心')
|
||||||
if (userCenterMenu) collectMenuIds(userCenterMenu, excludeIds)
|
if (userCenterMenu) collectMenuIds(userCenterMenu, excludeIds)
|
||||||
|
|
||||||
@ -477,12 +529,11 @@ const handleDelete = (record: Tenant) => {
|
|||||||
content: `确定要删除机构「${record.name}」吗?此操作不可恢复。`,
|
content: `确定要删除机构「${record.name}」吗?此操作不可恢复。`,
|
||||||
okText: '确定删除',
|
okText: '确定删除',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await tenantsApi.delete(record.id)
|
await tenantsApi.delete(record.id)
|
||||||
message.success('删除成功')
|
message.success('删除成功')
|
||||||
refreshList()
|
fetchList()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error?.response?.data?.message || '删除失败')
|
message.error(error?.response?.data?.message || '删除失败')
|
||||||
}
|
}
|
||||||
@ -497,7 +548,6 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
const excludeIds = getTenantMenuIds()
|
const excludeIds = getTenantMenuIds()
|
||||||
const visibleMenuIds = (form.menuIds || []).filter((id) => !excludeIds.has(id))
|
const visibleMenuIds = (form.menuIds || []).filter((id) => !excludeIds.has(id))
|
||||||
// 编辑时:把被排除但原本已分配的菜单 ID 保留回去,避免保存时丢失
|
|
||||||
const menuIds = editingId.value
|
const menuIds = editingId.value
|
||||||
? [...visibleMenuIds, ...preservedExcludedMenuIds.value]
|
? [...visibleMenuIds, ...preservedExcludedMenuIds.value]
|
||||||
: visibleMenuIds
|
: visibleMenuIds
|
||||||
@ -511,19 +561,23 @@ const handleSubmit = async () => {
|
|||||||
menuIds,
|
menuIds,
|
||||||
} as UpdateTenantForm)
|
} as UpdateTenantForm)
|
||||||
message.success('保存成功')
|
message.success('保存成功')
|
||||||
|
modalVisible.value = false
|
||||||
} else {
|
} else {
|
||||||
await tenantsApi.create({
|
const created = await tenantsApi.create({
|
||||||
name: form.name, code: form.code,
|
name: form.name, code: form.code,
|
||||||
domain: form.domain || undefined,
|
domain: form.domain || undefined,
|
||||||
description: form.description || undefined,
|
description: form.description || undefined,
|
||||||
tenantType: form.tenantType,
|
tenantType: form.tenantType,
|
||||||
menuIds,
|
menuIds,
|
||||||
} as CreateTenantForm)
|
} as CreateTenantForm)
|
||||||
message.success('添加成功')
|
modalVisible.value = false
|
||||||
|
// #6 新建成功引导
|
||||||
|
lastCreatedName.value = form.name
|
||||||
|
lastCreatedId.value = created.id
|
||||||
|
guideVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
modalVisible.value = false
|
fetchList()
|
||||||
refreshList()
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.errorFields) return
|
if (error?.errorFields) return
|
||||||
message.error(error?.response?.data?.message || '操作失败')
|
message.error(error?.response?.data?.message || '操作失败')
|
||||||
@ -532,6 +586,14 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #6 引导跳转创建管理员
|
||||||
|
const goCreateAdmin = () => {
|
||||||
|
guideVisible.value = false
|
||||||
|
// 跳转到用户管理页面(带上租户信息)
|
||||||
|
const tenantCode = authStore.tenantCode || 'super'
|
||||||
|
router.push(`/${tenantCode}/system/users`)
|
||||||
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
@ -539,67 +601,53 @@ const handleCancel = () => {
|
|||||||
form.menuIds = []
|
form.menuIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { fetchAllMenus() })
|
onMounted(() => { fetchList(); fetchAllMenus() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
$primary: #6366f1;
|
$primary: #6366f1;
|
||||||
|
|
||||||
.search-form {
|
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||||
margin-bottom: 16px;
|
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||||
|
:deep(.ant-card-body) { padding: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-cell {
|
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
|
||||||
.org-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1e1b4b;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-code {
|
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
|
||||||
:deep(.ant-tag) {
|
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
|
||||||
font-size: 11px;
|
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
|
||||||
}
|
.ant-table-pagination { padding: 16px; margin: 0; }
|
||||||
|
} }
|
||||||
|
|
||||||
|
.org-cell {
|
||||||
|
.org-name { font-weight: 600; color: #1e1b4b; font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.org-code { :deep(.ant-tag) { font-size: 11px; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// #3 登录地址列
|
||||||
|
.login-url-cell {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
.url-text { font-size: 12px; color: #6b7280; font-family: monospace; }
|
||||||
|
.copy-btn { padding: 0 4px; font-size: 12px; color: #9ca3af;
|
||||||
|
&:hover { color: $primary; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
font-size: 13px;
|
font-size: 13px; color: #6b7280;
|
||||||
color: #6b7280;
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-hint {
|
.form-hint { font-size: 12px; color: #9ca3af; }
|
||||||
font-size: 12px;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单配置区域
|
|
||||||
.menu-config {
|
.menu-config {
|
||||||
.menu-config-hint {
|
.menu-config-hint { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
|
||||||
font-size: 13px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-group {
|
.menu-group {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px; padding: 16px; background: #faf9fe; border-radius: 12px;
|
||||||
padding: 16px;
|
.menu-group-header { margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
|
||||||
background: #faf9fe;
|
.menu-items { padding-left: 24px; }
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
.menu-group-header {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #f0ecf9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-items {
|
|
||||||
padding-left: 24px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user