feat(admin,shop-admin): content/brand management + merchant shop profile (add-content-admin-ui)

This commit is contained in:
Chengdong Zhang
2026-09-23 14:27:00 +08:00
parent 9a749e2551
commit 93e5a05d48
22 changed files with 930 additions and 22 deletions
+269
View File
@@ -0,0 +1,269 @@
<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-sm text-muted">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="my-2 text-sm text-danger" 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="mb-3 rounded-md border border-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="h-4 w-4 accent-primary" />
{{ $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="mb-3 rounded-md border border-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="mb-3.5 h-8 w-8 fill-current text-primary" 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="h-4 w-4 accent-primary" />
{{ $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="mb-3 rounded-md border border-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="h-4 w-4 accent-primary" />
{{ $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>