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 @@
-
+
+ 解忧商行印章
+ 解忧商行方形朱砂印章
+
+
+ 解
+ 忧
+
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 @@
+
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 @@
+
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 (
)
}
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}
+
+
+ } 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}
+
+
+ } 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}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+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,
},
})