Files
ai-kefu-admin/admin/src/views/tenants/list/index.vue
T

1128 lines
37 KiB
Vue
Raw Normal View History

<!--
租户管理与站点管理(Phase 3 Task 6,仅超管——菜单 roles:['super'] 已挡)
- 列表:slug/名称/状态/域名数/版本号/管理员概要 + 状态筛选 + 行操作
(详情抽屉 / 改名 / 停用 / 复启用)
- 创建向导(ElSteps 四步):①名称+slug(^[a-z0-9-]+$ + 对现列表撞车提示)
→ ②绑定域名(可多填,进 main 站点)→ ③出厂档案 auto/jiancai/blank
(blank=墙与提示词取建材出厂档案;同档案第二个租户会撞 push.prefix
全局唯一 → 400 引导改 blank 或先改占用方)→ ④管理员账号 → 创建
(201 后列表刷新 + 提示"租户已创建,管理员首登需改密")
- 站点管理(详情抽屉内):站点列表/新建(门面覆盖层表单,留空=继承租户
默认)/编辑门面(Task 7a:PATCH brand_overlay 整字典,留空剔除,共用
SiteOverlayFields 组件)/停用复启用/域名绑定解绑(撞车 400 的归属信息
内联展示)
- 停用后果文案(Phase 2 语义):租户停用=官网挂件 503 服务暂停页;
站点停用=该站点域名 404(不是服务暂停)——确认框写明
- slug 不可变(后端 PATCH 带 slug 键 400)——界面不提供改 slug 入口
-->
<template>
<div class="tenants-page">
<!-- 头部:标题 + 新建入口 -->
<div class="page-header">
<div>
<h2 class="page-title">租户管理</h2>
<p class="page-sub">租户生命周期与站点 / 域名管理(仅超级管理员)</p>
</div>
<ElButton type="primary" @click="openWizard">新建租户</ElButton>
</div>
<!-- 租户列表 -->
<ElCard shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">租户列表</span>
<ElRadioGroup v-model="statusFilter">
<ElRadioButton value="">全部</ElRadioButton>
<ElRadioButton value="active">启用</ElRadioButton>
<ElRadioButton value="disabled">停用</ElRadioButton>
</ElRadioGroup>
</div>
</template>
<ElAlert
v-if="listError"
type="error"
:closable="false"
:title="listError"
class="load-error"
>
<ElButton size="small" :loading="listLoading" @click="loadList">重试</ElButton>
</ElAlert>
<template v-else>
<ElEmpty
v-if="!listLoading && tenants.length === 0"
description="暂无租户——点击右上角「新建租户」走创建向导"
/>
<ElEmpty
v-else-if="!listLoading && filteredTenants.length === 0"
:description="`没有${statusFilter === 'active' ? '启用' : '停用'}状态的租户`"
/>
<ElTable v-else v-loading="listLoading" :data="filteredTenants" row-key="slug">
<ElTableColumn label="名称" min-width="170" show-overflow-tooltip>
<template #default="{ row }">
<span class="tenant-name">{{ row.name }}</span>
<span class="tenant-slug">@{{ row.slug }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="slug" label="slug" width="120" show-overflow-tooltip />
<ElTableColumn label="状态" width="84">
<template #default="{ row }">
<ElTag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '启用' : '停用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="域名数" width="80" align="center">
<template #default="{ row }">
<span>{{ row.domain_count }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="版本" width="70" align="center">
<template #default="{ row }">
<span>{{ row.current_version ?? '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="管理员" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.admins.length > 0">{{ adminNames(row.admins) }}</span>
<span v-else class="muted">—</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="216" fixed="right">
<template #default="{ row }">
<ElButton type="primary" link size="small" @click="openDetail(row)"> 详情 </ElButton>
<ElButton type="primary" link size="small" @click="openRename(row)"> 改名 </ElButton>
<ElButton
:type="row.status === 'active' ? 'danger' : 'success'"
link
size="small"
@click="onToggleTenant(row)"
>
{{ row.status === 'active' ? '停用' : '复启用' }}
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</template>
</ElCard>
<!-- ==================== 租户详情抽屉(站点管理) ==================== -->
<ElDrawer v-model="detailVisible" size="780px" @closed="onDetailClosed">
<template #header>
<div class="drawer-header">
<span class="drawer-title">
租户详情:{{ detail?.name ?? detailSlug }}
<ElTag
v-if="detail"
:type="detail.status === 'active' ? 'success' : 'info'"
size="small"
>
{{ detail.status === 'active' ? '启用' : '停用' }}
</ElTag>
</span>
<ElButton
v-if="detail"
size="small"
:disabled="detail.status !== 'active'"
:title="detail.status !== 'active' ? '租户已停用,复启用后可新建站点' : ''"
@click="openSiteDialog"
>
新建站点
</ElButton>
</div>
</template>
<div v-loading="detailLoading" class="detail-body">
<ElAlert
v-if="detailError"
type="error"
:closable="false"
:title="detailError"
class="load-error"
>
<ElButton size="small" :loading="detailLoading" @click="loadDetail">重试</ElButton>
</ElAlert>
<template v-else-if="detail">
<!-- 租户元信息 -->
<ElDescriptions :column="2" border size="small" class="detail-section">
<ElDescriptionsItem label="slug">
<span class="mono">{{ detail.slug }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="当前版本">
v{{ detail.current_version ?? '—' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="管理员" :span="2">
<span v-if="detail.admins.length > 0">
{{ detail.admins.map((a) => a.username).join('、') }}
</span>
<span v-else class="muted">—</span>
</ElDescriptionsItem>
</ElDescriptions>
<!-- 站点列表 -->
<section class="detail-section">
<h4 class="section-title">站点({{ detail.sites.length }})</h4>
<ElTable :data="detail.sites" row-key="slug" size="small">
<ElTableColumn label="站点" width="110" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono">{{ row.slug }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="名称" min-width="110" show-overflow-tooltip />
<ElTableColumn label="状态" width="76">
<template #default="{ row }">
<ElTag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '启用' : '停用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="域名" min-width="180">
<template #default="{ row }">
<div v-if="row.domains.length > 0" class="domain-tags">
<ElTag
v-for="domain in row.domains"
:key="domain"
closable
size="small"
@close="onUnbindDomain(row, domain)"
>
{{ domain }}
</ElTag>
</div>
<span v-else class="muted">—</span>
</template>
</ElTableColumn>
<ElTableColumn label="门面" width="120">
<template #default="{ row }">
<ElTooltip
v-if="overlayKeys(row).length > 0"
:content="overlayKeys(row).map(overlayKeyLabel).join('、')"
placement="top"
>
<span class="overlay-hint">覆盖 {{ overlayKeys(row).length }} 项</span>
</ElTooltip>
<span v-else class="muted">继承租户默认</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="216" fixed="right">
<template #default="{ row }">
<ElButton type="primary" link size="small" @click="openOverlayEdit(row)">
编辑门面
</ElButton>
<ElButton
:type="row.status === 'active' ? 'danger' : 'success'"
link
size="small"
@click="onToggleSite(row)"
>
{{ row.status === 'active' ? '停用' : '复启用' }}
</ElButton>
<ElButton
type="primary"
link
size="small"
:disabled="row.status !== 'active'"
@click="openDomainDialog(row)"
>
绑定域名
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<p class="section-tip">
域名标签上的 × 可解绑;站点停用 = 该站点全部域名访问 404(区别于租户停用的 503
服务暂停页)。
</p>
</section>
</template>
</div>
</ElDrawer>
<!-- ==================== 改名弹窗(slug 不可变) ==================== -->
<ElDialog v-model="renameVisible" title="租户改名" width="460px">
<ElForm label-width="90px" @submit.prevent>
<ElFormItem label="slug">
<span class="mono">{{ renameSlug }}</span>
</ElFormItem>
<ElFormItem label="新名称" required>
<ElInput v-model="renameName" maxlength="128" placeholder="1-128 字符" />
</ElFormItem>
</ElForm>
<p class="dialog-tip">slug 不可修改(后端约束);仅更新租户显示名称。</p>
<template #footer>
<ElButton @click="renameVisible = false">取消</ElButton>
<ElButton type="primary" :loading="renaming" @click="onRename">保存</ElButton>
</template>
</ElDialog>
<!-- ==================== 创建向导 ==================== -->
<ElDialog
v-model="wizardVisible"
title="新建租户(创建向导)"
width="640px"
:close-on-click-modal="false"
>
<ElSteps :active="wizardStep" align-center finish-status="success" class="wizard-steps">
<ElStep title="基本信息" />
<ElStep title="绑定域名" />
<ElStep title="出厂档案" />
<ElStep title="管理员账号" />
</ElSteps>
<div class="wizard-body">
<!-- 步骤 1:名称 + slug -->
<template v-if="wizardStep === 0">
<ElForm label-width="90px" @submit.prevent>
<ElFormItem label="租户名称" required>
<ElInput v-model="wizard.name" maxlength="128" placeholder="1-128 字符" />
</ElFormItem>
<ElFormItem label="slug" required>
<ElInput
v-model="wizard.slug"
maxlength="64"
placeholder="小写字母、数字、连字符(如 jiancai)"
/>
</ElFormItem>
</ElForm>
<p class="dialog-tip">
slug 创建后不可修改,且全局唯一(与现有租户撞车会在本步拦截)。
</p>
</template>
<!-- 步骤 2:绑定域名(可多填,进 main 站点) -->
<template v-else-if="wizardStep === 1">
<div v-for="(_, index) in wizard.domains" :key="index" class="domain-row">
<ElInput
v-model="wizard.domains[index]"
placeholder="example.com(入库自动归一:小写 / 去端口 / 去尾点)"
/>
<ElButton
v-if="wizard.domains.length > 1"
type="danger"
link
@click="wizard.domains.splice(index, 1)"
>
删除
</ElButton>
</div>
<ElButton size="small" @click="wizard.domains.push('')">+ 添加域名</ElButton>
<p class="dialog-tip">
可全部留空(创建后在站点管理中随时绑定);域名全局唯一,撞车会在提交时报出归属。
</p>
</template>
<!-- 步骤 3:出厂档案 -->
<template v-else-if="wizardStep === 2">
<ElRadioGroup v-model="wizard.profile" class="profile-group">
<ElRadio v-for="item in PROFILE_OPTIONS" :key="item.value" :value="item.value">
<span class="profile-label">{{ item.label }}</span>
<span class="profile-desc">{{ item.desc }}</span>
</ElRadio>
</ElRadioGroup>
<ElAlert
v-if="wizard.profile === 'auto' || wizard.profile === 'jiancai'"
type="warning"
:closable="false"
class="profile-alert"
title="push.prefix 全局唯一:同一档案的第二个租户会被 400 拒绝"
description="届时改选 blank(创建后经配置中心改前缀),或先修改占用方租户的前缀。"
/>
<ElAlert
v-else-if="wizard.profile === 'blank'"
type="info"
:closable="false"
class="profile-alert"
title="blank:墙与提示词取建材出厂档案"
description="品牌 / 推送等身份组取出厂默认(push.prefix 为空,不占全局唯一位);上线前建议到配置中心按业务改写。"
/>
</template>
<!-- 步骤 4:管理员账号 -->
<template v-else>
<ElForm label-width="100px" @submit.prevent>
<ElFormItem label="管理员用户名" required>
<ElInput v-model="wizard.adminUsername" maxlength="64" placeholder="全局唯一" />
</ElFormItem>
<ElFormItem label="初始密码" required>
<ElInput
v-model="wizard.adminPassword"
type="password"
show-password
autocomplete="new-password"
placeholder="管理员首次登录将被强制修改密码"
/>
</ElFormItem>
</ElForm>
<p class="dialog-tip"> 创建成功后管理员首登需改密(must_change_password 强制门)。 </p>
</template>
<!-- 内联错误:步骤校验 / 后端 400(含撞车与 prefix 引导文案)常驻展示 -->
<ElAlert
v-if="wizardError"
type="error"
:closable="false"
class="wizard-error"
:title="wizardError"
/>
</div>
<template #footer>
<ElButton v-if="wizardStep > 0" :disabled="wizardCreating" @click="wizardPrev">
上一步
</ElButton>
<ElButton v-if="wizardStep < 3" type="primary" @click="wizardNext">下一步</ElButton>
<ElButton v-else type="primary" :loading="wizardCreating" @click="wizardCreate">
创建租户
</ElButton>
</template>
</ElDialog>
<!-- ==================== 新建站点弹窗 ==================== -->
<ElDialog
v-model="siteDialogVisible"
title="新建站点"
width="620px"
append-to-body
:close-on-click-modal="false"
>
<ElForm label-position="top" @submit.prevent>
<div class="site-basic-row">
<ElFormItem label="站点 slug" required>
<ElInput
v-model="siteForm.slug"
maxlength="64"
placeholder="小写字母、数字、连字符(创建后不可改)"
/>
</ElFormItem>
<ElFormItem label="站点名称">
<ElInput v-model="siteForm.name" maxlength="128" placeholder="留空则同 slug" />
</ElFormItem>
</div>
<SiteOverlayFields :form="siteForm" />
</ElForm>
<p class="dialog-tip">
站点创建后,域名经站点列表的「绑定域名」挂载;覆盖层可随时经「编辑门面」整组更新。
</p>
<template #footer>
<ElButton @click="siteDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="siteCreating" @click="onCreateSite"> 创建站点 </ElButton>
</template>
</ElDialog>
<!-- ==================== 编辑门面覆盖层弹窗(Task 7a 顺手项) ==================== -->
<ElDialog
v-model="overlayEditVisible"
:title="`编辑门面覆盖层(站点 ${overlayEditSiteSlug})`"
width="620px"
append-to-body
:close-on-click-modal="false"
>
<ElForm label-position="top" @submit.prevent>
<SiteOverlayFields :form="overlayEditForm" />
</ElForm>
<ElAlert
v-if="overlayUnknownKeys.length > 0"
type="warning"
:closable="false"
class="overlay-edit-alert"
:title="`覆盖层含未支持编辑的键:${overlayUnknownKeys.join('、')}——保存将整组替换,未识别键会被移除`"
/>
<p class="dialog-tip">
保存按整组替换提交:留空字段即剔除覆盖、恢复继承租户默认品牌(与新建站点同一语义)。
</p>
<template #footer>
<ElButton @click="overlayEditVisible = false">取消</ElButton>
<ElButton type="primary" :loading="overlayEditSaving" @click="onSaveOverlay">
保存
</ElButton>
</template>
</ElDialog>
<!-- ==================== 绑定域名弹窗 ==================== -->
<ElDialog
v-model="domainDialogVisible"
:title="`绑定域名(站点 ${domainSiteSlug})`"
width="520px"
append-to-body
:close-on-click-modal="false"
@closed="domainInput = ''"
>
<ElForm label-width="80px" @submit.prevent>
<ElFormItem label="域名" required>
<ElInput
v-model="domainInput"
placeholder="example.com(入库自动归一:小写 / 去端口 / 去尾点)"
@keyup.enter="onBindDomain"
/>
</ElFormItem>
</ElForm>
<!-- 撞车 400 的归属信息(后端 detail:已绑定租户 x 的站点 y)内联常驻 -->
<ElAlert
v-if="domainError"
type="error"
:closable="false"
class="wizard-error"
:title="domainError"
/>
<p class="dialog-tip">域名全局唯一(跨租户也不可重复绑定);绑定成功立即生效。</p>
<template #footer>
<ElButton @click="domainDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="domainBinding" @click="onBindDomain"> 绑定 </ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import SiteOverlayFields from '@/components/business/SiteOverlayFields/index.vue'
import {
OVERLAY_LABELS,
blankOverlayForm,
buildOverlay,
overlayToForm,
type OverlayFormState
} from '@/components/business/SiteOverlayFields/model'
import {
addSiteDomain,
createSite,
createTenant,
deleteSiteDomain,
fetchTenantDetail,
fetchTenantList,
patchSite,
patchTenant,
type SiteSummary,
type TenantAdminSummary,
type TenantDetailResponse,
type TenantListItem
} from '@/api/tenants'
defineOptions({ name: 'TenantList' })
/** slug 规则(与后端 SLUG_RE 同一事实源文案) */
const SLUG_RE = /^[a-z0-9-]+$/
/** 出厂档案选项(Phase 2 创建向导语义;撞车引导文案照 tenant_routes.py) */
const PROFILE_OPTIONS = [
{
value: 'auto',
label: 'auto',
desc: '通用外贸出厂档案(品牌 / 推送 / 提示词全套,push.prefix=ai-kefu)'
},
{
value: 'jiancai',
label: 'jiancai',
desc: '建材行业出厂档案(西班牙语墙 / 建材词表,push.prefix=ai-kefu-jc)'
},
{
value: 'blank',
label: 'blank',
desc: '空白档案——品牌 / 推送等身份组取出厂默认,不携带对方业务身份'
}
] as const
function overlayKeyLabel(key: string): string {
return OVERLAY_LABELS[key] ?? key
}
/** 管理员概要 → 用户名串(模板不写带类型注解的内联箭头) */
function adminNames(admins: TenantAdminSummary[]): string {
return admins.map((a) => a.username).join('、')
}
// ---- 租户列表 -------------------------------------------------------------------
const tenants = ref<TenantListItem[]>([])
const listLoading = ref(false)
const listError = ref('')
const listSeq = ref(0)
const statusFilter = ref<'active' | 'disabled' | ''>('')
const filteredTenants = computed(() =>
statusFilter.value
? tenants.value.filter((t) => t.status === statusFilter.value)
: tenants.value
)
async function loadList(): Promise<void> {
const seq = ++listSeq.value
listLoading.value = true
listError.value = ''
try {
const res = await fetchTenantList()
if (seq !== listSeq.value) return
tenants.value = res.items
} catch (error) {
if (seq !== listSeq.value) return
listError.value = error instanceof Error ? error.message : '租户列表加载失败'
} finally {
if (seq === listSeq.value) listLoading.value = false
}
}
/** 详情开着时的联动刷新帮助(站点/域名操作会改变域名数) */
function refreshAfterMutation(): void {
loadList()
if (detailVisible.value && detailSlug.value) loadDetail()
}
// ---- 停用 / 复启用(确认框写明 Phase 2 语义后果) ---------------------------------
async function onToggleTenant(row: TenantListItem): Promise<void> {
const disabling = row.status === 'active'
const tip = disabling
? '停用后该租户官网挂件将进入 503 服务暂停页,全部域名服务暂停(后台数据与列表仍可见)。'
: '复启用后该租户全部域名立即恢复服务。'
try {
await ElMessageBox.confirm(
`${disabling ? '停用' : '复启用'}租户「${row.name}」(@${row.slug})?${tip}`,
disabling ? '停用租户' : '复启用租户',
{
type: 'warning',
confirmButtonText: disabling ? '停用' : '复启用',
cancelButtonText: '取消'
}
)
} catch {
return // 用户取消
}
try {
await patchTenant(row.slug, { status: disabling ? 'disabled' : 'active' })
ElMessage.success(disabling ? '租户已停用(官网挂件 503 服务暂停)' : '租户已复启用')
refreshAfterMutation()
} catch {
// 失败提示由 client 全局拦截器统一弹出
}
}
// ---- 改名(slug 不可变——不提供改 slug 入口) --------------------------------------
const renameVisible = ref(false)
const renameSlug = ref('')
const renameName = ref('')
const renaming = ref(false)
function openRename(row: TenantListItem): void {
renameSlug.value = row.slug
renameName.value = row.name
renameVisible.value = true
}
async function onRename(): Promise<void> {
const name = renameName.value.trim()
if (!name) {
ElMessage.warning('名称不能为空')
return
}
renaming.value = true
try {
await patchTenant(renameSlug.value, { name })
ElMessage.success('已改名')
renameVisible.value = false
refreshAfterMutation()
} catch {
// 全局拦截器已提示(如 400 名称长度)
} finally {
renaming.value = false
}
}
// ---- 详情抽屉 + 站点管理 -----------------------------------------------------------
const detailVisible = ref(false)
const detailSlug = ref('')
const detail = ref<TenantDetailResponse | null>(null)
const detailLoading = ref(false)
const detailError = ref('')
const detailSeq = ref(0)
function openDetail(row: TenantListItem): void {
detailSlug.value = row.slug
detail.value = null
detailError.value = ''
detailVisible.value = true
loadDetail()
}
async function loadDetail(): Promise<void> {
if (!detailSlug.value) return
const seq = ++detailSeq.value
detailLoading.value = true
detailError.value = ''
try {
const res = await fetchTenantDetail(detailSlug.value)
if (seq !== detailSeq.value || !detailVisible.value) return
detail.value = res
} catch (error) {
if (seq !== detailSeq.value) return
detailError.value = error instanceof Error ? error.message : '详情加载失败'
} finally {
if (seq === detailSeq.value) detailLoading.value = false
}
}
function onDetailClosed(): void {
detailSeq.value++ // 使在途请求作废
detail.value = null
detailError.value = ''
}
/** 站点覆盖键列表(空值键也计入——展示实际落库形状) */
function overlayKeys(site: SiteSummary): string[] {
return Object.keys(site.brand_overlay ?? {})
}
// ---- 站点停用 / 复启用 -------------------------------------------------------------
async function onToggleSite(site: SiteSummary): Promise<void> {
const disabling = site.status === 'active'
const tip = disabling
? '站点停用后,该站点全部域名的访问将返回 404(区别于租户停用的 503 服务暂停页)。'
: '复启用后该站点域名恢复服务。'
try {
await ElMessageBox.confirm(
`${disabling ? '停用' : '复启用'}站点「${site.name}」(${detailSlug.value}/${site.slug})?${tip}`,
disabling ? '停用站点' : '复启用站点',
{
type: 'warning',
confirmButtonText: disabling ? '停用' : '复启用',
cancelButtonText: '取消'
}
)
} catch {
return
}
try {
await patchSite(detailSlug.value, site.slug, {
status: disabling ? 'disabled' : 'active'
})
ElMessage.success(disabling ? '站点已停用(该站点域名 404)' : '站点已复启用')
refreshAfterMutation()
} catch {
// 全局拦截器已提示
}
}
// ---- 域名绑定 / 解绑 ----------------------------------------------------------------
const domainDialogVisible = ref(false)
const domainSiteSlug = ref('')
const domainInput = ref('')
const domainBinding = ref(false)
const domainError = ref('')
function openDomainDialog(site: SiteSummary): void {
domainSiteSlug.value = site.slug
domainInput.value = ''
domainError.value = ''
domainDialogVisible.value = true
}
async function onBindDomain(): Promise<void> {
const value = domainInput.value.trim()
if (!value) {
domainError.value = '域名不能为空'
return
}
domainBinding.value = true
domainError.value = ''
try {
await addSiteDomain(
detailSlug.value,
domainSiteSlug.value,
{ domain: value },
{ showErrorMessage: false }
)
ElMessage.success(`域名已绑定到站点 ${domainSiteSlug.value}`)
domainDialogVisible.value = false
refreshAfterMutation()
} catch (error) {
// 撞车 400 detail 带归属("已绑定租户 x 的站点 y")——内联常驻展示
domainError.value = error instanceof Error ? error.message : '绑定失败'
} finally {
domainBinding.value = false
}
}
async function onUnbindDomain(site: SiteSummary, domain: string): Promise<void> {
try {
await ElMessageBox.confirm(
`解绑域名 ${domain}(站点 ${site.slug})?解绑后该域名立即 404。`,
'解绑域名',
{ type: 'warning', confirmButtonText: '解绑', cancelButtonText: '取消' }
)
} catch {
return
}
try {
await deleteSiteDomain(detailSlug.value, site.slug, domain)
ElMessage.success('域名已解绑')
refreshAfterMutation()
} catch {
// 全局拦截器已提示(如 404 已被他人解绑)
}
}
// ---- 新建站点(门面覆盖层:留空=继承租户默认) ---------------------------------------
const siteDialogVisible = ref(false)
const siteCreating = ref(false)
function blankSiteForm(): OverlayFormState & { slug: string; name: string } {
return { slug: '', name: '', ...blankOverlayForm() }
}
const siteForm = reactive(blankSiteForm())
function openSiteDialog(): void {
Object.assign(siteForm, blankSiteForm())
siteDialogVisible.value = true
}
async function onCreateSite(): Promise<void> {
const slug = siteForm.slug.trim()
if (!SLUG_RE.test(slug)) {
ElMessage.warning('站点 slug 只允许小写字母、数字和连字符')
return
}
siteCreating.value = true
try {
await createSite(detailSlug.value, {
slug,
name: siteForm.name.trim() || undefined,
brand_overlay: buildOverlay(siteForm)
})
ElMessage.success('站点已创建')
siteDialogVisible.value = false
refreshAfterMutation()
} catch {
// 全局拦截器已提示(如 400 站点 slug 已存在)
} finally {
siteCreating.value = false
}
}
// ---- 编辑门面覆盖层(Task 7a 顺手项:PATCH sites 的 brand_overlay 整字典) ----------
const overlayEditVisible = ref(false)
const overlayEditSaving = ref(false)
const overlayEditSiteSlug = ref('')
const overlayEditForm = reactive(blankOverlayForm())
/** 落库 overlay 中未支持编辑的键(保存整组替换会丢弃——弹窗内警示) */
const overlayUnknownKeys = ref<string[]>([])
function openOverlayEdit(site: SiteSummary): void {
overlayEditSiteSlug.value = site.slug
const { form, unknownKeys } = overlayToForm(site.brand_overlay)
Object.assign(overlayEditForm, form)
overlayUnknownKeys.value = unknownKeys
overlayEditVisible.value = true
}
async function onSaveOverlay(): Promise<void> {
overlayEditSaving.value = true
try {
await patchSite(detailSlug.value, overlayEditSiteSlug.value, {
brand_overlay: buildOverlay(overlayEditForm)
})
ElMessage.success('门面覆盖层已更新(热生效)')
overlayEditVisible.value = false
refreshAfterMutation()
} catch {
// 全局拦截器已提示
} finally {
overlayEditSaving.value = false
}
}
// ---- 创建向导(四步) -----------------------------------------------------------------
const wizardVisible = ref(false)
const wizardStep = ref(0)
const wizardCreating = ref(false)
const wizardError = ref('')
const wizard = reactive({
name: '',
slug: '',
domains: [''] as string[],
profile: '',
adminUsername: '',
adminPassword: ''
})
function openWizard(): void {
wizardStep.value = 0
wizardError.value = ''
Object.assign(wizard, {
name: '',
slug: '',
domains: [''],
profile: '',
adminUsername: '',
adminPassword: ''
})
wizardVisible.value = true
}
function wizardPrev(): void {
wizardError.value = ''
wizardStep.value -= 1
}
/** 当前步骤校验:空串=通过;错误文案进向导内联 alert */
function validateWizardStep(): string {
if (wizardStep.value === 0) {
if (!wizard.name.trim()) return '请填写租户名称'
if (!SLUG_RE.test(wizard.slug)) {
return 'slug 只允许小写字母、数字和连字符(如 jiancai)'
}
if (tenants.value.some((t) => t.slug === wizard.slug)) {
return `slug 已存在:${wizard.slug}(与现有租户撞车,请换一个)`
}
return ''
}
if (wizardStep.value === 2) {
if (!wizard.profile) return '请选出厂档案'
return ''
}
return ''
}
function wizardNext(): void {
const error = validateWizardStep()
if (error) {
wizardError.value = error
return
}
wizardError.value = ''
wizardStep.value += 1
}
async function wizardCreate(): Promise<void> {
if (!wizard.adminUsername.trim()) {
wizardError.value = '请填写管理员用户名'
return
}
if (!wizard.adminPassword) {
wizardError.value = '请填写管理员初始密码'
return
}
wizardCreating.value = true
wizardError.value = ''
try {
await createTenant(
{
name: wizard.name.trim(),
slug: wizard.slug,
profile: wizard.profile,
admin_username: wizard.adminUsername.trim(),
admin_password: wizard.adminPassword,
domains: wizard.domains.map((d) => d.trim()).filter(Boolean)
},
{ showErrorMessage: false }
)
wizardVisible.value = false
ElMessage.success('租户已创建,管理员首登需改密')
refreshAfterMutation()
} catch (error) {
// 400 预检撞车 detail(slug 已存在 / 用户名被占 / 域名归属 /
// prefix 引导改 blank 文案)在向导内联常驻展示
wizardError.value = error instanceof Error ? error.message : '创建失败'
} finally {
wizardCreating.value = false
}
}
// ---- 装配 ---------------------------------------------------------------------------
onMounted(loadList)
</script>
<style scoped lang="scss">
.tenants-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.page-sub {
margin: 4px 0 0;
font-size: 13px;
color: var(--art-gray-500);
}
}
.section-card {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-weight: 600;
}
}
.load-error {
margin-bottom: 12px;
}
.muted {
color: var(--art-gray-400);
}
.mono {
font-family: var(--art-font-family-mono, monospace);
font-size: 12px;
}
// ---- 列表行 -----------------------------------------------------------------------
.tenant-name {
font-weight: 600;
}
.tenant-slug {
margin-left: 6px;
font-size: 12px;
color: var(--art-gray-400);
}
// ---- 详情抽屉 ---------------------------------------------------------------------
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.drawer-title {
font-size: 16px;
font-weight: 600;
}
}
.detail-body {
min-height: 120px;
}
.detail-section {
margin-bottom: 20px;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
}
.section-tip {
margin: 8px 0 0;
font-size: 12px;
color: var(--art-gray-400);
}
.domain-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.overlay-hint {
color: var(--el-color-primary);
cursor: help;
}
// ---- 弹窗通用 ---------------------------------------------------------------------
.dialog-tip {
margin: 8px 0 0;
font-size: 12px;
color: var(--art-gray-400);
}
// ---- 创建向导 ---------------------------------------------------------------------
.wizard-steps {
margin-bottom: 20px;
}
.wizard-body {
min-height: 180px;
}
.domain-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.profile-group {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
.el-radio {
align-items: flex-start;
height: auto;
margin-right: 0;
padding: 8px 0;
.profile-label {
font-weight: 600;
}
.profile-desc {
display: block;
margin-top: 2px;
font-size: 12px;
font-weight: 400;
color: var(--art-gray-500);
white-space: normal;
}
}
}
.profile-alert {
margin-top: 12px;
}
.wizard-error {
margin-top: 12px;
}
// ---- 新建站点 ---------------------------------------------------------------------
.site-basic-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
// ---- 编辑门面 ---------------------------------------------------------------------
.overlay-edit-alert {
margin-bottom: 8px;
}
</style>