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>
|
||||
@@ -44,7 +44,7 @@ P2 freight ───────────┘(改 checkout/order totals,
|
||||
| P3 | `add-wallet-settlement` | P0 | proposed | — |
|
||||
| P4 | `add-mobile-h5` | P0、P3(软) | proposed | — |
|
||||
| P5 | `add-merchant-onboarding` | — | proposed | — |
|
||||
| P6 | `add-content-admin-ui` | — | proposed | — |
|
||||
| P6 | `add-content-admin-ui` | — | archived | 2026-09-23 |
|
||||
| P7 | `add-membership-messaging` | — | proposed | — |
|
||||
|
||||
状态取值:`proposed` → `implementing` → `verified`(tasks 全勾 + 测试/构建/smoke 通过)→ `archived`。
|
||||
|
||||
+17
-17
@@ -1,28 +1,28 @@
|
||||
## 1. Admin content management page
|
||||
|
||||
- [ ] 1.1 Add `apps/admin/pages/content.vue` loading all four kinds through `admin.getContent()` from `@vmall/shared` (inactive entries included) and add a content entry to the `apps/admin` nav beside existing platform operations.
|
||||
- [ ] 1.2 Implement one editor section per kind — banners, promos, quick links, floor adverts — with the exact per-entry fields the API accepts: image and destination URL for banners and promos, `{ en, zh }` label, destination URL and inline SVG glyph for quick links, and image for floor adverts, plus an active flag on every row.
|
||||
- [ ] 1.3 Support ordered row editing per kind (add, remove, move up/down — no drag and drop) and pre-validate each row with the API rules (non-empty image and destination URLs, non-empty glyph, non-empty `en` and `zh` quick-link labels), showing inline field errors before any request is sent.
|
||||
- [ ] 1.4 Save one kind at a time by submitting the whole displayed list through `admin.replaceContent(kind, items)` so the API replaces that kind atomically and reindexes positions, leaving the other three kinds untouched; render a per-entry storefront preview (image, destination, glyph/label) and clear success feedback on save.
|
||||
- [ ] 1.5 On a rejected save keep the edited rows and show a visible failure message, and add the page's bilingual strings through the existing admin locale sources.
|
||||
- [x] 1.1 Add `apps/admin/pages/content.vue` loading all four kinds through `admin.getContent()` from `@vmall/shared` (inactive entries included) and add a content entry to the `apps/admin` nav beside existing platform operations.
|
||||
- [x] 1.2 Implement one editor section per kind — banners, promos, quick links, floor adverts — with the exact per-entry fields the API accepts: image and destination URL for banners and promos, `{ en, zh }` label, destination URL and inline SVG glyph for quick links, and image for floor adverts, plus an active flag on every row.
|
||||
- [x] 1.3 Support ordered row editing per kind (add, remove, move up/down — no drag and drop) and pre-validate each row with the API rules (non-empty image and destination URLs, non-empty glyph, non-empty `en` and `zh` quick-link labels), showing inline field errors before any request is sent.
|
||||
- [x] 1.4 Save one kind at a time by submitting the whole displayed list through `admin.replaceContent(kind, items)` so the API replaces that kind atomically and reindexes positions, leaving the other three kinds untouched; render a per-entry storefront preview (image, destination, glyph/label) and clear success feedback on save.
|
||||
- [x] 1.5 On a rejected save keep the edited rows and show a visible failure message, and add the page's bilingual strings through the existing admin locale sources.
|
||||
|
||||
## 2. Admin brand management page
|
||||
|
||||
- [ ] 2.1 Add `apps/admin/pages/brands.vue` loading the registry through `admin.getBrands()` and add a brands entry to the `apps/admin` nav beside existing platform operations.
|
||||
- [ ] 2.2 Edit the whole ordered list with add, remove and move up/down rows carrying slug, non-empty `{ en, zh }` names and an active flag; pre-validate slugs against the API pattern (ascii `a-z`, `0-9`, `-`) and reject duplicate slugs and incomplete bilingual names with inline errors before any request is sent.
|
||||
- [ ] 2.3 Save the full list through `admin.replaceBrands(items)` so positions follow the displayed order, and render a preview row per brand as the public list serves it (slug and bilingual name) with clear success feedback on save.
|
||||
- [ ] 2.4 On a rejected save keep the edited rows and show a visible failure message, and add the page's bilingual strings through the existing admin locale sources.
|
||||
- [x] 2.1 Add `apps/admin/pages/brands.vue` loading the registry through `admin.getBrands()` and add a brands entry to the `apps/admin` nav beside existing platform operations.
|
||||
- [x] 2.2 Edit the whole ordered list with add, remove and move up/down rows carrying slug, non-empty `{ en, zh }` names and an active flag; pre-validate slugs against the API pattern (ascii `a-z`, `0-9`, `-`) and reject duplicate slugs and incomplete bilingual names with inline errors before any request is sent.
|
||||
- [x] 2.3 Save the full list through `admin.replaceBrands(items)` so positions follow the displayed order, and render a preview row per brand as the public list serves it (slug and bilingual name) with clear success feedback on save.
|
||||
- [x] 2.4 On a rejected save keep the edited rows and show a visible failure message, and add the page's bilingual strings through the existing admin locale sources.
|
||||
|
||||
## 3. Shop-admin profile editing
|
||||
|
||||
- [ ] 3.1 Add the minimal merchant-scoped endpoint `PUT /api/shop/profile` in `apps/api/src/modules/shop/`: an authenticated write scoped with `own_shop` over the caller's shop, reusing the existing profile upsert and `{ en, zh }` bilingual validation, accepting logo, banner, company, region, address, notice and after_sale, and never storing `score_rating`, `score_agreement`, `score_service` or `score_speed`; the service returns `ApiResult<ShopProfileView>`.
|
||||
- [ ] 3.2 Add a `shop.updateMyProfile` method to the `@vmall/shared` contract (own-profile input without score fields, returning `ShopProfile`) with live-client wiring; all frontend calls go through this shared contract only.
|
||||
- [ ] 3.3 Add `apps/shop-admin/pages/shop-profile.vue` editing the caller's own shop profile — logo and banner URLs, company, region and bilingual address, notice and after-sale copy — prefilled from `shop.getMyShop()` and the composed `getShop(slug)` read, with non-empty `en` and `zh` validation on bilingual fields and no score fields in the form.
|
||||
- [ ] 3.4 Save through `shop.updateMyProfile`, keep the form values and show a visible failure message when the write is refused (success feedback otherwise), and add the shop-profile nav entry to `apps/shop-admin` with bilingual strings through the existing shop-admin locale sources.
|
||||
- [x] 3.1 Add the minimal merchant-scoped endpoint `PUT /api/shop/profile` in `apps/api/src/modules/shop/`: an authenticated write scoped with `own_shop` over the caller's shop, reusing the existing profile upsert and `{ en, zh }` bilingual validation, accepting logo, banner, company, region, address, notice and after_sale, and never storing `score_rating`, `score_agreement`, `score_service` or `score_speed`; the service returns `ApiResult<ShopProfileView>`.
|
||||
- [x] 3.2 Add a `shop.updateMyProfile` method to the `@vmall/shared` contract (own-profile input without score fields, returning `ShopProfile`) with live-client wiring; all frontend calls go through this shared contract only.
|
||||
- [x] 3.3 Add `apps/shop-admin/pages/shop-profile.vue` editing the caller's own shop profile — logo and banner URLs, company, region and bilingual address, notice and after-sale copy — prefilled from `shop.getMyShop()` and the composed `getShop(slug)` read, with non-empty `en` and `zh` validation on bilingual fields and no score fields in the form.
|
||||
- [x] 3.4 Save through `shop.updateMyProfile`, keep the form values and show a visible failure message when the write is refused (success feedback otherwise), and add the shop-profile nav entry to `apps/shop-admin` with bilingual strings through the existing shop-admin locale sources.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 Add integration coverage in `apps/api/tests/` reusing the `tests/common/mod.rs` fixtures for the new endpoint: a merchant upsert round-trips to the public shop read, incomplete bilingual text is refused with the stored profile unchanged, submitted score values leave the stored scores unchanged, a user without a shop is refused, and another shop's profile is untouched; run the focused `cargo test -p vmall-api --test shops`.
|
||||
- [ ] 4.2 Build all three frontends because the shared contract changes: `pnpm --filter @vmall/admin build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/mall build`.
|
||||
- [ ] 4.3 Run the API and browser-smoke the admin content page (edit, reorder, per-kind save reflected on the mall home with the other kinds untouched), the admin brands page (edit and save reflected in the public brand list), and the shop-admin profile page (edit, save, and the mall store page showing the new copy for that shop only).
|
||||
- [ ] 4.4 Check every OpenSpec task and run `openspec change validate add-content-admin-ui --strict` plus `openspec validate --all --strict`.
|
||||
- [x] 4.1 Add integration coverage in `apps/api/tests/` reusing the `tests/common/mod.rs` fixtures for the new endpoint: a merchant upsert round-trips to the public shop read, incomplete bilingual text is refused with the stored profile unchanged, submitted score values leave the stored scores unchanged, a user without a shop is refused, and another shop's profile is untouched; run the focused `cargo test -p vmall-api --test shops`.
|
||||
- [x] 4.2 Build all three frontends because the shared contract changes: `pnpm --filter @vmall/admin build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/mall build`.
|
||||
- [x] 4.3 Run the API and browser-smoke the admin content page (edit, reorder, per-kind save reflected on the mall home with the other kinds untouched), the admin brands page (edit and save reflected in the public brand list), and the shop-admin profile page (edit, save, and the mall store page showing the new copy for that shop only).
|
||||
- [x] 4.4 Check every OpenSpec task and run `openspec change validate add-content-admin-ui --strict` plus `openspec validate --all --strict`.
|
||||
@@ -42,3 +42,33 @@ Platform admin SHALL expose the shared accent preset control (`red`, `blue`, `te
|
||||
- **WHEN** an authenticated platform admin opens the console
|
||||
- **THEN** they can select an accent preset without leaving the current page
|
||||
|
||||
### Requirement: Storefront content management
|
||||
The platform console SHALL manage the four home-content kinds — banners, promos, quick links and floor adverts — on a content page reachable from the console navigation, loading every entry including inactive ones through the shared API adapter. Each kind SHALL be edited as its ordered list of rows with add, remove and reorder controls (no drag and drop), the per-entry fields the API accepts, an active flag per entry, and a preview of how the storefront renders each entry. Saving a kind SHALL submit the whole edited list in its displayed order as one replacement with the semantics of `PUT /api/admin/content/{kind}`, so positions follow the submitted order and the other three kinds are untouched. Rows SHALL be validated before any request with the rules the API enforces — non-empty image and destination URLs, non-empty glyph, and non-empty `en` and `zh` quick-link labels in `{ en, zh }` JSONB text — with inline field errors on failing rows. Saves SHALL give clear success feedback; a rejected save SHALL keep the edited rows and show a visible failure message.
|
||||
|
||||
#### Scenario: save replaces one kind only
|
||||
- **WHEN** an admin reorders banners, saves that kind, and the mall reloads its home content
|
||||
- **THEN** banners appear in the new order while promos, quick links and floor adverts are unchanged
|
||||
|
||||
#### Scenario: invalid row is blocked before submit
|
||||
- **WHEN** an admin leaves a banner image URL empty and saves
|
||||
- **THEN** the row shows an inline field error and no replacement request is sent
|
||||
|
||||
#### Scenario: rejected save keeps the editor state
|
||||
- **WHEN** the API rejects a replacement
|
||||
- **THEN** the page shows a failure message and keeps the edited rows for correction
|
||||
|
||||
### Requirement: Brand management
|
||||
Platform admins SHALL manage the ordered brand registry on a brands page reachable from the console navigation, through the shared API adapter. The page SHALL edit the whole list — slug, non-empty `{ en, zh }` names and an active flag per row — with add, remove and reorder controls and a preview of each brand as the public list serves it. Saving SHALL replace the whole list in its displayed order so positions follow the submitted order. Slugs SHALL be validated against the ascii `a-z`, `0-9`, `-` pattern and duplicate slugs and incomplete bilingual names SHALL be rejected with inline errors before any request is sent. Saves SHALL give clear success feedback; a rejected save SHALL keep the edited rows and show a visible failure message.
|
||||
|
||||
#### Scenario: replace round-trips to the public list
|
||||
- **WHEN** an admin reorders brands, renames one, and saves
|
||||
- **THEN** the public brand list returns the same entries in the submitted order
|
||||
|
||||
#### Scenario: duplicate slug is blocked before submit
|
||||
- **WHEN** an admin saves with two rows carrying the same slug
|
||||
- **THEN** the duplicate rows show inline errors and no replacement request is sent
|
||||
|
||||
#### Scenario: save feedback distinguishes success and failure
|
||||
- **WHEN** a save succeeds or the API refuses the list
|
||||
- **THEN** the page shows a success confirmation or keeps the edited rows with a failure message, respectively
|
||||
|
||||
|
||||
@@ -64,3 +64,22 @@ Shop-admin SHALL expose the shared accent preset control (`red`, `blue`, `teal`,
|
||||
- **WHEN** an authenticated shop user opens shop-admin
|
||||
- **THEN** they can select an accent preset without leaving the current page
|
||||
|
||||
### Requirement: Merchant shop profile editing
|
||||
Shop users SHALL edit their own shop's profile from a shop-admin page reachable from the shop-scoped navigation, through the shared API adapter's shop-scoped profile update. The form SHALL prefill from the shop's current profile and cover the merchant-writable fields only: logo and banner URLs, company, region, and `{ en, zh }` bilingual address, notice and after-sale copy. Bilingual fields SHALL be validated with non-empty `en` and `zh` text before any request is sent, every write SHALL target the caller's own shop, and profile scores SHALL NOT be editable — they remain platform-set and saving never changes them. Saves SHALL give clear success feedback; a refused save SHALL keep the form values and show a visible failure message.
|
||||
|
||||
#### Scenario: merchant updates their own profile
|
||||
- **WHEN** a shop owner edits their notice, saves, and the mall store page reloads
|
||||
- **THEN** only that shop shows the new notice and no other shop's profile changes
|
||||
|
||||
#### Scenario: incomplete bilingual text is blocked before submit
|
||||
- **WHEN** a shop owner leaves the `zh` after-sale text empty and saves
|
||||
- **THEN** the field shows an inline error and no update request is sent
|
||||
|
||||
#### Scenario: scores are not editable
|
||||
- **WHEN** the profile page loads and is saved
|
||||
- **THEN** no score fields are offered and the platform-set scores remain unchanged
|
||||
|
||||
#### Scenario: refused save keeps the form
|
||||
- **WHEN** the API refuses an update
|
||||
- **THEN** the page keeps the entered values and shows a failure message
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
## Purpose
|
||||
The buyer-facing view of a shop: its public profile — identity, contact and service copy — alongside the products it sells, so the store directory, a store's home page and the store card on a product page all read real shops.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Public shop directory
|
||||
`GET /api/shops` SHALL list the active shops without authentication, and `GET /api/shops/{slug}` SHALL return one shop by slug, answering 404 for an unknown slug or a suspended shop. Each entry SHALL carry the shop's id, slug, bilingual name and its profile: logo and banner URLs, company, region, bilingual address, bilingual notice and after-sale copy, and four score values. A shop with no profile row SHALL still be returned, with the profile fields absent rather than invented.
|
||||
|
||||
@@ -34,3 +32,23 @@ A platform admin SHALL set a shop's profile with `PUT /api/admin/shops/{id}/prof
|
||||
#### Scenario: only platform admins may write
|
||||
- **WHEN** a shop owner or customer submits a profile
|
||||
- **THEN** the API refuses the write
|
||||
|
||||
### Requirement: Merchant self-service shop profile
|
||||
A shop owner SHALL set their own shop's profile with `PUT /api/shop/profile`, which upserts the profile row of the shop the caller owns and returns the composed shop profile. The write SHALL require an authenticated user with a shop under the `own_shop` scope and SHALL accept logo and banner URLs, company, region, and bilingual `address`, `notice` and `after_sale` text with the same `{ en, zh }` validation as the platform-admin profile write. Profile scores remain platform-set: merchant-supplied `score_rating`, `score_agreement`, `score_service` or `score_speed` values SHALL NOT be stored. A user without a shop SHALL be refused, and the platform-admin write and public reads SHALL keep their existing behavior.
|
||||
|
||||
#### Scenario: merchant upsert round-trips
|
||||
- **WHEN** a shop owner sets their profile and the storefront reads the shop by slug
|
||||
- **THEN** the public read returns those values for that shop
|
||||
|
||||
#### Scenario: incomplete bilingual text is refused
|
||||
- **WHEN** a shop owner submits a notice with only `en` text
|
||||
- **THEN** the request is refused and the stored profile is unchanged
|
||||
|
||||
#### Scenario: merchant cannot set scores
|
||||
- **WHEN** a shop owner submits score values with their profile
|
||||
- **THEN** the stored scores are unchanged
|
||||
|
||||
#### Scenario: a user without a shop is refused
|
||||
- **WHEN** a signed-in customer sends the merchant profile write
|
||||
- **THEN** the API refuses the write
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
ShopFlashSaleSession,
|
||||
ShopProfile,
|
||||
ShopProfileInput,
|
||||
ShopProfileSelfInput,
|
||||
Sku,
|
||||
User,
|
||||
} from "./types";
|
||||
@@ -240,6 +241,8 @@ export interface ApiClient {
|
||||
listGroupBuyingActivities(): Promise<GroupBuyingActivityView[]>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
/** Merchant self-write of the own shop profile; scores stay platform-owned. */
|
||||
updateMyProfile(body: ShopProfileSelfInput): Promise<ShopProfile>;
|
||||
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
|
||||
getProduct(id: string): Promise<Product>;
|
||||
createProduct(body: ProductUpsertBody): Promise<Product>;
|
||||
@@ -357,6 +360,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
listGroupBuyingActivities: () => r("GET", "/group-buying/activities"),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
updateMyProfile: (body) => r("PUT", "/shop/profile", body),
|
||||
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
|
||||
getProduct: (id) => r("GET", `/shop/products/${id}`),
|
||||
createProduct: (body) => r("POST", "/shop/products", body),
|
||||
|
||||
@@ -612,3 +612,9 @@ export interface ShopProfileInput {
|
||||
score_speed?: number | null;
|
||||
}
|
||||
|
||||
/** Merchant self-write profile input; the platform-owned score fields are excluded. */
|
||||
export type ShopProfileSelfInput = Omit<
|
||||
ShopProfileInput,
|
||||
"score_rating" | "score_agreement" | "score_service" | "score_speed"
|
||||
>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user