Files
hfb_sys/frontend/src/features/admin/views/AdminDistributionSitesView.vue
T

1381 lines
47 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.
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
Plus,
Shop,
Link,
DocumentCopy,
Refresh,
Clock,
Coin,
Message,
Edit,
Delete,
Search,
Key,
ArrowDown,
} from '@element-plus/icons-vue'
import {
fetchDistributionSites,
fetchDistributionSiteDefaults,
onboardDistributionSite,
syncDistributionSiteAccounts,
updateDistributionSite,
deleteDistributionSite,
resetDistributionSiteAdminPassword,
fetchDistributionSiteSMSConfig,
updateDistributionSiteSMSConfig,
testDistributionSiteSMSConfig,
type DistributionSite,
type OnboardDistributionSitePayload,
type OnboardDistributionSiteResult,
} from '@/features/admin/api/adminDistribution'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
import { smsNodeMetas } from '@/features/admin/api/smsNodeTemplates'
import {
distributionAdminBaseUrl,
distributionAdminLoginUrl,
} from '@/shared/utils/distributionAdmin'
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>()
const successDialogVisible = ref(false)
const onboardResult = ref<OnboardDistributionSiteResult | null>(null)
const settlementDialogVisible = ref(false)
const settlementSubmitting = ref(false)
const settlementForm = reactive({
site_id: 0,
site_name: '',
settlement_hold_days: defaultSettlementHoldDays.value,
})
const receiveRatioDialogVisible = ref(false)
const receiveRatioSubmitting = ref(false)
const receiveRatioForm = reactive({
site_id: 0,
site_name: '',
receive_cost_delta: 0,
})
const smsDialogVisible = ref(false)
const smsLoading = ref(false)
const smsSubmitting = ref(false)
const smsTesting = ref(false)
const smsTestCooldown = ref(0)
let smsCooldownTimer: ReturnType<typeof setInterval> | null = null
function startSmsCooldown(seconds = 60) {
smsTestCooldown.value = seconds
if (smsCooldownTimer) clearInterval(smsCooldownTimer)
smsCooldownTimer = setInterval(() => {
if (smsTestCooldown.value > 0) {
smsTestCooldown.value--
} else if (smsCooldownTimer) {
clearInterval(smsCooldownTimer)
smsCooldownTimer = null
}
}, 1000)
}
onUnmounted(() => {
if (smsCooldownTimer) clearInterval(smsCooldownTimer)
})
const smsSecretInput = ref('')
const smsTestPhone = ref('')
const smsForm = reactive({
site_id: 0,
site_name: '',
enabled: false,
provider: 'aliyun',
sign_name: '',
login_template_code: '',
access_key_id: '',
masked_secret: '',
has_secret: false,
endpoint: '',
business_templates: {} as Record<string, string>,
})
function handleOpenSettlement(row: DistributionSite) {
settlementForm.site_id = row.id
settlementForm.site_name = row.site_name
settlementForm.settlement_hold_days = row.settlement_hold_days ?? defaultSettlementHoldDays.value
settlementDialogVisible.value = true
}
async function handleSettlementSubmit() {
settlementSubmitting.value = true
try {
await updateDistributionSite(settlementForm.site_id, {
settlement_hold_days: Number(settlementForm.settlement_hold_days),
})
ElMessage.success(`【${settlementForm.site_name}】结算冷却期已更新`)
settlementDialogVisible.value = false
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '更新结算冷却期失败'))
} finally {
settlementSubmitting.value = false
}
}
function handleOpenReceiveRatio(row: DistributionSite) {
receiveRatioForm.site_id = row.id
receiveRatioForm.site_name = row.site_name
receiveRatioForm.receive_cost_delta = Number(row.receive_cost_delta || 0)
receiveRatioDialogVisible.value = true
}
async function handleReceiveRatioSubmit() {
receiveRatioSubmitting.value = true
try {
await updateDistributionSite(receiveRatioForm.site_id, {
receive_cost_delta: Number(receiveRatioForm.receive_cost_delta),
})
ElMessage.success(`【${receiveRatioForm.site_name}】收号压价已更新`)
receiveRatioDialogVisible.value = false
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '更新收号比例失败'))
} finally {
receiveRatioSubmitting.value = false
}
}
async function handleOpenSMS(row: DistributionSite) {
smsForm.site_id = row.id
smsForm.site_name = row.site_name
smsSecretInput.value = ''
smsTestPhone.value = ''
smsDialogVisible.value = true
smsLoading.value = true
try {
const cfg = await fetchDistributionSiteSMSConfig(row.id)
smsForm.enabled = cfg.enabled
smsForm.provider = cfg.provider || 'aliyun'
smsForm.sign_name = cfg.sign_name || ''
smsForm.login_template_code = cfg.login_template_code || ''
smsForm.access_key_id = cfg.access_key_id || ''
smsForm.masked_secret = cfg.masked_secret || ''
smsForm.has_secret = cfg.has_secret
smsForm.endpoint = cfg.endpoint || ''
// 逐节点初始化,未配置的节点留空表示回退主站模板。
smsForm.business_templates = Object.fromEntries(
smsNodeMetas.map(meta => [meta.code, cfg.business_templates?.[meta.code] || ''])
)
} catch (err) {
ElMessage.error(readError(err, '读取分站短信配置失败'))
} finally {
smsLoading.value = false
}
}
function trimmedBusinessTemplates() {
const out: Record<string, string> = {}
for (const meta of smsNodeMetas) {
const code = (smsForm.business_templates[meta.code] || '').trim()
if (code) out[meta.code] = code
}
return out
}
async function handleSMSSubmit() {
if (smsForm.enabled) {
if (!smsForm.sign_name.trim()) {
ElMessage.warning('启用独立短信时,短信签名不能为空')
return
}
if (!smsForm.login_template_code.trim()) {
ElMessage.warning('启用独立短信时,登录验证码模板 Code 不能为空')
return
}
if (!smsForm.access_key_id.trim()) {
ElMessage.warning('启用独立短信时,AccessKey ID 不能为空')
return
}
if (!smsForm.has_secret && !smsSecretInput.value.trim()) {
ElMessage.warning('启用独立短信时,AccessKey Secret 不能为空')
return
}
}
smsSubmitting.value = true
try {
const payload: {
enabled: boolean
provider: string
sign_name: string
login_template_code: string
access_key_id: string
access_key_secret?: string
endpoint?: string
business_templates: Record<string, string>
} = {
enabled: smsForm.enabled,
provider: smsForm.provider || 'aliyun',
sign_name: smsForm.sign_name.trim(),
login_template_code: smsForm.login_template_code.trim(),
access_key_id: smsForm.access_key_id.trim(),
endpoint: smsForm.endpoint.trim(),
business_templates: trimmedBusinessTemplates(),
}
if (smsSecretInput.value.trim()) {
payload.access_key_secret = smsSecretInput.value.trim()
}
await updateDistributionSiteSMSConfig(smsForm.site_id, payload)
ElMessage.success(`【${smsForm.site_name}】短信配置已保存`)
smsDialogVisible.value = false
} catch (err) {
ElMessage.error(readError(err, '保存短信配置失败'))
} finally {
smsSubmitting.value = false
}
}
async function handleSMSTest() {
const phone = smsTestPhone.value.trim()
if (!phone) {
ElMessage.warning('请输入接收测试短信的手机号')
return
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
ElMessage.warning('手机号格式不正确,请输入有效的11位手机号码')
return
}
if (!smsForm.access_key_id.trim()) {
ElMessage.warning('请先填写 AccessKey ID')
return
}
if (!smsForm.has_secret && !smsSecretInput.value.trim()) {
ElMessage.warning('请先填写 AccessKey Secret')
return
}
if (!smsForm.sign_name.trim()) {
ElMessage.warning('请先填写短信签名')
return
}
if (!smsForm.login_template_code.trim()) {
ElMessage.warning('请先填写验证码模板 Code')
return
}
try {
await ElMessageBox.confirm(
`将向手机号 ${phone} 发送一条测试验证码,并消耗该站点的阿里云短信额度,是否继续?`,
'确认发送测试短信',
{
confirmButtonText: '确认发送',
cancelButtonText: '取消',
type: 'info',
}
)
} catch {
return
}
smsTesting.value = true
try {
const payload = {
phone,
enabled: true,
provider: smsForm.provider || 'aliyun',
sign_name: smsForm.sign_name.trim(),
login_template_code: smsForm.login_template_code.trim(),
access_key_id: smsForm.access_key_id.trim(),
endpoint: smsForm.endpoint.trim() || undefined,
access_key_secret: smsSecretInput.value.trim() || undefined,
}
await testDistributionSiteSMSConfig(smsForm.site_id, payload)
ElMessage.success('测试短信发送成功,请查收手机短信')
startSmsCooldown(60)
} catch (err) {
ElMessage.error(readError(err, '测试短信发送失败'))
} finally {
smsTesting.value = false
}
}
const onboardForm = reactive({
site_name: '',
site_code: '',
domain: '',
admin_username: '',
admin_password: '',
admin_nickname: '',
member_type: 'operator',
settlement_hold_days: defaultSettlementHoldDays.value,
display_account_enabled: true,
tax_enabled: false,
tax_rate_bps: 100,
})
const onboardRules: FormRules = {
site_name: [{ required: true, message: '请输入分销站点名称', trigger: 'blur' }],
site_code: [
{ required: true, message: '请输入站点唯一标识', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '仅支持英文字母、数字及下划线', trigger: 'blur' },
],
domain: [{ required: true, message: '请输入分销专属域名', trigger: 'blur' }],
admin_username: [{ required: true, message: '请输入分销管理员账号', trigger: 'blur' }],
admin_password: [
{ required: true, message: '请输入初始密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
],
}
async function loadSites() {
loading.value = true
try {
sites.value = await fetchDistributionSites()
} catch (err) {
ElMessage.error(readError(err, '获取分销站点列表失败'))
} finally {
loading.value = false
}
}
function handleOpenOnboard() {
onboardForm.site_name = ''
onboardForm.site_code = ''
onboardForm.domain = ''
onboardForm.admin_username = ''
onboardForm.admin_password = ''
onboardForm.admin_nickname = ''
onboardForm.member_type = 'operator'
onboardForm.settlement_hold_days = defaultSettlementHoldDays.value
onboardForm.display_account_enabled = true
onboardForm.tax_enabled = false
onboardForm.tax_rate_bps = 100
onboardDialogVisible.value = true
}
async function handleOnboardSubmit() {
if (!onboardFormRef.value) return
await onboardFormRef.value.validate(async valid => {
if (!valid) return
submitting.value = true
try {
const payload: OnboardDistributionSitePayload = {
site_name: onboardForm.site_name.trim(),
site_code: onboardForm.site_code.trim(),
domain: onboardForm.domain.trim(),
admin_username: onboardForm.admin_username.trim(),
admin_password: onboardForm.admin_password,
admin_nickname: onboardForm.admin_nickname.trim() || undefined,
member_type: onboardForm.member_type,
settlement_hold_days: Number(onboardForm.settlement_hold_days),
display_account_enabled: onboardForm.display_account_enabled,
tax_enabled: onboardForm.tax_enabled,
tax_rate_bps: onboardForm.tax_enabled ? onboardForm.tax_rate_bps : 0,
}
const res = await onboardDistributionSite(payload)
ElMessage.success('分销站点与管理员账号已成功开通!')
onboardDialogVisible.value = false
onboardResult.value = res
successDialogVisible.value = true
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '开通分销商失败'))
} finally {
submitting.value = false
}
})
}
function handleCopyInfo() {
if (!onboardResult.value) return
const text = `分销站点:${onboardResult.value.site.site_name} (${onboardResult.value.site.site_code})\n分销域名:${onboardResult.value.site.domain}\n后台登录地址:${distributionAdminLoginUrl()}\n管理员账号:${onboardResult.value.member.username}`
navigator.clipboard?.writeText(text)
ElMessage.success('已复制分销商开通凭证到剪贴板')
}
function openDistributionAdmin() {
window.open(distributionAdminBaseUrl(), '_blank')
}
const syncingSiteId = ref<number | null>(null)
async function handleSyncAccounts(row: DistributionSite) {
syncingSiteId.value = row.id
try {
const res = await syncDistributionSiteAccounts(row.id)
const reenabled = res.reenabled_count ? `,恢复展示 ${res.reenabled_count} 个` : ''
ElMessage.success(
`同步成功!为【${row.site_name}】新增货架商品 ${res.synced_count} 个,已存在 ${res.skipped_count} 个${reenabled}(本次候选 ${res.total_active} 个)`
)
} catch (err) {
ElMessage.error(readError(err, '同步商品失败'))
} finally {
syncingSiteId.value = null
}
}
// 站点编辑状态
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()
.then(res => {
const days = res?.default_settlement_hold_days
if (typeof days === 'number' && days >= 0 && days <= 365) {
defaultSettlementHoldDays.value = days
}
})
.catch(() => {
// 拉取失败时保持内置默认值 7,不弹错误提示
})
})
</script>
<template>
<div class="distribution-sites-view">
<div class="page-header">
<div>
<h2 class="page-title">分销站点管理</h2>
<p class="page-subtitle">
开通分销站、配置结算冷却期与收号比例上限。发布群进群码在「分站二维码池」按站点维护。利润按
35% 收号 / 40% 卖出 / 25% 服务费切分。
</p>
</div>
<div class="header-actions">
<el-button :icon="Link" @click="openDistributionAdmin"> 打开独立分销后台 </el-button>
<el-button type="primary" :icon="Plus" @click="handleOpenOnboard">
一键开通分销商
</el-button>
</div>
</div>
<el-card shadow="never">
<!-- 搜索筛选栏 -->
<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">
<el-icon class="site-icon"><Shop /></el-icon>
<strong>{{ row.site_name }}</strong>
</div>
</template>
</el-table-column>
<el-table-column label="站点标识" prop="site_code" min-width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.site_code }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定域名" prop="domain" min-width="190">
<template #default="{ row }">
<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="分销管理员" 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="用户体量" width="130">
<template #default="{ row }">
<div class="user-stats-cell">
<div>
<strong style="color: #67c23a">{{ row.user_count || 0 }}</strong> 注册
</div>
<div class="text-muted" style="font-size: 12px">
{{ row.order_user_count || 0 }} 成交客户
</div>
</div>
</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="110">
<template #default="{ row }">
<el-tag v-if="row.tax_enabled" type="warning" size="small">
{{ (row.tax_rate_bps / 100).toFixed(1) }}%
</el-tag>
<el-tag v-else type="info" size="small">免税</el-tag>
</template>
</el-table-column>
<el-table-column label="结算冷却期" width="110">
<template #default="{ row }">
<el-tag :type="row.settlement_hold_days > 0 ? 'primary' : 'success'" size="small">
{{ row.settlement_hold_days > 0 ? `${row.settlement_hold_days} 天` : '立即释放' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="收号压价" width="110">
<template #default="{ row }">
<el-tag
:type="Number(row.receive_cost_delta || 0) > 0 ? 'warning' : 'info'"
size="small"
>
{{
Number(row.receive_cost_delta || 0) > 0
? `低 ${Number(row.receive_cost_delta).toFixed(1)} 点`
: '与主站一致'
}}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<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
:icon="Refresh"
:loading="syncingSiteId === row.id"
@click="handleSyncAccounts(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="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>
</el-card>
<!-- 一键开通分销商对话框 -->
<el-dialog v-model="onboardDialogVisible" title="一键开通分销商" width="680px" destroy-on-close>
<el-form ref="onboardFormRef" :model="onboardForm" :rules="onboardRules" label-width="140px">
<div class="form-section-title">1. 分销站点基础信息</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="站点名称" prop="site_name">
<el-input v-model="onboardForm.site_name" placeholder="例如:广州游戏分销站" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="站点唯一标识" prop="site_code">
<el-input v-model="onboardForm.site_code" placeholder="例如:gz_dist" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分销专属域名" prop="domain">
<el-input v-model="onboardForm.domain" placeholder="例如:gz.distribution.hao858.com" />
</el-form-item>
<div class="form-section-title">2. 分销商管理员账号</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="登录用户名" prop="admin_username">
<el-input v-model="onboardForm.admin_username" placeholder="例如:gz_admin" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="初始登录密码" prop="admin_password">
<el-input
v-model="onboardForm.admin_password"
type="password"
show-password
placeholder="至少6位"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="站长姓名/昵称" prop="admin_nickname">
<el-input v-model="onboardForm.admin_nickname" placeholder="例如:张站长" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="初始角色权限" prop="member_type">
<el-select v-model="onboardForm.member_type" style="width: 100%">
<el-option label="分销运营 (operator)" value="operator" />
<el-option label="分销财务 (finance)" value="finance" />
<el-option label="客服人员 (customer_service)" value="customer_service" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="form-section-title">3. 结算冷却期与展示</div>
<el-alert
type="info"
:closable="false"
show-icon
title="开通后四种分成模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖为 75% + 25%)。之后可在「分销规则」页单独调整。"
style="margin-bottom: 12px"
/>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="结算冷却期 (天)">
<el-input-number
v-model="onboardForm.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-switch v-model="onboardForm.display_account_enabled" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="是否征税">
<el-switch v-model="onboardForm.tax_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onboardDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleOnboardSubmit">
立即开通并生成分销商
</el-button>
</template>
</el-dialog>
<!-- 开通成功凭证对话框 -->
<el-dialog v-model="successDialogVisible" title="分销商开通成功" width="560px">
<div v-if="onboardResult">
<el-alert
type="success"
:closable="false"
show-icon
title="分销站点与管理员账号已成功初始化,请妥善保管交付凭证:"
style="margin-bottom: 16px"
/>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="分销站点">
<strong>{{ onboardResult.site.site_name }}</strong> ({{ onboardResult.site.site_code }})
</el-descriptions-item>
<el-descriptions-item label="分销专属域名">
{{ onboardResult.site.domain }}
</el-descriptions-item>
<el-descriptions-item label="分销后台登录账号">
<el-tag type="success">{{ onboardResult.member.username }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分销后台地址">
<code>{{ distributionAdminLoginUrl() }}</code>
</el-descriptions-item>
<el-descriptions-item label="初始分成规则">
四种模式均已按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% +
25%),可到规则页调整。
</el-descriptions-item>
<el-descriptions-item label="结算冷却期">
{{ onboardResult.site.settlement_hold_days }} 天
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button :icon="DocumentCopy" type="primary" @click="handleCopyInfo">
一键复制交付信息
</el-button>
<el-button @click="successDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 结算冷却期设置对话框 -->
<el-dialog
v-model="settlementDialogVisible"
title="结算冷却期设置"
width="480px"
destroy-on-close
>
<el-alert
type="info"
:closable="false"
show-icon
title="订单完成后进入冷却期,期满自动释放到商户可提现余额;0 天表示完成即释放。修改后对尚未释放的已完成订单立即生效。"
style="margin-bottom: 16px"
/>
<el-form label-width="110px">
<el-form-item label="分销站点">
<el-input :model-value="settlementForm.site_name" disabled />
</el-form-item>
<el-form-item label="冷却天数">
<el-input-number
v-model="settlementForm.settlement_hold_days"
:min="0"
:max="365"
:step="1"
/>
<span class="settlement-hint">天(0-365)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="settlementDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="settlementSubmitting" @click="handleSettlementSubmit">
保存设置
</el-button>
</template>
</el-dialog>
<!-- 分站收号压价(收号价始终不高于主站参考价) -->
<el-dialog
v-model="receiveRatioDialogVisible"
title="分站收号压价"
width="520px"
destroy-on-close
>
<el-alert
type="info"
:closable="false"
show-icon
title="分站收号价比主站更低:压价点数越大,号主在分站拿到的收号价越低。买家售价保持与主站一致,压价差进入三方分成(35% 收号 / 40% 卖出 / 25% 服务费)。0 表示与主站同价收号。"
style="margin-bottom: 16px"
/>
<el-form label-width="110px">
<el-form-item label="分销站点">
<el-input :model-value="receiveRatioForm.site_name" disabled />
</el-form-item>
<el-form-item label="收号价比主站低">
<el-input-number
v-model="receiveRatioForm.receive_cost_delta"
:min="0"
:max="20"
:step="0.1"
:precision="1"
/>
<span class="settlement-hint">点(0-20)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="receiveRatioDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="receiveRatioSubmitting"
@click="handleReceiveRatioSubmit"
>
保存设置
</el-button>
</template>
</el-dialog>
<!-- 短信配置对话框 -->
<el-dialog
v-model="smsDialogVisible"
title="分销站点独立短信服务配置"
width="620px"
destroy-on-close
>
<div v-loading="smsLoading">
<el-alert
type="info"
:closable="false"
show-icon
title="启用后,该分站用户的登录注册验证码将使用独立的签名与模板,并直接计入该分站的阿里云短信额度;未启用时自动回退使用主站默认短信通道。"
style="margin-bottom: 16px"
/>
<el-form label-width="130px">
<el-form-item label="分销站点">
<el-input :model-value="smsForm.site_name" disabled />
</el-form-item>
<el-form-item label="启用独立短信">
<el-switch v-model="smsForm.enabled" />
<span style="margin-left: 12px; font-size: 13px; color: #6b7280">
{{ smsForm.enabled ? '已启用(优先使用分站凭据)' : '未启用(回退使用主站额度)' }}
</span>
</el-form-item>
<el-form-item label="短信服务商">
<el-select v-model="smsForm.provider" style="width: 100%">
<el-option label="阿里云短信服务 (Aliyun Dysms)" value="aliyun" />
</el-select>
</el-form-item>
<el-form-item label="短信签名" :required="smsForm.enabled">
<el-input v-model="smsForm.sign_name" placeholder="例如:XX租号" />
</el-form-item>
<el-form-item label="验证码模板 Code" :required="smsForm.enabled">
<el-input v-model="smsForm.login_template_code" placeholder="例如:SMS_123456789" />
</el-form-item>
<el-form-item label="AccessKey ID" :required="smsForm.enabled">
<el-input v-model="smsForm.access_key_id" placeholder="例如:LTAI5t..." />
</el-form-item>
<el-form-item label="AccessKey Secret" :required="smsForm.enabled && !smsForm.has_secret">
<el-input
v-model="smsSecretInput"
type="password"
show-password
:placeholder="
smsForm.has_secret
? `已设置密钥 (${smsForm.masked_secret}),留空保持不变`
: '请输入 AccessKey Secret'
"
/>
</el-form-item>
<el-form-item label="API Endpoint">
<el-input v-model="smsForm.endpoint" placeholder="默认 dysmsapi.aliyuncs.com" />
</el-form-item>
<el-divider content-position="left">节点短信模板(可选)</el-divider>
<el-form-item v-for="meta in smsNodeMetas" :key="meta.code" :label="meta.name">
<el-input
v-model="smsForm.business_templates[meta.code]"
:placeholder="`留空则用主站模板,例如:${meta.templateCode}`"
/>
<div class="sms-node-hint">{{ meta.description }}</div>
</el-form-item>
<el-form-item label="连通性测试">
<div style="display: flex; gap: 8px; width: 100%">
<el-input v-model="smsTestPhone" placeholder="输入接收测试短信的手机号" />
<el-button
:loading="smsTesting"
:disabled="smsTestCooldown > 0"
@click="handleSMSTest"
>
{{ smsTestCooldown > 0 ? `重新发送(${smsTestCooldown}s)` : '发送测试' }}
</el-button>
</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="smsDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="smsSubmitting" @click="handleSMSSubmit">
保存设置
</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>
<style scoped>
.distribution-sites-view {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.header-actions {
display: flex;
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;
gap: 8px;
}
.site-icon {
color: #409eff;
font-size: 16px;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 16px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px dashed #e5e7eb;
}
.settlement-hint {
margin-left: 8px;
color: #6b7280;
font-size: 13px;
}
.sms-node-hint {
width: 100%;
margin-top: 4px;
color: #9ca3af;
font-size: 12px;
line-height: 1.5;
}
</style>