feat(shop-admin): manage group-buying activities
Add a group-buying page where a shop user lists, creates, edits, and deletes its own activities, configuring the SKU, localized name and description, group price, required paid members, active window, and group lifetime, and seeing each activity's open groups. Expose it as a group-buying navigation entry.
This commit is contained in:
@@ -27,6 +27,7 @@ watchEffect(() => {
|
||||
<NuxtLink to="/invoices">{{ $t("nav.invoices") }}</NuxtLink>
|
||||
<NuxtLink to="/coupons">{{ $t("nav.coupons") }}</NuxtLink>
|
||||
<NuxtLink to="/flash-sales">{{ $t("nav.flashSales") }}</NuxtLink>
|
||||
<NuxtLink to="/group-buying">{{ $t("nav.groupBuying") }}</NuxtLink>
|
||||
</aside>
|
||||
<main>
|
||||
<div class="main-head">
|
||||
|
||||
@@ -7,6 +7,7 @@ export const enExtra = {
|
||||
nav: {
|
||||
coupons: "Coupons",
|
||||
flashSales: "Flash sales",
|
||||
groupBuying: "Group buying",
|
||||
},
|
||||
shop: {
|
||||
dashboardTitle: "Merchant dashboard",
|
||||
@@ -117,6 +118,33 @@ export const enExtra = {
|
||||
flashStockInvalid: "Reserved stock cannot be negative.",
|
||||
flashLimitInvalid: "Per-customer limit must be a positive integer.",
|
||||
flashWindowInvalid: "End must not precede start.",
|
||||
groupBuyingList: "Group buying",
|
||||
newGroupActivity: "New activity",
|
||||
editGroupActivity: "Edit activity",
|
||||
groupNameEn: "Name (English)",
|
||||
groupNameZh: "Name (中文)",
|
||||
groupDescriptionEn: "Description (English)",
|
||||
groupDescriptionZh: "Description (中文)",
|
||||
groupSku: "SKU",
|
||||
groupPrice: "Group price (minor units)",
|
||||
groupRequiredMembers: "Required paid members",
|
||||
groupLifetimeHours: "Group lifetime (hours)",
|
||||
groupStartsAt: "Starts at",
|
||||
groupEndsAt: "Ends at",
|
||||
groupEnabled: "Enabled",
|
||||
createGroupActivity: "Create activity",
|
||||
updateGroupActivity: "Update activity",
|
||||
groupActivitySaved: "Activity saved.",
|
||||
groupOpenGroups: "Open groups",
|
||||
groupNoOpenGroups: "No open groups.",
|
||||
groupPaidMembers: "Joined",
|
||||
groupExpiresAt: "Expires",
|
||||
groupNameRequired: "Both language names are required.",
|
||||
groupSkuRequired: "Choose a SKU.",
|
||||
groupPriceInvalid: "Group price must be a positive integer.",
|
||||
groupMembersInvalid: "At least two paid members are required.",
|
||||
groupLifetimeInvalid: "Group lifetime must be a positive number of hours.",
|
||||
groupWindowInvalid: "End must not precede start.",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
@@ -127,6 +155,7 @@ export const zhExtra = {
|
||||
nav: {
|
||||
coupons: "优惠券",
|
||||
flashSales: "秒杀",
|
||||
groupBuying: "拼团",
|
||||
},
|
||||
shop: {
|
||||
dashboardTitle: "商家仪表盘",
|
||||
@@ -237,5 +266,32 @@ export const zhExtra = {
|
||||
flashStockInvalid: "活动库存不能为负数。",
|
||||
flashLimitInvalid: "每人限购必须为正整数。",
|
||||
flashWindowInvalid: "结束时间不能早于开始时间。",
|
||||
groupBuyingList: "拼团活动",
|
||||
newGroupActivity: "新建活动",
|
||||
editGroupActivity: "编辑活动",
|
||||
groupNameEn: "名称(英文)",
|
||||
groupNameZh: "名称(中文)",
|
||||
groupDescriptionEn: "描述(英文)",
|
||||
groupDescriptionZh: "描述(中文)",
|
||||
groupSku: "SKU",
|
||||
groupPrice: "拼团价(最小单位)",
|
||||
groupRequiredMembers: "成团人数",
|
||||
groupLifetimeHours: "团有效期(小时)",
|
||||
groupStartsAt: "开始时间",
|
||||
groupEndsAt: "结束时间",
|
||||
groupEnabled: "启用",
|
||||
createGroupActivity: "创建活动",
|
||||
updateGroupActivity: "更新活动",
|
||||
groupActivitySaved: "活动已保存。",
|
||||
groupOpenGroups: "进行中的团",
|
||||
groupNoOpenGroups: "暂无进行中的团。",
|
||||
groupPaidMembers: "已参团",
|
||||
groupExpiresAt: "到期",
|
||||
groupNameRequired: "中英文名称均为必填项。",
|
||||
groupSkuRequired: "请选择 SKU。",
|
||||
groupPriceInvalid: "拼团价必须为正整数。",
|
||||
groupMembersInvalid: "成团人数至少为 2。",
|
||||
groupLifetimeInvalid: "团有效期必须为正数小时。",
|
||||
groupWindowInvalid: "结束时间不能早于开始时间。",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import type { Currency, GroupBuyingActivityView } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
|
||||
const activities = ref<GroupBuyingActivityView[]>([]);
|
||||
const currencies = ref<Currency[]>([]);
|
||||
const skuOptions = ref<{ id: string; label: string }[]>([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const actionId = ref("");
|
||||
const error = ref("");
|
||||
const message = ref("");
|
||||
const editingId = ref("");
|
||||
|
||||
const form = reactive({
|
||||
skuId: "",
|
||||
nameEn: "",
|
||||
nameZh: "",
|
||||
descriptionEn: "",
|
||||
descriptionZh: "",
|
||||
groupPriceMinor: 0,
|
||||
currency: "USD",
|
||||
requiredMembers: 2,
|
||||
groupLifetimeHours: 24,
|
||||
startsAt: "",
|
||||
endsAt: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
function toLocalInput(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 7 * 24 * 3600 * 1000);
|
||||
editingId.value = "";
|
||||
form.skuId = skuOptions.value[0]?.id ?? "";
|
||||
form.nameEn = "";
|
||||
form.nameZh = "";
|
||||
form.descriptionEn = "";
|
||||
form.descriptionZh = "";
|
||||
form.groupPriceMinor = 0;
|
||||
form.currency =
|
||||
currencies.value.find((c) => c.is_base)?.code ?? currencies.value[0]?.code ?? "USD";
|
||||
form.requiredMembers = 2;
|
||||
form.groupLifetimeHours = 24;
|
||||
form.startsAt = toLocalInput(now.toISOString());
|
||||
form.endsAt = toLocalInput(later.toISOString());
|
||||
form.enabled = true;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
activities.value = await $api.shop.listGroupBuyingActivities();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
try {
|
||||
const page = await $api.shop.listMyProducts({ per_page: 100 });
|
||||
skuOptions.value = page.items.flatMap((product) =>
|
||||
(product.skus ?? []).map((sku) => ({
|
||||
id: sku.id,
|
||||
label: `${product.name[locale.value] ?? product.name.en ?? product.slug} · ${sku.sku_code}`,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
skuOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrencies(): Promise<void> {
|
||||
try {
|
||||
currencies.value = (await $api.listCurrencies()).filter((c) => c.enabled);
|
||||
} catch {
|
||||
currencies.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
if (!form.nameEn.trim() || !form.nameZh.trim()) {
|
||||
error.value = translate("shop.groupNameRequired");
|
||||
return;
|
||||
}
|
||||
if (!form.skuId) {
|
||||
error.value = translate("shop.groupSkuRequired");
|
||||
return;
|
||||
}
|
||||
if (!(Number(form.groupPriceMinor) > 0)) {
|
||||
error.value = translate("shop.groupPriceInvalid");
|
||||
return;
|
||||
}
|
||||
if (Number(form.requiredMembers) < 2) {
|
||||
error.value = translate("shop.groupMembersInvalid");
|
||||
return;
|
||||
}
|
||||
if (!(Number(form.groupLifetimeHours) > 0)) {
|
||||
error.value = translate("shop.groupLifetimeInvalid");
|
||||
return;
|
||||
}
|
||||
if (new Date(form.endsAt) < new Date(form.startsAt)) {
|
||||
error.value = translate("shop.groupWindowInvalid");
|
||||
return;
|
||||
}
|
||||
const description =
|
||||
form.descriptionEn.trim() && form.descriptionZh.trim()
|
||||
? { en: form.descriptionEn.trim(), zh: form.descriptionZh.trim() }
|
||||
: null;
|
||||
const body = {
|
||||
sku_id: form.skuId,
|
||||
name: { en: form.nameEn.trim(), zh: form.nameZh.trim() },
|
||||
description,
|
||||
group_price_minor: Number(form.groupPriceMinor),
|
||||
currency: form.currency,
|
||||
required_members: Number(form.requiredMembers),
|
||||
starts_at: new Date(form.startsAt).toISOString(),
|
||||
ends_at: new Date(form.endsAt).toISOString(),
|
||||
group_lifetime_hours: Number(form.groupLifetimeHours),
|
||||
enabled: form.enabled,
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingId.value) await $api.shop.updateGroupBuyingActivity(editingId.value, body);
|
||||
else await $api.shop.createGroupBuyingActivity(body);
|
||||
message.value = translate("shop.groupActivitySaved");
|
||||
resetForm();
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function edit(activity: GroupBuyingActivityView): void {
|
||||
editingId.value = activity.id;
|
||||
form.skuId = activity.sku_id;
|
||||
form.nameEn = activity.name.en ?? "";
|
||||
form.nameZh = activity.name.zh ?? "";
|
||||
form.descriptionEn = activity.description?.en ?? "";
|
||||
form.descriptionZh = activity.description?.zh ?? "";
|
||||
form.groupPriceMinor = activity.group_price_minor;
|
||||
form.currency = activity.currency;
|
||||
form.requiredMembers = activity.required_members;
|
||||
form.groupLifetimeHours = activity.group_lifetime_hours;
|
||||
form.startsAt = toLocalInput(activity.starts_at);
|
||||
form.endsAt = toLocalInput(activity.ends_at);
|
||||
form.enabled = activity.enabled;
|
||||
}
|
||||
|
||||
async function remove(activity: GroupBuyingActivityView): Promise<void> {
|
||||
actionId.value = activity.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.deleteGroupBuyingActivity(activity.id);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadMoney();
|
||||
await Promise.all([loadCurrencies(), loadCatalog()]);
|
||||
resetForm();
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">{{ $t("shop.groupBuyingList") }}</h1>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<div v-if="message" class="muted" role="status">{{ message }}</div>
|
||||
|
||||
<section class="card create-card">
|
||||
<h2>{{ editingId ? $t("shop.editGroupActivity") : $t("shop.newGroupActivity") }}</h2>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>{{ $t("shop.groupSku") }}</span>
|
||||
<select v-model="form.skuId" class="input">
|
||||
<option value="">{{ $t("shop.flashSelectSku") }}</option>
|
||||
<option v-for="option in skuOptions" :key="option.id" :value="option.id">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupNameEn") }}</span>
|
||||
<input v-model="form.nameEn" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupNameZh") }}</span>
|
||||
<input v-model="form.nameZh" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupDescriptionEn") }}</span>
|
||||
<input v-model="form.descriptionEn" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupDescriptionZh") }}</span>
|
||||
<input v-model="form.descriptionZh" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupPrice") }}</span>
|
||||
<input v-model.number="form.groupPriceMinor" class="input" type="number" min="1" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.couponCurrency") }}</span>
|
||||
<select v-model="form.currency" class="input">
|
||||
<option v-for="c in currencies" :key="c.code" :value="c.code">{{ c.code }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupRequiredMembers") }}</span>
|
||||
<input v-model.number="form.requiredMembers" class="input" type="number" min="2" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupLifetimeHours") }}</span>
|
||||
<input v-model.number="form.groupLifetimeHours" class="input" type="number" min="1" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupStartsAt") }}</span>
|
||||
<input v-model="form.startsAt" class="input" type="datetime-local" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.groupEndsAt") }}</span>
|
||||
<input v-model="form.endsAt" class="input" type="datetime-local" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>{{ $t("shop.groupEnabled") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn primary" :disabled="saving" @click="save">
|
||||
{{ editingId ? $t("shop.updateGroupActivity") : $t("shop.createGroupActivity") }}
|
||||
</button>
|
||||
<button v-if="editingId" class="btn" type="button" @click="resetForm">
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="!activities.length" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.groupSku") }}</th>
|
||||
<th>{{ $t("shop.groupNameEn") }}</th>
|
||||
<th>{{ $t("shop.groupPrice") }}</th>
|
||||
<th>{{ $t("shop.groupRequiredMembers") }}</th>
|
||||
<th>{{ $t("shop.groupLifetimeHours") }}</th>
|
||||
<th>{{ $t("shop.groupStartsAt") }}</th>
|
||||
<th>{{ $t("shop.groupOpenGroups") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="activity in activities" :key="activity.id">
|
||||
<td>{{ activity.sku_code }}</td>
|
||||
<td>{{ activity.name[locale] ?? activity.name.en }}</td>
|
||||
<td>{{ fmt(activity.group_price_minor, activity.currency) }}</td>
|
||||
<td>{{ activity.required_members }}</td>
|
||||
<td>{{ activity.group_lifetime_hours }}</td>
|
||||
<td>{{ activity.starts_at.slice(0, 10) }}</td>
|
||||
<td>
|
||||
<template v-if="activity.open_groups.length">
|
||||
<span v-for="group in activity.open_groups" :key="group.id" class="badge orange">
|
||||
{{ $t("shop.groupPaidMembers") }} {{ group.paid_member_count }}/{{ group.required_members }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="muted">{{ $t("shop.groupNoOpenGroups") }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="activity.enabled ? 'green' : 'red'">
|
||||
{{ activity.enabled ? $t("shop.enabledOn") : $t("shop.enabledOff") }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="action-row">
|
||||
<button class="btn sm" :disabled="actionId === activity.id" @click="edit(activity)">
|
||||
{{ $t("shop.edit") }}
|
||||
</button>
|
||||
<button class="btn sm" :disabled="actionId === activity.id" @click="remove(activity)">
|
||||
{{ $t("common.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.create-card h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.form-grid .checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.table {
|
||||
min-width: 1100px;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user