2026-10-13 18:27:56 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
租户管理与站点管理(Phase 3 Task 6,仅超管——菜单 roles:['super'] 已挡)
|
|
|
|
|
|
- 列表:slug/名称/状态/域名数/版本号/管理员概要 + 状态筛选 + 行操作
|
|
|
|
|
|
(详情抽屉 / 改名 / 停用 / 复启用)
|
|
|
|
|
|
- 创建向导(ElSteps 四步):①名称+slug(^[a-z0-9-]+$ + 对现列表撞车提示)
|
|
|
|
|
|
→ ②绑定域名(可多填,进 main 站点)→ ③出厂档案 auto/jiancai/blank
|
|
|
|
|
|
(blank=墙与提示词取建材出厂档案;同档案第二个租户会撞 push.prefix
|
|
|
|
|
|
全局唯一 → 400 引导改 blank 或先改占用方)→ ④管理员账号 → 创建
|
|
|
|
|
|
(201 后列表刷新 + 提示"租户已创建,管理员首登需改密")
|
|
|
|
|
|
- 站点管理(详情抽屉内):站点列表/新建(门面覆盖层表单,留空=继承租户
|
2026-10-13 19:27:08 +08:00
|
|
|
|
默认)/编辑门面(Task 7a:PATCH brand_overlay 整字典,留空剔除,共用
|
|
|
|
|
|
SiteOverlayFields 组件)/停用复启用/域名绑定解绑(撞车 400 的归属信息
|
|
|
|
|
|
内联展示)
|
2026-10-13 18:27:56 +08:00
|
|
|
|
- 停用后果文案(Phase 2 语义):租户停用=官网挂件 503 服务暂停页;
|
|
|
|
|
|
站点停用=该站点域名 404(不是服务暂停)——确认框写明
|
|
|
|
|
|
- slug 不可变(后端 PATCH 带 slug 键 400)——界面不提供改 slug 入口
|
|
|
|
|
|
-->
|
2026-10-13 14:51:24 +08:00
|
|
|
|
<template>
|
2026-10-13 18:27:56 +08:00
|
|
|
|
<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">
|
2026-10-13 14:51:24 +08:00
|
|
|
|
<template #header>
|
2026-10-13 18:27:56 +08:00
|
|
|
|
<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>
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</ElCard>
|
2026-10-13 18:27:56 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- ==================== 租户详情抽屉(站点管理) ==================== -->
|
|
|
|
|
|
<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>
|
2026-10-13 19:27:08 +08:00
|
|
|
|
<ElTableColumn label="操作" width="216" fixed="right">
|
2026-10-13 18:27:56 +08:00
|
|
|
|
<template #default="{ row }">
|
2026-10-13 19:27:08 +08:00
|
|
|
|
<ElButton type="primary" link size="small" @click="openOverlayEdit(row)">
|
|
|
|
|
|
编辑门面
|
|
|
|
|
|
</ElButton>
|
2026-10-13 18:27:56 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-13 19:27:08 +08:00
|
|
|
|
<SiteOverlayFields :form="siteForm" />
|
2026-10-13 18:27:56 +08:00
|
|
|
|
</ElForm>
|
|
|
|
|
|
<p class="dialog-tip">
|
2026-10-13 19:27:08 +08:00
|
|
|
|
站点创建后,域名经站点列表的「绑定域名」挂载;覆盖层可随时经「编辑门面」整组更新。
|
2026-10-13 18:27:56 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<ElButton @click="siteDialogVisible = false">取消</ElButton>
|
|
|
|
|
|
<ElButton type="primary" :loading="siteCreating" @click="onCreateSite"> 创建站点 </ElButton>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElDialog>
|
|
|
|
|
|
|
2026-10-13 19:27:08 +08:00
|
|
|
|
<!-- ==================== 编辑门面覆盖层弹窗(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>
|
|
|
|
|
|
|
2026-10-13 18:27:56 +08:00
|
|
|
|
<!-- ==================== 绑定域名弹窗 ==================== -->
|
|
|
|
|
|
<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>
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-10-13 18:27:56 +08:00
|
|
|
|
import { ElMessageBox } from 'element-plus'
|
2026-10-13 19:27:08 +08:00
|
|
|
|
import SiteOverlayFields from '@/components/business/SiteOverlayFields/index.vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
OVERLAY_LABELS,
|
|
|
|
|
|
blankOverlayForm,
|
|
|
|
|
|
buildOverlay,
|
|
|
|
|
|
overlayToForm,
|
|
|
|
|
|
type OverlayFormState
|
|
|
|
|
|
} from '@/components/business/SiteOverlayFields/model'
|
2026-10-13 18:27:56 +08:00
|
|
|
|
import {
|
|
|
|
|
|
addSiteDomain,
|
|
|
|
|
|
createSite,
|
|
|
|
|
|
createTenant,
|
|
|
|
|
|
deleteSiteDomain,
|
|
|
|
|
|
fetchTenantDetail,
|
|
|
|
|
|
fetchTenantList,
|
|
|
|
|
|
patchSite,
|
|
|
|
|
|
patchTenant,
|
|
|
|
|
|
type SiteSummary,
|
|
|
|
|
|
type TenantAdminSummary,
|
|
|
|
|
|
type TenantDetailResponse,
|
|
|
|
|
|
type TenantListItem
|
|
|
|
|
|
} from '@/api/tenants'
|
|
|
|
|
|
|
2026-10-13 14:51:24 +08:00
|
|
|
|
defineOptions({ name: 'TenantList' })
|
2026-10-13 18:27:56 +08:00
|
|
|
|
|
|
|
|
|
|
/** 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)
|
|
|
|
|
|
|
2026-10-13 19:27:08 +08:00
|
|
|
|
function blankSiteForm(): OverlayFormState & { slug: string; name: string } {
|
|
|
|
|
|
return { slug: '', name: '', ...blankOverlayForm() }
|
2026-10-13 18:27:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-13 19:27:08 +08:00
|
|
|
|
brand_overlay: buildOverlay(siteForm)
|
2026-10-13 18:27:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
ElMessage.success('站点已创建')
|
|
|
|
|
|
siteDialogVisible.value = false
|
|
|
|
|
|
refreshAfterMutation()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 全局拦截器已提示(如 400 站点 slug 已存在)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
siteCreating.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 19:27:08 +08:00
|
|
|
|
// ---- 编辑门面覆盖层(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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 18:27:56 +08:00
|
|
|
|
// ---- 创建向导(四步) -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
2026-10-13 18:27:56 +08:00
|
|
|
|
.tenants-page {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 16px;
|
2026-10-13 14:51:24 +08:00
|
|
|
|
padding: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 18:27:56 +08:00
|
|
|
|
.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 {
|
2026-10-13 14:51:24 +08:00
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
2026-10-13 18:27:56 +08:00
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 19:27:08 +08:00
|
|
|
|
// ---- 编辑门面 ---------------------------------------------------------------------
|
2026-10-13 18:27:56 +08:00
|
|
|
|
|
2026-10-13 19:27:08 +08:00
|
|
|
|
.overlay-edit-alert {
|
|
|
|
|
|
margin-bottom: 8px;
|
2026-10-13 18:27:56 +08:00
|
|
|
|
}
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</style>
|