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
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">← 返回公码列表</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>
|
|
|