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:
aid 2026-03-31 15:39:14 +08:00
parent f246b38fc1
commit 83f007d20e
6 changed files with 277 additions and 150 deletions

View File

@ -35,10 +35,17 @@ export class TenantsController {
@RequirePermission('tenant:read')
findAll(
@Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
@Query('pageSize', new ParseIntPipe({ optional: true }))
pageSize: number = 10,
@Query('pageSize', new ParseIntPipe({ optional: true })) 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')

View File

@ -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 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([
this.prisma.tenant.findMany({
where,
skip,
take: pageSize,
include: {
menus: {
include: {
menu: true,
},
},
_count: {
select: {
users: true,
@ -107,15 +122,23 @@ export class TenantsService {
createTime: 'desc',
},
}),
this.prisma.tenant.count(),
this.prisma.tenant.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
return { 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) {

View File

@ -11,6 +11,7 @@
| [评审进度优化](./super-admin/review-progress-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/org-management.md) | 机构管理 | 已优化 | 2026-03-31 |
## 机构管理端

View 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 类型筛选
```

View File

@ -87,6 +87,11 @@ export async function getTenantMenus(id: number): Promise<Menu[]> {
return response;
}
// 切换租户启用/停用
export async function toggleTenantStatus(id: number): Promise<Tenant> {
return await request.patch<any, Tenant>(`/tenants/${id}/status`);
}
// 兼容性导出:保留 tenantsApi 对象
export const tenantsApi = {
getList: getTenantsList,
@ -95,4 +100,5 @@ export const tenantsApi = {
update: updateTenant,
delete: deleteTenant,
getTenantMenus: getTenantMenus,
toggleStatus: toggleTenantStatus,
};

View File

@ -1,6 +1,6 @@
<template>
<div class="tenants-page">
<a-card class="mb-4">
<a-card class="title-card">
<template #title>机构管理</template>
<template #extra>
<a-button v-permission="'tenant:create'" type="primary" @click="handleAdd">
@ -11,13 +11,14 @@
</a-card>
<!-- 搜索 -->
<a-form layout="inline" class="search-form" @finish="handleSearch">
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="机构名称">
<a-input v-model:value="searchKeyword" placeholder="搜索机构名称或编码" allow-clear style="width: 200px"
@press-enter="handleSearch" />
<a-input v-model:value="searchKeyword" placeholder="搜索机构名称或编码" allow-clear style="width: 200px" />
</a-form-item>
<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="">全部类型</a-select-option>
<a-select-option value="library">图书馆</a-select-option>
<a-select-option value="kindergarten">幼儿园</a-select-option>
<a-select-option value="school">学校</a-select-option>
@ -27,15 +28,20 @@
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">搜索</a-button>
<a-button @click="handleResetSearch">重置</a-button>
<a-button type="primary" html-type="submit">
<template #icon><search-outlined /></template>搜索
</a-button>
<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"
row-key="id" @change="handleTableChange">
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination"
row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nameInfo'">
<div class="org-cell">
@ -50,16 +56,22 @@
{{ tenantTypeLabel(record.tenantType) }}
</a-tag>
</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'">
<a-space :size="12">
<span class="stat-item">
<user-outlined />
{{ record._count?.users || 0 }}
</span>
<span class="stat-item">
<safety-outlined />
{{ record._count?.roles || 0 }}
</span>
<a-tooltip title="用户数">
<span class="stat-item"><user-outlined /> {{ record._count?.users || 0 }}</span>
</a-tooltip>
<a-tooltip title="角色数">
<span class="stat-item"><safety-outlined /> {{ record._count?.roles || 0 }}</span>
</a-tooltip>
</a-space>
</template>
<template v-else-if="column.key === 'validState'">
@ -75,7 +87,12 @@
@click="handleEdit(record)">
编辑
</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)">
删除
</a-button>
@ -155,14 +172,27 @@
</a-tab-pane>
</a-tabs>
</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>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } 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 {
tenantsApi,
type Tenant,
@ -170,11 +200,15 @@ import {
type UpdateTenantForm,
} from '@/api/tenants'
import { menusApi, type Menu } from '@/api/menus'
import { useListRequest } from '@/composables/useListRequest'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
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 detailLoading = ref(false)
const menusLoading = ref(false)
@ -184,44 +218,19 @@ const formRef = ref<FormInstance>()
const editingId = ref<number | null>(null)
const activeTab = ref('basic')
//
// #2 #6
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 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 }>({
name: '',
code: '',
@ -248,13 +257,15 @@ const rules = {
],
}
// #3 + #6
const columns: TableColumnsType = [
{ title: '机构信息', key: 'nameInfo', width: 260 },
{ title: '类型', key: 'tenantType', width: 100 },
{ title: '用户/角色', key: 'statistics', width: 120 },
{ title: '机构信息', key: 'nameInfo', width: 220 },
{ title: '类型', key: 'tenantType', width: 90 },
{ title: '登录地址', key: 'loginUrl', width: 160 },
{ title: '用户/角色', key: 'statistics', width: 110 },
{ title: '状态', key: 'validState', width: 80 },
{ title: '创建时间', key: 'createTime', width: 160 },
{ title: '操作', key: 'action', width: 140, fixed: 'right' },
{ title: '创建时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 180, fixed: 'right' },
]
const tenantTypeLabel = (type: string) => {
@ -275,18 +286,63 @@ const tenantTypeColor = (type: string) => {
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
return new Date(dateStr).toLocaleDateString('zh-CN')
}
const handleSearch = () => { /* 前端过滤computed 自动触发 */ }
const handleResetSearch = () => {
searchKeyword.value = ''
searchType.value = undefined
// #3
const copyLoginUrl = (code: string) => {
const url = `${window.location.origin}/${code}/login`
navigator.clipboard.writeText(url).then(() => {
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 () => {
menusLoading.value = true
@ -303,13 +359,11 @@ const getTenantMenuIds = (): Set<number> => {
const tenantMenuIds = new Set<number>()
const tenantMenu = findMenuInTree(allMenus.value, '租户管理')
if (tenantMenu) collectMenuIds(tenantMenu, tenantMenuIds)
// ""
const orgMenu = findMenuInTree(allMenus.value, '机构管理')
if (orgMenu) collectMenuIds(orgMenu, tenantMenuIds)
return tenantMenuIds
}
// ID
const preservedExcludedMenuIds = ref<number[]>([])
const fetchTenantMenus = async (tenantId: number) => {
@ -325,7 +379,6 @@ const fetchTenantMenus = async (tenantId: number) => {
}
const allMenuIds = extractMenuIds(tenantMenus)
const excludeIds = getTenantMenuIds()
//
preservedExcludedMenuIds.value = allMenuIds.filter((id) => excludeIds.has(id))
form.menuIds = allMenuIds.filter((id) => !excludeIds.has(id))
} catch {
@ -365,7 +418,6 @@ const collectMenuIds = (menu: Menu, ids: Set<number>) => {
const buildMenuTree = (menus: Menu[]): Menu[] => {
const flatMenus = flattenMenus(menus)
const excludeIds = getTenantMenuIds()
//
const userCenterMenu = findMenuInTree(menus, '用户中心')
if (userCenterMenu) collectMenuIds(userCenterMenu, excludeIds)
@ -477,12 +529,11 @@ const handleDelete = (record: Tenant) => {
content: `确定要删除机构「${record.name}」吗?此操作不可恢复。`,
okText: '确定删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await tenantsApi.delete(record.id)
message.success('删除成功')
refreshList()
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '删除失败')
}
@ -497,7 +548,6 @@ const handleSubmit = async () => {
const excludeIds = getTenantMenuIds()
const visibleMenuIds = (form.menuIds || []).filter((id) => !excludeIds.has(id))
// ID
const menuIds = editingId.value
? [...visibleMenuIds, ...preservedExcludedMenuIds.value]
: visibleMenuIds
@ -511,19 +561,23 @@ const handleSubmit = async () => {
menuIds,
} as UpdateTenantForm)
message.success('保存成功')
modalVisible.value = false
} else {
await tenantsApi.create({
const created = await tenantsApi.create({
name: form.name, code: form.code,
domain: form.domain || undefined,
description: form.description || undefined,
tenantType: form.tenantType,
menuIds,
} as CreateTenantForm)
message.success('添加成功')
modalVisible.value = false
// #6
lastCreatedName.value = form.name
lastCreatedId.value = created.id
guideVisible.value = true
}
modalVisible.value = false
refreshList()
fetchList()
} catch (error: any) {
if (error?.errorFields) return
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 = () => {
modalVisible.value = false
formRef.value?.resetFields()
@ -539,67 +601,53 @@ const handleCancel = () => {
form.menuIds = []
}
onMounted(() => { fetchAllMenus() })
onMounted(() => { fetchList(); fetchAllMenus() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.search-form {
margin-bottom: 16px;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
:deep(.ant-card-body) { padding: 0; }
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.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-name { font-weight: 600; color: #1e1b4b; font-size: 14px; margin-bottom: 4px; }
.org-code { :deep(.ant-tag) { font-size: 11px; } }
}
.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 {
font-size: 13px;
color: #6b7280;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px; color: #6b7280;
display: inline-flex; align-items: center; gap: 4px;
}
.form-hint {
font-size: 12px;
color: #9ca3af;
}
.form-hint { font-size: 12px; color: #9ca3af; }
//
.menu-config {
.menu-config-hint {
font-size: 13px;
color: #6b7280;
margin-bottom: 16px;
}
.menu-config-hint { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
}
.menu-group {
margin-bottom: 16px;
padding: 16px;
background: #faf9fe;
border-radius: 12px;
.menu-group-header {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #f0ecf9;
}
.menu-items {
padding-left: 24px;
}
margin-bottom: 16px; padding: 16px; background: #faf9fe; border-radius: 12px;
.menu-group-header { margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.menu-items { padding-left: 24px; }
}
</style>