Files
quyun-v2/frontend/portal/src/views/creator/CouponsView.vue

546 lines
17 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, 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>