feat: add creator coupons and portal lint

This commit is contained in:
2026-01-14 09:51:23 +08:00
parent 4f315cc2db
commit fb0a1c2f84
14 changed files with 5103 additions and 1973 deletions

View File

@@ -0,0 +1,545 @@
<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>