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.
 
 
 
 
 
 

445 lines
17 KiB

<template>
<div class="space-y-6">
<!-- ========== 顶部筛选:选品牌商 ========== -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
<div class="flex items-end gap-3 flex-wrap">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">品牌商</span></label>
<select v-model.number="currentBrandId" class="select select-bordered select-sm" style="min-width:200px;" @change="onBrandChange">
<option :value="0">全部品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ brandMap[b.id] }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">状态</span></label>
<select v-model.number="filterStatus" class="select select-bordered select-sm" @change="loadCodes">
<option :value="-1">全部</option>
<option :value="0">启用</option>
<option :value="1">停用</option>
</select>
</div>
<button class="btn btn-primary btn-sm text-white" title="新增公码需先选择具体品牌商" :disabled="currentBrandId === 0" @click="showCodeForm()">+ 新增公码</button>
</div>
</div>
<!-- ========== 公码列表 ========== -->
<div v-show="view === 'list'">
<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>公码</th><th>品牌商</th><th>关联产品</th><th>备注</th>
<th>VIP天/翻译分/会议分</th>
<th>核销/上限</th><th>失效时间</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="9" class="text-center text-slate-400 py-8">加载中…</td>
</tr>
<tr v-else-if="!codes.length">
<td colspan="9" class="text-center text-slate-400 py-8">{{ currentBrandId ? '暂无公码,点击右上角“新增公码”创建' : '暂无公码' }}</td>
</tr>
<tr v-for="c in codes" :key="c.code">
<td class="font-mono text-base font-bold text-indigo-700">{{ c.code || '' }}</td>
<td class="text-sm">{{ facName(c) }}</td>
<td class="text-sm">{{ prodName(c) }}</td>
<td class="text-sm text-slate-500">{{ c.remark || '-' }}</td>
<td class="text-sm">{{ (c.bindrewardvpitime || 0) }} / {{ (c.bindrewardtranslate || 0) }} / {{ (c.bindrewardmeeting || 0) }}</td>
<td class="text-sm">{{ (c.useduses || 0) }} / {{ c.maxuses ? c.maxuses : '∞' }}</td>
<td class="text-sm">{{ formatTs(c.expiretime) }}</td>
<td>
<span v-if="c.status === 0" class="badge badge-success badge-sm text-white">启用</span>
<span v-else class="badge badge-ghost badge-sm">停用</span>
</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs text-indigo-600" @click="showQrCode(c.code)">二维码</button>
<button class="btn btn-ghost btn-xs" @click="showCodeForm(c)">编辑</button>
<button class="btn btn-ghost btn-xs text-amber-600" @click="toggleCodeStatus(c)">{{ c.status === 0 ? '停用' : '启用' }}</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delCode(c.code)">删除</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="backToCodes">&larr; 返回公码列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? `编辑公码 ${form.code}` : '新增公码' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveCode">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">关联产品(选填,仅作分类)</span></label>
<select v-model.number="form.productid" class="select select-bordered w-full">
<option :value="0">不关联</option>
<option v-for="p in allProducts" :key="p.id" :value="p.id">{{ p.devicename || ('#' + p.id) }}</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">备注</span></label>
<input v-model="form.remark" type="text" class="input input-bordered w-full" placeholder="例如:包装海报-2026春季" />
</div>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">奖励VIP天数</span></label>
<input v-model.number="form.bindrewardvpitime" type="number" min="0" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">奖励翻译分钟</span></label>
<input v-model.number="form.bindrewardtranslate" type="number" min="0" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">奖励会议分钟</span></label>
<input v-model.number="form.bindrewardmeeting" type="number" min="0" class="input input-bordered" />
</div>
</div>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">最大核销人数(0=不限)</span></label>
<input v-model.number="form.maxuses" type="number" min="0" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">失效时间(留空=永久)</span></label>
<input v-model="form.expire" type="datetime-local" class="input input-bordered" />
</div>
</div>
<div v-show="isEditing" class="form-control mb-6">
<label class="label cursor-pointer justify-start gap-3">
<input v-model="form.disabled" type="checkbox" class="checkbox" />
<span class="label-text font-medium">停用</span>
</label>
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="backToCodes">取消</button>
<button type="submit" class="btn btn-primary text-white">保存</button>
</div>
</form>
</div>
</div>
<!-- 二维码弹窗 -->
<dialog ref="qrModal" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4 text-slate-800">公码二维码</h3>
<div class="flex flex-col items-center gap-4">
<div ref="qrCanvas" class="p-3 bg-white border border-slate-200 rounded-lg"></div>
<div class="text-center">
<div class="text-xs text-slate-500 mb-1">公码</div>
<div class="font-mono text-2xl font-bold text-indigo-700 tracking-widest">{{ currentQrCode }}</div>
</div>
<div class="text-xs text-slate-400 text-center">
新用户使用 App 扫描即可领取奖励,每人仅可领取 1 次
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-outline btn-sm" @click="downloadQr">下载图片</button>
<button type="button" class="btn btn-outline btn-sm" @click="printQr">打印</button>
<form method="dialog"><button class="btn btn-sm">关闭</button></form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '公码管理' })
interface PublicCode {
code: string
brandid: number
productid: number
remark: string
status: number
maxuses: number
useduses: number
expiretime: number
bindrewardvpitime: number
bindrewardtranslate: number
bindrewardmeeting: number
[key: string]: unknown
}
interface Brand { id: number; brand: string }
interface Product { id: number; devicename: string }
const { webApi } = useApi()
const { success, error, warn } = useToast()
const view = ref<'list' | 'form'>('list')
const loading = ref(false)
const isEditing = ref(false)
const currentBrandId = ref(0) // 0 = 全部品牌商
const filterStatus = ref(-1)
const codes = ref<PublicCode[]>([])
const brands = ref<Brand[]>([])
const allProducts = ref<Product[]>([]) // 缓存产品列表给 select 用 + 表格产品名展示
const brandMap = reactive<Record<number, string>>({}) // brandid -> 品牌商名
const form = reactive({
code: '',
productid: 0,
remark: '',
bindrewardvpitime: 0,
bindrewardtranslate: 0,
bindrewardmeeting: 0,
maxuses: 0,
expire: '',
disabled: false,
})
// ---- 时间工具 ----
function formatTs(ts?: number) {
if (!ts) return '永久'
return new Date(ts * 1000).toLocaleString()
}
function tsToLocalInput(ts?: number) {
if (!ts) return ''
const d = new Date(ts * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : '' + n)
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
function localInputToTs(val?: string) {
if (!val) return 0
return Math.floor(new Date(val).getTime() / 1000)
}
// ---- 表格展示 ----
function facName(c: PublicCode) {
return brandMap[c.brandid] || (c.brandid ? ('品牌商#' + c.brandid) : '-')
}
function prodName(c: PublicCode) {
const prod = allProducts.value.find((p) => p.id === c.productid)
return prod ? (prod.devicename || ('#' + c.productid)) : (c.productid ? ('#' + c.productid) : '-')
}
// ---- 视图切换 ----
function backToCodes() {
view.value = 'list'
loadCodes()
}
// ---- 品牌商切换 ----
function onBrandChange() {
backToCodes()
}
// ---- 公码列表 ----
async function loadCodes() {
loading.value = true
try {
// brandid=0 → 后端不过滤品牌商,返回全部公码
const d = await webApi<{ codes: PublicCode[] }>('getfactorypubliccodes', {
brandid: currentBrandId.value,
status: filterStatus.value,
})
codes.value = d?.codes ?? []
} catch (e: any) {
error(e?.message ?? '获取公码列表失败')
codes.value = []
} finally {
loading.value = false
}
}
function showCodeForm(code?: PublicCode) {
if (!currentBrandId.value) { warn('请先选择品牌商'); return }
if (code) {
isEditing.value = true
form.code = code.code
form.productid = code.productid || 0
form.remark = code.remark || ''
form.bindrewardvpitime = code.bindrewardvpitime || 0
form.bindrewardtranslate = code.bindrewardtranslate || 0
form.bindrewardmeeting = code.bindrewardmeeting || 0
form.maxuses = code.maxuses || 0
form.expire = tsToLocalInput(code.expiretime)
form.disabled = code.status === 1
} else {
isEditing.value = false
form.code = ''
form.productid = 0
form.remark = ''
form.bindrewardvpitime = 0
form.bindrewardtranslate = 0
form.bindrewardmeeting = 0
form.maxuses = 0
form.expire = ''
form.disabled = false
}
view.value = 'form'
}
async function saveCode() {
const common: Record<string, unknown> = {
productid: Number(form.productid) || 0,
remark: form.remark,
maxuses: Number(form.maxuses) || 0,
expiretime: localInputToTs(form.expire),
bindrewardvpitime: Number(form.bindrewardvpitime) || 0,
bindrewardtranslate: Number(form.bindrewardtranslate) || 0,
bindrewardmeeting: Number(form.bindrewardmeeting) || 0,
}
try {
if (isEditing.value) {
common.code = form.code
common.status = form.disabled ? 1 : 0
await webApi('updatefactorypubliccode', common)
success('保存成功')
} else {
common.brandid = currentBrandId.value
const d = await webApi<{ code?: { code?: string } }>('createfactorypubliccode', common)
success('新公码已生成:' + (d?.code?.code || ''))
}
backToCodes()
} catch (e: any) {
error(e?.message ?? '保存失败')
}
}
async function toggleCodeStatus(code: PublicCode) {
const newStatus = code.status === 0 ? 1 : 0
try {
await webApi('updatefactorypubliccode', {
code: code.code,
productid: code.productid || 0,
remark: code.remark || '',
status: newStatus,
maxuses: code.maxuses || 0,
expiretime: code.expiretime || 0,
bindrewardvpitime: code.bindrewardvpitime || 0,
bindrewardtranslate: code.bindrewardtranslate || 0,
bindrewardmeeting: code.bindrewardmeeting || 0,
})
success(newStatus === 1 ? '已停用' : '已启用')
loadCodes()
} catch (e: any) {
error(e?.message ?? '操作失败')
}
}
function delCode(code: string) {
if (!window.confirm(`确定删除公码 "${code}" 吗?删除后该码无法再被领取(已领取记录保留)`)) return
webApi('delfactorypubliccode', { code })
.then(() => { success('删除成功'); loadCodes() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
// =================== 公码二维码 ===================
const qrModal = ref<HTMLDialogElement | null>(null)
const qrCanvas = ref<HTMLElement | null>(null)
const currentQrCode = ref('')
// 动态加载 qrcodejs(保持原版 CDN 方案,不引入新依赖)
const QR_CDN = 'https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js'
function loadQrLib(): Promise<any> {
return new Promise((resolve, reject) => {
const w = window as any
if (w.QRCode) { resolve(w.QRCode); return }
const s = document.createElement('script')
s.src = QR_CDN
s.onload = () => resolve(w.QRCode)
s.onerror = () => reject(new Error('二维码库加载失败'))
document.head.appendChild(s)
})
}
async function showQrCode(codeStr: string) {
currentQrCode.value = codeStr
qrModal.value?.showModal()
let QRCode: any
try {
QRCode = await loadQrLib()
} catch {
error('二维码库加载失败')
return
}
const box = qrCanvas.value
if (!box || !QRCode) return
box.innerHTML = ''
// eslint-disable-next-line no-new
new QRCode(box, {
text: codeStr,
width: 240,
height: 240,
colorDark: '#1E293B',
colorLight: '#FFFFFF',
correctLevel: QRCode.CorrectLevel.H,
})
}
function getQrDataUrl(): string {
const box = qrCanvas.value
if (!box) return ''
const canvas = box.querySelector('canvas')
if (canvas) return canvas.toDataURL('image/png')
const img = box.querySelector('img')
return img ? img.src : ''
}
function downloadQr() {
const url = getQrDataUrl()
if (!url) { warn('二维码尚未生成'); return }
const a = document.createElement('a')
a.href = url
a.download = 'publiccode_' + currentQrCode.value + '.png'
document.body.appendChild(a); a.click(); document.body.removeChild(a)
}
function printQr() {
const url = getQrDataUrl()
if (!url) { warn('二维码尚未生成'); return }
const w = window.open('', '_blank')
if (!w) { warn('浏览器拦截了打印窗口'); return }
w.document.write(
'<html><head><title>' + currentQrCode.value + '</title>' +
'<style>body{font-family:sans-serif;text-align:center;padding:48px;}' +
'img{width:320px;height:320px;}h2{font-size:36px;letter-spacing:6px;margin:24px 0 8px;}' +
'p{color:#666;font-size:14px;}</style></head><body>' +
'<img src="' + url + '" />' +
'<h2>' + currentQrCode.value + '</h2>' +
'<p>新用户扫码领取奖励</p>' +
'<scr' + 'ipt>window.onload=function(){window.print();}</scr' + 'ipt>' +
'</body></html>'
)
w.document.close()
}
// =================== 初始化 ===================
async function initData() {
// 加载品牌商列表
try {
const fr = await webApi<{ brands: Brand[] }>('getbrands', {})
const facs = fr?.brands ?? []
brands.value = facs
facs.forEach((b) => { brandMap[b.id] = b.brand || ('品牌商#' + b.id) })
} catch {
brands.value = []
}
// 预加载产品列表(用于公码表单下拉 + 表格产品名展示)
try {
const pr = await webApi<{ products: Product[] }>('getproducts', {})
allProducts.value = pr?.products ?? []
} catch {
allProducts.value = []
}
// 默认厂家=全部,直接展示所有公码
loadCodes()
}
onMounted(initData)
</script>