From ef01486e537c185a94cd71dcbe39384f1596a767 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 12:26:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20=E6=96=B0=E5=A2=9E=E8=A7=A3?= =?UTF-8?q?=E5=BF=A7=E5=95=86=E8=A1=8C=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E8=AF=A6=E6=83=85=E4=B8=8E=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- docs/Phase1-验收报告.md | 31 +- docs/项目计划.md | 2 +- frontend/package-lock.json | 303 +++++++++ frontend/package.json | 2 + frontend/src/app/AppRouter.tsx | 47 ++ frontend/src/features/auth/LoginPage.tsx | 3 +- frontend/src/features/listings/api.ts | 14 +- .../src/features/mobile/MobileHomePage.tsx | 166 +++++ .../mobile/MobileListingDetailPage.tsx | 155 +++++ .../features/mobile/MobilePlaceholderPage.tsx | 26 + .../src/features/mobile/MobileProfilePage.tsx | 50 ++ frontend/src/layouts/MobileLayout.tsx | 66 ++ frontend/src/styles/global.css | 641 ++++++++++++++++++ 14 files changed, 1499 insertions(+), 10 deletions(-) create mode 100644 frontend/src/features/mobile/MobileHomePage.tsx create mode 100644 frontend/src/features/mobile/MobileListingDetailPage.tsx create mode 100644 frontend/src/features/mobile/MobilePlaceholderPage.tsx create mode 100644 frontend/src/features/mobile/MobileProfilePage.tsx create mode 100644 frontend/src/layouts/MobileLayout.tsx diff --git a/README.md b/README.md index 0f4baf0..de6b963 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index ef723de..49130dd 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -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. 分销后台主题轻改。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 3147a2a..ce2697a 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -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:颜色、字体、间距、圆角、阴影、动效。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 14fb8a6..c53c175 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index af99512..7f57a9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 604d0e3..4a10760 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> + } /> + + }> + } /> + } /> + } /> + + } + /> + + } + /> + } /> + } /> }> diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index 5527867..fda0f31 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -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 : '登录没有完成,请核对手机号和密码' diff --git a/frontend/src/features/listings/api.ts b/frontend/src/features/listings/api.ts index a9e2a1f..ce2c973 100644 --- a/frontend/src/features/listings/api.ts +++ b/frontend/src/features/listings/api.ts @@ -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(apiClient.get>(`/listings/${id}`)) +} + +export function useListingDetail(id?: string) { + return useQuery({ + queryKey: ['listing-detail', id], + queryFn: () => fetchListingDetail(id as string), + enabled: Boolean(id), + }) +} diff --git a/frontend/src/features/mobile/MobileHomePage.tsx b/frontend/src/features/mobile/MobileHomePage.tsx new file mode 100644 index 0000000..12d3c5a --- /dev/null +++ b/frontend/src/features/mobile/MobileHomePage.tsx @@ -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 ( + +
+ {item.cover_url ? ( + {item.title} + ) : ( + + )} +
+
+
+ {item.listing_no} + + {item.in_transaction ? '租赁中' : '可租'} + +
+

{item.title}

+
+ {item.server_region || '区服待定'} + {item.rank_level || '段位待定'} + 哈夫币 {item.haf_coin_amount.toLocaleString()} +
+
+
+ {money(item.price_cent)} + 押金 {money(item.deposit_amount_cent)} +
+ + 查看 + +
+
+ + ) +} + +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 ( +
+
+
JIEYOU TRADING HOUSE
+

+ 租得明白, +
+ 还得安心。 +

+

实名审核 · 流程留痕 · 账单逐项列明

+ setKeyword(value.trim())} + placeholder="搜索区服、段位、装备或商品号" + searchIcon={} + /> +
+ {quickFilters.map(item => ( + + ))} +
+
+ +
+ 实名审核 + 交接留痕 + 争议可申诉 +
+ +
+
+
+ CURATED ACCOUNTS +

本周柜选

+
+ 共 {data?.total ?? 0} 件 +
+ + {isError ? ( +
+ + +
+ ) : null} + + refetch().then(() => undefined)}> + {isPending ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) : data?.items.length ? ( +
+ {data.items.map(item => ( + + ))} +
+ ) : ( +
+ + +
+ )} +
+
+ +
+ RULES, IN PLAIN WORDS +

先把规则说明白

+

押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注不参与系统结算。

+
+
+ ) +} diff --git a/frontend/src/features/mobile/MobileListingDetailPage.tsx b/frontend/src/features/mobile/MobileListingDetailPage.tsx new file mode 100644 index 0000000..e061b30 --- /dev/null +++ b/frontend/src/features/mobile/MobileListingDetailPage.tsx @@ -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 + 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 ( +
+ + +
+ ) + } + + if (isError || !data) { + return ( +
+ + +
+ ) + } + + 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 ( +
+ + +
+ {data.cover_url ? ( + {data.title} + ) : ( + + )} +
+ +
+
{data.listing_no}
+

{data.title}

+
+ + {data.in_transaction ? '租赁中' : '可租'} + + {data.server_region || '区服待定'} + {data.login_platform || '登录方式待定'} + {data.rank_level || '段位待定'} +
+
+ +
+
+ 租金 + {money(data.price_cent)} +
+
+ 押金 + {money(data.deposit_amount_cent)} +
+
+ 哈夫币 + {data.haf_coin_amount.toLocaleString()} +
+
+ +
+

账号说明

+

{data.description || '号主暂未填写补充说明。'}

+
+ +
+

资产摘要

+
+ {assetEntries.length ? ( + assetEntries.map(([key, value]) => ( +
+
{key}
+
{assetValue(value)}
+
+ )) + ) : ( +
+
资产
+
未填写
+
+ )} +
+
在线时间
+
{assetValue(onlineTime)}
+
+
+
+ +
+ 先看清规则,再决定要不要租 +

押金、租金、额外消耗和退款规则会在下单页逐项列出;交接、归还、结账全程留痕。

+
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/features/mobile/MobilePlaceholderPage.tsx b/frontend/src/features/mobile/MobilePlaceholderPage.tsx new file mode 100644 index 0000000..b2da71c --- /dev/null +++ b/frontend/src/features/mobile/MobilePlaceholderPage.tsx @@ -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 ( +
+ + PHASE 1 · COMING NEXT +

{title}

+

{description}

+ +
+ ) +} diff --git a/frontend/src/features/mobile/MobileProfilePage.tsx b/frontend/src/features/mobile/MobileProfilePage.tsx new file mode 100644 index 0000000..2f10278 --- /dev/null +++ b/frontend/src/features/mobile/MobileProfilePage.tsx @@ -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 ( +
+
+ +
+

{user ? user.nickname || '我的商行' : '还没登录'}

+

{user ? user.phone : '登录后查看订单、钱包与实名状态'}

+
+
+ + + 账号状态 + + 实名认证 + + navigate('/m/orders')}>我的订单 + navigate('/m/messages')}>消息中心 + + +
+ {user ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/layouts/MobileLayout.tsx b/frontend/src/layouts/MobileLayout.tsx new file mode 100644 index 0000000..4253756 --- /dev/null +++ b/frontend/src/layouts/MobileLayout.tsx @@ -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: }, + { key: '/m/orders', title: '订单', icon: }, + { key: '/m/messages', title: '消息', icon: }, + { key: '/m/profile', title: '我的', icon: }, +] + +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 ( +
+
+ + + +
+ {user ? ( + + {user.nickname || '我的'} + + ) : ( + + 登录 + + )} +
+
+ +
+ +
+ + +
+ ) +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 1f0704f..bc92eca 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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); + } +} + +