Files
jczxw-pc/app/pages/admin/tenants.vue
2026-04-23 16:30:57 +08:00

602 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>