546 lines
17 KiB
Vue
546 lines
17 KiB
Vue
<script setup>
|
||
import { computed, onMounted, ref, watch } from "vue";
|
||
import Dialog from "primevue/dialog";
|
||
import Paginator from "primevue/paginator";
|
||
import Toast from "primevue/toast";
|
||
import { useToast } from "primevue/usetoast";
|
||
import { creatorApi } from "../../api/creator";
|
||
|
||
const toast = useToast();
|
||
const coupons = ref([]);
|
||
const totalRecords = ref(0);
|
||
const rows = ref(10);
|
||
const first = ref(0);
|
||
const filterStatus = ref("all");
|
||
const filterType = ref("all");
|
||
const searchKeyword = ref("");
|
||
|
||
const editorVisible = ref(false);
|
||
const grantVisible = ref(false);
|
||
const editingId = ref(null);
|
||
const grantCouponId = ref(null);
|
||
const grantUsers = ref("");
|
||
|
||
const form = ref({
|
||
title: "",
|
||
description: "",
|
||
type: "fix_amount",
|
||
value: 0,
|
||
min_order_amount: 0,
|
||
max_discount: 0,
|
||
total_quantity: 0,
|
||
start_at: "",
|
||
end_at: "",
|
||
});
|
||
|
||
const editorTitle = computed(() =>
|
||
editingId.value ? "编辑优惠券" : "新建优惠券",
|
||
);
|
||
|
||
const resetForm = () => {
|
||
form.value = {
|
||
title: "",
|
||
description: "",
|
||
type: "fix_amount",
|
||
value: 0,
|
||
min_order_amount: 0,
|
||
max_discount: 0,
|
||
total_quantity: 0,
|
||
start_at: "",
|
||
end_at: "",
|
||
};
|
||
};
|
||
|
||
const toLocalInput = (value) => {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
};
|
||
|
||
const toRFC3339 = (value) => {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
return date.toISOString();
|
||
};
|
||
|
||
const fetchCoupons = async () => {
|
||
const params = {
|
||
page: first.value / rows.value + 1,
|
||
limit: rows.value,
|
||
};
|
||
if (filterStatus.value !== "all") params.status = filterStatus.value;
|
||
if (filterType.value !== "all") params.type = filterType.value;
|
||
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
||
|
||
try {
|
||
const res = await creatorApi.listCoupons(params);
|
||
if (res && res.items) {
|
||
coupons.value = res.items;
|
||
totalRecords.value = res.total || 0;
|
||
} else {
|
||
coupons.value = [];
|
||
totalRecords.value = 0;
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
const onPage = (event) => {
|
||
first.value = event.first;
|
||
rows.value = event.rows;
|
||
fetchCoupons();
|
||
};
|
||
|
||
const openCreate = () => {
|
||
editingId.value = null;
|
||
resetForm();
|
||
editorVisible.value = true;
|
||
};
|
||
|
||
const openEdit = (coupon) => {
|
||
editingId.value = coupon.id;
|
||
form.value = {
|
||
title: coupon.title || "",
|
||
description: coupon.description || "",
|
||
type: coupon.type || "fix_amount",
|
||
value: coupon.value || 0,
|
||
min_order_amount: coupon.min_order_amount || 0,
|
||
max_discount: coupon.max_discount || 0,
|
||
total_quantity: coupon.total_quantity || 0,
|
||
start_at: toLocalInput(coupon.start_at),
|
||
end_at: toLocalInput(coupon.end_at),
|
||
};
|
||
editorVisible.value = true;
|
||
};
|
||
|
||
const openGrant = (coupon) => {
|
||
grantCouponId.value = coupon.id;
|
||
grantUsers.value = "";
|
||
grantVisible.value = true;
|
||
};
|
||
|
||
const submitForm = async () => {
|
||
const payload = {
|
||
title: form.value.title,
|
||
description: form.value.description,
|
||
type: form.value.type,
|
||
value: Number(form.value.value) || 0,
|
||
min_order_amount: Number(form.value.min_order_amount) || 0,
|
||
max_discount: Number(form.value.max_discount) || 0,
|
||
total_quantity: Number(form.value.total_quantity) || 0,
|
||
start_at: toRFC3339(form.value.start_at),
|
||
end_at: toRFC3339(form.value.end_at),
|
||
};
|
||
|
||
try {
|
||
if (editingId.value) {
|
||
await creatorApi.updateCoupon(editingId.value, payload);
|
||
toast.add({
|
||
severity: "success",
|
||
summary: "更新成功",
|
||
detail: "优惠券已更新",
|
||
life: 2000,
|
||
});
|
||
} else {
|
||
await creatorApi.createCoupon(payload);
|
||
toast.add({
|
||
severity: "success",
|
||
summary: "创建成功",
|
||
detail: "优惠券已创建",
|
||
life: 2000,
|
||
});
|
||
}
|
||
editorVisible.value = false;
|
||
fetchCoupons();
|
||
} catch (e) {
|
||
console.error(e);
|
||
toast.add({
|
||
severity: "error",
|
||
summary: "操作失败",
|
||
detail: e.message || "请稍后重试",
|
||
life: 3000,
|
||
});
|
||
}
|
||
};
|
||
|
||
const submitGrant = async () => {
|
||
const ids = grantUsers.value
|
||
.split(/[,\s,]+/)
|
||
.map((val) => parseInt(val, 10))
|
||
.filter((val) => Number.isFinite(val) && val > 0);
|
||
|
||
if (!grantCouponId.value || ids.length === 0) {
|
||
toast.add({
|
||
severity: "warn",
|
||
summary: "请输入用户ID",
|
||
detail: "至少输入一个有效 ID",
|
||
life: 2000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await creatorApi.grantCoupon(grantCouponId.value, { user_ids: ids });
|
||
toast.add({
|
||
severity: "success",
|
||
summary: "发放成功",
|
||
detail: `已发放 ${ids.length} 张`,
|
||
life: 2000,
|
||
});
|
||
grantVisible.value = false;
|
||
fetchCoupons();
|
||
} catch (e) {
|
||
console.error(e);
|
||
toast.add({
|
||
severity: "error",
|
||
summary: "发放失败",
|
||
detail: e.message || "请稍后重试",
|
||
life: 3000,
|
||
});
|
||
}
|
||
};
|
||
|
||
const formatMoney = (value) => {
|
||
return (Number(value) / 100).toFixed(2);
|
||
};
|
||
|
||
const typeLabel = (value) => {
|
||
if (value === "discount") return "折扣";
|
||
if (value === "fix_amount") return "满减";
|
||
return value || "-";
|
||
};
|
||
|
||
const formatRange = (start, end) => {
|
||
const startText = start ? start.replace("T", " ").replace("Z", "") : "-";
|
||
const endText = end ? end.replace("T", " ").replace("Z", "") : "-";
|
||
return `${startText} ~ ${endText}`;
|
||
};
|
||
|
||
const statusStyle = (coupon) => {
|
||
const now = new Date();
|
||
if (coupon.end_at) {
|
||
const end = new Date(coupon.end_at);
|
||
if (!Number.isNaN(end.getTime()) && end < now) {
|
||
return { label: "已过期", bg: "bg-slate-100", text: "text-slate-500" };
|
||
}
|
||
}
|
||
return { label: "生效中", bg: "bg-emerald-50", text: "text-emerald-600" };
|
||
};
|
||
|
||
onMounted(fetchCoupons);
|
||
watch([filterStatus, filterType], () => {
|
||
first.value = 0;
|
||
fetchCoupons();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<div class="flex items-center justify-between mb-8">
|
||
<h1 class="text-2xl font-bold text-slate-900">优惠券管理</h1>
|
||
<button
|
||
class="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-bold hover:bg-slate-800 transition-colors"
|
||
@click="openCreate"
|
||
>
|
||
<i class="pi pi-plus mr-1"></i> 新建优惠券
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||
<select
|
||
v-model="filterStatus"
|
||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer"
|
||
>
|
||
<option value="all">全部</option>
|
||
<option value="active">生效中</option>
|
||
<option value="expired">已过期</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm font-bold text-slate-500">类型:</span>
|
||
<select
|
||
v-model="filterType"
|
||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer"
|
||
>
|
||
<option value="all">全部</option>
|
||
<option value="fix_amount">满减</option>
|
||
<option value="discount">折扣</option>
|
||
</select>
|
||
</div>
|
||
<div class="ml-auto relative w-64">
|
||
<i
|
||
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||
></i>
|
||
<input
|
||
type="text"
|
||
v-model="searchKeyword"
|
||
@keyup.enter="fetchCoupons"
|
||
placeholder="搜索标题或描述..."
|
||
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none transition-all"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
|
||
>
|
||
<table class="w-full text-left text-sm">
|
||
<thead
|
||
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
|
||
>
|
||
<tr>
|
||
<th class="px-6 py-4">标题</th>
|
||
<th class="px-6 py-4 whitespace-nowrap">类型</th>
|
||
<th class="px-6 py-4 whitespace-nowrap">面值</th>
|
||
<th class="px-6 py-4 whitespace-nowrap">门槛</th>
|
||
<th class="px-6 py-4 whitespace-nowrap">发放/使用</th>
|
||
<th class="px-6 py-4 whitespace-nowrap">有效期</th>
|
||
<th class="px-6 py-4 whitespace-nowrap">状态</th>
|
||
<th class="px-6 py-4 text-right whitespace-nowrap">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-slate-100">
|
||
<tr
|
||
v-for="coupon in coupons"
|
||
:key="coupon.id"
|
||
class="hover:bg-slate-50 transition-colors"
|
||
>
|
||
<td class="px-6 py-4">
|
||
<div class="font-bold text-slate-900">{{ coupon.title }}</div>
|
||
<div class="text-xs text-slate-500 mt-1 line-clamp-1">
|
||
{{ coupon.description || "-" }}
|
||
</div>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
{{ typeLabel(coupon.type) }}
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span v-if="coupon.type === 'fix_amount'"
|
||
>¥ {{ formatMoney(coupon.value) }}</span
|
||
>
|
||
<span v-else>{{ coupon.value }}%</span>
|
||
<span
|
||
v-if="coupon.type === 'discount' && coupon.max_discount > 0"
|
||
class="text-xs text-slate-500 ml-1"
|
||
>
|
||
封顶 ¥ {{ formatMoney(coupon.max_discount) }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
¥ {{ formatMoney(coupon.min_order_amount) }}
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span class="font-bold">{{ coupon.used_quantity }}</span>
|
||
<span class="text-slate-400">
|
||
/
|
||
{{
|
||
coupon.total_quantity === 0 ? "不限" : coupon.total_quantity
|
||
}}</span
|
||
>
|
||
</td>
|
||
<td class="px-6 py-4 text-slate-500 whitespace-nowrap">
|
||
{{ formatRange(coupon.start_at, coupon.end_at) }}
|
||
</td>
|
||
<td class="px-6 py-4">
|
||
<span
|
||
class="inline-block px-2.5 py-1 rounded text-xs font-bold whitespace-nowrap"
|
||
:class="statusStyle(coupon).bg + ' ' + statusStyle(coupon).text"
|
||
>
|
||
{{ statusStyle(coupon).label }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||
<button
|
||
class="text-primary-600 hover:text-primary-700 font-medium mr-4 cursor-pointer hover:bg-primary-50 px-2 py-1 rounded transition-colors"
|
||
@click="openEdit(coupon)"
|
||
>
|
||
编辑
|
||
</button>
|
||
<button
|
||
class="text-slate-700 hover:text-slate-900 font-medium cursor-pointer hover:bg-slate-100 px-2 py-1 rounded transition-colors"
|
||
@click="openGrant(coupon)"
|
||
>
|
||
发放
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div v-if="coupons.length === 0" class="text-center py-12 text-slate-400">
|
||
暂无优惠券
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-6 flex justify-end" v-if="totalRecords > rows">
|
||
<Paginator
|
||
:rows="rows"
|
||
:first="first"
|
||
:totalRecords="totalRecords"
|
||
@page="onPage"
|
||
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown"
|
||
:rowsPerPageOptions="[10, 20, 50]"
|
||
/>
|
||
</div>
|
||
|
||
<Dialog
|
||
v-model:visible="editorVisible"
|
||
modal
|
||
:header="editorTitle"
|
||
:style="{ width: '36rem' }"
|
||
>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600">标题</label>
|
||
<input
|
||
v-model="form.title"
|
||
type="text"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600">描述</label>
|
||
<textarea
|
||
v-model="form.description"
|
||
rows="2"
|
||
class="w-full mt-1 p-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
></textarea>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600">类型</label>
|
||
<select
|
||
v-model="form.type"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
|
||
>
|
||
<option value="fix_amount">满减</option>
|
||
<option value="discount">折扣</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600">面值</label>
|
||
<input
|
||
v-model.number="form.value"
|
||
type="number"
|
||
min="0"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
/>
|
||
<p class="text-xs text-slate-400 mt-1">
|
||
满减输入分;折扣输入 1-100
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600"
|
||
>使用门槛(分)</label
|
||
>
|
||
<input
|
||
v-model.number="form.min_order_amount"
|
||
type="number"
|
||
min="0"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
/>
|
||
</div>
|
||
<div v-if="form.type === 'discount'">
|
||
<label class="text-sm font-medium text-slate-600"
|
||
>最高抵扣(分)</label
|
||
>
|
||
<input
|
||
v-model.number="form.max_discount"
|
||
type="number"
|
||
min="0"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600">发行数量</label>
|
||
<input
|
||
v-model.number="form.total_quantity"
|
||
type="number"
|
||
min="0"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
/>
|
||
<p class="text-xs text-slate-400 mt-1">0 表示不限量</p>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600">生效时间</label>
|
||
<input
|
||
v-model="form.start_at"
|
||
type="datetime-local"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="text-sm font-medium text-slate-600">过期时间</label>
|
||
<input
|
||
v-model="form.end_at"
|
||
type="datetime-local"
|
||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<button
|
||
@click="editorVisible = false"
|
||
class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
@click="submitForm"
|
||
class="px-4 py-2 bg-slate-900 text-white rounded text-sm hover:bg-slate-800"
|
||
>
|
||
保存
|
||
</button>
|
||
</template>
|
||
</Dialog>
|
||
|
||
<Dialog
|
||
v-model:visible="grantVisible"
|
||
modal
|
||
header="发放优惠券"
|
||
:style="{ width: '28rem' }"
|
||
>
|
||
<div class="space-y-3">
|
||
<p class="text-sm text-slate-600">
|
||
请输入用户 ID,使用逗号或空格分隔。
|
||
</p>
|
||
<textarea
|
||
v-model="grantUsers"
|
||
rows="3"
|
||
class="w-full p-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||
placeholder="例如:1001,1002,1003"
|
||
></textarea>
|
||
</div>
|
||
<template #footer>
|
||
<button
|
||
@click="grantVisible = false"
|
||
class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
@click="submitGrant"
|
||
class="px-4 py-2 bg-primary-600 text-white rounded text-sm hover:bg-primary-700"
|
||
>
|
||
确认发放
|
||
</button>
|
||
</template>
|
||
</Dialog>
|
||
|
||
<Toast />
|
||
</div>
|
||
</template>
|