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.
 
 
 
 
 
 

354 lines
15 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 class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增品牌商</button>
</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 class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('brand')">品牌名称{{ sortMark('brand') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('sharerate')">分成比例{{ sortMark('sharerate') }}</th>
<th>联系邮箱</th><th>产品</th>
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!viewRows.length">
<td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td>
</tr>
<tr v-for="b in viewRows" :key="b.id">
<td class="font-mono text-sm">{{ hexId(b.id) }}</td>
<td class="font-medium">{{ b.brand }}</td>
<td class="text-sm">{{ ratePct(b.sharerate) }}</td>
<td class="text-sm">
<template v-if="mailList(b.contactmails).length">
<span v-for="(e, i) in mailList(b.contactmails)" :key="i" class="badge badge-ghost badge-sm mr-1">{{ e }}</span>
</template>
<span v-else class="text-slate-300">-</span>
</td>
<td class="text-sm text-slate-500">{{ b.productlists || '' }}</td>
<td class="text-xs text-slate-500">{{ formatTs(b.createtime) }}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(b)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delBrand(b)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回列表</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="saveBrand">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">品牌名称 *</span></label>
<input v-model="form.brand" type="text" class="input input-bordered w-full" required />
</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" />
<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>
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3"></textarea>
</div>
<!-- 多邮箱管理 -->
<div class="form-control mb-6">
<label class="label"><span class="label-text font-medium">联系邮箱</span></label>
<div class="space-y-2">
<p v-if="!emails.length" class="text-xs text-slate-400">暂无邮箱</p>
<div v-for="(e, i) in emails" :key="i" class="flex items-center gap-2">
<span class="badge badge-lg gap-1 bg-slate-100 border-slate-200">
<svg class="h-3 w-3 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{{ e }}
</span>
<button type="button" class="btn btn-ghost btn-xs text-red-400 hover:text-red-600" @click="removeEmail(i)">×</button>
</div>
</div>
<div class="flex gap-2 mt-2">
<input v-model="newEmail" type="email" class="input input-bordered input-sm flex-1" placeholder="输入邮箱地址" @keydown.enter.prevent="addEmail" />
<button type="button" class="btn btn-outline btn-sm btn-primary" @click="addEmail">添加</button>
</div>
</div>
<!-- 官方渠道:新增时必建,代表品牌自营的销售线,不配渠道账号 -->
<div v-if="!isEditing" class="form-control mb-6 border-t border-slate-100 pt-4">
<label class="label"><span class="label-text font-medium">品牌官方渠道</span></label>
<input v-model="form.officialChannelName" type="text" class="input input-bordered w-full" :placeholder="defaultOfficialName" />
<span class="label-text-alt text-slate-400 mt-1">
新建品牌商时自动创建一条该品牌自己的渠道(直接启用、分成 0%、不创建渠道账号),用于品牌直销的授权码归属;留空则叫「{{ defaultOfficialName }}」。
</span>
</div>
<!-- 同时创建品牌账号(超管/管理员) -->
<div v-if="!isEditing && auth.isAdmin" 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="留空自动生成,如 Brand-A001" 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="!credentialMails.length" 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>
</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 { useTableSort } from '~/composables/useTableSort'
import type { AccountCredential } from '~/components/AccountCredentialDialog.vue'
definePageMeta({ title: '品牌商管理' })
interface Brand {
id: number
brand: string
description: string
contactmails: string
productlists: string
sharerate: number // 万分比
createtime?: number
}
// addbrand 的返回:品牌商本体 + 自动建的官方渠道 + (可选)品牌账号凭据
interface AddBrandResp {
channel?: { id: string; name: string }
account?: string
account_password?: string
mail_to?: string
mail_error?: string
}
const { webApi } = useApi()
const { success, error, warn } = useToast()
const auth = useAuthStore()
const view = ref<'list' | 'form'>('list')
const rows = ref<Brand[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
const emails = ref<string[]>([])
const newEmail = ref('')
// 比例在表单里用百分比(更直观),提交时换算成后端的万分比整数
const form = reactive({
id: 0, brand: '', description: '', sharepct: 0,
// 仅新增时用:官方渠道名称 + 顺手开一个品牌商账号
officialChannelName: '',
createAccount: false,
accountUsername: '',
accountPassword: '',
accountRemark: '',
})
// 官方渠道默认名(与后端 addOfficialChannel 的兜底一致)
const defaultOfficialName = computed(() => (form.brand.trim() || '品牌') + '官方渠道')
// 能收凭据邮件的邮箱:平台默认邮箱不算(它是为收授权码清单强行塞进列表的,不是品牌商的人)。
// 与后端 credentialMails 同口径。
const PLATFORM_MAIL = '358030594@qq.com'
const credentialMails = computed(() =>
emails.value.filter((e) => e.toLowerCase() !== PLATFORM_MAIL),
)
// 新开账号的凭据回执(密码只有这一次能看到)
const cred = ref<AccountCredential | null>(null)
function formatTs(ts?: number) {
if (!ts) return '-'
const d = new Date(ts * 1000)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') +
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
// 列表排序:默认按品牌商 ID 升序,表头可切
const { toggleSort, sortMark, applySort } = useTableSort<Brand>({
id: (a, b) => Number(a.id) - Number(b.id),
brand: (a, b) => String(a.brand || '').localeCompare(String(b.brand || ''), 'zh') || Number(a.id) - Number(b.id),
sharerate: (a, b) => Number(a.sharerate || 0) - Number(b.sharerate || 0) || Number(a.id) - Number(b.id),
// 历史品牌商没有创建时间(0),相等时退回按 ID
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 0) || Number(a.id) - Number(b.id),
}, 'id')
const viewRows = computed(() => applySort(rows.value))
function ratePct(rate?: number) {
return ((Number(rate) || 0) / 100).toFixed(2) + '%'
}
function hexId(id: number) {
return id ? Number(id).toString(16).toUpperCase() : ''
}
function mailList(s?: string) {
return (s || '').split(',').map((e) => e.trim()).filter(Boolean)
}
async function loadData() {
loading.value = true
try {
const data = await webApi<{ brands: Brand[] }>('getbrands', {})
rows.value = data?.brands ?? []
} catch (e: any) {
error(e?.message ?? '获取品牌商列表失败')
rows.value = []
} finally {
loading.value = false
}
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(b?: Brand) {
if (b) {
isEditing.value = true
form.id = b.id
form.brand = b.brand || ''
form.description = b.description || ''
form.sharepct = (Number(b.sharerate) || 0) / 100
emails.value = mailList(b.contactmails)
} else {
isEditing.value = false
form.id = 0
form.brand = ''
form.description = ''
form.sharepct = 0
emails.value = []
}
form.officialChannelName = ''
form.createAccount = false
form.accountUsername = ''
form.accountPassword = ''
form.accountRemark = ''
newEmail.value = ''
view.value = 'form'
}
function addEmail() {
const val = newEmail.value.trim()
if (!val) return
if (emails.value.includes(val)) { warn('邮箱已存在'); return }
emails.value.push(val)
newEmail.value = ''
}
function removeEmail(idx: number) {
emails.value.splice(idx, 1)
}
async function saveBrand() {
submitting.value = true
try {
const payload: Record<string, unknown> = {
brand: {
brand: form.brand,
description: form.description,
sharerate: Math.round((Number(form.sharepct) || 0) * 100),
contactmails: emails.value.join(','),
} as Record<string, unknown>,
}
if (isEditing.value) {
;(payload.brand as Record<string, unknown>).id = Number(form.id)
} else {
// 新增才带:官方渠道名 + 是否顺手开品牌账号
payload.official_channel_name = form.officialChannelName.trim()
payload.create_account = form.createAccount
if (form.createAccount) {
// 用户名/密码留空 = 交给服务端生成(用户名按品牌 id 推导,密码随机)
payload.account_username = form.accountUsername.trim()
payload.account_password = form.accountPassword
payload.account_remark = form.accountRemark.trim()
}
}
const d = await webApi<AddBrandResp>(isEditing.value ? 'updatebrand' : 'addbrand', payload)
if (isEditing.value) {
success('保存成功')
} else {
const parts = ['品牌商已创建']
if (d?.channel?.id) parts.push(`官方渠道「${d.channel.name}」(${d.channel.id})`)
success(parts.join(','))
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
}
}
function delBrand(b: Brand) {
if (!window.confirm(`确定删除品牌商 "${b.brand}" 吗?`)) return
webApi('delbrand', { id: b.id })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadData)
</script>