362 lines
13 KiB
Vue
362 lines
13 KiB
Vue
<script setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import {
|
||
couponTemplateListApi,
|
||
couponTemplateSaveApi,
|
||
couponTemplateToggleApi,
|
||
} from '@/api/request/coupon'
|
||
import PageShell from '@/components/PageShell.vue'
|
||
import GlassCard from '@/components/GlassCard.vue'
|
||
import StateBlock from '@/components/StateBlock.vue'
|
||
import AppModal from '@/components/AppModal.vue'
|
||
|
||
// ============================================================================
|
||
// 券模板管理(A6–A8,N6 承前项)
|
||
// 响应 snake_case;toForm/fromForm 显式映射(S2)。
|
||
// 券型隔离(S7):coupon_type 1=免单券(走支付)/ 2=展示券(仅展示)。
|
||
// 新建默认「展示券」,未显式选型时后端也按 2 兜底(fail-closed),UI 与后端一致。
|
||
// ============================================================================
|
||
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
const list = ref([])
|
||
const total = ref(0)
|
||
const query = reactive({ page: 1, pageSize: 20, keyword: '', status: undefined, couponType: undefined })
|
||
|
||
const TYPE_OPTIONS = [
|
||
{ value: 1, label: '免单券(走支付)' },
|
||
{ value: 2, label: '展示券(仅展示)' },
|
||
]
|
||
const KIND_OPTIONS = [
|
||
{ value: 1, label: '满减' },
|
||
{ value: 2, label: '免单' },
|
||
]
|
||
const SCOPE_OPTIONS = [
|
||
{ value: 0, label: '全部商品' },
|
||
{ value: 1, label: '指定会员套餐' },
|
||
{ value: 2, label: '指定次数包' },
|
||
]
|
||
const STATUS_OPTIONS = [
|
||
{ value: 1, label: '启用' },
|
||
{ value: 0, label: '停用' },
|
||
]
|
||
const typeLabel = (v) => TYPE_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||
const kindLabel = (v) => KIND_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||
const scopeLabel = (v) => SCOPE_OPTIONS.find((o) => o.value === v)?.label || '-'
|
||
|
||
const columns = [
|
||
{ title: '券名', key: 'title' },
|
||
{ title: '券型', key: 'coupon_type', width: 150 },
|
||
{ title: '面额/门槛(分)', key: 'value', width: 130, align: 'right' },
|
||
{ title: '适用范围', key: 'scope', width: 120 },
|
||
{ title: '已发/已用', key: 'counts', width: 110, align: 'right' },
|
||
{ title: '单人限领', dataIndex: 'per_user_limit', key: 'per_user_limit', width: 90, align: 'right' },
|
||
{ title: '有效期', key: 'valid', width: 200 },
|
||
{ title: '状态', key: 'status', width: 80 },
|
||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||
]
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
const res = await couponTemplateListApi({
|
||
keyword: query.keyword || undefined,
|
||
status: query.status,
|
||
couponType: query.couponType,
|
||
page: query.page,
|
||
pageSize: query.pageSize,
|
||
})
|
||
if (res) {
|
||
list.value = res.list || []
|
||
total.value = res.total || 0
|
||
}
|
||
} catch {
|
||
loadError.value = '加载失败,请稍后重试'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
|
||
function applyFilters() {
|
||
query.page = 1
|
||
load()
|
||
}
|
||
function resetFilters() {
|
||
query.keyword = ''
|
||
query.status = undefined
|
||
query.couponType = undefined
|
||
query.page = 1
|
||
load()
|
||
}
|
||
function onTableChange(pag) {
|
||
query.page = pag.current
|
||
query.pageSize = pag.pageSize
|
||
load()
|
||
}
|
||
|
||
// ===== 表单(S2 显式映射)=====
|
||
const modalOpen = ref(false)
|
||
const saving = ref(false)
|
||
const form = reactive({
|
||
id: 0,
|
||
title: '',
|
||
subTitle: '',
|
||
couponType: 2, // 默认展示券(fail-closed)
|
||
kind: 1, // 默认满减:避免「免单」成为静默默认(后端 coupons.kind 兜底为 2=免单,新建展示券用满减更中性,见 openCreate 同值)
|
||
value: 0,
|
||
thresholdCents: 0,
|
||
scope: 0,
|
||
scopeKeys: [],
|
||
totalLimit: 0,
|
||
perUserLimit: 1,
|
||
validFrom: undefined,
|
||
validTo: undefined,
|
||
status: 1,
|
||
remark: '',
|
||
})
|
||
|
||
function toForm(record) {
|
||
return {
|
||
id: record.id ?? 0,
|
||
title: record.title || '',
|
||
subTitle: record.sub_title || '',
|
||
couponType: record.coupon_type ?? 2,
|
||
kind: record.kind ?? 2,
|
||
value: record.value ?? 0,
|
||
thresholdCents: record.threshold_cents ?? 0,
|
||
scope: record.scope ?? 0,
|
||
scopeKeys: Array.isArray(record.scope_keys) ? [...record.scope_keys] : [],
|
||
totalLimit: record.total_limit ?? 0,
|
||
perUserLimit: record.per_user_limit ?? 1,
|
||
validFrom: record.valid_from || undefined,
|
||
validTo: record.valid_to || undefined,
|
||
status: record.status ?? 1,
|
||
remark: record.remark || '',
|
||
}
|
||
}
|
||
|
||
function fromForm(f) {
|
||
return {
|
||
id: f.id,
|
||
title: f.title,
|
||
subTitle: f.subTitle,
|
||
// 未显式选型 → 展示券(与后端 fail-closed 兜底一致)
|
||
couponType: f.couponType === 1 ? 1 : 2,
|
||
kind: f.kind,
|
||
value: f.value,
|
||
thresholdCents: f.thresholdCents,
|
||
scope: f.scope,
|
||
scopeKeys: f.scope === 0 ? [] : f.scopeKeys,
|
||
totalLimit: f.totalLimit,
|
||
perUserLimit: f.perUserLimit,
|
||
validFrom: f.validFrom || '',
|
||
validTo: f.validTo || '',
|
||
status: f.status,
|
||
remark: f.remark || '',
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
Object.assign(form, {
|
||
id: 0,
|
||
title: '',
|
||
subTitle: '',
|
||
couponType: 2,
|
||
kind: 1, // 默认满减(同 form 初值)
|
||
value: 0,
|
||
thresholdCents: 0,
|
||
scope: 0,
|
||
scopeKeys: [],
|
||
totalLimit: 0,
|
||
perUserLimit: 1,
|
||
validFrom: undefined,
|
||
validTo: undefined,
|
||
status: 1,
|
||
remark: '',
|
||
})
|
||
modalOpen.value = true
|
||
}
|
||
|
||
function openEdit(record) {
|
||
Object.assign(form, toForm(record))
|
||
modalOpen.value = true
|
||
}
|
||
|
||
async function save() {
|
||
if (!form.title?.trim()) {
|
||
message.warning('请填写券名')
|
||
return
|
||
}
|
||
if (form.scope !== 0 && !form.scopeKeys?.length) {
|
||
message.warning('限定了适用范围时,请至少选择一个 key')
|
||
return
|
||
}
|
||
if (form.validFrom && form.validTo && form.validFrom > form.validTo) {
|
||
message.warning('有效期起不得晚于止')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
await couponTemplateSaveApi(fromForm(form))
|
||
message.success('已保存')
|
||
modalOpen.value = false
|
||
load()
|
||
} catch {
|
||
/* 拦截器已提示 */
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function toggleStatus(record) {
|
||
const prev = record.status
|
||
record.status = record.status === 1 ? 0 : 1
|
||
try {
|
||
await couponTemplateToggleApi({ id: record.id, status: record.status })
|
||
message.success(record.status === 1 ? '已启用' : '已停用')
|
||
} catch {
|
||
record.status = prev
|
||
}
|
||
}
|
||
|
||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||
const hasFilter = computed(
|
||
() => !!query.keyword || query.status !== undefined || query.couponType !== undefined,
|
||
)
|
||
const validText = (r) => `${r.valid_from || '不限'} ~ ${r.valid_to || '不限'}`
|
||
</script>
|
||
|
||
<template>
|
||
<PageShell title="券模板" desc="维护优惠券模板;免单券走支付,展示券仅用于展示">
|
||
<template #actions>
|
||
<a-input-search v-model:value="query.keyword" placeholder="券名" style="width: 200px" allowClear
|
||
@search="applyFilters" />
|
||
<a-button @click="load">刷新</a-button>
|
||
<a-button type="primary" @click="openCreate">新增券模板</a-button>
|
||
</template>
|
||
|
||
<div class="app-toolbar filter-bar">
|
||
<a-select v-model:value="query.couponType" placeholder="全部券型" allowClear style="width: 160px"
|
||
:options="TYPE_OPTIONS" @change="applyFilters" />
|
||
<a-select v-model:value="query.status" placeholder="全部状态" allowClear style="width: 130px"
|
||
:options="STATUS_OPTIONS" @change="applyFilters" />
|
||
<a-button type="primary" @click="applyFilters">查询</a-button>
|
||
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
|
||
</div>
|
||
|
||
<GlassCard>
|
||
<StateBlock :error="loadError" :empty="isEmpty" empty-text="暂无券模板,点击右上角「新增券模板」开始" @retry="load">
|
||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||
:pagination="{
|
||
current: query.page,
|
||
pageSize: query.pageSize,
|
||
total,
|
||
showSizeChanger: false,
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
}" @change="onTableChange">
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'title'">
|
||
<div class="font-medium" style="color: var(--text-1)">{{ record.title }}</div>
|
||
<div v-if="record.sub_title" class="app-muted" style="font-size: var(--font-xs)">
|
||
{{ record.sub_title }}
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'coupon_type'">
|
||
<a-tag :color="record.coupon_type === 1 ? 'gold' : 'default'">
|
||
{{ typeLabel(record.coupon_type) }}
|
||
</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'value'">
|
||
{{ record.value }} / {{ record.threshold_cents }}
|
||
<span class="app-muted" style="font-size: var(--font-xs)">({{ kindLabel(record.kind) }})</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'scope'">
|
||
<a-tag color="orange">{{ scopeLabel(record.scope) }}</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'counts'">
|
||
{{ record.granted_count }} / {{ record.used_count }}
|
||
</template>
|
||
<template v-else-if="column.key === 'valid'">
|
||
<span class="app-muted" style="font-size: var(--font-xs)">{{ validText(record) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<a-switch :checked="record.status === 1" size="small" @change="() => toggleStatus(record)" />
|
||
</template>
|
||
<template v-else-if="column.key === 'action'">
|
||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</StateBlock>
|
||
</GlassCard>
|
||
|
||
<AppModal v-model:open="modalOpen" :title="form.id ? '编辑券模板' : '新增券模板'" :width="560"
|
||
:confirm-loading="saving" ok-text="保存" @ok="save">
|
||
<a-form layout="vertical">
|
||
<a-form-item label="券名" required>
|
||
<a-input v-model:value="form.title" :maxlength="64" />
|
||
</a-form-item>
|
||
<a-form-item label="副标题">
|
||
<a-input v-model:value="form.subTitle" :maxlength="128" />
|
||
</a-form-item>
|
||
<a-form-item label="券型" extra="展示券不参与支付抵扣,仅在小程序展示;新建默认展示券">
|
||
<a-select v-model:value="form.couponType" :options="TYPE_OPTIONS" />
|
||
</a-form-item>
|
||
<div class="grid grid-cols-3 gap-3">
|
||
<a-form-item label="券面语义">
|
||
<a-select v-model:value="form.kind" :options="KIND_OPTIONS" />
|
||
</a-form-item>
|
||
<a-form-item label="面额(分)">
|
||
<a-input-number v-model:value="form.value" :min="0" class="w-full" />
|
||
</a-form-item>
|
||
<a-form-item label="门槛(分)">
|
||
<a-input-number v-model:value="form.thresholdCents" :min="0" class="w-full" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="适用范围">
|
||
<a-select v-model:value="form.scope" :options="SCOPE_OPTIONS" />
|
||
</a-form-item>
|
||
<a-form-item v-if="form.scope !== 0" label="范围 key(plan_key / pack_key)">
|
||
<a-select v-model:value="form.scopeKeys" mode="tags" placeholder="输入后回车添加"
|
||
:token-separators="[',']" />
|
||
</a-form-item>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<a-form-item label="总发行上限(0=不限)">
|
||
<a-input-number v-model:value="form.totalLimit" :min="0" class="w-full" />
|
||
</a-form-item>
|
||
<a-form-item label="单人限领">
|
||
<a-input-number v-model:value="form.perUserLimit" :min="0" class="w-full" />
|
||
</a-form-item>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<a-form-item label="生效起">
|
||
<a-date-picker v-model:value="form.validFrom" show-time value-format="YYYY-MM-DD HH:mm:ss"
|
||
class="w-full" placeholder="不限" />
|
||
</a-form-item>
|
||
<a-form-item label="生效止">
|
||
<a-date-picker v-model:value="form.validTo" show-time value-format="YYYY-MM-DD HH:mm:ss"
|
||
class="w-full" placeholder="不限" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="启用">
|
||
<a-switch :checked="form.status === 1" @change="(v) => (form.status = v ? 1 : 0)" />
|
||
</a-form-item>
|
||
<a-form-item label="备注">
|
||
<a-textarea v-model:value="form.remark" :rows="2" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</AppModal>
|
||
</PageShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filter-bar {
|
||
flex-wrap: wrap;
|
||
margin-bottom: var(--space-4);
|
||
}
|
||
</style>
|