feat(shop-admin): manage flash-sale sessions and activity items
Add a flash-sale page where a shop user lists, creates, edits, and deletes its own sessions and their SKU activity items, setting the fixed sale price, currency, reserved stock, and per-customer limit. Expose it as a flash-sale navigation entry beside the existing shop operations.
This commit is contained in:
@@ -26,6 +26,7 @@ watchEffect(() => {
|
||||
<NuxtLink to="/shipments">{{ $t("nav.shipments") }}</NuxtLink>
|
||||
<NuxtLink to="/invoices">{{ $t("nav.invoices") }}</NuxtLink>
|
||||
<NuxtLink to="/coupons">{{ $t("nav.coupons") }}</NuxtLink>
|
||||
<NuxtLink to="/flash-sales">{{ $t("nav.flashSales") }}</NuxtLink>
|
||||
</aside>
|
||||
<main>
|
||||
<div class="main-head">
|
||||
|
||||
@@ -6,6 +6,7 @@ export const enExtra = {
|
||||
},
|
||||
nav: {
|
||||
coupons: "Coupons",
|
||||
flashSales: "Flash sales",
|
||||
},
|
||||
shop: {
|
||||
dashboardTitle: "Merchant dashboard",
|
||||
@@ -85,6 +86,37 @@ export const enExtra = {
|
||||
couponWindowInvalid: "End must not precede start.",
|
||||
enabledOn: "Enabled",
|
||||
enabledOff: "Disabled",
|
||||
flashSaleList: "Flash sales",
|
||||
newFlashSale: "New session",
|
||||
editFlashSale: "Edit session",
|
||||
flashLabelEn: "Label (English)",
|
||||
flashLabelZh: "Label (中文)",
|
||||
flashStartsAt: "Starts at",
|
||||
flashEndsAt: "Ends at",
|
||||
flashEnabled: "Enabled",
|
||||
createFlashSale: "Create session",
|
||||
updateFlashSale: "Update session",
|
||||
flashSaleSaved: "Session saved.",
|
||||
flashItemSaved: "Activity item saved.",
|
||||
flashItems: "Activity items",
|
||||
noFlashItems: "No activity items yet.",
|
||||
newFlashItem: "Add activity item",
|
||||
editFlashItem: "Edit activity item",
|
||||
flashSku: "SKU",
|
||||
flashSelectSku: "Select a SKU",
|
||||
flashSalePrice: "Sale price (minor units)",
|
||||
flashReservedStock: "Reserved stock",
|
||||
flashPerCustomerLimit: "Per-customer limit",
|
||||
saveFlashItem: "Save item",
|
||||
flashOriginalPrice: "Catalog price",
|
||||
flashSold: "Sold",
|
||||
flashRemaining: "Remaining",
|
||||
flashLabelRequired: "Both language labels are required.",
|
||||
flashSkuRequired: "Choose a SKU.",
|
||||
flashPriceInvalid: "Sale price must be a positive integer.",
|
||||
flashStockInvalid: "Reserved stock cannot be negative.",
|
||||
flashLimitInvalid: "Per-customer limit must be a positive integer.",
|
||||
flashWindowInvalid: "End must not precede start.",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
@@ -94,6 +126,7 @@ export const zhExtra = {
|
||||
},
|
||||
nav: {
|
||||
coupons: "优惠券",
|
||||
flashSales: "秒杀",
|
||||
},
|
||||
shop: {
|
||||
dashboardTitle: "商家仪表盘",
|
||||
@@ -173,5 +206,36 @@ export const zhExtra = {
|
||||
couponWindowInvalid: "结束时间不能早于开始时间。",
|
||||
enabledOn: "已启用",
|
||||
enabledOff: "已停用",
|
||||
flashSaleList: "秒杀活动",
|
||||
newFlashSale: "新建场次",
|
||||
editFlashSale: "编辑场次",
|
||||
flashLabelEn: "标题(英文)",
|
||||
flashLabelZh: "标题(中文)",
|
||||
flashStartsAt: "开始时间",
|
||||
flashEndsAt: "结束时间",
|
||||
flashEnabled: "启用",
|
||||
createFlashSale: "创建场次",
|
||||
updateFlashSale: "更新场次",
|
||||
flashSaleSaved: "场次已保存。",
|
||||
flashItemSaved: "活动商品已保存。",
|
||||
flashItems: "活动商品",
|
||||
noFlashItems: "暂无活动商品。",
|
||||
newFlashItem: "添加活动商品",
|
||||
editFlashItem: "编辑活动商品",
|
||||
flashSku: "SKU",
|
||||
flashSelectSku: "请选择 SKU",
|
||||
flashSalePrice: "秒杀价(最小单位)",
|
||||
flashReservedStock: "活动库存",
|
||||
flashPerCustomerLimit: "每人限购",
|
||||
saveFlashItem: "保存商品",
|
||||
flashOriginalPrice: "原价",
|
||||
flashSold: "已售",
|
||||
flashRemaining: "剩余",
|
||||
flashLabelRequired: "中英文标题均为必填项。",
|
||||
flashSkuRequired: "请选择 SKU。",
|
||||
flashPriceInvalid: "秒杀价必须为正整数。",
|
||||
flashStockInvalid: "活动库存不能为负数。",
|
||||
flashLimitInvalid: "每人限购必须为正整数。",
|
||||
flashWindowInvalid: "结束时间不能早于开始时间。",
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Currency,
|
||||
ShopFlashSaleItem,
|
||||
ShopFlashSaleSession,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
|
||||
const sessions = ref<ShopFlashSaleSession[]>([]);
|
||||
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 editingSessionId = ref("");
|
||||
const editingItemId = ref("");
|
||||
const itemSessionId = ref("");
|
||||
|
||||
const sessionForm = reactive({
|
||||
labelEn: "",
|
||||
labelZh: "",
|
||||
startsAt: "",
|
||||
endsAt: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const itemForm = reactive({
|
||||
skuId: "",
|
||||
salePriceMinor: 0,
|
||||
currency: "USD",
|
||||
reservedStock: 0,
|
||||
perCustomerLimit: 1,
|
||||
});
|
||||
|
||||
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 resetSessionForm(): void {
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 7 * 24 * 3600 * 1000);
|
||||
editingSessionId.value = "";
|
||||
sessionForm.labelEn = "";
|
||||
sessionForm.labelZh = "";
|
||||
sessionForm.startsAt = toLocalInput(now.toISOString());
|
||||
sessionForm.endsAt = toLocalInput(later.toISOString());
|
||||
sessionForm.enabled = true;
|
||||
}
|
||||
|
||||
function resetItemForm(): void {
|
||||
editingItemId.value = "";
|
||||
itemForm.skuId = skuOptions.value[0]?.id ?? "";
|
||||
itemForm.salePriceMinor = 0;
|
||||
itemForm.currency =
|
||||
currencies.value.find((c) => c.is_base)?.code ?? currencies.value[0]?.code ?? "USD";
|
||||
itemForm.reservedStock = 0;
|
||||
itemForm.perCustomerLimit = 1;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
sessions.value = await $api.shop.listFlashSales();
|
||||
} 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 saveSession(): Promise<void> {
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
if (!sessionForm.labelEn.trim() || !sessionForm.labelZh.trim()) {
|
||||
error.value = translate("shop.flashLabelRequired");
|
||||
return;
|
||||
}
|
||||
if (new Date(sessionForm.endsAt) < new Date(sessionForm.startsAt)) {
|
||||
error.value = translate("shop.flashWindowInvalid");
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
label: { en: sessionForm.labelEn.trim(), zh: sessionForm.labelZh.trim() },
|
||||
starts_at: new Date(sessionForm.startsAt).toISOString(),
|
||||
ends_at: new Date(sessionForm.endsAt).toISOString(),
|
||||
enabled: sessionForm.enabled,
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingSessionId.value) await $api.shop.updateFlashSale(editingSessionId.value, body);
|
||||
else await $api.shop.createFlashSale(body);
|
||||
message.value = translate("shop.flashSaleSaved");
|
||||
resetSessionForm();
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editSession(session: ShopFlashSaleSession): void {
|
||||
editingSessionId.value = session.id;
|
||||
sessionForm.labelEn = session.label.en ?? "";
|
||||
sessionForm.labelZh = session.label.zh ?? "";
|
||||
sessionForm.startsAt = toLocalInput(session.starts_at);
|
||||
sessionForm.endsAt = toLocalInput(session.ends_at);
|
||||
sessionForm.enabled = session.enabled;
|
||||
}
|
||||
|
||||
async function removeSession(session: ShopFlashSaleSession): Promise<void> {
|
||||
actionId.value = session.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.deleteFlashSale(session.id);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function startAddItem(session: ShopFlashSaleSession): void {
|
||||
itemSessionId.value = session.id;
|
||||
resetItemForm();
|
||||
}
|
||||
|
||||
function editItem(session: ShopFlashSaleSession, item: ShopFlashSaleItem): void {
|
||||
itemSessionId.value = session.id;
|
||||
editingItemId.value = item.id;
|
||||
itemForm.skuId = item.sku_id;
|
||||
itemForm.salePriceMinor = item.sale_price_minor;
|
||||
itemForm.currency = item.currency;
|
||||
itemForm.reservedStock = item.reserved_stock;
|
||||
itemForm.perCustomerLimit = item.per_customer_limit;
|
||||
}
|
||||
|
||||
async function saveItem(session: ShopFlashSaleSession): Promise<void> {
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
if (!itemForm.skuId) {
|
||||
error.value = translate("shop.flashSkuRequired");
|
||||
return;
|
||||
}
|
||||
if (!(Number(itemForm.salePriceMinor) > 0)) {
|
||||
error.value = translate("shop.flashPriceInvalid");
|
||||
return;
|
||||
}
|
||||
if (Number(itemForm.reservedStock) < 0) {
|
||||
error.value = translate("shop.flashStockInvalid");
|
||||
return;
|
||||
}
|
||||
if (!(Number(itemForm.perCustomerLimit) > 0)) {
|
||||
error.value = translate("shop.flashLimitInvalid");
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
sku_id: itemForm.skuId,
|
||||
sale_price_minor: Number(itemForm.salePriceMinor),
|
||||
currency: itemForm.currency,
|
||||
reserved_stock: Number(itemForm.reservedStock),
|
||||
per_customer_limit: Number(itemForm.perCustomerLimit),
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingItemId.value) await $api.shop.updateFlashSaleItem(editingItemId.value, body);
|
||||
else await $api.shop.addFlashSaleItem(session.id, body);
|
||||
message.value = translate("shop.flashItemSaved");
|
||||
resetItemForm();
|
||||
itemSessionId.value = "";
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: ShopFlashSaleItem): Promise<void> {
|
||||
actionId.value = item.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.deleteFlashSaleItem(item.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()]);
|
||||
resetSessionForm();
|
||||
resetItemForm();
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">{{ $t("shop.flashSaleList") }}</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>{{ editingSessionId ? $t("shop.editFlashSale") : $t("shop.newFlashSale") }}</h2>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>{{ $t("shop.flashLabelEn") }}</span>
|
||||
<input v-model="sessionForm.labelEn" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.flashLabelZh") }}</span>
|
||||
<input v-model="sessionForm.labelZh" class="input" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.flashStartsAt") }}</span>
|
||||
<input v-model="sessionForm.startsAt" class="input" type="datetime-local" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.flashEndsAt") }}</span>
|
||||
<input v-model="sessionForm.endsAt" class="input" type="datetime-local" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input v-model="sessionForm.enabled" type="checkbox" />
|
||||
<span>{{ $t("shop.flashEnabled") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn primary" :disabled="saving" @click="saveSession">
|
||||
{{ editingSessionId ? $t("shop.updateFlashSale") : $t("shop.createFlashSale") }}
|
||||
</button>
|
||||
<button v-if="editingSessionId" class="btn" type="button" @click="resetSessionForm">
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="!sessions.length" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<template v-else>
|
||||
<section v-for="session in sessions" :key="session.id" class="card session-card">
|
||||
<header class="session-head">
|
||||
<div>
|
||||
<strong>{{ session.label[locale] ?? session.label.en }}</strong>
|
||||
<span class="muted">
|
||||
{{ session.starts_at.slice(0, 10) }} → {{ session.ends_at.slice(0, 10) }}
|
||||
</span>
|
||||
<span class="badge" :class="session.enabled ? 'green' : 'red'">
|
||||
{{ session.enabled ? $t("shop.enabledOn") : $t("shop.enabledOff") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="btn sm" :disabled="actionId === session.id" @click="editSession(session)">
|
||||
{{ $t("shop.edit") }}
|
||||
</button>
|
||||
<button class="btn sm" :disabled="actionId === session.id" @click="startAddItem(session)">
|
||||
{{ $t("shop.newFlashItem") }}
|
||||
</button>
|
||||
<button class="btn sm" :disabled="actionId === session.id" @click="removeSession(session)">
|
||||
{{ $t("common.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h3 class="sub-title">{{ $t("shop.flashItems") }}</h3>
|
||||
<div v-if="!session.items.length" class="muted">{{ $t("shop.noFlashItems") }}</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.flashSku") }}</th>
|
||||
<th>{{ $t("shop.flashSalePrice") }}</th>
|
||||
<th>{{ $t("shop.flashOriginalPrice") }}</th>
|
||||
<th>{{ $t("shop.flashRemaining") }}</th>
|
||||
<th>{{ $t("shop.flashSold") }}</th>
|
||||
<th>{{ $t("shop.flashPerCustomerLimit") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in session.items" :key="item.id">
|
||||
<td>{{ item.sku_code }}</td>
|
||||
<td>{{ fmt(item.sale_price_minor, item.currency) }}</td>
|
||||
<td>{{ fmt(item.original_price_minor, item.original_currency) }}</td>
|
||||
<td>{{ item.reserved_stock }}</td>
|
||||
<td>{{ item.sold_count }}</td>
|
||||
<td>{{ item.per_customer_limit }}</td>
|
||||
<td class="action-row">
|
||||
<button class="btn sm" :disabled="actionId === item.id" @click="editItem(session, item)">
|
||||
{{ $t("shop.edit") }}
|
||||
</button>
|
||||
<button class="btn sm" :disabled="actionId === item.id" @click="removeItem(item)">
|
||||
{{ $t("common.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="itemSessionId === session.id" class="item-form">
|
||||
<h3 class="sub-title">
|
||||
{{ editingItemId ? $t("shop.editFlashItem") : $t("shop.newFlashItem") }}
|
||||
</h3>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>{{ $t("shop.flashSku") }}</span>
|
||||
<select v-model="itemForm.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.flashSalePrice") }}</span>
|
||||
<input v-model.number="itemForm.salePriceMinor" class="input" type="number" min="1" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.couponCurrency") }}</span>
|
||||
<select v-model="itemForm.currency" class="input">
|
||||
<option v-for="c in currencies" :key="c.code" :value="c.code">{{ c.code }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.flashReservedStock") }}</span>
|
||||
<input v-model.number="itemForm.reservedStock" class="input" type="number" min="0" step="1" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ $t("shop.flashPerCustomerLimit") }}</span>
|
||||
<input v-model.number="itemForm.perCustomerLimit" class="input" type="number" min="1" step="1" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn primary" :disabled="saving" @click="saveItem(session)">
|
||||
{{ $t("shop.saveFlashItem") }}
|
||||
</button>
|
||||
<button class="btn" type="button" @click="itemSessionId = ''; resetItemForm()">
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</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;
|
||||
}
|
||||
.session-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.session-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.session-head .muted {
|
||||
margin: 0 10px;
|
||||
}
|
||||
.sub-title {
|
||||
font-size: 14px;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.item-form {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--mall-line, #eee);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user