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

1128 lines
37 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.
<!--
租户管理与站点管理(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>