cac91e9e3e
Co-Authored-By: Claude Code <noreply@anthropic.com>
1128 lines
37 KiB
Vue
1128 lines
37 KiB
Vue
<!--
|
||
租户管理与站点管理(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>
|