From 93e5a05d4831630472e700fa5ee5bf7a973f7728 Mon Sep 17 00:00:00 2001 From: Chengdong Zhang Date: Wed, 23 Sep 2026 14:27:00 +0800 Subject: [PATCH] feat(admin,shop-admin): content/brand management + merchant shop profile (add-content-admin-ui) --- apps/admin/app.vue | 2 + apps/admin/locales-extra.ts | 42 +++ apps/admin/pages/brands.vue | 152 ++++++++++ apps/admin/pages/content.vue | 269 ++++++++++++++++++ apps/api/src/modules/shop/handlers.rs | 13 +- apps/api/src/modules/shop/service.rs | 70 +++++ apps/api/tests/shops.rs | 113 ++++++++ apps/mall/mock/api.ts | 1 + apps/shop-admin/app.vue | 1 + apps/shop-admin/locales-extra.ts | 26 ++ apps/shop-admin/pages/shop-profile.vue | 146 ++++++++++ openspec/MIGRATION-PLAN.md | 2 +- .../proposal.md | 0 .../specs/frontend-admin/spec.md | 0 .../specs/frontend-shop-admin/spec.md | 0 .../specs/store-directory/spec.md | 0 .../2026-09-23-add-content-admin-ui}/tasks.md | 34 +-- openspec/specs/frontend-admin/spec.md | 30 ++ openspec/specs/frontend-shop-admin/spec.md | 19 ++ openspec/specs/store-directory/spec.md | 22 +- packages/shared/src/api.ts | 4 + packages/shared/src/types.ts | 6 + 22 files changed, 930 insertions(+), 22 deletions(-) create mode 100644 apps/admin/pages/brands.vue create mode 100644 apps/admin/pages/content.vue create mode 100644 apps/shop-admin/pages/shop-profile.vue rename openspec/changes/{add-content-admin-ui => archive/2026-09-23-add-content-admin-ui}/proposal.md (100%) rename openspec/changes/{add-content-admin-ui => archive/2026-09-23-add-content-admin-ui}/specs/frontend-admin/spec.md (100%) rename openspec/changes/{add-content-admin-ui => archive/2026-09-23-add-content-admin-ui}/specs/frontend-shop-admin/spec.md (100%) rename openspec/changes/{add-content-admin-ui => archive/2026-09-23-add-content-admin-ui}/specs/store-directory/spec.md (100%) rename openspec/changes/{add-content-admin-ui => archive/2026-09-23-add-content-admin-ui}/tasks.md (77%) diff --git a/apps/admin/app.vue b/apps/admin/app.vue index 331ae59..d681897 100644 --- a/apps/admin/app.vue +++ b/apps/admin/app.vue @@ -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" }, diff --git a/apps/admin/locales-extra.ts b/apps/admin/locales-extra.ts index c2ae98e..04f669c 100644 --- a/apps/admin/locales-extra.ts +++ b/apps/admin/locales-extra.ts @@ -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; diff --git a/apps/admin/pages/brands.vue b/apps/admin/pages/brands.vue new file mode 100644 index 0000000..fff1028 --- /dev/null +++ b/apps/admin/pages/brands.vue @@ -0,0 +1,152 @@ + + + \ No newline at end of file diff --git a/apps/admin/pages/content.vue b/apps/admin/pages/content.vue new file mode 100644 index 0000000..461269c --- /dev/null +++ b/apps/admin/pages/content.vue @@ -0,0 +1,269 @@ + + + \ No newline at end of file diff --git a/apps/api/src/modules/shop/handlers.rs b/apps/api/src/modules/shop/handlers.rs index 6712992..1569460 100644 --- a/apps/api/src/modules/shop/handlers.rs +++ b/apps/api/src/modules/shop/handlers.rs @@ -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 { 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, auth: AuthUser) -> ApiResult, + auth: AuthUser, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::set_my_profile(&state, shop_id, body).await?)) +} + async fn list_shops(State(state): State) -> ApiResult>> { Ok(Json(service::list_active_profiles(&state).await?)) } diff --git a/apps/api/src/modules/shop/service.rs b/apps/api/src/modules/shop/service.rs index 5cdbbc1..e0b1b51 100644 --- a/apps/api/src/modules/shop/service.rs +++ b/apps/api/src/modules/shop/service.rs @@ -111,6 +111,19 @@ pub struct ProfileBody { pub score_speed: Option, } +/// 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, + pub banner: Option, + pub company: Option, + pub region: Option, + pub address: Option, + pub notice: Option, + pub after_sale: Option, +} + 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 { + 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())) +} diff --git a/apps/api/tests/shops.rs b/apps/api/tests/shops.rs index d127a39..4e0c4bb 100644 --- a/apps/api/tests/shops.rs +++ b/apps/api/tests/shops.rs @@ -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" + ); +} diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index e911cbb..af2bb5b 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -944,6 +944,7 @@ export function createMockApi(): ApiClient { shop: { getMyShop: () => unsupported(), + updateMyProfile: () => unsupported(), listMyProducts: () => unsupported(), getProduct: () => unsupported(), createProduct: () => unsupported(), diff --git a/apps/shop-admin/app.vue b/apps/shop-admin/app.vue index 3e28337..f79028a 100644 --- a/apps/shop-admin/app.vue +++ b/apps/shop-admin/app.vue @@ -26,6 +26,7 @@ watchEffect(() => {