feat(frontend): 完成品牌 SVG 与后台 Phase 1 基础切片
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
<BrandLogo variant="light" />
|
||||
<BrandLogo variant="dark" />
|
||||
<BrandLogo variant="seal" />
|
||||
```
|
||||
|
||||
> 当前 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 按需优化。
|
||||
+1
-1
@@ -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 版本:
|
||||
|
||||
|
||||
@@ -352,6 +352,8 @@ hfb_jysh/
|
||||
|
||||
### Phase 1:品牌与设计系统(1 周)
|
||||
|
||||
> 状态:**基础切片已完成(2026-10-30)**,验收见 [`Phase1-验收报告.md`](./Phase1-验收报告.md)。已完成 SVG 品牌资产、管理员会话、后台登录/强制改密/Shell/仪表盘与路由懒加载;移动端与后台列表页原型继续。
|
||||
|
||||
- 重绘 logo SVG:横版、方形印章、单色、反白。
|
||||
- 落地 Design Token:颜色、字体、间距、圆角、阴影、动效。
|
||||
- 搭建 AntD `ConfigProvider` 主题。
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1E4D42" />
|
||||
<meta
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">解忧商行印章</title>
|
||||
<desc id="desc">解忧商行方形朱砂印章</desc>
|
||||
<rect x="2" y="2" width="60" height="60" rx="8" fill="#A64332"/>
|
||||
<rect x="7" y="7" width="50" height="50" rx="3" fill="none" stroke="#ECD0B1" stroke-width="1.4"/>
|
||||
<text x="32" y="31" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">解</text>
|
||||
<text x="32" y="52" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">忧</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 722 B |
@@ -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 (
|
||||
<Routes>
|
||||
<Route element={<StorefrontLayout />}>
|
||||
<Route index element={<ListingListPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path={`${adminBase}/*`} element={<AdminPlaceholderPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route element={<StorefrontLayout />}>
|
||||
<Route index element={<ListingListPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route path={`${adminBase}/login`} element={<AdminLoginPage />} />
|
||||
<Route path={adminBase} element={<AdminGuard />}>
|
||||
<Route path="change-password" element={<AdminChangePasswordPage />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
{adminSections.map(section => (
|
||||
<Route
|
||||
key={section.path}
|
||||
path={section.path}
|
||||
element={
|
||||
<AdminSectionPlaceholder
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AdminSectionPlaceholder
|
||||
title="功能建设中"
|
||||
description="这个后台页面还没有进入迁移批次。"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" role="img" aria-labelledby="title desc">
|
||||
<title id="title">解忧商行</title>
|
||||
<desc id="desc">解忧商行横版 logo,深色背景反白使用</desc>
|
||||
<rect x="2" y="10" width="60" height="60" rx="7" fill="#A64332"/>
|
||||
<rect x="7" y="15" width="50" height="50" rx="3" fill="none" stroke="#ECD0B1" stroke-width="1.2"/>
|
||||
<text x="32" y="39" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">解</text>
|
||||
<text x="32" y="60" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">忧</text>
|
||||
<text x="78" y="46" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="34" font-weight="600" letter-spacing="4" fill="#F0F4F2">解忧商行</text>
|
||||
<text x="80" y="66" font-family="'Inter','PingFang SC',sans-serif" font-size="9.5" font-weight="500" letter-spacing="2.8" fill="#ECD0B1">JIEYOU TRADING HOUSE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" role="img" aria-labelledby="title desc">
|
||||
<title id="title">解忧商行</title>
|
||||
<desc id="desc">解忧商行横版 logo,浅色背景使用</desc>
|
||||
<rect x="2" y="10" width="60" height="60" rx="7" fill="#A64332"/>
|
||||
<rect x="7" y="15" width="50" height="50" rx="3" fill="none" stroke="#ECD0B1" stroke-width="1.2"/>
|
||||
<text x="32" y="39" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">解</text>
|
||||
<text x="32" y="60" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">忧</text>
|
||||
<text x="78" y="46" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="34" font-weight="600" letter-spacing="4" fill="#1E4D42">解忧商行</text>
|
||||
<text x="80" y="66" font-family="'Inter','PingFang SC',sans-serif" font-size="9.5" font-weight="500" letter-spacing="2.8" fill="#6F857C">JIEYOU TRADING HOUSE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">解忧商行印章</title>
|
||||
<desc id="desc">解忧商行方形朱砂印章</desc>
|
||||
<rect x="2" y="2" width="60" height="60" rx="8" fill="#A64332"/>
|
||||
<rect x="7" y="7" width="50" height="50" rx="3" fill="none" stroke="#ECD0B1" stroke-width="1.4"/>
|
||||
<text x="32" y="31" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">解</text>
|
||||
<text x="32" y="52" text-anchor="middle" font-family="'Noto Serif SC','Source Han Serif SC','Songti SC',serif" font-size="20" font-weight="700" fill="#FFFDF8">忧</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 722 B |
@@ -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<BrandLogoVariant, string> = {
|
||||
light: logoLight,
|
||||
dark: logoDark,
|
||||
seal: logoSeal,
|
||||
}
|
||||
|
||||
export function BrandLogo({ height = 44, variant = 'light', className }: BrandLogoProps) {
|
||||
return (
|
||||
<img
|
||||
src={logo}
|
||||
src={logoSources[variant]}
|
||||
alt="解忧商行 JIEYOU TRADING HOUSE"
|
||||
className={className}
|
||||
style={{ height, width: 'auto', display: 'block', borderRadius: 4 }}
|
||||
style={{ height, width: 'auto', display: 'block' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Spin } from 'antd'
|
||||
|
||||
export function PageLoading({ tip = '正在核对商行账本…' }: { tip?: string }) {
|
||||
return (
|
||||
<div className="page-loading">
|
||||
<Spin size="large" tip={tip}>
|
||||
<div className="page-loading__placeholder" />
|
||||
</Spin>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <Navigate to={getAdminLoginPath()} replace />
|
||||
if (!admin.password_must_change) return <Navigate to={basePath} replace />
|
||||
|
||||
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 (
|
||||
<main className="admin-login-page">
|
||||
<section className="admin-login-brand">
|
||||
<div className="admin-login-brand__inner">
|
||||
<BrandLogo height={56} variant="dark" />
|
||||
<div className="admin-login-brand__eyebrow">FIRST RUN · SECURITY</div>
|
||||
<Typography.Title className="brand-title admin-login-brand__title">
|
||||
先把钥匙
|
||||
<br />
|
||||
换掉。
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="admin-login-brand__copy">
|
||||
初始密码只用于第一次进入。修改完成后,这个后台会话才能打开订单、财务和分销操作。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-login-form-panel">
|
||||
<div className="admin-login-card">
|
||||
<div className="admin-login-card__head">
|
||||
<BrandLogo height={34} />
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
修改初始密码
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
当前账号:{admin.nickname || admin.username}({admin.username})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} className="login-alert" /> : null}
|
||||
|
||||
<Form<ChangePasswordForm>
|
||||
layout="vertical"
|
||||
size="large"
|
||||
requiredMark={false}
|
||||
onFinish={onFinish}
|
||||
>
|
||||
<Form.Item
|
||||
label="原密码"
|
||||
name="old_password"
|
||||
rules={[{ required: true, message: '请输入原密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="请输入原密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="new_password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, max: 20, message: '密码长度需为 8–20 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="8–20 位新密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirm_password"
|
||||
dependencies={['new_password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('new_password') === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的新密码不一致'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="再次输入新密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block loading={submitting}>
|
||||
更新密码并进入后台
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="admin-login-card__foot">
|
||||
<span>改密后旧会话会按后端规则刷新</span>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={async () => {
|
||||
await logout()
|
||||
navigate(getAdminLoginPath(), { replace: true })
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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<string, { label: string; color: string }> = {
|
||||
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<RecentOrder> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
width: 150,
|
||||
render: value => <span className="admin-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: value => {
|
||||
const item = statusMap[value] || { label: value, color: 'default' }
|
||||
return <Tag color={item.color}>{item.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '租金',
|
||||
dataIndex: 'rent_amount_cent',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: value => <span className="admin-money">{money(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '押金',
|
||||
dataIndex: 'deposit_amount_cent',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: value => <span className="admin-money">{money(value)}</span>,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className="admin-page">
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="仪表盘没有加载出来"
|
||||
description="后台会话可能已失效,或者仪表盘服务暂时不可用。"
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page__head">
|
||||
<div>
|
||||
<Typography.Text className="admin-page__eyebrow">TODAY AT JIEYOU</Typography.Text>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
工作台
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
先看今天要处理的事,再看整盘账。数据更新于{' '}
|
||||
{data?.generated_at ? dayjs(data.generated_at).format('HH:mm:ss') : '加载中'}。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate('orders')}>
|
||||
去处理订单 <ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Col xs={24} sm={12} xl={6} key={index}>
|
||||
<Card className="admin-kpi-card">
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<ShoppingCartOutlined />
|
||||
</div>
|
||||
<Statistic title="今日订单" value={data?.metrics.today_orders ?? 0} />
|
||||
<span className="admin-kpi-card__foot">
|
||||
总订单 {data?.metrics.total_orders ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<Statistic title="用户总数" value={data?.metrics.total_users ?? 0} />
|
||||
<span className="admin-kpi-card__foot">
|
||||
已实名 {data?.metrics.verified_users ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<FileSearchOutlined />
|
||||
</div>
|
||||
<Statistic title="在架商品" value={data?.metrics.published_listings ?? 0} />
|
||||
<span className="admin-kpi-card__foot">
|
||||
商品总数 {data?.metrics.total_listings ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card admin-kpi-card--accent">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<WalletOutlined />
|
||||
</div>
|
||||
<Statistic
|
||||
title="今日入账"
|
||||
value={money(data?.metrics.today_ledger_amount_cent ?? 0)}
|
||||
/>
|
||||
<span className="admin-kpi-card__foot">
|
||||
在租订单 {data?.metrics.renting_orders ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<div className="admin-section-title">
|
||||
<Typography.Title level={4}>待办</Typography.Title>
|
||||
<Typography.Text type="secondary">客服和运营每天先处理这四类</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{[
|
||||
['商品待审核', 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]) => (
|
||||
<Col xs={24} sm={12} xl={6} key={String(label)}>
|
||||
<Card className="admin-pending-card">
|
||||
<div>
|
||||
<span className="admin-pending-card__label">{label}</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
<Button type="link" onClick={() => navigate(String(path))}>
|
||||
{action} <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
className="admin-table-card"
|
||||
title={
|
||||
<span>
|
||||
<ClockCircleOutlined /> 最近订单
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button type="link" onClick={() => navigate('orders')}>
|
||||
查看全部
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table<RecentOrder>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={orderColumns}
|
||||
dataSource={data?.recent_orders ?? []}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有订单" />,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <PageLoading tip="正在核对后台会话…" />
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
const redirect = encodeURIComponent(location.pathname + location.search)
|
||||
return <Navigate to={`${getAdminLoginPath()}?redirect=${redirect}`} replace />
|
||||
}
|
||||
|
||||
const changePasswordPath = `${getAdminBasePath()}/change-password`
|
||||
if (admin.password_must_change && location.pathname !== changePasswordPath) {
|
||||
return <Navigate to={changePasswordPath} replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -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<AdminLoginForm>()
|
||||
const [captcha, setCaptcha] = useState<AdminCaptcha | null>(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 <Navigate to={basePath} replace />
|
||||
|
||||
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 (
|
||||
<main className="admin-login-page">
|
||||
<section className="admin-login-brand">
|
||||
<div className="admin-login-brand__inner">
|
||||
<BrandLogo height={56} variant="dark" />
|
||||
<div className="admin-login-brand__eyebrow">JIEYOU · OPERATIONS DESK</div>
|
||||
<Typography.Title className="brand-title admin-login-brand__title">
|
||||
同一本账,
|
||||
<br />
|
||||
另一面。
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="admin-login-brand__copy">
|
||||
订单、商品、资金、分销和客服会话都在这里。后台不是另一套系统,而是商行账本的操作台。
|
||||
</Typography.Paragraph>
|
||||
<div className="admin-login-brand__foot">
|
||||
<SafetyCertificateOutlined />
|
||||
独立认证 · 权限分级 · 高风险操作留痕
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-login-form-panel">
|
||||
<div className="admin-login-card">
|
||||
<div className="admin-login-card__head">
|
||||
<BrandLogo height={34} />
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
后台登录
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">使用管理员账号和图形验证码进入</Typography.Text>
|
||||
</div>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} className="login-alert" /> : null}
|
||||
|
||||
<Form<AdminLoginForm>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
requiredMark={false}
|
||||
onFinish={onFinish}
|
||||
>
|
||||
<Form.Item
|
||||
label="管理员账号"
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入管理员账号' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="请输入管理员账号" autoComplete="username" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="图形验证码" required>
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
name="captcha_code"
|
||||
noStyle
|
||||
rules={[{ required: true, message: '请输入图形验证码' }]}
|
||||
>
|
||||
<Input placeholder="请输入验证码" maxLength={6} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
className="admin-captcha-button"
|
||||
onClick={() => void refreshCaptcha()}
|
||||
loading={captchaLoading}
|
||||
icon={captcha ? undefined : <ReloadOutlined />}
|
||||
>
|
||||
{captcha ? <img src={captcha.image} alt="图形验证码" /> : '刷新'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
进入后台
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<div className="login-dev-hint">开发管理员:admin / JieyouAdmin@2026</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-login-card__foot">
|
||||
<span>解忧商行 · 运营后台</span>
|
||||
<Button type="link" onClick={() => navigate('/')}>
|
||||
返回商行首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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<ApiResponse<AdminCaptcha>>('/admin/auth/captcha')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function loginAdmin(
|
||||
username: string,
|
||||
password: string,
|
||||
captchaId: string,
|
||||
captchaCode: string
|
||||
) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminLoginData>>('/admin/auth/login', {
|
||||
username,
|
||||
password,
|
||||
captcha_id: captchaId,
|
||||
captcha_code: captchaCode,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminMe() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminUser>>('/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<ApiResponse<DashboardData>>('/admin/dashboard')
|
||||
return data.data
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function LoginPage() {
|
||||
<section className="login-brand-panel">
|
||||
<div className="login-brand-panel__inner">
|
||||
<div className="login-brand__logo">
|
||||
<BrandLogo height={58} />
|
||||
<BrandLogo height={58} variant="dark" />
|
||||
</div>
|
||||
<Typography.Title level={1} className="brand-title login-brand__title">
|
||||
租得明白
|
||||
|
||||
@@ -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: <DashboardOutlined />,
|
||||
children: [{ key: '/', label: '仪表盘', permission: 'dashboard:view' }],
|
||||
},
|
||||
{
|
||||
key: 'trade',
|
||||
label: '交易',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
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: <ShopOutlined />,
|
||||
children: [
|
||||
{ key: '/listings', label: '商品管理', permission: 'listing:view' },
|
||||
{ key: '/listings/review', label: '商品审核', permission: 'listing:approve' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户',
|
||||
icon: <TeamOutlined />,
|
||||
children: [{ key: '/users', label: '用户管理', permission: 'user:view' }],
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
label: '财务',
|
||||
icon: <AccountBookOutlined />,
|
||||
children: [
|
||||
{ key: '/finance', label: '财务看板', permission: 'finance:view' },
|
||||
{ key: '/withdrawals', label: '提现审核', permission: 'withdrawal:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'distribution',
|
||||
label: '分销',
|
||||
icon: <ShareAltOutlined />,
|
||||
children: [
|
||||
{ key: '/distribution/sites', label: '站点管理', permission: 'distribution:site_view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'operations',
|
||||
label: '运营',
|
||||
icon: <BellOutlined />,
|
||||
children: [{ key: '/announcements', label: '公告管理', permission: 'announcement:view' }],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统',
|
||||
icon: <SettingOutlined />,
|
||||
children: [
|
||||
{ key: '/system-configs', label: '系统配置', permission: 'systemconfig:view' },
|
||||
{ key: '/audit-logs', label: '审计日志', permission: 'audit:view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const sectionMeta: Record<string, { title: string; description: string }> = {
|
||||
'/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 (
|
||||
<Layout className="admin-shell">
|
||||
<Sider
|
||||
className="admin-sider"
|
||||
theme="light"
|
||||
width={240}
|
||||
collapsedWidth={72}
|
||||
collapsed={collapsed}
|
||||
trigger={null}
|
||||
>
|
||||
<Link to={basePath} className="admin-sider__brand" aria-label="解忧商行后台首页">
|
||||
<BrandLogo height={34} variant="light" />
|
||||
{!collapsed ? <span className="admin-sider__phase">OPS · P1</span> : null}
|
||||
</Link>
|
||||
|
||||
<Menu
|
||||
className="admin-menu"
|
||||
mode="inline"
|
||||
theme="light"
|
||||
inlineCollapsed={collapsed}
|
||||
items={menuItems}
|
||||
selectedKeys={[relativePath]}
|
||||
defaultOpenKeys={visibleGroups.map(group => group.key)}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('/')) {
|
||||
navigate(key === '/' ? basePath : `${basePath}${key}`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{!collapsed ? (
|
||||
<div className="admin-sider__foot">
|
||||
<Typography.Text type="secondary">解忧商行 · 运营后台</Typography.Text>
|
||||
<Typography.Text type="secondary">Phase 1 Shell</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</Sider>
|
||||
|
||||
<Layout className="admin-main">
|
||||
<Header className="admin-header">
|
||||
<div className="admin-header__left">
|
||||
<Button
|
||||
type="text"
|
||||
className="admin-collapse"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(value => !value)}
|
||||
aria-label={collapsed ? '展开侧栏' : '收起侧栏'}
|
||||
/>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ title: '解忧商行' },
|
||||
{ title: currentLabel },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space size={16}>
|
||||
{admin?.roles?.[0] ? <Tag className="admin-role-tag">{admin.roles[0].name}</Tag> : null}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'account',
|
||||
label: `${admin?.nickname || admin?.username || '管理员'} · ${admin?.username || ''}`,
|
||||
disabled: true,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ key: 'logout', label: '退出后台', icon: <LogoutOutlined />, onClick: onLogout },
|
||||
],
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" className="admin-account">
|
||||
<Avatar size={30} className="admin-avatar">
|
||||
{(admin?.nickname || admin?.username || '管').slice(0, 1)}
|
||||
</Avatar>
|
||||
<span className="admin-account__name">{admin?.nickname || admin?.username}</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Content className="admin-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminSectionPlaceholder({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page__head">
|
||||
<div>
|
||||
<Typography.Text className="admin-page__eyebrow">JIEYOU OPS</Typography.Text>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
{title}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">{description}</Typography.Text>
|
||||
</div>
|
||||
<Tag icon={<FileDoneOutlined />} className="admin-phase-tag">
|
||||
Phase 1 · 待迁移
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div className="admin-placeholder-card">
|
||||
<span className="admin-placeholder-card__seal">解忧</span>
|
||||
<Typography.Title level={4}>这个页面还没开始迁移</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
当前批次先把后台登录、权限会话、浅色 Shell 和真实仪表盘跑通。{title}
|
||||
会在后续批次按统一列表/详情/表单原型迁移,不会直接复用旧站页面。
|
||||
</Typography.Paragraph>
|
||||
<Button type="primary" onClick={() => window.history.back()}>
|
||||
返回上一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function getAdminSectionMeta(key: string) {
|
||||
return sectionMeta[key]
|
||||
}
|
||||
@@ -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<void> | 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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<void>
|
||||
loadMe: () => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<void>
|
||||
hasPermission: (code: string) => boolean
|
||||
isSuperAdmin: () => boolean
|
||||
}
|
||||
|
||||
export const useAdminSessionStore = create<AdminSessionState>((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')
|
||||
)
|
||||
},
|
||||
}))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-17
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user