Files
qitongxue-admin/src/views/coupon/CouponTemplate.vue
2026-09-29 10:58:25 +08:00

362 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>