602 lines
18 KiB
Vue
602 lines
18 KiB
Vue
<template>
|
||
<div class="tenants-page">
|
||
<div class="page-header">
|
||
<div>
|
||
<h2 class="page-title">🏢 租户管理</h2>
|
||
<p class="page-desc">管理平台所有租户</p>
|
||
</div>
|
||
<a-space>
|
||
<a-button type="primary" @click="handleAdd">
|
||
<template #icon><PlusOutlined /></template>
|
||
新增租户
|
||
</a-button>
|
||
<a-button @click="loadTenants" :loading="loading">
|
||
<template #icon><ReloadOutlined /></template>
|
||
刷新
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<!-- 列表 -->
|
||
<div class="panel">
|
||
<div class="panel-header">
|
||
<span class="panel-title">📋 租户列表</span>
|
||
<a-space wrap>
|
||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||
<a-select-option :value="1">正常</a-select-option>
|
||
<a-select-option :value="0">已停用</a-select-option>
|
||
</a-select>
|
||
<a-input-search
|
||
v-model:value="searchKeyword"
|
||
placeholder="搜索租户名称/企业名称"
|
||
style="width: 240px"
|
||
@search="handleSearch"
|
||
/>
|
||
</a-space>
|
||
</div>
|
||
|
||
<a-table
|
||
:columns="columns"
|
||
:data-source="tenants"
|
||
:loading="loading"
|
||
:pagination="pagination"
|
||
row-key="tenantId"
|
||
@change="handleTableChange"
|
||
size="middle"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<!-- 租户信息 -->
|
||
<template v-if="column.key === 'tenantInfo'">
|
||
<div class="tenant-info-cell">
|
||
<a-avatar :size="40" :src="record.logo" style="flex-shrink:0">
|
||
<template #icon><BankOutlined /></template>
|
||
</a-avatar>
|
||
<div class="tenant-info-text">
|
||
<div class="tenant-name">{{ record.tenantName }}</div>
|
||
<div class="tenant-sub">ID: {{ record.tenantId }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 企业名称 -->
|
||
<template v-if="column.key === 'companyName'">
|
||
{{ record.companyName || '-' }}
|
||
</template>
|
||
|
||
<!-- 客户名称(昵称/真实姓名/企业名称) -->
|
||
<template v-if="column.key === 'customerName'">
|
||
<div class="customer-name-cell">
|
||
<div v-if="record.nickname || record.realName || record.companyName" class="customer-info">
|
||
<span v-if="record.nickname" class="customer-nickname">{{ record.nickname }}</span>
|
||
<span v-if="record.realName" class="customer-realname">{{ record.realName }}</span>
|
||
<span v-if="record.companyName" class="customer-company">{{ record.companyName }}</span>
|
||
</div>
|
||
<span v-else class="text-gray-400">-</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 账号 -->
|
||
<template v-if="column.key === 'username'">
|
||
<span class="mono-text">{{ record.username || '-' }}</span>
|
||
</template>
|
||
|
||
<!-- 超级管理员手机号 -->
|
||
<template v-if="column.key === 'phone'">
|
||
<span class="mono-text">{{ record.phone || '-' }}</span>
|
||
</template>
|
||
|
||
<!-- 状态 -->
|
||
<template v-if="column.key === 'status'">
|
||
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '正常' : '已停用'" />
|
||
</template>
|
||
|
||
<!-- 备注 -->
|
||
<template v-if="column.key === 'description'">
|
||
<a-tooltip :title="record.description">
|
||
<span class="desc-text">{{ record.description || '-' }}</span>
|
||
</a-tooltip>
|
||
</template>
|
||
|
||
<!-- 创建时间 -->
|
||
<template v-if="column.key === 'createTime'">
|
||
<span class="text-sm text-gray">{{ formatDate(record.createTime) }}</span>
|
||
</template>
|
||
|
||
<!-- 操作 -->
|
||
<template v-if="column.key === 'action'">
|
||
<a-space>
|
||
<a-button type="link" size="small" @click="handleViewApps(record)">查看应用</a-button>
|
||
<a-button type="link" size="small" @click="handleTransfer(record)">转移</a-button>
|
||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||
<a-popconfirm
|
||
:title="record.status === 1 ? '确认停用此租户?' : '确认启用此租户?'"
|
||
@confirm="handleToggleStatus(record)"
|
||
>
|
||
<a-button type="link" size="small" :danger="record.status === 1">
|
||
{{ record.status === 1 ? '停用' : '启用' }}
|
||
</a-button>
|
||
</a-popconfirm>
|
||
<a-popconfirm title="确认删除此租户?" @confirm="handleDelete(record)">
|
||
<a-button type="link" size="small" danger>删除</a-button>
|
||
</a-popconfirm>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
|
||
<!-- 编辑/新增弹窗 -->
|
||
<a-modal
|
||
v-model:open="showModal"
|
||
:title="modalMode === 'add' ? '新增租户' : '编辑租户'"
|
||
width="560px"
|
||
@ok="handleSubmit"
|
||
:confirmLoading="submitLoading"
|
||
>
|
||
<a-form ref="formRef" :model="formData" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
||
<a-form-item label="租户名称" name="tenantName" :rules="[{ required: true, message: '请输入租户名称' }]">
|
||
<a-input v-model:value="formData.tenantName" placeholder="请输入租户名称" />
|
||
</a-form-item>
|
||
<a-form-item label="企业名称" name="companyName">
|
||
<a-input v-model:value="formData.companyName" placeholder="请输入企业名称" />
|
||
</a-form-item>
|
||
<a-form-item label="Logo" name="logo">
|
||
<a-input v-model:value="formData.logo" placeholder="请输入Logo URL" />
|
||
</a-form-item>
|
||
<a-form-item label="状态" name="status">
|
||
<a-select v-model:value="formData.status">
|
||
<a-select-option :value="1">正常</a-select-option>
|
||
<a-select-option :value="0">停用</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="备注" name="description">
|
||
<a-textarea v-model:value="formData.description" :rows="3" placeholder="请输入备注" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<!-- 查看应用弹窗 -->
|
||
<a-modal
|
||
v-model:open="showAppsModal"
|
||
:title="`租户应用:${currentTenant?.tenantName || ''}`"
|
||
width="900px"
|
||
:footer="null"
|
||
>
|
||
<div v-if="tenantApps.length > 0" class="apps-grid">
|
||
<div v-for="app in tenantApps" :key="app.productId" class="app-card">
|
||
<div class="app-card-header">
|
||
<img v-if="app.icon" :src="app.icon" class="app-icon" />
|
||
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(app.productName) }">
|
||
{{ (app.productName || 'A').charAt(0).toUpperCase() }}
|
||
</div>
|
||
<div class="app-card-info">
|
||
<div class="app-name">{{ app.productName }}</div>
|
||
<div class="app-code">{{ app.productCode }}</div>
|
||
</div>
|
||
<a-badge :status="appStatusBadge(app.status)" :text="appStatusText(app.status)" />
|
||
</div>
|
||
<div class="app-card-meta">
|
||
<span>类型:{{ APP_TYPE_NAME[app.appType ?? 10] || '未知' }}</span>
|
||
<span>创建:{{ formatDate(app.createTime) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<a-empty v-else description="该租户暂无应用" />
|
||
</a-modal>
|
||
|
||
<!-- 转移所有权弹窗 -->
|
||
<a-modal
|
||
v-model:open="showTransferModal"
|
||
title="转移租户所有权"
|
||
width="500px"
|
||
@ok="handleTransferSubmit"
|
||
:confirmLoading="transferLoading"
|
||
>
|
||
<div class="transfer-info">
|
||
<p>当前租户:<strong>{{ currentTenant?.tenantName }}</strong></p>
|
||
<p>当前归属:<span class="mono-text">{{ currentTenant?.username || '-' }}</span> ({{ currentTenant?.phone || '-' }})</p>
|
||
</div>
|
||
<a-divider>选择新归属用户</a-divider>
|
||
<a-select
|
||
v-model:value="transferUserId"
|
||
show-search
|
||
filter-option
|
||
placeholder="搜索用户账号/手机号/昵称"
|
||
style="width: 100%"
|
||
:loading="loadingUsers"
|
||
@search="handleSearchUsers"
|
||
>
|
||
<a-select-option v-for="u in userList" :key="u.userId" :value="u.userId">
|
||
<div class="user-option">
|
||
<span>{{ u.username || u.nickname || '用户' + u.userId }}</span>
|
||
<span class="user-phone">{{ u.phone }}</span>
|
||
</div>
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
BankOutlined
|
||
} from '@ant-design/icons-vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { pageTenant, addTenant, updateTenant, removeTenant, transferTenantOwner, listUsers } from '@/api/system/tenant/index'
|
||
import { pageAppProduct } from '@/api/app/appProduct'
|
||
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||
import type { Tenant } from '@/api/system/tenant/model'
|
||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||
|
||
definePageMeta({ layout: 'admin' })
|
||
useHead({ title: '租户管理 - 平台管理' })
|
||
|
||
const loading = ref(false)
|
||
const submitLoading = ref(false)
|
||
const tenants = ref<Tenant[]>([])
|
||
const filterStatus = ref<number | undefined>(undefined)
|
||
const searchKeyword = ref('')
|
||
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 20,
|
||
total: 0,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
})
|
||
|
||
const columns = [
|
||
{ title: '租户信息', key: 'tenantInfo', width: 180 },
|
||
{ title: '客户名称', key: 'customerName', width: 180 },
|
||
{ title: '账号', key: 'username', width: 120 },
|
||
{ title: '手机号', key: 'phone', width: 130 },
|
||
{ title: '状态', key: 'status', width: 90 },
|
||
{ title: '创建时间', key: 'createTime', width: 110 },
|
||
{ title: '操作', key: 'action', width: 260 },
|
||
]
|
||
|
||
const showModal = ref(false)
|
||
const modalMode = ref<'add' | 'edit'>('add')
|
||
const formData = ref<Tenant>({ status: 1 })
|
||
const formRef = ref()
|
||
|
||
// 查看应用相关
|
||
const showAppsModal = ref(false)
|
||
const currentTenant = ref<Tenant | null>(null)
|
||
const tenantApps = ref<AppProduct[]>([])
|
||
const loadingApps = ref(false)
|
||
|
||
// 转移所有权相关
|
||
const showTransferModal = ref(false)
|
||
const transferUserId = ref<number | undefined>()
|
||
const transferLoading = ref(false)
|
||
const userList = ref<any[]>([])
|
||
const loadingUsers = ref(false)
|
||
|
||
async function loadUsers(keywords?: string) {
|
||
loadingUsers.value = true
|
||
try {
|
||
const res = await listUsers({ keywords })
|
||
userList.value = res || []
|
||
} catch {
|
||
userList.value = []
|
||
} finally {
|
||
loadingUsers.value = false
|
||
}
|
||
}
|
||
|
||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||
function handleSearchUsers(value: string) {
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(() => {
|
||
loadUsers(value)
|
||
}, 300)
|
||
}
|
||
|
||
function handleTransfer(record: Tenant) {
|
||
currentTenant.value = record
|
||
transferUserId.value = undefined
|
||
loadUsers()
|
||
showTransferModal.value = true
|
||
}
|
||
|
||
async function handleTransferSubmit() {
|
||
if (!transferUserId.value) {
|
||
message.warning('请选择新归属用户')
|
||
return
|
||
}
|
||
if (!currentTenant.value?.tenantId) return
|
||
transferLoading.value = true
|
||
try {
|
||
await transferTenantOwner(currentTenant.value.tenantId, transferUserId.value)
|
||
message.success('所有权转移成功')
|
||
showTransferModal.value = false
|
||
loadTenants()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '转移失败')
|
||
} finally {
|
||
transferLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadTenants() {
|
||
loading.value = true
|
||
try {
|
||
const res = await pageTenant({
|
||
page: pagination.current,
|
||
limit: pagination.pageSize,
|
||
tenantName: searchKeyword.value || undefined,
|
||
companyName: searchKeyword.value || undefined,
|
||
})
|
||
let list = res?.list || []
|
||
if (filterStatus.value !== undefined) {
|
||
list = list.filter((t: Tenant) => t.status === filterStatus.value)
|
||
}
|
||
tenants.value = list
|
||
pagination.total = res?.count || 0
|
||
} catch {
|
||
message.error('加载租户列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.current = 1
|
||
loadTenants()
|
||
}
|
||
|
||
function handleTableChange(pag: any) {
|
||
pagination.current = pag.current
|
||
pagination.pageSize = pag.pageSize
|
||
loadTenants()
|
||
}
|
||
|
||
function handleAdd() {
|
||
formData.value = { status: 1 }
|
||
modalMode.value = 'add'
|
||
showModal.value = true
|
||
}
|
||
|
||
function handleEdit(record: Tenant) {
|
||
formData.value = { ...record }
|
||
modalMode.value = 'edit'
|
||
showModal.value = true
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!formRef.value) return
|
||
try {
|
||
await formRef.value.validate()
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
submitLoading.value = true
|
||
try {
|
||
if (modalMode.value === 'add') {
|
||
await addTenant(formData.value)
|
||
message.success('租户创建成功')
|
||
} else {
|
||
await updateTenant(formData.value)
|
||
message.success('租户信息保存成功')
|
||
}
|
||
showModal.value = false
|
||
loadTenants()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败')
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleToggleStatus(record: Tenant) {
|
||
const newStatus = record.status === 1 ? 0 : 1
|
||
try {
|
||
await updateTenant({ ...record, status: newStatus })
|
||
message.success(newStatus === 1 ? '租户已启用' : '租户已停用')
|
||
loadTenants()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
async function handleDelete(record: Tenant) {
|
||
try {
|
||
await removeTenant(record.tenantId)
|
||
message.success('租户删除成功')
|
||
loadTenants()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
// 查看租户应用
|
||
async function handleViewApps(record: Tenant) {
|
||
currentTenant.value = record
|
||
showAppsModal.value = true
|
||
loadingApps.value = true
|
||
try {
|
||
const res = await pageAppProduct({
|
||
current: 1,
|
||
size: 100,
|
||
tenantId: record.tenantId,
|
||
})
|
||
tenantApps.value = res?.list || []
|
||
} catch {
|
||
message.error('加载应用列表失败')
|
||
tenantApps.value = []
|
||
} finally {
|
||
loadingApps.value = false
|
||
}
|
||
}
|
||
|
||
function appStatusText(status?: number) {
|
||
const map: Record<number, string> = { 0: '未开通', 1: '运行中', 2: '维护中', 3: '已关闭' }
|
||
return map[status ?? -1] || '未知'
|
||
}
|
||
|
||
function appStatusBadge(status?: number): 'success' | 'warning' | 'error' | 'default' {
|
||
if (status === 1) return 'success'
|
||
if (status === 2) return 'warning'
|
||
if (status === 3) return 'error'
|
||
return 'default'
|
||
}
|
||
|
||
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#457b9d']
|
||
function iconBgColor(name?: string) {
|
||
if (!name) return PALETTE[0]
|
||
let h = 0
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
|
||
return PALETTE[Math.abs(h) % PALETTE.length]
|
||
}
|
||
|
||
function formatDate(dateStr?: string) {
|
||
return dateStr ? dateStr.substring(0, 10) : '-'
|
||
}
|
||
|
||
onMounted(() => loadTenants())
|
||
</script>
|
||
|
||
<style scoped>
|
||
.tenants-page { min-height: 100%; }
|
||
|
||
.page-header {
|
||
display: flex; align-items: center;
|
||
justify-content: space-between; margin-bottom: 20px;
|
||
}
|
||
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
|
||
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
|
||
|
||
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
|
||
.panel-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 14px 18px; border-bottom: 1px solid #f5f5f5; flex-wrap: wrap; gap: 10px;
|
||
}
|
||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||
|
||
.tenant-info-cell { display: flex; align-items: center; gap: 12px; }
|
||
.tenant-info-text { flex: 1; min-width: 0; }
|
||
.tenant-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
|
||
.tenant-sub { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||
|
||
.mono-text {
|
||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||
font-size: 13px;
|
||
}
|
||
.desc-text {
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.text-sm { font-size: 12px; }
|
||
.text-gray { color: rgba(0,0,0,0.45); }
|
||
|
||
/* 应用卡片样式 */
|
||
.apps-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.app-card {
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.app-card:hover {
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.app-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.app-icon {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.app-icon-placeholder {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.app-card-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.app-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: rgba(0,0,0,0.85);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.app-code {
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.45);
|
||
}
|
||
|
||
.app-card-meta {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #f5f5f5;
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.45);
|
||
}
|
||
|
||
/* 客户名称单元格 */
|
||
.customer-name-cell { line-height: 1.5; }
|
||
.customer-info { display: flex; flex-direction: column; gap: 2px; }
|
||
.customer-nickname { font-size: 13px; color: rgba(0,0,0,0.85); }
|
||
.customer-realname { font-size: 12px; color: rgba(0,0,0,0.65); }
|
||
.customer-company { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||
|
||
/* 转移所有权弹窗 */
|
||
.transfer-info {
|
||
background: #fafafa;
|
||
padding: 12px 16px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.user-option {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 2px 0;
|
||
}
|
||
.user-phone {
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.45);
|
||
}
|
||
</style>
|