You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
421 lines
18 KiB
421 lines
18 KiB
<template>
|
|
<div>
|
|
<!-- 列表视图 -->
|
|
<div v-show="view === 'list'">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<h2 class="text-lg font-semibold text-slate-800">渠道商列表</h2>
|
|
<button v-if="!auth.isDealer" class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增渠道商</button>
|
|
</div>
|
|
|
|
<!-- 筛选 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4">
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
<div v-if="!auth.isBrandAccount && !auth.isDealer" class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">品牌商</span></label>
|
|
<select v-model.number="filters.brandid" class="select select-bordered select-sm w-48" @change="loadData">
|
|
<option :value="0">全部品牌商</option>
|
|
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label>
|
|
<select v-model.number="filters.status" class="select select-bordered select-sm w-36" @change="loadData">
|
|
<option :value="-1">全部状态</option>
|
|
<option :value="0">待审核</option>
|
|
<option :value="1">启用</option>
|
|
<option :value="2">停用</option>
|
|
<option :value="3">驳回</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">搜索</span></label>
|
|
<input
|
|
v-model="filters.keyword" type="text" class="input input-bordered input-sm w-52"
|
|
placeholder="名称 / 渠道商ID" @keydown.enter="loadData" />
|
|
</div>
|
|
<button class="btn btn-sm btn-primary text-white" @click="loadData">查询</button>
|
|
<button class="btn btn-sm btn-ghost" @click="resetFilters">重置</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>渠道商ID</th><th>名称</th><th>所属品牌商</th><th>分成比例</th>
|
|
<th>联系方式</th><th>状态</th><th>创建时间</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td colspan="8" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
|
|
</tr>
|
|
<tr v-else-if="!rows.length">
|
|
<td colspan="8" class="text-center text-slate-400 py-8">暂无数据</td>
|
|
</tr>
|
|
<tr v-for="ch in rows" :key="ch.id">
|
|
<td class="font-mono text-sm font-semibold">{{ ch.id }}</td>
|
|
<td class="font-medium">
|
|
{{ ch.name }}
|
|
<span v-if="ch.official" class="badge badge-sm badge-primary badge-outline ml-1">官方</span>
|
|
</td>
|
|
<td><span class="badge badge-ghost badge-sm">{{ brandName(ch.brandid) }}</span></td>
|
|
<td class="text-sm">{{ ratePct(ch.sharerate) }}</td>
|
|
<td class="text-sm text-slate-500">
|
|
<div v-if="ch.contact">{{ ch.contact }}</div>
|
|
<div v-if="ch.contactmails" class="text-xs text-slate-400">{{ ch.contactmails }}</div>
|
|
<span v-if="!ch.contact && !ch.contactmails" class="text-slate-300">-</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge badge-sm" :class="statusClass(ch.status)">{{ statusLabel(ch.status) }}</span>
|
|
<div v-if="ch.status === 3 && ch.auditremark" class="text-xs text-slate-400 mt-1">{{ ch.auditremark }}</div>
|
|
</td>
|
|
<td class="text-sm text-slate-500">{{ fmtTime(ch.createtime) }}</td>
|
|
<td>
|
|
<div class="flex flex-wrap gap-1">
|
|
<button class="btn btn-ghost btn-xs" @click="showForm(ch)">编辑</button>
|
|
<template v-if="canAudit">
|
|
<button
|
|
v-if="ch.status !== 1" class="btn btn-ghost btn-xs text-emerald-600"
|
|
@click="audit(ch, 1)">{{ ch.status === 0 ? '通过' : '启用' }}</button>
|
|
<button v-if="ch.status === 1" class="btn btn-ghost btn-xs text-amber-600" @click="audit(ch, 2)">停用</button>
|
|
<button v-if="ch.status === 0" class="btn btn-ghost btn-xs text-red-500" @click="audit(ch, 3)">驳回</button>
|
|
</template>
|
|
<!-- 官方渠道随品牌商而生,不单独删 -->
|
|
<button v-if="!auth.isDealer && !ch.official" class="btn btn-ghost btn-xs text-red-500" @click="delChannel(ch)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-3">
|
|
<template v-if="auth.isBrandAccount">
|
|
这里只显示贵品牌商名下的渠道商。新增后为「待审核」,需平台审核通过(状态=启用)才能用于生产授权码;分成比例由平台设置。
|
|
</template>
|
|
<template v-else>
|
|
渠道商新增后需超管/管理员审核通过(状态=启用)才能用于生产授权码;已有生产批次的渠道商不能删除,只能停用。
|
|
标「官方」的是品牌自营渠道,随品牌商自动创建、直接启用、不可删除。
|
|
</template>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 表单视图 -->
|
|
<div v-show="view === 'form'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回列表</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑渠道商' : '新增渠道商' }}</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
|
|
<form @submit.prevent="saveChannel">
|
|
<div v-if="isEditing" class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">渠道商ID</span></label>
|
|
<input :value="form.id" type="text" class="input input-bordered w-full font-mono" disabled />
|
|
<span class="label-text-alt text-slate-400 mt-1">短码由系统生成,创建后不可修改</span>
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">渠道商名称 *</span></label>
|
|
<input v-model="form.name" type="text" class="input input-bordered w-full" required />
|
|
</div>
|
|
<div v-if="!auth.isBrandAccount" class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">所属品牌商 *</span></label>
|
|
<select v-model.number="form.brandid" class="select select-bordered w-full" :disabled="isEditing" required>
|
|
<option :value="0">请选择品牌商</option>
|
|
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
|
|
</select>
|
|
<span v-if="isEditing" class="label-text-alt text-slate-400 mt-1">归属品牌商创建后不可修改</span>
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">分成比例(%)</span></label>
|
|
<input
|
|
v-model.number="form.sharepct" type="number" step="0.01" min="0" max="100"
|
|
class="input input-bordered w-full" :disabled="!canAudit" />
|
|
<span class="label-text-alt text-slate-400 mt-1">
|
|
从所属品牌商的分成里再切;仅超管/管理员可设置。
|
|
</span>
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">联系人 / 电话</span></label>
|
|
<input v-model="form.contact" type="text" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="form-control mb-4">
|
|
<label class="label"><span class="label-text font-medium">联系邮箱</span></label>
|
|
<input v-model="form.contactmails" type="text" class="input input-bordered w-full" placeholder="多个邮箱用逗号分隔" />
|
|
</div>
|
|
<div class="form-control mb-6">
|
|
<label class="label"><span class="label-text font-medium">备注</span></label>
|
|
<textarea v-model="form.remark" class="textarea textarea-bordered w-full" rows="3"></textarea>
|
|
</div>
|
|
|
|
<!-- 同时创建渠道账号(超管/管理员;新增时可用) -->
|
|
<div v-if="!isEditing && canAudit" class="form-control mb-6 border-t border-slate-100 pt-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input v-model="form.createAccount" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span class="label-text font-medium">同时创建渠道账号</span>
|
|
</label>
|
|
<span class="label-text-alt text-slate-400 mt-1">
|
|
建一个绑定本渠道商的「渠道商」角色登录账号,只能看到自己渠道的数据与结算单。
|
|
用户名/密码留空则自动生成,创建后凭据会邮件发给上面填的联系邮箱。
|
|
</span>
|
|
<div v-if="form.createAccount" class="grid grid-cols-2 gap-3 mt-3">
|
|
<div>
|
|
<label class="text-xs text-slate-500">登录用户名</label>
|
|
<input
|
|
v-model="form.accountUsername" type="text" class="input input-bordered input-sm w-full"
|
|
placeholder="留空自动生成,如 Channel-K7QM3ZTX" autocomplete="off" />
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-500">登录密码</label>
|
|
<input
|
|
v-model="form.accountPassword" type="text" class="input input-bordered input-sm w-full font-mono"
|
|
placeholder="留空自动生成 12 位随机密码" autocomplete="off" />
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="text-xs text-slate-500">备注</label>
|
|
<input v-model="form.accountRemark" type="text" class="input input-bordered input-sm w-full" placeholder="可选" />
|
|
</div>
|
|
<p v-if="!form.contactmails.trim()" class="col-span-2 text-xs text-amber-600">
|
|
没填联系邮箱,凭据将无法邮件送达,只会在创建后弹窗显示一次。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3">
|
|
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
|
|
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
|
|
</div>
|
|
<p v-if="!isEditing && !canAudit" class="text-xs text-amber-600 mt-3">
|
|
提交后为「待审核」状态,须超管/管理员通过后才能用于生产。
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新开渠道账号的凭据回执 -->
|
|
<AccountCredentialDialog :info="cred" @close="cred = null" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useAuthStore } from '~/stores/auth'
|
|
import type { AccountCredential } from '~/components/AccountCredentialDialog.vue'
|
|
|
|
definePageMeta({ title: '渠道商管理' })
|
|
|
|
interface Channel {
|
|
id: string
|
|
brandid: number
|
|
name: string
|
|
contact: string
|
|
contactmails: string
|
|
remark: string
|
|
sharerate: number // 万分比
|
|
status: number // 0待审核 1启用 2停用 3驳回
|
|
applyaccount: string
|
|
auditaccount: string
|
|
auditremark: string
|
|
audittime: number
|
|
createtime: number
|
|
updatetime: number
|
|
official: boolean // 品牌官方渠道:随品牌商自动创建,不可删除
|
|
}
|
|
interface Brand { id: number; brand: string }
|
|
// addchannel 的返回:渠道商本体 + (可选)渠道账号凭据
|
|
interface AddChannelResp {
|
|
account?: string
|
|
account_password?: string
|
|
mail_to?: string
|
|
mail_error?: string
|
|
}
|
|
|
|
const { webApi } = useApi()
|
|
const { success, error } = useToast()
|
|
const auth = useAuthStore()
|
|
|
|
// 审批(含分成比例设置、顺手开渠道账号)仅超管/管理员。
|
|
const canAudit = computed(() => auth.isAdmin)
|
|
|
|
// 新开账号的凭据回执(密码只有这一次能看到)
|
|
const cred = ref<AccountCredential | null>(null)
|
|
|
|
const view = ref<'list' | 'form'>('list')
|
|
const rows = ref<Channel[]>([])
|
|
const brands = ref<Brand[]>([])
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const isEditing = ref(false)
|
|
|
|
// status 用 -1 表示「全部」:0 是合法取值(待审核),不能拿零值当全部。
|
|
const filters = reactive({ brandid: 0, status: -1, keyword: '' })
|
|
// 表单里的比例用百分比(更直观),提交时换算成后端的万分比整数。
|
|
const form = reactive({
|
|
id: '', name: '', brandid: 0, sharepct: 0, contact: '', contactmails: '', remark: '',
|
|
// 仅新增时用:顺手开一个绑定本渠道商的渠道账号
|
|
createAccount: false,
|
|
accountUsername: '',
|
|
accountPassword: '',
|
|
accountRemark: '',
|
|
})
|
|
|
|
const STATUS_LABELS: Record<number, string> = { 0: '待审核', 1: '启用', 2: '停用', 3: '驳回' }
|
|
const STATUS_CLASS: Record<number, string> = {
|
|
0: 'badge-warning', 1: 'badge-success', 2: 'badge-ghost', 3: 'badge-error',
|
|
}
|
|
function statusLabel(s: number) { return STATUS_LABELS[s] ?? String(s) }
|
|
function statusClass(s: number) { return STATUS_CLASS[s] ?? 'badge-ghost' }
|
|
function ratePct(rate?: number) { return ((Number(rate) || 0) / 100).toFixed(2) + '%' }
|
|
function brandName(id?: number) {
|
|
const b = brands.value.find((x) => x.id === id)
|
|
return b ? b.brand : (id ? '品牌商#' + Number(id).toString(16).toUpperCase() : '-')
|
|
}
|
|
function fmtTime(ts?: number) {
|
|
if (!ts) return '-'
|
|
const d = new Date(ts * 1000)
|
|
const p = (n: number) => ('0' + n).slice(-2)
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
}
|
|
|
|
async function loadBrands() {
|
|
try {
|
|
const d = await webApi<{ brands: Brand[] }>('getbrands', {})
|
|
brands.value = d?.brands ?? []
|
|
} catch {
|
|
brands.value = []
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
if (!brands.value.length) await loadBrands()
|
|
const d = await webApi<{ channels: Channel[] }>('getchannels', {
|
|
brandid: Number(filters.brandid) || 0,
|
|
status: Number(filters.status),
|
|
keyword: filters.keyword.trim(),
|
|
})
|
|
rows.value = d?.channels ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取渠道商列表失败')
|
|
rows.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function resetFilters() {
|
|
filters.brandid = 0
|
|
filters.status = -1
|
|
filters.keyword = ''
|
|
loadData()
|
|
}
|
|
|
|
function showList() {
|
|
view.value = 'list'
|
|
loadData()
|
|
}
|
|
|
|
function showForm(ch?: Channel) {
|
|
if (ch) {
|
|
isEditing.value = true
|
|
form.id = ch.id
|
|
form.name = ch.name || ''
|
|
form.brandid = ch.brandid || 0
|
|
form.sharepct = (Number(ch.sharerate) || 0) / 100
|
|
form.contact = ch.contact || ''
|
|
form.contactmails = ch.contactmails || ''
|
|
form.remark = ch.remark || ''
|
|
} else {
|
|
isEditing.value = false
|
|
form.id = ''
|
|
form.name = ''
|
|
form.brandid = Number(filters.brandid) || 0
|
|
form.sharepct = 0
|
|
form.contact = ''
|
|
form.contactmails = ''
|
|
form.remark = ''
|
|
}
|
|
form.createAccount = false
|
|
form.accountUsername = ''
|
|
form.accountPassword = ''
|
|
form.accountRemark = ''
|
|
view.value = 'form'
|
|
}
|
|
|
|
async function saveChannel() {
|
|
// 品牌商账号不选品牌商:后端按账号绑定自动补成自己名下。
|
|
if (!form.brandid && !auth.isBrandAccount) { error('请选择所属品牌商'); return }
|
|
submitting.value = true
|
|
try {
|
|
const channel: Record<string, unknown> = {
|
|
name: form.name.trim(),
|
|
brandid: Number(form.brandid) || 0,
|
|
// 百分比 → 万分比整数,避免浮点比例入库。
|
|
sharerate: Math.round((Number(form.sharepct) || 0) * 100),
|
|
contact: form.contact,
|
|
contactmails: form.contactmails,
|
|
remark: form.remark,
|
|
}
|
|
if (isEditing.value) channel.id = form.id
|
|
const payload: Record<string, unknown> = { channel }
|
|
if (!isEditing.value && form.createAccount) {
|
|
payload.create_account = true
|
|
payload.account_username = form.accountUsername.trim()
|
|
payload.account_password = form.accountPassword
|
|
payload.account_remark = form.accountRemark.trim()
|
|
}
|
|
const d = await webApi<AddChannelResp>(isEditing.value ? 'updatechannel' : 'addchannel', payload)
|
|
if (isEditing.value) {
|
|
success('保存成功')
|
|
} else {
|
|
success(canAudit.value ? '新增成功' : '已提交,等待审核')
|
|
if (d?.account) {
|
|
cred.value = {
|
|
title: `渠道账号「${d.account}」已创建`,
|
|
username: d.account,
|
|
password: d.account_password ?? '',
|
|
mailTo: d.mail_to ?? '',
|
|
mailError: d.mail_error ?? '',
|
|
}
|
|
}
|
|
}
|
|
showList()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function audit(ch: Channel, status: number) {
|
|
const actions: Record<number, string> = { 1: ch.status === 0 ? '通过' : '启用', 2: '停用', 3: '驳回' }
|
|
let auditremark = ''
|
|
if (status === 3) {
|
|
const input = window.prompt(`驳回渠道商 "${ch.name}" 的原因:`, '')
|
|
if (input === null) return
|
|
auditremark = input
|
|
} else if (!window.confirm(`确定${actions[status]}渠道商 "${ch.name}" 吗?`)) {
|
|
return
|
|
}
|
|
try {
|
|
await webApi('auditchannel', { id: ch.id, status, auditremark })
|
|
success(actions[status] + '成功')
|
|
loadData()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '操作失败')
|
|
}
|
|
}
|
|
|
|
function delChannel(ch: Channel) {
|
|
if (!window.confirm(`确定删除渠道商 "${ch.name}" 吗?已有生产批次的渠道商不能删除。`)) return
|
|
webApi('delchannel', { id: ch.id })
|
|
.then(() => { success('删除成功'); loadData() })
|
|
.catch((e: any) => error(e?.message ?? '删除失败'))
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|