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

This commit is contained in:
Chengdong Zhang
2026-09-23 14:27:00 +08:00
parent 9a749e2551
commit 93e5a05d48
22 changed files with 930 additions and 22 deletions
+2
View File
@@ -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" },
+42
View File
@@ -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;
+152
View File
@@ -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>
+269
View File
@@ -0,0 +1,269 @@
<script setup lang="ts">
import type { ContentInputByKind, ContentKind } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
type BannerRow = ContentInputByKind["banners"][number];
type QuickLinkRow = ContentInputByKind["quick-links"][number];
type FloorRow = ContentInputByKind["floor-adverts"][number];
const { $api } = useNuxtApp();
const { t } = useI18n();
const banners = ref<BannerRow[]>([]);
const promos = ref<BannerRow[]>([]);
const quickLinks = ref<QuickLinkRow[]>([]);
const floorAdverts = ref<FloorRow[]>([]);
const loading = ref(true);
const loadError = ref("");
const saving = ref<ContentKind | null>(null);
/** Per-kind row field errors, keyed by row index. */
const rowErrors = reactive<Record<ContentKind, Record<number, string>>>({
banners: {},
promos: {},
"quick-links": {},
"floor-adverts": {},
});
const feedback = reactive<Record<ContentKind, { ok: boolean; message: string } | null>>({
banners: null,
promos: null,
"quick-links": null,
"floor-adverts": null,
});
const kindLabelKey: Record<ContentKind, string> = {
banners: "admin.contentBanners",
promos: "admin.contentPromos",
"quick-links": "admin.contentQuickLinks",
"floor-adverts": "admin.contentFloorAdverts",
};
async function loadContent(): Promise<void> {
loading.value = true;
loadError.value = "";
try {
const content = await $api.admin.getContent();
banners.value = content.banners.map(({ image, url, active }) => ({ image, url, active }));
promos.value = content.promos.map(({ image, url, active }) => ({ image, url, active }));
quickLinks.value = content.quick_links.map(({ label, url, glyph, active }) => ({
label: { ...label },
url,
glyph,
active,
}));
floorAdverts.value = content.floor_adverts.map(({ image, active }) => ({ image, active }));
} catch (error: unknown) {
loadError.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
function rowsOf(kind: ContentKind): { image?: string; url?: string }[] {
switch (kind) {
case "banners":
return banners.value;
case "promos":
return promos.value;
case "quick-links":
return quickLinks.value;
case "floor-adverts":
return floorAdverts.value;
}
}
function addRow(kind: ContentKind): void {
feedback[kind] = null;
if (kind === "quick-links") {
quickLinks.value.push({ label: { en: "", zh: "" }, url: "", glyph: "", active: true });
} else if (kind === "floor-adverts") {
floorAdverts.value.push({ image: "", active: true });
} else if (kind === "banners") {
banners.value.push({ image: "", url: "", active: true });
} else {
promos.value.push({ image: "", url: "", active: true });
}
}
function removeRow(kind: ContentKind, index: number): void {
feedback[kind] = null;
rowsOf(kind).splice(index, 1);
rowErrors[kind] = {};
}
function moveRow(kind: ContentKind, index: number, dir: -1 | 1): void {
const list = rowsOf(kind);
const target = index + dir;
if (target < 0 || target >= list.length) return;
feedback[kind] = null;
const [row] = list.splice(index, 1);
list.splice(target, 0, row);
rowErrors[kind] = {};
}
function validate(kind: ContentKind): boolean {
const errors: Record<number, string> = {};
const list = rowsOf(kind);
for (const [index, row] of list.entries()) {
if (kind === "quick-links") {
const link = row as QuickLinkRow;
if (!link.label.en.trim() || !link.label.zh.trim() || !link.url.trim() || !link.glyph.trim()) {
errors[index] = t("admin.contentQuickLinkInvalid");
}
} else if (kind === "floor-adverts") {
if (!(row as FloorRow).image.trim()) errors[index] = t("admin.contentImageRequired");
} else if (!row.image?.trim() || !row.url?.trim()) {
errors[index] = t("admin.contentBannerInvalid");
}
}
rowErrors[kind] = errors;
return Object.keys(errors).length === 0;
}
async function saveKind(kind: ContentKind): Promise<void> {
feedback[kind] = null;
if (!validate(kind)) return;
saving.value = kind;
try {
switch (kind) {
case "banners":
await $api.admin.replaceContent("banners", banners.value);
break;
case "promos":
await $api.admin.replaceContent("promos", promos.value);
break;
case "quick-links":
await $api.admin.replaceContent("quick-links", quickLinks.value);
break;
case "floor-adverts":
await $api.admin.replaceContent("floor-adverts", floorAdverts.value);
break;
}
feedback[kind] = { ok: true, message: t("admin.contentSaved") };
} catch (error: unknown) {
feedback[kind] = {
ok: false,
message: error instanceof Error ? error.message : t("common.error"),
};
} finally {
saving.value = null;
}
}
onMounted(() => {
void loadContent();
});
</script>
<template>
<VPage :title="$t('nav.content')">
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="my-2 text-sm text-danger" role="alert">{{ loadError }}</p>
<template v-else>
<VCard v-for="kind in (['banners', 'promos'] as ContentKind[])" :key="kind" class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t(kindLabelKey[kind]) }}</h2>
<div v-for="(row, index) in rowsOf(kind)" :key="index" class="mb-3 rounded-md border border-border p-3">
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_1fr_auto_auto]">
<img v-if="row.image" :src="row.image" :alt="$t(kindLabelKey[kind])" class="h-12 w-20 rounded object-cover" />
<VField :label="$t('admin.contentImage')" :error="rowErrors[kind][index]">
<VInput v-model="row.image" />
</VField>
<VField :label="$t('admin.contentUrl')">
<VInput v-model="row.url" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow(kind, index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === rowsOf(kind).length - 1" @click="moveRow(kind, index, 1)">↓</VBtn>
<VBtn size="sm" @click="removeRow(kind, index)">{{ $t("common.delete") }}</VBtn>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<VBtn size="sm" @click="addRow(kind)">{{ $t("admin.contentAddRow") }}</VBtn>
<VBtn variant="primary" :disabled="saving === kind" @click="saveKind(kind)">
{{ saving === kind ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback[kind]" role="status" class="text-sm" :class="feedback[kind]!.ok ? 'text-success' : 'text-danger'">
{{ feedback[kind]!.message }}
</p>
</div>
</VCard>
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.contentQuickLinks") }}</h2>
<div v-for="(row, index) in quickLinks" :key="index" class="mb-3 rounded-md border border-border p-3">
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_1fr_1fr_1fr_auto_auto]">
<svg viewBox="0 0 24 24" class="mb-3.5 h-8 w-8 fill-current text-primary" aria-hidden="true">
<path :d="row.glyph" />
</svg>
<VField :label="$t('admin.contentLabelEn')" :error="rowErrors['quick-links'][index]">
<VInput v-model="row.label.en" />
</VField>
<VField :label="$t('admin.contentLabelZh')">
<VInput v-model="row.label.zh" />
</VField>
<VField :label="$t('admin.contentUrl')">
<VInput v-model="row.url" />
</VField>
<VField :label="$t('admin.contentGlyph')">
<VInput v-model="row.glyph" placeholder="M12 2L2 22h20z" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow('quick-links', index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === quickLinks.length - 1" @click="moveRow('quick-links', index, 1)">↓</VBtn>
<VBtn size="sm" @click="removeRow('quick-links', index)">{{ $t("common.delete") }}</VBtn>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<VBtn size="sm" @click="addRow('quick-links')">{{ $t("admin.contentAddRow") }}</VBtn>
<VBtn variant="primary" :disabled="saving === 'quick-links'" @click="saveKind('quick-links')">
{{ saving === "quick-links" ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback['quick-links']" role="status" class="text-sm" :class="feedback['quick-links']!.ok ? 'text-success' : 'text-danger'">
{{ feedback["quick-links"]!.message }}
</p>
</div>
</VCard>
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.contentFloorAdverts") }}</h2>
<div v-for="(row, index) in floorAdverts" :key="index" class="mb-3 rounded-md border border-border p-3">
<div class="grid items-end gap-3 md:grid-cols-[auto_1fr_auto_auto]">
<img v-if="row.image" :src="row.image" :alt="$t('admin.contentFloorAdverts')" class="h-12 w-20 rounded object-cover" />
<VField :label="$t('admin.contentImage')" :error="rowErrors['floor-adverts'][index]">
<VInput v-model="row.image" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow('floor-adverts', index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === floorAdverts.length - 1" @click="moveRow('floor-adverts', index, 1)">↓</VBtn>
<VBtn size="sm" @click="removeRow('floor-adverts', index)">{{ $t("common.delete") }}</VBtn>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<VBtn size="sm" @click="addRow('floor-adverts')">{{ $t("admin.contentAddRow") }}</VBtn>
<VBtn variant="primary" :disabled="saving === 'floor-adverts'" @click="saveKind('floor-adverts')">
{{ saving === "floor-adverts" ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback['floor-adverts']" role="status" class="text-sm" :class="feedback['floor-adverts']!.ok ? 'text-success' : 'text-danger'">
{{ feedback["floor-adverts"]!.message }}
</p>
</div>
</VCard>
</template>
</VPage>
</template>
+11 -2
View File
@@ -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?))
}
+70
View File
@@ -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()))
}
+113
View File
@@ -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"
);
}
+1
View File
@@ -944,6 +944,7 @@ export function createMockApi(): ApiClient {
shop: {
getMyShop: () => unsupported(),
updateMyProfile: () => unsupported(),
listMyProducts: () => unsupported(),
getProduct: () => unsupported(),
createProduct: () => unsupported(),
+1
View File
@@ -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>
+26
View File
@@ -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: "店铺状态",
+146
View File
@@ -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>