feat(mobile): 新增解忧商行移动端首页、详情与个人中心

This commit is contained in:
yml2213
2026-10-30 12:26:31 +08:00
parent 97167fa482
commit ef01486e53
14 changed files with 1499 additions and 10 deletions
+2 -1
View File
@@ -4,9 +4,10 @@
## 当前状态
**Phase 0 已完成,Phase 1 基础切片已完成。** 后端已复制并跑通空库迁移、构建、健康检查与全量测试;前端已完成 SVG 品牌资产、用户首页/登录/商品列表、管理员验证码登录、强制改密、浅色后台 Shell 与真实仪表盘。
**Phase 0 已完成,Phase 1 基础切片已完成。** 后端已复制并跑通空库迁移、构建、健康检查与全量测试;前端已完成 SVG 品牌资产、用户首页/登录/商品列表、移动端 `/m` 首页/详情/个人中心、管理员验证码登录、强制改密、浅色后台 Shell 与真实仪表盘。
- 前端:`http://localhost:5289/`
- 移动端:`http://localhost:5289/m`
- 后台:`http://localhost:5289/console-jy8k3m/login`
- 后端:`http://localhost:18089/`
- 健康检查:`http://localhost:18089/health/ready`
+25 -6
View File
@@ -89,9 +89,22 @@
`AppRouter` 已改为 `React.lazy + Suspense`:
- 首页商品列表、用户登录、后台登录、后台改密、后台仪表盘、后台 Shell 分别按路由加载。
- 移动端 Layout、首页、详情、个人中心独立分包。
- 后台代码不会在用户首页首屏直接执行。
- 已移除会提前触发后台模块加载的顶层 `import()`。
### 1.8 移动端 `/m` 基础切片
已引入 `antd-mobile` + `antd-mobile-icons`:
- `MobileLayout`:品牌顶栏、底部 TabBar(首页 / 订单 / 消息 / 我的)、详情页自动隐藏 TabBar。
- `MobileHomePage`:深绿品牌首屏、搜索、快捷筛选、PullToRefresh、商品卡列表、空/错误状态。
- `MobileListingDetailPage`:商品图、价格/押金/哈夫币、账号说明、资产摘要、在线时间、固定底部操作栏。
- `MobileProfilePage`:登录状态、实名状态、订单/消息入口、退出登录。
- `/m/orders`、`/m/messages` 先用统一占位页,下一批迁移真实业务。
- 移动端 CSS 变量已接入解忧商行品牌色,不使用 Vant/旧站视觉。
---
## 2. 验证结果
@@ -126,6 +139,10 @@ listings = ok,返回 1 件联调商品
```text
/console-jy8k3m/login HTTP 200
/favicon.svg HTTP 200
/m HTTP 200
/m/listings/1 HTTP 200
/m/login HTTP 200
/api/listings/1 code=ok,返回联调商品详情
```
---
@@ -149,9 +166,11 @@ listings = ok,返回 1 件联调商品
当前生产构建:
```text
dist/assets/index-*.js 884.51 kB │ gzip 288.64 kB
dist/assets/AdminLayout-*.js 36.74 kB │ gzip 12.98 kB
dist/assets/AdminDashboardPage-*.js 179.67 kB │ gzip 58.44 kB
dist/assets/index-*.js 886.81 kB │ gzip 289.33 kB
dist/assets/AdminLayout-*.js 36.75 kB │ gzip 12.98 kB
dist/assets/AdminDashboardPage-*.js 179.71 kB │ gzip 58.46 kB
dist/assets/MobileHomePage-*.js 36.96 kB │ gzip 13.37 kB
dist/assets/MobileListingDetailPage-*.js 27.31 kB │ gzip 10.70 kB
```
**问题:** 首屏主包仍偏大,超过 Phase 1 目标。
@@ -162,19 +181,19 @@ dist/assets/AdminDashboardPage-*.js 179.67 kB │ gzip 58.44 kB
## 5. 已知限制
1. 移动端 `/m` 尚未开始迁移。
1. 移动端订单与消息页仍是占位,下单、客服会话尚未接入。
2. 后台 43 个页面目前只有仪表盘是真实页面,其余为统一占位。
3. 分销后台尚未做品牌轻改。
4. 后台菜单权限码基于旧项目推断,后续需与后端权限表逐一核对。
5. SVG 中文尚未转曲。
6. 首屏包体仍偏大。
7. 本批次尚未提交 Git。
7. 本移动端批次尚未提交 Git。
---
## 6. 建议下一批次
1. 移动端 `/m` 首页 + 商品列表 + 底部导航。
1. 移动端订单列表、订单详情、消息与聊天。
2. 后台列表页原型:DataTable、FilterBar、详情 Drawer、批量操作。
3. 先迁移后台订单列表与商品审核列表。
4. 分销后台主题轻改。
+1 -1
View File
@@ -352,7 +352,7 @@ hfb_jysh/
### Phase 1:品牌与设计系统(1 周)
> 状态:**基础切片已完成(2026-10-30)**,验收见 [`Phase1-验收报告.md`](./Phase1-验收报告.md)。已完成 SVG 品牌资产、管理员会话、后台登录/强制改密/Shell/仪表盘与路由懒加载;移动端与后台列表页原型继续。
> 状态:**基础切片已完成(2026-10-30)**,验收见 [`Phase1-验收报告.md`](./Phase1-验收报告.md)。已完成 SVG 品牌资产、管理员会话、后台登录/强制改密/Shell/仪表盘、路由懒加载与移动端 `/m` 首页/详情/个人中心;后台列表页原型与移动订单/消息继续。
- 重绘 logo SVG:横版、方形印章、单色、反白。
- 落地 Design Token:颜色、字体、间距、圆角、阴影、动效。
+303
View File
@@ -11,6 +11,8 @@
"@ant-design/icons": "^6.0.0",
"@tanstack/react-query": "^5.90.5",
"antd": "^5.27.6",
"antd-mobile": "^5.43.0",
"antd-mobile-icons": "^0.3.0",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"react": "^19.1.1",
@@ -865,6 +867,31 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1108,6 +1135,18 @@
"react-dom": ">=18.0.0"
}
},
"node_modules/@react-spring/rafz": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz",
"integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==",
"license": "MIT"
},
"node_modules/@react-spring/types": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz",
"integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1618,6 +1657,24 @@
"@types/react": "^19.3.0"
}
},
"node_modules/@use-gesture/core": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.0.tgz",
"integrity": "sha512-rh+6MND31zfHcy9VU3dOZCqGY511lvGcfyJenN4cWZe0u1BH6brBpBddLVXhF2r4BMqWbvxfsbL7D287thJU2A==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.0.tgz",
"integrity": "sha512-3zc+Ve99z4usVP6l9knYVbVnZgfqhKah7sIG+PS2w+vpig2v2OLct05vs+ZXMzwxdNCMka8B+8WlOo0z6Pn6DA==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.0"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1651,6 +1708,26 @@
"node": ">= 6.0.0"
}
},
"node_modules/ahooks": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.11.0.tgz",
"integrity": "sha512-mQML7DrxZo7xvxm145NTMowE9m75tflSayKjlwEMzwQSRFguweNCdCSIq88aQ4RRfVMugBrH8AtGresJwqMy5A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0",
"dayjs": "^1.9.1",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"react-fast-compare": "^3.2.2",
"resize-observer-polyfill": "^1.5.1",
"screenfull": "^5.0.0",
"tslib": "^2.4.1"
},
"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"
}
},
"node_modules/antd": {
"version": "5.29.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@@ -1716,6 +1793,160 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/antd-mobile": {
"version": "5.43.0",
"resolved": "https://registry.npmjs.org/antd-mobile/-/antd-mobile-5.43.0.tgz",
"integrity": "sha512-Pe8P/hHmv1vDHzbGuT6C23SJPeiH7ryESi001eiNqEm9WzEgyKhV60T69Mt26fUvkF7k6Wd75cMcdDziGYeJbw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.4.2",
"@rc-component/mini-decimal": "^1.1.0",
"@react-spring/web": "~9.6.1",
"@use-gesture/react": "10.3.0",
"ahooks": "^3.7.6",
"antd-mobile-icons": "^0.3.0",
"antd-mobile-v5-count": "^1.0.1",
"classnames": "^2.3.2",
"dayjs": "^1.11.7",
"deepmerge": "^4.3.1",
"nano-memoize": "^3.0.16",
"rc-field-form": "^1.34.2",
"rc-segmented": "~2.4.1",
"rc-util": "^5.44.4",
"react-fast-compare": "^3.2.2",
"react-is": "^18.2.0",
"runes2": "^1.1.2",
"staged-components": "^1.1.3",
"tslib": "^2.5.0",
"use-sync-external-store": "^1.2.0"
},
"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"
}
},
"node_modules/antd-mobile-icons": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/antd-mobile-icons/-/antd-mobile-icons-0.3.0.tgz",
"integrity": "sha512-rqINQpJWZWrva9moCd1Ye695MZYWmqLPE+bY8d2xLRy7iSQwPsinCdZYjpUPp2zL/LnKYSyXxP2ut2A+DC+whQ==",
"license": "MIT"
},
"node_modules/antd-mobile-v5-count": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/antd-mobile-v5-count/-/antd-mobile-v5-count-1.0.1.tgz",
"integrity": "sha512-YGsiEDCPUDz3SzfXi6gLZn/HpeSMW+jgPc4qiYUr1fSopg3hkUie2TnooJdExgfiETHefH3Ggs58He0OVfegLA==",
"license": "MIT"
},
"node_modules/antd-mobile/node_modules/@react-spring/web": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.6.1.tgz",
"integrity": "sha512-X2zR6q2Z+FjsWfGAmAXlQaoUHbPmfuCaXpuM6TcwXPpLE1ZD4A1eys/wpXboFQmDkjnrlTmKvpVna1MjWpZ5Hw==",
"license": "MIT",
"dependencies": {
"@react-spring/animated": "~9.6.1",
"@react-spring/core": "~9.6.1",
"@react-spring/shared": "~9.6.1",
"@react-spring/types": "~9.6.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/antd-mobile/node_modules/@react-spring/web/node_modules/@react-spring/animated": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz",
"integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==",
"license": "MIT",
"dependencies": {
"@react-spring/shared": "~9.6.1",
"@react-spring/types": "~9.6.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/antd-mobile/node_modules/@react-spring/web/node_modules/@react-spring/core": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz",
"integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==",
"license": "MIT",
"dependencies": {
"@react-spring/animated": "~9.6.1",
"@react-spring/rafz": "~9.6.1",
"@react-spring/shared": "~9.6.1",
"@react-spring/types": "~9.6.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-spring/donate"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/antd-mobile/node_modules/@react-spring/web/node_modules/@react-spring/shared": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz",
"integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==",
"license": "MIT",
"dependencies": {
"@react-spring/rafz": "~9.6.1",
"@react-spring/types": "~9.6.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/antd-mobile/node_modules/rc-field-form": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-1.44.0.tgz",
"integrity": "sha512-el7w87fyDUsca63Y/s8qJcq9kNkf/J5h+iTdqG5WsSHLH0e6Usl7QuYSmSVzJMgtp40mOVZIY/W/QP9zwrp1FA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.0",
"async-validator": "^4.1.0",
"rc-util": "^5.32.2"
},
"engines": {
"node": ">=8.x"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/antd-mobile/node_modules/rc-segmented": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.4.1.tgz",
"integrity": "sha512-KUi+JJFdKnumV9iXlm+BJ00O4NdVBp2TEexLCk6bK1x/RH83TvYKQMzIz/7m3UTRPD08RM/8VG/JNjWgWbd4cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.1",
"classnames": "^2.2.1",
"rc-motion": "^2.4.4",
"rc-util": "^5.17.0"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/antd-mobile/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/antd-mobile/node_modules/staged-components": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/staged-components/-/staged-components-1.1.3.tgz",
"integrity": "sha512-9EIswzDqjwlEu+ymkV09TTlJfzSbKgEnNteUnZSTxkpMgr5Wx2CzzA9WcMFWBNCldqVPsHVnRGGrApduq2Se5A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/antd/node_modules/@ant-design/colors": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz",
@@ -1757,6 +1988,12 @@
"react-dom": ">=16.0.0"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1947,6 +2184,15 @@
}
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2269,6 +2515,12 @@
"integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
"license": "MIT"
},
"node_modules/js-cookie": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2311,6 +2563,12 @@
"node": ">=6"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2357,6 +2615,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nano-memoize": {
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/nano-memoize/-/nano-memoize-3.0.16.tgz",
"integrity": "sha512-JyK96AKVGAwVeMj3MoMhaSXaUNqgMbCRSQB3trUV8tYZfWEzqUBKdK1qJpfuNXgKeHOx1jv/IEYTM659ly7zUA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -3077,6 +3341,12 @@
"react": "^19.3.1"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "19.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz",
@@ -3183,12 +3453,30 @@
"fsevents": "~2.3.2"
}
},
"node_modules/runes2": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.10.tgz",
"integrity": "sha512-4Y1en7X+3i0pf9NDPMEerT1CkZlExpQJPnEPlrBXzXSIHO3etyZR6Wbpl+hHWVYRFnhPVCyPHaJks6GcCFgEiA==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/screenfull": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
@@ -3268,6 +3556,12 @@
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3320,6 +3614,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz",
+2
View File
@@ -13,6 +13,8 @@
"@ant-design/icons": "^6.0.0",
"@tanstack/react-query": "^5.90.5",
"antd": "^5.27.6",
"antd-mobile": "^5.43.0",
"antd-mobile-icons": "^0.3.0",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"react": "^19.1.1",
+47
View File
@@ -11,6 +11,27 @@ const ListingListPage = lazy(() =>
const LoginPage = lazy(() =>
import('@/features/auth/LoginPage').then(module => ({ default: module.LoginPage }))
)
const MobileLayout = lazy(() =>
import('@/layouts/MobileLayout').then(module => ({ default: module.MobileLayout }))
)
const MobileHomePage = lazy(() =>
import('@/features/mobile/MobileHomePage').then(module => ({ default: module.MobileHomePage }))
)
const MobileListingDetailPage = lazy(() =>
import('@/features/mobile/MobileListingDetailPage').then(module => ({
default: module.MobileListingDetailPage,
}))
)
const MobileProfilePage = lazy(() =>
import('@/features/mobile/MobileProfilePage').then(module => ({
default: module.MobileProfilePage,
}))
)
const MobilePlaceholderPage = lazy(() =>
import('@/features/mobile/MobilePlaceholderPage').then(module => ({
default: module.MobilePlaceholderPage,
}))
)
const AdminLoginPage = lazy(() =>
import('@/features/admin/AdminLoginPage').then(module => ({ default: module.AdminLoginPage }))
)
@@ -60,6 +81,32 @@ export function AppRouter() {
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/m/login" element={<LoginPage />} />
<Route path="/m" element={<MobileLayout />}>
<Route index element={<MobileHomePage />} />
<Route path="listings" element={<MobileHomePage />} />
<Route path="listings/:id" element={<MobileListingDetailPage />} />
<Route
path="orders"
element={
<MobilePlaceholderPage
title="订单中心"
description="订单列表、交接、归还和结账会在下一批迁移到这里。"
/>
}
/>
<Route
path="messages"
element={
<MobilePlaceholderPage
title="消息中心"
description="订单群聊、客服会话和系统通知会在下一批迁移到这里。"
/>
}
/>
<Route path="profile" element={<MobileProfilePage />} />
</Route>
<Route path={`${adminBase}/login`} element={<AdminLoginPage />} />
<Route path={adminBase} element={<AdminGuard />}>
+2 -1
View File
@@ -29,7 +29,8 @@ export function LoginPage() {
await login(values.phone.trim(), values.password)
message.success('登录成功')
const redirect = searchParams.get('redirect')
navigate(redirect && redirect.startsWith('/') ? redirect : '/', { replace: true })
const fallbackPath = window.location.pathname.startsWith('/m') ? '/m' : '/'
navigate(redirect && redirect.startsWith('/') ? redirect : fallbackPath, { replace: true })
} catch (err) {
const text =
err instanceof Error && err.message ? err.message : '登录没有完成,请核对手机号和密码'
+13 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse, PublicListingPage } from '@/shared/api/types'
import type { ApiResponse, Listing, PublicListingPage } from '@/shared/api/types'
export interface ListingQuery {
page?: number
@@ -30,3 +30,15 @@ export function useListings(query: ListingQuery) {
queryFn: () => fetchListings(query),
})
}
export function fetchListingDetail(id: number | string) {
return unwrapData<Listing>(apiClient.get<ApiResponse<Listing>>(`/listings/${id}`))
}
export function useListingDetail(id?: string) {
return useQuery({
queryKey: ['listing-detail', id],
queryFn: () => fetchListingDetail(id as string),
enabled: Boolean(id),
})
}
@@ -0,0 +1,166 @@
import { useState } from 'react'
import { Button, Empty, ErrorBlock, PullToRefresh, SearchBar, Skeleton, Tag } from 'antd-mobile'
import { RightOutline, SearchOutline } from 'antd-mobile-icons'
import { Link } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useListings } from '@/features/listings/api'
import type { Listing } from '@/shared/api/types'
const quickFilters = [
{ label: '全部', keyword: '' },
{ label: '国服', keyword: '国服' },
{ label: '高资产', keyword: '高资产' },
{ label: '可租', keyword: '' },
]
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
}
function MobileListingCard({ item }: { item: Listing }) {
return (
<Link to={`/m/listings/${item.id}`} className="mobile-listing-card">
<div className="mobile-listing-card__cover">
{item.cover_url ? (
<img src={item.cover_url} alt={item.title} loading="lazy" />
) : (
<BrandLogo height={38} variant="seal" />
)}
</div>
<div className="mobile-listing-card__body">
<div className="mobile-listing-card__top">
<span className="mobile-listing-card__no">{item.listing_no}</span>
<Tag color={item.in_transaction ? 'default' : 'success'} fill="outline">
{item.in_transaction ? '租赁中' : '可租'}
</Tag>
</div>
<h3>{item.title}</h3>
<div className="mobile-listing-card__tags">
<span>{item.server_region || '区服待定'}</span>
<span>{item.rank_level || '段位待定'}</span>
<span>哈夫币 {item.haf_coin_amount.toLocaleString()}</span>
</div>
<div className="mobile-listing-card__bottom">
<div>
<strong>{money(item.price_cent)}</strong>
<small>押金 {money(item.deposit_amount_cent)}</small>
</div>
<span className="mobile-listing-card__more">
查看 <RightOutline />
</span>
</div>
</div>
</Link>
)
}
export function MobileHomePage() {
const [keyword, setKeyword] = useState('')
const [searchValue, setSearchValue] = useState('')
const { data, isPending, isError, refetch } = useListings({
page: 1,
page_size: 20,
keyword,
})
const applyFilter = (value: string) => {
setKeyword(value)
setSearchValue(value)
}
return (
<div className="mobile-home">
<section className="mobile-hero">
<div className="mobile-hero__eyebrow">JIEYOU TRADING HOUSE</div>
<h1>
租得明白,
<br />
还得安心。
</h1>
<p>实名审核 · 流程留痕 · 账单逐项列明</p>
<SearchBar
className="mobile-search"
value={searchValue}
onChange={setSearchValue}
onSearch={value => setKeyword(value.trim())}
placeholder="搜索区服、段位、装备或商品号"
searchIcon={<SearchOutline />}
/>
<div className="mobile-chips">
{quickFilters.map(item => (
<button
type="button"
key={item.label}
className={keyword === item.keyword && item.label !== '全部' ? 'is-active' : ''}
onClick={() => applyFilter(item.keyword)}
>
{item.label}
</button>
))}
</div>
</section>
<section className="mobile-trust">
<span>实名审核</span>
<span>交接留痕</span>
<span>争议可申诉</span>
</section>
<section className="mobile-list-section">
<div className="mobile-section-head">
<div>
<span className="mobile-eyebrow">CURATED ACCOUNTS</span>
<h2>本周柜选</h2>
</div>
<span className="mobile-count">共 {data?.total ?? 0} 件</span>
</div>
{isError ? (
<div className="mobile-error">
<ErrorBlock
status="disconnected"
title="商品没有加载出来"
description="后端服务可能暂时不可用,你的账号和订单不受影响。"
/>
<Button color="primary" size="small" onClick={() => void refetch()}>
重试
</Button>
</div>
) : null}
<PullToRefresh onRefresh={() => refetch().then(() => undefined)}>
{isPending ? (
<div className="mobile-skeleton-list">
{Array.from({ length: 3 }).map((_, index) => (
<div className="mobile-skeleton-card" key={index}>
<Skeleton animated className="mobile-skeleton-cover" />
<Skeleton.Paragraph lineCount={3} animated />
</div>
))}
</div>
) : data?.items.length ? (
<div className="mobile-listing-list">
{data.items.map(item => (
<MobileListingCard item={item} key={item.id} />
))}
</div>
) : (
<div className="mobile-empty">
<Empty description="还没有符合条件的商品" />
<Button size="small" color="primary" onClick={() => applyFilter('')}>
查看全部
</Button>
</div>
)}
</PullToRefresh>
</section>
<section className="mobile-rules">
<span className="mobile-eyebrow">RULES, IN PLAIN WORDS</span>
<h2>先把规则说明白</h2>
<p>押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注不参与系统结算。</p>
</section>
</div>
)
}
@@ -0,0 +1,155 @@
import { Button, ErrorBlock, Skeleton, Tag, Toast } from 'antd-mobile'
import { LeftOutline, PhoneFill } from 'antd-mobile-icons'
import { useNavigate, useParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useListingDetail } from '@/features/listings/api'
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
}
function assetValue(value: unknown) {
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>
if (typeof record.start === 'string' && typeof record.end === 'string') {
return `${record.start} – ${record.end}`
}
return Object.entries(record)
.map(([key, item]) => `${key}:${String(item)}`)
.join(',')
}
if (Array.isArray(value)) return value.map(String).join('、')
return value == null || value === '' ? '未填写' : String(value)
}
export function MobileListingDetailPage() {
const navigate = useNavigate()
const { id } = useParams()
const { data, isPending, isError, refetch } = useListingDetail(id)
if (isPending) {
return (
<div className="mobile-detail mobile-detail--loading">
<Skeleton animated className="mobile-detail__cover-skeleton" />
<Skeleton.Paragraph lineCount={6} animated />
</div>
)
}
if (isError || !data) {
return (
<div className="mobile-detail mobile-detail--error">
<ErrorBlock status="disconnected" title="商品没有加载出来" description="请返回列表后重试。" />
<Button color="primary" onClick={() => void refetch()}>
重试
</Button>
</div>
)
}
const onlineTime =
data.asset_summary && typeof data.asset_summary === 'object'
? (data.asset_summary.online_time as unknown)
: undefined
const assetEntries = Object.entries(data.asset_summary || {}).filter(
([key]) => key !== 'online_time'
)
return (
<div className="mobile-detail">
<button type="button" className="mobile-detail__back" onClick={() => navigate(-1)}>
<LeftOutline /> 返回
</button>
<div className="mobile-detail__cover">
{data.cover_url ? (
<img src={data.cover_url} alt={data.title} />
) : (
<BrandLogo height={52} variant="seal" />
)}
</div>
<section className="mobile-detail__head">
<div className="mobile-detail__no">{data.listing_no}</div>
<h1>{data.title}</h1>
<div className="mobile-detail__tags">
<Tag color={data.in_transaction ? 'default' : 'success'} fill="outline">
{data.in_transaction ? '租赁中' : '可租'}
</Tag>
<Tag fill="outline">{data.server_region || '区服待定'}</Tag>
<Tag fill="outline">{data.login_platform || '登录方式待定'}</Tag>
<Tag fill="outline">{data.rank_level || '段位待定'}</Tag>
</div>
</section>
<section className="mobile-detail__price-card">
<div>
<span>租金</span>
<strong>{money(data.price_cent)}</strong>
</div>
<div>
<span>押金</span>
<strong>{money(data.deposit_amount_cent)}</strong>
</div>
<div>
<span>哈夫币</span>
<strong>{data.haf_coin_amount.toLocaleString()}</strong>
</div>
</section>
<section className="mobile-detail__section">
<h2>账号说明</h2>
<p>{data.description || '号主暂未填写补充说明。'}</p>
</section>
<section className="mobile-detail__section">
<h2>资产摘要</h2>
<dl className="mobile-detail__assets">
{assetEntries.length ? (
assetEntries.map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{assetValue(value)}</dd>
</div>
))
) : (
<div>
<dt>资产</dt>
<dd>未填写</dd>
</div>
)}
<div>
<dt>在线时间</dt>
<dd>{assetValue(onlineTime)}</dd>
</div>
</dl>
</section>
<section className="mobile-detail__notice">
<strong>先看清规则,再决定要不要租</strong>
<p>押金、租金、额外消耗和退款规则会在下单页逐项列出;交接、归还、结账全程留痕。</p>
</section>
<div className="mobile-detail__actions">
<Button
block
onClick={() => {
Toast.show({ content: '客服接入将在下一批完成' })
}}
>
<PhoneFill /> 联系客服
</Button>
<Button
block
color="primary"
onClick={() => {
Toast.show({ content: '下单流程将在下一批接入' })
}}
>
确认租赁
</Button>
</div>
</div>
)
}
@@ -0,0 +1,26 @@
import { Button } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
export function MobilePlaceholderPage({
title,
description,
}: {
title: string
description: string
}) {
const navigate = useNavigate()
return (
<div className="mobile-placeholder">
<BrandLogo height={50} variant="seal" />
<span className="mobile-eyebrow">PHASE 1 · COMING NEXT</span>
<h1>{title}</h1>
<p>{description}</p>
<Button color="primary" onClick={() => navigate('/m')}>
回柜选看看
</Button>
</div>
)
}
@@ -0,0 +1,50 @@
import { Button, List } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useSessionStore } from '@/stores/session'
export function MobileProfilePage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const logout = useSessionStore(state => state.logout)
return (
<div className="mobile-profile">
<section className="mobile-profile__head">
<BrandLogo height={42} variant="seal" />
<div>
<h1>{user ? user.nickname || '我的商行' : '还没登录'}</h1>
<p>{user ? user.phone : '登录后查看订单、钱包与实名状态'}</p>
</div>
</section>
<List className="mobile-profile__list">
<List.Item extra={user ? '已登录' : '未登录'}>账号状态</List.Item>
<List.Item extra={user?.realname_status === 'verified' ? '已实名' : '未实名'}>
实名认证
</List.Item>
<List.Item onClick={() => navigate('/m/orders')}>我的订单</List.Item>
<List.Item onClick={() => navigate('/m/messages')}>消息中心</List.Item>
</List>
<div className="mobile-profile__actions">
{user ? (
<Button
block
onClick={async () => {
await logout()
navigate('/m')
}}
>
退出登录
</Button>
) : (
<Button block color="primary" onClick={() => navigate('/m/login')}>
登录 / 注册
</Button>
)}
</div>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import 'antd-mobile/es/global'
import { TabBar } from 'antd-mobile'
import { AppOutline, MessageOutline, UnorderedListOutline, UserOutline } from 'antd-mobile-icons'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useSessionStore } from '@/stores/session'
const tabs = [
{ key: '/m', title: '首页', icon: <AppOutline /> },
{ key: '/m/orders', title: '订单', icon: <UnorderedListOutline /> },
{ key: '/m/messages', title: '消息', icon: <MessageOutline /> },
{ key: '/m/profile', title: '我的', icon: <UserOutline /> },
]
function activeTab(pathname: string) {
if (pathname.startsWith('/m/orders') || pathname.startsWith('/m/chats')) return '/m/orders'
if (pathname.startsWith('/m/messages')) return '/m/messages'
if (pathname.startsWith('/m/profile')) return '/m/profile'
return '/m'
}
export function MobileLayout() {
const location = useLocation()
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const hideTabbar = /^\/m\/listings\/[^/]+$/.test(location.pathname)
return (
<div className="mobile-shell">
<header className="mobile-header">
<Link to="/m" className="mobile-header__brand" aria-label="解忧商行移动端首页">
<BrandLogo height={30} />
</Link>
<div className="mobile-header__right">
{user ? (
<Link to="/m/profile" className="mobile-header__user">
{user.nickname || '我的'}
</Link>
) : (
<Link to="/m/login" className="mobile-header__login">
登录
</Link>
)}
</div>
</header>
<main className="mobile-content">
<Outlet />
</main>
<div className="mobile-tabbar" hidden={hideTabbar}>
<TabBar
activeKey={activeTab(location.pathname)}
onChange={key => navigate(key)}
safeArea
>
{tabs.map(tab => (
<TabBar.Item key={tab.key} icon={tab.icon} title={tab.title} />
))}
</TabBar>
</div>
</div>
)
}
+641
View File
@@ -1317,3 +1317,644 @@ a {
}
}
/* Mobile storefront */
.mobile-shell {
--adm-color-primary: #1e4d42;
--adm-color-success: #2f7d5c;
--adm-color-warning: #c17a2b;
--adm-color-danger: #a64332;
--adm-font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
position: relative;
width: 100%;
max-width: 560px;
min-height: 100vh;
margin: 0 auto;
padding-bottom: calc(72px + env(safe-area-inset-bottom));
background: var(--paper);
color: var(--ink);
}
.mobile-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
height: 58px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
background: rgba(255, 253, 248, 0.95);
backdrop-filter: blur(12px);
}
.mobile-header__brand {
display: inline-flex;
align-items: center;
}
.mobile-header__right {
display: flex;
align-items: center;
gap: 10px;
}
.mobile-header__login,
.mobile-header__user {
padding: 7px 12px;
border: 1px solid var(--line);
border-radius: 4px;
color: var(--brand-ink);
font-size: 12px;
}
.mobile-header__login {
border-color: var(--brand-ink);
background: var(--brand-ink);
color: var(--paper-warm);
}
.mobile-content {
min-height: calc(100vh - 130px);
}
.mobile-tabbar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 30;
width: 100%;
max-width: 560px;
margin: 0 auto;
border-top: 1px solid var(--line);
background: var(--paper-warm);
}
.mobile-tabbar[hidden] {
display: none;
}
.mobile-tabbar .adm-tab-bar-item {
color: var(--ink-muted);
}
.mobile-tabbar .adm-tab-bar-item-active {
color: var(--brand-ink);
}
.mobile-tabbar .adm-tab-bar-item-title {
font-size: 11px;
}
.mobile-home {
padding-bottom: 12px;
}
.mobile-hero {
position: relative;
overflow: hidden;
padding: 28px 18px 24px;
background: var(--brand-ink);
color: var(--paper-warm);
}
.mobile-hero::after {
position: absolute;
right: -70px;
bottom: -100px;
width: 220px;
height: 220px;
border: 1px solid rgba(236, 208, 177, 0.22);
border-radius: 50%;
content: '';
}
.mobile-hero__eyebrow {
position: relative;
z-index: 1;
color: var(--brand-seal-line);
font-size: 9px;
letter-spacing: 0.16em;
}
.mobile-hero h1 {
position: relative;
z-index: 1;
margin: 12px 0 10px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 32px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.24;
}
.mobile-hero p {
position: relative;
z-index: 1;
margin: 0 0 18px;
color: #d8e2dd;
font-size: 12px;
letter-spacing: 0.02em;
}
.mobile-search {
position: relative;
z-index: 1;
--background: #fffdf8;
--border-radius: 6px;
--height: 42px;
--padding-left: 12px;
color: var(--ink);
}
.mobile-chips {
position: relative;
z-index: 1;
display: flex;
gap: 8px;
margin-top: 14px;
overflow-x: auto;
scrollbar-width: none;
}
.mobile-chips::-webkit-scrollbar {
display: none;
}
.mobile-chips button {
flex: 0 0 auto;
padding: 7px 13px;
border: 1px solid rgba(236, 208, 177, 0.38);
border-radius: 3px;
background: transparent;
color: #d8e2dd;
font-size: 12px;
}
.mobile-chips button.is-active {
border-color: var(--brand-seal-line);
background: var(--brand-seal-line);
color: var(--brand-ink);
}
.mobile-trust {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
background: var(--paper-warm);
}
.mobile-trust span {
padding: 12px 4px;
border-right: 1px solid var(--line);
color: var(--ink-secondary);
font-size: 11px;
text-align: center;
}
.mobile-trust span:last-child {
border-right: 0;
}
.mobile-list-section {
padding: 24px 16px 8px;
}
.mobile-section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 14px;
}
.mobile-eyebrow {
display: block;
color: var(--brand-seal);
font-size: 9px !important;
letter-spacing: 0.16em;
}
.mobile-section-head h2,
.mobile-rules h2,
.mobile-placeholder h1,
.mobile-profile h1 {
margin: 5px 0 0;
color: var(--brand-ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 24px;
font-weight: 600;
letter-spacing: 0.04em;
}
.mobile-count {
color: var(--ink-muted);
font-size: 11px;
}
.mobile-listing-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-listing-card {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper-warm);
}
.mobile-listing-card__cover {
display: flex;
flex: 0 0 108px;
align-items: center;
justify-content: center;
width: 108px;
height: 108px;
overflow: hidden;
border-radius: 5px;
background: #eef1ea;
}
.mobile-listing-card__cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mobile-listing-card__body {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.mobile-listing-card__top,
.mobile-listing-card__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mobile-listing-card__no {
color: var(--ink-muted);
font-family: 'Inter', monospace;
font-size: 9px;
}
.mobile-listing-card__body h3 {
display: -webkit-box;
margin: 7px 0 6px;
overflow: hidden;
color: var(--ink);
font-size: 14px;
font-weight: 600;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.mobile-listing-card__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mobile-listing-card__tags span {
padding: 2px 5px;
border-radius: 2px;
background: #eef2ec;
color: var(--brand-ink);
font-size: 9px;
}
.mobile-listing-card__bottom {
margin-top: auto;
padding-top: 9px;
}
.mobile-listing-card__bottom strong {
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 19px;
}
.mobile-listing-card__bottom small {
display: block;
margin-top: 2px;
color: var(--ink-muted);
font-size: 9px;
}
.mobile-listing-card__more {
color: var(--ink-muted);
font-size: 10px;
}
.mobile-skeleton-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-skeleton-card {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper-warm);
}
.mobile-skeleton-cover {
flex: 0 0 108px;
width: 108px;
height: 108px;
border-radius: 5px;
}
.mobile-skeleton-card .adm-skeleton-paragraph {
flex: 1;
}
.mobile-empty,
.mobile-error {
padding: 36px 0;
text-align: center;
}
.mobile-error .adm-button {
margin-top: 10px;
}
.mobile-rules {
margin: 22px 16px 12px;
padding: 22px 18px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper-warm);
}
.mobile-rules p {
margin: 10px 0 0;
color: var(--ink-secondary);
font-size: 12px;
line-height: 1.8;
}
.mobile-detail {
padding: 14px 16px 108px;
}
.mobile-detail--loading,
.mobile-detail--error {
padding-top: 24px;
}
.mobile-detail--error {
text-align: center;
}
.mobile-detail--error .adm-button {
margin-top: 12px;
}
.mobile-detail__back {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
padding: 0;
border: 0;
background: transparent;
color: var(--ink-secondary);
font-size: 12px;
}
.mobile-detail__cover,
.mobile-detail__cover-skeleton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 8px;
background: #eef1ea;
}
.mobile-detail__cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mobile-detail__head {
padding: 18px 0 14px;
}
.mobile-detail__no {
color: var(--ink-muted);
font-family: 'Inter', monospace;
font-size: 10px;
}
.mobile-detail__head h1 {
margin: 6px 0 10px;
color: var(--ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 23px;
font-weight: 600;
line-height: 1.4;
}
.mobile-detail__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mobile-detail__price-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--line);
}
.mobile-detail__price-card > div {
padding: 14px 10px;
background: var(--paper-warm);
}
.mobile-detail__price-card span,
.mobile-detail__price-card strong {
display: block;
}
.mobile-detail__price-card span {
margin-bottom: 5px;
color: var(--ink-muted);
font-size: 10px;
}
.mobile-detail__price-card strong {
color: var(--brand-ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 18px;
}
.mobile-detail__section {
padding: 20px 0 0;
}
.mobile-detail__section h2 {
margin: 0 0 10px;
color: var(--brand-ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 18px;
font-weight: 600;
}
.mobile-detail__section p {
margin: 0;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.85;
}
.mobile-detail__assets {
margin: 0;
border-top: 1px solid var(--line);
}
.mobile-detail__assets > div {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
font-size: 12px;
}
.mobile-detail__assets dt {
color: var(--ink-muted);
}
.mobile-detail__assets dd {
margin: 0;
color: var(--ink);
text-align: right;
}
.mobile-detail__notice {
margin-top: 22px;
padding: 16px;
border-left: 2px solid var(--brand-seal);
background: #f1ece2;
}
.mobile-detail__notice strong {
color: var(--brand-ink);
font-size: 13px;
}
.mobile-detail__notice p {
margin: 7px 0 0;
color: var(--ink-secondary);
font-size: 11px;
line-height: 1.7;
}
.mobile-detail__actions {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 30;
display: flex;
gap: 10px;
width: 100%;
max-width: 560px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: var(--paper-warm);
}
.mobile-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 70vh;
padding: 44px 28px;
text-align: center;
}
.mobile-placeholder .mobile-eyebrow {
margin-top: 24px;
}
.mobile-placeholder p {
max-width: 320px;
margin: 14px 0 22px;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.8;
}
.mobile-profile {
padding: 22px 16px;
}
.mobile-profile__head {
display: flex;
align-items: center;
gap: 14px;
padding: 20px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--brand-ink);
}
.mobile-profile__head h1 {
margin: 0 0 4px;
color: #f0f4f2;
font-size: 22px;
}
.mobile-profile__head p {
margin: 0;
color: #b9cbc3;
font-size: 11px;
}
.mobile-profile__list {
margin-top: 14px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper-warm);
--border-inner: 1px solid var(--line);
}
.mobile-profile__actions {
margin-top: 18px;
}
@media (min-width: 640px) {
.mobile-shell {
border-right: 1px solid var(--line);
border-left: 1px solid var(--line);
box-shadow: 0 0 60px rgba(29, 36, 33, 0.08);
}
}