支持配置后台页面入口

This commit is contained in:
yml2213
2026-06-11 08:00:13 +08:00
parent 7130135e23
commit 6d61a336d6
27 changed files with 157 additions and 85 deletions
+11 -11
View File
@@ -20,7 +20,7 @@
前端地址:`http://localhost:5173`
后台地址:`http://localhost:5173/admin/login`,开发态默认管理员为 `admin / admin123456`
后台地址默认`http://localhost:5173/admin/login`。可通过 `VITE_ADMIN_BASE_PATH` 调整后台页面入口
如需拆分调试,可手动执行:
@@ -55,19 +55,19 @@ npm run dev
- 文件上传已接入 MinIO,发布账号资产截图和申诉证据可上传 JPG、PNG、WebP 或 PDF。
- 如果本机有代理工具占用 `127.0.0.1:9000`MinIO 开发地址使用 `http://localhost:9000`
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance``GET /api/wallet/ledger` 查看。
- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`支持按用户、订单和业务类型查询。
- 后台资金流水已接入,支持按用户、订单和业务类型查询。
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
- 订单群聊已支持支付成功后自动创建,租客、号主和客服可在移动端消息页进入会话。
- 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理,后台页面为 `http://localhost:5173/admin/disputes`
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`更新会写入审计日志。
- 后台已使用独立登录、图形验证码和独立管理 UI,页面`http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`
- 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`支持冻结和解冻用户。
- 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`支持查看全量订单和交接记录。
- 商品管理后台已接入,页面为 `http://localhost:5173/admin/listings`支持查看全量商品、商品详情、强制下架和标记异常。
- 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理。
- 系统配置已支持默认配置初始化和后台编辑,更新会写入审计日志。
- 后台已使用独立登录、图形验证码和独立管理 UI,页面入口由 `VITE_ADMIN_BASE_PATH` 控制
- 后台仪表盘已接入真实统计数据。
- 用户管理后台已接入,支持冻结和解冻用户。
- 订单管理后台已接入,支持查看全量订单和交接记录。
- 商品管理后台已接入,支持查看全量商品、商品详情、强制下架和标记异常。
- 订单详情后台已支持客服关闭和标记异常,相关操作会写入审计日志并通知双方。
- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`审核员可查看账号资产截图后通过或拒绝。
- 审计日志后台已接入,页面为 `http://localhost:5173/admin/audit-logs`支持查看高风险操作明细。
- 商品审核后台已接入,审核员可查看账号资产截图后通过或拒绝。
- 审计日志后台已接入,支持查看高风险操作明细。
## 文档
+3
View File
@@ -14,6 +14,9 @@ REDIS_DB=0
JWT_SECRET=change-me
# 前端后台页面入口;本地默认 /admin,生产建议改成不公开的单段路径。
VITE_ADMIN_BASE_PATH=/admin
# 首次启动且管理员表为空时,按以下变量创建首个超级管理员。
# 本地开发可自行填写,生产环境请使用随机强密码,创建后建议从 .env 移除。
ADMIN_BOOTSTRAP_USERNAME=
+3
View File
@@ -19,6 +19,9 @@ REDIS_DB=0
JWT_SECRET=change-to-a-long-random-secret
# 前端后台页面入口;生产请改成不公开的单段路径,不要使用 /admin。
VITE_ADMIN_BASE_PATH=/manage-7x9k2p
# 首次启动且管理员表为空时创建首个超级管理员;生产密码至少 12 位且包含字母和数字。
# 创建成功后建议从 .env 移除,后续管理员通过后台维护。
ADMIN_BOOTSTRAP_USERNAME=change-admin-username
+2 -2
View File
@@ -108,8 +108,8 @@ curl https://你的域名/api/health
浏览器访问:
```text
https://hfb.221329.cc.cd
https://hfb.221329.cc.cd/admin/login
https://你的域名
https://你的域名/你配置的后台入口/login
```
Caddy 证书和 ACME 账号数据保存在 Docker 卷 `caddy_data``caddy_config` 中。不要随意删除这两个卷,否则 Caddy 会重新申请证书,频繁重建可能触发证书签发频率限制。
+6
View File
@@ -48,6 +48,12 @@
}
}
# 生产后台页面使用构建时配置的隐藏入口,旧 /admin 入口直接返回 404。
@legacy_admin path /admin /admin/*
handle @legacy_admin {
respond 404
}
# 其余流量返回前端静态资源,未命中的路径交给 SPA 处理。
handle {
root * /srv/dist
+3
View File
@@ -1,6 +1,9 @@
# 第一阶段:构建前端静态资源
FROM node:24-alpine AS build
ARG VITE_ADMIN_BASE_PATH=/manage-7x9k2p
ENV VITE_ADMIN_BASE_PATH=${VITE_ADMIN_BASE_PATH}
WORKDIR /src
COPY frontend/package*.json ./
RUN npm install
+2
View File
@@ -3,6 +3,8 @@ services:
build:
context: ..
dockerfile: deploy/caddy/Dockerfile
args:
VITE_ADMIN_BASE_PATH: ${VITE_ADMIN_BASE_PATH:-/manage-7x9k2p}
restart: unless-stopped
environment:
TZ: Asia/Shanghai
@@ -19,6 +19,7 @@ import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
import { adminPath } from '@/shared/utils/adminPath'
import { formatDateMinute } from '@/shared/utils/time'
import TransferDialog from '../components/TransferDialog.vue'
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
@@ -407,7 +408,7 @@ function getSupportName(item: ChatConversation) {
</div>
<div class="head-actions">
<el-button size="small" @click="transferVisible = true">转接</el-button>
<RouterLink v-if="active.order_id" :to="`/admin/orders/${active.order_id}`">
<RouterLink v-if="active.order_id" :to="adminPath(`orders/${active.order_id}`)">
<el-button size="small">查看订单</el-button>
</RouterLink>
</div>
@@ -18,6 +18,7 @@ import {
import { fetchAdminDashboard, type AdminDashboard } from '@/features/admin/api/adminDashboard'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { adminPath } from '@/shared/utils/adminPath'
import { disputeStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
@@ -121,7 +122,7 @@ const {
</el-tag>
</div>
<div class="pending-list">
<RouterLink class="pending-row" to="/admin/disputes">
<RouterLink class="pending-row" :to="adminPath('disputes')">
<div class="pending-info">
<span class="pending-label">待仲裁申诉</span>
<span class="pending-desc">需要处理的用户争议</span>
@@ -130,7 +131,7 @@ const {
dashboard.pending.disputes
}}</strong>
</RouterLink>
<RouterLink class="pending-row" to="/admin/listings/review">
<RouterLink class="pending-row" :to="adminPath('listings/review')">
<div class="pending-info">
<span class="pending-label">待审核商品</span>
<span class="pending-desc">新提交的商品审核</span>
@@ -168,27 +169,27 @@ const {
</h2>
</div>
<div class="quick-links">
<RouterLink class="quick-link" to="/admin/users">
<RouterLink class="quick-link" :to="adminPath('users')">
<el-icon><User /></el-icon>
<span>用户管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/orders">
<RouterLink class="quick-link" :to="adminPath('orders')">
<el-icon><Tickets /></el-icon>
<span>订单管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/disputes">
<RouterLink class="quick-link" :to="adminPath('disputes')">
<el-icon><ScaleToOriginal /></el-icon>
<span>仲裁中心</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/listings">
<RouterLink class="quick-link" :to="adminPath('listings')">
<el-icon><Shop /></el-icon>
<span>商品管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/chats">
<RouterLink class="quick-link" :to="adminPath('chats')">
<el-icon><ChatDotRound /></el-icon>
<span>客服群聊</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/system-configs">
<RouterLink class="quick-link" :to="adminPath('system-configs')">
<el-icon><Operation /></el-icon>
<span>系统配置</span>
</RouterLink>
@@ -259,7 +260,7 @@ const {
</el-table-column>
<el-table-column label="操作" width="100">
<template #default>
<RouterLink to="/admin/disputes">
<RouterLink :to="adminPath('disputes')">
<el-button size="small" type="primary" link>处理</el-button>
</RouterLink>
</template>
@@ -3,6 +3,7 @@ import { Search } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import { fetchFinanceDetails, type FinanceDetail } from '@/features/admin/api/adminFinance'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
@@ -197,7 +198,7 @@ function formatInputDate(date: Date) {
<div v-if="details.length > 0" class="finance-grid-body">
<div v-for="row in details" :key="row.order_id" class="finance-grid-row">
<div class="order-cell">
<RouterLink class="order-no" :to="`/admin/orders/${row.order_id}`">
<RouterLink class="order-no" :to="adminPath(`orders/${row.order_id}`)">
{{ row.order_no }}
</RouterLink>
<span class="cell-muted">结算 {{ shortDateTime(row.settled_at) }}</span>
@@ -6,6 +6,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import {
adminMarkListingAbnormal,
@@ -117,7 +118,7 @@ async function openScreenshot(url: string) {
</div>
<div class="toolbar-actions">
<el-button :icon="CopyDocument" @click="copyListingCode">复制编号</el-button>
<RouterLink to="/admin/listings">
<RouterLink :to="adminPath('listings')">
<el-button>返回列表</el-button>
</RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { adminPath } from '@/shared/utils/adminPath'
import { centToYuan, formatCent, formatMoneyWithSymbol, yuanToCent } from '@/shared/utils/money'
import {
adjustListingReviewPrice,
@@ -667,7 +668,7 @@ async function openScreenshot(url: string) {
<h2>{{ selectedListing.title }}</h2>
</div>
<div class="hero-actions">
<RouterLink :to="`/admin/listings/${selectedListing.id}`">
<RouterLink :to="adminPath(`listings/${selectedListing.id}`)">
<el-button>详情页</el-button>
</RouterLink>
<el-button
@@ -10,6 +10,7 @@ import {
type Listing,
} from '@/features/listings'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import {
assetRegions,
@@ -558,7 +559,7 @@ function formatQuantity(value: number) {
</el-table-column>
<el-table-column label="详情" width="60">
<template #default="{ row }">
<RouterLink :to="`/admin/listings/${row.id}`">
<RouterLink :to="adminPath(`listings/${row.id}`)">
<el-button size="small">详情</el-button>
</RouterLink>
</template>
@@ -7,6 +7,7 @@ import { useRoute, useRouter } from 'vue-router'
import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
import { useAdminSessionStore } from '@/stores/adminSession'
import { ADMIN_DASHBOARD_PATH, isAdminPath } from '@/shared/utils/adminPath'
const router = useRouter()
const route = useRoute()
@@ -45,7 +46,7 @@ async function handleLogin() {
)
ElMessage.success('后台登录成功')
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
await router.push(redirect.startsWith('/admin') ? redirect : '/admin/dashboard')
await router.push(isAdminPath(redirect) ? redirect : ADMIN_DASHBOARD_PATH)
} catch (error) {
ElMessage.error(readError(error, '后台登录失败'))
await loadCaptcha()
@@ -16,6 +16,7 @@ import {
type RefundStatus,
} from '@/features/orders'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { adminPath } from '@/shared/utils/adminPath'
import { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
@@ -195,7 +196,7 @@ function formatHandoffRecordType(type: string) {
</p>
</div>
<div class="toolbar-actions">
<RouterLink to="/admin/orders">
<RouterLink :to="adminPath('orders')">
<el-button>返回列表</el-button>
</RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
@@ -10,6 +10,7 @@ import {
import { formatDateTime } from '@/shared/utils/time'
import { centToYuan, formatMoney } from '@/shared/utils/money'
import { formatListingNo } from '@/shared/utils/listingDisplay'
import { adminPath } from '@/shared/utils/adminPath'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const status = ref('')
@@ -109,7 +110,7 @@ function money(value: unknown) {
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<RouterLink :to="`/admin/orders/${row.id}`">
<RouterLink :to="adminPath(`orders/${row.id}`)">
<el-button size="small">详情</el-button>
</RouterLink>
</template>
@@ -3,6 +3,7 @@ import { Document, Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
@@ -196,7 +197,7 @@ function jsonText(value: unknown) {
<el-table-column prop="payment_no" label="支付单号" min-width="220" />
<el-table-column label="订单" min-width="180">
<template #default="{ row }">
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{
<RouterLink v-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">{{
row.order_no || row.order_id
}}</RouterLink>
<span v-else>-</span>
@@ -3,6 +3,7 @@ import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { balanceTypeLabel, ledgerDirectionLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
@@ -136,7 +137,7 @@ function directionLabel(direction: string) {
</el-table-column>
<el-table-column label="订单" min-width="160">
<template #default="{ row }">
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{
<RouterLink v-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">{{
row.order_no || row.order_id
}}</RouterLink>
<span v-else>-</span>
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { ChatEvent } from './useChatSSE'
import { adminPath } from '@/shared/utils/adminPath'
import { debugError } from '@/shared/utils/debug'
export type NotificationPermission = 'default' | 'granted' | 'denied'
@@ -109,7 +110,7 @@ export function useDesktopNotification(scope: 'user' | 'admin') {
window.focus()
const path =
scope === 'admin'
? `/admin/chats/${event.conversation_id}`
? adminPath(`chats/${event.conversation_id}`)
: `/messages/${event.conversation_id}`
router.push(path)
notification.close()
+23 -22
View File
@@ -32,6 +32,7 @@ import { useRoute, useRouter } from 'vue-router'
import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin'
import { useAdminSessionStore } from '@/stores/adminSession'
import { adminPath, ADMIN_DASHBOARD_PATH, ADMIN_LOGIN_PATH } from '@/shared/utils/adminPath'
import { readError } from '@/shared/utils/error'
const router = useRouter()
@@ -66,7 +67,7 @@ const allNavGroups: NavGroup[] = [
index: 'workspace',
icon: House,
children: [
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine, permission: 'dashboard:view' },
{ label: '仪表盘', to: ADMIN_DASHBOARD_PATH, icon: DataLine, permission: 'dashboard:view' },
],
},
{
@@ -74,12 +75,12 @@ const allNavGroups: NavGroup[] = [
index: 'business',
icon: Briefcase,
children: [
{ label: '用户管理', to: '/admin/users', icon: User, permission: 'user:view' },
{ label: '订单管理', to: '/admin/orders', icon: Tickets, permission: 'order:view' },
{ label: '商品管理', to: '/admin/listings', icon: Shop, permission: 'listing:view' },
{ label: '用户管理', to: adminPath('users'), icon: User, permission: 'user:view' },
{ label: '订单管理', to: adminPath('orders'), icon: Tickets, permission: 'order:view' },
{ label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' },
{
label: '商品审核',
to: '/admin/listings/review',
to: adminPath('listings/review'),
icon: DocumentChecked,
permission: 'listing:approve',
},
@@ -92,11 +93,11 @@ const allNavGroups: NavGroup[] = [
children: [
{
label: '仲裁中心',
to: '/admin/disputes',
to: adminPath('disputes'),
icon: ScaleToOriginal,
permission: 'dispute:view',
},
{ label: '客服群聊', to: '/admin/chats', icon: ChatDotRound, permission: 'chat:view' },
{ label: '客服群聊', to: adminPath('chats'), icon: ChatDotRound, permission: 'chat:view' },
],
},
{
@@ -106,22 +107,22 @@ const allNavGroups: NavGroup[] = [
children: [
{
label: '财务仪表盘',
to: '/admin/finance/dashboard',
to: adminPath('finance/dashboard'),
icon: DataLine,
permission: 'wallet:view',
},
{
label: '财务明细',
to: '/admin/finance/details',
to: adminPath('finance/details'),
icon: Document,
permission: 'wallet:view',
},
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet, permission: 'wallet:view' },
{ label: '支付流水', to: '/admin/payments', icon: CreditCard, permission: 'wallet:view' },
{ label: '提现审核', to: '/admin/withdrawals', icon: Money, permission: 'withdrawal:list' },
{ label: '资金流水', to: adminPath('wallet-ledger'), icon: Wallet, permission: 'wallet:view' },
{ label: '支付流水', to: adminPath('payments'), icon: CreditCard, permission: 'wallet:view' },
{ label: '提现审核', to: adminPath('withdrawals'), icon: Money, permission: 'withdrawal:list' },
{
label: '支付配置',
to: '/admin/payment-configs',
to: adminPath('payment-configs'),
icon: CreditCard,
permission: 'payment_config:list',
},
@@ -134,13 +135,13 @@ const allNavGroups: NavGroup[] = [
children: [
{
label: '公告管理',
to: '/admin/announcements',
to: adminPath('announcements'),
icon: Bell,
permission: 'announcement:view',
},
{
label: '系统配置',
to: '/admin/system-configs',
to: adminPath('system-configs'),
icon: Operation,
permission: 'system_config:view',
},
@@ -151,14 +152,14 @@ const allNavGroups: NavGroup[] = [
index: 'system-auth',
icon: Lock,
children: [
{ label: '审计日志', to: '/admin/audit-logs', icon: Document, permission: 'audit_log:view' },
{ label: '审计日志', to: adminPath('audit-logs'), icon: Document, permission: 'audit_log:view' },
{
label: '管理员管理',
to: '/admin/admin-users',
to: adminPath('admin-users'),
icon: UserFilled,
permission: 'admin_user:manage',
},
{ label: '角色管理', to: '/admin/roles', icon: Setting, permission: 'role:manage' },
{ label: '角色管理', to: adminPath('roles'), icon: Setting, permission: 'role:manage' },
],
},
]
@@ -236,7 +237,7 @@ async function handleLogout() {
}
adminSession.logout()
ElMessage.success('已退出后台')
await router.push('/admin/login')
await router.push(ADMIN_LOGIN_PATH)
}
function passwordStrongEnough(value: string) {
@@ -264,7 +265,7 @@ async function handleForcedPasswordChange() {
})
ElMessage.success('密码已修改,请重新登录')
adminSession.logout()
await router.replace('/admin/login')
await router.replace(ADMIN_LOGIN_PATH)
} catch (error) {
ElMessage.error(readError(error, '密码修改失败'))
} finally {
@@ -277,7 +278,7 @@ async function handleForcedPasswordChange() {
<div class="admin-shell" :class="{ 'admin-shell--collapsed': isCollapsed }">
<aside class="admin-sidebar">
<div class="admin-sidebar-header">
<RouterLink class="admin-brand" to="/admin/dashboard">
<RouterLink class="admin-brand" :to="ADMIN_DASHBOARD_PATH">
<span class="brand-mark">H</span>
<span class="brand-text" v-show="!isCollapsed">哈夫币后台</span>
</RouterLink>
@@ -318,7 +319,7 @@ async function handleForcedPasswordChange() {
<header class="admin-topbar">
<div class="topbar-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/admin/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: ADMIN_DASHBOARD_PATH }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
+24 -22
View File
@@ -1,131 +1,133 @@
import type { RouteRecordRaw } from 'vue-router'
import { adminPath, ADMIN_DASHBOARD_PATH } from '@/shared/utils/adminPath'
const adminMeta = { layout: 'admin', requiresAdmin: true }
export const adminRoutes: RouteRecordRaw[] = [
{ path: '/admin', redirect: '/admin/dashboard' },
{ path: adminPath(), redirect: ADMIN_DASHBOARD_PATH },
{
path: '/admin/login',
path: adminPath('login'),
name: 'admin-login',
component: () => import('@/features/admin/views/AdminLoginView.vue'),
meta: { layout: 'blank' },
},
{
path: '/admin/dashboard',
path: adminPath('dashboard'),
name: 'admin-dashboard',
component: () => import('@/features/admin/views/AdminDashboardView.vue'),
meta: adminMeta,
},
{
path: '/admin/users',
path: adminPath('users'),
name: 'admin-users',
component: () => import('@/features/admin/views/AdminUsersView.vue'),
meta: adminMeta,
},
{
path: '/admin/orders',
path: adminPath('orders'),
name: 'admin-orders',
component: () => import('@/features/admin/views/AdminOrdersView.vue'),
meta: adminMeta,
},
{
path: '/admin/orders/:id',
path: adminPath('orders/:id'),
name: 'admin-order-detail',
component: () => import('@/features/admin/views/AdminOrderDetailView.vue'),
meta: adminMeta,
},
{
path: '/admin/listings',
path: adminPath('listings'),
name: 'admin-listings',
component: () => import('@/features/admin/views/AdminListingsView.vue'),
meta: adminMeta,
},
{
path: '/admin/listings/:id',
path: adminPath('listings/:id'),
name: 'admin-listing-detail',
component: () => import('@/features/admin/views/AdminListingDetailView.vue'),
meta: adminMeta,
},
{
path: '/admin/listings/review',
path: adminPath('listings/review'),
name: 'admin-listing-review',
component: () => import('@/features/admin/views/AdminListingReviewView.vue'),
meta: adminMeta,
},
{
path: '/admin/disputes',
path: adminPath('disputes'),
name: 'admin-disputes',
component: () => import('@/features/admin/views/AdminDisputesView.vue'),
meta: adminMeta,
},
{
path: '/admin/chats',
path: adminPath('chats'),
name: 'admin-chats',
component: () => import('@/features/admin/views/AdminChatsView.vue'),
meta: adminMeta,
},
{
path: '/admin/finance/dashboard',
path: adminPath('finance/dashboard'),
name: 'admin-finance-dashboard',
component: () => import('@/features/admin/views/AdminFinanceDashboardView.vue'),
meta: adminMeta,
},
{
path: '/admin/finance/details',
path: adminPath('finance/details'),
name: 'admin-finance-details',
component: () => import('@/features/admin/views/AdminFinanceDetailsView.vue'),
meta: adminMeta,
},
{
path: '/admin/wallet-ledger',
path: adminPath('wallet-ledger'),
name: 'admin-wallet-ledger',
component: () => import('@/features/admin/views/AdminWalletLedgerView.vue'),
meta: adminMeta,
},
{
path: '/admin/payments',
path: adminPath('payments'),
name: 'admin-payments',
component: () => import('@/features/admin/views/AdminPaymentsView.vue'),
meta: adminMeta,
},
{
path: '/admin/withdrawals',
path: adminPath('withdrawals'),
name: 'admin-withdrawals',
component: () => import('@/features/admin/views/AdminWithdrawalsView.vue'),
meta: adminMeta,
},
{
path: '/admin/system-configs',
path: adminPath('system-configs'),
name: 'admin-system-configs',
component: () => import('@/features/admin/views/AdminSystemConfigsView.vue'),
meta: adminMeta,
},
{
path: '/admin/payment-configs',
path: adminPath('payment-configs'),
name: 'admin-payment-configs',
component: () => import('@/features/admin/views/AdminPaymentConfigsView.vue'),
meta: adminMeta,
},
{
path: '/admin/audit-logs',
path: adminPath('audit-logs'),
name: 'admin-audit-logs',
component: () => import('@/features/admin/views/AdminAuditLogsView.vue'),
meta: adminMeta,
},
{
path: '/admin/admin-users',
path: adminPath('admin-users'),
name: 'admin-admin-users',
component: () => import('@/features/admin/views/AdminMgrUsersView.vue'),
meta: adminMeta,
},
{
path: '/admin/roles',
path: adminPath('roles'),
name: 'admin-roles',
component: () => import('@/features/admin/views/AdminRolesView.vue'),
meta: adminMeta,
},
{
path: '/admin/announcements',
path: adminPath('announcements'),
name: 'admin-announcements',
component: () => import('@/features/admin/views/AdminAnnouncementsView.vue'),
meta: adminMeta,
+4 -3
View File
@@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useAdminSessionStore } from '@/stores/adminSession'
import { useSessionStore } from '@/stores/session'
import { getAccessToken, getLoginPath } from '@/shared/utils/authStorage'
import { ADMIN_DASHBOARD_PATH, ADMIN_LOGIN_PATH } from '@/shared/utils/adminPath'
import { accountRoutes } from './accountRoutes'
import { adminRoutes } from './adminRoutes'
import { isMobileHost, isMobileDevice, getMobilePath, getPcPath } from './mobileHost'
@@ -77,11 +78,11 @@ router.beforeEach(async to => {
const adminSession = useAdminSessionStore()
adminSession.syncFromStorage()
if (to.path === '/admin/login') {
if (to.path === ADMIN_LOGIN_PATH) {
if (!adminSession.hasSessionHint) return true
try {
await adminSession.loadMe()
return '/admin/dashboard'
return ADMIN_DASHBOARD_PATH
} catch {
adminSession.logout()
return true
@@ -98,7 +99,7 @@ router.beforeEach(async to => {
}
} catch {
adminSession.logout()
return { path: '/admin/login', query: { redirect: to.fullPath } }
return { path: ADMIN_LOGIN_PATH, query: { redirect: to.fullPath } }
}
}
return true
+4 -2
View File
@@ -1,3 +1,5 @@
import { isAdminPath } from '@/shared/utils/adminPath'
export function isMobileHost(hostname = window.location.hostname) {
return hostname === 'm.localhost' || hostname.startsWith('m.')
}
@@ -14,7 +16,7 @@ export function getMobilePath(path: string): string {
if (path === '/' || path === '') {
return '/m'
}
if (path === '/m' || path.startsWith('/m/') || path.startsWith('/admin')) {
if (path === '/m' || path.startsWith('/m/') || isAdminPath(path)) {
return path
}
@@ -44,7 +46,7 @@ export function getMobilePath(path: string): string {
}
export function getPcPath(path: string): string {
if ((path !== '/m' && !path.startsWith('/m/')) || path.startsWith('/admin')) {
if ((path !== '/m' && !path.startsWith('/m/')) || isAdminPath(path)) {
return path
}
+23
View File
@@ -0,0 +1,23 @@
const DEFAULT_ADMIN_BASE_PATH = '/admin'
function normalizeAdminBasePath(value: string | undefined) {
const raw = (value || DEFAULT_ADMIN_BASE_PATH).trim()
if (!raw || raw === '/') return DEFAULT_ADMIN_BASE_PATH
const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`
return withLeadingSlash.replace(/\/+$/, '') || DEFAULT_ADMIN_BASE_PATH
}
export const ADMIN_BASE_PATH = normalizeAdminBasePath(import.meta.env.VITE_ADMIN_BASE_PATH)
export function adminPath(path = '') {
const suffix = path.trim().replace(/^\/+/, '')
if (!suffix) return ADMIN_BASE_PATH
return `${ADMIN_BASE_PATH}/${suffix}`
}
export const ADMIN_LOGIN_PATH = adminPath('login')
export const ADMIN_DASHBOARD_PATH = adminPath('dashboard')
export function isAdminPath(path: string) {
return path === ADMIN_BASE_PATH || path.startsWith(`${ADMIN_BASE_PATH}/`)
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { ADMIN_LOGIN_PATH } from '@/shared/utils/adminPath'
export type AuthScope = 'user' | 'admin'
export interface AuthTokenPair {
@@ -53,7 +55,7 @@ export function clearAuthStorage(scope: AuthScope) {
}
export function getLoginPath(scope: AuthScope, currentPath: string) {
if (scope === 'admin') return '/admin/login'
if (scope === 'admin') return ADMIN_LOGIN_PATH
return currentPath.startsWith('/m') ? '/m/login' : '/login'
}
+12 -1
View File
@@ -127,13 +127,14 @@ validate_env() {
exit 1
fi
local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint
local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint admin_base_path
app_env="$(require_env APP_ENV)"
caddy_domain="$(require_env CADDY_DOMAIN)"
caddy_email="$(require_env CADDY_EMAIL)"
mysql_dsn="$(require_env MYSQL_DSN)"
redis_addr="$(require_env REDIS_ADDR)"
storage_endpoint="$(require_env STORAGE_ENDPOINT)"
admin_base_path="$(require_env VITE_ADMIN_BASE_PATH)"
require_env MYSQL_DATABASE >/dev/null
require_env MYSQL_ROOT_PASSWORD >/dev/null
require_env MYSQL_USER >/dev/null
@@ -173,6 +174,14 @@ validate_env() {
log_error "STORAGE_ENDPOINT 仍指向本机,请改为 http://minio:9000"
exit 1
fi
if [[ ! "${admin_base_path}" =~ ^/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
log_error "VITE_ADMIN_BASE_PATH 必须是单段路径,例如 /manage-7x9k2p,当前值:${admin_base_path}"
exit 1
fi
if [[ "${admin_base_path}" == "/admin" || "${admin_base_path}" == "/m" || "${admin_base_path}" == "/api" ]]; then
log_error "VITE_ADMIN_BASE_PATH 不能使用 ${admin_base_path},请改成不公开的后台入口"
exit 1
fi
if awk -F= '/^[A-Z0-9_]+=/{ if ($0 ~ /=(change-|保持你的|你的)/) found=1 } END { exit found ? 0 : 1 }' "${BACKEND_ENV}"; then
log_error "backend/.env 里还有占位值,请先替换后再部署"
exit 1
@@ -186,8 +195,10 @@ compose() {
export_caddy_env() {
export CADDY_DOMAIN
export CADDY_EMAIL
export VITE_ADMIN_BASE_PATH
CADDY_DOMAIN="$(require_env CADDY_DOMAIN)"
CADDY_EMAIL="$(require_env CADDY_EMAIL)"
VITE_ADMIN_BASE_PATH="$(require_env VITE_ADMIN_BASE_PATH)"
}
public_url() {
+2 -1
View File
@@ -587,8 +587,9 @@ main() {
log_success "开发环境已启动"
if [[ "${NO_FRONTEND}" == "0" ]]; then
local admin_base_path="${VITE_ADMIN_BASE_PATH:-/admin}"
log "前台:http://localhost:5173"
log "后台:http://localhost:5173/admin/login"
log "后台:http://localhost:5173${admin_base_path%/}/login"
fi
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"