feat(distribution): 分销站点管理支持完整 CRUD、安全删除保护与管理员密码重置

- 后端支持分销站点编辑(域名唯一性校验、收号压价点位及税率更新)
- 后端支持分销站点安全删除校验(阻断存在业务订单、财务流水、结算批次或钱包余额的站点删除;对干净站点连带清理分成规则、展示账号绑定、支付通道配置及专有站长账号)
- 后端支持站长账号展示与超级管理员重置站长密码
- 主管理后台与分销后台前端全面支持筛选搜索、编辑模态窗、状态快捷切换、站长密码重置与安全删除确认
- 补充后端 CRUD、删除保护与密码重置覆盖率测试用例
This commit is contained in:
yml2213
2026-09-17 19:35:52 +08:00
parent 079e18bd3a
commit 1c3a75eef0
10 changed files with 1452 additions and 72 deletions
@@ -14,6 +14,10 @@ export interface DistributionSite {
listing_qrcode_url?: string
receive_ratio_delta?: number
receive_cost_delta?: number
admin_user_id?: number
admin_username?: string
admin_nickname?: string
member_type?: string
created_at: string
}
@@ -166,6 +170,21 @@ export async function updateDistributionSite(
return data.data
}
export async function deleteDistributionSite(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
`/admin/distribution/sites/${id}`
)
return data.data
}
export async function resetDistributionSiteAdminPassword(id: number, newPassword: string) {
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
`/admin/distribution/sites/${id}/admin-password`,
{ new_password: newPassword }
)
return data.data
}
export async function fetchDistributionRules(siteId?: number, mode?: string) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionRule[] }>>(
'/admin/distribution/rules',
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
Plus,
@@ -12,6 +12,11 @@ import {
Upload,
Coin,
Message,
Edit,
Delete,
Search,
Key,
ArrowDown,
} from '@element-plus/icons-vue'
import {
fetchDistributionSites,
@@ -19,6 +24,8 @@ import {
onboardDistributionSite,
syncDistributionSiteAccounts,
updateDistributionSite,
deleteDistributionSite,
resetDistributionSiteAdminPassword,
fetchDistributionSiteSMSConfig,
updateDistributionSiteSMSConfig,
testDistributionSiteSMSConfig,
@@ -39,6 +46,47 @@ const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const defaultSettlementHoldDays = ref(7)
// 筛选检索状态
const searchQuery = ref('')
const statusFilter = ref('')
const displayAccountFilter = ref('')
const filteredSites = computed(() => {
return sites.value.filter(site => {
if (statusFilter.value && site.status !== statusFilter.value) {
return false
}
if (displayAccountFilter.value !== '') {
const want = displayAccountFilter.value === 'true'
if (site.display_account_enabled !== want) return false
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase()
const matchName = site.site_name?.toLowerCase().includes(q)
const matchCode = site.site_code?.toLowerCase().includes(q)
const matchDomain = site.domain?.toLowerCase().includes(q)
const matchAdmin =
site.admin_username?.toLowerCase().includes(q) ||
site.admin_nickname?.toLowerCase().includes(q)
if (!matchName && !matchCode && !matchDomain && !matchAdmin) {
return false
}
}
return true
})
})
function handleResetFilter() {
searchQuery.value = ''
statusFilter.value = ''
displayAccountFilter.value = ''
}
function copyDomain(domain: string) {
navigator.clipboard?.writeText(domain)
ElMessage.success('域名已复制到剪贴板')
}
const onboardDialogVisible = ref(false)
const submitting = ref(false)
const onboardFormRef = ref<FormInstance>()
@@ -452,6 +500,160 @@ async function handleSyncAccounts(row: DistributionSite) {
}
}
// 站点编辑状态
const editDialogVisible = ref(false)
const editSubmitting = ref(false)
const editFormRef = ref<FormInstance>()
const editForm = reactive({
id: 0,
site_code: '',
site_name: '',
domain: '',
status: 'active',
display_account_enabled: true,
settlement_hold_days: defaultSettlementHoldDays.value,
receive_cost_delta: 0,
tax_enabled: false,
tax_rate_bps: 100,
admin_username: '',
})
const editRules: FormRules = {
site_name: [{ required: true, message: '请输入分销站点名称', trigger: 'blur' }],
domain: [{ required: true, message: '请输入分销专属域名', trigger: 'blur' }],
}
function handleOpenEdit(row: DistributionSite) {
editForm.id = row.id
editForm.site_code = row.site_code
editForm.site_name = row.site_name
editForm.domain = row.domain
editForm.status = row.status || 'active'
editForm.display_account_enabled = !!row.display_account_enabled
editForm.settlement_hold_days = row.settlement_hold_days ?? defaultSettlementHoldDays.value
editForm.receive_cost_delta = Number(row.receive_cost_delta || 0)
editForm.tax_enabled = !!row.tax_enabled
editForm.tax_rate_bps = row.tax_rate_bps ?? 100
editForm.admin_username = row.admin_username || ''
editDialogVisible.value = true
}
async function handleEditSubmit() {
if (!editFormRef.value) return
await editFormRef.value.validate(async valid => {
if (!valid) return
editSubmitting.value = true
try {
await updateDistributionSite(editForm.id, {
site_name: editForm.site_name.trim(),
domain: editForm.domain.trim(),
status: editForm.status,
display_account_enabled: editForm.display_account_enabled,
settlement_hold_days: Number(editForm.settlement_hold_days),
receive_cost_delta: Number(editForm.receive_cost_delta),
tax_enabled: editForm.tax_enabled,
tax_rate_bps: editForm.tax_enabled ? Number(editForm.tax_rate_bps) : 0,
})
ElMessage.success(`【${editForm.site_name}】分销站点信息已更新`)
editDialogVisible.value = false
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '更新分销站点信息失败'))
} finally {
editSubmitting.value = false
}
})
}
// 快速启停状态
async function handleToggleStatus(row: DistributionSite) {
const isCurrentlyActive = row.status === 'active'
const newStatus = isCurrentlyActive ? 'disabled' : 'active'
const actionText = isCurrentlyActive ? '停用' : '启用'
const confirmMsg = isCurrentlyActive
? `确认停用分销站点【${row.site_name}】?停用后该站点前台将暂停服务,无法进行租号与收号。`
: `确认重新启用分销站点【${row.site_name}】?启用后该站点将恢复正常对外运营。`
try {
await ElMessageBox.confirm(confirmMsg, `确认${actionText}分销站点`, {
confirmButtonText: `确认${actionText}`,
cancelButtonText: '取消',
type: isCurrentlyActive ? 'warning' : 'info',
})
} catch {
return
}
try {
await updateDistributionSite(row.id, { status: newStatus })
ElMessage.success(`分销站点【${row.site_name}】已${actionText}`)
await loadSites()
} catch (err) {
ElMessage.error(readError(err, `${actionText}分销站点失败`))
}
}
// 重置分销商密码状态
const resetPwdDialogVisible = ref(false)
const resetPwdSubmitting = ref(false)
const resetPwdForm = reactive({
site_id: 0,
site_name: '',
admin_username: '',
new_password: '',
})
function handleOpenResetPassword(row: DistributionSite) {
resetPwdForm.site_id = row.id
resetPwdForm.site_name = row.site_name
resetPwdForm.admin_username = row.admin_username || '专属管理员'
resetPwdForm.new_password = ''
resetPwdDialogVisible.value = true
}
async function handleResetPasswordSubmit() {
if (!resetPwdForm.new_password || resetPwdForm.new_password.length < 6) {
ElMessage.warning('新密码长度不能少于6位')
return
}
resetPwdSubmitting.value = true
try {
await resetDistributionSiteAdminPassword(resetPwdForm.site_id, resetPwdForm.new_password)
ElMessage.success(`【${resetPwdForm.site_name}】管理员密码重置成功!`)
resetPwdDialogVisible.value = false
} catch (err) {
ElMessage.error(readError(err, '重置分销商密码失败'))
} finally {
resetPwdSubmitting.value = false
}
}
// 删除分销站点
async function handleDeleteSite(row: DistributionSite) {
try {
await ElMessageBox.confirm(
`确定要删除分销站点【${row.site_name}】(${row.site_code}) 吗?\n此操作将永久清理该分销站及其规则、上架关系与专属管理员账号。\n若该分销站点已产生业务订单或分账流水,系统将予以拦截保护。`,
'警告:确认删除分销站点',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'error',
confirmButtonClass: 'el-button--danger',
}
)
} catch {
return
}
try {
await deleteDistributionSite(row.id)
ElMessage.success(`分销站点【${row.site_name}】已成功删除`)
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '删除分销站点失败'))
}
}
onMounted(() => {
loadSites()
fetchDistributionSiteDefaults()
@@ -486,7 +688,38 @@ onMounted(() => {
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="sites" stripe style="width: 100%">
<!-- 搜索筛选栏 -->
<div class="filter-bar">
<el-input
v-model="searchQuery"
placeholder="搜索站点名称 / 标识 / 域名 / 管理员"
clearable
:prefix-icon="Search"
style="width: 280px"
/>
<el-select v-model="statusFilter" placeholder="运行状态" clearable style="width: 130px">
<el-option label="全部状态" value="" />
<el-option label="正常运营" value="active" />
<el-option label="已停用" value="disabled" />
</el-select>
<el-select
v-model="displayAccountFilter"
placeholder="展示主站账号"
clearable
style="width: 140px"
>
<el-option label="全部" value="" />
<el-option label="已开启" value="true" />
<el-option label="已关闭" value="false" />
</el-select>
<el-button @click="handleResetFilter">重置筛选</el-button>
<el-button :icon="Refresh" @click="loadSites">刷新</el-button>
<div class="filter-stats">
共 <strong>{{ filteredSites.length }}</strong> 个分销站点
</div>
</div>
<el-table v-loading="loading" :data="filteredSites" stripe style="width: 100%">
<el-table-column label="站点名称" prop="site_name" min-width="160">
<template #default="{ row }">
<div class="site-name-cell">
@@ -500,22 +733,55 @@ onMounted(() => {
<el-tag size="small">{{ row.site_code }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定域名" prop="domain" min-width="180" />
<el-table-column label="运行状态" prop="status" width="100">
<el-table-column label="绑定域名" prop="domain" min-width="190">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '正常' : '已停用' }}
</el-tag>
<div class="domain-cell">
<span class="domain-text">{{ row.domain }}</span>
<el-button
:icon="DocumentCopy"
link
size="small"
title="复制域名"
@click="copyDomain(row.domain)"
/>
</div>
</template>
</el-table-column>
<el-table-column label="展示主站账号" prop="display_account_enabled" width="130">
<el-table-column label="分销管理员" min-width="140">
<template #default="{ row }">
<div v-if="row.admin_username" class="admin-cell">
<el-tag size="small" type="info">{{ row.admin_username }}</el-tag>
<span v-if="row.admin_nickname" class="admin-nickname"
>({{ row.admin_nickname }})</span
>
</div>
<span v-else class="text-muted">未关联</span>
</template>
</el-table-column>
<el-table-column label="运行状态" prop="status" width="110">
<template #default="{ row }">
<el-tooltip
:content="row.status === 'active' ? '点击可停用分销站点' : '点击可启用分销站点'"
placement="top"
>
<el-switch
:model-value="row.status === 'active'"
inline-prompt
active-text="正常"
inactive-text="停用"
@click.prevent="handleToggleStatus(row)"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="展示主站账号" prop="display_account_enabled" width="120">
<template #default="{ row }">
<el-tag :type="row.display_account_enabled ? 'success' : 'info'" size="small">
{{ row.display_account_enabled ? '已开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="税费设定" width="120">
<el-table-column label="税费设定" width="110">
<template #default="{ row }">
<el-tag v-if="row.tax_enabled" type="warning" size="small">
{{ (row.tax_rate_bps / 100).toFixed(1) }}%
@@ -561,8 +827,11 @@ onMounted(() => {
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="410" fixed="right">
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="Edit" @click="handleOpenEdit(row)">
编辑
</el-button>
<el-button
type="primary"
link
@@ -572,18 +841,38 @@ onMounted(() => {
>
同步商品
</el-button>
<el-button type="primary" link :icon="Clock" @click="handleOpenSettlement(row)">
结算设置
</el-button>
<el-button type="primary" link :icon="Coin" @click="handleOpenReceiveRatio(row)">
收号比例
</el-button>
<el-button type="primary" link :icon="Picture" @click="handleOpenQrcode(row)">
二维码
</el-button>
<el-button type="primary" link :icon="Message" @click="handleOpenSMS(row)">
短信配置
</el-button>
<el-dropdown trigger="click">
<el-button type="primary" link>
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :icon="Clock" @click="handleOpenSettlement(row)">
结算设置
</el-dropdown-item>
<el-dropdown-item :icon="Coin" @click="handleOpenReceiveRatio(row)">
收号比例
</el-dropdown-item>
<el-dropdown-item :icon="Picture" @click="handleOpenQrcode(row)">
发布二维码
</el-dropdown-item>
<el-dropdown-item :icon="Message" @click="handleOpenSMS(row)">
短信配置
</el-dropdown-item>
<el-dropdown-item :icon="Key" @click="handleOpenResetPassword(row)">
重置管理员密码
</el-dropdown-item>
<el-dropdown-item
divided
:icon="Delete"
style="color: #f56c6c"
@click="handleDeleteSite(row)"
>
删除站点
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
@@ -926,6 +1215,144 @@ onMounted(() => {
</el-button>
</template>
</el-dialog>
<!-- 编辑分销站点对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑分销站点" width="620px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="130px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="站点标识">
<el-tag size="default" type="info">{{ editForm.site_code }}</el-tag>
<span style="margin-left: 8px; font-size: 12px; color: #9ca3af">不可更改</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分销管理员">
<span v-if="editForm.admin_username" style="font-weight: 500">
{{ editForm.admin_username }}
</span>
<span v-else style="color: #9ca3af">未关联</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="站点名称" prop="site_name">
<el-input v-model="editForm.site_name" placeholder="请输入分销站点名称" />
</el-form-item>
<el-form-item label="分销专属域名" prop="domain">
<el-input v-model="editForm.domain" placeholder="请输入绑定的独立域名" />
</el-form-item>
<el-form-item label="运行状态" prop="status">
<el-radio-group v-model="editForm.status">
<el-radio value="active">正常运营</el-radio>
<el-radio value="disabled">暂停服务 (已停用)</el-radio>
</el-radio-group>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="展示主站账号">
<el-switch v-model="editForm.display_account_enabled" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否征税">
<el-switch v-model="editForm.tax_enabled" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16" v-if="editForm.tax_enabled">
<el-col :span="24">
<el-form-item label="综合税率 (%)">
<el-input-number
:model-value="editForm.tax_rate_bps / 100"
:min="0"
:max="50"
:step="0.1"
:precision="2"
@update:model-value="
(v: number | undefined) => (editForm.tax_rate_bps = Math.round((v || 0) * 100))
"
/>
<span class="settlement-hint">%(基点折算)</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="结算冷却期 (天)">
<el-input-number
v-model="editForm.settlement_hold_days"
:min="0"
:max="365"
:step="1"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="收号压价 (点)">
<el-input-number
v-model="editForm.receive_cost_delta"
:min="0"
:max="20"
:step="0.1"
:precision="1"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleEditSubmit">
保存修改
</el-button>
</template>
</el-dialog>
<!-- 重置分销商管理员密码对话框 -->
<el-dialog
v-model="resetPwdDialogVisible"
title="重置分销商管理员密码"
width="460px"
destroy-on-close
>
<el-alert
type="warning"
:closable="false"
show-icon
title="重置后旧密码将立即失效,分销商管理员需使用新密码重新登录独立分销后台。"
style="margin-bottom: 16px"
/>
<el-form label-width="110px">
<el-form-item label="分销站点">
<el-input :model-value="resetPwdForm.site_name" disabled />
</el-form-item>
<el-form-item label="管理员账号">
<el-input :model-value="resetPwdForm.admin_username" disabled />
</el-form-item>
<el-form-item label="新登录密码" required>
<el-input
v-model="resetPwdForm.new_password"
type="password"
show-password
placeholder="请输入至少6位新登录密码"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwdDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="resetPwdSubmitting" @click="handleResetPasswordSubmit">
确认重置密码
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -959,6 +1386,51 @@ onMounted(() => {
gap: 12px;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.filter-stats {
margin-left: auto;
font-size: 13px;
color: #6b7280;
}
.filter-stats strong {
color: #1f2937;
}
.domain-cell {
display: flex;
align-items: center;
gap: 4px;
}
.domain-text {
font-family: monospace;
font-size: 13px;
}
.admin-cell {
display: flex;
align-items: center;
gap: 4px;
}
.admin-nickname {
font-size: 12px;
color: #6b7280;
}
.text-muted {
color: #9ca3af;
font-size: 13px;
}
.site-name-cell {
display: flex;
align-items: center;