346 lines
12 KiB
Vue
346 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import type { ContentInputByKind, ContentKind } from "@vmall/shared";
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
|
|
type BannerRow = ContentInputByKind["banners"][number];
|
|
type QuickLinkRow = ContentInputByKind["quick-links"][number];
|
|
type FloorRow = ContentInputByKind["floor-adverts"][number];
|
|
|
|
const { $api } = useNuxtApp();
|
|
const { t } = useI18n();
|
|
|
|
const banners = ref<BannerRow[]>([]);
|
|
const promos = ref<BannerRow[]>([]);
|
|
const quickLinks = ref<QuickLinkRow[]>([]);
|
|
const floorAdverts = ref<FloorRow[]>([]);
|
|
const loading = ref(true);
|
|
const loadError = ref("");
|
|
const saving = ref<ContentKind | null>(null);
|
|
/** Per-kind row field errors, keyed by row index. */
|
|
const rowErrors = reactive<Record<ContentKind, Record<number, string>>>({
|
|
banners: {},
|
|
promos: {},
|
|
"quick-links": {},
|
|
"floor-adverts": {},
|
|
});
|
|
const feedback = reactive<Record<ContentKind, { ok: boolean; message: string } | null>>({
|
|
banners: null,
|
|
promos: null,
|
|
"quick-links": null,
|
|
"floor-adverts": null,
|
|
});
|
|
|
|
const kindLabelKey: Record<ContentKind, string> = {
|
|
banners: "admin.contentBanners",
|
|
promos: "admin.contentPromos",
|
|
"quick-links": "admin.contentQuickLinks",
|
|
"floor-adverts": "admin.contentFloorAdverts",
|
|
};
|
|
|
|
async function loadContent(): Promise<void> {
|
|
loading.value = true;
|
|
loadError.value = "";
|
|
try {
|
|
const content = await $api.admin.getContent();
|
|
banners.value = content.banners.map(({ image, url, active }) => ({ image, url, active }));
|
|
promos.value = content.promos.map(({ image, url, active }) => ({ image, url, active }));
|
|
quickLinks.value = content.quick_links.map(({ label, url, glyph, active }) => ({
|
|
label: { ...label },
|
|
url,
|
|
glyph,
|
|
active,
|
|
}));
|
|
floorAdverts.value = content.floor_adverts.map(({ image, active }) => ({ image, active }));
|
|
} catch (error: unknown) {
|
|
loadError.value = error instanceof Error ? error.message : t("common.error");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function rowsOf(kind: ContentKind): { image?: string; url?: string }[] {
|
|
switch (kind) {
|
|
case "banners":
|
|
return banners.value;
|
|
case "promos":
|
|
return promos.value;
|
|
case "quick-links":
|
|
return quickLinks.value;
|
|
case "floor-adverts":
|
|
return floorAdverts.value;
|
|
}
|
|
}
|
|
|
|
function addRow(kind: ContentKind): void {
|
|
feedback[kind] = null;
|
|
if (kind === "quick-links") {
|
|
quickLinks.value.push({ label: { en: "", zh: "" }, url: "", glyph: "", active: true });
|
|
} else if (kind === "floor-adverts") {
|
|
floorAdverts.value.push({ image: "", active: true });
|
|
} else if (kind === "banners") {
|
|
banners.value.push({ image: "", url: "", active: true });
|
|
} else {
|
|
promos.value.push({ image: "", url: "", active: true });
|
|
}
|
|
}
|
|
|
|
function removeRow(kind: ContentKind, index: number): void {
|
|
feedback[kind] = null;
|
|
rowsOf(kind).splice(index, 1);
|
|
rowErrors[kind] = {};
|
|
}
|
|
|
|
function moveRow(kind: ContentKind, index: number, dir: -1 | 1): void {
|
|
const list = rowsOf(kind);
|
|
const target = index + dir;
|
|
if (target < 0 || target >= list.length) return;
|
|
feedback[kind] = null;
|
|
const [row] = list.splice(index, 1);
|
|
list.splice(target, 0, row);
|
|
rowErrors[kind] = {};
|
|
}
|
|
|
|
function validate(kind: ContentKind): boolean {
|
|
const errors: Record<number, string> = {};
|
|
const list = rowsOf(kind);
|
|
for (const [index, row] of list.entries()) {
|
|
if (kind === "quick-links") {
|
|
const link = row as QuickLinkRow;
|
|
if (
|
|
!link.label.en.trim() ||
|
|
!link.label.zh.trim() ||
|
|
!link.url.trim() ||
|
|
!link.glyph.trim()
|
|
) {
|
|
errors[index] = t("admin.contentQuickLinkInvalid");
|
|
}
|
|
} else if (kind === "floor-adverts") {
|
|
if (!(row as FloorRow).image.trim()) errors[index] = t("admin.contentImageRequired");
|
|
} else if (!row.image?.trim() || !row.url?.trim()) {
|
|
errors[index] = t("admin.contentBannerInvalid");
|
|
}
|
|
}
|
|
rowErrors[kind] = errors;
|
|
return Object.keys(errors).length === 0;
|
|
}
|
|
|
|
async function saveKind(kind: ContentKind): Promise<void> {
|
|
feedback[kind] = null;
|
|
if (!validate(kind)) return;
|
|
saving.value = kind;
|
|
try {
|
|
switch (kind) {
|
|
case "banners":
|
|
await $api.admin.replaceContent("banners", banners.value);
|
|
break;
|
|
case "promos":
|
|
await $api.admin.replaceContent("promos", promos.value);
|
|
break;
|
|
case "quick-links":
|
|
await $api.admin.replaceContent("quick-links", quickLinks.value);
|
|
break;
|
|
case "floor-adverts":
|
|
await $api.admin.replaceContent("floor-adverts", floorAdverts.value);
|
|
break;
|
|
}
|
|
feedback[kind] = { ok: true, message: t("admin.contentSaved") };
|
|
} catch (error: unknown) {
|
|
feedback[kind] = {
|
|
ok: false,
|
|
message: error instanceof Error ? error.message : t("common.error"),
|
|
};
|
|
} finally {
|
|
saving.value = null;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadContent();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<VPage :title="$t('nav.content')">
|
|
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
|
<p v-else-if="loadError" class="text-danger my-2 text-sm" role="alert">{{ loadError }}</p>
|
|
<template v-else>
|
|
<VCard v-for="kind in ['banners', 'promos'] as ContentKind[]" :key="kind" class="mb-5">
|
|
<h2 class="mb-4 text-base font-semibold">{{ $t(kindLabelKey[kind]) }}</h2>
|
|
<div
|
|
v-for="(row, index) in rowsOf(kind)"
|
|
:key="index"
|
|
class="border-border mb-3 rounded-md border p-3"
|
|
>
|
|
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_1fr_auto_auto]">
|
|
<img
|
|
v-if="row.image"
|
|
:src="row.image"
|
|
:alt="$t(kindLabelKey[kind])"
|
|
class="h-12 w-20 rounded object-cover"
|
|
/>
|
|
<VField :label="$t('admin.contentImage')" :error="rowErrors[kind][index]">
|
|
<VInput v-model="row.image" />
|
|
</VField>
|
|
<VField :label="$t('admin.contentUrl')">
|
|
<VInput v-model="row.url" />
|
|
</VField>
|
|
<label class="mb-3.5 flex items-center gap-2 text-sm">
|
|
<input v-model="row.active" type="checkbox" class="accent-primary h-4 w-4" />
|
|
{{ $t("admin.enabled") }}
|
|
</label>
|
|
<div class="mb-3.5 flex gap-1">
|
|
<VBtn size="sm" :disabled="index === 0" @click="moveRow(kind, index, -1)">↑</VBtn>
|
|
<VBtn
|
|
size="sm"
|
|
:disabled="index === rowsOf(kind).length - 1"
|
|
@click="moveRow(kind, index, 1)"
|
|
>↓</VBtn
|
|
>
|
|
<VBtn size="sm" @click="removeRow(kind, index)">{{ $t("common.delete") }}</VBtn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<VBtn size="sm" @click="addRow(kind)">{{ $t("admin.contentAddRow") }}</VBtn>
|
|
<VBtn variant="primary" :disabled="saving === kind" @click="saveKind(kind)">
|
|
{{ saving === kind ? $t("common.loading") : $t("common.save") }}
|
|
</VBtn>
|
|
<p
|
|
v-if="feedback[kind]"
|
|
role="status"
|
|
class="text-sm"
|
|
:class="feedback[kind]!.ok ? 'text-success' : 'text-danger'"
|
|
>
|
|
{{ feedback[kind]!.message }}
|
|
</p>
|
|
</div>
|
|
</VCard>
|
|
|
|
<VCard class="mb-5">
|
|
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.contentQuickLinks") }}</h2>
|
|
<div
|
|
v-for="(row, index) in quickLinks"
|
|
:key="index"
|
|
class="border-border mb-3 rounded-md border p-3"
|
|
>
|
|
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_1fr_1fr_1fr_auto_auto]">
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
class="text-primary mb-3.5 h-8 w-8 fill-current"
|
|
aria-hidden="true"
|
|
>
|
|
<path :d="row.glyph" />
|
|
</svg>
|
|
<VField :label="$t('admin.contentLabelEn')" :error="rowErrors['quick-links'][index]">
|
|
<VInput v-model="row.label.en" />
|
|
</VField>
|
|
<VField :label="$t('admin.contentLabelZh')">
|
|
<VInput v-model="row.label.zh" />
|
|
</VField>
|
|
<VField :label="$t('admin.contentUrl')">
|
|
<VInput v-model="row.url" />
|
|
</VField>
|
|
<VField :label="$t('admin.contentGlyph')">
|
|
<VInput v-model="row.glyph" placeholder="M12 2L2 22h20z" />
|
|
</VField>
|
|
<label class="mb-3.5 flex items-center gap-2 text-sm">
|
|
<input v-model="row.active" type="checkbox" class="accent-primary h-4 w-4" />
|
|
{{ $t("admin.enabled") }}
|
|
</label>
|
|
<div class="mb-3.5 flex gap-1">
|
|
<VBtn size="sm" :disabled="index === 0" @click="moveRow('quick-links', index, -1)"
|
|
>↑</VBtn
|
|
>
|
|
<VBtn
|
|
size="sm"
|
|
:disabled="index === quickLinks.length - 1"
|
|
@click="moveRow('quick-links', index, 1)"
|
|
>↓</VBtn
|
|
>
|
|
<VBtn size="sm" @click="removeRow('quick-links', index)">{{
|
|
$t("common.delete")
|
|
}}</VBtn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<VBtn size="sm" @click="addRow('quick-links')">{{ $t("admin.contentAddRow") }}</VBtn>
|
|
<VBtn
|
|
variant="primary"
|
|
:disabled="saving === 'quick-links'"
|
|
@click="saveKind('quick-links')"
|
|
>
|
|
{{ saving === "quick-links" ? $t("common.loading") : $t("common.save") }}
|
|
</VBtn>
|
|
<p
|
|
v-if="feedback['quick-links']"
|
|
role="status"
|
|
class="text-sm"
|
|
:class="feedback['quick-links']!.ok ? 'text-success' : 'text-danger'"
|
|
>
|
|
{{ feedback["quick-links"]!.message }}
|
|
</p>
|
|
</div>
|
|
</VCard>
|
|
|
|
<VCard class="mb-5">
|
|
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.contentFloorAdverts") }}</h2>
|
|
<div
|
|
v-for="(row, index) in floorAdverts"
|
|
:key="index"
|
|
class="border-border mb-3 rounded-md border p-3"
|
|
>
|
|
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_auto_auto]">
|
|
<img
|
|
v-if="row.image"
|
|
:src="row.image"
|
|
:alt="$t('admin.contentFloorAdverts')"
|
|
class="h-12 w-20 rounded object-cover"
|
|
/>
|
|
<VField :label="$t('admin.contentImage')" :error="rowErrors['floor-adverts'][index]">
|
|
<VInput v-model="row.image" />
|
|
</VField>
|
|
<label class="mb-3.5 flex items-center gap-2 text-sm">
|
|
<input v-model="row.active" type="checkbox" class="accent-primary h-4 w-4" />
|
|
{{ $t("admin.enabled") }}
|
|
</label>
|
|
<div class="mb-3.5 flex gap-1">
|
|
<VBtn size="sm" :disabled="index === 0" @click="moveRow('floor-adverts', index, -1)"
|
|
>↑</VBtn
|
|
>
|
|
<VBtn
|
|
size="sm"
|
|
:disabled="index === floorAdverts.length - 1"
|
|
@click="moveRow('floor-adverts', index, 1)"
|
|
>↓</VBtn
|
|
>
|
|
<VBtn size="sm" @click="removeRow('floor-adverts', index)">{{
|
|
$t("common.delete")
|
|
}}</VBtn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<VBtn size="sm" @click="addRow('floor-adverts')">{{ $t("admin.contentAddRow") }}</VBtn>
|
|
<VBtn
|
|
variant="primary"
|
|
:disabled="saving === 'floor-adverts'"
|
|
@click="saveKind('floor-adverts')"
|
|
>
|
|
{{ saving === "floor-adverts" ? $t("common.loading") : $t("common.save") }}
|
|
</VBtn>
|
|
<p
|
|
v-if="feedback['floor-adverts']"
|
|
role="status"
|
|
class="text-sm"
|
|
:class="feedback['floor-adverts']!.ok ? 'text-success' : 'text-danger'"
|
|
>
|
|
{{ feedback["floor-adverts"]!.message }}
|
|
</p>
|
|
</div>
|
|
</VCard>
|
|
</template>
|
|
</VPage>
|
|
</template>
|