Files
hfb_sys/frontend/src/features/admin/views/AdminDistributionSitesView.vue
T
yml2213 22fcaa5945 feat(distribution): 分站收号压价解耦买家价(方案 B 新口径)
核心语义反转:receive_cost_delta 为压价点数,收号比例 = 主站参考比例 R + 点数,
点数越大号主收号价越低;买家售价锚定主站参考比例推导,不再随收号价联动。

- 迁移 000072:distribution_sites 新增 receive_cost_delta(旧 receive_ratio_delta 保留只读)
- receive_pricing:新增 Normalize/ApplySiteReceiveCostDelta;分站定价买家侧改用
  R 锚点(referenceSellerCoinBasePrice),快照写入 site_receive_cost_delta 与 pricing_model 标记
- site_receive:新增 resolveSiteReceiveCostDelta 读取链路,旧 ResolveReceiveRatioDelta 保留
- 分成不变:压价差经结算层 profit 自动进入 35/40/25 三方分成池;
  货架自主加价仍 100% 归分站,两条边界均以主站为锚
- 权限对齐旧字段:主站全局管理员与分站操作员均可设置
- 前端三端同步:主站 pricing.ts/usePricingCalculator 买卖解耦试算、
  主站后台弹窗改「收号压价」价格语义文案、分销后台 SiteSettings 同步
- 测试:单测锚定 R=43 时 cost_delta=3 → 收号 217.4 / 卖价 263.2 不变 / markup 池 30.6→54.9
2026-09-17 15:54:56 +08:00

1013 lines
34 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, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
Plus,
Shop,
Link,
DocumentCopy,
Refresh,
Clock,
Picture,
Upload,
Coin,
Message,
} from '@element-plus/icons-vue'
import {
fetchDistributionSites,
fetchDistributionSiteDefaults,
onboardDistributionSite,
syncDistributionSiteAccounts,
updateDistributionSite,
fetchDistributionSiteSMSConfig,
updateDistributionSiteSMSConfig,
testDistributionSiteSMSConfig,
type DistributionSite,
type OnboardDistributionSitePayload,
type OnboardDistributionSiteResult,
} from '@/features/admin/api/adminDistribution'
import { uploadAdminFile } from '@/shared/api/files'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
import {
distributionAdminBaseUrl,
distributionAdminLoginUrl,
} from '@/shared/utils/distributionAdmin'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const defaultSettlementHoldDays = ref(7)
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 qrDialogVisible = ref(false)
const qrSubmitting = ref(false)
const qrUploading = ref(false)
const qrForm = reactive({
site_id: 0,
site_name: '',
listing_qrcode_url: '',
})
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: '',
})
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
}
}
function handleOpenQrcode(row: DistributionSite) {
qrForm.site_id = row.id
qrForm.site_name = row.site_name
qrForm.listing_qrcode_url = row.listing_qrcode_url || ''
qrDialogVisible.value = true
}
async function uploadSiteQrcode(file: File) {
qrUploading.value = true
try {
const uploaded = await uploadAdminFile(file, 'qrcode')
qrForm.listing_qrcode_url = uploaded.url
ElMessage.success('二维码已上传,保存后生效')
} catch (err) {
ElMessage.error(readError(err, '上传二维码失败'))
} finally {
qrUploading.value = false
}
return false
}
function clearSiteQrcode() {
qrForm.listing_qrcode_url = ''
}
async function handleQrcodeSubmit() {
qrSubmitting.value = true
try {
await updateDistributionSite(qrForm.site_id, {
listing_qrcode_url: qrForm.listing_qrcode_url.trim(),
})
ElMessage.success(
qrForm.listing_qrcode_url.trim()
? `【${qrForm.site_name}】发布群固定二维码已更新`
: `【${qrForm.site_name}】已清空发布群固定二维码`
)
qrDialogVisible.value = false
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '更新发布群二维码失败'))
} finally {
qrSubmitting.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 || ''
} catch (err) {
ElMessage.error(readError(err, '读取分站短信配置失败'))
} finally {
smsLoading.value = false
}
}
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
} = {
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(),
}
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)
ElMessage.success(
`同步成功!为【${row.site_name}】新增本站上传货 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(本次候选 ${res.total_active} 个)`
)
} catch (err) {
ElMessage.error(readError(err, '同步商品失败'))
} finally {
syncingSiteId.value = null
}
}
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">
<el-table v-loading="loading" :data="sites" 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="180" />
<el-table-column label="运行状态" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '正常' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="展示主站账号" prop="display_account_enabled" width="130">
<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">
<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="发布群二维码" width="120" align="center">
<template #default="{ row }">
<AuthImage
v-if="row.listing_qrcode_url"
:source="row.listing_qrcode_url"
fit="cover"
:image-style="{ width: '48px', height: '48px', borderRadius: '6px' }"
:preview-src-list="[row.listing_qrcode_url]"
/>
<el-tag v-else type="info" size="small">未配置</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="410" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
:icon="Refresh"
:loading="syncingSiteId === row.id"
@click="handleSyncAccounts(row)"
>
同步商品
</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>
</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="qrDialogVisible" title="发布群固定二维码" width="520px" destroy-on-close>
<el-alert
type="info"
:closable="false"
show-icon
title="号主在该分站上传账号后,会把这张固定二维码发到对应发布群。同一分站可重复使用,不消耗主站二维码池。"
style="margin-bottom: 16px"
/>
<el-form label-width="110px">
<el-form-item label="分销站点">
<el-input :model-value="qrForm.site_name" disabled />
</el-form-item>
<el-form-item label="固定二维码">
<div class="qr-row">
<AuthImage
v-if="qrForm.listing_qrcode_url"
:source="qrForm.listing_qrcode_url"
fit="contain"
:image-style="{
width: '160px',
height: '160px',
borderRadius: '12px',
background: '#f7f9fc',
}"
:preview-src-list="[qrForm.listing_qrcode_url]"
/>
<div v-else class="qr-empty">尚未配置</div>
<div class="qr-actions">
<el-upload
:show-file-list="false"
accept="image/*"
:disabled="qrUploading"
:before-upload="uploadSiteQrcode"
>
<el-button :icon="Upload" :loading="qrUploading">上传二维码</el-button>
</el-upload>
<el-button :disabled="!qrForm.listing_qrcode_url" @click="clearSiteQrcode">
清空
</el-button>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="qrDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="qrSubmitting" @click="handleQrcodeSubmit">
保存设置
</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-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>
</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;
}
.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;
}
.qr-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.qr-empty {
width: 160px;
height: 160px;
border-radius: 12px;
background: #f7f9fc;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.qr-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>