feat(admin,shop-admin): content/brand management + merchant shop profile (add-content-admin-ui)
This commit is contained in:
@@ -15,6 +15,8 @@ const navItems = [
|
||||
{ to: "/users", label: "nav.users" },
|
||||
{ to: "/shops", label: "nav.shops" },
|
||||
{ to: "/orders", label: "nav.orders" },
|
||||
{ to: "/content", label: "nav.content" },
|
||||
{ to: "/brands", label: "nav.brands" },
|
||||
{ to: "/currencies", label: "nav.currencies" },
|
||||
{ to: "/points-products", label: "nav.pointsProducts" },
|
||||
{ to: "/points-orders", label: "nav.pointsOrders" },
|
||||
|
||||
@@ -8,6 +8,8 @@ export const enExtra = {
|
||||
nav: {
|
||||
pointsProducts: "Points products",
|
||||
pointsOrders: "Point redemptions",
|
||||
content: "Content",
|
||||
brands: "Brands",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "Platform overview",
|
||||
@@ -67,6 +69,25 @@ export const enExtra = {
|
||||
fulfilled: "Fulfilled",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
contentBanners: "Banners",
|
||||
contentPromos: "Promotions",
|
||||
contentQuickLinks: "Quick links",
|
||||
contentFloorAdverts: "Floor adverts",
|
||||
contentImage: "Image URL",
|
||||
contentUrl: "Destination URL",
|
||||
contentLabelEn: "Label (English)",
|
||||
contentLabelZh: "Label (中文)",
|
||||
contentGlyph: "Icon SVG path",
|
||||
contentAddRow: "Add row",
|
||||
contentSaved: "Saved.",
|
||||
contentBannerInvalid: "Image and destination URL are required.",
|
||||
contentImageRequired: "Image URL is required.",
|
||||
contentQuickLinkInvalid: "Both labels, URL and icon are required.",
|
||||
brandSlug: "Slug",
|
||||
brandSlugInvalid: "Use lowercase letters, numbers, and dashes only.",
|
||||
brandNameRequired: "Both language names are required.",
|
||||
brandSlugDuplicate: "Duplicate slug.",
|
||||
brandsSaved: "Brands saved.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -78,6 +99,8 @@ export const zhExtra = {
|
||||
nav: {
|
||||
pointsProducts: "积分商品",
|
||||
pointsOrders: "积分兑换单",
|
||||
content: "内容",
|
||||
brands: "品牌",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "平台概览",
|
||||
@@ -137,5 +160,24 @@ export const zhExtra = {
|
||||
fulfilled: "已发货",
|
||||
cancelled: "已取消",
|
||||
},
|
||||
contentBanners: "横幅",
|
||||
contentPromos: "促销位",
|
||||
contentQuickLinks: "快捷入口",
|
||||
contentFloorAdverts: "楼层广告",
|
||||
contentImage: "图片链接",
|
||||
contentUrl: "跳转链接",
|
||||
contentLabelEn: "名称(英文)",
|
||||
contentLabelZh: "名称(中文)",
|
||||
contentGlyph: "图标 SVG 路径",
|
||||
contentAddRow: "添加一行",
|
||||
contentSaved: "已保存。",
|
||||
contentBannerInvalid: "图片和跳转链接均为必填项。",
|
||||
contentImageRequired: "图片链接为必填项。",
|
||||
contentQuickLinkInvalid: "中英文名称、链接和图标均为必填项。",
|
||||
brandSlug: "别名",
|
||||
brandSlugInvalid: "仅允许小写字母、数字和连字符。",
|
||||
brandNameRequired: "中英文名称均为必填项。",
|
||||
brandSlugDuplicate: "别名重复。",
|
||||
brandsSaved: "品牌已保存。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import type { BrandInput } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
interface BrandRow {
|
||||
slug: string;
|
||||
nameEn: string;
|
||||
nameZh: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t } = useI18n();
|
||||
|
||||
const rows = ref<BrandRow[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const saving = ref(false);
|
||||
const rowErrors = ref<Record<number, string>>({});
|
||||
const feedback = ref<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function loadBrands(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const brands = await $api.admin.getBrands();
|
||||
rows.value = brands.map((b) => ({
|
||||
slug: b.slug,
|
||||
nameEn: b.name.en,
|
||||
nameZh: b.name.zh,
|
||||
active: b.active,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
loadError.value = error instanceof Error ? error.message : t("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
feedback.value = null;
|
||||
rows.value.push({ slug: "", nameEn: "", nameZh: "", active: true });
|
||||
}
|
||||
|
||||
function removeRow(index: number): void {
|
||||
feedback.value = null;
|
||||
rows.value.splice(index, 1);
|
||||
rowErrors.value = {};
|
||||
}
|
||||
|
||||
function moveRow(index: number, dir: -1 | 1): void {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= rows.value.length) return;
|
||||
feedback.value = null;
|
||||
const [row] = rows.value.splice(index, 1);
|
||||
rows.value.splice(target, 0, row);
|
||||
rowErrors.value = {};
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const errors: Record<number, string> = {};
|
||||
const seen = new Map<string, number>();
|
||||
for (const [index, row] of rows.value.entries()) {
|
||||
const slug = row.slug.trim();
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||
errors[index] = t("admin.brandSlugInvalid");
|
||||
continue;
|
||||
}
|
||||
if (!row.nameEn.trim() || !row.nameZh.trim()) {
|
||||
errors[index] = t("admin.brandNameRequired");
|
||||
continue;
|
||||
}
|
||||
const first = seen.get(slug);
|
||||
if (first !== undefined) {
|
||||
errors[first] = t("admin.brandSlugDuplicate");
|
||||
errors[index] = t("admin.brandSlugDuplicate");
|
||||
} else {
|
||||
seen.set(slug, index);
|
||||
}
|
||||
}
|
||||
rowErrors.value = errors;
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
feedback.value = null;
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const items: BrandInput[] = rows.value.map((row) => ({
|
||||
slug: row.slug.trim(),
|
||||
name: { en: row.nameEn.trim(), zh: row.nameZh.trim() },
|
||||
active: row.active,
|
||||
}));
|
||||
await $api.admin.replaceBrands(items);
|
||||
feedback.value = { ok: true, message: t("admin.brandsSaved") };
|
||||
} catch (error: unknown) {
|
||||
feedback.value = {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : t("common.error"),
|
||||
};
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
void loadBrands();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.brands')">
|
||||
<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>
|
||||
<VCard v-else>
|
||||
<div v-for="(row, index) in rows" :key="index" class="mb-3 rounded-md border border-border p-3">
|
||||
<div class="grid items-end gap-3 md:grid-cols-[1fr_1fr_1fr_auto_auto]">
|
||||
<VField :label="$t('admin.brandSlug')" :error="rowErrors[index]">
|
||||
<VInput v-model="row.slug" placeholder="acme" />
|
||||
</VField>
|
||||
<VField :label="$t('admin.currencyNameEn')">
|
||||
<VInput v-model="row.nameEn" />
|
||||
</VField>
|
||||
<VField :label="$t('admin.currencyNameZh')">
|
||||
<VInput v-model="row.nameZh" />
|
||||
</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(index, -1)">↑</VBtn>
|
||||
<VBtn size="sm" :disabled="index === rows.length - 1" @click="moveRow(index, 1)">↓</VBtn>
|
||||
<VBtn size="sm" @click="removeRow(index)">{{ $t("common.delete") }}</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<VBtn size="sm" @click="addRow">{{ $t("admin.contentAddRow") }}</VBtn>
|
||||
<VBtn variant="primary" :disabled="saving" @click="save">
|
||||
{{ saving ? $t("common.loading") : $t("common.save") }}
|
||||
</VBtn>
|
||||
<p v-if="feedback" role="status" class="text-sm" :class="feedback.ok ? 'text-success' : 'text-danger'">
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
</div>
|
||||
</VCard>
|
||||
</VPage>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -13,11 +13,11 @@ use crate::error::ApiResult;
|
||||
use crate::models::{Shop, ShopStatus};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::service::{self, ProfileBody, ShopProfileView};
|
||||
use super::service::{self, MerchantProfileBody, ProfileBody, ShopProfileView};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/shop/profile", get(my_shop))
|
||||
.route("/shop/profile", get(my_shop).put(update_my_profile))
|
||||
.route("/shops", get(list_shops))
|
||||
.route("/shops/{slug}", get(get_shop))
|
||||
.route("/admin/shops", get(admin_list_shops).post(create_shop))
|
||||
@@ -30,6 +30,15 @@ async fn my_shop(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Jso
|
||||
Ok(Json(service::get_by_id(&state, shop_id).await?))
|
||||
}
|
||||
|
||||
async fn update_my_profile(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<MerchantProfileBody>,
|
||||
) -> ApiResult<Json<ShopProfileView>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::set_my_profile(&state, shop_id, body).await?))
|
||||
}
|
||||
|
||||
async fn list_shops(State(state): State<AppState>) -> ApiResult<Json<Vec<ShopProfileView>>> {
|
||||
Ok(Json(service::list_active_profiles(&state).await?))
|
||||
}
|
||||
|
||||
@@ -111,6 +111,19 @@ pub struct ProfileBody {
|
||||
pub score_speed: Option<f64>,
|
||||
}
|
||||
|
||||
/// Merchant self-service write: same fields as the admin body minus the
|
||||
/// platform-owned scores; serde ignores any score values merchants send.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MerchantProfileBody {
|
||||
pub logo: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub address: Option<Value>,
|
||||
pub notice: Option<Value>,
|
||||
pub after_sale: Option<Value>,
|
||||
}
|
||||
|
||||
fn bilingual(label: &Value, field: &str) -> ApiResult<()> {
|
||||
let ok = ["en", "zh"].iter().all(|code| {
|
||||
label
|
||||
@@ -189,3 +202,60 @@ pub async fn set_profile(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("shop".into()))
|
||||
}
|
||||
|
||||
/// Merchant upsert of their own shop profile. Scores are platform-owned, so
|
||||
/// this statement never touches the score columns on insert or update.
|
||||
pub async fn set_my_profile(
|
||||
state: &AppState,
|
||||
id: Uuid,
|
||||
body: MerchantProfileBody,
|
||||
) -> ApiResult<ShopProfileView> {
|
||||
for (value, field) in [
|
||||
(&body.address, "address"),
|
||||
(&body.notice, "notice"),
|
||||
(&body.after_sale, "after_sale"),
|
||||
] {
|
||||
if let Some(label) = value {
|
||||
bilingual(label, field)?;
|
||||
}
|
||||
}
|
||||
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)")
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(ApiError::NotFound("shop".into()));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO shop_profiles (shop_id, logo, banner, company, region, address, notice,
|
||||
after_sale, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (shop_id) DO UPDATE SET
|
||||
logo = EXCLUDED.logo,
|
||||
banner = EXCLUDED.banner,
|
||||
company = EXCLUDED.company,
|
||||
region = EXCLUDED.region,
|
||||
address = EXCLUDED.address,
|
||||
notice = EXCLUDED.notice,
|
||||
after_sale = EXCLUDED.after_sale,
|
||||
updated_at = now()",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&body.logo)
|
||||
.bind(&body.banner)
|
||||
.bind(&body.company)
|
||||
.bind(&body.region)
|
||||
.bind(&body.address)
|
||||
.bind(&body.notice)
|
||||
.bind(&body.after_sale)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, ShopProfileView>(&format!("{SELECT_PROFILE} WHERE s.id = $1"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("shop".into()))
|
||||
}
|
||||
|
||||
@@ -182,3 +182,116 @@ async fn profile_writes_require_a_platform_admin() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_my_profile(
|
||||
app: &common::TestApp,
|
||||
token: &str,
|
||||
body: serde_json::Value,
|
||||
) -> reqwest::Response {
|
||||
client()
|
||||
.put(app.url("/api/shop/profile"))
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_profile_upsert_round_trips_to_the_public_read() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-self-profile").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"logo": "/mock/store-self.svg",
|
||||
"company": "Self Co.",
|
||||
"region": "California",
|
||||
"address": {"en": "2 Mission Street", "zh": "米申街 2 号"},
|
||||
"notice": {"en": "Self-service notice", "zh": "自助公告"},
|
||||
"after_sale": {"en": "Self returns.", "zh": "自助退货。"}
|
||||
});
|
||||
let res = set_my_profile(&app, &owner, body).await;
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
|
||||
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
|
||||
assert_eq!(shop["company"], "Self Co.");
|
||||
assert_eq!(shop["address"]["zh"], "米申街 2 号");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_profile_refuses_incomplete_bilingual_without_writing() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-self-bilingual").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
|
||||
let good = serde_json::json!({
|
||||
"company": "Merchant Kept Co.",
|
||||
"notice": {"en": "Merchant notice", "zh": "商家公告"}
|
||||
});
|
||||
assert_eq!(set_my_profile(&app, &owner, good).await.status(), 200);
|
||||
|
||||
let bad = serde_json::json!({
|
||||
"company": "Merchant Changed Co.",
|
||||
"notice": {"en": "Only English"}
|
||||
});
|
||||
let res = set_my_profile(&app, &owner, bad).await;
|
||||
assert_eq!(res.status(), 400, "a label missing zh must be refused");
|
||||
|
||||
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
|
||||
assert_eq!(shop["company"], "Merchant Kept Co.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_profile_never_stores_scores() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-self-scores").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
|
||||
let admin_body = serde_json::json!({
|
||||
"company": "Scored Co.",
|
||||
"score_rating": 4.9,
|
||||
"score_service": 4.7
|
||||
});
|
||||
assert_eq!(set_profile(&app, &admin, &shop_id, admin_body).await.status(), 200);
|
||||
|
||||
let merchant_body = serde_json::json!({
|
||||
"company": "Scored Co. Renamed",
|
||||
"score_rating": 1.0,
|
||||
"score_service": 1.0
|
||||
});
|
||||
assert_eq!(set_my_profile(&app, &owner, merchant_body).await.status(), 200);
|
||||
|
||||
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
|
||||
assert_eq!(shop["company"], "Scored Co. Renamed");
|
||||
assert_eq!(shop["score_rating"], 4.9, "merchant writes must not touch scores");
|
||||
assert_eq!(shop["score_service"], 4.7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_profile_requires_a_shop_and_scopes_to_it() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_a = create_shop(&app, &admin, "shop-self-scope-a").await;
|
||||
let shop_b = create_shop(&app, &admin, "shop-self-scope-b").await;
|
||||
let owner_a = make_shop_owner(&app, &admin, &shop_a).await;
|
||||
let (customer, _) = register_customer(&app, "shop-self-cust").await;
|
||||
|
||||
let body = serde_json::json!({ "company": "Scoped Co." });
|
||||
let res = set_my_profile(&app, &customer, body.clone()).await;
|
||||
assert_eq!(res.status(), 403, "a user without a shop must be refused");
|
||||
|
||||
assert_eq!(set_my_profile(&app, &owner_a, body).await.status(), 200);
|
||||
let shop_b_view = find(&list_shops(&app).await, &shop_b).unwrap().clone();
|
||||
assert!(
|
||||
shop_b_view["company"].is_null(),
|
||||
"another shop's profile must stay untouched"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -944,6 +944,7 @@ export function createMockApi(): ApiClient {
|
||||
|
||||
shop: {
|
||||
getMyShop: () => unsupported(),
|
||||
updateMyProfile: () => unsupported(),
|
||||
listMyProducts: () => unsupported(),
|
||||
getProduct: () => unsupported(),
|
||||
createProduct: () => unsupported(),
|
||||
|
||||
@@ -26,6 +26,7 @@ watchEffect(() => {
|
||||
<nav class="grid gap-1" :aria-label="$t('common.appName')">
|
||||
<NuxtLink to="/" exact-active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.dashboard") }}</NuxtLink>
|
||||
<NuxtLink to="/products" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.products") }}</NuxtLink>
|
||||
<NuxtLink to="/shop-profile" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.shopProfile") }}</NuxtLink>
|
||||
<NuxtLink to="/orders" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.orders") }}</NuxtLink>
|
||||
<NuxtLink to="/shipments" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.shipments") }}</NuxtLink>
|
||||
<NuxtLink to="/invoices" active-class="bg-primary-soft text-primary" class="rounded-md px-3 py-2 text-sm font-medium text-muted hover:bg-bg">{{ $t("nav.invoices") }}</NuxtLink>
|
||||
|
||||
@@ -8,8 +8,21 @@ export const enExtra = {
|
||||
coupons: "Coupons",
|
||||
flashSales: "Flash sales",
|
||||
groupBuying: "Group buying",
|
||||
shopProfile: "Shop profile",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "Shop profile saved.",
|
||||
bilingualRequired: "Both English and Chinese text are required.",
|
||||
profileLogo: "Logo URL",
|
||||
profileBanner: "Banner URL",
|
||||
profileCompany: "Company",
|
||||
profileRegion: "Region",
|
||||
profileAddressEn: "Address (English)",
|
||||
profileAddressZh: "Address (中文)",
|
||||
profileNoticeEn: "Notice (English)",
|
||||
profileNoticeZh: "Notice (中文)",
|
||||
profileAfterSaleEn: "After-sale policy (English)",
|
||||
profileAfterSaleZh: "After-sale policy (中文)",
|
||||
dashboardTitle: "Merchant dashboard",
|
||||
profile: "Shop profile",
|
||||
shopStatus: "Shop status",
|
||||
@@ -156,8 +169,21 @@ export const zhExtra = {
|
||||
coupons: "优惠券",
|
||||
flashSales: "秒杀",
|
||||
groupBuying: "拼团",
|
||||
shopProfile: "店铺资料",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "店铺资料已保存。",
|
||||
bilingualRequired: "中英文内容均为必填项。",
|
||||
profileLogo: "Logo 链接",
|
||||
profileBanner: "横幅链接",
|
||||
profileCompany: "公司",
|
||||
profileRegion: "地区",
|
||||
profileAddressEn: "地址(英文)",
|
||||
profileAddressZh: "地址(中文)",
|
||||
profileNoticeEn: "公告(英文)",
|
||||
profileNoticeZh: "公告(中文)",
|
||||
profileAfterSaleEn: "售后政策(英文)",
|
||||
profileAfterSaleZh: "售后政策(中文)",
|
||||
dashboardTitle: "商家仪表盘",
|
||||
profile: "店铺信息",
|
||||
shopStatus: "店铺状态",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import type { ShopProfileSelfInput } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({
|
||||
logo: "",
|
||||
banner: "",
|
||||
company: "",
|
||||
region: "",
|
||||
addressEn: "",
|
||||
addressZh: "",
|
||||
noticeEn: "",
|
||||
noticeZh: "",
|
||||
afterSaleEn: "",
|
||||
afterSaleZh: "",
|
||||
});
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const loadError = ref("");
|
||||
const formError = ref("");
|
||||
const feedback = ref<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function loadProfile(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const shop = await $api.shop.getMyShop();
|
||||
const profile = await $api.getShop(shop.slug);
|
||||
form.logo = profile.logo ?? "";
|
||||
form.banner = profile.banner ?? "";
|
||||
form.company = profile.company ?? "";
|
||||
form.region = profile.region ?? "";
|
||||
form.addressEn = profile.address?.en ?? "";
|
||||
form.addressZh = profile.address?.zh ?? "";
|
||||
form.noticeEn = profile.notice?.en ?? "";
|
||||
form.noticeZh = profile.notice?.zh ?? "";
|
||||
form.afterSaleEn = profile.after_sale?.en ?? "";
|
||||
form.afterSaleZh = profile.after_sale?.zh ?? "";
|
||||
} catch (error: unknown) {
|
||||
loadError.value = error instanceof Error ? error.message : t("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bilingualField(en: string, zh: string): { en: string; zh: string } | null {
|
||||
const enText = en.trim();
|
||||
const zhText = zh.trim();
|
||||
if (!enText && !zhText) return null;
|
||||
if (!enText || !zhText) throw new Error(t("shop.bilingualRequired"));
|
||||
return { en: enText, zh: zhText };
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
feedback.value = null;
|
||||
formError.value = "";
|
||||
let body: ShopProfileSelfInput;
|
||||
try {
|
||||
body = {
|
||||
logo: form.logo.trim() || null,
|
||||
banner: form.banner.trim() || null,
|
||||
company: form.company.trim() || null,
|
||||
region: form.region.trim() || null,
|
||||
address: bilingualField(form.addressEn, form.addressZh),
|
||||
notice: bilingualField(form.noticeEn, form.noticeZh),
|
||||
after_sale: bilingualField(form.afterSaleEn, form.afterSaleZh),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
formError.value = error instanceof Error ? error.message : t("common.error");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await $api.shop.updateMyProfile(body);
|
||||
feedback.value = { ok: true, message: t("shop.profileSaved") };
|
||||
} catch (error: unknown) {
|
||||
feedback.value = {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : t("common.error"),
|
||||
};
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30 disabled:opacity-50";
|
||||
|
||||
onMounted(() => {
|
||||
void loadProfile();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.shopProfile')">
|
||||
<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>
|
||||
<VCard v-else>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<VField :label="$t('shop.profileLogo')">
|
||||
<VInput v-model="form.logo" placeholder="https://…" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileBanner')">
|
||||
<VInput v-model="form.banner" placeholder="https://…" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileCompany')">
|
||||
<VInput v-model="form.company" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileRegion')">
|
||||
<VInput v-model="form.region" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileAddressEn')">
|
||||
<VInput v-model="form.addressEn" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileAddressZh')">
|
||||
<VInput v-model="form.addressZh" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileNoticeEn')">
|
||||
<textarea v-model="form.noticeEn" :class="inputClass" rows="3" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileNoticeZh')">
|
||||
<textarea v-model="form.noticeZh" :class="inputClass" rows="3" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileAfterSaleEn')">
|
||||
<textarea v-model="form.afterSaleEn" :class="inputClass" rows="3" />
|
||||
</VField>
|
||||
<VField :label="$t('shop.profileAfterSaleZh')">
|
||||
<textarea v-model="form.afterSaleZh" :class="inputClass" rows="3" />
|
||||
</VField>
|
||||
</div>
|
||||
<p v-if="formError" class="my-2 text-sm text-danger" role="alert">{{ formError }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<VBtn variant="primary" :disabled="saving" @click="save">
|
||||
{{ saving ? $t("common.loading") : $t("common.save") }}
|
||||
</VBtn>
|
||||
<p v-if="feedback" role="status" class="text-sm" :class="feedback.ok ? 'text-success' : 'text-danger'">
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
</div>
|
||||
</VCard>
|
||||
</VPage>
|
||||
</template>
|
||||
Reference in New Issue
Block a user