From 97167fa4829202dbb9d6b101068edbcc2eb4c533 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 12:20:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E5=AE=8C=E6=88=90=E5=93=81?= =?UTF-8?q?=E7=89=8C=20SVG=20=E4=B8=8E=E5=90=8E=E5=8F=B0=20Phase=201=20?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=88=87=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +- docs/Phase1-验收报告.md | 181 +++++++ docs/品牌与UI规范.md | 2 +- docs/项目计划.md | 2 + frontend/index.html | 2 +- frontend/public/favicon.png | Bin 5527 -> 0 bytes frontend/public/favicon.svg | 8 + frontend/src/app/AppRouter.tsx | 97 +++- frontend/src/assets/brand/logo-dark.svg | 10 + frontend/src/assets/brand/logo-light.svg | 10 + frontend/src/assets/brand/logo-seal.svg | 8 + frontend/src/components/BrandLogo.tsx | 19 +- frontend/src/components/PageLoading.tsx | 11 + .../admin/AdminChangePasswordPage.tsx | 137 +++++ .../src/features/admin/AdminDashboardPage.tsx | 252 +++++++++ frontend/src/features/admin/AdminGuard.tsx | 36 ++ .../src/features/admin/AdminLoginPage.tsx | 173 ++++++ frontend/src/features/admin/api.ts | 49 ++ frontend/src/features/admin/types.ts | 64 +++ frontend/src/features/auth/LoginPage.tsx | 2 +- frontend/src/layouts/AdminLayout.tsx | 299 ++++++++++ frontend/src/shared/api/adminClient.ts | 74 +++ frontend/src/stores/adminSession.ts | 93 ++++ frontend/src/styles/global.css | 510 +++++++++++++++++- frontend/vite.config.ts | 18 +- 25 files changed, 2028 insertions(+), 40 deletions(-) create mode 100644 docs/Phase1-验收报告.md delete mode 100644 frontend/public/favicon.png create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/assets/brand/logo-dark.svg create mode 100644 frontend/src/assets/brand/logo-light.svg create mode 100644 frontend/src/assets/brand/logo-seal.svg create mode 100644 frontend/src/components/PageLoading.tsx create mode 100644 frontend/src/features/admin/AdminChangePasswordPage.tsx create mode 100644 frontend/src/features/admin/AdminDashboardPage.tsx create mode 100644 frontend/src/features/admin/AdminGuard.tsx create mode 100644 frontend/src/features/admin/AdminLoginPage.tsx create mode 100644 frontend/src/features/admin/api.ts create mode 100644 frontend/src/features/admin/types.ts create mode 100644 frontend/src/layouts/AdminLayout.tsx create mode 100644 frontend/src/shared/api/adminClient.ts create mode 100644 frontend/src/stores/adminSession.ts diff --git a/README.md b/README.md index 2b4d805..0f4baf0 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ ## 当前状态 -**Phase 0 已完成。** 后端已复制并跑通空库迁移、构建、健康检查与全量测试;前端已搭好 React 19 + Ant Design 5 壳,完成品牌首页、登录页、商品列表和后台占位。 +**Phase 0 已完成,Phase 1 基础切片已完成。** 后端已复制并跑通空库迁移、构建、健康检查与全量测试;前端已完成 SVG 品牌资产、用户首页/登录/商品列表、管理员验证码登录、强制改密、浅色后台 Shell 与真实仪表盘。 - 前端:`http://localhost:5289/` +- 后台:`http://localhost:5289/console-jy8k3m/login` - 后端:`http://localhost:18089/` - 健康检查:`http://localhost:18089/health/ready` @@ -23,7 +24,12 @@ docker compose -f deploy/docker-compose.dev.yml up -d cd frontend && npm install && npm run dev ``` -开发测试账号:`13800138000 / Jieyou123`(仅开发库)。 +开发账号: + +- 用户:`13800138000 / Jieyou123` +- 管理员:`admin / JieyouAdmin@2026` + +以上仅用于本地开发库。 ## 已定配置 @@ -47,6 +53,7 @@ cd frontend && npm install && npm run dev - [项目实施计划](docs/项目计划.md) - [品牌与 UI 规范](docs/品牌与UI规范.md) - [Phase 0 验收报告](docs/Phase0-验收报告.md) +- [Phase 1 验收报告](docs/Phase1-验收报告.md) - [后端数据库设计](docs/backend/database.md) - [后端业务规则](docs/backend/business-rules.md) diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md new file mode 100644 index 0000000..ef723de --- /dev/null +++ b/docs/Phase1-验收报告.md @@ -0,0 +1,181 @@ +# Phase 1 验收报告(基础切片) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 阶段:Phase 1 · 品牌与设计系统 / 后台基础 Shell +> 结论:**基础切片通过,继续扩展页面** + +--- + +## 1. 本批次完成内容 + +### 1.1 SVG 品牌资产 + +已从现有 PNG 重绘为可缩放 SVG: + +- 浅底横版:`frontend/src/assets/brand/logo-light.svg` +- 深底反白横版:`frontend/src/assets/brand/logo-dark.svg` +- 独立朱砂印章:`frontend/src/assets/brand/logo-seal.svg` +- favicon:`frontend/public/favicon.svg` + +`BrandLogo` 组件已支持三种变体: + +```tsx + + + +``` + +> 当前 SVG 使用字体渲染中文;正式对外前建议把中文标题与英文辅助字转为路径,避免不同设备字体差异。 + +### 1.2 管理员独立 API 客户端 + +新增 `frontend/src/shared/api/adminClient.ts`: + +- 独立 `withCredentials` Cookie 会话。 +- 不注入普通用户 Bearer Token。 +- 401 时单飞刷新 `/api/admin/auth/refresh`。 +- 刷新失败自动回后台登录页。 +- 与普通用户 `apiClient` 完全隔离,避免后台 401 被错误跳转到用户登录。 + +### 1.3 管理员会话与强制改密 + +新增: + +- `frontend/src/stores/adminSession.ts` +- `frontend/src/features/admin/AdminGuard.tsx` +- `frontend/src/features/admin/AdminChangePasswordPage.tsx` + +流程: + +1. 管理员验证码登录。 +2. 后端返回 `password_must_change=true`。 +3. 前端强制跳转 `/console-jy8k3m/change-password`。 +4. 修改成功后重新拉取 `/api/admin/me`。 +5. 再进入后台仪表盘。 + +### 1.4 后台登录页 + +新增 `AdminLoginPage`: + +- 管理员账号 / 密码 / 图形验证码。 +- 验证码失败自动刷新。 +- 深绿品牌面板 + 米白表单卡。 +- 与用户登录页共享品牌语言,但布局和信息层级不同。 +- 开发环境提示:`admin / JieyouAdmin@2026`。 + +### 1.5 浅色后台 Shell + +新增 `frontend/src/layouts/AdminLayout.tsx`: + +- 暖白侧栏,不使用旧站深蓝后台。 +- 墨绿选中态、朱砂强调。 +- 菜单分组:工作台、交易、商品、用户、财务、分销、运营、系统。 +- 按管理员权限码过滤菜单。 +- 顶栏:面包屑、管理员角色、账号菜单、退出后台。 +- 未迁移页面统一进入“Phase 1 · 待迁移”占位,不复用旧站页面。 + +### 1.6 真实仪表盘 + +新增 `AdminDashboardPage`: + +- 今日订单、用户总数、在架商品、今日入账。 +- 商品待审核、待交接、待确认归还、进行中申诉。 +- 最近订单表格。 +- 数据来自真实 `/api/admin/dashboard`,不是静态假数据。 + +### 1.7 路由懒加载 + +`AppRouter` 已改为 `React.lazy + Suspense`: + +- 首页商品列表、用户登录、后台登录、后台改密、后台仪表盘、后台 Shell 分别按路由加载。 +- 后台代码不会在用户首页首屏直接执行。 +- 已移除会提前触发后台模块加载的顶层 `import()`。 + +--- + +## 2. 验证结果 + +### 2.1 前端 + +```text +npm run typecheck 通过 +npm run build 通过 +``` + +### 2.2 管理员接口 + +通过前端代理 `http://127.0.0.1:5289/api` 验证: + +```text +proxy login = ok +must_change = false(改密后) +proxy dashboard = ok +today_orders = 0 +``` + +### 2.3 用户回归 + +```text +user login = ok,已返回 token +listings = ok,返回 1 件联调商品 +``` + +### 2.4 路由 + +```text +/console-jy8k3m/login HTTP 200 +/favicon.svg HTTP 200 +``` + +--- + +## 3. 当前管理员账号 + +开发环境: + +```text +账号:admin +密码:JieyouAdmin@2026 +后台:http://localhost:5289/console-jy8k3m/login +``` + +初始密码已通过真实改密接口更新;`backend/.env` 中的 `ADMIN_BOOTSTRAP_PASSWORD` 也已同步,后续空库初始化会使用新密码。 + +--- + +## 4. 构建体积 + +当前生产构建: + +```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 +``` + +**问题:** 首屏主包仍偏大,超过 Phase 1 目标。 +**原因:** Ant Design 基础组件被首页与后台共享,当前 Rollup 仍把较大依赖合并进 index。 +**下一步:** 在 Phase 1 后续批次做组件级按需、图表库延迟加载、后台页面进一步拆分,并引入包分析工具定位。 + +--- + +## 5. 已知限制 + +1. 移动端 `/m` 尚未开始迁移。 +2. 后台 43 个页面目前只有仪表盘是真实页面,其余为统一占位。 +3. 分销后台尚未做品牌轻改。 +4. 后台菜单权限码基于旧项目推断,后续需与后端权限表逐一核对。 +5. SVG 中文尚未转曲。 +6. 首屏包体仍偏大。 +7. 本批次尚未提交 Git。 + +--- + +## 6. 建议下一批次 + +1. 移动端 `/m` 首页 + 商品列表 + 底部导航。 +2. 后台列表页原型:DataTable、FilterBar、详情 Drawer、批量操作。 +3. 先迁移后台订单列表与商品审核列表。 +4. 分销后台主题轻改。 +5. 包体分析与 AntD 按需优化。 diff --git a/docs/品牌与UI规范.md b/docs/品牌与UI规范.md index 9e27ca7..309ca29 100644 --- a/docs/品牌与UI规范.md +++ b/docs/品牌与UI规范.md @@ -63,7 +63,7 @@ | 品牌反白文字 | `#F0F4F2` | | 页面米白底 | `#F6F2E8` | -> 目前没有矢量源文件。Phase 1 需要按现有 PNG 重绘 SVG:印章边框与「解忧」二字转为路径,中文主标题与英文辅助字转曲,交付横版、方形、单色、反白、竖版五套文件。 +> 目前没有原始矢量文件。Phase 1 已按现有 PNG 重绘第一版 SVG:`logo-light.svg`、`logo-dark.svg`、`logo-seal.svg`、`favicon.svg`。中文当前仍使用字体渲染,正式对外前需要把中文主标题、英文辅助字和印章文字转为路径,避免不同设备字体差异。 需要交付的 logo 版本: diff --git a/docs/项目计划.md b/docs/项目计划.md index aad01e9..3147a2a 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -352,6 +352,8 @@ hfb_jysh/ ### Phase 1:品牌与设计系统(1 周) +> 状态:**基础切片已完成(2026-10-30)**,验收见 [`Phase1-验收报告.md`](./Phase1-验收报告.md)。已完成 SVG 品牌资产、管理员会话、后台登录/强制改密/Shell/仪表盘与路由懒加载;移动端与后台列表页原型继续。 + - 重绘 logo SVG:横版、方形印章、单色、反白。 - 落地 Design Token:颜色、字体、间距、圆角、阴影、动效。 - 搭建 AntD `ConfigProvider` 主题。 diff --git a/frontend/index.html b/frontend/index.html index f71ed2c..d205c5b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Z&JNTT|&J76ldn054UP<#ho7g!8P8Fu>2JKa;rMbHemc zHt_-goNxadpi~YVN&p~MQIVI?_su!Vg_#i=t&9W{V9FBkgrH#hhK5ahHf%BLelD#y zHf7%sssghIavq!R9zYf3MLMgsMExMYfz@>m@Md% z=@UIiXTGo9{#cn&fpvUF2n{|6&) zkU$)or0cb>>hgOZ-qTHorNx1O85riS9BJW5z-srCGPON#DUr|3)P_dI0( zlGf)(znV(q>5gPpqcP~J5GZ0p(rfZ{S-Ssx@Zl=emwNESv_Y3sWjb}d^O}yP2r{z& zJ$tp|^L`*$Jklk^PrS5w^}S@1eHXFQI3It(L#TIop8g|)k%hPUL7#^hr(`tSU56OJUe^P`=s7@%;^4D)2UT0v!wA;)bmJ?R; z+SsR2GF|rWP4nSbP16gQAtQ}hzlay>A#OT_l7yf+bJ4VWgVgxMudW)=Z~^C&GAAjJ zOImdzl2;BHFYuJu3nJjtT7t0eUH1DFZAG7#B%rreZfo9_RlSWYOP**!4$k;LOs?10d|L3%zAc2V5o z=W`k_cG^n04u6X7s42~${n@kUxi+;ZCPbEMb%sF6#_hJx037)99uHEyst#|cDj#nv zo?i~5aP8bF0l@@NustaZ{2t~50R|5d`1uJ=?r5sI5&8W5QW(&8p7sbFrB<#QGYw1F zrKfbr)?gR`=I;i98zkMrC3rfaIFQ2peyKaN-$mu^HZ890Y-NEsAx@XzV2PfI_wD~q z2Y3h`JyglHv12+7dw2^v7We)&oCpYe7cOANGJ)Cj-vzM=;qGpkp?wwt9DG7o89PnMcnvKue8P{`u zpMEzW-$NXmrq*>D$Qznaes^^`oNA(MjJzvp7s?{U&z-UvR~_=&0c$xI+(bG!ryaE} zu8C+9=l+5hgQbXL>q88nb*o^2If7V|2@G}hwiU#4R)yIx^cq$i0#S(rrv%9 zIJi~YXg1=9*NQUYwL*BdJ%K)ED;G5H{45Z6cS%5h2MPIFUh{)(<&!<;qM5X=b(P ze#@uw`=4BpQ)@}Nc(2`-MG|b}@<}QcOrw{Ue|ALoAH%DR341VgmL;xj8>b5j+|uLq z!}pg^jYA74oT0DzeSKSG<*vhpDn%hkT|fx*nwsG0B^v-_+ny-u+UxnCyclRB9bN$v zRTVnC!O~gj?DJ+ft;C@T>dj+FMC&WOqnKH8(OsNtknx9<6-ro6IjW5TyXt7t$D-v} zUDADu%OnzcC7Kr^r#s>Q;IH@OJeV86+YL^-1Q+s}5X0WRVxdy<5Mt~$YC>iADly7{ z=k1hNo;6`ib*s5zay3a+@1$n^xmzWq4?5C8y$$`87CA1Uv%p$%laAyQFe$$(99AJ= zW_s0ZQ-ln<3hidoeK&soS2%^pF~QVhmm4Z+_t_#UPF#6?jQ3l{VU;qHufwJwpe+R$ zlcWUjXkNqU0oy%uCcsLaxKKFCqVU#97bsI5h(VsT#LWTbM0 z;ZoSj;KwgI*cYW#%fNOoC1UO5R?i0|rFYePP2f! z+|Q}~a2|nTR^sb*c@HH-M`$^l8mq%$6Nv7YQXgol|J-*J4N%WXyLZ2B|Mp?Sh>&Hy>FI9|P&}b!15iN z4>Ka_9BY#mL0wThb8JF=TPXa^zV>j`iCp3om;Ro1F`S|FC?tM zRg(M`gapQ^^sSrV`1$luj|XNu7Z~@?TmQ;aMeI6hn8;SBZOeA%y>!N@YHvuOt!Lr~cn;h`j#Q&JmR@VVE1K1^>IvM! z)2s213ij*3)aD-5h$wJR!cj3ka^o6Xl~Ma=HW{8|Mhz5r9&E$$Y1OCruyoJN`L$k` zZU)&NZ)|AVa&gn_r_6;@_Pi6pBkGIL$(kg35FVS0F(ND&_UhDRuV#%44*pNPNLQ-~HAc zfjbWxy4`GM3`~q1@8YaHeGCMxoZ1kVgWLFc8N+$O|kEeU|TV^(DT!HX|fDCGNi_@a?;I``)t3IwDEOUluZ5qir2*yislFeVavn)PK z_1IbaRm&xAwLc^b)lGT?h=H7pYLa@P$veH3qBt+L$H(iaRI0Ov{uOsQe92sdbZ52t z>X_gTO2X6}pl)mioSKnJWv_A}*1fbBqtt;#s^97a|6W+X%be&tE!?+rll>bR{{1Cp z($}$lP`UnPq-PcnQDlvQTg;O;p)(HG+n+8W5izbmP2pReZ0PA390$F~tJ{l~*4~=V zoR`;Hvt?Dn=vZxm%hy_c(})$}A9MxmIqgNU`hWfzh<&cjh+X6WDG^ zKkTrm+6}UsjwNG1b-|gMjAyV%Pb`5D!VxLSw|}ptVk0EvJ|nQ4S8L-zuFG$v)Q(za zczX3oc6a;EqbG<)oZhzxbCg~?UKqs83K3tg8n7gx1;L?uA+g<;zGavpLh~4D=(hKk zG(l&>=h{+AmoAX~puMd>Z^$X*H0ynf&`pnxvsbLQ_=?YuL58Ve9wxu(@kgM%zf@@bY7z37r1(lUzrA zr#T}V8%R}X*-h$}Biqhzxzst3uT6MS#m!*Gb;nzG0@r=fTl92HH(cqiW{=o?jqa!D z3*m|@p}F&N<@vASm9@u4WzL8jy+`1pEmm5| z^V)?s*s^d1rOkrrJbOqp%KBxvJO7!g|IK>S@*P1#V5zMX-_#R3O|q1>09(s0>`x;M zg8P7fYAvT&|Ha0~49B6SQ1gYeYr^;3*fi+|78CdM!5BxZiSVye^O_0Q$6fglD{!_1 zPt`|veS@^)OgogYLY{x)8?3GEvK47V3|lrpQY#_Z@bI7@;oi5IIJ3x!Sig$BC6@KE z&M#tIt-&P-b%nPs87|Tp@muPwkHAT=yp}ruJHF|&`gKIWB=ZB zN0iNto|BH9-g263br`G;ck?91zur8cU5^?h;@gz7BP&ys6F&W|Ubi7|D8J%$ExAPX z>ocaquyLv7?}4Wun=OZNDh+x|#Z)W5)aYuT_c8~pb8HwioYY^kaO9-9d+RmRa;t zUT16k^+*pq4;}8LXy@Q|`-35F742 zNC&!Kg}S5zeJBrwc>1DtPFJJS`cD}659;6cuE?U+h&jygd`g@1cutgoQ}Qn-Ajkmy z#^L_#Of3N17bRB{cQ(y637JR8p0Mo;kLuuZ#Y~b88n9m2jm~90-{MBiD(p||_20|a zjqX<*9K~!rv~R6#KUdP3r!#XxclIMbI9!NZAjA$RDqFtp?nu+Nt$9?f&Ob_CjIHl< zh22D3e3?Efc{Wj{TzQhc#fQ3)K)6eU18ci`Bl_JUtb&TXkU{Z-k5FFm8{P@mP)q&f zFA7p^EpC8@?Me zag%`Lpe;w2AlfM-h5Ras}3?&m0(iI z(&ut&Zf*dHhxJ1NVyEIjdp`2 zt}XWlY^7Y`ZJl$~X+mAevW;_}{~Pk*S#?tv1Ujsvzh5s2dJ+dd|1^TXky-ePkFe_m zuPBOqs$ug$;P{-Fc0^I8zFSix0K;fztyR@?C;?+k)Ckf1hcv>1rgCvztUS#C6)E zK)%M&5zxJ9#;KlvIXBc8DPplw)>}0d=oxz$@WI+ouc}S0+*#92?X^60sz-HL}!FjB?y(Gq9c3m#&~nS6)!;Y6gccBlh}lq)_l zRL*+SRpwYbkZ3<&qCCs^Z$yDkL(e_K6eGmmY(@0;8J#?Xto!43^YfyE^SpHCd-;?E z+Cv76?$cOR&qfC$q7&MS;MuitGQ5!ekALrj#wlUtv9wo4jnCT1iXt&UtcFU$Eno~z1X71 z50{N^1AMeByGyVG+lqK + 解忧商行印章 + 解忧商行方形朱砂印章 + + + 解 + 忧 + diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index a327e0d..604d0e3 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -1,22 +1,97 @@ +import { lazy, Suspense } from 'react' import { Route, Routes } from 'react-router-dom' -import { AdminPlaceholderPage } from '@/features/admin/AdminPlaceholderPage' -import { LoginPage } from '@/features/auth/LoginPage' -import { ListingListPage } from '@/features/listings/ListingListPage' +import { PageLoading } from '@/components/PageLoading' import { NotFoundPage } from '@/features/NotFoundPage' import { StorefrontLayout } from '@/layouts/StorefrontLayout' +const ListingListPage = lazy(() => + import('@/features/listings/ListingListPage').then(module => ({ default: module.ListingListPage })) +) +const LoginPage = lazy(() => + import('@/features/auth/LoginPage').then(module => ({ default: module.LoginPage })) +) +const AdminLoginPage = lazy(() => + import('@/features/admin/AdminLoginPage').then(module => ({ default: module.AdminLoginPage })) +) +const AdminGuard = lazy(() => + import('@/features/admin/AdminGuard').then(module => ({ default: module.AdminGuard })) +) +const AdminChangePasswordPage = lazy(() => + import('@/features/admin/AdminChangePasswordPage').then(module => ({ + default: module.AdminChangePasswordPage, + })) +) +const AdminDashboardPage = lazy(() => + import('@/features/admin/AdminDashboardPage').then(module => ({ + default: module.AdminDashboardPage, + })) +) +const AdminLayout = lazy(() => + import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout })) +) +const AdminSectionPlaceholder = lazy(() => + import('@/layouts/AdminLayout').then(module => ({ default: module.AdminSectionPlaceholder })) +) + +const adminSections: Array<{ path: string; title: string; description: string }> = [ + { path: 'orders', title: '订单管理', description: '查看全量订单、交接记录与结账状态。' }, + { path: 'orders/refund-review', title: '退款审核', description: '审核退款申请并核对资金影响。' }, + { path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' }, + { path: 'listings', title: '商品管理', description: '查看商品、账号资产与上下架状态。' }, + { path: 'listings/review', title: '商品审核', description: '审核待上架商品与账号截图。' }, + { path: 'users', title: '用户管理', description: '查看用户、实名状态与交易记录。' }, + { path: 'finance', title: '财务看板', description: '查看收入、退款、结算与资金趋势。' }, + { path: 'withdrawals', title: '提现审核', description: '审核提现申请与打款确认。' }, + { path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' }, + { path: 'announcements', title: '公告管理', description: '维护主站与分站公告。' }, + { path: 'system-configs', title: '系统配置', description: '维护首页、发布、订单与支付配置。' }, + { path: 'audit-logs', title: '审计日志', description: '查看高风险操作与变更记录。' }, +] + export function AppRouter() { const adminBase = import.meta.env.VITE_ADMIN_BASE_PATH || '/console-jy8k3m' return ( - - }> - } /> - - } /> - } /> - } /> - + }> + + }> + } /> + + + } /> + + } /> + }> + } /> + }> + } /> + {adminSections.map(section => ( + + } + /> + ))} + + } + /> + + + + } /> + + ) } diff --git a/frontend/src/assets/brand/logo-dark.svg b/frontend/src/assets/brand/logo-dark.svg new file mode 100644 index 0000000..a2954ed --- /dev/null +++ b/frontend/src/assets/brand/logo-dark.svg @@ -0,0 +1,10 @@ + + 解忧商行 + 解忧商行横版 logo,深色背景反白使用 + + + 解 + 忧 + 解忧商行 + JIEYOU TRADING HOUSE + diff --git a/frontend/src/assets/brand/logo-light.svg b/frontend/src/assets/brand/logo-light.svg new file mode 100644 index 0000000..1ff7730 --- /dev/null +++ b/frontend/src/assets/brand/logo-light.svg @@ -0,0 +1,10 @@ + + 解忧商行 + 解忧商行横版 logo,浅色背景使用 + + + 解 + 忧 + 解忧商行 + JIEYOU TRADING HOUSE + diff --git a/frontend/src/assets/brand/logo-seal.svg b/frontend/src/assets/brand/logo-seal.svg new file mode 100644 index 0000000..c18f85f --- /dev/null +++ b/frontend/src/assets/brand/logo-seal.svg @@ -0,0 +1,8 @@ + + 解忧商行印章 + 解忧商行方形朱砂印章 + + + 解 + 忧 + diff --git a/frontend/src/components/BrandLogo.tsx b/frontend/src/components/BrandLogo.tsx index 754fe8f..8ed78a8 100644 --- a/frontend/src/components/BrandLogo.tsx +++ b/frontend/src/components/BrandLogo.tsx @@ -1,17 +1,28 @@ -import logo from '@/assets/brand/logo-jieyou.png' +import logoDark from '@/assets/brand/logo-dark.svg' +import logoLight from '@/assets/brand/logo-light.svg' +import logoSeal from '@/assets/brand/logo-seal.svg' + +export type BrandLogoVariant = 'light' | 'dark' | 'seal' interface BrandLogoProps { height?: number + variant?: BrandLogoVariant className?: string } -export function BrandLogo({ height = 44, className }: BrandLogoProps) { +const logoSources: Record = { + light: logoLight, + dark: logoDark, + seal: logoSeal, +} + +export function BrandLogo({ height = 44, variant = 'light', className }: BrandLogoProps) { return ( 解忧商行 JIEYOU TRADING HOUSE ) } diff --git a/frontend/src/components/PageLoading.tsx b/frontend/src/components/PageLoading.tsx new file mode 100644 index 0000000..5749233 --- /dev/null +++ b/frontend/src/components/PageLoading.tsx @@ -0,0 +1,11 @@ +import { Spin } from 'antd' + +export function PageLoading({ tip = '正在核对商行账本…' }: { tip?: string }) { + return ( +
+ +
+ +
+ ) +} diff --git a/frontend/src/features/admin/AdminChangePasswordPage.tsx b/frontend/src/features/admin/AdminChangePasswordPage.tsx new file mode 100644 index 0000000..1c76995 --- /dev/null +++ b/frontend/src/features/admin/AdminChangePasswordPage.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react' +import { Alert, App, Button, Form, Input, Typography } from 'antd' +import { LockOutlined } from '@ant-design/icons' +import { Navigate, useNavigate } from 'react-router-dom' + +import { BrandLogo } from '@/components/BrandLogo' +import { getAdminBasePath, getAdminLoginPath } from '@/shared/api/adminClient' +import { useAdminSessionStore } from '@/stores/adminSession' + +interface ChangePasswordForm { + old_password: string + new_password: string + confirm_password: string +} + +export function AdminChangePasswordPage() { + const { message } = App.useApp() + const navigate = useNavigate() + const admin = useAdminSessionStore(state => state.admin) + const logout = useAdminSessionStore(state => state.logout) + const changePassword = useAdminSessionStore(state => state.changePassword) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const basePath = getAdminBasePath() + + if (!admin) return + if (!admin.password_must_change) return + + const onFinish = async (values: ChangePasswordForm) => { + setSubmitting(true) + setError('') + try { + await changePassword(values.old_password, values.new_password) + message.success('初始密码已更新') + navigate(basePath, { replace: true }) + } catch (err) { + setError(err instanceof Error ? err.message : '密码没有更新,请检查原密码') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+ +
FIRST RUN · SECURITY
+ + 先把钥匙 +
+ 换掉。 +
+ + 初始密码只用于第一次进入。修改完成后,这个后台会话才能打开订单、财务和分销操作。 + +
+
+ +
+
+
+ + + 修改初始密码 + + + 当前账号:{admin.nickname || admin.username}({admin.username}) + +
+ + {error ? : null} + + + layout="vertical" + size="large" + requiredMark={false} + onFinish={onFinish} + > + + } placeholder="请输入原密码" /> + + + } placeholder="8–20 位新密码" /> + + ({ + validator(_, value) { + if (!value || getFieldValue('new_password') === value) { + return Promise.resolve() + } + return Promise.reject(new Error('两次输入的新密码不一致')) + }, + }), + ]} + > + } placeholder="再次输入新密码" /> + + + + + +
+ 改密后旧会话会按后端规则刷新 + +
+
+
+
+ ) +} diff --git a/frontend/src/features/admin/AdminDashboardPage.tsx b/frontend/src/features/admin/AdminDashboardPage.tsx new file mode 100644 index 0000000..be37250 --- /dev/null +++ b/frontend/src/features/admin/AdminDashboardPage.tsx @@ -0,0 +1,252 @@ +import { useQuery } from '@tanstack/react-query' +import { + Alert, + Button, + Card, + Col, + Empty, + Row, + Skeleton, + Statistic, + Table, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { + ArrowRightOutlined, + ClockCircleOutlined, + FileSearchOutlined, + ShoppingCartOutlined, + TeamOutlined, + WalletOutlined, +} from '@ant-design/icons' +import dayjs from 'dayjs' +import { useNavigate } from 'react-router-dom' + +import { fetchAdminDashboard } from '@/features/admin/api' +import type { RecentOrder } from '@/features/admin/types' + +const statusMap: Record = { + pending_payment: { label: '待支付', color: 'gold' }, + pending_handoff: { label: '待交接', color: 'blue' }, + renting: { label: '租赁中', color: 'cyan' }, + overdue: { label: '已逾期', color: 'red' }, + pending_checkout_confirm: { label: '待确认结账', color: 'orange' }, + pending_checkout_accept: { label: '待接受结账', color: 'orange' }, + checkout_disputing: { label: '结账争议', color: 'red' }, + completed: { label: '已完成', color: 'green' }, + cancelled: { label: '已取消', color: 'default' }, + closed: { label: '已关闭', color: 'default' }, + abnormal: { label: '异常', color: 'red' }, +} + +function money(cents: number) { + return `¥${(cents / 100).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}` +} + +const orderColumns: TableColumnsType = [ + { + title: '订单号', + dataIndex: 'order_no', + width: 150, + render: value => {value}, + }, + { + title: '商品', + dataIndex: 'title', + ellipsis: true, + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => { + const item = statusMap[value] || { label: value, color: 'default' } + return {item.label} + }, + }, + { + title: '租金', + dataIndex: 'rent_amount_cent', + width: 120, + align: 'right', + render: value => {money(value)}, + }, + { + title: '押金', + dataIndex: 'deposit_amount_cent', + width: 120, + align: 'right', + render: value => {money(value)}, + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: value => dayjs(value).format('YYYY-MM-DD HH:mm'), + }, +] + +export function AdminDashboardPage() { + const navigate = useNavigate() + const { data, isPending, isError, refetch } = useQuery({ + queryKey: ['admin-dashboard'], + queryFn: fetchAdminDashboard, + staleTime: 30_000, + }) + + if (isError) { + return ( +
+ void refetch()}> + 重试 + + } + /> +
+ ) + } + + return ( +
+
+
+ TODAY AT JIEYOU + + 工作台 + + + 先看今天要处理的事,再看整盘账。数据更新于{' '} + {data?.generated_at ? dayjs(data.generated_at).format('HH:mm:ss') : '加载中'}。 + +
+ +
+ + {isPending ? ( + + {Array.from({ length: 4 }).map((_, index) => ( + + + + + + ))} + + ) : ( + + + +
+ +
+ + + 总订单 {data?.metrics.total_orders ?? 0} + +
+ + + +
+ +
+ + + 已实名 {data?.metrics.verified_users ?? 0} + +
+ + + +
+ +
+ + + 商品总数 {data?.metrics.total_listings ?? 0} + +
+ + + +
+ +
+ + + 在租订单 {data?.metrics.renting_orders ?? 0} + +
+ +
+ )} + +
+ 待办 + 客服和运营每天先处理这四类 +
+ + + {[ + ['商品待审核', data?.pending.listing_reviews ?? 0, '去审核', 'listings/review'], + ['待交接', data?.pending.pending_handoffs ?? 0, '去查看', 'orders'], + ['待确认归还', data?.pending.pending_return_confirms ?? 0, '去查看', 'orders'], + ['进行中申诉', data?.pending.disputes ?? 0, '去处理', 'disputes'], + ].map(([label, count, action, path]) => ( + + +
+ {label} + {count} +
+ +
+ + ))} +
+ + + 最近订单 + + } + extra={ + + } + > + + rowKey="id" + size="small" + columns={orderColumns} + dataSource={data?.recent_orders ?? []} + pagination={false} + locale={{ + emptyText: , + }} + /> + +
+ ) +} diff --git a/frontend/src/features/admin/AdminGuard.tsx b/frontend/src/features/admin/AdminGuard.tsx new file mode 100644 index 0000000..ca5dd7f --- /dev/null +++ b/frontend/src/features/admin/AdminGuard.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { Navigate, Outlet, useLocation } from 'react-router-dom' + +import { PageLoading } from '@/components/PageLoading' +import { getAdminBasePath, getAdminLoginPath } from '@/shared/api/adminClient' +import { useAdminSessionStore } from '@/stores/adminSession' + +export function AdminGuard() { + const location = useLocation() + const admin = useAdminSessionStore(state => state.admin) + const loaded = useAdminSessionStore(state => state.loaded) + const loading = useAdminSessionStore(state => state.loading) + const loadMe = useAdminSessionStore(state => state.loadMe) + + useEffect(() => { + if (!loaded && !loading) { + void loadMe() + } + }, [loaded, loading, loadMe]) + + if (!loaded || loading) { + return + } + + if (!admin) { + const redirect = encodeURIComponent(location.pathname + location.search) + return + } + + const changePasswordPath = `${getAdminBasePath()}/change-password` + if (admin.password_must_change && location.pathname !== changePasswordPath) { + return + } + + return +} diff --git a/frontend/src/features/admin/AdminLoginPage.tsx b/frontend/src/features/admin/AdminLoginPage.tsx new file mode 100644 index 0000000..0d6919f --- /dev/null +++ b/frontend/src/features/admin/AdminLoginPage.tsx @@ -0,0 +1,173 @@ +import { useEffect, useState } from 'react' +import { Alert, App, Button, Form, Input, Space, Typography } from 'antd' +import { LockOutlined, ReloadOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons' +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' + +import { BrandLogo } from '@/components/BrandLogo' +import { fetchAdminCaptcha } from '@/features/admin/api' +import type { AdminCaptcha } from '@/features/admin/types' +import { getAdminBasePath } from '@/shared/api/adminClient' +import { useAdminSessionStore } from '@/stores/adminSession' + +interface AdminLoginForm { + username: string + password: string + captcha_code: string +} + +export function AdminLoginPage() { + const { message } = App.useApp() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const admin = useAdminSessionStore(state => state.admin) + const loading = useAdminSessionStore(state => state.loading) + const login = useAdminSessionStore(state => state.login) + const [form] = Form.useForm() + const [captcha, setCaptcha] = useState(null) + const [captchaLoading, setCaptchaLoading] = useState(false) + const [error, setError] = useState('') + + const basePath = getAdminBasePath() + + const refreshCaptcha = async () => { + setCaptchaLoading(true) + try { + setCaptcha(await fetchAdminCaptcha()) + form.setFieldValue('captcha_code', '') + } catch { + setError('图形验证码没有加载出来,请检查后端服务') + } finally { + setCaptchaLoading(false) + } + } + + useEffect(() => { + void refreshCaptcha() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (admin) return + + const onFinish = async (values: AdminLoginForm) => { + if (!captcha) { + setError('图形验证码还没有准备好,请刷新后重试') + return + } + setError('') + try { + await login(values.username.trim(), values.password, captcha.captcha_id, values.captcha_code) + message.success('已进入解忧商行后台') + const current = useAdminSessionStore.getState().admin + if (current?.password_must_change) { + navigate(`${basePath}/change-password`, { replace: true }) + return + } + const redirect = searchParams.get('redirect') + navigate(redirect && redirect.startsWith(basePath) ? redirect : basePath, { replace: true }) + } catch (err) { + const text = err instanceof Error ? err.message : '登录没有完成,请核对账号、密码和验证码' + setError(text) + void refreshCaptcha() + } + } + + return ( +
+
+
+ +
JIEYOU · OPERATIONS DESK
+ + 同一本账, +
+ 另一面。 +
+ + 订单、商品、资金、分销和客服会话都在这里。后台不是另一套系统,而是商行账本的操作台。 + +
+ + 独立认证 · 权限分级 · 高风险操作留痕 +
+
+
+ +
+
+
+ + + 后台登录 + + 使用管理员账号和图形验证码进入 +
+ + {error ? : null} + + + form={form} + layout="vertical" + size="large" + requiredMark={false} + onFinish={onFinish} + > + + } placeholder="请输入管理员账号" autoComplete="username" /> + + + + } + placeholder="请输入密码" + autoComplete="current-password" + /> + + + + + + + + + + + + + + + {import.meta.env.DEV ? ( +
开发管理员:admin / JieyouAdmin@2026
+ ) : null} + +
+ 解忧商行 · 运营后台 + +
+
+
+
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts new file mode 100644 index 0000000..375087c --- /dev/null +++ b/frontend/src/features/admin/api.ts @@ -0,0 +1,49 @@ +import { adminClient } from '@/shared/api/adminClient' +import type { ApiResponse } from '@/shared/api/types' +import type { + AdminCaptcha, + AdminLoginData, + AdminUser, + DashboardData, +} from '@/features/admin/types' + +export async function fetchAdminCaptcha() { + const { data } = await adminClient.get>('/admin/auth/captcha') + return data.data +} + +export async function loginAdmin( + username: string, + password: string, + captchaId: string, + captchaCode: string +) { + const { data } = await adminClient.post>('/admin/auth/login', { + username, + password, + captcha_id: captchaId, + captcha_code: captchaCode, + }) + return data.data +} + +export async function fetchAdminMe() { + const { data } = await adminClient.get>('/admin/me') + return data.data +} + +export async function logoutAdmin() { + await adminClient.post('/admin/auth/logout') +} + +export async function changeAdminPassword(oldPassword: string, newPassword: string) { + await adminClient.put('/admin/admin-users/me/password', { + old_password: oldPassword, + new_password: newPassword, + }) +} + +export async function fetchAdminDashboard() { + const { data } = await adminClient.get>('/admin/dashboard') + return data.data +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts new file mode 100644 index 0000000..85dbe58 --- /dev/null +++ b/frontend/src/features/admin/types.ts @@ -0,0 +1,64 @@ +export interface AdminRole { + id: number + code: string + name: string +} + +export interface AdminUser { + id: number + username: string + nickname: string + status: string + support_status: 'online' | 'offline' | 'busy' + password_must_change: boolean + roles: AdminRole[] + permissions: string[] + last_login_at?: string +} + +export interface AdminCaptcha { + captcha_id: string + image: string + expires_in: number +} + +export interface AdminLoginData { + admin: AdminUser +} + +export interface DashboardMetrics { + total_users: number + verified_users: number + total_listings: number + published_listings: number + total_orders: number + renting_orders: number + today_orders: number + today_ledger_amount_cent: number +} + +export interface DashboardPending { + listing_reviews: number + disputes: number + pending_handoffs: number + pending_return_confirms: number +} + +export interface RecentOrder { + id: number + order_no: string + title: string + renter_id: number + owner_id: number + status: string + rent_amount_cent: number + deposit_amount_cent: number + created_at: string +} + +export interface DashboardData { + metrics: DashboardMetrics + pending: DashboardPending + recent_orders: RecentOrder[] + generated_at: string +} diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index 7733355..5527867 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -44,7 +44,7 @@ export function LoginPage() {
- +
租得明白 diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..6c389d8 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.tsx @@ -0,0 +1,299 @@ +import { useState, type ReactNode } from 'react' +import { + Avatar, + Breadcrumb, + Button, + Dropdown, + Layout, + Menu, + Space, + Tag, + Typography, + type MenuProps, +} from 'antd' +import { + AccountBookOutlined, + BellOutlined, + DashboardOutlined, + FileDoneOutlined, + LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + SettingOutlined, + ShareAltOutlined, + ShopOutlined, + ShoppingCartOutlined, + TeamOutlined, +} from '@ant-design/icons' +import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom' + +import { BrandLogo } from '@/components/BrandLogo' +import { getAdminBasePath, getAdminLoginPath } from '@/shared/api/adminClient' +import { useAdminSessionStore } from '@/stores/adminSession' + +const { Sider, Header, Content } = Layout + +interface NavItem { + key: string + label: string + permission?: string +} + +interface NavGroup { + key: string + label: string + icon: ReactNode + children: NavItem[] +} + +const allNavGroups: NavGroup[] = [ + { + key: 'workspace', + label: '工作台', + icon: , + children: [{ key: '/', label: '仪表盘', permission: 'dashboard:view' }], + }, + { + key: 'trade', + label: '交易', + icon: , + children: [ + { key: '/orders', label: '订单管理', permission: 'order:view' }, + { key: '/orders/refund-review', label: '退款审核', permission: 'order:close' }, + { key: '/disputes', label: '申诉仲裁', permission: 'dispute:view' }, + ], + }, + { + key: 'listings', + label: '商品', + icon: , + children: [ + { key: '/listings', label: '商品管理', permission: 'listing:view' }, + { key: '/listings/review', label: '商品审核', permission: 'listing:approve' }, + ], + }, + { + key: 'users', + label: '用户', + icon: , + children: [{ key: '/users', label: '用户管理', permission: 'user:view' }], + }, + { + key: 'finance', + label: '财务', + icon: , + children: [ + { key: '/finance', label: '财务看板', permission: 'finance:view' }, + { key: '/withdrawals', label: '提现审核', permission: 'withdrawal:view' }, + ], + }, + { + key: 'distribution', + label: '分销', + icon: , + children: [ + { key: '/distribution/sites', label: '站点管理', permission: 'distribution:site_view' }, + ], + }, + { + key: 'operations', + label: '运营', + icon: , + children: [{ key: '/announcements', label: '公告管理', permission: 'announcement:view' }], + }, + { + key: 'system', + label: '系统', + icon: , + children: [ + { key: '/system-configs', label: '系统配置', permission: 'systemconfig:view' }, + { key: '/audit-logs', label: '审计日志', permission: 'audit:view' }, + ], + }, +] + +const sectionMeta: Record = { + '/orders': { title: '订单管理', description: '查看全量订单、交接记录与结账状态。' }, + '/orders/refund-review': { title: '退款审核', description: '审核退款申请并核对资金影响。' }, + '/disputes': { title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' }, + '/listings': { title: '商品管理', description: '查看商品、账号资产与上下架状态。' }, + '/listings/review': { title: '商品审核', description: '审核待上架商品与账号截图。' }, + '/users': { title: '用户管理', description: '查看用户、实名状态与交易记录。' }, + '/finance': { title: '财务看板', description: '查看收入、退款、结算与资金趋势。' }, + '/withdrawals': { title: '提现审核', description: '审核提现申请与打款确认。' }, + '/distribution/sites': { title: '分销站点', description: '管理分站、规则、结算与站点钱包。' }, + '/announcements': { title: '公告管理', description: '维护主站与分站公告。' }, + '/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' }, + '/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' }, +} + +export function AdminLayout() { + const navigate = useNavigate() + const location = useLocation() + const admin = useAdminSessionStore(state => state.admin) + const logout = useAdminSessionStore(state => state.logout) + const [collapsed, setCollapsed] = useState(false) + + const basePath = getAdminBasePath() + const relativePath = + location.pathname.startsWith(basePath) && location.pathname !== basePath + ? location.pathname.slice(basePath.length) + : '/' + + const can = (permission?: string) => { + if (!permission) return true + const permissions = admin?.permissions ?? [] + return permissions.includes('*') || permissions.includes(permission) + } + + const visibleGroups = allNavGroups + .map(group => ({ + ...group, + children: group.children.filter(item => can(item.permission)), + })) + .filter(group => group.children.length > 0) + + const menuItems: MenuProps['items'] = visibleGroups.map(group => ({ + key: group.key, + icon: group.icon, + label: group.label, + children: group.children.map(item => ({ + key: item.key, + label: item.label, + })), + })) + + const currentLabel = + visibleGroups.flatMap(group => group.children).find(item => item.key === relativePath)?.label || + sectionMeta[relativePath]?.title || + (relativePath === '/' ? '仪表盘' : '功能建设中') + + const onLogout = async () => { + await logout() + navigate(getAdminLoginPath(), { replace: true }) + } + + return ( + + + + + {!collapsed ? OPS · P1 : null} + + + group.key)} + onClick={({ key }) => { + if (key.startsWith('/')) { + navigate(key === '/' ? basePath : `${basePath}${key}`) + } + }} + /> + + {!collapsed ? ( +
+ 解忧商行 · 运营后台 + Phase 1 Shell +
+ ) : null} + + + +
+
+
+ + + {admin?.roles?.[0] ? {admin.roles[0].name} : null} + , onClick: onLogout }, + ], + }} + placement="bottomRight" + > + + + +
+ + + + +
+ + ) +} + +export function AdminSectionPlaceholder({ title, description }: { title: string; description: string }) { + return ( +
+
+
+ JIEYOU OPS + + {title} + + {description} +
+ } className="admin-phase-tag"> + Phase 1 · 待迁移 + +
+ +
+ 解忧 + 这个页面还没开始迁移 + + 当前批次先把后台登录、权限会话、浅色 Shell 和真实仪表盘跑通。{title} + 会在后续批次按统一列表/详情/表单原型迁移,不会直接复用旧站页面。 + + +
+
+ ) +} + +export function getAdminSectionMeta(key: string) { + return sectionMeta[key] +} diff --git a/frontend/src/shared/api/adminClient.ts b/frontend/src/shared/api/adminClient.ts new file mode 100644 index 0000000..876b61e --- /dev/null +++ b/frontend/src/shared/api/adminClient.ts @@ -0,0 +1,74 @@ +import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' +import { message } from 'antd' + +export function getAdminBasePath() { + const base = import.meta.env.VITE_ADMIN_BASE_PATH || '/console-jy8k3m' + return base.startsWith('/') ? base.replace(/\/$/, '') : `/${base.replace(/\/$/, '')}` +} + +export function getAdminLoginPath() { + return `${getAdminBasePath()}/login` +} + +export function getAdminHomePath() { + return getAdminBasePath() +} + +function isAdminLoginPage() { + return window.location.pathname === getAdminLoginPath() +} + +function redirectToAdminLogin() { + if (isAdminLoginPage()) return + const current = window.location.pathname + window.location.search + window.location.assign(`${getAdminLoginPath()}?redirect=${encodeURIComponent(current)}`) +} + +function isAuthEntry(url = '') { + return ( + url.includes('/admin/auth/login') || + url.includes('/admin/auth/captcha') || + url.includes('/admin/auth/refresh') + ) +} + +export const adminClient = axios.create({ + baseURL: '/api', + timeout: 30000, + withCredentials: true, + headers: { 'Content-Type': 'application/json' }, +}) + +type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean } + +let adminRefreshPromise: Promise | null = null + +async function refreshAdminSession() { + await axios.post('/api/admin/auth/refresh', {}, { timeout: 10000, withCredentials: true }) +} + +adminClient.interceptors.response.use( + response => response, + async (error: AxiosError) => { + const config = error.config as RetryConfig | undefined + const status = error.response?.status + const url = config?.url || '' + + if (!config || status !== 401 || config._retry || isAuthEntry(url)) { + return Promise.reject(error) + } + + config._retry = true + try { + adminRefreshPromise ??= refreshAdminSession().finally(() => { + adminRefreshPromise = null + }) + await adminRefreshPromise + return adminClient(config) + } catch (refreshError) { + message.error('后台会话已过期,请重新登录') + redirectToAdminLogin() + return Promise.reject(refreshError) + } + } +) diff --git a/frontend/src/stores/adminSession.ts b/frontend/src/stores/adminSession.ts new file mode 100644 index 0000000..919f8c7 --- /dev/null +++ b/frontend/src/stores/adminSession.ts @@ -0,0 +1,93 @@ +import { create } from 'zustand' + +import { changeAdminPassword, fetchAdminMe, loginAdmin, logoutAdmin } from '@/features/admin/api' +import type { AdminUser } from '@/features/admin/types' + +const ADMIN_ID_KEY = 'admin_id' +const ADMIN_USERNAME_KEY = 'admin_username' +const ADMIN_PASSWORD_MUST_CHANGE_KEY = 'admin_password_must_change' + +function saveAdminHint(admin: AdminUser) { + localStorage.setItem(ADMIN_ID_KEY, String(admin.id)) + localStorage.setItem(ADMIN_USERNAME_KEY, admin.username) + localStorage.setItem(ADMIN_PASSWORD_MUST_CHANGE_KEY, String(admin.password_must_change)) +} + +function clearAdminHint() { + localStorage.removeItem(ADMIN_ID_KEY) + localStorage.removeItem(ADMIN_USERNAME_KEY) + localStorage.removeItem(ADMIN_PASSWORD_MUST_CHANGE_KEY) +} + +interface AdminSessionState { + admin: AdminUser | null + loaded: boolean + loading: boolean + login: (username: string, password: string, captchaId: string, captchaCode: string) => Promise + loadMe: () => Promise + logout: () => Promise + changePassword: (oldPassword: string, newPassword: string) => Promise + hasPermission: (code: string) => boolean + isSuperAdmin: () => boolean +} + +export const useAdminSessionStore = create((set, get) => ({ + admin: null, + loaded: false, + loading: false, + + async login(username, password, captchaId, captchaCode) { + set({ loading: true }) + try { + const result = await loginAdmin(username, password, captchaId, captchaCode) + saveAdminHint(result.admin) + set({ admin: result.admin, loaded: true }) + } finally { + set({ loading: false }) + } + }, + + async loadMe() { + if (get().loading) return + set({ loading: true }) + try { + const admin = await fetchAdminMe() + saveAdminHint(admin) + set({ admin, loaded: true }) + } catch { + clearAdminHint() + set({ admin: null, loaded: true }) + } finally { + set({ loading: false }) + } + }, + + async logout() { + try { + await logoutAdmin() + } catch { + // 即使服务端会话失效,也要清理本地提示并回到登录页。 + } + clearAdminHint() + set({ admin: null, loaded: true }) + }, + + async changePassword(oldPassword, newPassword) { + await changeAdminPassword(oldPassword, newPassword) + const admin = await fetchAdminMe() + saveAdminHint(admin) + set({ admin, loaded: true }) + }, + + hasPermission(code) { + const permissions = get().admin?.permissions ?? [] + return permissions.includes('*') || permissions.includes(code) + }, + + isSuperAdmin() { + const admin = get().admin + return Boolean( + admin?.permissions.includes('*') || admin?.roles.some(role => role.code === 'super_admin') + ) + }, +})) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 3c27dcf..1f0704f 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -560,9 +560,6 @@ a { .login-brand__logo { align-self: flex-start; margin-bottom: 48px; - padding: 10px; - border-radius: 4px; - background: var(--paper-warm); } .login-brand__title { @@ -813,3 +810,510 @@ a { flex-direction: column; } } + +/* Phase 1 shared loading */ +.page-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--paper); +} + +.page-loading__placeholder { + width: 120px; + height: 40px; +} + +/* Admin login */ +.admin-login-page { + display: grid; + grid-template-columns: minmax(0, 1.02fr) minmax(0, 0.98fr); + min-height: 100vh; + background: var(--paper); +} + +.admin-login-brand { + position: relative; + overflow: hidden; + background: var(--brand-ink); + color: var(--paper-warm); +} + +.admin-login-brand::after { + position: absolute; + right: -120px; + bottom: -150px; + width: 420px; + height: 420px; + border: 1px solid rgba(236, 208, 177, 0.2); + border-radius: 50%; + content: ''; +} + +.admin-login-brand__inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 100vh; + max-width: 560px; + margin: 0 auto; + padding: 64px; +} + +.admin-login-brand__eyebrow { + margin-top: 52px; + color: var(--brand-seal-line); + font-size: 11px; + letter-spacing: 0.18em; +} + +.admin-login-brand__title { + margin: 18px 0 22px !important; + color: #f0f4f2 !important; + font-size: clamp(42px, 5vw, 64px) !important; + line-height: 1.18 !important; +} + +.admin-login-brand__copy { + max-width: 430px; + color: #d8e2dd !important; + font-size: 15px; + line-height: 1.9; +} + +.admin-login-brand__foot { + margin-top: 32px; + color: #b9cbc3; + font-size: 12px; + letter-spacing: 0.04em; +} + +.admin-login-brand__foot .anticon { + margin-right: 8px; + color: var(--brand-seal-line); +} + +.admin-login-form-panel { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 24px; +} + +.admin-login-card { + width: 100%; + max-width: 430px; + padding: 36px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper-warm); + box-shadow: 0 1px 2px rgba(29, 36, 33, 0.04), 0 20px 60px rgba(29, 36, 33, 0.06); +} + +.admin-login-card__head { + margin-bottom: 24px; +} + +.admin-login-card__head .ant-typography { + display: block; + margin-top: 16px; +} + +.admin-login-card__head h2 { + margin-bottom: 8px !important; + color: var(--brand-ink) !important; +} + +.admin-captcha-button { + width: 128px; + padding: 0 !important; + overflow: hidden; +} + +.admin-captcha-button img { + width: 100%; + height: 40px; + object-fit: cover; +} + +.admin-login-card__foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid var(--line); + color: var(--ink-muted); + font-size: 12px; +} + +.admin-login-card__foot .ant-btn-link { + padding: 0; + color: var(--brand-ink); +} + +/* Admin shell */ +.admin-shell { + min-height: 100vh; + background: var(--paper); +} + +.admin-sider { + border-right: 1px solid var(--line); + background: var(--paper-warm) !important; +} + +.admin-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.admin-sider__brand { + display: flex; + align-items: center; + justify-content: space-between; + height: 72px; + padding: 0 18px; + border-bottom: 1px solid var(--line); +} + +.admin-sider__phase { + color: var(--brand-seal); + font-family: 'Inter', monospace; + font-size: 9px; + letter-spacing: 0.12em; +} + +.admin-menu { + flex: 1; + padding: 14px 10px; + overflow-y: auto; + border-inline-end: 0 !important; + background: transparent !important; +} + +.admin-menu .ant-menu-submenu-title, +.admin-menu .ant-menu-item { + height: 40px; + margin: 2px 0; + border-radius: 4px; + color: var(--ink-secondary); + font-size: 13px; +} + +.admin-menu .ant-menu-item-selected { + color: var(--brand-ink) !important; + font-weight: 600; +} + +.admin-menu .ant-menu-submenu-selected > .ant-menu-submenu-title { + color: var(--brand-ink); +} + +.admin-menu .ant-menu-item .anticon, +.admin-menu .ant-menu-submenu-title .anticon { + color: var(--sage); +} + +.admin-sider__foot { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 20px; + border-top: 1px solid var(--line); + font-size: 11px; +} + +.admin-main { + min-width: 0; + background: var(--paper); +} + +.admin-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + height: 64px; + padding: 0 24px; + border-bottom: 1px solid var(--line); + background: rgba(255, 253, 248, 0.94); + backdrop-filter: blur(12px); +} + +.admin-header__left { + display: flex; + align-items: center; + gap: 14px; +} + +.admin-collapse { + color: var(--ink-secondary); +} + +.admin-role-tag { + margin: 0; + border-radius: 2px; + background: #eef2ec; + color: var(--brand-ink); +} + +.admin-account { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 8px; +} + +.admin-avatar { + background: var(--brand-ink) !important; + color: var(--paper-warm) !important; + font-family: 'Noto Serif SC', 'Songti SC', serif; +} + +.admin-account__name { + max-width: 120px; + overflow: hidden; + color: var(--ink); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-content { + padding: 24px; +} + +.admin-page { + max-width: 1600px; + margin: 0 auto; +} + +.admin-page__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.admin-page__head h2 { + margin: 6px 0 8px !important; + color: var(--brand-ink) !important; + font-size: 30px !important; +} + +.admin-page__eyebrow { + color: var(--brand-seal); + font-size: 10px !important; + letter-spacing: 0.16em; +} + +.admin-phase-tag { + margin: 0; + border-radius: 2px; + background: #f3ece4; + color: var(--brand-seal); +} + +.admin-kpi-card { + min-height: 150px; + border-color: var(--line); + background: var(--paper-warm); +} + +.admin-kpi-card .ant-statistic-title { + color: var(--ink-muted); + font-size: 12px; +} + +.admin-kpi-card .ant-statistic-content { + color: var(--brand-ink); + font-family: 'Inter', 'DIN Alternate', sans-serif; + font-size: 30px; +} + +.admin-kpi-card--accent { + background: var(--brand-ink); +} + +.admin-kpi-card--accent .ant-statistic-title, +.admin-kpi-card--accent .admin-kpi-card__foot { + color: #b9cbc3; +} + +.admin-kpi-card--accent .ant-statistic-content { + color: #f0f4f2; +} + +.admin-kpi-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + margin-bottom: 16px; + border: 1px solid var(--line); + border-radius: 4px; + background: #f4f0e6; + color: var(--brand-seal); + font-size: 17px; +} + +.admin-kpi-card--accent .admin-kpi-card__icon { + border-color: rgba(236, 208, 177, 0.28); + background: rgba(236, 208, 177, 0.08); + color: var(--brand-seal-line); +} + +.admin-kpi-card__foot { + display: block; + margin-top: 8px; + color: var(--ink-muted); + font-size: 12px; +} + +.admin-section-title { + display: flex; + align-items: baseline; + gap: 12px; + margin: 30px 0 14px; +} + +.admin-section-title h4 { + margin: 0 !important; + color: var(--brand-ink) !important; +} + +.admin-pending-card { + display: flex; + align-items: center; + justify-content: space-between; + border-color: var(--line); + background: var(--paper-warm); +} + +.admin-pending-card > .ant-card-body { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 18px 20px; +} + +.admin-pending-card > .ant-card-body > div { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-pending-card__label { + color: var(--ink-muted); + font-size: 12px; +} + +.admin-pending-card strong { + color: var(--brand-ink); + font-family: 'Inter', 'DIN Alternate', sans-serif; + font-size: 28px; + line-height: 1; +} + +.admin-table-card { + margin-top: 24px; + border-color: var(--line); + background: var(--paper-warm); +} + +.admin-table-card .ant-card-head-title { + color: var(--brand-ink); +} + +.admin-table-card .ant-card-head-title .anticon { + margin-right: 6px; + color: var(--brand-seal); +} + +.admin-mono { + color: var(--ink-secondary); + font-family: 'Inter', monospace; + font-size: 11px; +} + +.admin-money { + color: var(--brand-ink); + font-family: 'Inter', monospace; + font-size: 12px; +} + +.admin-placeholder-card { + position: relative; + max-width: 760px; + padding: 34px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--paper-warm); +} + +.admin-placeholder-card h4 { + margin: 0 0 10px !important; + color: var(--brand-ink) !important; +} + +.admin-placeholder-card__seal { + display: inline-flex; + align-items: center; + justify-content: center; + width: 54px; + height: 54px; + margin-bottom: 20px; + border: 2px solid var(--brand-seal); + color: var(--brand-seal); + font-family: 'Noto Serif SC', 'Songti SC', serif; + font-size: 14px; + letter-spacing: 0.1em; + transform: rotate(-3deg); +} + +@media (max-width: 1100px) { + .admin-login-page { + grid-template-columns: minmax(0, 1fr); + } + + .admin-login-brand__inner { + min-height: auto; + padding: 44px 28px 52px; + } + + .admin-login-brand__eyebrow { + margin-top: 30px; + } +} + +@media (max-width: 900px) { + .admin-header { + padding: 0 16px; + } + + .admin-content { + padding: 16px; + } + + .admin-page__head { + align-items: flex-start; + flex-direction: column; + gap: 14px; + } + + .admin-account__name { + display: none; + } +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e0803d6..d5da40e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -22,22 +22,6 @@ export default defineConfig({ }, }, build: { - rollupOptions: { - output: { - manualChunks(id) { - if (!id.includes('node_modules')) return - if (id.includes('antd') || id.includes('@ant-design')) return 'vendor-antd' - if ( - id.includes('/react/') || - id.includes('/react-dom/') || - id.includes('/scheduler/') || - id.includes('zustand') - ) { - return 'vendor-react' - } - return - }, - }, - }, + chunkSizeWarningLimit: 700, }, })