commit 1c8de9b5f44aaff14cc649420e8cfbed60530f71
Author: 魔方熊成祥 <3615096616@qq.com>
Date: Wed Apr 29 21:38:14 2026 +0800
1
diff --git a/.env b/.env
new file mode 100644
index 0000000..d49180f
--- /dev/null
+++ b/.env
@@ -0,0 +1 @@
+VITE_API_BASE_URL=https://test-gateway.yuntuo.hk
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4f3a35a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.DS_Store
+
diff --git a/docs/home-interface-missing-fields.md b/docs/home-interface-missing-fields.md
new file mode 100644
index 0000000..d74dc22
--- /dev/null
+++ b/docs/home-interface-missing-fields.md
@@ -0,0 +1,381 @@
+# 商城首页接口缺字段补充建议
+
+## 背景
+
+当前首页已经按原来的页面结构保留:
+
+- 顶部搜索区
+- 左侧分类导航
+- 中间 Banner 主视觉
+- 右侧档口排行榜
+- 中下方 Banner 卡片区
+- 商品列表区
+- 店铺推荐区
+
+同时页面展示已经改成“优先按接口字段渲染”,不再使用本地 mock 数据补业务内容。
+
+当前直接使用的接口是:
+
+- `AppHome`
+- `AppGoodsPage`
+
+现状是页面能跑通,但为了完全摆脱前端硬编码、数字占位和临时推导,首页还有几组字段缺口需要后端补齐。
+
+## 当前接口已能直接支撑的内容
+
+### `AppHome`
+
+当前已足够支撑:
+
+- Banner 图片:`banners[].imageUrl`
+- Banner 主标题:`banners[].title`
+- Banner 跳转信息:`banners[].linkType`、`banners[].linkValue`
+- 推荐商品:`recommendGoods[]`
+- 推荐店铺:`recommendStores[]`
+
+### `AppGoodsPage`
+
+当前已足够支撑:
+
+- 商品分页列表:`list[]`
+- 商品基础卡片:`spuName`、`subTitle`、`coverUrl`
+- 价格:`minPrice`、`maxPrice`
+- 销量:`salesVolume`、`virtualSales`
+- 库存:`totalStock`
+- 排序:`sortType`
+- 类目筛选请求值:`categoryId`
+
+## 缺字段清单
+
+### 1. 首页分类导航缺少类目展示字段
+
+#### 当前问题
+
+当前前端只能拿到 `GoodsSummary.categoryId`,但左侧分类导航如果要保持现有样式,需要:
+
+- 类目名称
+- 类目排序
+- 商品数量
+
+现在如果只靠 `categoryId`,前端只能显示成 `类目 12` 这种数字占位,不够可用,也不适合正式页面。
+
+#### 为什么需要
+
+左侧分类导航不仅是筛选入口,也是首页首屏的重要信息模块。如果没有类目名称:
+
+- 用户看不懂类目内容
+- 前端无法稳定排序
+- 无法避免写死类目文案或临时用数字占位
+
+#### 建议补充方式
+
+优先建议直接补到 `AppHomeResponse`,因为首页首屏进入时就需要这组数据。
+
+#### 建议定义
+
+```ts
+interface HomeCategoryNav {
+ categoryId: number
+ categoryName: string
+ goodsCount: number
+ sort: number
+}
+```
+
+建议追加到:
+
+```ts
+interface AppHomeResponse {
+ banners: BannerInfo[]
+ recommendGoods: GoodsSummary[]
+ recommendStores: RecommendStoreInfo[]
+ categoryNavs: HomeCategoryNav[]
+}
+```
+
+#### 示例
+
+```json
+{
+ "categoryId": 101,
+ "categoryName": "手机配件",
+ "goodsCount": 286,
+ "sort": 10
+}
+```
+
+### 2. Banner 缺少副文案和按钮文案字段
+
+#### 当前问题
+
+`BannerInfo` 目前只有:
+
+- `title`
+- `imageUrl`
+- `linkType`
+- `linkValue`
+
+这足够渲染图片和标题,但首屏 Banner 按当前页面样式还需要:
+
+- 副标题
+- 描述文案
+- 主按钮文案
+
+不补的话,这些内容只能前端写死。
+
+#### 为什么需要
+
+Banner 是首页视觉中心。如果副文案和按钮文案不能由接口控制:
+
+- 运营无法灵活调整首屏内容
+- 前端只能用固定文案
+- 不同 Banner 的表达层级无法统一
+
+#### 建议定义
+
+```ts
+interface BannerInfo {
+ id: number
+ title: string
+ subTitle: string
+ description: string
+ buttonText: string
+ positionCode: string
+ imageUrl: string
+ linkType: string
+ linkValue: string
+ sort: number
+ status: "enabled" | "disabled"
+ startTime: number
+ endTime: number
+}
+```
+
+#### 示例
+
+```json
+{
+ "id": 1,
+ "title": "春季上新",
+ "subTitle": "精选档口同步上新",
+ "description": "聚合热卖新品与高频补货商品",
+ "buttonText": "立即搜款",
+ "positionCode": "home_top",
+ "imageUrl": "https://cdn.example.com/banner/1.png",
+ "linkType": "goods",
+ "linkValue": "10001",
+ "sort": 1,
+ "status": "enabled",
+ "startTime": 1710000000,
+ "endTime": 1730000000
+}
+```
+
+### 3. 推荐店铺缺少推荐理由和排行依据
+
+#### 当前问题
+
+`RecommendStoreInfo` 现在只有:
+
+- `storeName`
+- `storeLogo`
+- `storeNotice`
+- `storeStatus`
+
+右侧“档口排行榜”区域如果要保持现有样式和业务含义,还需要:
+
+- 推荐理由
+- 主营类目
+- 排行分值或展示指标
+
+不补的话,只能展示店铺公告,排行榜会显得信息不完整。
+
+#### 为什么需要
+
+当前右侧区域不是普通店铺列表,而是“推荐 / 排行”型模块。用户需要知道:
+
+- 为什么推荐这家店
+- 它主要卖什么
+- 排行依据是什么
+
+#### 建议定义
+
+```ts
+interface RecommendStoreInfo {
+ id: number
+ merchantId: number
+ storeName: string
+ storeLogo: string
+ storeNotice: string
+ storeStatus: "enabled" | "disabled"
+ mainCategoryName: string
+ recommendReason: string
+ rankScore: number
+}
+```
+
+#### 示例
+
+```json
+{
+ "id": 9001,
+ "merchantId": 5001,
+ "storeName": "星驰数码档口",
+ "storeLogo": "https://cdn.example.com/store/logo-1.png",
+ "storeNotice": "手机配件热销档口",
+ "storeStatus": "enabled",
+ "mainCategoryName": "手机配件",
+ "recommendReason": "近 7 天出单稳定,适合做日常补货",
+ "rankScore": 98.6
+}
+```
+
+### 4. 商品摘要缺少类目名称
+
+#### 当前问题
+
+`GoodsSummary` 里有 `categoryId`,但没有 `categoryName`。
+
+这会导致:
+
+- 商品卡片无法展示人类可读的类目名
+- 分类筛选区无法直接使用列表结果做回显
+- 前端必须再维护一套 `categoryId -> 名称` 映射
+
+#### 为什么需要
+
+商品列表卡片、推荐商品卡片、筛选回显,都需要类目中文名。只靠 `categoryId` 不够。
+
+#### 建议定义
+
+```ts
+interface GoodsSummary {
+ id: number
+ merchantId: number
+ storeId: number
+ categoryId: number
+ categoryName: string
+ spuNo: string
+ spuName: string
+ subTitle: string
+ coverUrl: string
+ status: "enabled" | "disabled"
+ auditStatus: "pending" | "approved" | "rejected"
+ saleStatus: "off" | "on"
+ sort: number
+ salesVolume: number
+ virtualSales: number
+ viewCount: number
+ minPrice: string
+ maxPrice: string
+ totalStock: number
+ lockedStock: number
+ createdAt: number
+ updatedAt: number
+}
+```
+
+### 5. 搜索热词缺少接口字段
+
+#### 当前问题
+
+搜索框下方原样式里有热词区。当前文档没有专门的热词字段,所以前端只能临时从 `recommendGoods[].spuName` 里抽。
+
+#### 为什么需要
+
+热词一般是运营位,不应该完全依赖推荐商品标题:
+
+- 热词和推荐商品不是一个概念
+- 热词排序和内容应该可单独控制
+- 需要支持临时活动词、营销词
+
+#### 建议定义
+
+```ts
+interface AppHomeResponse {
+ banners: BannerInfo[]
+ recommendGoods: GoodsSummary[]
+ recommendStores: RecommendStoreInfo[]
+ categoryNavs: HomeCategoryNav[]
+ searchKeywords: string[]
+}
+```
+
+#### 示例
+
+```json
+[
+ "磁吸手机壳",
+ "直播补光灯",
+ "蓝牙耳机",
+ "快充套装",
+ "智能手表表带"
+]
+```
+
+### 6. 平台专区 / 频道卡片缺少独立数据源
+
+#### 当前问题
+
+原首页样式里有一块“平台专区 / 场景卡片”区域。当前接口没有对应字段,所以如果要保留这块业务含义,前端只能拿别的数据硬凑。
+
+#### 为什么需要
+
+这类区域通常代表固定业务入口,例如:
+
+- 平台铺货
+- 一件代发
+- 活动专区
+- 类目专题
+
+它不适合和 Banner 或推荐商品混用。
+
+#### 建议定义
+
+```ts
+interface HomeChannelCard {
+ id: number
+ title: string
+ subTitle: string
+ iconUrl: string
+ linkType: string
+ linkValue: string
+ sort: number
+ status: "enabled" | "disabled"
+}
+```
+
+建议追加到:
+
+```ts
+interface AppHomeResponse {
+ banners: BannerInfo[]
+ recommendGoods: GoodsSummary[]
+ recommendStores: RecommendStoreInfo[]
+ categoryNavs: HomeCategoryNav[]
+ searchKeywords: string[]
+ channels: HomeChannelCard[]
+}
+```
+
+## 建议优先级
+
+如果按“先把当前首页做完整”来排,建议优先级如下:
+
+1. `categoryNavs`
+2. `BannerInfo.subTitle / description / buttonText`
+3. `GoodsSummary.categoryName`
+4. `RecommendStoreInfo.mainCategoryName / recommendReason / rankScore`
+5. `searchKeywords`
+6. `channels`
+
+## 结论
+
+当前首页已经可以只用现有接口字段跑起来,但如果要真正做到:
+
+- 样式不变
+- 内容不写死
+- 页面表达完整
+- 运营可配置
+
+上面这些字段建议尽快补到文档和接口定义里。
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..281dfbc
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Mall App
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9bfa408
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "mall-app",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "typecheck": "tsc -b",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "ahooks": "^3.8.5",
+ "axios": "^1.9.0",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-router-dom": "^7.6.3",
+ "sonner": "^2.0.6",
+ "zustand": "^5.0.6"
+ },
+ "devDependencies": {
+ "@types/node": "^24.0.13",
+ "@types/react": "^18.2.48",
+ "@types/react-dom": "^18.2.18",
+ "@vitejs/plugin-react-swc": "^3.10.2",
+ "typescript": "~5.8.3",
+ "vite": "^7.0.4"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..1b94267
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,1268 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ ahooks:
+ specifier: ^3.8.5
+ version: 3.9.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ axios:
+ specifier: ^1.9.0
+ version: 1.15.2
+ react:
+ specifier: 18.2.0
+ version: 18.2.0
+ react-dom:
+ specifier: 18.2.0
+ version: 18.2.0(react@18.2.0)
+ react-router-dom:
+ specifier: ^7.6.3
+ version: 7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ sonner:
+ specifier: ^2.0.6
+ version: 2.0.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ zustand:
+ specifier: ^5.0.6
+ version: 5.0.12(@types/react@18.3.28)(react@18.2.0)
+ devDependencies:
+ '@types/node':
+ specifier: ^24.0.13
+ version: 24.12.2
+ '@types/react':
+ specifier: ^18.2.48
+ version: 18.3.28
+ '@types/react-dom':
+ specifier: ^18.2.18
+ version: 18.3.7(@types/react@18.3.28)
+ '@vitejs/plugin-react-swc':
+ specifier: ^3.10.2
+ version: 3.11.0(vite@7.3.2(@types/node@24.12.2))
+ typescript:
+ specifier: ~5.8.3
+ version: 5.8.3
+ vite:
+ specifier: ^7.0.4
+ version: 7.3.2(@types/node@24.12.2)
+
+packages:
+
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.27.7':
+ resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.7':
+ resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.7':
+ resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.7':
+ resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.7':
+ resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.7':
+ resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.7':
+ resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.7':
+ resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.7':
+ resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.7':
+ resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.7':
+ resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.7':
+ resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.7':
+ resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.7':
+ resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.7':
+ resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.7':
+ resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.7':
+ resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.7':
+ resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.7':
+ resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.7':
+ resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.7':
+ resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.7':
+ resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.7':
+ resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.7':
+ resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core-darwin-arm64@1.15.30':
+ resolution: {integrity: sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/core-darwin-x64@1.15.30':
+ resolution: {integrity: sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/core-linux-arm-gnueabihf@1.15.30':
+ resolution: {integrity: sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/core-linux-arm64-gnu@1.15.30':
+ resolution: {integrity: sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/core-linux-arm64-musl@1.15.30':
+ resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/core-linux-ppc64-gnu@1.15.30':
+ resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@swc/core-linux-s390x-gnu@1.15.30':
+ resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@swc/core-linux-x64-gnu@1.15.30':
+ resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/core-linux-x64-musl@1.15.30':
+ resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/core-win32-arm64-msvc@1.15.30':
+ resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/core-win32-ia32-msvc@1.15.30':
+ resolution: {integrity: sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/core-win32-x64-msvc@1.15.30':
+ resolution: {integrity: sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core@1.15.30':
+ resolution: {integrity: sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.17'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/types@0.1.26':
+ resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/js-cookie@3.0.6':
+ resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
+
+ '@types/node@24.12.2':
+ resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
+
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
+ '@types/react-dom@18.3.7':
+ resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
+ peerDependencies:
+ '@types/react': ^18.0.0
+
+ '@types/react@18.3.28':
+ resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
+
+ '@vitejs/plugin-react-swc@3.11.0':
+ resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==}
+ peerDependencies:
+ vite: ^4 || ^5 || ^6 || ^7
+
+ ahooks@3.9.7:
+ resolution: {integrity: sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ axios@1.15.2:
+ resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ dayjs@1.11.20:
+ resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
+
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.1:
+ resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.27.7:
+ resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
+ form-data@4.0.5:
+ resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
+ engines: {node: '>= 6'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.3:
+ resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
+ engines: {node: '>= 0.4'}
+
+ intersection-observer@0.12.2:
+ resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
+ deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
+
+ js-cookie@3.0.5:
+ resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
+ engines: {node: '>=14'}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.10:
+ resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
+
+ react-dom@18.2.0:
+ resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
+ peerDependencies:
+ react: ^18.2.0
+
+ react-fast-compare@3.2.2:
+ resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
+
+ react-router-dom@7.14.2:
+ resolution: {integrity: sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.14.2:
+ resolution: {integrity: sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react@18.2.0:
+ resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
+ engines: {node: '>=0.10.0'}
+
+ resize-observer-polyfill@1.5.1:
+ resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
+
+ rollup@4.60.2:
+ resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
+ screenfull@5.2.0:
+ resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==}
+ engines: {node: '>=0.10.0'}
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ sonner@2.0.7:
+ resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.8.3:
+ resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ vite@7.3.2:
+ resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ zustand@5.0.12:
+ resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
+snapshots:
+
+ '@babel/runtime@7.29.2': {}
+
+ '@esbuild/aix-ppc64@0.27.7':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/android-arm@0.27.7':
+ optional: true
+
+ '@esbuild/android-x64@0.27.7':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.7':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.7':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.7':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.7':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.7':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.7':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.7':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.7':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.7':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ optional: true
+
+ '@swc/core-darwin-arm64@1.15.30':
+ optional: true
+
+ '@swc/core-darwin-x64@1.15.30':
+ optional: true
+
+ '@swc/core-linux-arm-gnueabihf@1.15.30':
+ optional: true
+
+ '@swc/core-linux-arm64-gnu@1.15.30':
+ optional: true
+
+ '@swc/core-linux-arm64-musl@1.15.30':
+ optional: true
+
+ '@swc/core-linux-ppc64-gnu@1.15.30':
+ optional: true
+
+ '@swc/core-linux-s390x-gnu@1.15.30':
+ optional: true
+
+ '@swc/core-linux-x64-gnu@1.15.30':
+ optional: true
+
+ '@swc/core-linux-x64-musl@1.15.30':
+ optional: true
+
+ '@swc/core-win32-arm64-msvc@1.15.30':
+ optional: true
+
+ '@swc/core-win32-ia32-msvc@1.15.30':
+ optional: true
+
+ '@swc/core-win32-x64-msvc@1.15.30':
+ optional: true
+
+ '@swc/core@1.15.30':
+ dependencies:
+ '@swc/counter': 0.1.3
+ '@swc/types': 0.1.26
+ optionalDependencies:
+ '@swc/core-darwin-arm64': 1.15.30
+ '@swc/core-darwin-x64': 1.15.30
+ '@swc/core-linux-arm-gnueabihf': 1.15.30
+ '@swc/core-linux-arm64-gnu': 1.15.30
+ '@swc/core-linux-arm64-musl': 1.15.30
+ '@swc/core-linux-ppc64-gnu': 1.15.30
+ '@swc/core-linux-s390x-gnu': 1.15.30
+ '@swc/core-linux-x64-gnu': 1.15.30
+ '@swc/core-linux-x64-musl': 1.15.30
+ '@swc/core-win32-arm64-msvc': 1.15.30
+ '@swc/core-win32-ia32-msvc': 1.15.30
+ '@swc/core-win32-x64-msvc': 1.15.30
+
+ '@swc/counter@0.1.3': {}
+
+ '@swc/types@0.1.26':
+ dependencies:
+ '@swc/counter': 0.1.3
+
+ '@types/estree@1.0.8': {}
+
+ '@types/js-cookie@3.0.6': {}
+
+ '@types/node@24.12.2':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@types/prop-types@15.7.15': {}
+
+ '@types/react-dom@18.3.7(@types/react@18.3.28)':
+ dependencies:
+ '@types/react': 18.3.28
+
+ '@types/react@18.3.28':
+ dependencies:
+ '@types/prop-types': 15.7.15
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react-swc@3.11.0(vite@7.3.2(@types/node@24.12.2))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@swc/core': 1.15.30
+ vite: 7.3.2(@types/node@24.12.2)
+ transitivePeerDependencies:
+ - '@swc/helpers'
+
+ ahooks@3.9.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@types/js-cookie': 3.0.6
+ dayjs: 1.11.20
+ intersection-observer: 0.12.2
+ js-cookie: 3.0.5
+ lodash: 4.18.1
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ react-fast-compare: 3.2.2
+ resize-observer-polyfill: 1.5.1
+ screenfull: 5.2.0
+ tslib: 2.8.1
+
+ asynckit@0.4.0: {}
+
+ axios@1.15.2:
+ dependencies:
+ follow-redirects: 1.16.0
+ form-data: 4.0.5
+ proxy-from-env: 2.1.0
+ transitivePeerDependencies:
+ - debug
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
+ cookie@1.1.1: {}
+
+ csstype@3.2.3: {}
+
+ dayjs@1.11.20: {}
+
+ delayed-stream@1.0.0: {}
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.3
+
+ esbuild@0.27.7:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.7
+ '@esbuild/android-arm': 0.27.7
+ '@esbuild/android-arm64': 0.27.7
+ '@esbuild/android-x64': 0.27.7
+ '@esbuild/darwin-arm64': 0.27.7
+ '@esbuild/darwin-x64': 0.27.7
+ '@esbuild/freebsd-arm64': 0.27.7
+ '@esbuild/freebsd-x64': 0.27.7
+ '@esbuild/linux-arm': 0.27.7
+ '@esbuild/linux-arm64': 0.27.7
+ '@esbuild/linux-ia32': 0.27.7
+ '@esbuild/linux-loong64': 0.27.7
+ '@esbuild/linux-mips64el': 0.27.7
+ '@esbuild/linux-ppc64': 0.27.7
+ '@esbuild/linux-riscv64': 0.27.7
+ '@esbuild/linux-s390x': 0.27.7
+ '@esbuild/linux-x64': 0.27.7
+ '@esbuild/netbsd-arm64': 0.27.7
+ '@esbuild/netbsd-x64': 0.27.7
+ '@esbuild/openbsd-arm64': 0.27.7
+ '@esbuild/openbsd-x64': 0.27.7
+ '@esbuild/openharmony-arm64': 0.27.7
+ '@esbuild/sunos-x64': 0.27.7
+ '@esbuild/win32-arm64': 0.27.7
+ '@esbuild/win32-ia32': 0.27.7
+ '@esbuild/win32-x64': 0.27.7
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ follow-redirects@1.16.0: {}
+
+ form-data@4.0.5:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.3
+ mime-types: 2.1.35
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.3
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.1
+
+ gopd@1.2.0: {}
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.3:
+ dependencies:
+ function-bind: 1.1.2
+
+ intersection-observer@0.12.2: {}
+
+ js-cookie@3.0.5: {}
+
+ js-tokens@4.0.0: {}
+
+ lodash@4.18.1: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ math-intrinsics@1.1.0: {}
+
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ nanoid@3.3.11: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
+ postcss@8.5.10:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ proxy-from-env@2.1.0: {}
+
+ react-dom@18.2.0(react@18.2.0):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.2.0
+ scheduler: 0.23.2
+
+ react-fast-compare@3.2.2: {}
+
+ react-router-dom@7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+ dependencies:
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ react-router: 7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+
+ react-router@7.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+ dependencies:
+ cookie: 1.1.1
+ react: 18.2.0
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 18.2.0(react@18.2.0)
+
+ react@18.2.0:
+ dependencies:
+ loose-envify: 1.4.0
+
+ resize-observer-polyfill@1.5.1: {}
+
+ rollup@4.60.2:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.2
+ '@rollup/rollup-android-arm64': 4.60.2
+ '@rollup/rollup-darwin-arm64': 4.60.2
+ '@rollup/rollup-darwin-x64': 4.60.2
+ '@rollup/rollup-freebsd-arm64': 4.60.2
+ '@rollup/rollup-freebsd-x64': 4.60.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.2
+ '@rollup/rollup-linux-arm64-gnu': 4.60.2
+ '@rollup/rollup-linux-arm64-musl': 4.60.2
+ '@rollup/rollup-linux-loong64-gnu': 4.60.2
+ '@rollup/rollup-linux-loong64-musl': 4.60.2
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.2
+ '@rollup/rollup-linux-ppc64-musl': 4.60.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.2
+ '@rollup/rollup-linux-riscv64-musl': 4.60.2
+ '@rollup/rollup-linux-s390x-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-musl': 4.60.2
+ '@rollup/rollup-openbsd-x64': 4.60.2
+ '@rollup/rollup-openharmony-arm64': 4.60.2
+ '@rollup/rollup-win32-arm64-msvc': 4.60.2
+ '@rollup/rollup-win32-ia32-msvc': 4.60.2
+ '@rollup/rollup-win32-x64-gnu': 4.60.2
+ '@rollup/rollup-win32-x64-msvc': 4.60.2
+ fsevents: 2.3.3
+
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
+ screenfull@5.2.0: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ sonner@2.0.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+ dependencies:
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ tslib@2.8.1: {}
+
+ typescript@5.8.3: {}
+
+ undici-types@7.16.0: {}
+
+ vite@7.3.2(@types/node@24.12.2):
+ dependencies:
+ esbuild: 0.27.7
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.10
+ rollup: 4.60.2
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 24.12.2
+ fsevents: 2.3.3
+
+ zustand@5.0.12(@types/react@18.3.28)(react@18.2.0):
+ optionalDependencies:
+ '@types/react': 18.3.28
+ react: 18.2.0
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..2056858
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,27 @@
+import { Navigate, Route, Routes } from 'react-router-dom'
+import { Toaster } from 'sonner'
+import GoodsDetailPage from '@/pages/goods/GoodsDetailPage'
+import HomePage from '@/pages/home/HomePage'
+import LoginPage from '@/pages/login/LoginPage'
+import MemberCenterPage from '@/pages/member/MemberCenterPage'
+import ShopPage from '@/pages/shop/ShopPage'
+
+function App() {
+ return (
+ <>
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ >
+ )
+}
+
+export default App
diff --git a/src/apis/apiMallApp.ts b/src/apis/apiMallApp.ts
new file mode 100644
index 0000000..f8e70ff
--- /dev/null
+++ b/src/apis/apiMallApp.ts
@@ -0,0 +1 @@
+export * from './mall-app'
diff --git a/src/apis/apiUser.ts b/src/apis/apiUser.ts
new file mode 100644
index 0000000..08a2223
--- /dev/null
+++ b/src/apis/apiUser.ts
@@ -0,0 +1,67 @@
+import axiosInstance from './base/axiosInstance'
+import type { IBaseResponse } from './base/types'
+import type { ITokenData } from '@/types/ITokenData'
+import type { IUserInfo } from '@/types/IUserInfo'
+
+export type UserAccountLoginType = '0' | '1'
+
+export interface UserLoginPayload {
+ account: string
+ captcha?: string
+ password?: string
+ loginType: UserAccountLoginType
+ type: '0' | '1'
+}
+
+export interface UserRegisterPayload {
+ account: string
+ password: string
+ captcha: string
+ loginType: UserAccountLoginType
+ invite?: string
+ type: '0'
+}
+
+export interface SmsCodePayload {
+ account: string
+ loginType: UserAccountLoginType
+ type: '0' | '1' | '2'
+}
+
+export const apiUserLogin = async (payload: UserLoginPayload, signal?: AbortSignal) => {
+ const path = '/api/v1/user/login'
+ const response = await axiosInstance.post>(path, payload, {
+ signal,
+ })
+ return response.data
+}
+
+export const apiUserRegister = async (payload: UserRegisterPayload, signal?: AbortSignal) => {
+ const path = '/api/v1/user/register'
+ const response = await axiosInstance.post>(path, payload, {
+ signal,
+ })
+ return response.data
+}
+
+export const apiGetSmsCode = async (payload: SmsCodePayload, signal?: AbortSignal) => {
+ const path = '/api/v1/user/captcha'
+ const response = await axiosInstance.post>(path, payload, {
+ signal,
+ })
+ return response.data
+}
+
+export const apiGetUserInfo = async (signal?: AbortSignal) => {
+ const path = '/api/v1/user/detail'
+ const response = await axiosInstance.post>(
+ path,
+ {},
+ {
+ signal,
+ }
+ )
+ return response.data
+}
+
+export const apiUserReg = apiUserRegister
diff --git a/src/apis/base/axiosInstance.ts b/src/apis/base/axiosInstance.ts
new file mode 100644
index 0000000..f6c8c76
--- /dev/null
+++ b/src/apis/base/axiosInstance.ts
@@ -0,0 +1,147 @@
+import axios, { AxiosHeaders } from 'axios'
+import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
+import { getBaseUrl, getVersion } from '@/config/source/config'
+import { useUserStore } from '@/store/user'
+import { API_ERROR_CODES, API_ERROR_TYPES, ApiError, type IBaseResponse } from './types'
+
+const axiosInstance = axios.create({
+ baseURL: getBaseUrl(),
+ timeout: 30 * 1000,
+})
+
+const getMessage = (data: unknown, fallback: string) => {
+ if (!data || typeof data !== 'object') {
+ return fallback
+ }
+
+ const maybeMessage = (data as { message?: unknown; msg?: unknown }).message
+ if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
+ return maybeMessage
+ }
+
+ const maybeMsg = (data as { message?: unknown; msg?: unknown }).msg
+ if (typeof maybeMsg === 'string' && maybeMsg.trim()) {
+ return maybeMsg
+ }
+
+ return fallback
+}
+
+const handleHttpError = (status: number, data: unknown, message: string, error: unknown): never => {
+ if (status === 401) {
+ useUserStore.getState().logout()
+ throw new ApiError(API_ERROR_CODES.UNAUTHORIZED, 'Unauthorized', API_ERROR_TYPES.HTTP, data, error)
+ }
+
+ if (status === 403) {
+ throw new ApiError(API_ERROR_CODES.FORBIDDEN, 'Forbidden', API_ERROR_TYPES.HTTP, data, error)
+ }
+
+ if (status === 500 || status === 503) {
+ throw new ApiError(API_ERROR_CODES.SERVER_ERROR, 'Server error', API_ERROR_TYPES.HTTP, data, error)
+ }
+
+ throw new ApiError(status, message, API_ERROR_TYPES.HTTP, data, error)
+}
+
+const handleAxiosError = (error: AxiosError): never => {
+ if (error.code === 'ECONNABORTED') {
+ throw new ApiError(
+ API_ERROR_CODES.CLIENT_TIMEOUT_ERROR,
+ 'Request timeout',
+ API_ERROR_TYPES.NETWORK,
+ undefined,
+ error
+ )
+ }
+
+ if (error.response) {
+ const { status, data } = error.response
+ return handleHttpError(status, data, getMessage(data, error.message), error)
+ }
+
+ if (error.request) {
+ throw new ApiError(
+ API_ERROR_CODES.CLIENT_NETWORK_ERROR,
+ 'Network error',
+ API_ERROR_TYPES.NETWORK,
+ undefined,
+ error
+ )
+ }
+
+ throw new ApiError(
+ API_ERROR_CODES.SERVER_ERROR,
+ error.message,
+ API_ERROR_TYPES.UNKNOWN,
+ undefined,
+ error
+ )
+}
+
+axiosInstance.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ const token = useUserStore.getState().token?.accessToken ?? ''
+ const version = getVersion()
+
+ config.baseURL = getBaseUrl()
+ const headers = AxiosHeaders.from(config.headers)
+ headers.set('Content-Type', 'application/json;charset=utf-8')
+ headers.set('version', version)
+
+ if (token) {
+ headers.set('authorization', token)
+ }
+
+ config.headers = headers
+
+ return config
+ },
+ error => {
+ throw new ApiError(
+ API_ERROR_CODES.SERVER_ERROR,
+ 'Request setup failed',
+ API_ERROR_TYPES.UNKNOWN,
+ undefined,
+ error
+ )
+ }
+)
+
+axiosInstance.interceptors.response.use(
+ response => {
+ const payload = response.data as IBaseResponse
+ const code = payload?.code
+
+ if (code === API_ERROR_CODES.SUCCESS) {
+ return response
+ }
+
+ const message = getMessage(payload, 'Request failed')
+ if (code === API_ERROR_CODES.UNAUTHORIZED) {
+ useUserStore.getState().logout()
+ }
+
+ throw new ApiError(code, message, API_ERROR_TYPES.BUSINESS, payload?.data)
+ },
+ (error: unknown): never => {
+ if (axios.isCancel(error)) {
+ throw new ApiError(
+ API_ERROR_CODES.CLIENT_CANCEL_ERROR,
+ 'Request canceled',
+ API_ERROR_TYPES.UNKNOWN,
+ undefined,
+ error
+ )
+ }
+
+ if (error instanceof Error && 'isAxiosError' in error && (error as AxiosError).isAxiosError) {
+ return handleAxiosError(error as AxiosError)
+ }
+
+ const message = error instanceof Error ? error.message : 'Unknown error'
+ throw new ApiError(API_ERROR_CODES.SERVER_ERROR, message, API_ERROR_TYPES.UNKNOWN, undefined, error)
+ }
+)
+
+export default axiosInstance
diff --git a/src/apis/base/types.ts b/src/apis/base/types.ts
new file mode 100644
index 0000000..a159d5f
--- /dev/null
+++ b/src/apis/base/types.ts
@@ -0,0 +1,58 @@
+export const API_ERROR_TYPES = {
+ HTTP: 'HTTP',
+ BUSINESS: 'BUSINESS',
+ NETWORK: 'NETWORK',
+ UNKNOWN: 'UNKNOWN',
+} as const
+
+export type ApiErrorType = (typeof API_ERROR_TYPES)[keyof typeof API_ERROR_TYPES]
+
+export const API_ERROR_CODES = {
+ SUCCESS: 200,
+ UNAUTHORIZED: 600,
+ TOKEN_GENERATE_FAILED: 802,
+ PARAM_ERROR: 965,
+ SERVER_BUSY: 999,
+ DB_ERROR: 1001,
+ UPDATE_NO_ROWS: 1002,
+ FORBIDDEN: 403,
+ SERVER_ERROR: 500,
+ CLIENT_NETWORK_ERROR: 10000,
+ CLIENT_TIMEOUT_ERROR: 10001,
+ CLIENT_CANCEL_ERROR: 10002,
+} as const
+
+export class ApiError extends Error {
+ code: number
+ data?: unknown
+ originalError?: unknown
+ type: ApiErrorType
+
+ constructor(
+ code: number,
+ message: string,
+ type: ApiErrorType,
+ data?: unknown,
+ originalError?: unknown
+ ) {
+ super(message)
+ this.name = 'ApiError'
+ this.code = code
+ this.type = type
+ this.data = data
+ this.originalError = originalError
+ }
+}
+
+export interface IBaseResponse {
+ code: number
+ message?: string
+ msg?: string
+ data: T
+}
+
+export interface IPageParams {
+ page: number
+ size: number
+}
+
diff --git a/src/apis/base/useAxios.ts b/src/apis/base/useAxios.ts
new file mode 100644
index 0000000..6f6ed4e
--- /dev/null
+++ b/src/apis/base/useAxios.ts
@@ -0,0 +1,78 @@
+import { useRequest } from 'ahooks'
+import { useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+import { API_ERROR_CODES, type ApiError } from './types'
+import { useUserStore } from '@/store/user'
+
+type RequestService = (...args: TParams) => Promise
+
+export interface OptionsExt {
+ manual?: boolean
+ defaultParams?: TParams
+ showLoading?: boolean
+ showError?: boolean
+ showSuccess?: boolean
+ successText?: string
+ errorText?: string
+ onSuccess?: (data: TData, params: TParams) => void
+ onError?: (error: Error, params: TParams) => void
+}
+
+export const useAxios = (
+ service: RequestService,
+ options: OptionsExt = {}
+) => {
+ const loadingToastRef = useRef(null)
+ const { logout } = useUserStore()
+
+ useEffect(() => {
+ return () => {
+ if (loadingToastRef.current) {
+ toast.dismiss(loadingToastRef.current)
+ loadingToastRef.current = null
+ }
+ }
+ }, [])
+
+ return useRequest(service, {
+ manual: options.manual ?? true,
+ defaultParams: options.defaultParams,
+ onBefore: () => {
+ if (options.showLoading) {
+ loadingToastRef.current = toast.loading('Processing...')
+ }
+ },
+ onSuccess: (data, params) => {
+ if (loadingToastRef.current) {
+ toast.dismiss(loadingToastRef.current)
+ loadingToastRef.current = null
+ }
+
+ if (options.showSuccess) {
+ toast.success(options.successText || 'Success')
+ }
+
+ options.onSuccess?.(data, params)
+ },
+ onError: (error, params) => {
+ if (loadingToastRef.current) {
+ toast.dismiss(loadingToastRef.current)
+ loadingToastRef.current = null
+ }
+
+ const apiError = error as ApiError
+ if (options.showError !== false) {
+ toast.error(options.errorText || apiError.message || 'Request failed')
+ }
+
+ if (apiError.code === API_ERROR_CODES.UNAUTHORIZED) {
+ logout()
+ }
+
+ options.onError?.(error, params)
+ },
+ })
+}
+
+export default useAxios
+
diff --git a/src/apis/index.ts b/src/apis/index.ts
new file mode 100644
index 0000000..db5fb4f
--- /dev/null
+++ b/src/apis/index.ts
@@ -0,0 +1,4 @@
+export * from './apiMallApp'
+export * from './apiUser'
+export * from './base/types'
+export * from './base/useAxios'
diff --git a/src/apis/mall-app/afterSale.ts b/src/apis/mall-app/afterSale.ts
new file mode 100644
index 0000000..34c1608
--- /dev/null
+++ b/src/apis/mall-app/afterSale.ts
@@ -0,0 +1,15 @@
+import { APP_API_PATHS, normalizeMallAppPayload, postMallApp } from './shared'
+import type {
+ AppAfterSaleApplyRequest,
+ AppAfterSaleApplyResponse,
+} from '@/types/mall'
+
+export const apiAppAfterSaleApply = (
+ payload: AppAfterSaleApplyRequest,
+ signal?: AbortSignal
+) =>
+ postMallApp(
+ APP_API_PATHS.afterSaleApply,
+ normalizeMallAppPayload(payload),
+ signal
+ )
diff --git a/src/apis/mall-app/cart.ts b/src/apis/mall-app/cart.ts
new file mode 100644
index 0000000..6f401b4
--- /dev/null
+++ b/src/apis/mall-app/cart.ts
@@ -0,0 +1,36 @@
+import { APP_API_PATHS, normalizeAppCartListResponse, normalizeMallAppPayload, postMallApp } from './shared'
+import type {
+ AppCartListRequest,
+ AppCartListResponse,
+ AppCartRemoveRequest,
+ AppCartRemoveResponse,
+ AppCartSaveRequest,
+ AppCartSaveResponse,
+} from '@/types/mall'
+
+export const apiAppCartList = async (payload: AppCartListRequest, signal?: AbortSignal) => {
+ const response = await postMallApp(
+ APP_API_PATHS.cartList,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppCartListResponse(response.data),
+ }
+}
+
+export const apiAppCartSave = (payload: AppCartSaveRequest, signal?: AbortSignal) =>
+ postMallApp(
+ APP_API_PATHS.cartSave,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+export const apiAppCartRemove = (payload: AppCartRemoveRequest, signal?: AbortSignal) =>
+ postMallApp(
+ APP_API_PATHS.cartRemove,
+ normalizeMallAppPayload(payload),
+ signal
+ )
diff --git a/src/apis/mall-app/goods.ts b/src/apis/mall-app/goods.ts
new file mode 100644
index 0000000..6d68503
--- /dev/null
+++ b/src/apis/mall-app/goods.ts
@@ -0,0 +1,39 @@
+import {
+ APP_API_PATHS,
+ normalizeAppGoodsDetailResponse,
+ normalizeAppGoodsPageResponse,
+ normalizeMallAppPayload,
+ postMallApp,
+} from './shared'
+import type {
+ AppGoodsDetailRequest,
+ AppGoodsDetailResponse,
+ AppGoodsPageRequest,
+ AppGoodsPageResponse,
+} from '@/types/mall'
+
+export const apiAppGoodsPage = async (payload: AppGoodsPageRequest, signal?: AbortSignal) => {
+ const response = await postMallApp(
+ APP_API_PATHS.goodsPage,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppGoodsPageResponse(response.data),
+ }
+}
+
+export const apiAppGoodsDetail = async (payload: AppGoodsDetailRequest, signal?: AbortSignal) => {
+ const response = await postMallApp(
+ APP_API_PATHS.goodsDetail,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppGoodsDetailResponse(response.data),
+ }
+}
diff --git a/src/apis/mall-app/home.ts b/src/apis/mall-app/home.ts
new file mode 100644
index 0000000..0471631
--- /dev/null
+++ b/src/apis/mall-app/home.ts
@@ -0,0 +1,67 @@
+import {
+ APP_API_PATHS,
+ normalizeAppHomeMerchantGoodsResponse,
+ normalizeAppHomeResponse,
+ normalizeAppRecentNewGoodsResponse,
+ normalizeMallAppPayload,
+ postMallApp,
+} from './shared'
+import type {
+ AppHomeMerchantGoodsRequest,
+ AppHomeMerchantGoodsResponse,
+ AppHomeRequest,
+ AppHomeResponse,
+ AppHomeStoreGoodsRequest,
+ AppRecentNewGoodsRequest,
+ AppRecentNewGoodsResponse,
+} from '@/types/mall'
+
+export const apiAppHome = async (payload: AppHomeRequest = {}, signal?: AbortSignal) => {
+ const response = await postMallApp(
+ APP_API_PATHS.home,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppHomeResponse(response.data),
+ }
+}
+
+export const apiAppRecentNewGoods = async (
+ payload: AppRecentNewGoodsRequest = {},
+ signal?: AbortSignal
+) => {
+ const response = await postMallApp(
+ APP_API_PATHS.recentNewGoods,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppRecentNewGoodsResponse(response.data),
+ }
+}
+
+export const apiAppHomeMerchantGoods = async (
+ payload: AppHomeMerchantGoodsRequest = {},
+ signal?: AbortSignal
+) => {
+ const response = await postMallApp(
+ APP_API_PATHS.homeMerchantGoods,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppHomeMerchantGoodsResponse(response.data),
+ }
+}
+
+export const apiAppHomeStoreGoods = (
+ payload: AppHomeStoreGoodsRequest = {},
+ signal?: AbortSignal
+) => apiAppHomeMerchantGoods(payload, signal)
diff --git a/src/apis/mall-app/index.ts b/src/apis/mall-app/index.ts
new file mode 100644
index 0000000..a1dd192
--- /dev/null
+++ b/src/apis/mall-app/index.ts
@@ -0,0 +1,7 @@
+export * from './shared'
+export * from './home'
+export * from './goods'
+export * from './cart'
+export * from './trade'
+export * from './order'
+export * from './afterSale'
diff --git a/src/apis/mall-app/normalize.ts b/src/apis/mall-app/normalize.ts
new file mode 100644
index 0000000..c40a925
--- /dev/null
+++ b/src/apis/mall-app/normalize.ts
@@ -0,0 +1,373 @@
+import type {
+ AppCartListResponse,
+ AppGoodsDetailResponse,
+ AppGoodsPageResponse,
+ AppHomeMerchantGoodsResponse,
+ AppHomeResponse,
+ AppRecentNewGoodsResponse,
+ AppOrderDetailResponse,
+ AppOrderPageResponse,
+ CartItemInfo,
+ GoodsDetailInfo,
+ GoodsSkuInfo,
+ GoodsSummary,
+ AppTradePreviewResponse,
+ HomeMerchantGoodsBlock,
+ MerchantId,
+ MerchantInfo,
+ OrderDetailInfo,
+ OrderSummary,
+ RecommendMerchantInfo,
+ RecentNewGoodsItem,
+ TradePreviewInfo,
+ TradePreviewStoreInfo,
+} from '@/types/mall'
+
+type MaybeMallUidPayload = {
+ uid?: number
+ memberId?: number
+}
+
+type MaybeMallMerchantPayload = {
+ merchantId?: MerchantId | number
+ storeId?: number | string
+}
+
+type RawRecommendMerchantPayload = Partial & {
+ id?: RecommendMerchantInfo['id']
+ merchantId?: MerchantId | number | string
+ merchantName?: string
+ logo?: string
+ notice?: string
+ status?: string
+ rankScore?: number | string
+ goodsCount?: number | string
+ recentNewGoodsCount?: number | string
+ orderCount?: number | string
+}
+
+type RawMerchantEntityPayload = Partial & {
+ id?: MerchantInfo['id'] | string
+ merchantId?: MerchantId | number | string
+ merchantName?: string
+ logo?: string
+ notice?: string
+ status?: string
+}
+
+type RawGoodsSummaryPayload = Partial & {
+ merchantId?: MerchantId | number | string
+ createdAt?: GoodsSummary['createdAt'] | string | null
+ updatedAt?: GoodsSummary['updatedAt'] | string | null
+ createAt?: number | string | null
+ updateAt?: number | string | null
+ created_at?: number | string | null
+ updated_at?: number | string | null
+ createTime?: number | string | null
+ updateTime?: number | string | null
+ create_time?: number | string | null
+ update_time?: number | string | null
+}
+
+const toMerchantId = (value: MerchantId | number | string | null | undefined): MerchantId =>
+ value == null ? '' : String(value)
+
+const toNumber = (value: number | string | null | undefined): number => {
+ const parsed = Number(value)
+ return Number.isFinite(parsed) ? parsed : 0
+}
+
+const pickText = (...values: Array): string => {
+ for (const value of values) {
+ if (typeof value === 'string' && value.trim()) {
+ return value
+ }
+ }
+
+ return ''
+}
+
+const toUnixTime = (value: number | string | null | undefined): number => {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value > 1_000_000_000_000 ? Math.floor(value / 1000) : value
+ }
+
+ if (typeof value !== 'string') {
+ return 0
+ }
+
+ const text = value.trim()
+ if (!text) {
+ return 0
+ }
+
+ const numeric = Number(text)
+ if (Number.isFinite(numeric)) {
+ return numeric > 1_000_000_000_000 ? Math.floor(numeric / 1000) : numeric
+ }
+
+ const parsed = Date.parse(text.replace(/-/g, '/'))
+ return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0
+}
+
+export const normalizeMallAppPayload = (payload: TPayload): TPayload => {
+ const normalized = { ...payload } as TPayload & MaybeMallUidPayload & MaybeMallMerchantPayload
+
+ if (normalized.uid == null && normalized.memberId != null) {
+ normalized.uid = normalized.memberId
+ }
+ delete normalized.memberId
+
+ if (normalized.merchantId != null) {
+ normalized.merchantId = toMerchantId(normalized.merchantId)
+ }
+ if (normalized.merchantId == null && normalized.storeId != null) {
+ normalized.merchantId = toMerchantId(normalized.storeId)
+ }
+ delete normalized.storeId
+
+ return normalized as TPayload
+}
+
+const normalizeGoodsSku = (sku: GoodsSkuInfo): GoodsSkuInfo => ({
+ ...sku,
+ merchantId: toMerchantId(sku.merchantId),
+})
+
+const normalizeGoodsSummary = (goods: RawGoodsSummaryPayload): GoodsSummary => {
+ const normalized = goods as GoodsSummary
+
+ return {
+ ...normalized,
+ merchantId: toMerchantId(goods.merchantId),
+ merchantName: pickText(goods.merchantName),
+ merchantLogo: pickText(goods.merchantLogo),
+ createdAt: toUnixTime(goods.createdAt ?? goods.createAt ?? goods.created_at ?? goods.createTime ?? goods.create_time),
+ updatedAt: toUnixTime(goods.updatedAt ?? goods.updateAt ?? goods.updated_at ?? goods.updateTime ?? goods.update_time),
+ }
+}
+
+const normalizeRecentNewGoodsItem = (goods: RecentNewGoodsItem): RecentNewGoodsItem => ({
+ ...goods,
+ merchantId: goods.merchantId == null ? undefined : toMerchantId(goods.merchantId),
+ merchantName: pickText(goods.merchantName, goods.storeName),
+ merchantLogo: goods.merchantLogo?.trim(),
+ storeName: pickText(goods.storeName, goods.merchantName),
+ spuId: String(goods.spuId),
+ createdAt: toUnixTime(goods.createdAt),
+})
+
+const normalizeGoodsDetailInfo = (detail: GoodsDetailInfo): GoodsDetailInfo => ({
+ ...detail,
+ merchantId: toMerchantId(detail.merchantId),
+ createdAt: toUnixTime(detail.createdAt),
+ updatedAt: toUnixTime(detail.updatedAt),
+ skus: (detail.skus ?? []).map(normalizeGoodsSku),
+})
+
+const normalizeCartItem = (item: CartItemInfo): CartItemInfo => ({
+ ...item,
+ merchantId: toMerchantId(item.merchantId),
+})
+
+const normalizeTradePreviewGroup = (group: TradePreviewStoreInfo): TradePreviewStoreInfo => ({
+ ...group,
+ merchantId: toMerchantId(group.merchantId),
+})
+
+const normalizeOrderSummary = (order: OrderSummary): OrderSummary => ({
+ ...order,
+ merchantId: toMerchantId(order.merchantId),
+})
+
+const normalizeOrderDetailInfo = (order: OrderDetailInfo): OrderDetailInfo => ({
+ ...order,
+ merchantId: toMerchantId(order.merchantId),
+})
+
+const normalizeRecommendMerchant = (
+ merchant: RawRecommendMerchantPayload | null | undefined
+): RecommendMerchantInfo | undefined => {
+ if (!merchant) {
+ return undefined
+ }
+
+ return {
+ ...(merchant as RecommendMerchantInfo),
+ merchantId: toMerchantId(merchant.merchantId ?? merchant.id),
+ storeName: pickText(merchant.storeName, merchant.merchantName),
+ storeLogo: pickText(merchant.storeLogo, merchant.logo),
+ storeNotice: pickText(merchant.storeNotice, merchant.notice),
+ storeStatus: (merchant.storeStatus ?? merchant.status ?? 'enabled') as RecommendMerchantInfo['storeStatus'],
+ rankScore: toNumber(merchant.rankScore),
+ goodsCount: toNumber(merchant.goodsCount),
+ recentNewGoodsCount: toNumber(merchant.recentNewGoodsCount),
+ orderCount: toNumber(merchant.orderCount),
+ }
+}
+
+const normalizeMerchantEntity = (
+ merchant: RawMerchantEntityPayload | null | undefined
+): MerchantInfo | undefined => {
+ if (!merchant) {
+ return undefined
+ }
+
+ return {
+ ...(merchant as MerchantInfo),
+ merchantId: toMerchantId(merchant.merchantId ?? merchant.id),
+ storeName: pickText(merchant.storeName, merchant.merchantName),
+ storeLogo: pickText(merchant.storeLogo, merchant.logo),
+ storeNotice: pickText(merchant.storeNotice, merchant.notice),
+ storeStatus: (merchant.storeStatus ?? merchant.status ?? 'enabled') as MerchantInfo['storeStatus'],
+ }
+}
+
+const normalizeHomeMerchantGoodsBlock = (
+ block: HomeMerchantGoodsBlock | undefined
+): HomeMerchantGoodsBlock | undefined => {
+ if (!block) {
+ return undefined
+ }
+
+ const merchant = normalizeRecommendMerchant(block.merchant ?? block.store)
+ if (!merchant) {
+ return undefined
+ }
+
+ return {
+ ...block,
+ merchant,
+ goods: block.goods.map(normalizeGoodsSummary),
+ store: merchant,
+ }
+}
+
+export const normalizeAppGoodsPageResponse = (
+ payload: AppGoodsPageResponse
+): AppGoodsPageResponse => ({
+ ...payload,
+ list: payload.list.map(normalizeGoodsSummary),
+})
+
+export const normalizeAppRecentNewGoodsResponse = (
+ payload: AppRecentNewGoodsResponse
+): AppRecentNewGoodsResponse => ({
+ ...payload,
+ total: payload.total ?? 0,
+ list: (payload.list ?? []).map(normalizeRecentNewGoodsItem),
+})
+
+export const normalizeAppCartListResponse = (
+ payload: AppCartListResponse
+): AppCartListResponse => ({
+ ...payload,
+ list: payload.list.map(normalizeCartItem),
+})
+
+export const normalizeAppOrderPageResponse = (
+ payload: AppOrderPageResponse
+): AppOrderPageResponse => ({
+ ...payload,
+ list: payload.list.map(normalizeOrderSummary),
+})
+
+export const normalizeAppOrderDetailResponse = (
+ payload: AppOrderDetailResponse
+): AppOrderDetailResponse => ({
+ ...payload,
+ data: normalizeOrderDetailInfo(payload.data),
+})
+
+export const normalizeAppHomeResponse = (payload: AppHomeResponse): AppHomeResponse => {
+ const recommendMerchants = (payload.recommendMerchants ?? payload.recommendStores ?? [])
+ .map(normalizeRecommendMerchant)
+ .filter(Boolean) as RecommendMerchantInfo[]
+ const featuredMerchants = (payload.featuredMerchants ?? payload.featuredStores ?? [])
+ .map(normalizeRecommendMerchant)
+ .filter(Boolean) as RecommendMerchantInfo[]
+
+ return {
+ ...payload,
+ recommendGoods: payload.recommendGoods.map(normalizeGoodsSummary),
+ recommendMerchants,
+ featuredMerchants,
+ recommendStores: recommendMerchants,
+ featuredStores: featuredMerchants,
+ }
+}
+
+export const normalizeAppHomeMerchantGoodsResponse = (
+ payload: AppHomeMerchantGoodsResponse
+): AppHomeMerchantGoodsResponse => ({
+ ...payload,
+ data:
+ normalizeHomeMerchantGoodsBlock(payload.data) ??
+ ({
+ merchant: {
+ id: 0,
+ merchantId: '0',
+ storeName: '',
+ storeLogo: '',
+ storeNotice: '',
+ storeStatus: 'disabled',
+ mainCategoryName: '',
+ recommendReason: '',
+ rankScore: 0,
+ goodsCount: 0,
+ recentNewGoodsCount: 0,
+ orderCount: 0,
+ },
+ store: {
+ id: 0,
+ merchantId: '0',
+ storeName: '',
+ storeLogo: '',
+ storeNotice: '',
+ storeStatus: 'disabled',
+ mainCategoryName: '',
+ recommendReason: '',
+ rankScore: 0,
+ goodsCount: 0,
+ recentNewGoodsCount: 0,
+ orderCount: 0,
+ },
+ goods: [],
+ } satisfies HomeMerchantGoodsBlock),
+})
+
+export const normalizeAppGoodsDetailResponse = (
+ payload: AppGoodsDetailResponse
+): AppGoodsDetailResponse => {
+ const merchant = normalizeMerchantEntity(payload.merchant ?? payload.store)
+
+ return {
+ ...payload,
+ data: normalizeGoodsDetailInfo(payload.data),
+ merchant: merchant ?? payload.store!,
+ store: merchant ?? payload.store,
+ }
+}
+
+export const normalizeAppTradePreviewResponse = (
+ payload: AppTradePreviewResponse
+): AppTradePreviewResponse => {
+ const data = payload.data as TradePreviewInfo & {
+ uid?: number
+ memberId?: number
+ merchantGroups?: TradePreviewInfo['merchantGroups']
+ storeGroups?: TradePreviewInfo['merchantGroups']
+ }
+ const merchantGroups = data.merchantGroups ?? data.storeGroups ?? []
+
+ return {
+ ...payload,
+ data: {
+ ...data,
+ uid: data.uid ?? data.memberId ?? 0,
+ memberId: data.memberId ?? data.uid,
+ merchantGroups: merchantGroups.map(normalizeTradePreviewGroup),
+ storeGroups: merchantGroups.map(normalizeTradePreviewGroup),
+ },
+ }
+}
diff --git a/src/apis/mall-app/order.ts b/src/apis/mall-app/order.ts
new file mode 100644
index 0000000..e6f5313
--- /dev/null
+++ b/src/apis/mall-app/order.ts
@@ -0,0 +1,48 @@
+import {
+ APP_API_PATHS,
+ normalizeAppOrderDetailResponse,
+ normalizeAppOrderPageResponse,
+ normalizeMallAppPayload,
+ postMallApp,
+} from './shared'
+import type {
+ AppOrderConfirmRequest,
+ AppOrderConfirmResponse,
+ AppOrderDetailRequest,
+ AppOrderDetailResponse,
+ AppOrderPageRequest,
+ AppOrderPageResponse,
+} from '@/types/mall'
+
+export const apiAppOrderPage = async (payload: AppOrderPageRequest, signal?: AbortSignal) => {
+ const response = await postMallApp(
+ APP_API_PATHS.orderPage,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppOrderPageResponse(response.data),
+ }
+}
+
+export const apiAppOrderDetail = async (payload: AppOrderDetailRequest, signal?: AbortSignal) => {
+ const response = await postMallApp(
+ APP_API_PATHS.orderDetail,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppOrderDetailResponse(response.data),
+ }
+}
+
+export const apiAppOrderConfirm = (payload: AppOrderConfirmRequest, signal?: AbortSignal) =>
+ postMallApp(
+ APP_API_PATHS.orderConfirm,
+ normalizeMallAppPayload(payload),
+ signal
+ )
diff --git a/src/apis/mall-app/shared.ts b/src/apis/mall-app/shared.ts
new file mode 100644
index 0000000..3467ce6
--- /dev/null
+++ b/src/apis/mall-app/shared.ts
@@ -0,0 +1,34 @@
+import axiosInstance from '@/apis/base/axiosInstance'
+import type { IBaseResponse } from '@/apis/base/types'
+
+export const APP_API_PATHS = {
+ home: '/api/v1/mall/app/home',
+ recentNewGoods: '/api/v1/mall/app/recent_new_goods',
+ homeMerchantGoods: '/api/v1/mall/app/home_merchant_goods',
+ homeStoreGoods: '/api/v1/mall/app/home_merchant_goods',
+ goodsPage: '/api/v1/mall/app/goods_page',
+ goodsDetail: '/api/v1/mall/app/goods_detail',
+ cartList: '/api/v1/mall/app/cart_list',
+ cartSave: '/api/v1/mall/app/cart_save',
+ cartRemove: '/api/v1/mall/app/cart_remove',
+ tradePreview: '/api/v1/mall/app/trade_preview',
+ tradeCreate: '/api/v1/mall/app/trade_create',
+ orderPage: '/api/v1/mall/app/order_page',
+ orderDetail: '/api/v1/mall/app/order_detail',
+ orderConfirm: '/api/v1/mall/app/order_confirm',
+ afterSaleApply: '/api/v1/mall/app/after_sale_apply',
+} as const
+
+export const postMallApp = async (
+ path: string,
+ payload: TPayload,
+ signal?: AbortSignal
+): Promise> => {
+ const response = await axiosInstance.post>(path, payload, {
+ signal,
+ })
+
+ return response.data
+}
+
+export * from './normalize'
diff --git a/src/apis/mall-app/trade.ts b/src/apis/mall-app/trade.ts
new file mode 100644
index 0000000..22e861d
--- /dev/null
+++ b/src/apis/mall-app/trade.ts
@@ -0,0 +1,32 @@
+import {
+ APP_API_PATHS,
+ normalizeAppTradePreviewResponse,
+ normalizeMallAppPayload,
+ postMallApp,
+} from './shared'
+import type {
+ AppTradeCreateRequest,
+ AppTradeCreateResponse,
+ AppTradePreviewRequest,
+ AppTradePreviewResponse,
+} from '@/types/mall'
+
+export const apiAppTradePreview = async (payload: AppTradePreviewRequest, signal?: AbortSignal) => {
+ const response = await postMallApp(
+ APP_API_PATHS.tradePreview,
+ normalizeMallAppPayload(payload),
+ signal
+ )
+
+ return {
+ ...response,
+ data: normalizeAppTradePreviewResponse(response.data),
+ }
+}
+
+export const apiAppTradeCreate = (payload: AppTradeCreateRequest, signal?: AbortSignal) =>
+ postMallApp(
+ APP_API_PATHS.tradeCreate,
+ normalizeMallAppPayload(payload),
+ signal
+ )
diff --git a/src/components/MarketNav.tsx b/src/components/MarketNav.tsx
new file mode 100644
index 0000000..cbdfb3b
--- /dev/null
+++ b/src/components/MarketNav.tsx
@@ -0,0 +1,34 @@
+export type MarketNavKey = 'home' | 'rank'
+
+export const MARKET_NAV_ITEMS: Array<{ key: MarketNavKey; label: string }> = [
+ { key: 'home', label: '首页' },
+ { key: 'rank', label: '商家排行' },
+]
+
+interface MarketNavProps {
+ activeKey?: MarketNavKey
+ onSelect: (key: MarketNavKey) => void
+}
+
+function MarketNav({ activeKey, onSelect }: MarketNavProps) {
+ return (
+
+
+
+ {MARKET_NAV_ITEMS.map(item => (
+
+ ))}
+
+
+
+ )
+}
+
+export default MarketNav
diff --git a/src/components/MarketPageHeader.tsx b/src/components/MarketPageHeader.tsx
new file mode 100644
index 0000000..8645fca
--- /dev/null
+++ b/src/components/MarketPageHeader.tsx
@@ -0,0 +1,173 @@
+import { Link, useNavigate } from 'react-router-dom'
+import { toast } from 'sonner'
+import MarketNav, { type MarketNavKey } from '@/components/MarketNav'
+import { useUserStore } from '@/store/user'
+
+interface MarketPageHeaderProps {
+ activeNav?: MarketNavKey
+ hotKeywords?: string[]
+ keyword: string
+ onBrandClick?: () => void
+ onKeywordChange: (value: string) => void
+ onNavSelect?: (key: MarketNavKey) => void
+ onSearch?: (keyword: string) => void
+ searchInputId?: string
+ searchLoading?: boolean
+}
+
+const getSearchPath = (keyword: string) => {
+ const trimmedKeyword = keyword.trim()
+ return trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new'
+}
+
+function MarketPageHeader({
+ activeNav,
+ hotKeywords = [],
+ keyword,
+ onBrandClick,
+ onKeywordChange,
+ onNavSelect,
+ onSearch,
+ searchInputId = 'market-search-input',
+ searchLoading = false,
+}: MarketPageHeaderProps) {
+ const navigate = useNavigate()
+ const token = useUserStore(state => state.token)
+ const userInfo = useUserStore(state => state.userInfo)
+ const storedMemberId = useUserStore(state => state.memberId)
+ const logout = useUserStore(state => state.logout)
+
+ const userLabel = userInfo?.name?.trim() || userInfo?.account?.trim() || token?.account?.trim() || String(storedMemberId || '')
+
+ const handleSearch = (nextKeyword: string) => {
+ if (onSearch) {
+ onSearch(nextKeyword)
+ return
+ }
+
+ navigate(getSearchPath(nextKeyword))
+ }
+
+ const handleNavSelect = (key: MarketNavKey) => {
+ if (onNavSelect) {
+ onNavSelect(key)
+ return
+ }
+
+ navigate(key === 'rank' ? '/?nav=rank' : '/')
+ }
+
+ return (
+ <>
+
+
+
+ {token ? `您好,${userLabel || '采购伙伴'}` : '您好!'}
+ {token ? (
+
+ ) : (
+ <>
+ 请登录
+ 注册
+ >
+ )}
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default MarketPageHeader
diff --git a/src/config/source/config.ts b/src/config/source/config.ts
new file mode 100644
index 0000000..f0530b7
--- /dev/null
+++ b/src/config/source/config.ts
@@ -0,0 +1,79 @@
+import packageJson from '../../../package.json'
+
+export interface GlobalConfig {
+ VERSION: string
+ BASE_URL: string
+ RESOURCE_ASSETS_URL: string
+}
+
+const DEFAULT_VERSION = packageJson.version ?? '0.1.0'
+const DEFAULT_BASE_URL = 'https://test-gateway.yuntuo.hk'
+
+export const getGlobalConfig = (): GlobalConfig => {
+ if (typeof window === 'undefined') {
+ return {
+ VERSION: DEFAULT_VERSION,
+ BASE_URL: import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL,
+ RESOURCE_ASSETS_URL: '',
+ }
+ }
+
+ const windowConfig = (window as Window & { config?: GlobalConfig }).config
+ return (
+ windowConfig || {
+ VERSION: DEFAULT_VERSION,
+ BASE_URL: import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL,
+ RESOURCE_ASSETS_URL: '',
+ }
+ )
+}
+
+export const getBaseUrl = (): string => {
+ if (typeof window !== 'undefined') {
+ const config = (window as Window & { config?: GlobalConfig }).config
+ const fromConfig = config?.BASE_URL?.trim()
+ if (fromConfig) {
+ return fromConfig
+ }
+
+ const primaryMeta = document.querySelector('meta[name="api-base-url"]') as
+ | HTMLMetaElement
+ | null
+ const altMeta = document.querySelector('meta[name="yt-api-base-url"]') as
+ | HTMLMetaElement
+ | null
+
+ if (primaryMeta?.content?.trim()) {
+ return primaryMeta.content.trim()
+ }
+
+ if (altMeta?.content?.trim()) {
+ return altMeta.content.trim()
+ }
+ }
+
+ return import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL
+}
+
+export const getVersion = (): string => DEFAULT_VERSION
+
+export const getResourceAssetsUrl = (): string => {
+ const value = getGlobalConfig().RESOURCE_ASSETS_URL
+ return typeof value === 'string' ? value.trim() : ''
+}
+
+export const getResourceUrl = (path: string): string => {
+ const baseUrl = getResourceAssetsUrl()
+ if (!baseUrl) {
+ return path
+ }
+
+ const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
+ const cleanPath = path.startsWith('/') ? path.slice(1) : path
+
+ if (cleanBaseUrl.includes('/dist/assets')) {
+ return `${cleanBaseUrl}/${cleanPath.replace(/^assets\//, '')}`
+ }
+
+ return `${cleanBaseUrl}/${cleanPath}`
+}
diff --git a/src/constants/mall.ts b/src/constants/mall.ts
new file mode 100644
index 0000000..38fd80d
--- /dev/null
+++ b/src/constants/mall.ts
@@ -0,0 +1,47 @@
+import type { AfterSaleStatus, AfterSaleType, GoodsSortType, OrderStatus, SaleStatus } from '@/types/mall'
+
+export const GOODS_SORT_OPTIONS: Array<{ label: string; value: GoodsSortType }> = [
+ { label: 'Comprehensive', value: 'comprehensive' },
+ { label: 'Price low to high', value: 'price_asc' },
+ { label: 'Price high to low', value: 'price_desc' },
+ { label: 'Sales high to low', value: 'sales_desc' },
+]
+
+export const SALE_STATUS_LABELS: Record = {
+ off: 'Off shelf',
+ on: 'On shelf',
+}
+
+export const ORDER_STATUS_LABELS: Record = {
+ pending_pay: 'Pending payment',
+ paid_wait_ship: 'Pending shipment',
+ shipped: 'Shipped',
+ finished: 'Finished',
+ canceled: 'Canceled',
+ after_sale: 'After sale',
+ refund_finished: 'Refund finished',
+}
+
+export const AFTER_SALE_STATUS_LABELS: Record = {
+ none: 'No after sale',
+ pending: 'Pending review',
+ approved: 'Approved',
+ rejected: 'Rejected',
+ refunding: 'Refunding',
+ finished: 'Finished',
+ closed: 'Closed',
+}
+
+export const AFTER_SALE_TYPE_LABELS: Record = {
+ 1: 'Refund only',
+ 2: 'Return and refund',
+}
+
+export const APP_ORDER_PRIMARY_ACTIONS: Partial> = {
+ pending_pay: 'Pending payment',
+ paid_wait_ship: 'Pending shipment',
+ shipped: 'Confirm receipt',
+ finished: 'Apply after sale',
+ after_sale: 'After sale in progress',
+}
+
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..0326ad9
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,3614 @@
+:root {
+ --market-bg: #f5f7fa;
+ --market-surface: rgba(255, 255, 255, 0.98);
+ --market-ink: #0e1513;
+ --market-muted: rgba(14, 21, 19, 0.65);
+ --market-line: rgba(5, 5, 5, 0.06);
+ --market-brand: #156f4e;
+ --market-brand-soft: #349172;
+ --market-brand-strong: #054e38;
+ --market-brand-bg: #e5f2ee;
+ --market-brand-bg-hover: #cde7de;
+ --market-brand-border: #86c2ae;
+ --market-brand-border-hover: #61ae94;
+ --market-radius-xs: 4px;
+ --market-radius-sm: 6px;
+ --market-radius: 8px;
+ --market-radius-lg: 12px;
+ --market-radius-xl: 16px;
+ --market-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
+ font-family:
+ "PingFang SC",
+ "Hiragino Sans GB",
+ "Microsoft YaHei",
+ sans-serif;
+ color: var(--market-ink);
+ background:
+ radial-gradient(circle at top left, rgba(21, 111, 78, 0.08), transparent 26%),
+ radial-gradient(circle at top right, rgba(21, 111, 78, 0.04), transparent 22%),
+ linear-gradient(180deg, #fbfcfd 0%, #f5f7fa 100%);
+ line-height: 1.5715;
+ font-weight: 400;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ font-size: 14px;
+ color: var(--market-ink);
+ background: var(--market-bg);
+}
+
+body,
+button,
+input {
+ font: inherit;
+}
+
+img {
+ display: block;
+ max-width: 100%;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button {
+ border: 0;
+ background: none;
+ color: inherit;
+ cursor: pointer;
+ transition:
+ color 0.2s ease,
+ border-color 0.2s ease,
+ background-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+input {
+ outline: none;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ box-shadow: none;
+}
+
+input::placeholder {
+ color: rgba(14, 21, 19, 0.35);
+}
+
+#root {
+ min-height: 100vh;
+}
+
+.shell {
+ width: min(1320px, calc(100% - 32px));
+ margin: 0 auto;
+}
+
+.market-app {
+ min-height: 100vh;
+}
+
+.market-kicker {
+ margin: 0;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--market-brand);
+}
+
+.market-link-button {
+ padding: 0;
+ color: inherit;
+}
+
+.market-topbar {
+ background: #20242d;
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 0.86rem;
+}
+
+.market-topbar__inner,
+.market-topbar__left {
+ display: flex;
+ align-items: center;
+}
+
+.market-topbar__inner {
+ justify-content: space-between;
+ min-height: 44px;
+ gap: 16px;
+}
+
+.market-topbar__left {
+ gap: 18px;
+ flex-wrap: wrap;
+}
+
+.market-topbar__left a,
+.market-topbar__left button {
+ color: #fff;
+}
+
+.market-header {
+ background: rgba(255, 255, 255, 0.96);
+ border-bottom: 1px solid var(--market-line);
+ backdrop-filter: blur(12px);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.market-header__inner {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 24px;
+ min-height: 88px;
+}
+
+.market-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+ justify-self: start;
+ text-align: left;
+}
+
+.market-brand strong {
+ display: block;
+ font-size: 1.12rem;
+}
+
+.market-brand small {
+ display: block;
+ font-size: 0.86rem;
+ color: var(--market-muted);
+}
+
+.market-brand__mark {
+ display: grid;
+ place-items: center;
+ width: 48px;
+ height: 48px;
+ border-radius: var(--market-radius-lg);
+ background: linear-gradient(135deg, var(--market-brand), var(--market-brand-soft));
+ box-shadow: 0 10px 24px rgba(21, 111, 78, 0.18);
+ color: #fff;
+ font-size: 1rem;
+ font-weight: 800;
+}
+
+.market-search {
+ display: grid;
+ gap: 8px;
+}
+
+.market-search__bar {
+ display: block;
+}
+
+.market-search__core {
+ position: relative;
+ display: grid;
+ grid-template-columns: auto auto minmax(0, 1fr);
+ align-items: center;
+ border: 2px solid var(--market-brand);
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
+}
+
+.market-search__picker-wrap {
+ position: relative;
+ min-width: 106px;
+}
+
+.market-search__picker-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 44px;
+ padding: 0 16px;
+ color: var(--market-brand);
+ font-size: 0.98rem;
+ font-weight: 500;
+}
+
+.market-search__picker-caret {
+ display: inline-block;
+ color: #8b98ae;
+ transition: transform 0.18s ease;
+}
+
+.market-search__picker-caret.is-open {
+ transform: rotate(180deg);
+}
+
+.market-search__picker-menu {
+ position: absolute;
+ left: -3px;
+ top: calc(100% + 8px);
+ z-index: 30;
+ display: grid;
+ min-width: 124px;
+ overflow: hidden;
+ border: 1px solid var(--market-brand-border);
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+}
+
+.market-search__picker-menu button {
+ min-height: 44px;
+ padding: 0 16px;
+ text-align: left;
+ color: var(--market-ink);
+ font-size: 0.94rem;
+ font-weight: 500;
+ background: #fff;
+ transition: 0.18s ease;
+}
+
+.market-search__picker-menu button + button {
+ border-top: 1px solid rgba(26, 38, 62, 0.08);
+}
+
+.market-search__picker-menu button.is-active,
+.market-search__picker-menu button:hover {
+ color: var(--market-brand);
+ background: var(--market-brand-bg);
+}
+
+.market-search__divider {
+ width: 1px;
+ height: 20px;
+ background: rgba(26, 38, 62, 0.14);
+}
+
+.market-search__field {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 0;
+ padding: 0;
+}
+
+.market-search__field input {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ min-height: 44px;
+ padding: 0 16px;
+ font-size: 0.94rem;
+ background: transparent;
+}
+
+.market-search__camera,
+.market-ghost-action,
+.market-primary-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--market-radius);
+ min-height: 40px;
+ padding: 0 15px;
+ font-size: 0.94rem;
+ font-weight: 500;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease;
+}
+
+.market-search__camera,
+.market-ghost-action {
+ border: 1px solid var(--market-brand-border);
+ background: #fff;
+ color: var(--market-brand);
+}
+
+.market-primary-action,
+.market-search__submit {
+ border: 1px solid var(--market-brand);
+ background: var(--market-brand);
+ color: #fff;
+ box-shadow: none;
+}
+
+.market-search__submit {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ border-radius: 0 var(--market-radius) var(--market-radius) 0;
+ min-height: 44px;
+ padding: 0 20px;
+ box-shadow: none;
+}
+
+.market-search__camera:hover,
+.market-ghost-action:hover,
+.market-primary-action:hover,
+.market-search__submit:hover {
+ box-shadow: none;
+}
+
+.market-search__camera:hover,
+.market-ghost-action:hover {
+ color: var(--market-brand-soft);
+ border-color: var(--market-brand-border-hover);
+ background: var(--market-brand-bg);
+}
+
+.market-primary-action:hover,
+.market-search__submit:hover {
+ border-color: var(--market-brand-soft);
+ background: var(--market-brand-soft);
+}
+
+.market-primary-action:active,
+.market-search__submit:active {
+ border-color: var(--market-brand-strong);
+ background: var(--market-brand-strong);
+}
+
+.market-search__camera {
+ width: 44px;
+ min-width: 44px;
+ min-height: 44px;
+ border: 0;
+ border-left: 1px solid rgba(26, 38, 62, 0.08);
+ border-radius: 0;
+ background: transparent;
+}
+
+.market-search__icon {
+ position: relative;
+ display: inline-block;
+ flex: 0 0 auto;
+}
+
+.market-search__icon--search {
+ width: 18px;
+ height: 18px;
+ border: 2px solid currentColor;
+ border-radius: 50%;
+}
+
+.market-search__icon--search::after {
+ content: "";
+ position: absolute;
+ right: -4px;
+ bottom: -4px;
+ width: 8px;
+ height: 2px;
+ border-radius: 999px;
+ background: currentColor;
+ transform: rotate(45deg);
+ transform-origin: center;
+}
+
+.market-search__icon--camera {
+ width: 18px;
+ height: 14px;
+ border: 2px solid currentColor;
+ border-radius: 4px;
+}
+
+.market-search__icon--camera::before {
+ content: "";
+ position: absolute;
+ left: 5px;
+ top: 3px;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.market-search__icon--camera::after {
+ content: "";
+ position: absolute;
+ left: 3px;
+ top: -5px;
+ width: 7px;
+ height: 4px;
+ border-radius: 4px 4px 0 0;
+ background: currentColor;
+}
+
+.market-search__hotwords {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.market-search__hotwords button,
+.market-meta-pills span {
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: var(--market-brand-bg);
+ color: var(--market-brand);
+ font-size: 0.8rem;
+}
+
+.market-search__hotwords button:hover {
+ background: var(--market-brand-bg-hover);
+}
+
+.market-header__actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.market-nav {
+ background: rgba(255, 255, 255, 0.9);
+ border-bottom: 1px solid var(--market-line);
+}
+
+.market-nav__inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 52px;
+}
+
+.market-nav__links {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.market-nav__links button {
+ border-radius: var(--market-radius);
+ padding: 6px 12px;
+ font-size: 0.94rem;
+ font-weight: 500;
+ color: var(--market-ink);
+}
+
+.market-nav__links button:hover {
+ color: var(--market-brand);
+ background: var(--market-brand-bg);
+}
+
+.market-nav__links button.is-active {
+ color: var(--market-brand);
+ font-weight: 700;
+ background: transparent;
+ box-shadow: inset 0 -2px 0 var(--market-brand);
+}
+
+.market-source-chip {
+ border-radius: 999px;
+ padding: 6px 12px;
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+
+.market-source-chip--api {
+ background: var(--market-brand-bg);
+ color: var(--market-brand);
+}
+
+.market-source-chip--empty {
+ background: rgba(250, 173, 20, 0.12);
+ color: #d48806;
+}
+
+.market-source-chip--error {
+ background: rgba(239, 72, 94, 0.12);
+ color: #cf1322;
+}
+
+.market-main {
+ padding: 16px 0 48px;
+}
+
+.market-stage {
+ display: grid;
+ grid-template-columns: 270px minmax(0, 1fr) 300px;
+ gap: 18px;
+ align-items: start;
+}
+
+.market-recent-panel,
+.market-sort-panel,
+.market-gallery-panel,
+.market-rank-panel,
+.market-section,
+.login-showcase,
+.login-card {
+ background: var(--market-surface);
+ border: 1px solid var(--market-line);
+ border-radius: var(--market-radius-xl);
+ box-shadow: var(--market-shadow);
+ backdrop-filter: blur(12px);
+}
+
+.market-recent-panel,
+.market-sort-panel,
+.market-rank-panel {
+ min-height: 480px;
+ padding: 14px;
+}
+
+.market-recent-panel {
+ display: flex;
+ flex-direction: column;
+ height: 480px;
+ min-height: 480px;
+ overflow: hidden;
+ background: #fff;
+}
+
+.market-sort-panel {
+ position: relative;
+ overflow: visible;
+}
+
+.market-recent-panel__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 2px 10px;
+}
+
+.market-recent-panel__head strong {
+ display: block;
+ color: #ef4444;
+ font-size: 0.92rem;
+ font-weight: 800;
+ line-height: 1.25;
+}
+
+.market-recent-panel__head span {
+ display: block;
+ margin-top: 4px;
+ color: var(--market-muted);
+ font-size: 0.78rem;
+}
+
+.market-recent-panel__refresh {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 24px;
+ padding: 0 2px;
+ color: #ef4444;
+ font-size: 0.78rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.market-recent-panel__refresh:hover {
+ color: #dc2626;
+}
+
+.market-refresh-icon {
+ position: relative;
+ display: inline-block;
+ width: 15px;
+ height: 15px;
+ border: 2px solid currentColor;
+ border-left-color: transparent;
+ border-radius: 50%;
+}
+
+.market-refresh-icon::after {
+ content: "";
+ position: absolute;
+ top: -3px;
+ right: -3px;
+ width: 6px;
+ height: 6px;
+ border-top: 2px solid currentColor;
+ border-right: 2px solid currentColor;
+ transform: rotate(35deg);
+}
+
+.market-recent-list {
+ flex: 1;
+ min-height: 0;
+ max-height: 420px;
+ overflow-y: auto;
+ padding: 0 4px 2px 0;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
+}
+
+.market-recent-list::-webkit-scrollbar {
+ width: 6px;
+}
+
+.market-recent-list::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: rgba(148, 163, 184, 0.45);
+}
+
+.market-recent-item {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 56px minmax(0, 1fr);
+ gap: 10px;
+ align-items: start;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(15, 23, 42, 0.1);
+ text-align: left;
+}
+
+.market-recent-item:first-child {
+ padding-top: 8px;
+}
+
+.market-recent-item:last-child {
+ border-bottom: 0;
+}
+
+.market-recent-item:hover strong {
+ color: #ef4444;
+}
+
+.market-recent-item img,
+.market-recent-item__placeholder {
+ width: 56px;
+ height: 56px;
+ border-radius: var(--market-radius-sm);
+ object-fit: cover;
+}
+
+.market-recent-item__placeholder {
+ display: grid;
+ place-items: center;
+ color: var(--market-muted);
+ background: rgba(248, 250, 252, 0.96);
+ border: 1px dashed rgba(5, 5, 5, 0.1);
+ font-size: 0.7rem;
+}
+
+.market-recent-item__body {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.market-recent-item__store {
+ display: -webkit-box;
+ overflow: hidden;
+ color: #20242d;
+ font-size: 0.86rem;
+ font-weight: 800;
+ line-height: 1.28;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+}
+
+.market-recent-item__body strong {
+ display: -webkit-box;
+ overflow: hidden;
+ color: #3f434a;
+ font-size: 0.82rem;
+ font-weight: 500;
+ line-height: 1.32;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.market-recent-item__body small {
+ color: #8f959e;
+ font-size: 0.78rem;
+}
+
+.market-empty-state--recent {
+ min-height: 390px;
+}
+
+.market-sort-panel__head,
+.market-rank-panel__head,
+.market-section__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.market-sort-panel__head strong,
+.market-rank-panel__head strong,
+.market-section__head h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.market-sort-panel__head span,
+.market-rank-panel__head span,
+.market-section__meta,
+.market-inline-note {
+ color: var(--market-muted);
+ font-size: 0.86rem;
+}
+
+.market-sort-panel__head {
+ padding: 4px 4px 14px;
+ border-bottom: 1px solid rgba(226, 232, 240, 0.85);
+}
+
+.market-sort-list {
+ display: grid;
+ gap: 0;
+ margin-top: 12px;
+}
+
+.market-sort-list button {
+ display: grid;
+ grid-template-columns: 26px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ min-height: 62px;
+ padding: 10px 8px;
+ border-radius: 0;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid rgba(226, 232, 240, 0.7);
+ transition: 0.18s ease;
+}
+
+.market-sort-list button:hover {
+ background: rgba(21, 111, 78, 0.05);
+}
+
+.market-sort-list button.is-active {
+ background: linear-gradient(90deg, rgba(21, 111, 78, 0.08), rgba(21, 111, 78, 0.02));
+ box-shadow: inset 3px 0 0 var(--market-brand);
+}
+
+.market-sort-list button:last-child {
+ border-bottom: 0;
+}
+
+.market-sort-list small {
+ color: var(--market-muted);
+ font-size: 0.82rem;
+}
+
+.market-sort-item__icon {
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 0;
+ background: transparent;
+ color: #1f2937;
+}
+
+.market-sort-item__icon svg {
+ width: 18px;
+ height: 18px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.7;
+}
+
+.market-sort-item__body {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+}
+
+.market-sort-item__body strong {
+ font-size: 0.98rem;
+ color: #111827;
+}
+
+.market-sort-item__arrow {
+ color: rgba(148, 163, 184, 0.95);
+ font-size: 1.15rem;
+ line-height: 1;
+}
+
+.market-category-mega {
+ position: absolute;
+ top: 12px;
+ left: calc(100% - 4px);
+ z-index: 18;
+ width: min(920px, calc(100vw - 380px));
+ min-height: 420px;
+ padding: 22px 24px;
+ border-radius: 24px;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ background: rgba(255, 255, 255, 0.98);
+ box-shadow: 0 26px 54px rgba(15, 23, 42, 0.16);
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transform: translateY(10px);
+ transition: 0.22s ease;
+}
+
+.market-sort-panel.is-open .market-category-mega {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: translateY(0);
+}
+
+.market-category-mega__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid rgba(226, 232, 240, 0.9);
+}
+
+.market-category-mega__head strong {
+ display: block;
+ margin: 0;
+ font-size: 1.18rem;
+ color: #0f172a;
+}
+
+.market-category-mega__head span {
+ display: block;
+ margin-top: 6px;
+ color: var(--market-muted);
+ font-size: 0.88rem;
+}
+
+.market-category-mega__action {
+ min-height: 34px;
+ padding: 0 14px;
+ border-radius: 999px;
+ border: 1px solid var(--market-brand-border);
+ background: var(--market-brand-bg);
+ color: var(--market-brand);
+ font-size: 0.84rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.market-category-mega__rows {
+ display: grid;
+ gap: 16px;
+ margin-top: 18px;
+}
+
+.market-category-mega__row {
+ display: grid;
+ grid-template-columns: 112px minmax(0, 1fr);
+ gap: 18px;
+ align-items: start;
+}
+
+.market-category-mega__row strong {
+ padding-top: 4px;
+ font-size: 1rem;
+ color: #111827;
+}
+
+.market-category-mega__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 18px;
+}
+
+.market-category-mega__chips button {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #4b5563;
+ font-size: 0.96rem;
+ line-height: 1.7;
+}
+
+.market-category-mega__chips button:hover {
+ color: var(--market-brand);
+}
+
+.market-gallery-panel {
+ display: grid;
+ gap: 14px;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+}
+
+.market-gallery-card {
+ position: relative;
+ min-height: 482px;
+ overflow: hidden;
+ border-radius: var(--market-radius-xl);
+ border: 1px solid rgba(26, 38, 62, 0.06);
+ box-shadow: var(--market-shadow);
+}
+
+.market-gallery-card img {
+ width: 100%;
+ height: 482px;
+ object-fit: cover;
+}
+
+.market-gallery-card__overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ padding: 24px;
+ background:
+ linear-gradient(180deg, rgba(17, 24, 39, 0.08), rgba(17, 24, 39, 0.62)),
+ linear-gradient(90deg, rgba(20, 32, 51, 0.54), rgba(20, 32, 51, 0.12));
+ color: #fff;
+}
+
+.market-gallery-card__overlay h1 {
+ margin: 10px 0;
+ font-size: clamp(1.6rem, 2.5vw, 2.3rem);
+ line-height: 1.14;
+}
+
+.market-gallery-card__overlay p:last-of-type {
+ max-width: 560px;
+ margin: 0;
+ color: rgba(255, 255, 255, 0.84);
+}
+
+.market-gallery-card__actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 18px;
+ flex-wrap: wrap;
+}
+
+.market-gallery-strip {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.market-gallery-strip > .market-empty-state {
+ grid-column: 1 / -1;
+}
+
+.market-spot-card,
+.market-platform-card,
+.market-store-card {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 249, 255, 0.94));
+ border: 1px solid rgba(26, 38, 62, 0.06);
+ box-shadow: 0 14px 34px rgba(17, 28, 45, 0.05);
+}
+
+.market-spot-card {
+ display: grid;
+ grid-template-columns: 88px minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+ padding: 12px;
+ border-radius: 18px;
+}
+
+.market-spot-card img {
+ width: 88px;
+ height: 88px;
+ border-radius: 16px;
+ object-fit: cover;
+}
+
+.market-spot-card span,
+.market-product-card__platform {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.76rem;
+ font-weight: 700;
+ color: var(--market-brand-strong);
+}
+
+.market-spot-card strong,
+.market-ranking-item strong,
+.market-store-card strong,
+.market-product-card strong,
+.login-showcase h1,
+.login-card__head h2 {
+ font-size: 0.98rem;
+}
+
+.market-spot-card p,
+.market-ranking-item p,
+.market-store-card p,
+.market-product-card p,
+.login-showcase p,
+.market-platform-card p {
+ margin: 4px 0 0;
+ color: var(--market-muted);
+}
+
+.market-rank-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px 16px;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)),
+ linear-gradient(180deg, rgba(21, 111, 78, 0.03), transparent 30%);
+}
+
+.market-rank-panel__head button {
+ color: var(--market-brand);
+ font-weight: 500;
+}
+
+.market-rank-panel__head {
+ align-items: flex-start;
+ gap: 14px;
+}
+
+.market-rank-panel__head strong {
+ font-size: 1.12rem;
+ line-height: 1.3;
+}
+
+.market-rank-panel__head span {
+ margin-top: 6px;
+ max-width: 190px;
+ line-height: 1.55;
+}
+
+.market-ranking-list {
+ display: grid;
+ gap: 8px;
+}
+
+.market-ranking-item {
+ display: grid;
+ align-items: center;
+ gap: 10px;
+ padding: 4px 0;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.market-ranking-item.is-podium {
+ grid-template-columns: 28px 42px minmax(0, 1fr);
+}
+
+.market-ranking-item.is-compact {
+ grid-template-columns: 28px minmax(0, 1fr);
+ min-height: 34px;
+}
+
+.market-rank-no {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 10px;
+ color: #fff;
+ font-size: 0.88rem;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.market-rank-no.is-order {
+ background: #ef4444;
+}
+
+.market-rank-no.is-top-1 {
+ background: linear-gradient(180deg, #f7c948, #e9a11d);
+}
+
+.market-rank-no.is-top-2 {
+ background: linear-gradient(180deg, #d5dde8, #97a4b2);
+}
+
+.market-rank-no.is-top-3 {
+ background: linear-gradient(180deg, #e6a66a, #c8793a);
+}
+
+.market-ranking-item img {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.market-ranking-item__avatar-fallback {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at top left, rgba(52, 145, 114, 0.26), transparent 45%),
+ linear-gradient(135deg, rgba(21, 111, 78, 0.12), rgba(21, 111, 78, 0.04));
+ color: var(--market-brand-strong);
+ font-size: 0.82rem;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ border: 1px solid rgba(21, 111, 78, 0.1);
+}
+
+.market-ranking-item__content {
+ min-width: 0;
+ padding-top: 1px;
+}
+
+.market-ranking-item__content strong {
+ display: block;
+ overflow: hidden;
+ font-size: 0.92rem;
+ line-height: 1.45;
+ color: #111827;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.market-inline-note {
+ margin: 8px 0 0;
+}
+
+.market-rank-page {
+ display: grid;
+ gap: 12px;
+}
+
+.market-rank-filter {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ min-height: 54px;
+ padding: 10px 12px;
+ border-radius: var(--market-radius);
+ background: #fff;
+}
+
+.market-rank-filter label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #111827;
+ font-weight: 600;
+}
+
+.market-rank-filter input,
+.market-rank-filter select {
+ height: 34px;
+ min-width: 172px;
+ border: 1px solid rgba(5, 5, 5, 0.12);
+ border-radius: var(--market-radius-sm);
+ background: #fff;
+ padding: 0 12px;
+ color: #111827;
+ font-weight: 400;
+}
+
+.market-rank-filter select {
+ min-width: 142px;
+}
+
+.market-rank-filter input:focus,
+.market-rank-filter select:focus {
+ outline: none;
+ border-color: var(--market-brand-border-hover);
+ box-shadow: 0 0 0 2px rgba(21, 111, 78, 0.1);
+}
+
+.market-rank-filter__submit,
+.market-rank-filter__reset {
+ height: 34px;
+ border-radius: var(--market-radius-sm);
+ padding: 0 18px;
+ font-weight: 600;
+}
+
+.market-rank-filter__submit {
+ background: var(--market-brand);
+ color: #fff;
+}
+
+.market-rank-filter__submit:hover {
+ background: var(--market-brand-strong);
+}
+
+.market-rank-filter__reset {
+ border: 1px solid rgba(5, 5, 5, 0.12);
+ background: #fff;
+ color: #111827;
+}
+
+.market-rank-board {
+ padding: 20px 16px;
+ border-radius: var(--market-radius);
+ background: #fff;
+}
+
+.market-rank-board__grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 22px 34px;
+}
+
+.market-rank-card {
+ display: grid;
+ grid-template-columns: 88px minmax(220px, 1fr) minmax(160px, 220px);
+ align-items: center;
+ gap: 18px;
+ min-height: 132px;
+}
+
+.market-rank-card__medal {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 78px;
+ height: 78px;
+ border-radius: 50%;
+ color: #fff;
+ box-shadow: inset 0 -8px 0 rgba(0, 0, 0, 0.08);
+}
+
+.market-rank-card__medal::after {
+ content: '';
+ position: absolute;
+ inset: -5px;
+ border-radius: 50%;
+ border: 3px solid rgba(255, 210, 96, 0.58);
+ box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08);
+}
+
+.market-rank-card__medal strong {
+ position: relative;
+ z-index: 1;
+ font-size: 2rem;
+ line-height: 1;
+}
+
+.market-rank-card__medal.is-rank-1 {
+ background: linear-gradient(180deg, #ffd829, #e9a11d);
+}
+
+.market-rank-card__medal.is-rank-2 {
+ background: linear-gradient(180deg, #2bd26a, #12a548);
+}
+
+.market-rank-card__medal.is-rank-3 {
+ background: linear-gradient(180deg, #ffbd36, #e38a25);
+}
+
+.market-rank-card__medal.is-rank-4 {
+ background: linear-gradient(180deg, #138bd6, #0769b3);
+}
+
+.market-rank-card__medal.is-rank-5 {
+ background: linear-gradient(180deg, #ff86aa, #ec5b82);
+}
+
+.market-rank-card__medal.is-rank-6 {
+ background: linear-gradient(180deg, #ff753a, #e84b17);
+}
+
+.market-rank-card__medal.is-rank-7 {
+ background: linear-gradient(180deg, #ff5f1f, #df2e0d);
+}
+
+.market-rank-card__medal.is-rank-8 {
+ background: linear-gradient(180deg, #d97ec8, #b555a8);
+}
+
+.market-rank-card__content {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.market-rank-card__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.market-rank-card__title strong {
+ overflow: hidden;
+ color: #333;
+ font-size: 1.08rem;
+ line-height: 1.35;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.market-rank-card__title span {
+ flex-shrink: 0;
+ border-radius: 999px;
+ padding: 2px 7px;
+ background: #fff4db;
+ color: #ff7a00;
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+.market-rank-card__stats {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ flex-wrap: wrap;
+ color: #111827;
+ font-size: 0.86rem;
+}
+
+.market-rank-card__stats span {
+ white-space: nowrap;
+}
+
+.market-rank-card__stats span:not(:first-child) {
+ color: #00b35a;
+}
+
+.market-rank-card__content p {
+ margin: 0;
+ overflow: hidden;
+ color: #111827;
+ line-height: 1.45;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.market-rank-card__actions {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+}
+
+.market-rank-card__actions a,
+.market-rank-card__actions button {
+ padding: 0;
+ color: #ef4444;
+ font-weight: 500;
+}
+
+.market-rank-card__previews {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ min-width: 0;
+}
+
+.market-rank-card__previews img {
+ width: 74px;
+ height: 74px;
+ border: 3px solid #eef1f4;
+ border-radius: 50%;
+ object-fit: cover;
+ background: #fff;
+}
+
+.market-rank-card__previews img + img {
+ margin-left: -18px;
+}
+
+.market-rank-card__preview-fallback {
+ display: grid;
+ place-items: center;
+ width: 92px;
+ height: 74px;
+ border-radius: 999px;
+ background: rgba(248, 250, 252, 0.96);
+ color: var(--market-muted);
+ font-size: 0.82rem;
+}
+
+.market-search-page {
+ display: grid;
+ grid-template-columns: 252px minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+}
+
+.market-search-sidebar,
+.market-search-attrs,
+.market-search-filters,
+.market-search-sortbar {
+ border-radius: var(--market-radius);
+ background: #fff;
+}
+
+.market-search-sidebar {
+ padding: 16px 14px;
+}
+
+.market-search-sidebar__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.market-search-sidebar__head strong {
+ color: #ef4444;
+ font-size: 1rem;
+}
+
+.market-search-sidebar__head button {
+ color: #ef4444;
+ font-weight: 500;
+}
+
+.market-search-ranking-list {
+ display: grid;
+ gap: 10px;
+}
+
+.market-search-ranking-item {
+ display: grid;
+ align-items: center;
+ gap: 10px;
+ min-height: 36px;
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.market-search-ranking-item.is-podium {
+ grid-template-columns: 28px 48px minmax(0, 1fr);
+}
+
+.market-search-ranking-item.is-compact {
+ grid-template-columns: 28px minmax(0, 1fr);
+}
+
+.market-search-rank-no {
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 7px;
+ color: #fff;
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.market-search-rank-no.is-order {
+ background: #ef4444;
+}
+
+.market-search-rank-no.is-top-1 {
+ background: linear-gradient(180deg, #f7c948, #e9a11d);
+}
+
+.market-search-rank-no.is-top-2 {
+ background: linear-gradient(180deg, #d5dde8, #97a4b2);
+}
+
+.market-search-rank-no.is-top-3 {
+ background: linear-gradient(180deg, #e6a66a, #c8793a);
+}
+
+.market-search-ranking-item img,
+.market-search-ranking-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+}
+
+.market-search-ranking-item img {
+ object-fit: cover;
+}
+
+.market-search-ranking-avatar {
+ display: grid;
+ place-items: center;
+ background: var(--market-brand-bg);
+ color: var(--market-brand-strong);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.market-search-ranking-item strong {
+ overflow: hidden;
+ color: #333;
+ font-size: 0.92rem;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.market-search-results {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+}
+
+.market-search-attrs {
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+}
+
+.market-search-attr-row {
+ display: grid;
+ grid-template-columns: 86px auto minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+}
+
+.market-search-attr-row:first-child {
+ grid-template-columns: 86px auto auto auto auto minmax(0, 1fr);
+ padding-bottom: 10px;
+ border-bottom: 1px dashed rgba(5, 5, 5, 0.22);
+}
+
+.market-search-attr-row strong {
+ color: #111827;
+}
+
+.market-search-attr-row span {
+ color: #111827;
+}
+
+.market-search-chip-group {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.market-search-chip-group--wrap {
+ flex-wrap: wrap;
+}
+
+.market-search-chip-group button {
+ padding: 0;
+ color: #111827;
+ white-space: nowrap;
+}
+
+.market-search-chip-group button.is-active,
+.market-search-chip-group button:hover {
+ color: #ef4444;
+}
+
+.market-search-filters {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ min-height: 82px;
+ padding: 12px;
+}
+
+.market-search-filters label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #111827;
+ font-weight: 600;
+}
+
+.market-search-filters input,
+.market-search-filters select {
+ height: 34px;
+ min-width: 126px;
+ border: 1px solid rgba(15, 23, 42, 0.12);
+ border-radius: var(--market-radius-sm);
+ background: #fff;
+ padding: 0 10px;
+ color: #111827;
+ font-weight: 400;
+}
+
+.market-search-price-field input {
+ width: 88px;
+ min-width: 88px;
+}
+
+.market-search-price-field i {
+ color: #9ca3af;
+ font-style: normal;
+}
+
+.market-search-filters input:focus,
+.market-search-filters select:focus {
+ outline: none;
+ border-color: var(--market-brand-border-hover);
+ box-shadow: 0 0 0 2px rgba(21, 111, 78, 0.1);
+}
+
+.market-search-filters__submit,
+.market-search-filters__reset,
+.market-search-batch-button {
+ height: 34px;
+ border-radius: var(--market-radius-sm);
+ padding: 0 18px;
+ font-weight: 600;
+}
+
+.market-search-filters__submit,
+.market-search-batch-button {
+ background: #ef4444;
+ color: #fff;
+}
+
+.market-search-filters__submit:hover,
+.market-search-batch-button:hover {
+ background: #dc2626;
+}
+
+.market-search-filters__reset {
+ border: 1px solid rgba(15, 23, 42, 0.12);
+ background: #fff;
+ color: #111827;
+}
+
+.market-search-sortbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 54px;
+ padding: 10px 12px;
+}
+
+.market-search-sortbar__left {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ flex-wrap: wrap;
+}
+
+.market-search-sortbar__left strong {
+ color: #ef4444;
+}
+
+.market-search-sortbar__left button {
+ color: #111827;
+ font-weight: 500;
+}
+
+.market-search-sortbar__left button.is-active,
+.market-search-sortbar__left button:hover {
+ color: var(--market-brand);
+}
+
+.market-search-product-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));
+ gap: 12px;
+}
+
+.market-section {
+ margin-top: 18px;
+ padding: 20px;
+}
+
+.market-section__head {
+ margin-bottom: 16px;
+}
+
+.market-section__toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.market-sort-tabs {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.market-sort-tabs button {
+ border: 1px solid rgba(5, 5, 5, 0.08);
+ border-radius: var(--market-radius);
+ background: #fff;
+ color: var(--market-muted);
+ padding: 6px 12px;
+ font-size: 0.86rem;
+ font-weight: 500;
+}
+
+.market-sort-tabs button:hover {
+ color: var(--market-brand);
+ border-color: var(--market-brand-border-hover);
+ background: var(--market-brand-bg);
+}
+
+.market-sort-tabs button.is-active {
+ color: var(--market-brand);
+ border-color: var(--market-brand-border);
+ background: var(--market-brand-bg);
+}
+
+.market-platform-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.market-platform-card {
+ display: grid;
+ gap: 6px;
+ padding: 14px;
+ border-radius: var(--market-radius-xl);
+}
+
+.market-platform-card strong {
+ font-size: 1rem;
+}
+
+.market-product-grid,
+.market-store-grid {
+ display: grid;
+ align-items: start;
+ justify-content: start;
+ gap: 12px;
+}
+
+.market-product-grid {
+ grid-template-columns: repeat(auto-fill, minmax(204px, 204px));
+}
+
+.market-product-card {
+ display: grid;
+ grid-template-rows: auto auto auto;
+ gap: 0;
+ overflow: hidden;
+ border-radius: var(--market-radius);
+ background: #fff;
+ border: 1px solid rgba(26, 38, 62, 0.06);
+ box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
+ transition: 0.2s ease;
+}
+
+.market-product-card:hover,
+.market-store-card:hover,
+.market-spot-card:hover,
+.market-platform-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 18px 42px rgba(17, 28, 45, 0.08);
+}
+
+.market-product-card__media {
+ position: relative;
+ overflow: hidden;
+ background: #f8fafc;
+}
+
+.market-product-card__media img {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: contain;
+}
+
+.market-product-card__media span {
+ position: absolute;
+ left: 8px;
+ top: 8px;
+ max-width: calc(100% - 16px);
+ overflow: hidden;
+ border-radius: 999px;
+ padding: 4px 8px;
+ background: rgba(20, 32, 51, 0.72);
+ color: #fff;
+ font-size: 0.7rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-product-card__center,
+.market-product-card__bottom {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ background: #fff;
+}
+
+.market-product-card__center {
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px 3px;
+}
+
+.market-product-card__bottom {
+ justify-content: space-between;
+ gap: 8px;
+ padding: 0 10px 10px;
+ color: rgba(14, 21, 19, 0.68);
+ font-size: 0.66rem;
+}
+
+.market-product-card__center strong,
+.market-product-card__bottom span,
+.market-store-card__body strong {
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-product-card__center strong {
+ color: #4b5563;
+ font-size: 0.8rem;
+ font-weight: 500;
+ line-height: 1.3;
+}
+
+.market-product-card__center span {
+ flex: 0 0 auto;
+ color: #ff0036;
+ font-size: 0.9rem;
+ font-weight: 800;
+ line-height: 1.2;
+}
+
+.market-product-card__bottom time {
+ flex: 0 0 auto;
+ color: rgba(14, 21, 19, 0.62);
+}
+
+.market-empty-state,
+.market-media-placeholder {
+ display: grid;
+ place-items: center;
+ text-align: center;
+ color: var(--market-muted);
+ background: rgba(248, 250, 252, 0.96);
+ border: 1px dashed rgba(5, 5, 5, 0.1);
+}
+
+.market-empty-state {
+ min-height: 180px;
+ padding: 24px;
+ border-radius: var(--market-radius-lg);
+}
+
+.market-empty-state--panel {
+ min-height: 220px;
+}
+
+.market-member-layout {
+ display: grid;
+ grid-template-columns: 210px minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.market-member-sidebar,
+.market-member-panel {
+ border: 1px solid var(--market-line);
+ border-radius: var(--market-radius-xl);
+ background: #fff;
+ box-shadow: var(--market-shadow);
+}
+
+.market-member-sidebar {
+ position: sticky;
+ top: 128px;
+ display: grid;
+ gap: 16px;
+ padding: 18px;
+}
+
+.market-member-sidebar > strong {
+ color: #111827;
+ font-size: 1.06rem;
+}
+
+.market-member-tabs {
+ display: grid;
+ gap: 8px;
+}
+
+.market-member-tabs button {
+ min-height: 40px;
+ padding: 0 12px;
+ border-radius: var(--market-radius);
+ color: #4b5563;
+ text-align: left;
+ font-weight: 600;
+}
+
+.market-member-tabs button:hover,
+.market-member-tabs button.is-active {
+ color: var(--market-brand);
+ background: var(--market-brand-bg);
+}
+
+.market-member-panel {
+ min-height: 560px;
+ padding: 20px;
+}
+
+.market-member-panel__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+.market-member-panel__head h1 {
+ margin: 2px 0 0;
+ color: #111827;
+ font-size: 1.28rem;
+}
+
+.market-member-summary {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 12px;
+ color: var(--market-muted);
+}
+
+.market-member-summary strong {
+ color: #ef4444;
+ font-size: 1.2rem;
+}
+
+.market-member-login-link {
+ display: inline-flex;
+ justify-content: center;
+ min-width: 92px;
+ margin-top: 12px;
+ padding: 8px 14px;
+ border-radius: var(--market-radius);
+ background: var(--market-brand);
+ color: #fff;
+ font-weight: 700;
+}
+
+.market-cart-list,
+.market-order-list {
+ display: grid;
+ gap: 12px;
+}
+
+.market-cart-item {
+ display: grid;
+ grid-template-columns: auto 86px minmax(0, 1fr) 96px 112px 104px auto;
+ gap: 14px;
+ align-items: center;
+ padding: 14px;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: var(--market-radius-lg);
+ background: #fff;
+}
+
+.market-cart-item > input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--market-brand);
+}
+
+.market-cart-item__media img,
+.market-cart-item__media .market-media-placeholder {
+ width: 86px;
+ height: 86px;
+ border-radius: var(--market-radius);
+ object-fit: cover;
+}
+
+.market-cart-item__body {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.market-cart-item__body a {
+ overflow: hidden;
+ color: #111827;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-cart-item__body span,
+.market-cart-item__body small {
+ overflow: hidden;
+ color: var(--market-muted);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-cart-item > strong {
+ color: #0f5138;
+}
+
+.market-cart-item__quantity {
+ display: grid;
+ grid-template-columns: 30px 40px 30px;
+ align-items: center;
+ overflow: hidden;
+ border: 1px solid rgba(15, 23, 42, 0.12);
+ border-radius: var(--market-radius);
+}
+
+.market-cart-item__quantity button,
+.market-cart-item__quantity span {
+ display: grid;
+ place-items: center;
+ min-height: 32px;
+}
+
+.market-cart-item__quantity span {
+ border-inline: 1px solid rgba(15, 23, 42, 0.08);
+}
+
+.market-cart-item > button {
+ color: #ef4444;
+ font-weight: 700;
+}
+
+.market-order-card {
+ display: grid;
+ gap: 14px;
+ padding: 16px;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: var(--market-radius-lg);
+ background: #fff;
+}
+
+.market-order-card__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.market-order-card__head div {
+ display: grid;
+ gap: 4px;
+}
+
+.market-order-card__head strong {
+ color: #111827;
+}
+
+.market-order-card__head span {
+ color: var(--market-muted);
+}
+
+.market-order-status {
+ display: inline-flex;
+ align-items: center;
+ min-height: 28px;
+ padding: 0 10px;
+ border-radius: 999px;
+ background: var(--market-brand-bg);
+ color: var(--market-brand) !important;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.market-order-card__body {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.market-order-card__body article {
+ display: grid;
+ gap: 4px;
+ padding: 12px;
+ border-radius: var(--market-radius);
+ background: rgba(248, 250, 252, 0.9);
+}
+
+.market-order-card__body span {
+ color: var(--market-muted);
+ font-size: 0.82rem;
+}
+
+.market-order-card__body strong {
+ color: #111827;
+}
+
+.market-media-placeholder {
+ min-height: 100%;
+ border-radius: 14px;
+ font-size: 0.86rem;
+}
+
+.market-media-placeholder--banner {
+ min-height: 482px;
+ border-radius: var(--market-radius-xl);
+}
+
+.market-media-placeholder--square,
+.market-media-placeholder--store {
+ width: 88px;
+ height: 88px;
+}
+
+.market-media-placeholder--product {
+ aspect-ratio: 1 / 1;
+ width: 100%;
+}
+
+.market-store-grid {
+ grid-template-columns: repeat(auto-fill, minmax(210px, 210px));
+ gap: 12px;
+}
+
+.market-store-card {
+ display: grid;
+ gap: 10px;
+ padding: 10px;
+ border-radius: var(--market-radius-lg);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(246, 250, 255, 0.96));
+ border: 1px solid rgba(15, 23, 42, 0.06);
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
+}
+
+.market-store-card__hero {
+ position: relative;
+ display: grid;
+ place-items: center;
+ min-height: 108px;
+ padding: 16px 12px 12px;
+ border-radius: var(--market-radius-lg);
+ overflow: hidden;
+ background:
+ radial-gradient(circle at top center, rgba(21, 111, 78, 0.16), transparent 34%),
+ radial-gradient(circle at center 78%, rgba(15, 23, 42, 0.12), transparent 42%),
+ linear-gradient(180deg, rgba(249, 252, 250, 0.98), rgba(237, 244, 240, 0.95));
+}
+
+.market-store-card__hero::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ bottom: 8px;
+ width: 76px;
+ height: 20px;
+ transform: translateX(-50%);
+ border-radius: 999px;
+ background: rgba(15, 23, 42, 0.12);
+ filter: blur(12px);
+}
+
+.market-store-card__badge {
+ position: absolute;
+ left: 10px;
+ top: 10px;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ min-height: 22px;
+ padding: 0 8px;
+ border-radius: 999px;
+ background: rgba(21, 111, 78, 0.1);
+ color: var(--market-brand-strong);
+ font-size: 0.7rem;
+ font-weight: 700;
+}
+
+.market-store-card__logo-shell {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ place-items: center;
+ width: 72px;
+ height: 72px;
+ padding: 5px;
+ border-radius: 50%;
+ background: linear-gradient(180deg, #fff, rgba(250, 252, 255, 0.94));
+ border: 1px solid rgba(21, 111, 78, 0.1);
+ box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
+}
+
+.market-store-card__logo-shell img,
+.market-store-card__avatar-fallback {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.market-store-card__avatar-fallback {
+ display: grid;
+ place-items: center;
+ background:
+ radial-gradient(circle at top left, rgba(52, 145, 114, 0.28), transparent 45%),
+ linear-gradient(135deg, rgba(21, 111, 78, 0.18), rgba(21, 111, 78, 0.06));
+ color: var(--market-brand-strong);
+ font-size: 1rem;
+ font-weight: 800;
+ letter-spacing: 0.03em;
+}
+
+.market-store-card__body {
+ display: grid;
+ justify-items: center;
+ gap: 7px;
+ min-width: 0;
+ text-align: center;
+ padding: 0 2px;
+}
+
+.market-store-card__body strong {
+ font-size: 0.98rem;
+ line-height: 1.28;
+ color: #111827;
+}
+
+.market-store-card__reason {
+ margin: 0;
+ color: var(--market-brand-strong);
+ font-size: 0.76rem;
+ font-weight: 600;
+}
+
+.market-store-card__stats {
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ padding: 8px 0;
+ border-top: 1px solid rgba(15, 23, 42, 0.06);
+ border-bottom: 1px solid rgba(15, 23, 42, 0.06);
+}
+
+.market-store-card__stats article {
+ display: grid;
+ gap: 3px;
+ justify-items: center;
+ padding: 0 4px;
+}
+
+.market-store-card__stats article + article {
+ border-left: 1px solid rgba(15, 23, 42, 0.06);
+}
+
+.market-store-card__stats strong {
+ font-size: 0.9rem;
+ color: var(--market-brand-strong);
+}
+
+.market-store-card__stats span {
+ color: #667085;
+ font-size: 0.7rem;
+ line-height: 1.25;
+}
+
+.market-store-card__business {
+ margin: 0;
+ color: #667085;
+ font-size: 0.76rem;
+ line-height: 1.5;
+}
+
+.market-header__inner--detail {
+ grid-template-columns: 220px minmax(0, 1fr);
+ min-height: 72px;
+}
+
+.market-detail-breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ color: var(--market-muted);
+ font-size: 0.86rem;
+}
+
+.market-detail-breadcrumb a {
+ color: var(--market-brand);
+ font-weight: 600;
+}
+
+.market-detail-breadcrumb strong {
+ min-width: 0;
+ overflow: hidden;
+ color: #111827;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-detail-main {
+ padding-top: 18px;
+}
+
+.market-detail-layout {
+ display: grid;
+ grid-template-columns: 420px minmax(0, 1fr) 260px;
+ gap: 16px;
+ align-items: start;
+ padding: 16px;
+ border: 1px solid var(--market-line);
+ border-radius: var(--market-radius-xl);
+ background: #fff;
+ box-shadow: var(--market-shadow);
+}
+
+.market-detail-gallery,
+.market-detail-info,
+.market-detail-merchant {
+ min-width: 0;
+}
+
+.market-detail-gallery__stage {
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ aspect-ratio: 1 / 1;
+ border-radius: var(--market-radius);
+ background: #f5f7fa;
+}
+
+.market-detail-gallery__stage img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.market-detail-thumbs {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.market-detail-thumbs button {
+ overflow: hidden;
+ aspect-ratio: 1 / 1;
+ border: 2px solid transparent;
+ border-radius: var(--market-radius);
+ background: #f8fafc;
+}
+
+.market-detail-thumbs button.is-active {
+ border-color: #ef4444;
+}
+
+.market-detail-thumbs img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.market-detail-gallery__stats {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ margin-top: 12px;
+ color: #111827;
+ font-size: 0.86rem;
+ flex-wrap: wrap;
+}
+
+.market-detail-info {
+ display: grid;
+ gap: 14px;
+}
+
+.market-detail-title h1 {
+ margin: 0;
+ color: #111827;
+ font-size: 1.2rem;
+ line-height: 1.35;
+}
+
+.market-detail-title p {
+ margin: 6px 0 0;
+ color: #111827;
+ font-weight: 600;
+}
+
+.market-detail-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px 18px;
+ padding: 14px;
+ border-radius: var(--market-radius);
+ background: #f5f6f8;
+}
+
+.market-detail-meta article {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.market-detail-meta span,
+.market-detail-line span,
+.market-detail-services span,
+.market-detail-price span {
+ flex: 0 0 auto;
+ color: #111827;
+ font-weight: 700;
+}
+
+.market-detail-meta strong,
+.market-detail-line strong {
+ min-width: 0;
+ overflow: hidden;
+ color: #111827;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-detail-line,
+.market-detail-services,
+.market-detail-price {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.market-detail-line strong {
+ color: #ef4444;
+}
+
+.market-detail-services strong {
+ padding: 2px 7px;
+ border-radius: var(--market-radius-xs);
+ background: #ff9f43;
+ color: #fff;
+ font-size: 0.82rem;
+}
+
+.market-detail-price strong {
+ color: #ff0036;
+ font-size: 1.5rem;
+ line-height: 1;
+}
+
+.market-detail-price em {
+ color: #ff0036;
+ font-style: normal;
+}
+
+.market-detail-spec-group {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 10px;
+ align-items: start;
+}
+
+.market-detail-spec-group > span {
+ padding-top: 8px;
+ color: #111827;
+ font-weight: 700;
+}
+
+.market-detail-color-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(72px, 72px));
+ gap: 10px 12px;
+}
+
+.market-detail-color-grid button {
+ display: grid;
+ gap: 5px;
+ justify-items: center;
+ min-width: 0;
+ color: #111827;
+}
+
+.market-detail-color-grid img {
+ width: 72px;
+ height: 72px;
+ object-fit: cover;
+ border: 2px solid rgba(15, 23, 42, 0.16);
+ border-radius: var(--market-radius);
+ background: #f8fafc;
+}
+
+.market-detail-color-grid button.is-active img {
+ border-color: #ef4444;
+}
+
+.market-detail-color-grid strong {
+ max-width: 72px;
+ overflow: hidden;
+ color: #111827;
+ font-size: 0.78rem;
+ font-weight: 600;
+ line-height: 1.2;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-detail-color-grid button.is-active strong {
+ color: #ef4444;
+}
+
+.market-detail-sku-list {
+ display: grid;
+ gap: 8px;
+ max-height: 420px;
+ overflow: auto;
+ padding-right: 4px;
+}
+
+.market-detail-sku {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 80px 70px 120px;
+ align-items: center;
+ gap: 10px;
+ min-height: 48px;
+ padding: 8px 10px;
+ border-radius: var(--market-radius);
+ background: #f5f6f8;
+}
+
+.market-detail-sku span {
+ min-width: 0;
+ overflow: hidden;
+ color: #111827;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-detail-sku strong {
+ color: #ff0036;
+ text-align: right;
+}
+
+.market-detail-sku small {
+ color: var(--market-muted);
+}
+
+.market-detail-sku div {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr) 28px;
+ align-items: center;
+ gap: 6px;
+}
+
+.market-detail-sku button {
+ display: grid;
+ place-items: center;
+ height: 28px;
+ border-radius: var(--market-radius-xs);
+ background: #9ca3af;
+ color: #fff;
+ font-size: 1.1rem;
+ font-weight: 800;
+}
+
+.market-detail-sku button:last-child {
+ background: #ef4444;
+}
+
+.market-detail-sku b {
+ display: grid;
+ place-items: center;
+ height: 30px;
+ border-radius: var(--market-radius-xs);
+ background: #fff;
+ color: #111827;
+ font-weight: 500;
+}
+
+.market-detail-merchant {
+ display: grid;
+ gap: 14px;
+ justify-items: center;
+ padding: 16px;
+ border-radius: var(--market-radius);
+ background: #f5f6f8;
+ text-align: center;
+}
+
+.market-detail-merchant__logo {
+ display: grid;
+ place-items: center;
+ width: 112px;
+ height: 112px;
+ overflow: hidden;
+ border-radius: 50%;
+ background: #ef4444;
+ color: #fff;
+ box-shadow: inset 0 0 0 4px rgba(245, 158, 11, 0.8);
+ font-size: 2.7rem;
+ font-weight: 800;
+}
+
+.market-detail-merchant__logo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.market-detail-merchant h2 {
+ margin: 0;
+ color: #111827;
+ font-size: 1rem;
+}
+
+.market-detail-merchant__metrics {
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.market-detail-merchant__metrics article {
+ display: grid;
+ gap: 4px;
+}
+
+.market-detail-merchant__metrics strong {
+ color: #f59e0b;
+ font-size: 1rem;
+}
+
+.market-detail-merchant__metrics span,
+.market-detail-merchant p {
+ color: #4b5563;
+ font-size: 0.82rem;
+}
+
+.market-detail-merchant p {
+ margin: 0;
+}
+
+.market-detail-merchant button {
+ width: 100%;
+ min-height: 36px;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: var(--market-radius);
+ background: #fff;
+ color: #111827;
+ font-weight: 600;
+}
+
+.market-detail-merchant button:hover {
+ border-color: var(--market-brand-border);
+ color: var(--market-brand);
+}
+
+.market-detail-merchant[role='button'] {
+ cursor: pointer;
+}
+
+.market-detail-content-panel {
+ overflow: hidden;
+ border: 1px solid var(--market-line);
+ border-radius: 26px;
+ background: #fff;
+ box-shadow: var(--market-shadow);
+}
+
+.market-detail-content-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ padding: 0;
+ background: #fff;
+}
+
+.market-detail-content-tabs button {
+ display: grid;
+ place-items: center;
+ min-height: 58px;
+ border-radius: 0;
+ background: #fff;
+ color: #111827;
+ font-size: 1.05rem;
+ font-weight: 800;
+}
+
+.market-detail-content-tabs button.is-active {
+ border-radius: 8px;
+ background: #ef4444;
+ color: #fff;
+}
+
+.market-detail-content-body {
+ display: grid;
+ gap: 22px;
+ padding: 18px 0 34px;
+ background: #f5f6f8;
+}
+
+.market-detail-param-row {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 18px 28px;
+ padding: 20px 28px;
+ border-radius: 14px;
+ background: #fff;
+ color: #111827;
+}
+
+.market-detail-param-row article {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.market-detail-param-row strong {
+ flex: 0 0 auto;
+ font-weight: 800;
+}
+
+.market-detail-param-row span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.market-detail-text-blocks,
+.market-detail-dropship-list {
+ display: grid;
+ gap: 12px;
+ padding: 0 28px;
+}
+
+.market-detail-text-blocks p,
+.market-detail-dropship-list article {
+ margin: 0;
+ padding: 16px 18px;
+ border-radius: 14px;
+ background: #fff;
+ color: #111827;
+ line-height: 1.7;
+}
+
+.market-detail-dropship-list article {
+ display: grid;
+ gap: 6px;
+}
+
+.market-detail-dropship-list strong {
+ font-size: 1rem;
+}
+
+.market-detail-dropship-list span {
+ color: var(--market-muted);
+}
+
+.market-detail-long-images {
+ display: grid;
+ justify-items: center;
+ gap: 0;
+ padding: 0 28px;
+}
+
+.market-detail-long-images img {
+ display: block;
+ width: min(100%, 1080px);
+ height: auto;
+ background: #fff;
+ object-fit: contain;
+}
+
+.market-shop-layout {
+ display: grid;
+ grid-template-columns: 250px minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.market-shop-sidebar,
+.market-shop-content,
+.market-shop-filter,
+.market-shop-search,
+.market-shop-sort {
+ border: 1px solid var(--market-line);
+ border-radius: var(--market-radius-lg);
+ background: #fff;
+ box-shadow: var(--market-shadow);
+}
+
+.market-shop-sidebar {
+ display: grid;
+ gap: 14px;
+ justify-items: center;
+ padding: 18px;
+ text-align: center;
+}
+
+.market-shop-logo {
+ display: grid;
+ place-items: center;
+ width: 140px;
+ height: 140px;
+ overflow: hidden;
+ border-radius: 50%;
+ background: #ef4444;
+ color: #fff;
+ box-shadow: inset 0 0 0 4px rgba(245, 158, 11, 0.82);
+ font-size: 2.8rem;
+ font-weight: 800;
+}
+
+.market-shop-logo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.market-shop-sidebar h1 {
+ margin: 0;
+ color: #111827;
+ font-size: 1.08rem;
+}
+
+.market-shop-metrics {
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px 8px;
+}
+
+.market-shop-metrics article {
+ display: grid;
+ gap: 4px;
+}
+
+.market-shop-metrics strong {
+ color: #f59e0b;
+ font-size: 1rem;
+}
+
+.market-shop-metrics span,
+.market-shop-sidebar p {
+ color: #4b5563;
+ font-size: 0.82rem;
+}
+
+.market-shop-sidebar p {
+ margin: 0;
+ line-height: 1.6;
+}
+
+.market-shop-content {
+ display: grid;
+ gap: 12px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.market-shop-filter,
+.market-shop-search,
+.market-shop-sort {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-height: 58px;
+ padding: 12px;
+ flex-wrap: wrap;
+ box-shadow: none;
+}
+
+.market-shop-filter strong,
+.market-shop-sort strong {
+ color: #111827;
+}
+
+.market-shop-filter span {
+ color: #ef4444;
+ font-weight: 600;
+}
+
+.market-shop-search label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #111827;
+ font-weight: 600;
+}
+
+.market-shop-search input {
+ width: 160px;
+ min-height: 34px;
+ border: 1px solid rgba(15, 23, 42, 0.12);
+ border-radius: var(--market-radius);
+ padding: 0 10px;
+}
+
+.market-shop-search button,
+.market-shop-sort button {
+ min-height: 34px;
+ padding: 0 18px;
+ border: 1px solid rgba(15, 23, 42, 0.1);
+ border-radius: var(--market-radius);
+ background: #fff;
+ color: #111827;
+ font-weight: 600;
+}
+
+.market-shop-search button[type='submit'],
+.market-shop-sort button.is-active {
+ border-color: #ef4444;
+ background: #ef4444;
+ color: #fff;
+}
+
+.market-shop-sort span {
+ margin-left: auto;
+ color: var(--market-muted);
+}
+
+.market-shop-product-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(204px, 204px));
+ gap: 12px;
+ align-items: start;
+}
+
+.market-footer {
+ border-top: 1px solid rgba(26, 38, 62, 0.06);
+ background: rgba(255, 255, 255, 0.72);
+}
+
+.market-footer__inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 20px 0 36px;
+ color: var(--market-muted);
+}
+
+.market-footer__inner strong {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--market-ink);
+}
+
+.market-footer__inner p {
+ margin: 0;
+ max-width: 620px;
+}
+
+.market-footer__inner div:last-child {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.login-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ padding: 28px 0;
+}
+
+.login-page--with-market-header {
+ min-height: auto;
+ padding: 42px 0 56px;
+}
+
+.login-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.15fr) minmax(380px, 480px);
+ gap: 18px;
+ align-items: stretch;
+}
+
+.login-showcase {
+ padding: 30px;
+ background:
+ radial-gradient(circle at top right, rgba(255, 255, 255, 0.16), transparent 24%),
+ linear-gradient(145deg, #103226, #156f4e 58%, #349172);
+ color: rgba(255, 255, 255, 0.84);
+}
+
+.login-showcase h1 {
+ margin: 14px 0 10px;
+ font-size: clamp(1.8rem, 3.4vw, 2.6rem);
+ line-height: 1.18;
+ color: #fff;
+}
+
+.login-back {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 30px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.login-feature-list {
+ display: grid;
+ gap: 12px;
+ margin-top: 30px;
+}
+
+.login-feature-list article {
+ padding: 14px 16px;
+ border-radius: var(--market-radius-xl);
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+.login-feature-list strong,
+.login-feature-list span {
+ display: block;
+}
+
+.login-feature-list span {
+ margin-top: 6px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.login-card {
+ padding: 28px;
+}
+
+.login-auth-tabs {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin-bottom: 18px;
+ padding: 6px;
+ border-radius: var(--market-radius-lg);
+ background: rgba(15, 23, 42, 0.04);
+}
+
+.login-auth-tabs a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ border-radius: var(--market-radius);
+ font-size: 0.94rem;
+ font-weight: 500;
+ color: var(--market-muted);
+}
+
+.login-auth-tabs a.is-active {
+ background: #fff;
+ color: var(--market-brand);
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
+}
+
+.login-card__head h2 {
+ margin: 10px 0 0;
+ font-size: 1.5rem;
+}
+
+.login-mode-tabs {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin-top: 24px;
+ padding: 6px;
+ border-radius: var(--market-radius-lg);
+ background: rgba(15, 23, 42, 0.04);
+}
+
+.login-mode-tabs button {
+ min-height: 40px;
+ border-radius: var(--market-radius);
+ font-size: 0.94rem;
+ font-weight: 500;
+ color: var(--market-muted);
+}
+
+.login-mode-tabs button.is-active {
+ background: #fff;
+ color: var(--market-brand);
+ box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
+}
+
+.login-form {
+ display: grid;
+ gap: 14px;
+ margin-top: 20px;
+}
+
+.login-form__hint {
+ margin: 18px 0 0;
+ font-size: 0.9rem;
+ color: var(--market-muted);
+}
+
+.login-form label {
+ display: grid;
+ gap: 10px;
+}
+
+.login-form span {
+ font-size: 0.88rem;
+ font-weight: 500;
+}
+
+.login-form input {
+ width: 100%;
+ min-height: 42px;
+ border-radius: var(--market-radius);
+ border: 1px solid rgba(5, 5, 5, 0.1);
+ background: #fff;
+ padding: 0 16px;
+}
+
+.login-form input:focus {
+ border-color: var(--market-brand-border-hover);
+ box-shadow: 0 0 0 2px rgba(21, 111, 78, 0.12);
+}
+
+.login-inline-field {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+}
+
+.login-submit {
+ width: 100%;
+ margin-top: 8px;
+}
+
+.login-card__footer {
+ margin: 18px 0 0;
+ font-size: 0.92rem;
+ color: var(--market-muted);
+}
+
+.login-card__footer a {
+ margin-left: 6px;
+ color: var(--market-brand);
+ font-weight: 500;
+}
+
+@media (max-width: 1180px) {
+ .market-header__inner {
+ grid-template-columns: 1fr;
+ justify-items: stretch;
+ padding: 18px 0 22px;
+ }
+
+ .market-header__actions {
+ justify-content: flex-start;
+ }
+
+ .market-stage,
+ .market-search-page,
+ .market-member-layout,
+ .market-detail-layout,
+ .market-shop-layout,
+ .market-platform-grid,
+ .login-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .market-member-sidebar {
+ position: static;
+ }
+
+ .market-member-tabs {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .market-member-tabs button {
+ text-align: center;
+ }
+
+ .market-cart-item {
+ grid-template-columns: auto 72px minmax(0, 1fr);
+ }
+
+ .market-cart-item > strong,
+ .market-cart-item__quantity,
+ .market-cart-item > button {
+ grid-column: 3;
+ justify-self: start;
+ }
+
+ .market-order-card__body {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .market-product-grid {
+ grid-template-columns: repeat(auto-fill, minmax(204px, 204px));
+ }
+
+ .market-rank-board__grid {
+ grid-template-columns: 1fr;
+ }
+
+ .market-search-product-grid {
+ grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));
+ }
+
+ .market-detail-param-row {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .market-store-grid {
+ grid-template-columns: repeat(auto-fill, minmax(210px, 210px));
+ }
+
+ .market-footer__inner {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .market-category-mega {
+ position: static;
+ width: 100%;
+ min-height: 0;
+ margin-top: 14px;
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: none;
+ display: none;
+ }
+
+ .market-sort-panel.is-open .market-category-mega {
+ display: block;
+ }
+}
+
+@media (max-width: 760px) {
+ .shell {
+ width: min(100% - 20px, 1320px);
+ }
+
+ .market-topbar__inner,
+ .market-nav__inner,
+ .market-nav__links,
+ .market-header__actions,
+ .market-gallery-card__actions {
+ align-items: flex-start;
+ }
+
+ .market-topbar__inner,
+ .market-nav__inner,
+ .market-member-panel__head,
+ .market-footer__inner {
+ flex-direction: column;
+ }
+
+ .market-search__field,
+ .login-inline-field {
+ grid-template-columns: 1fr;
+ }
+
+ .market-member-tabs,
+ .market-order-card__body {
+ grid-template-columns: 1fr;
+ }
+
+ .market-member-panel {
+ padding: 16px;
+ }
+
+ .market-cart-item {
+ grid-template-columns: auto 64px minmax(0, 1fr);
+ gap: 10px;
+ padding: 12px;
+ }
+
+ .market-cart-item__media img,
+ .market-cart-item__media .market-media-placeholder {
+ width: 64px;
+ height: 64px;
+ }
+
+ .market-search__core {
+ grid-template-columns: 1fr;
+ }
+
+ .market-search__picker-wrap {
+ width: 100%;
+ }
+
+ .market-search__picker-trigger {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .market-search__picker-menu {
+ left: 0;
+ right: 0;
+ min-width: 0;
+ }
+
+ .market-search__divider {
+ display: none;
+ }
+
+ .market-search__field {
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ padding: 0;
+ }
+
+ .market-gallery-strip {
+ grid-template-columns: 1fr;
+ }
+
+ .market-section,
+ .market-detail-layout,
+ .market-recent-panel,
+ .market-sort-panel,
+ .market-rank-panel,
+ .login-showcase,
+ .login-card {
+ padding: 18px;
+ border-radius: var(--market-radius-xl);
+ }
+
+ .market-header__inner--detail {
+ grid-template-columns: 1fr;
+ }
+
+ .market-detail-gallery__stage {
+ max-height: 560px;
+ }
+
+ .market-detail-meta,
+ .market-detail-sku {
+ grid-template-columns: 1fr;
+ }
+
+ .market-detail-spec-group {
+ grid-template-columns: 1fr;
+ }
+
+ .market-detail-color-grid {
+ grid-template-columns: repeat(auto-fill, minmax(64px, 64px));
+ }
+
+ .market-detail-color-grid img {
+ width: 64px;
+ height: 64px;
+ }
+
+ .market-detail-color-grid strong {
+ max-width: 64px;
+ }
+
+ .market-detail-sku strong {
+ text-align: left;
+ }
+
+ .market-detail-sku div {
+ max-width: 140px;
+ }
+
+ .market-detail-content-tabs {
+ grid-template-columns: 1fr;
+ }
+
+ .market-detail-content-tabs button,
+ .market-detail-content-tabs button.is-active {
+ min-height: 48px;
+ border-radius: 0;
+ font-size: 0.96rem;
+ }
+
+ .market-detail-content-body {
+ padding: 14px 0 22px;
+ }
+
+ .market-detail-param-row {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ padding: 16px;
+ }
+
+ .market-detail-param-row article {
+ font-size: 0.92rem;
+ }
+
+ .market-detail-text-blocks,
+ .market-detail-dropship-list,
+ .market-detail-long-images {
+ padding: 0 16px;
+ }
+
+ .market-product-grid {
+ grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
+ }
+
+ .market-shop-product-grid {
+ grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
+ }
+
+ .market-store-grid {
+ grid-template-columns: repeat(auto-fill, minmax(190px, 210px));
+ }
+
+ .market-search-attr-row,
+ .market-search-attr-row:first-child {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+
+ .market-search-filters label,
+ .market-search-filters input,
+ .market-search-filters select {
+ width: 100%;
+ }
+
+ .market-search-price-field {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .market-search-price-field input {
+ width: 100%;
+ }
+
+ .market-search-sortbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .market-search-batch-button {
+ width: 100%;
+ }
+
+ .market-rank-filter label,
+ .market-rank-filter input,
+ .market-rank-filter select {
+ width: 100%;
+ }
+
+ .market-rank-card {
+ grid-template-columns: 64px minmax(0, 1fr);
+ gap: 14px;
+ }
+
+ .market-rank-card__medal {
+ width: 58px;
+ height: 58px;
+ }
+
+ .market-rank-card__medal strong {
+ font-size: 1.5rem;
+ }
+
+ .market-rank-card__previews {
+ grid-column: 1 / -1;
+ justify-content: flex-start;
+ padding-left: 72px;
+ }
+
+ .market-rank-card__previews img {
+ width: 58px;
+ height: 58px;
+ }
+
+ .market-shop-filter,
+ .market-shop-search,
+ .market-shop-sort {
+ align-items: flex-start;
+ }
+
+ .market-shop-search label,
+ .market-shop-search input {
+ width: 100%;
+ }
+
+ .market-shop-sort span {
+ width: 100%;
+ margin-left: 0;
+ }
+
+ .market-sort-list button {
+ grid-template-columns: 34px minmax(0, 1fr);
+ }
+
+ .market-sort-item__arrow {
+ display: none;
+ }
+
+ .market-category-mega__head {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .market-category-mega__row {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+
+ .market-spot-card {
+ grid-template-columns: 72px minmax(0, 1fr);
+ }
+
+ .market-spot-card img {
+ width: 72px;
+ height: 72px;
+ }
+
+ .market-ranking-item.is-podium {
+ grid-template-columns: 28px 40px minmax(0, 1fr);
+ }
+
+ .market-ranking-item img {
+ width: 40px;
+ height: 40px;
+ }
+
+ .market-ranking-item__avatar-fallback {
+ width: 40px;
+ height: 40px;
+ font-size: 0.78rem;
+ }
+
+ .market-recent-item {
+ grid-template-columns: 72px minmax(0, 1fr);
+ gap: 12px;
+ }
+
+ .market-recent-item img,
+ .market-recent-item__placeholder {
+ width: 72px;
+ height: 64px;
+ }
+
+ .market-store-card {
+ padding: 16px;
+ }
+
+ .market-store-card__hero {
+ min-height: 168px;
+ padding: 22px 16px 16px;
+ }
+
+ .market-store-card__logo-shell {
+ width: 96px;
+ height: 96px;
+ }
+
+ .market-store-card__stats {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .market-store-card__stats strong {
+ font-size: 1rem;
+ }
+
+ .market-media-placeholder--square,
+ .market-media-placeholder--store {
+ width: 72px;
+ height: 72px;
+ }
+
+ .login-layout {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..9bf5366
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import App from './App'
+import './index.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/src/mocks/mall.ts b/src/mocks/mall.ts
new file mode 100644
index 0000000..23cafef
--- /dev/null
+++ b/src/mocks/mall.ts
@@ -0,0 +1,262 @@
+import type { AppGoodsPageResponse, AppHomeResponse, GoodsSummary, RecommendMerchantInfo } from '@/types/mall'
+
+export interface MarketCategory {
+ key: string
+ label: string
+ subtitle: string
+ accent: string
+ accentSoft: string
+}
+
+export interface PlatformChannel {
+ key: string
+ label: string
+ subtitle: string
+}
+
+const createArtwork = (
+ title: string,
+ subtitle: string,
+ accent: string,
+ accentSoft: string,
+ shadow = 'rgba(15, 23, 42, 0.18)'
+) =>
+ `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
+
+ `)}`
+
+export const MARKET_CATEGORIES: MarketCategory[] = [
+ {
+ key: 'all',
+ label: '全部货源',
+ subtitle: '一站搜款与热门供给',
+ accent: '#e4393c',
+ accentSoft: '#ff8a65',
+ },
+ {
+ key: 'digital',
+ label: '数码配件',
+ subtitle: '耳机、充电、支架',
+ accent: '#ef4444',
+ accentSoft: '#f59e0b',
+ },
+ {
+ key: 'wearables',
+ label: '智能穿戴',
+ subtitle: '手表、表带、贴膜',
+ accent: '#2563eb',
+ accentSoft: '#38bdf8',
+ },
+ {
+ key: 'live',
+ label: '直播器材',
+ subtitle: '补光灯、麦克风、支架',
+ accent: '#7c3aed',
+ accentSoft: '#c084fc',
+ },
+ {
+ key: 'home',
+ label: '小家电',
+ subtitle: '高转化日用电器',
+ accent: '#0f766e',
+ accentSoft: '#34d399',
+ },
+ {
+ key: 'fashion',
+ label: '潮流饰品',
+ subtitle: '包袋、首饰、文创',
+ accent: '#db2777',
+ accentSoft: '#f472b6',
+ },
+ {
+ key: 'office',
+ label: '电脑周边',
+ subtitle: '键鼠、扩展坞、线材',
+ accent: '#4f46e5',
+ accentSoft: '#8b5cf6',
+ },
+]
+
+export const PLATFORM_CHANNELS: PlatformChannel[] = [
+ { key: 'taobao', label: '淘宝', subtitle: '传统电商铺货' },
+ { key: 'douyin', label: '抖音', subtitle: '内容带货承接' },
+ { key: 'pdd', label: '拼多多', subtitle: '性价比爆单' },
+ { key: 'xiaohongshu', label: '小红书', subtitle: '种草与测品' },
+ { key: 'kuaishou', label: '快手', subtitle: '直播出单' },
+ { key: '1688', label: '1688', subtitle: '供应链补货' },
+]
+
+export const SERVICE_POINTS = [
+ '精准搜款',
+ '一件代发',
+ '爆款推荐',
+ '商家排行',
+ '多平台上货',
+ '移动端协同',
+]
+
+export const HOT_KEYWORDS = [
+ '磁吸手机壳',
+ '降噪蓝牙耳机',
+ '直播补光灯',
+ '智能手表表带',
+ '氮化镓快充',
+ '车载支架',
+]
+
+const productSeeds = [
+ ['MagSafe 磁吸手机壳', '防摔透明边框,支持磁吸快充,适合直播起量'],
+ ['55W 氮化镓快充套装', '双口快充,适合拼团和福利品渠道'],
+ ['蓝牙主动降噪耳机', '通勤人群高复购款,支持多色混批'],
+ ['直播桌面补光灯', '可折叠支架,适合短视频拍摄'],
+ ['智能手表尼龙表带', '轻量快干,多尺寸兼容'],
+ ['平板磁吸键盘壳', '办公学习双场景,高客单价'],
+ ['无线领夹麦克风', '直播收音稳定,单反手机通用'],
+ ['桌面多口扩展坞', '接口齐全,适合办公设备渠道'],
+ ['车载磁吸导航支架', '车品长期热卖,支持门店陈列'],
+ ['桌面香薰小风扇', '礼品属性强,适合夏季上新'],
+ ['电竞机械键盘', 'RGB 灯效,适合年轻用户'],
+ ['创意数显充电线', '视觉卖点强,适合短视频素材'],
+]
+
+const storeSeeds = [
+ ['星驰数码商家', '手机配件热销商家'],
+ ['新锐直播器材', '补光灯与麦克风主推'],
+ ['轻潮智造馆', '智能穿戴与家居小电'],
+ ['云仓选品中心', '多平台一件代发'],
+ ['闪电快充工坊', '充电品类深度供给'],
+]
+
+const buildGoods = (seed: string[], index: number): GoodsSummary => {
+ const category = MARKET_CATEGORIES[(index % (MARKET_CATEGORIES.length - 1)) + 1]
+ const price = 29 + index * 7
+
+ return {
+ id: 10001 + index,
+ merchantId: String(5001 + (index % 4)),
+ storeId: 6001 + (index % 5),
+ categoryId: index + 1,
+ categoryName: category.label,
+ spuNo: `YTSPU${String(index + 1).padStart(4, '0')}`,
+ spuName: seed[0],
+ subTitle: seed[1],
+ coverUrl: createArtwork(seed[0], category.label, category.accent, category.accentSoft),
+ status: 'enabled',
+ auditStatus: 'approved',
+ saleStatus: 'on',
+ sort: 100 - index,
+ salesVolume: 220 + index * 37,
+ virtualSales: 860 + index * 64,
+ viewCount: 1200 + index * 80,
+ minPrice: `${price}.00`,
+ maxPrice: `${price + 12}.00`,
+ totalStock: 320 + index * 18,
+ lockedStock: 16 + index,
+ createdAt: 1710000000 + index * 1000,
+ updatedAt: 1710003600 + index * 1000,
+ }
+}
+
+const buildStore = (seed: string[], index: number): RecommendMerchantInfo => {
+ const category = MARKET_CATEGORIES[(index % (MARKET_CATEGORIES.length - 1)) + 1]
+
+ return {
+ id: 9001 + index,
+ merchantId: String(5001 + index),
+ storeName: seed[0],
+ storeLogo: createArtwork(seed[0], seed[1], category.accent, category.accentSoft),
+ storeNotice: seed[1],
+ storeStatus: 'enabled',
+ mainCategoryName: category.label,
+ recommendReason: '首页推荐商家',
+ rankScore: 95 - index * 1.2,
+ goodsCount: 88 - index * 9,
+ recentNewGoodsCount: 12 - index,
+ orderCount: 1260 - index * 138,
+ }
+}
+
+const goodsList = productSeeds.map(buildGoods)
+const storeList = storeSeeds.map(buildStore)
+
+export const FALLBACK_GOODS_PAGE: AppGoodsPageResponse = {
+ total: goodsList.length,
+ list: goodsList,
+}
+
+export const FALLBACK_HOME: AppHomeResponse = {
+ categoryNavs: MARKET_CATEGORIES.map((item, index) => ({
+ categoryId: index + 1,
+ categoryName: item.label,
+ goodsCount: Math.max(0, goodsList.length - index),
+ sort: index,
+ })),
+ banners: [
+ {
+ id: 1,
+ title: '精准搜款 · 一件代发',
+ subTitle: '多平台供给与商家资源聚合',
+ description: '首页主视觉广告栏',
+ buttonText: '立即搜款',
+ positionCode: 'home_top',
+ imageUrl: createArtwork('精准搜款', '多平台供给与商家资源聚合', '#e4393c', '#fb7185'),
+ linkType: 'goods',
+ linkValue: '10001',
+ sort: 1,
+ status: 'enabled',
+ startTime: 1710000000,
+ endTime: 1730000000,
+ },
+ {
+ id: 2,
+ title: '热卖数码专场',
+ subTitle: '快充、支架、耳机同步起量',
+ description: '首页活动广告栏',
+ buttonText: '查看专场',
+ positionCode: 'home_top',
+ imageUrl: createArtwork('热卖数码专场', '快充、支架、耳机同步起量', '#2563eb', '#22d3ee'),
+ linkType: 'goods',
+ linkValue: '10002',
+ sort: 2,
+ status: 'enabled',
+ startTime: 1710000000,
+ endTime: 1730000000,
+ },
+ ],
+ searchKeywords: HOT_KEYWORDS,
+ channels: PLATFORM_CHANNELS.map((item, index) => ({
+ id: index + 1,
+ title: item.label,
+ subTitle: item.subtitle,
+ iconUrl: '',
+ linkType: 'topic',
+ linkValue: item.key,
+ sort: index,
+ status: 'enabled',
+ })),
+ recommendGoods: goodsList.slice(0, 6),
+ recommendMerchants: storeList,
+ featuredMerchants: storeList.slice(0, 3),
+}
+
+export const createFallbackArtwork = createArtwork
diff --git a/src/pages/goods/GoodsDetailPage.tsx b/src/pages/goods/GoodsDetailPage.tsx
new file mode 100644
index 0000000..7c990c8
--- /dev/null
+++ b/src/pages/goods/GoodsDetailPage.tsx
@@ -0,0 +1,585 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { apiAppGoodsDetail } from '@/apis'
+import MarketPageHeader from '@/components/MarketPageHeader'
+import { useUserStore } from '@/store/user'
+import type {
+ AppGoodsDetailResponse,
+ GoodsSkuInfo,
+ MerchantInfo,
+ ParamItem,
+ ResolvedGoodsDetailInfo,
+ ResolvedGoodsSkuInfo,
+} from '@/types/mall'
+import { resolveGoodsDetailInfo } from '@/utils/mall/transform'
+
+const detailDateFormatter = new Intl.DateTimeFormat('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+})
+
+const formatDate = (value: number) => {
+ if (!Number.isFinite(value) || value <= 0) {
+ return '日期待定'
+ }
+
+ return detailDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-')
+}
+
+const formatMoney = (value?: string | number | null) => {
+ const text = value == null ? '' : String(value)
+ return text ? `¥${text.replace(/\.00$/, '')}` : '¥--'
+}
+
+const getSkuImage = (sku: GoodsSkuInfo) => sku.imageUrl || ''
+
+type SkuDisplayOption = {
+ color: string
+ size: string
+ image: string
+ sku: ResolvedGoodsSkuInfo
+}
+
+type DetailTabKey = 'detail' | 'dropship' | 'whiteImages'
+
+const DETAIL_TABS: Array<{ key: DetailTabKey; label: string }> = [
+ { key: 'detail', label: '商品详情' },
+ { key: 'dropship', label: '代发说明' },
+ { key: 'whiteImages', label: '水洗图/白底图' },
+]
+
+const COLOR_NAME_PATTERNS = ['颜色', '色号', '色系', 'color']
+const SIZE_NAME_PATTERNS = ['尺码', '尺寸', '型号', '机型', '规格', '容量', '版本', 'size', 'model']
+const SKU_NAME_SEPARATORS = /[_\-//|,,;;]+/
+const COLOR_TEXT_PATTERN = /色|白|黑|红|粉|蓝|绿|黄|橙|紫|灰|金|银|棕|咖|奶|米|香|透明|原色/i
+const IMAGE_URL_PATTERN = /^(https?:)?\/\/|^data:image\//i
+const WHITE_IMAGE_KEYWORDS = ['white', 'wash', 'clean', 'plain', 'background', '白底', '水洗']
+
+const getSpecValue = (sku: ResolvedGoodsSkuInfo, patterns: string[]) => {
+ const matched = sku.specs.find(spec => {
+ const name = typeof spec.name === 'string' ? spec.name : ''
+
+ return patterns.some(pattern => name.toLowerCase().includes(pattern.toLowerCase()))
+ })
+
+ return typeof matched?.value === 'string' ? matched.value.trim() : ''
+}
+
+const splitSkuName = (skuName: string) =>
+ skuName
+ .split(SKU_NAME_SEPARATORS)
+ .map(item => item.trim())
+ .filter(Boolean)
+
+const resolveSkuColorSize = (sku: ResolvedGoodsSkuInfo) => {
+ const specColor = getSpecValue(sku, COLOR_NAME_PATTERNS)
+ const specSize = getSpecValue(sku, SIZE_NAME_PATTERNS)
+ const parts = splitSkuName(sku.skuName)
+ const colorIndex = parts.findIndex(part => COLOR_TEXT_PATTERN.test(part))
+ const fallbackColor = colorIndex >= 0 ? parts[colorIndex] : parts[1] || parts[0] || '默认'
+ const fallbackSize =
+ parts.find((_, index) => index !== colorIndex) || sku.skuName || sku.skuNo || '默认尺寸'
+
+ return {
+ color: specColor || fallbackColor,
+ size: specSize || fallbackSize,
+ }
+}
+
+const resolveSkuOptions = (skus: ResolvedGoodsSkuInfo[]): SkuDisplayOption[] =>
+ skus.map(sku => {
+ const { color, size } = resolveSkuColorSize(sku)
+
+ return {
+ color,
+ size,
+ image: getSkuImage(sku),
+ sku,
+ }
+ })
+
+const isImageUrl = (value: string) => IMAGE_URL_PATTERN.test(value.trim())
+
+const getUniqueTexts = (values: string[]) =>
+ Array.from(new Set(values.map(value => value.trim()).filter(Boolean)))
+
+const collectImages = (value: unknown): string[] => {
+ if (typeof value === 'string') {
+ const text = value.trim()
+
+ if (!text) {
+ return []
+ }
+
+ if ((text.startsWith('[') && text.endsWith(']')) || (text.startsWith('{') && text.endsWith('}'))) {
+ try {
+ return collectImages(JSON.parse(text) as unknown)
+ } catch {
+ return isImageUrl(text) ? [text] : []
+ }
+ }
+
+ return text
+ .split(/[,,\n\r]+/)
+ .map(item => item.trim())
+ .filter(isImageUrl)
+ }
+
+ if (Array.isArray(value)) {
+ return value.flatMap(collectImages)
+ }
+
+ if (value && typeof value === 'object') {
+ return Object.values(value as Record).flatMap(collectImages)
+ }
+
+ return []
+}
+
+const getDetailImageBlocks = (detail: ResolvedGoodsDetailInfo) =>
+ getUniqueTexts(
+ detail.detail.blocks
+ .filter(block => block.type.toLowerCase().includes('image') || isImageUrl(block.value))
+ .flatMap(block => collectImages(block.value))
+ )
+
+const getDetailTextBlocks = (detail: ResolvedGoodsDetailInfo) =>
+ detail.detail.blocks
+ .filter(block => !block.type.toLowerCase().includes('image') && !isImageUrl(block.value))
+ .map(block => block.value.trim())
+ .filter(Boolean)
+
+const getWhiteImageList = (detail: ResolvedGoodsDetailInfo) => {
+ const matchedImages = Object.entries(detail.extraInfo)
+ .filter(([key]) => {
+ const normalizedKey = key.toLowerCase()
+ return WHITE_IMAGE_KEYWORDS.some(keyword => normalizedKey.includes(keyword))
+ })
+ .flatMap(([, value]) => collectImages(value))
+
+ return getUniqueTexts(matchedImages)
+}
+
+const getProductParams = (detail: ResolvedGoodsDetailInfo): ParamItem[] => {
+ const params = detail.params.filter(item => item.name?.trim() && item.value?.trim())
+
+ if (params.length > 0) {
+ return params
+ }
+
+ return [
+ { name: '商品分类', value: detail.categoryName || `类目 ${detail.categoryId}` },
+ { name: '商品货号', value: detail.spuNo },
+ { name: '库存', value: String(detail.totalStock) },
+ ].filter(item => item.value)
+}
+
+function GoodsDetailPage() {
+ const { spuId = '' } = useParams()
+ const navigate = useNavigate()
+ const token = useUserStore(state => state.token)
+ const userInfo = useUserStore(state => state.userInfo)
+ const storedMemberId = useUserStore(state => state.memberId)
+
+ const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0
+
+ const [payload, setPayload] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [activeImage, setActiveImage] = useState('')
+ const [keyword, setKeyword] = useState('')
+ const [selectedColor, setSelectedColor] = useState('')
+ const [activeDetailTab, setActiveDetailTab] = useState('detail')
+ const [quantities, setQuantities] = useState>({})
+
+ useEffect(() => {
+ if (!spuId) {
+ setError('商品参数缺失')
+ setLoading(false)
+ return
+ }
+
+ const controller = new AbortController()
+ setLoading(true)
+ setError(null)
+
+ apiAppGoodsDetail({ uid: currentUid, spuId }, controller.signal)
+ .then(response => {
+ setPayload(response.data)
+ })
+ .catch(nextError => {
+ if (!controller.signal.aborted) {
+ setError(nextError instanceof Error ? nextError.message : '商品详情接口请求失败')
+ }
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) {
+ setLoading(false)
+ }
+ })
+
+ return () => controller.abort()
+ }, [currentUid, spuId])
+
+ const detail = useMemo(() => (payload?.data ? resolveGoodsDetailInfo(payload.data) : null), [payload])
+ const merchant = payload?.merchant ?? payload?.store ?? null
+ const merchantName = getMerchantName(merchant)
+
+ const galleryImages = useMemo(() => {
+ if (!detail) {
+ return []
+ }
+
+ return Array.from(
+ new Set([
+ detail.coverUrl,
+ ...detail.media.images,
+ ...detail.skus.map(getSkuImage),
+ ].filter(Boolean))
+ )
+ }, [detail])
+
+ const skuOptions = useMemo(() => resolveSkuOptions(detail?.skus ?? []), [detail])
+ const colorOptions = useMemo(() => {
+ const options = new Map()
+
+ skuOptions.forEach(option => {
+ if (!options.has(option.color)) {
+ options.set(option.color, option)
+ }
+ })
+
+ return Array.from(options.values())
+ }, [skuOptions])
+ const selectedColorOptions = useMemo(
+ () => skuOptions.filter(option => option.color === selectedColor),
+ [selectedColor, skuOptions]
+ )
+ const productParams = useMemo(() => (detail ? getProductParams(detail) : []), [detail])
+ const detailImages = useMemo(() => {
+ if (!detail) {
+ return []
+ }
+
+ return getDetailImageBlocks(detail)
+ }, [detail])
+ const detailTextBlocks = useMemo(() => (detail ? getDetailTextBlocks(detail) : []), [detail])
+ const fallbackDetailImages = useMemo(() => {
+ if (detailImages.length > 0) {
+ return detailImages
+ }
+
+ return galleryImages
+ }, [detailImages, galleryImages])
+ const whiteImages = useMemo(() => (detail ? getWhiteImageList(detail) : []), [detail])
+
+ useEffect(() => {
+ setActiveImage(galleryImages[0] ?? '')
+ }, [galleryImages])
+
+ useEffect(() => {
+ setSelectedColor(colorOptions[0]?.color ?? '')
+ }, [colorOptions])
+
+ const updateQuantity = (skuId: number, delta: number) => {
+ setQuantities(current => {
+ const nextValue = Math.max(0, (current[skuId] ?? 0) + delta)
+ return {
+ ...current,
+ [skuId]: nextValue,
+ }
+ })
+ }
+
+ const handleHeaderSearch = (nextKeyword: string) => {
+ const trimmedKeyword = nextKeyword.trim()
+ navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
+ }
+
+ return (
+
+
+
+
+ {loading ? 商品详情加载中...
: null}
+ {!loading && error ? (
+
+ 商品详情加载失败
+ {error}
+
+ ) : null}
+ {!loading && detail ? (
+ <>
+
+
+
+
+
+
{detail.spuName}
+ {detail.subTitle && detail.subTitle !== '暂无副标题' ?
{detail.subTitle}
: null}
+
+
+
+
+ 商品ID
+ {detail.id}
+
+
+ 货号
+ {detail.spuNo}
+
+
+ 上架时间
+ {formatDate(detail.createdAt)}
+
+
+ 更新时间
+ {formatDate(detail.updatedAt)}
+
+
+
+
+ 分类:
+ {detail.categoryName || `类目 ${detail.categoryId}`}
+
+
+ {detail.services.length > 0 ? (
+
+ 服务:
+ {detail.services.map(service => (
+ {service.title}
+ ))}
+
+ ) : null}
+
+
+ 价格:
+ {formatMoney(detail.minPrice)}
+ {detail.minPrice !== detail.maxPrice ? {formatMoney(detail.maxPrice)} : null}
+
+
+ {colorOptions.length > 0 ? (
+
+
颜色:
+
+ {colorOptions.map(option => (
+
+ ))}
+
+
+ ) : null}
+
+ {selectedColorOptions.length > 0 ? (
+
+
尺码:
+
+ {selectedColorOptions.map(option => (
+
+ {option.size}
+ {formatMoney(option.sku.salePrice || detail.minPrice)}
+ 库存 {option.sku.stock}
+
+
+ {quantities[option.sku.id] ?? 0}
+
+
+
+ ))}
+
+
+ ) : (
+ 暂无 SKU 信息
+ )}
+
+
+
+
+
+
+
+ {DETAIL_TABS.map(tab => (
+
+ ))}
+
+
+ {activeDetailTab === 'detail' ? (
+
+ {productParams.length > 0 ? (
+
+ {productParams.map(item => (
+
+ {item.name}:
+ {item.value}
+
+ ))}
+
+ ) : null}
+
+ {detailTextBlocks.length > 0 ? (
+
+ {detailTextBlocks.map(text => (
+
{text}
+ ))}
+
+ ) : null}
+
+ {fallbackDetailImages.length > 0 ? (
+
+ {fallbackDetailImages.map(image => (
+

+ ))}
+
+ ) : (
+
暂无商品详情图
+ )}
+
+ ) : null}
+
+ {activeDetailTab === 'dropship' ? (
+
+ {detail.services.length > 0 ? (
+
+ {detail.services.map(service => (
+
+ {service.title}
+ {service.desc ? {service.desc} : null}
+
+ ))}
+
+ ) : (
+
暂无代发说明
+ )}
+
+ ) : null}
+
+ {activeDetailTab === 'whiteImages' ? (
+
+ {whiteImages.length > 0 ? (
+
+ {whiteImages.map(image => (
+

+ ))}
+
+ ) : (
+
暂无水洗图/白底图
+ )}
+
+ ) : null}
+
+ >
+ ) : null}
+
+
+ )
+}
+
+const getMerchantName = (merchant: MerchantInfo | null) => {
+ const name = merchant?.storeName?.trim() || merchant?.merchantName?.trim()
+ return name || '未命名商家'
+}
+
+export default GoodsDetailPage
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
new file mode 100644
index 0000000..e34d86a
--- /dev/null
+++ b/src/pages/home/HomePage.tsx
@@ -0,0 +1,1041 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
+import { toast } from 'sonner'
+import { apiAppGoodsPage, apiAppHome, apiAppRecentNewGoods } from '@/apis'
+import MarketPageHeader from '@/components/MarketPageHeader'
+import type { MarketNavKey } from '@/components/MarketNav'
+import { useUserStore } from '@/store/user'
+import type {
+ AppGoodsPageResponse,
+ AppHomeResponse,
+ AppRecentNewGoodsResponse,
+ GoodsSortType,
+ GoodsSummary,
+ RecommendStoreInfo,
+ RecentNewGoodsItem,
+} from '@/types/mall'
+import {
+ resolveHomeFeaturedStores,
+ resolveHomeRankStores,
+ resolveHomeSearchKeywords,
+ resolveHomeTopBanners,
+} from '@/utils/mall'
+import logger from '@/utils/logger'
+
+const GOODS_SORT_OPTIONS: Array<{ key: GoodsSortType; label: string }> = [
+ { key: 'comprehensive', label: '综合排序' },
+ { key: 'price_asc', label: '价格升序' },
+ { key: 'price_desc', label: '价格降序' },
+ { key: 'sales_desc', label: '销量优先' },
+]
+
+const SEARCH_IMAGE_OPTIONS = [
+ { key: 'all', label: '全部' },
+ { key: 'with_image', label: '有图' },
+ { key: 'without_image', label: '无图' },
+] as const
+
+const SEARCH_UPDATED_OPTIONS = [
+ { key: 'all', label: '全部' },
+ { key: 'today', label: '今天' },
+ { key: '7d', label: '近 7 天' },
+ { key: '30d', label: '近 30 天' },
+] as const
+
+type SearchImageFilter = (typeof SEARCH_IMAGE_OPTIONS)[number]['key']
+type SearchUpdatedFilter = (typeof SEARCH_UPDATED_OPTIONS)[number]['key']
+
+const RECENT_GOODS_PAGE_SIZE = 20
+const recentGoodsDateFormatter = new Intl.DateTimeFormat('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+})
+
+const getGoodsList = (payload: AppGoodsPageResponse | null) =>
+ (payload?.list ?? []).filter(item => item.saleStatus === 'on')
+
+const getRecentNewGoodsList = (payload: AppRecentNewGoodsResponse | null) =>
+ [...(payload?.list ?? [])].sort((a, b) => {
+ const timeDiff = b.createdAt - a.createdAt
+
+ if (timeDiff !== 0) {
+ return timeDiff
+ }
+
+ return String(b.spuId).localeCompare(String(a.spuId), 'zh-CN', { numeric: true })
+ })
+
+const formatRecentGoodsDate = (createdAt: number) => {
+ if (!Number.isFinite(createdAt) || createdAt <= 0) {
+ return '日期待定'
+ }
+
+ return recentGoodsDateFormatter.format(new Date(createdAt * 1000)).replace(/\//g, '-')
+}
+
+const getRecentGoodsStoreName = (item: RecentNewGoodsItem) => item.merchantName?.trim() || ''
+
+const formatGoodsUpdatedDate = (updatedAt: number) => formatRecentGoodsDate(updatedAt)
+
+const getPriceText = (item: GoodsSummary) => {
+ if (item.minPrice === item.maxPrice) {
+ return `¥${item.minPrice}`
+ }
+
+ return `¥${item.minPrice} - ¥${item.maxPrice}`
+}
+
+const getGoodsPriceNumber = (item: GoodsSummary) => {
+ const price = Number(item.minPrice)
+ return Number.isFinite(price) ? price : 0
+}
+
+const isGoodsInUpdatedRange = (item: GoodsSummary, range: SearchUpdatedFilter) => {
+ if (range === 'all') {
+ return true
+ }
+
+ const updatedAt = Number(item.updatedAt)
+
+ if (!Number.isFinite(updatedAt) || updatedAt <= 0) {
+ return false
+ }
+
+ const now = Date.now()
+ const updatedTime = updatedAt * 1000
+ const dayMs = 24 * 60 * 60 * 1000
+
+ if (range === 'today') {
+ return new Date(updatedTime).toDateString() === new Date(now).toDateString()
+ }
+
+ return now - updatedTime <= (range === '7d' ? 7 : 30) * dayMs
+}
+
+const getStoreDisplayName = (name?: string | null) => {
+ const text = typeof name === 'string' ? name.trim() : ''
+ return text || '未命名商家'
+}
+
+const getStoreFallbackText = (name?: string | null) => {
+ const text = getStoreDisplayName(name)
+ return text === '未命名商家' ? '商家' : text.slice(0, Math.min(2, text.length))
+}
+
+const getStoreLink = (store: RecommendStoreInfo) => `/shop/${encodeURIComponent(store.merchantId)}`
+
+const formatStoreStat = (value?: number | null) => {
+ if (!Number.isFinite(value)) {
+ return '0'
+ }
+
+ return new Intl.NumberFormat('zh-CN').format(value ?? 0)
+}
+
+const getMerchantPreviewImages = (
+ store: RecommendStoreInfo,
+ previewMap: Map
+) => {
+ const goodsImages = previewMap.get(store.merchantId) ?? []
+
+ if (goodsImages.length > 0) {
+ return goodsImages
+ }
+
+ return store.storeLogo ? [store.storeLogo] : []
+}
+
+function HomePage() {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [searchParams] = useSearchParams()
+ const token = useUserStore(state => state.token)
+ const userInfo = useUserStore(state => state.userInfo)
+ const storedMemberId = useUserStore(state => state.memberId)
+
+ const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0
+ const initialSearchKeyword = searchParams.get('key')?.trim() ?? ''
+ const isInitialSearchPage = location.pathname === '/list/new' || Boolean(initialSearchKeyword)
+
+ const [keyword, setKeyword] = useState(initialSearchKeyword)
+ const [submittedKeyword, setSubmittedKeyword] = useState(initialSearchKeyword)
+ const [activeNav, setActiveNav] = useState(
+ searchParams.get('nav') === 'rank' ? 'rank' : 'home'
+ )
+ const [isSearchPage, setIsSearchPage] = useState(isInitialSearchPage)
+ const [rankKeyword, setRankKeyword] = useState('')
+ const [submittedRankKeyword, setSubmittedRankKeyword] = useState('')
+ const [rankCategory, setRankCategory] = useState('全部')
+ const [searchImageFilter, setSearchImageFilter] = useState('all')
+ const [searchCategory, setSearchCategory] = useState('全部')
+ const [searchMerchantId, setSearchMerchantId] = useState('全部')
+ const [searchGoodsNo, setSearchGoodsNo] = useState('')
+ const [searchMinPrice, setSearchMinPrice] = useState('')
+ const [searchMaxPrice, setSearchMaxPrice] = useState('')
+ const [searchUpdatedRange, setSearchUpdatedRange] = useState('all')
+ const [sortType, setSortType] = useState('comprehensive')
+ const [homePayload, setHomePayload] = useState(null)
+ const [goodsPayload, setGoodsPayload] = useState(null)
+ const [recentNewGoodsPayload, setRecentNewGoodsPayload] = useState(null)
+ const [, setHomeLoading] = useState(true)
+ const [goodsLoading, setGoodsLoading] = useState(true)
+ const [recentNewGoodsLoading, setRecentNewGoodsLoading] = useState(true)
+ const [homeError, setHomeError] = useState(null)
+ const [goodsError, setGoodsError] = useState(null)
+ const [recentNewGoodsError, setRecentNewGoodsError] = useState(null)
+ const [recentNewGoodsRefreshKey, setRecentNewGoodsRefreshKey] = useState(0)
+
+ useEffect(() => {
+ let canceled = false
+
+ const loadHome = async () => {
+ setHomeLoading(true)
+
+ try {
+ const response = await apiAppHome({ uid: currentUid })
+
+ if (canceled) {
+ return
+ }
+
+ setHomePayload(response.data)
+ setHomeError(null)
+ } catch (error) {
+ if (canceled) {
+ return
+ }
+
+ logger.warn('Load mall home failed', error)
+ setHomePayload(null)
+ setHomeError(error instanceof Error ? error.message : '首页接口请求失败')
+ } finally {
+ if (!canceled) {
+ setHomeLoading(false)
+ }
+ }
+ }
+
+ void loadHome()
+
+ return () => {
+ canceled = true
+ }
+ }, [currentUid])
+
+ useEffect(() => {
+ let canceled = false
+
+ const loadRecentNewGoods = async () => {
+ setRecentNewGoodsLoading(true)
+
+ try {
+ const response = await apiAppRecentNewGoods({
+ uid: currentUid,
+ page: 1,
+ size: RECENT_GOODS_PAGE_SIZE,
+ })
+
+ if (canceled) {
+ return
+ }
+
+ setRecentNewGoodsPayload(response.data)
+ setRecentNewGoodsError(null)
+ } catch (error) {
+ if (canceled) {
+ return
+ }
+
+ logger.warn('Load mall recent new goods failed', error)
+ setRecentNewGoodsPayload(null)
+ setRecentNewGoodsError(error instanceof Error ? error.message : '最近上新商品接口请求失败')
+ } finally {
+ if (!canceled) {
+ setRecentNewGoodsLoading(false)
+ }
+ }
+ }
+
+ void loadRecentNewGoods()
+
+ return () => {
+ canceled = true
+ }
+ }, [currentUid, recentNewGoodsRefreshKey])
+
+ useEffect(() => {
+ let canceled = false
+
+ const loadGoods = async () => {
+ setGoodsLoading(true)
+
+ try {
+ const response = await apiAppGoodsPage({
+ uid: currentUid,
+ keyword: submittedKeyword || undefined,
+ sortType,
+ page: 1,
+ size: 12,
+ })
+
+ if (canceled) {
+ return
+ }
+
+ setGoodsPayload(response.data)
+ setGoodsError(null)
+ } catch (error) {
+ if (canceled) {
+ return
+ }
+
+ logger.warn('Load mall goods page failed', error)
+ setGoodsPayload(null)
+ setGoodsError(error instanceof Error ? error.message : '商品列表接口请求失败')
+ } finally {
+ if (!canceled) {
+ setGoodsLoading(false)
+ }
+ }
+ }
+
+ void loadGoods()
+
+ return () => {
+ canceled = true
+ }
+ }, [currentUid, sortType, submittedKeyword])
+
+ const banners = useMemo(() => resolveHomeTopBanners(homePayload), [homePayload])
+ const recommendStores = useMemo(() => resolveHomeRankStores(homePayload), [homePayload])
+ const featuredStores = useMemo(() => resolveHomeFeaturedStores(homePayload), [homePayload])
+ const searchKeywords = useMemo(() => resolveHomeSearchKeywords(homePayload), [homePayload])
+ const goodsList = useMemo(() => getGoodsList(goodsPayload), [goodsPayload])
+ const recentNewGoodsList = useMemo(() => getRecentNewGoodsList(recentNewGoodsPayload), [recentNewGoodsPayload])
+ const hotKeywords = useMemo(() => searchKeywords.slice(0, 5), [searchKeywords])
+ const rankStores = useMemo(() => {
+ const seenMerchantIds = new Set()
+ const stores: RecommendStoreInfo[] = []
+
+ ;[...recommendStores, ...featuredStores].forEach(store => {
+ if (seenMerchantIds.has(store.merchantId)) {
+ return
+ }
+
+ seenMerchantIds.add(store.merchantId)
+ stores.push(store)
+ })
+
+ return stores
+ }, [featuredStores, recommendStores])
+ const rankCategoryOptions = useMemo(() => {
+ const names = rankStores
+ .map(store => (typeof store.mainCategoryName === 'string' ? store.mainCategoryName.trim() : ''))
+ .filter(Boolean)
+
+ return ['全部', ...Array.from(new Set(names))]
+ }, [rankStores])
+ const merchantPreviewMap = useMemo(() => {
+ const previewMap = new Map()
+
+ goodsList.forEach(goods => {
+ if (!goods.coverUrl) {
+ return
+ }
+
+ const images = previewMap.get(goods.merchantId) ?? []
+
+ if (images.length >= 4 || images.includes(goods.coverUrl)) {
+ return
+ }
+
+ previewMap.set(goods.merchantId, [...images, goods.coverUrl])
+ })
+
+ return previewMap
+ }, [goodsList])
+ const filteredRankStores = useMemo(() => {
+ const keywordText = submittedRankKeyword.trim().toLowerCase()
+
+ return rankStores.filter(store => {
+ const matchesCategory = rankCategory === '全部' || store.mainCategoryName === rankCategory
+ const searchText = [
+ store.storeName,
+ store.mainCategoryName,
+ store.storeNotice,
+ store.recommendReason,
+ ]
+ .join(' ')
+ .toLowerCase()
+
+ return matchesCategory && (!keywordText || searchText.includes(keywordText))
+ })
+ }, [rankCategory, rankStores, submittedRankKeyword])
+ const searchCategoryOptions = useMemo(() => {
+ const names = goodsList
+ .map(goods => goods.categoryName?.trim() ?? '')
+ .filter(Boolean)
+
+ return ['全部', ...Array.from(new Set(names))]
+ }, [goodsList])
+ const searchMerchantOptions = useMemo(() => {
+ const merchants = new Map()
+
+ rankStores.forEach(store => {
+ merchants.set(store.merchantId, getStoreDisplayName(store.storeName))
+ })
+
+ goodsList.forEach(goods => {
+ if (!goods.merchantId) {
+ return
+ }
+
+ merchants.set(goods.merchantId, goods.merchantName?.trim() || merchants.get(goods.merchantId) || '未命名商家')
+ })
+
+ return [
+ { id: '全部', name: '全部' },
+ ...Array.from(merchants.entries()).map(([id, name]) => ({ id, name })),
+ ]
+ }, [goodsList, rankStores])
+ const filteredSearchGoods = useMemo(() => {
+ const goodsNo = searchGoodsNo.trim().toLowerCase()
+ const minPrice = searchMinPrice.trim() ? Number(searchMinPrice) : null
+ const maxPrice = searchMaxPrice.trim() ? Number(searchMaxPrice) : null
+
+ return goodsList.filter(goods => {
+ const price = getGoodsPriceNumber(goods)
+ const matchesImage =
+ searchImageFilter === 'all' ||
+ (searchImageFilter === 'with_image' && Boolean(goods.coverUrl)) ||
+ (searchImageFilter === 'without_image' && !goods.coverUrl)
+ const matchesCategory = searchCategory === '全部' || goods.categoryName === searchCategory
+ const matchesMerchant = searchMerchantId === '全部' || goods.merchantId === searchMerchantId
+ const matchesGoodsNo = !goodsNo || goods.spuNo.toLowerCase().includes(goodsNo)
+ const matchesMinPrice = minPrice == null || !Number.isFinite(minPrice) || price >= minPrice
+ const matchesMaxPrice = maxPrice == null || !Number.isFinite(maxPrice) || price <= maxPrice
+
+ return (
+ matchesImage &&
+ matchesCategory &&
+ matchesMerchant &&
+ matchesGoodsNo &&
+ matchesMinPrice &&
+ matchesMaxPrice &&
+ isGoodsInUpdatedRange(goods, searchUpdatedRange)
+ )
+ })
+ }, [
+ goodsList,
+ searchCategory,
+ searchGoodsNo,
+ searchImageFilter,
+ searchMaxPrice,
+ searchMerchantId,
+ searchMinPrice,
+ searchUpdatedRange,
+ ])
+
+ const heroBanner = banners[0] ?? null
+ const loadError = homeError || goodsError || recentNewGoodsError
+
+ const handleSearch = (nextKeyword: string) => {
+ const trimmedKeyword = nextKeyword.trim()
+
+ setKeyword(trimmedKeyword)
+ setSubmittedKeyword(trimmedKeyword)
+ setActiveNav('home')
+ setIsSearchPage(true)
+ navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
+ }
+
+ const handleRecentNewGoodsRefresh = () => {
+ setRecentNewGoodsRefreshKey(value => value + 1)
+ }
+
+ const handleRecentNewGoodsClick = (item: RecentNewGoodsItem) => {
+ navigate(`/goods/${item.spuId}`)
+ }
+
+ const handleRankSearch = () => {
+ setSubmittedRankKeyword(rankKeyword)
+ }
+
+ const handleRankReset = () => {
+ setRankKeyword('')
+ setSubmittedRankKeyword('')
+ setRankCategory('全部')
+ }
+
+ const handleMarketNavSelect = (key: MarketNavKey) => {
+ setIsSearchPage(false)
+ setActiveNav(key)
+ navigate(key === 'rank' ? '/?nav=rank' : '/')
+ }
+
+ const handleSearchFilterReset = () => {
+ setKeyword('')
+ setSubmittedKeyword('')
+ setSearchImageFilter('all')
+ setSearchCategory('全部')
+ setSearchMerchantId('全部')
+ setSearchGoodsNo('')
+ setSearchMinPrice('')
+ setSearchMaxPrice('')
+ setSearchUpdatedRange('all')
+ setSortType('comprehensive')
+ setIsSearchPage(true)
+ setActiveNav('home')
+ navigate('/list/new')
+ }
+
+ return (
+
+
{
+ setActiveNav('home')
+ setIsSearchPage(false)
+ navigate('/')
+ }}
+ onKeywordChange={setKeyword}
+ onNavSelect={handleMarketNavSelect}
+ onSearch={handleSearch}
+ />
+
+
+ {isSearchPage ? (
+
+
+
+
+
+
+
商品属性:
+
图片:
+
+ {SEARCH_IMAGE_OPTIONS.map(option => (
+
+ ))}
+
+
视频:
+
+
+
+
+
+
商品分类:
+
+ {searchCategoryOptions.map(option => (
+
+ ))}
+
+
+
+
+
+
+
+
+ 排序:
+ {GOODS_SORT_OPTIONS.map(item => (
+
+ ))}
+
+
+
+
+ {filteredSearchGoods.length > 0 ? (
+
+ {filteredSearchGoods.map(item => (
+
+
+ {item.coverUrl ? (
+

+ ) : (
+
暂无主图
+ )}
+ {item.categoryName ?
{item.categoryName} : null}
+
+
+ {item.spuName}
+ {getPriceText(item)}
+
+
+ {item.merchantName || '未命名商家'}
+
+
+
+ ))}
+
+ ) : (
+ {goodsLoading ? '搜索商品加载中...' : '暂无匹配商品'}
+ )}
+
+
+ ) : activeNav === 'rank' ? (
+
+
+
+
+ {filteredRankStores.length > 0 ? (
+
+ {filteredRankStores.map((store, index) => {
+ const storeName = getStoreDisplayName(store.storeName)
+ const previewImages = getMerchantPreviewImages(store, merchantPreviewMap)
+ const businessSummary = store.mainCategoryName || store.storeNotice
+
+ return (
+
+
+ {index + 1}
+
+
+
+ {storeName}
+ 优选商家
+
+
+ 商品数: {formatStoreStat(store.goodsCount)}
+ 近期上新: {formatStoreStat(store.recentNewGoodsCount)}
+ 出单数: {formatStoreStat(store.orderCount)}
+
+ {businessSummary ?
主营: {businessSummary}
: null}
+
+ 商家首页
+
+
+
+
+ {previewImages.length > 0 ? (
+ previewImages.slice(0, 4).map(image => (
+

+ ))
+ ) : (
+
暂无商品图
+ )}
+
+
+ )
+ })}
+
+ ) : (
+
+ {homeError ? homeError : '暂无符合条件的商家'}
+
+ )}
+
+
+ ) : (
+ <>
+
+
+
+
+
+ {heroBanner?.imageUrl ? (
+
+ ) : (
+ 暂无 Banner 图片
+ )}
+
+
+
+
+
+
+
+
+
+ {featuredStores.length > 0 ? (
+
+ {featuredStores.map((store, index) => {
+ const storeName = getStoreDisplayName(store.storeName)
+ const businessSummary = store.mainCategoryName || store.storeNotice
+
+ return (
+
+
+
推荐第 {index + 1} 位
+
+ {store.storeLogo ? (
+

+ ) : (
+
{getStoreFallbackText(storeName)}
+ )}
+
+
+
+
{storeName}
+ {store.recommendReason ? (
+
{store.recommendReason}
+ ) : null}
+
+
+ {formatStoreStat(store.goodsCount)}
+ 商品数
+
+
+ {formatStoreStat(store.recentNewGoodsCount)}
+ 近期上新
+
+
+ {formatStoreStat(store.orderCount)}
+ 出单数
+
+
+ {businessSummary ?
主营:{businessSummary}
: null}
+
+
+ )
+ })}
+
+ ) : (
+ 暂无推荐商家
+ )}
+
+
+
+
+
+
{submittedKeyword ? '搜索结果' : '商品列表'}
+
{submittedKeyword ? `围绕“${submittedKeyword}”的商品陈列` : '商品列表'}
+
+
+
总数 {goodsPayload?.total ?? 0}
+
+ {GOODS_SORT_OPTIONS.map(item => (
+
+ ))}
+
+
+
+
+ {goodsList.length > 0 ? (
+
+ {goodsList.map(item => (
+
+
+ {item.coverUrl ? (
+

+ ) : (
+
暂无主图
+ )}
+ {item.categoryName ?
{item.categoryName} : null}
+
+
+ {item.spuName}
+ {getPriceText(item)}
+
+
+ {item.merchantName || '未命名商家'}
+
+
+
+ ))}
+
+ ) : (
+ {goodsLoading ? '商品列表加载中...' : '暂无商品'}
+ )}
+
+ >
+ )}
+
+
+
+
+ )
+}
+
+export default HomePage
diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx
new file mode 100644
index 0000000..8bc54e4
--- /dev/null
+++ b/src/pages/login/LoginPage.tsx
@@ -0,0 +1,412 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { toast } from 'sonner'
+import { apiGetSmsCode, apiUserLogin, apiUserRegister } from '@/apis'
+import MarketPageHeader from '@/components/MarketPageHeader'
+import { useUserStore } from '@/store/user'
+
+type LoginMode = 'password' | 'sms'
+
+function LoginPage() {
+ const location = useLocation()
+ const navigate = useNavigate()
+ const token = useUserStore(state => state.token)
+ const setSession = useUserStore(state => state.setSession)
+ const refreshUser = useUserStore(state => state.refreshUser)
+ const isRegisterView = location.pathname === '/register'
+
+ const [mode, setMode] = useState('password')
+ const [account, setAccount] = useState('')
+ const [password, setPassword] = useState('')
+ const [captcha, setCaptcha] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [inviteCode, setInviteCode] = useState('')
+ const [headerKeyword, setHeaderKeyword] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [smsSending, setSmsSending] = useState(false)
+ const [smsLeft, setSmsLeft] = useState(0)
+ const timerRef = useRef(null)
+
+ useEffect(() => {
+ if (token?.accessToken) {
+ navigate('/', { replace: true })
+ }
+ }, [navigate, token])
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) {
+ window.clearInterval(timerRef.current)
+ timerRef.current = null
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!isRegisterView) {
+ return
+ }
+
+ const searchParams = new URLSearchParams(location.search)
+ const invite = searchParams.get('inviteCode') || searchParams.get('invite') || ''
+ if (invite) {
+ setInviteCode(invite)
+ }
+ }, [isRegisterView, location.search])
+
+ const clearCountdown = () => {
+ if (timerRef.current) {
+ window.clearInterval(timerRef.current)
+ timerRef.current = null
+ }
+ setSmsLeft(0)
+ }
+
+ useEffect(() => {
+ clearCountdown()
+ setCaptcha('')
+ setPassword('')
+ setConfirmPassword('')
+
+ if (!isRegisterView) {
+ setInviteCode('')
+ }
+ }, [isRegisterView])
+
+ const startCountdown = (seconds: number) => {
+ clearCountdown()
+ setSmsLeft(seconds)
+
+ timerRef.current = window.setInterval(() => {
+ setSmsLeft(value => {
+ if (value <= 1) {
+ if (timerRef.current) {
+ window.clearInterval(timerRef.current)
+ timerRef.current = null
+ }
+ return 0
+ }
+ return value - 1
+ })
+ }, 1000)
+ }
+
+ const handleSendSms = async () => {
+ if (!/^1\d{10}$/.test(account.trim())) {
+ toast.error('请输入有效的手机号')
+ return
+ }
+
+ try {
+ setSmsSending(true)
+ await apiGetSmsCode({
+ account: account.trim(),
+ loginType: '0',
+ type: isRegisterView ? '0' : '1',
+ })
+ toast.success('验证码已发送')
+ startCountdown(60)
+ } catch (error: unknown) {
+ toast.error(error instanceof Error ? error.message : '发送验证码失败')
+ } finally {
+ setSmsSending(false)
+ }
+ }
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault()
+
+ if (!account.trim()) {
+ toast.error('请输入手机号')
+ return
+ }
+
+ if (!/^1\d{10}$/.test(account.trim())) {
+ toast.error('请输入有效的手机号')
+ return
+ }
+
+ if (!isRegisterView && mode === 'password' && password.trim().length < 6) {
+ toast.error('请输入至少 6 位密码')
+ return
+ }
+
+ if (!isRegisterView && mode === 'sms' && !/^\d{4}$/.test(captcha.trim())) {
+ toast.error('请输入 4 位短信验证码')
+ return
+ }
+
+ if (isRegisterView && !/^\d{4}$/.test(captcha.trim())) {
+ toast.error('请输入 4 位短信验证码')
+ return
+ }
+
+ if (isRegisterView && password.trim().length < 6) {
+ toast.error('请输入至少 6 位密码')
+ return
+ }
+
+ if (isRegisterView && confirmPassword.trim().length < 6) {
+ toast.error('请再次输入至少 6 位密码')
+ return
+ }
+
+ if (isRegisterView && password.trim() !== confirmPassword.trim()) {
+ toast.error('两次输入的密码不一致')
+ return
+ }
+
+ try {
+ setLoading(true)
+ const response = isRegisterView
+ ? await apiUserRegister({
+ account: account.trim(),
+ password: password.trim(),
+ captcha: captcha.trim(),
+ loginType: '0',
+ type: '0',
+ invite: inviteCode.trim() || undefined,
+ })
+ : await apiUserLogin(
+ mode === 'password'
+ ? {
+ account: account.trim(),
+ password: password.trim(),
+ loginType: '0',
+ type: '0',
+ }
+ : {
+ account: account.trim(),
+ captcha: captcha.trim(),
+ loginType: '0',
+ type: '1',
+ }
+ )
+
+ setSession({
+ token: response.data,
+ memberId: response.data.memberId ?? response.data.uid ?? null,
+ })
+ await refreshUser()
+ toast.success(isRegisterView ? '注册成功' : '登录成功')
+ navigate('/', { replace: true })
+ } catch (error: unknown) {
+ toast.error(error instanceof Error ? error.message : isRegisterView ? '注册失败' : '登录失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleHeaderSearch = (nextKeyword: string) => {
+ const trimmedKeyword = nextKeyword.trim()
+ navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
+ }
+
+ return (
+
+
+
+
+
+
+
+ 返回商城首页
+
+ {isRegisterView ? '用户注册' : '用户登录'}
+ {isRegisterView ? '把 ERP 注册接口接进商城前台。' : '把 ERP 登录接口接进商城前台。'}
+
+ 登录、注册、验证码和用户详情统一复用 `YT-ERP` 的 ERP 用户接口定义,商城前台只保留自己的页面视觉和路由。
+
+
+
+
+ 登录接口
+ `/api/v1/user/login`
+
+
+ 注册接口
+ `/api/v1/user/register`
+
+
+ 短信与用户详情
+ `/api/v1/user/captcha` · `/api/v1/user/detail`
+
+
+
+
+
+
+
+ 登录
+
+
+ 注册
+
+
+
+
+
{isRegisterView ? '创建账户' : '欢迎回来'}
+
{isRegisterView ? '注册 ERP 采购账户' : '登录 ERP 采购账户'}
+
+
+ {!isRegisterView ? (
+
+
+
+
+ ) : (
+ 注册使用 ERP 短信验证码接口,邀请码为选填项。
+ )}
+
+
+
+
+ {isRegisterView ? (
+ <>
+ 已有账号?去登录
+ >
+ ) : (
+ <>
+ 还没有账号?立即注册
+ >
+ )}
+
+
+
+
+
+ )
+}
+
+export default LoginPage
diff --git a/src/pages/member/MemberCenterPage.tsx b/src/pages/member/MemberCenterPage.tsx
new file mode 100644
index 0000000..f785713
--- /dev/null
+++ b/src/pages/member/MemberCenterPage.tsx
@@ -0,0 +1,455 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Link, useNavigate, useSearchParams } from 'react-router-dom'
+import { toast } from 'sonner'
+import { apiAppCartList, apiAppCartRemove, apiAppCartSave, apiAppOrderPage } from '@/apis'
+import MarketPageHeader from '@/components/MarketPageHeader'
+import { useUserStore } from '@/store/user'
+import type { AppCartListResponse, AppOrderPageResponse, CartItemInfo, OrderSummary } from '@/types/mall'
+
+type MemberTabKey = 'cart' | 'orders' | 'after-sales'
+
+const MEMBER_TABS: Array<{ key: MemberTabKey; label: string }> = [
+ { key: 'cart', label: '购物车' },
+ { key: 'orders', label: '订单页面' },
+ { key: 'after-sales', label: '售后管理' },
+]
+
+const ORDER_STATUS_TEXT: Record = {
+ pending_pay: '待付款',
+ paid_wait_ship: '待发货',
+ shipped: '待收货',
+ finished: '已完成',
+ canceled: '已取消',
+ after_sale: '售后中',
+ refund_finished: '退款完成',
+}
+
+const AFTER_SALE_STATUS_TEXT: Record = {
+ none: '无售后',
+ pending: '待处理',
+ approved: '已同意',
+ rejected: '已拒绝',
+ refunding: '退款中',
+ finished: '已完成',
+ closed: '已关闭',
+}
+
+const memberDateFormatter = new Intl.DateTimeFormat('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+})
+
+const getCurrentTab = (value: string | null): MemberTabKey =>
+ MEMBER_TABS.some(item => item.key === value) ? (value as MemberTabKey) : 'cart'
+
+const formatDate = (value: number) => {
+ if (!Number.isFinite(value) || value <= 0) {
+ return '-'
+ }
+
+ return memberDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-')
+}
+
+const formatMoney = (value?: string | number | null) => {
+ const text = value == null ? '' : String(value)
+ return text ? `¥${text.replace(/\.00$/, '')}` : '¥0'
+}
+
+const parseSkuSpecs = (value: string) => {
+ if (!value) {
+ return ''
+ }
+
+ try {
+ const parsed = JSON.parse(value) as unknown
+
+ if (Array.isArray(parsed)) {
+ return parsed
+ .map(item => {
+ if (!item || typeof item !== 'object') {
+ return ''
+ }
+
+ const spec = item as { name?: unknown; value?: unknown }
+ const name = typeof spec.name === 'string' ? spec.name : ''
+ const specValue = typeof spec.value === 'string' ? spec.value : ''
+
+ return [name, specValue].filter(Boolean).join(': ')
+ })
+ .filter(Boolean)
+ .join(' / ')
+ }
+
+ if (parsed && typeof parsed === 'object') {
+ return Object.entries(parsed as Record)
+ .map(([key, specValue]) => `${key}: ${String(specValue)}`)
+ .join(' / ')
+ }
+ } catch {
+ return value
+ }
+
+ return value
+}
+
+const getCartItemAmount = (item: CartItemInfo) => {
+ const price = Number(item.salePrice)
+ const count = Number(item.buyNum)
+
+ if (!Number.isFinite(price) || !Number.isFinite(count)) {
+ return '0'
+ }
+
+ return (price * count).toFixed(2)
+}
+
+const getOrderStatusText = (order: OrderSummary) => ORDER_STATUS_TEXT[order.orderStatus] ?? order.orderStatus
+
+const getAfterSaleStatusText = (order: OrderSummary) =>
+ AFTER_SALE_STATUS_TEXT[order.afterSaleStatus] ?? order.afterSaleStatus
+
+function MemberCenterPage() {
+ const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const token = useUserStore(state => state.token)
+ const userInfo = useUserStore(state => state.userInfo)
+ const storedMemberId = useUserStore(state => state.memberId)
+ const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0
+ const activeTab = getCurrentTab(searchParams.get('tab'))
+
+ const [keyword, setKeyword] = useState('')
+ const [cartPayload, setCartPayload] = useState(null)
+ const [orderPayload, setOrderPayload] = useState(null)
+ const [cartLoading, setCartLoading] = useState(false)
+ const [ordersLoading, setOrdersLoading] = useState(false)
+ const [cartError, setCartError] = useState(null)
+ const [ordersError, setOrdersError] = useState(null)
+ const [cartRefreshKey, setCartRefreshKey] = useState(0)
+
+ const isLoggedIn = Boolean(token)
+
+ useEffect(() => {
+ if (!isLoggedIn) {
+ setCartPayload(null)
+ return
+ }
+
+ const controller = new AbortController()
+ setCartLoading(true)
+ setCartError(null)
+
+ apiAppCartList({ uid: currentUid }, controller.signal)
+ .then(response => {
+ setCartPayload(response.data)
+ })
+ .catch(error => {
+ if (!controller.signal.aborted) {
+ setCartPayload(null)
+ setCartError(error instanceof Error ? error.message : '购物车加载失败')
+ }
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) {
+ setCartLoading(false)
+ }
+ })
+
+ return () => controller.abort()
+ }, [cartRefreshKey, currentUid, isLoggedIn])
+
+ useEffect(() => {
+ if (!isLoggedIn) {
+ setOrderPayload(null)
+ return
+ }
+
+ const controller = new AbortController()
+ setOrdersLoading(true)
+ setOrdersError(null)
+
+ apiAppOrderPage({ uid: currentUid, page: 1, size: 50 }, controller.signal)
+ .then(response => {
+ setOrderPayload(response.data)
+ })
+ .catch(error => {
+ if (!controller.signal.aborted) {
+ setOrderPayload(null)
+ setOrdersError(error instanceof Error ? error.message : '订单加载失败')
+ }
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) {
+ setOrdersLoading(false)
+ }
+ })
+
+ return () => controller.abort()
+ }, [currentUid, isLoggedIn])
+
+ const cartItems = cartPayload?.list ?? []
+ const orders = orderPayload?.list ?? []
+ const afterSaleOrders = useMemo(
+ () => orders.filter(order => order.afterSaleStatus !== 'none' || order.orderStatus === 'after_sale'),
+ [orders]
+ )
+ const checkedCartAmount =
+ cartPayload?.checkedAmount ??
+ cartItems
+ .reduce((total, item) => {
+ if (!item.checked) {
+ return total
+ }
+
+ return total + Number(getCartItemAmount(item))
+ }, 0)
+ .toFixed(2)
+
+ const handleTabChange = (key: MemberTabKey) => {
+ setSearchParams({ tab: key })
+ }
+
+ const handleCartQuantityChange = async (item: CartItemInfo, buyNum: number) => {
+ if (buyNum < 1) {
+ return
+ }
+
+ try {
+ await apiAppCartSave({
+ uid: currentUid,
+ skuId: item.skuId,
+ buyNum,
+ checked: item.checked,
+ })
+ setCartRefreshKey(value => value + 1)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : '购物车更新失败')
+ }
+ }
+
+ const handleCartCheckedChange = async (item: CartItemInfo, checked: boolean) => {
+ try {
+ await apiAppCartSave({
+ uid: currentUid,
+ skuId: item.skuId,
+ buyNum: item.buyNum,
+ checked,
+ })
+ setCartRefreshKey(value => value + 1)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : '购物车更新失败')
+ }
+ }
+
+ const handleCartRemove = async (item: CartItemInfo) => {
+ try {
+ await apiAppCartRemove({ uid: currentUid, skuIds: [item.skuId] })
+ setCartRefreshKey(value => value + 1)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : '商品移除失败')
+ }
+ }
+
+ const handleSearch = (nextKeyword: string) => {
+ const trimmedKeyword = nextKeyword.trim()
+ navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {!isLoggedIn ? (
+
+ 请先登录
+
+ 去登录
+
+
+ ) : null}
+
+ {isLoggedIn && activeTab === 'cart' ? (
+ <>
+
+
+
+ 已选 {cartPayload?.checkedCount ?? 0} 件
+ {formatMoney(checkedCartAmount)}
+
+
+
+ {cartLoading ? 购物车加载中...
: null}
+ {!cartLoading && cartError ? {cartError}
: null}
+ {!cartLoading && !cartError && cartItems.length === 0 ? (
+ 购物车暂无商品
+ ) : null}
+ {!cartLoading && !cartError && cartItems.length > 0 ? (
+
+ {cartItems.map(item => {
+ const specText = parseSkuSpecs(item.skuSpecJson)
+
+ return (
+
+ handleCartCheckedChange(item, event.target.checked)}
+ />
+
+ {item.coverUrl ? (
+
+ ) : (
+ 暂无主图
+ )}
+
+
+ {item.spuName}
+ {item.skuName ? {item.skuName} : null}
+ {specText ? {specText} : null}
+
+ {formatMoney(item.salePrice)}
+
+
+ {item.buyNum}
+
+
+ {formatMoney(getCartItemAmount(item))}
+
+
+ )
+ })}
+
+ ) : null}
+ >
+ ) : null}
+
+ {isLoggedIn && activeTab === 'orders' ? (
+ <>
+
+
+
共 {orderPayload?.total ?? orders.length} 个订单
+
+
+ >
+ ) : null}
+
+ {isLoggedIn && activeTab === 'after-sales' ? (
+ <>
+
+
+
共 {afterSaleOrders.length} 个售后订单
+
+
+ >
+ ) : null}
+
+
+
+
+ )
+}
+
+function OrderList({
+ emptyText,
+ error,
+ loading,
+ orders,
+ showAfterSale = false,
+}: {
+ emptyText: string
+ error: string | null
+ loading: boolean
+ orders: OrderSummary[]
+ showAfterSale?: boolean
+}) {
+ if (loading) {
+ return 订单加载中...
+ }
+
+ if (error) {
+ return {error}
+ }
+
+ if (orders.length === 0) {
+ return {emptyText}
+ }
+
+ return (
+
+ {orders.map(order => (
+
+
+
+ {order.orderNo}
+ {order.storeName}
+
+
+ {showAfterSale ? getAfterSaleStatusText(order) : getOrderStatusText(order)}
+
+
+
+
+ 商品金额
+ {formatMoney(order.goodsAmount)}
+
+
+ 运费
+ {formatMoney(order.freightAmount)}
+
+
+ 实付
+ {formatMoney(order.payAmount)}
+
+
+ 下单时间
+ {formatDate(order.createdAt)}
+
+
+
+ ))}
+
+ )
+}
+
+export default MemberCenterPage
diff --git a/src/pages/shop/ShopPage.tsx b/src/pages/shop/ShopPage.tsx
new file mode 100644
index 0000000..3fe6a50
--- /dev/null
+++ b/src/pages/shop/ShopPage.tsx
@@ -0,0 +1,246 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import { apiAppGoodsPage, apiAppHomeMerchantGoods } from '@/apis'
+import MarketPageHeader from '@/components/MarketPageHeader'
+import { useUserStore } from '@/store/user'
+import type { AppGoodsPageResponse, HomeMerchantGoodsBlock, GoodsSortType, GoodsSummary } from '@/types/mall'
+
+const GOODS_SORT_OPTIONS: Array<{ key: GoodsSortType; label: string }> = [
+ { key: 'comprehensive', label: '综合' },
+ { key: 'sales_desc', label: '上新' },
+ { key: 'price_asc', label: '价格升序' },
+ { key: 'price_desc', label: '价格降序' },
+]
+
+const shopDateFormatter = new Intl.DateTimeFormat('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+})
+
+const formatDate = (value: number) => {
+ if (!Number.isFinite(value) || value <= 0) {
+ return '日期待定'
+ }
+
+ return shopDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-')
+}
+
+const formatPrice = (item: GoodsSummary) => {
+ const min = item.minPrice?.replace(/\.00$/, '') || '--'
+ const max = item.maxPrice?.replace(/\.00$/, '') || min
+
+ return min === max ? `¥${min}` : `¥${min}-${max}`
+}
+
+function ShopPage() {
+ const { merchantId = '' } = useParams()
+ const navigate = useNavigate()
+ const token = useUserStore(state => state.token)
+ const userInfo = useUserStore(state => state.userInfo)
+ const storedMemberId = useUserStore(state => state.memberId)
+
+ const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0
+
+ const [headerKeyword, setHeaderKeyword] = useState('')
+ const [keyword, setKeyword] = useState('')
+ const [searchKeyword, setSearchKeyword] = useState('')
+ const [sortType, setSortType] = useState('comprehensive')
+ const [merchantBlock, setMerchantBlock] = useState(null)
+ const [goodsPayload, setGoodsPayload] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!merchantId) {
+ setError('商家参数缺失')
+ setLoading(false)
+ return
+ }
+
+ const controller = new AbortController()
+ setError(null)
+
+ apiAppHomeMerchantGoods({ uid: currentUid, merchantId, limit: 12 }, controller.signal)
+ .then(response => {
+ setMerchantBlock(response.data.data)
+ })
+ .catch(() => undefined)
+
+ return () => controller.abort()
+ }, [currentUid, merchantId])
+
+ useEffect(() => {
+ if (!merchantId) {
+ return
+ }
+
+ const controller = new AbortController()
+ setLoading(true)
+ setError(null)
+
+ apiAppGoodsPage(
+ {
+ uid: currentUid,
+ merchantId,
+ keyword: searchKeyword || undefined,
+ sortType,
+ page: 1,
+ size: 24,
+ },
+ controller.signal
+ )
+ .then(response => {
+ setGoodsPayload(response.data)
+ })
+ .catch(nextError => {
+ if (!controller.signal.aborted) {
+ setError(nextError instanceof Error ? nextError.message : '商家商品接口请求失败')
+ }
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) {
+ setLoading(false)
+ }
+ })
+
+ return () => controller.abort()
+ }, [currentUid, merchantId, searchKeyword, sortType])
+
+ const goodsList = useMemo(() => goodsPayload?.list ?? merchantBlock?.goods ?? [], [goodsPayload, merchantBlock])
+ const merchant = merchantBlock?.merchant ?? merchantBlock?.store ?? null
+ const merchantName = merchant?.storeName?.trim() || goodsList[0]?.merchantName?.trim() || '未命名商家'
+
+ const handleSearch = () => {
+ setSearchKeyword(keyword.trim())
+ }
+
+ const handleHeaderSearch = (nextKeyword: string) => {
+ const trimmedKeyword = nextKeyword.trim()
+ navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ 商品属性:
+ 图片:全部
+ 状态:上架
+ 视频:全部
+
+
+
+
+
+ 排序:
+ {GOODS_SORT_OPTIONS.map(item => (
+
+ ))}
+ 共 {goodsPayload?.total ?? goodsList.length} 件商品
+
+
+ {error ? {error}
: null}
+ {!error && goodsList.length > 0 ? (
+
+ {goodsList.map(item => (
+
+
+ {item.coverUrl ? (
+

+ ) : (
+
暂无主图
+ )}
+ {item.categoryName ?
{item.categoryName} : null}
+
+
+ {item.spuName}
+ {formatPrice(item)}
+
+
+ {item.merchantName || merchantName}
+
+
+
+ ))}
+
+ ) : null}
+ {!error && !loading && goodsList.length === 0 ? 暂无商品
: null}
+
+
+
+
+ )
+}
+
+export default ShopPage
diff --git a/src/store/user.ts b/src/store/user.ts
new file mode 100644
index 0000000..1b79449
--- /dev/null
+++ b/src/store/user.ts
@@ -0,0 +1,105 @@
+import { create } from 'zustand'
+import { createJSONStorage, persist } from 'zustand/middleware'
+import { apiGetUserInfo } from '@/apis/apiUser'
+import type { ITokenData } from '@/types/ITokenData'
+import type { IUserInfo } from '@/types/IUserInfo'
+import logger from '@/utils/logger'
+
+const STORAGE_KEY = 'mall-user-store'
+
+interface UserState {
+ token: ITokenData | null
+ userInfo: IUserInfo | null
+ memberId: number | null
+ refreshUsering: boolean
+ setToken: (token: ITokenData | null) => void
+ setUserInfo: (userInfo: IUserInfo | null) => void
+ setMemberId: (memberId: number | null) => void
+ setSession: (payload: {
+ token: ITokenData | null
+ memberId?: number | null
+ userInfo?: IUserInfo | null
+ }) => void
+ refreshUser: () => Promise
+ refreshAll: () => Promise
+ logout: () => void
+ clearStorage: () => void
+}
+
+export const useUserStore = create()(
+ persist(
+ (set, get) => ({
+ token: null,
+ userInfo: null,
+ memberId: null,
+ refreshUsering: false,
+ setToken: token => {
+ set({
+ token,
+ memberId: token?.memberId ?? token?.uid ?? get().memberId,
+ })
+ },
+ setUserInfo: userInfo => {
+ set({
+ userInfo,
+ memberId: userInfo?.uid ?? get().memberId,
+ })
+ },
+ setMemberId: memberId => {
+ set({ memberId })
+ },
+ setSession: ({ token, memberId, userInfo }) => {
+ set({
+ token,
+ userInfo: userInfo ?? get().userInfo,
+ memberId: memberId ?? userInfo?.uid ?? token?.memberId ?? token?.uid ?? null,
+ })
+ },
+ refreshUser: async () => {
+ try {
+ if (!get().token?.accessToken) {
+ return
+ }
+ set({ refreshUsering: true })
+ const response = await apiGetUserInfo()
+ set({
+ userInfo: response.data,
+ memberId: response.data.uid,
+ })
+ } catch (error) {
+ logger.warn('Refresh user failed', error)
+ } finally {
+ set({ refreshUsering: false })
+ }
+ },
+ refreshAll: async () => {
+ await get().refreshUser()
+ },
+ logout: () => {
+ get().clearStorage()
+ },
+ clearStorage: () => {
+ set({
+ token: null,
+ userInfo: null,
+ memberId: null,
+ })
+ localStorage.removeItem(STORAGE_KEY)
+ },
+ }),
+ {
+ name: STORAGE_KEY,
+ storage: createJSONStorage(() => localStorage),
+ partialize: state => ({
+ token: state.token,
+ userInfo: state.userInfo,
+ memberId: state.memberId,
+ }),
+ onRehydrateStorage: () => state => {
+ if (state?.token?.accessToken) {
+ void state.refreshAll()
+ }
+ },
+ }
+ )
+)
diff --git a/src/types/ITokenData.ts b/src/types/ITokenData.ts
new file mode 100644
index 0000000..fa099dc
--- /dev/null
+++ b/src/types/ITokenData.ts
@@ -0,0 +1,11 @@
+export interface ITokenData {
+ accessToken: string
+ accessExpire: number
+ refreshAfter: number
+ role?: string[]
+ utype?: string
+ uid?: number
+ account?: string
+ refreshToken?: string
+ memberId?: number
+}
diff --git a/src/types/IUserInfo.ts b/src/types/IUserInfo.ts
new file mode 100644
index 0000000..1046cdb
--- /dev/null
+++ b/src/types/IUserInfo.ts
@@ -0,0 +1,6 @@
+export interface IUserInfo {
+ uid: number
+ name: string
+ account: string
+}
+
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..40fe781
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,3 @@
+export * from './ITokenData'
+export * from './IUserInfo'
+export * from './mall'
diff --git a/src/types/mall.ts b/src/types/mall.ts
new file mode 100644
index 0000000..1c2b479
--- /dev/null
+++ b/src/types/mall.ts
@@ -0,0 +1,659 @@
+import type { IPageParams } from '@/apis/base/types'
+
+export type EnabledStatus = 'enabled' | 'disabled'
+export type AuditStatus = 'pending' | 'approved' | 'rejected'
+export type SettleStatus = 'pending' | 'active' | 'frozen'
+export type SaleStatus = 'off' | 'on'
+export type PayStatus = 'pending' | 'paid' | 'refunded'
+export type TradeStatus = 'pending' | 'paid' | 'closed' | 'finished' | 'refunding' | 'refunded'
+export type OrderStatus =
+ | 'pending_pay'
+ | 'paid_wait_ship'
+ | 'shipped'
+ | 'finished'
+ | 'canceled'
+ | 'after_sale'
+ | 'refund_finished'
+export type AfterSaleStatus =
+ | 'none'
+ | 'pending'
+ | 'approved'
+ | 'rejected'
+ | 'refunding'
+ | 'finished'
+ | 'closed'
+export type CartItemStatus = 'enabled' | 'disabled'
+export type GoodsSortType = 'comprehensive' | 'price_asc' | 'price_desc' | 'sales_desc'
+export type AfterSaleType = 1 | 2
+export type MerchantId = string
+export type HomePositionCode =
+ | 'home_top'
+ | 'home_rank_store'
+ | 'home_featured_store'
+ | 'home_featured_goods'
+export type BannerLinkType = 'goods' | 'store' | 'topic' | 'h5' | (string & {})
+
+export interface MediaJson {
+ images: string[]
+ videos: string[]
+}
+
+export interface DetailBlock {
+ type: string
+ value: string
+}
+
+export interface DetailJson {
+ blocks: DetailBlock[]
+}
+
+export interface SpecItem {
+ name: string
+ value: string
+}
+
+export interface ParamItem {
+ name: string
+ value: string
+}
+
+export interface ServicePromiseItem {
+ title: string
+ desc: string
+}
+
+export interface EvidenceJson {
+ images: string[]
+ videos: string[]
+}
+
+export interface ExtraInfoJson {
+ source?: string
+ tags?: string[]
+ [key: string]: unknown
+}
+
+export interface StoreInfo {
+ id: number
+ merchantId: MerchantId
+ merchantName?: string
+ logo?: string
+ storeNo: string
+ storeName: string
+ storeLogo: string
+ storeBanner: string
+ storeNotice: string
+ storeStatus: EnabledStatus
+ auditStatus: AuditStatus
+ servicePhone: string
+ serviceEmail: string
+ contactName: string
+ contactMobile: string
+ province: string
+ city: string
+ district: string
+ addressDetail: string
+ zipCode: string
+ sort: number
+ createdAt: number
+ updatedAt: number
+}
+
+export type MerchantInfo = StoreInfo
+
+export interface GoodsSkuInfo {
+ id: number
+ spuId: number
+ merchantId: MerchantId
+ storeId: number
+ skuNo: string
+ skuName: string
+ barcode: string
+ imageUrl: string
+ specJson: string
+ salePrice: string
+ originPrice: string
+ costPrice: string
+ weight: string
+ status: EnabledStatus
+ stock: number
+ lockedStock: number
+ warnStock: number
+ createdAt: number
+ updatedAt: number
+}
+
+export interface GoodsSummary {
+ id: number
+ merchantId: MerchantId
+ merchantName?: string
+ merchantLogo?: string
+ storeId: number
+ categoryId: number
+ categoryName: string
+ spuNo: string
+ spuName: string
+ subTitle: string
+ coverUrl: string
+ status: EnabledStatus
+ auditStatus: AuditStatus
+ saleStatus: SaleStatus
+ sort: number
+ salesVolume: number
+ virtualSales: number
+ viewCount: number
+ minPrice: string
+ maxPrice: string
+ totalStock: number
+ lockedStock: number
+ createdAt: number
+ updatedAt: number
+}
+
+export interface GoodsDetailInfo {
+ id: number
+ merchantId: MerchantId
+ storeId: number
+ categoryId: number
+ categoryName?: string
+ spuNo: string
+ spuName: string
+ subTitle: string
+ coverUrl: string
+ mediaJson: string
+ detailJson: string
+ extraInfoJson: string
+ status: EnabledStatus
+ auditStatus: AuditStatus
+ saleStatus: SaleStatus
+ sort: number
+ salesVolume: number
+ virtualSales: number
+ viewCount: number
+ minPrice: string
+ maxPrice: string
+ totalStock: number
+ lockedStock: number
+ paramJson: string
+ serviceJson: string
+ skus: GoodsSkuInfo[]
+ createdAt: number
+ updatedAt: number
+}
+
+export interface CartItemInfo {
+ id: number
+ uid: number
+ memberId?: number
+ merchantId: MerchantId
+ storeId: number
+ spuId: number
+ skuId: number
+ spuName: string
+ skuName: string
+ coverUrl: string
+ skuSpecJson: string
+ buyNum: number
+ checked: boolean
+ salePrice: string
+ status: CartItemStatus
+ createdAt: number
+ updatedAt: number
+}
+
+export interface TradeBuyItem {
+ skuId: number
+ buyNum: number
+}
+
+export interface TradePreviewItemInfo {
+ spuId: number
+ skuId: number
+ spuName: string
+ skuName: string
+ coverUrl: string
+ skuSpecJson: string
+ buyNum: number
+ salePrice: string
+ itemAmount: string
+}
+
+export interface TradePreviewStoreInfo {
+ merchantId: MerchantId
+ storeId: number
+ storeName: string
+ goodsAmount: string
+ freightAmount: string
+ discountAmount: string
+ payAmount: string
+ items: TradePreviewItemInfo[]
+}
+
+export interface TradePreviewInfo {
+ uid: number
+ memberId?: number
+ addressId: number
+ receiverName: string
+ receiverMobile: string
+ receiverCountryCode: string
+ receiverProvince: string
+ receiverCity: string
+ receiverDistrict: string
+ receiverAddress: string
+ receiverZipCode: string
+ buyerRemark: string
+ currency: string
+ goodsAmount: string
+ freightAmount: string
+ discountAmount: string
+ payAmount: string
+ orderCount: number
+ merchantGroups: TradePreviewStoreInfo[]
+ storeGroups?: TradePreviewStoreInfo[]
+}
+
+export interface OrderItemInfo {
+ id: number
+ orderId: number
+ tradeId: number
+ orderNo: string
+ spuId: number
+ skuId: number
+ spuName: string
+ skuName: string
+ coverUrl: string
+ skuSpecJson: string
+ buyNum: number
+ salePrice: string
+ originPrice: string
+ discountAmount: string
+ itemAmount: string
+ refundAmount: string
+ afterSaleStatus: AfterSaleStatus
+}
+
+export interface OrderSummary {
+ id: number
+ tradeId: number
+ tradeNo: string
+ orderNo: string
+ uid: number
+ memberId?: number
+ merchantId: MerchantId
+ storeId: number
+ storeName: string
+ orderStatus: OrderStatus
+ afterSaleStatus: AfterSaleStatus
+ currency: string
+ goodsAmount: string
+ freightAmount: string
+ discountAmount: string
+ payAmount: string
+ payTime: number
+ shipTime: number
+ finishTime: number
+ cancelTime: number
+ createdAt: number
+ updatedAt: number
+}
+
+export interface OrderDetailInfo {
+ id: number
+ tradeId: number
+ tradeNo: string
+ orderNo: string
+ uid: number
+ memberId?: number
+ merchantId: MerchantId
+ storeId: number
+ storeName: string
+ orderStatus: OrderStatus
+ afterSaleStatus: AfterSaleStatus
+ currency: string
+ goodsAmount: string
+ freightAmount: string
+ discountAmount: string
+ payAmount: string
+ buyerRemark: string
+ sellerRemark: string
+ receiverName: string
+ receiverMobile: string
+ receiverCountryCode: string
+ receiverProvince: string
+ receiverCity: string
+ receiverDistrict: string
+ receiverAddress: string
+ receiverZipCode: string
+ deliveryCompany: string
+ deliveryNo: string
+ payChannel: string
+ extraInfoJson: string
+ payTime: number
+ shipTime: number
+ finishTime: number
+ cancelTime: number
+ autoConfirmAt: number
+ items: OrderItemInfo[]
+ createdAt: number
+ updatedAt: number
+}
+
+export interface BannerInfo {
+ id: number
+ title: string
+ subTitle: string
+ description: string
+ buttonText: string
+ positionCode: HomePositionCode | string
+ imageUrl: string
+ linkType: BannerLinkType
+ linkValue: string
+ sort: number
+ status: EnabledStatus
+ startTime: number
+ endTime: number
+}
+
+export interface HomeCategoryNav {
+ categoryId: number
+ categoryName: string
+ goodsCount: number
+ sort: number
+}
+
+export interface HomeChannelCard {
+ id: number
+ title: string
+ subTitle: string
+ iconUrl: string
+ linkType: BannerLinkType
+ linkValue: string
+ sort: number
+ status: EnabledStatus
+}
+
+export interface RecommendStoreInfo {
+ id: string | number
+ merchantId: MerchantId
+ storeName: string
+ storeLogo: string
+ storeNotice: string
+ storeStatus: EnabledStatus
+ mainCategoryName: string
+ recommendReason: string
+ rankScore: number
+ goodsCount: number
+ recentNewGoodsCount: number
+ orderCount: number
+}
+
+export interface HomeStoreGoodsBlock {
+ merchant: RecommendStoreInfo
+ goods: GoodsSummary[]
+ store?: RecommendStoreInfo
+}
+
+export type RecommendMerchantInfo = RecommendStoreInfo
+export type HomeMerchantGoodsBlock = HomeStoreGoodsBlock
+export type TradePreviewMerchantInfo = TradePreviewStoreInfo
+
+export interface ResolvedGoodsSkuInfo extends GoodsSkuInfo {
+ specs: SpecItem[]
+}
+
+export interface ResolvedGoodsDetailInfo extends GoodsDetailInfo {
+ media: MediaJson
+ detail: DetailJson
+ extraInfo: ExtraInfoJson
+ params: ParamItem[]
+ services: ServicePromiseItem[]
+ skus: ResolvedGoodsSkuInfo[]
+}
+
+export interface ResolvedCartItemInfo extends CartItemInfo {
+ skuSpecs: SpecItem[]
+}
+
+export interface ResolvedTradePreviewItemInfo extends TradePreviewItemInfo {
+ skuSpecs: SpecItem[]
+}
+
+export interface ResolvedTradePreviewStoreInfo extends TradePreviewStoreInfo {
+ items: ResolvedTradePreviewItemInfo[]
+}
+
+export interface ResolvedTradePreviewInfo extends TradePreviewInfo {
+ merchantGroups: ResolvedTradePreviewStoreInfo[]
+ storeGroups?: ResolvedTradePreviewStoreInfo[]
+}
+
+export type ResolvedTradePreviewMerchantInfo = ResolvedTradePreviewStoreInfo
+
+export interface ResolvedOrderItemInfo extends OrderItemInfo {
+ skuSpecs: SpecItem[]
+}
+
+export interface ResolvedOrderDetailInfo extends OrderDetailInfo {
+ extraInfo: ExtraInfoJson
+ items: ResolvedOrderItemInfo[]
+}
+
+export interface AppHomeRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+}
+
+export interface AppHomeResponse {
+ banners: BannerInfo[]
+ categoryNavs: HomeCategoryNav[]
+ searchKeywords: string[]
+ channels: HomeChannelCard[]
+ recommendGoods: GoodsSummary[]
+ recommendMerchants: RecommendMerchantInfo[]
+ featuredMerchants: RecommendMerchantInfo[]
+ /** @deprecated use recommendMerchants */
+ recommendStores?: RecommendStoreInfo[]
+ /** @deprecated use featuredMerchants */
+ featuredStores?: RecommendStoreInfo[]
+}
+
+export interface RecentNewGoodsItem {
+ merchantId?: MerchantId
+ merchantName?: string
+ merchantLogo?: string
+ storeName?: string
+ spuId: string
+ spuName: string
+ coverUrl: string
+ createdAt: number
+}
+
+export interface AppRecentNewGoodsRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ page?: number
+ size?: number
+}
+
+export interface AppRecentNewGoodsResponse {
+ total: number
+ list: RecentNewGoodsItem[]
+}
+
+export interface AppHomeMerchantGoodsRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ merchantId?: MerchantId
+ /** @deprecated use merchantId */
+ storeId?: number
+ limit?: number
+}
+
+export interface AppHomeMerchantGoodsResponse {
+ data: HomeMerchantGoodsBlock
+}
+
+export interface AppGoodsPageRequest extends IPageParams {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ merchantId?: MerchantId
+ /** @deprecated use merchantId */
+ storeId?: number
+ categoryId?: number
+ keyword?: string
+ sortType?: GoodsSortType
+}
+
+export interface AppGoodsPageResponse {
+ total: number
+ list: GoodsSummary[]
+}
+
+export interface AppGoodsDetailRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ spuId: number | string
+}
+
+export interface AppGoodsDetailResponse {
+ data: GoodsDetailInfo
+ merchant: MerchantInfo
+ /** @deprecated use merchant */
+ store?: StoreInfo
+}
+
+export interface AppCartListRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+}
+
+export interface AppCartListResponse {
+ list: CartItemInfo[]
+ checkedCount: number
+ checkedAmount: string
+}
+
+export interface AppCartSaveRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ skuId: number
+ buyNum: number
+ checked: boolean
+}
+
+export interface AppCartSaveResponse {
+ cartId: number
+ buyNum: number
+ checked: boolean
+}
+
+export interface AppCartRemoveRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ skuIds: number[]
+}
+
+export interface AppCartRemoveResponse {
+ removedCount: number
+}
+
+export interface AppTradePreviewRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ buyItems?: TradeBuyItem[]
+ addressId: number
+ buyerRemark?: string
+}
+
+export interface AppTradePreviewResponse {
+ data: TradePreviewInfo
+}
+
+export interface AppTradeCreateRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ buyItems?: TradeBuyItem[]
+ addressId: number
+ buyerRemark?: string
+ payChannel: string
+}
+
+export interface AppTradeCreateResponse {
+ tradeId: number
+ tradeNo: string
+ payStatus: PayStatus
+ tradeStatus: TradeStatus
+ payAmount: string
+ expiredAt: number
+ orderIds: number[]
+ orderNos: string[]
+}
+
+export interface AppOrderPageRequest extends IPageParams {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ orderStatus?: OrderStatus
+ afterSaleStatus?: AfterSaleStatus
+}
+
+export interface AppOrderPageResponse {
+ total: number
+ list: OrderSummary[]
+}
+
+export interface AppOrderDetailRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ orderId: number
+}
+
+export interface AppOrderDetailResponse {
+ data: OrderDetailInfo
+}
+
+export interface AppOrderConfirmRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ orderId: number
+}
+
+export interface AppOrderConfirmResponse {
+ orderId: number
+ orderStatus: OrderStatus
+ finishTime: number
+}
+
+export interface AppAfterSaleApplyRequest {
+ uid?: number
+ /** @deprecated use uid */
+ memberId?: number
+ orderId: number
+ orderItemId: number
+ afterSaleType: AfterSaleType
+ reason: string
+ description?: string
+ evidenceJson?: string
+ applyAmount: string
+}
+
+export interface AppAfterSaleApplyResponse {
+ afterSaleId: number
+ afterSaleNo: string
+ afterSaleStatus: Exclude
+}
+
+export type AppHomeStoreGoodsRequest = AppHomeMerchantGoodsRequest
+export type AppHomeStoreGoodsResponse = AppHomeMerchantGoodsResponse
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
new file mode 100644
index 0000000..770d8ef
--- /dev/null
+++ b/src/utils/logger.ts
@@ -0,0 +1,74 @@
+type LogLevel = 'error' | 'warn' | 'info' | 'log' | 'debug'
+
+const levelWeight: Record = {
+ error: 4,
+ warn: 3,
+ info: 2,
+ log: 2,
+ debug: 1,
+}
+
+const getEnabled = () => {
+ const value = (import.meta.env.VITE_ENABLE_LOGS ?? '').toString().trim().toLowerCase()
+ if (value === 'false' || value === '0' || value === 'off' || value === 'no') {
+ return false
+ }
+ if (value === 'true' || value === '1' || value === 'on' || value === 'yes') {
+ return true
+ }
+ return import.meta.env.MODE !== 'production'
+}
+
+const getMinLevel = (): LogLevel => {
+ const value = (import.meta.env.VITE_LOG_LEVEL ?? '').toString().trim().toLowerCase()
+ if (value === 'error' || value === 'warn' || value === 'info' || value === 'log' || value === 'debug') {
+ return value
+ }
+ return 'warn'
+}
+
+const shouldLog = (level: LogLevel) => {
+ if (!getEnabled()) {
+ return false
+ }
+ return levelWeight[level] >= levelWeight[getMinLevel()]
+}
+
+export class WebLogger {
+ private static prefix = '[mall-app]'
+
+ static log(...args: unknown[]) {
+ if (shouldLog('log')) {
+ console.log(this.prefix, ...args)
+ }
+ }
+
+ static info(...args: unknown[]) {
+ if (shouldLog('info')) {
+ console.info(this.prefix, ...args)
+ }
+ }
+
+ static warn(...args: unknown[]) {
+ if (shouldLog('warn')) {
+ console.warn(this.prefix, ...args)
+ }
+ }
+
+ static error(...args: unknown[]) {
+ if (shouldLog('error')) {
+ console.error(this.prefix, ...args)
+ }
+ }
+
+ static debug(...args: unknown[]) {
+ if (shouldLog('debug')) {
+ console.debug(this.prefix, ...args)
+ }
+ }
+}
+
+export const logger = WebLogger
+
+export default logger
+
diff --git a/src/utils/mall/home.ts b/src/utils/mall/home.ts
new file mode 100644
index 0000000..11ec83e
--- /dev/null
+++ b/src/utils/mall/home.ts
@@ -0,0 +1,39 @@
+import type {
+ AppHomeResponse,
+ BannerInfo,
+ GoodsSummary,
+ HomeCategoryNav,
+ HomeChannelCard,
+ RecommendStoreInfo,
+} from '@/types/mall'
+
+const bySort = (a: T, b: T) => a.sort - b.sort
+
+const isEnabledStatus = (item: T) => item.status === 'enabled'
+
+const isEnabledStore = (store: RecommendStoreInfo) => store.storeStatus === 'enabled'
+
+const isOnSaleGoods = (item: GoodsSummary) => item.saleStatus === 'on'
+
+export const resolveHomeTopBanners = (payload: AppHomeResponse | null): BannerInfo[] =>
+ (payload?.banners ?? [])
+ .filter(item => item.positionCode === 'home_top' && isEnabledStatus(item))
+ .sort(bySort)
+
+export const resolveHomeCategoryNavs = (payload: AppHomeResponse | null): HomeCategoryNav[] =>
+ [...(payload?.categoryNavs ?? [])].sort(bySort)
+
+export const resolveHomeSearchKeywords = (payload: AppHomeResponse | null): string[] =>
+ (payload?.searchKeywords ?? []).filter(Boolean)
+
+export const resolveHomeChannels = (payload: AppHomeResponse | null): HomeChannelCard[] =>
+ (payload?.channels ?? []).filter(isEnabledStatus).sort(bySort)
+
+export const resolveHomeRecommendGoods = (payload: AppHomeResponse | null): GoodsSummary[] =>
+ (payload?.recommendGoods ?? []).filter(isOnSaleGoods)
+
+export const resolveHomeRankStores = (payload: AppHomeResponse | null): RecommendStoreInfo[] =>
+ (payload?.recommendMerchants ?? payload?.recommendStores ?? []).filter(isEnabledStore)
+
+export const resolveHomeFeaturedStores = (payload: AppHomeResponse | null): RecommendStoreInfo[] =>
+ (payload?.featuredMerchants ?? payload?.featuredStores ?? []).filter(isEnabledStore)
diff --git a/src/utils/mall/index.ts b/src/utils/mall/index.ts
new file mode 100644
index 0000000..79ed109
--- /dev/null
+++ b/src/utils/mall/index.ts
@@ -0,0 +1,2 @@
+export * from './transform'
+export * from './home'
diff --git a/src/utils/mall/transform.ts b/src/utils/mall/transform.ts
new file mode 100644
index 0000000..1557910
--- /dev/null
+++ b/src/utils/mall/transform.ts
@@ -0,0 +1,133 @@
+import type {
+ CartItemInfo,
+ DetailJson,
+ EvidenceJson,
+ ExtraInfoJson,
+ GoodsDetailInfo,
+ GoodsSkuInfo,
+ MediaJson,
+ OrderDetailInfo,
+ OrderItemInfo,
+ ParamItem,
+ ResolvedCartItemInfo,
+ ResolvedGoodsDetailInfo,
+ ResolvedGoodsSkuInfo,
+ ResolvedOrderDetailInfo,
+ ResolvedOrderItemInfo,
+ ResolvedTradePreviewInfo,
+ ResolvedTradePreviewItemInfo,
+ ResolvedTradePreviewStoreInfo,
+ ServicePromiseItem,
+ SpecItem,
+ TradePreviewInfo,
+ TradePreviewItemInfo,
+ TradePreviewStoreInfo,
+} from '@/types/mall'
+import logger from '@/utils/logger'
+
+const EMPTY_MEDIA_JSON: MediaJson = {
+ images: [],
+ videos: [],
+}
+
+const EMPTY_DETAIL_JSON: DetailJson = {
+ blocks: [],
+}
+
+const EMPTY_EVIDENCE_JSON: EvidenceJson = {
+ images: [],
+ videos: [],
+}
+
+const EMPTY_EXTRA_INFO_JSON: ExtraInfoJson = {}
+const EMPTY_NAME_VALUE_LIST: SpecItem[] = []
+const EMPTY_PARAM_LIST: ParamItem[] = []
+const EMPTY_SERVICE_LIST: ServicePromiseItem[] = []
+
+const parseJson = (fieldName: string, value: string | null | undefined, fallback: T): T => {
+ if (!value) {
+ return fallback
+ }
+
+ try {
+ return JSON.parse(value) as T
+ } catch (error) {
+ logger.warn(`Failed to parse ${fieldName}`, error, value)
+ return fallback
+ }
+}
+
+export const parseMediaJson = (value?: string | null): MediaJson =>
+ parseJson('mediaJson', value, EMPTY_MEDIA_JSON)
+
+export const parseDetailJson = (value?: string | null): DetailJson =>
+ parseJson('detailJson', value, EMPTY_DETAIL_JSON)
+
+export const parseSpecJson = (value?: string | null): SpecItem[] =>
+ parseJson('specJson', value, EMPTY_NAME_VALUE_LIST)
+
+export const parseParamJson = (value?: string | null): ParamItem[] =>
+ parseJson('paramJson', value, EMPTY_PARAM_LIST)
+
+export const parseServiceJson = (value?: string | null): ServicePromiseItem[] =>
+ parseJson('serviceJson', value, EMPTY_SERVICE_LIST)
+
+export const parseEvidenceJson = (value?: string | null): EvidenceJson =>
+ parseJson('evidenceJson', value, EMPTY_EVIDENCE_JSON)
+
+export const parseExtraInfoJson = (value?: string | null): ExtraInfoJson =>
+ parseJson('extraInfoJson', value, EMPTY_EXTRA_INFO_JSON)
+
+export const serializeEvidenceJson = (value?: EvidenceJson | null): string =>
+ JSON.stringify(value ?? EMPTY_EVIDENCE_JSON)
+
+export const resolveGoodsSkuInfo = (sku: GoodsSkuInfo): ResolvedGoodsSkuInfo => ({
+ ...sku,
+ specs: parseSpecJson(sku.specJson),
+})
+
+export const resolveGoodsDetailInfo = (detail: GoodsDetailInfo): ResolvedGoodsDetailInfo => ({
+ ...detail,
+ media: parseMediaJson(detail.mediaJson),
+ detail: parseDetailJson(detail.detailJson),
+ extraInfo: parseExtraInfoJson(detail.extraInfoJson),
+ params: parseParamJson(detail.paramJson),
+ services: parseServiceJson(detail.serviceJson),
+ skus: detail.skus.map(resolveGoodsSkuInfo),
+})
+
+export const resolveCartItemInfo = (item: CartItemInfo): ResolvedCartItemInfo => ({
+ ...item,
+ skuSpecs: parseSpecJson(item.skuSpecJson),
+})
+
+export const resolveTradePreviewItemInfo = (
+ item: TradePreviewItemInfo
+): ResolvedTradePreviewItemInfo => ({
+ ...item,
+ skuSpecs: parseSpecJson(item.skuSpecJson),
+})
+
+export const resolveTradePreviewStoreInfo = (
+ group: TradePreviewStoreInfo
+): ResolvedTradePreviewStoreInfo => ({
+ ...group,
+ items: group.items.map(resolveTradePreviewItemInfo),
+})
+
+export const resolveTradePreviewInfo = (preview: TradePreviewInfo): ResolvedTradePreviewInfo => ({
+ ...preview,
+ merchantGroups: (preview.merchantGroups ?? preview.storeGroups ?? []).map(resolveTradePreviewStoreInfo),
+ storeGroups: (preview.merchantGroups ?? preview.storeGroups ?? []).map(resolveTradePreviewStoreInfo),
+})
+
+export const resolveOrderItemInfo = (item: OrderItemInfo): ResolvedOrderItemInfo => ({
+ ...item,
+ skuSpecs: parseSpecJson(item.skuSpecJson),
+})
+
+export const resolveOrderDetailInfo = (detail: OrderDetailInfo): ResolvedOrderDetailInfo => ({
+ ...detail,
+ extraInfo: parseExtraInfoJson(detail.extraInfoJson),
+ items: detail.items.map(resolveOrderItemInfo),
+})
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..ed77210
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..d5da4ed
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "allowSyntheticDefaultImports": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
+
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..4e91a73
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
+
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..b531d12
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,17 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts"]
+}
+
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..616ed10
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react-swc'
+import { dirname, resolve } from 'path'
+import { fileURLToPath } from 'url'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ port: 5000,
+ },
+})
+