This commit is contained in:
2026-04-25 11:59:14 +08:00
parent a7cd16848c
commit 138a0dc288
26 changed files with 1665 additions and 215 deletions

8
.env
View File

@@ -1,3 +1,9 @@
# 与主站同仓;/sub/auth/* 子账号登注册不依赖本开关。为 true 时根路径进 /sub/auth/login、侧栏为子账号受限菜单
VITE_IS_SUB_PORTAL=false
VITE_API_URL="https://api.tianyuanapi.com" VITE_API_URL="https://api.tianyuanapi.com"
VITE_CAPTCHA_SCENE_ID="wynt39to" VITE_CAPTCHA_SCENE_ID="wynt39to"
VITE_CAPTCHA_ENCRYPTED_MODE=false VITE_CAPTCHA_ENCRYPTED_MODE=false
# 子账号专属前端域名(生产建议配置,如 https://subsole.tianyuanapi.com
VITE_SUB_PORTAL_BASE_URL=""
# 主控制台前端域名(用于从子域回跳主域,如 https://console.tianyuanapi.com
VITE_MAIN_PORTAL_BASE_URL=""

View File

@@ -366,7 +366,6 @@
"useThrottleFn": true, "useThrottleFn": true,
"useThrottledRefHistory": true, "useThrottledRefHistory": true,
"useTimeAgo": true, "useTimeAgo": true,
"useTimeAgoIntl": true,
"useTimeout": true, "useTimeout": true,
"useTimeoutFn": true, "useTimeoutFn": true,
"useTimeoutPoll": true, "useTimeoutPoll": true,

1
auto-imports.d.ts vendored
View File

@@ -817,7 +817,6 @@ declare module 'vue' {
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']> readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']> readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']> readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']> readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']> readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']> readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>

1
components.d.ts vendored
View File

@@ -58,6 +58,7 @@ declare module 'vue' {
ElSegmented: typeof import('element-plus/es')['ElSegmented'] ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']

View File

@@ -40,6 +40,20 @@ export const userApi = {
getUserStats: () => request.get('/users/admin/stats') getUserStats: () => request.get('/users/admin/stats')
} }
export const subPortalApi = {
register: (data) => request.post('/sub-portal/register', data)
}
export const subordinateApi = {
createInvitation: (data) => request.post('/subordinate/invitations', data || {}),
listSubordinates: (params) => request.get('/subordinate/subordinates', { params }),
allocate: (data) => request.post('/subordinate/allocate', data),
listAllocations: (params) => request.get('/subordinate/allocations', { params }),
assignSubscription: (data) => request.post('/subordinate/assign-subscription', data),
listChildSubscriptions: (params) => request.get('/subordinate/child-subscriptions', { params }),
removeChildSubscription: (subscriptionId, data) => request.delete(`/subordinate/child-subscriptions/${subscriptionId}`, { data })
}
// 验证码(阿里云滑块)相关接口 // 验证码(阿里云滑块)相关接口
export const captchaApi = { export const captchaApi = {
// 获取加密场景 ID用于前端加密模式初始化滑块 // 获取加密场景 ID用于前端加密模式初始化滑块
@@ -145,8 +159,8 @@ export const financeApi = {
getAlipayOrderStatus: (params) => request.get('/finance/wallet/alipay-order-status', { params }), getAlipayOrderStatus: (params) => request.get('/finance/wallet/alipay-order-status', { params }),
// 管理员充值功能 // 管理员充值功能
transferRecharge: (data) => request.post('/admin/finance/transfer-recharge', data), adminTransferRecharge: (data) => request.post('/admin/finance/transfer-recharge', data),
giftRecharge: (data) => request.post('/admin/finance/gift-recharge', data), adminGiftRecharge: (data) => request.post('/admin/finance/gift-recharge', data),
// 充值记录相关接口 // 充值记录相关接口
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }), getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -2,33 +2,36 @@
/* 输入框样式优化 */ /* 输入框样式优化 */
.auth-input :deep(.el-input__wrapper) { .auth-input :deep(.el-input__wrapper) {
border-radius: 8px !important; border-radius: 10px !important;
transition: all 0.3s ease !important; transition: all 0.3s ease !important;
border: 1px solid #d1d5db !important; border: 1px solid #d5deef !important;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04) !important;
min-height: 44px !important;
} }
.auth-input :deep(.el-input__wrapper:hover) { .auth-input :deep(.el-input__wrapper:hover) {
border-color: #3b82f6 !important; border-color: #7aa9f8 !important;
} }
.auth-input :deep(.el-input__wrapper.is-focus) { .auth-input :deep(.el-input__wrapper.is-focus) {
border-color: #3b82f6 !important; border-color: #2563eb !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13) !important;
} }
/* 按钮样式优化 */ /* 按钮样式优化 */
.auth-button :deep(.el-button--primary) { .auth-button :deep(.el-button--primary) {
border-radius: 8px !important; border-radius: 10px !important;
font-weight: 500 !important; font-weight: 600 !important;
transition: all 0.3s ease !important; transition: all 0.3s ease !important;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%) !important; background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 55%, #1e40af 100%) !important;
border: none !important; border: none !important;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.25) !important;
} }
.auth-button :deep(.el-button--primary:hover) { .auth-button :deep(.el-button--primary:hover) {
transform: translateY(-1px) !important; transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important; box-shadow: 0 12px 24px rgba(37, 99, 235, 0.32) !important;
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%) !important; background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%) !important;
} }
.auth-button :deep(.el-button--primary:active) { .auth-button :deep(.el-button--primary:active) {
@@ -36,19 +39,61 @@
} }
/* Radio button 样式优化 */ /* Radio button 样式优化 */
.auth-method-tabs {
border-radius: 12px;
padding: 4px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
border: 1px solid #e2e8f0;
display: flex !important;
width: 100%;
}
.auth-method-tab {
flex: 1;
}
.auth-method-tabs :deep(.el-radio-button) {
flex: 1;
display: block;
}
.auth-method-tabs :deep(.el-radio-button__inner) {
width: 100%;
border-left: none !important;
}
.auth-method-tabs :deep(.el-radio-button:first-child .el-radio-button__inner) {
border-radius: 10px !important;
}
.auth-method-tabs :deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 10px !important;
}
.auth-method-tabs :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: transparent !important;
}
.auth-radio :deep(.el-radio-button__inner) { .auth-radio :deep(.el-radio-button__inner) {
width: 100% !important;
border: none !important; border: none !important;
border-radius: 10px !important;
background: transparent !important; background: transparent !important;
color: #6b7280 !important; color: #475569 !important;
font-weight: 500 !important; font-weight: 600 !important;
transition: all 0.3s ease !important; transition: all 0.3s ease !important;
padding: 12px 16px !important; padding: 10px 14px !important;
box-shadow: none !important;
} }
.auth-radio :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { .auth-radio :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: white !important; background: #ffffff !important;
color: #3b82f6 !important; color: #0f172a !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12) !important;
}
.auth-radio :deep(.el-radio-button__inner:hover) {
color: #0f172a !important;
} }
/* 表单标签样式 */ /* 表单标签样式 */
@@ -58,21 +103,21 @@
/* 链接样式 */ /* 链接样式 */
.auth-link { .auth-link {
@apply text-gray-600 hover:text-sky-600 transition-colors mx-2; @apply text-slate-500 hover:text-blue-700 transition-colors mx-2;
} }
/* 卡片样式 */ /* 卡片样式 */
.auth-card { .auth-card {
@apply bg-white/95 backdrop-blur-sm shadow-2xl rounded-2xl border border-white/20; @apply bg-white/96 shadow-2xl rounded-2xl border border-white/40;
} }
/* 标题样式 */ /* 标题样式 */
.auth-title { .auth-title {
@apply text-2xl font-bold text-gray-900 mb-2; @apply text-2xl font-bold text-slate-900 mb-2;
} }
.auth-subtitle { .auth-subtitle {
@apply text-gray-600 text-sm; @apply text-slate-500 text-sm;
} }
/* 响应式优化 */ /* 响应式优化 */

View File

@@ -62,7 +62,7 @@
</el-icon> </el-icon>
个人中心 个人中心
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item command="home"> <el-dropdown-item v-if="showHomeEntry" command="home">
<el-icon class="dropdown-item-icon"> <el-icon class="dropdown-item-icon">
<Home /> <Home />
</el-icon> </el-icon>
@@ -93,7 +93,7 @@ import {
UserIcon as User UserIcon as User
} from '@heroicons/vue/24/outline' } from '@heroicons/vue/24/outline'
const props = defineProps({ defineProps({
title: { title: {
type: String, type: String,
required: true required: true
@@ -115,11 +115,10 @@ const props = defineProps({
const emit = defineEmits(['user-command']) const emit = defineEmits(['user-command'])
const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const appStore = useAppStore() const appStore = useAppStore()
const showNotifications = ref(false) const showHomeEntry = computed(() => userStore.accountKind !== 'subordinate')
// 处理用户菜单命令 // 处理用户菜单命令
const handleUserCommand = async (command) => { const handleUserCommand = async (command) => {

View File

@@ -28,7 +28,7 @@
</template> </template>
</el-menu> </el-menu>
<!-- 返回官网链接 --> <!-- 返回官网链接 -->
<div class="home-link-container"> <div v-if="showHomeLink" class="home-link-container">
<a href="https://www.tianyuanapi.com/" target="_blank" rel="noopener" class="home-link" @click="handleMenuSelect"> <a href="https://www.tianyuanapi.com/" target="_blank" rel="noopener" class="home-link" @click="handleMenuSelect">
<el-icon class="menu-icon"> <el-icon class="menu-icon">
<HomeIcon /> <HomeIcon />
@@ -56,6 +56,10 @@ const props = defineProps({
type: String, type: String,
default: 'user', // 'user' | 'admin' default: 'user', // 'user' | 'admin'
validator: (value) => ['user', 'admin'].includes(value) validator: (value) => ['user', 'admin'].includes(value)
},
showHomeLink: {
type: Boolean,
default: true
} }
}) })

View File

@@ -105,11 +105,68 @@ export const getMenuItems = (userType = 'user') => {
return userMenuItems return userMenuItems
} }
// 新增:获取用户可访问的菜单项(包含管理员菜单 // 子账号/子站壳:受限侧栏(不展示主站运营外链入口由布局控制
export const getUserAccessibleMenuItems = (userType = 'user') => { export const getSubordinateMenuItems = () => {
const baseMenuItems = [...userMenuItems] const financeGroup = userMenuItems.find((g) => g.group === '财务管理')?.children || []
const devGroup = userMenuItems.find((g) => g.group === '开发者中心')?.children || []
return [
{
group: '数据中心',
icon: ChartBar,
children: [
{ name: '我的订阅', path: '/subscriptions', icon: ShoppingCart }
]
},
{
group: '账户中心',
icon: User,
children: [
{ name: '账户中心', path: '/profile', icon: User },
{ name: '企业入驻', path: '/profile/certification', icon: ShieldCheck }
]
},
{
group: '财务管理',
icon: Wallet,
children: financeGroup.filter((item) => item.path === '/finance/transactions')
},
{
group: '开发者中心',
icon: Setting,
children: devGroup.filter((item) =>
['/apis/debugger', '/apis/usage', '/apis/whitelist'].includes(item.path)
)
}
]
}
// 新增:获取用户可访问的菜单项(包含管理员菜单)
// options: { accountKind, isSubPortal } 用于下属账号/子站独立构建
export const getUserAccessibleMenuItems = (userType = 'user', _isCertified = false, options = {}) => {
const { accountKind = 'standalone', isSubPortal: subBuild = false } = options
if (accountKind === 'subordinate' || subBuild) {
return getSubordinateMenuItems()
}
const baseMenuItems = userMenuItems.map((group) => ({
...group,
children: group.children ? [...group.children] : undefined
}))
// 主站:普通用户与管理员均可在「账户中心」进入下属管理(管理员同时具备用户侧能力)
const acc = baseMenuItems.find((g) => g.group === '账户中心')
if (acc && acc.children) {
const exists = acc.children.some((c) => c.path === '/parent/subordinates')
if (!exists) {
acc.children = [
...acc.children,
{ name: '下属', path: '/parent/subordinates', icon: Users, requiresCertification: false }
]
}
}
// 如果是管理员,添加管理员菜单组
if (userType === 'admin') { if (userType === 'admin') {
baseMenuItems.push({ baseMenuItems.push({
group: '管理后台', group: '管理后台',

27
src/constants/portal.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* 子账号注册/登录为 /sub/auth/*与主站同前端VITE_IS_SUB_PORTAL=true 时仅多一层「根进子账号登录 + 侧栏子账号壳」
*/
export const isSubPortal = import.meta.env.VITE_IS_SUB_PORTAL === 'true'
const trimTrailingSlash = (value = '') => value.replace(/\/+$/, '')
export const subPortalBaseURL = trimTrailingSlash(import.meta.env.VITE_SUB_PORTAL_BASE_URL || '')
export const mainPortalBaseURL = trimTrailingSlash(import.meta.env.VITE_MAIN_PORTAL_BASE_URL || '')
const getOrigin = (url) => {
if (!url) return ''
try {
return new URL(url).origin
} catch {
return ''
}
}
export const subPortalOrigin = getOrigin(subPortalBaseURL)
export const mainPortalOrigin = getOrigin(mainPortalBaseURL)
export const isPortalDomainConfigReady = Boolean(subPortalOrigin && mainPortalOrigin)
export const isCurrentOrigin = (origin) => {
if (!origin || typeof window === 'undefined') return false
return window.location.origin === origin
}

View File

@@ -1,25 +1,23 @@
<template> <template>
<div class="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 relative"> <div class="auth-shell">
<div class="relative w-full max-w-[480px] auth-card px-8 py-10 flex flex-col justify-center min-h-[480px] sm:min-h-[400px] mx-4"> <div class="auth-main-grid">
<!-- Logo与标题 --> <aside class="auth-brand-panel">
<div class="flex flex-col items-center mb-8"> <img class="auth-brand-logo" src="@/assets/logo.png" alt="天远数据 Logo" />
<div class="w-16 h-16 flex items-center justify-center bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl mb-4 shadow-lg"> <p class="auth-panel-kicker auth-panel-kicker-left">欢迎访问</p>
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <h2 class="auth-brand-heading">账号中心</h2>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> <p class="auth-brand-kicker">ENTERPRISE DATA CONSOLE</p>
</svg> <h1 class="auth-brand-title">天远数据控制台</h1>
<p class="auth-brand-subtitle">面向企业的数据服务平台提供稳定认证体系与安全访问能力</p>
</aside>
<section class="auth-form-panel">
<div class="auth-form-wrap px-8 py-10 sm:px-10 sm:py-12">
<router-view />
<div class="text-center text-xs text-gray-400 mt-8 pt-6 border-t border-gray-100">
&copy; 2026 天远数据控制台. All rights reserved.
</div>
</div> </div>
<h1 class="auth-title mb-1">天远数据控制台</h1> </section>
</div>
<!-- 内容区 -->
<div class="flex-1 flex flex-col justify-center">
<router-view />
</div>
<!-- 底部版权 -->
<div class="text-center text-xs text-gray-400 mt-8 pt-6 border-t border-gray-100">
&copy; 2024 天远数据控制台. All rights reserved.
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -29,34 +27,127 @@
</script> </script>
<style scoped> <style scoped>
/* 性能优化:减少动画效果 */ .auth-shell {
@media (prefers-reduced-motion: reduce) { min-height: 100vh;
* { width: 100%;
animation-duration: 0.01ms !important; padding: 20px;
animation-iteration-count: 1 !important; background:
transition-duration: 0.01ms !important; radial-gradient(1200px 460px at 10% -10%, rgba(30, 64, 175, 0.12), transparent 60%),
radial-gradient(1000px 500px at 100% 0%, rgba(51, 65, 85, 0.1), transparent 60%),
linear-gradient(150deg, #f8fbff 0%, #f5f8fd 45%, #f7f9fc 100%);
}
.auth-main-grid {
max-width: 1180px;
margin: 0 auto;
min-height: calc(100vh - 40px);
display: grid;
grid-template-columns: 1.1fr 0.9fr;
border-radius: 22px;
overflow: hidden;
box-shadow: 0 20px 52px rgba(15, 23, 42, 0.09);
border: 1px solid rgba(148, 163, 184, 0.16);
}
.auth-brand-panel {
padding: 56px 52px;
color: #fff;
background:
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
radial-gradient(500px 300px at 20% -10%, rgba(255, 255, 255, 0.16), transparent 60%),
linear-gradient(150deg, #0b1f3e 0%, #12305f 45%, #173e7a 100%);
background-size: 28px 28px, 28px 28px, auto, auto;
background-position: 0 0, 0 0, 0 0, 0 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
.auth-brand-logo {
width: 58px;
height: 58px;
padding: 8px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 24px rgba(3, 7, 18, 0.28);
}
.auth-brand-kicker {
margin-top: 14px;
font-size: 11px;
letter-spacing: 0.16em;
color: rgba(255, 255, 255, 0.68);
font-weight: 600;
}
.auth-brand-heading {
margin-top: 8px;
font-size: 26px;
line-height: 1.3;
font-weight: 600;
}
.auth-brand-title {
margin-top: 18px;
font-size: 34px;
line-height: 1.2;
font-weight: 700;
}
.auth-brand-subtitle {
margin-top: 14px;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
line-height: 1.9;
max-width: 420px;
}
.auth-form-panel {
background: rgba(255, 255, 255, 0.94);
padding: 26px;
display: flex;
align-items: center;
}
.auth-form-wrap {
width: 100%;
}
.auth-panel-kicker {
margin-bottom: 6px;
color: #1e3a8a;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
}
.auth-panel-kicker-left {
margin-top: 22px;
margin-bottom: 0;
color: rgba(255, 255, 255, 0.86);
}
@media (max-width: 960px) {
.auth-main-grid {
grid-template-columns: 1fr;
}
.auth-brand-panel {
padding: 32px 26px;
}
.auth-form-panel {
padding: 16px;
} }
} }
/* 性能优化:低端设备降级 */
@media (max-width: 640px) { @media (max-width: 640px) {
.min-h-\[480px\] { .auth-shell {
min-height: 320px !important; padding: 10px;
} }
.px-8 {
padding-left: 1rem !important;
padding-right: 1rem !important;
}
.mx-4 {
margin-left: 0.5rem !important;
margin-right: 0.5rem !important;
}
}
/* 性能优化:简化背景渐变 */ .auth-main-grid {
@media (max-width: 768px), (max-device-pixel-ratio: 1) { min-height: calc(100vh - 20px);
.bg-gradient-to-br { border-radius: 18px;
background: #f8fafc !important;
} }
} }
</style> </style>

View File

@@ -18,10 +18,10 @@
<el-container> <el-container>
<!-- 桌面端显示侧边栏移动端隐藏移动端使用抽屉式侧边栏 --> <!-- 桌面端显示侧边栏移动端隐藏移动端使用抽屉式侧边栏 -->
<el-aside v-if="!appStore.isMobile" width="240px"> <el-aside v-if="!appStore.isMobile" width="240px">
<AppSidebar :menu-items="currentMenuItems" :theme="sidebarTheme" /> <AppSidebar :menu-items="currentMenuItems" :theme="sidebarTheme" :show-home-link="showHomeLink" />
</el-aside> </el-aside>
<!-- 移动端也渲染侧边栏但使用固定定位的抽屉式效果 --> <!-- 移动端也渲染侧边栏但使用固定定位的抽屉式效果 -->
<AppSidebar v-if="appStore.isMobile" :menu-items="currentMenuItems" :theme="sidebarTheme" /> <AppSidebar v-if="appStore.isMobile" :menu-items="currentMenuItems" :theme="sidebarTheme" :show-home-link="showHomeLink" />
<el-main :class="{ 'mobile-main': appStore.isMobile }"> <el-main :class="{ 'mobile-main': appStore.isMobile }">
<div class="content-wrapper"> <div class="content-wrapper">
<!-- 企业认证提示 - 根据当前页面路径显示 --> <!-- 企业认证提示 - 根据当前页面路径显示 -->
@@ -42,7 +42,7 @@
/> />
<!-- 右侧浮动联系客服 --> <!-- 右侧浮动联系客服 -->
<FloatingCustomerService /> <FloatingCustomerService v-if="!isSubPortal" />
</el-container> </el-container>
</template> </template>
@@ -52,6 +52,7 @@ import FloatingCustomerService from '@/components/common/FloatingCustomerService
import AppHeader from '@/components/layout/AppHeader.vue' import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue' import AppSidebar from '@/components/layout/AppSidebar.vue'
import NotificationPanel from '@/components/layout/NotificationPanel.vue' import NotificationPanel from '@/components/layout/NotificationPanel.vue'
import { isSubPortal } from '@/constants/portal'
import { getCurrentPageCertificationConfig, getUserAccessibleMenuItems } from '@/constants/menu' import { getCurrentPageCertificationConfig, getUserAccessibleMenuItems } from '@/constants/menu'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@@ -63,16 +64,6 @@ const userStore = useUserStore()
const showNotifications = ref(false) const showNotifications = ref(false)
// 性能优化:检测设备性能
const isLowPerformanceDevice = computed(() => {
// 检测硬件并发数、内存等
const hardwareConcurrency = navigator.hardwareConcurrency || 4
const memory = navigator.deviceMemory || 4
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
return hardwareConcurrency <= 4 || memory <= 4 || isMobile
})
// 根据用户类型计算布局属性 // 根据用户类型计算布局属性
const isAdmin = computed(() => userStore.isAdmin) const isAdmin = computed(() => userStore.isAdmin)
@@ -106,7 +97,10 @@ const notificationTheme = computed(() => {
// 动态菜单项 // 动态菜单项
const currentMenuItems = computed(() => { const currentMenuItems = computed(() => {
return getUserAccessibleMenuItems(userStore.userType, userStore.isCertified) return getUserAccessibleMenuItems(userStore.userType, userStore.isCertified, {
accountKind: userStore.accountKind,
isSubPortal
})
}) })
// 认证相关逻辑 // 认证相关逻辑
@@ -135,10 +129,15 @@ const shouldShowCertificationNotice = computed(() => {
return true return true
}) })
const showHomeLink = computed(() => !(userStore.accountKind === 'subordinate' || isSubPortal))
// 处理用户菜单命令 // 处理用户菜单命令
const handleUserCommand = async (command) => { const handleUserCommand = async (command) => {
switch (command) { switch (command) {
case 'home': case 'home':
if (isSubPortal) {
return
}
window.open('https://www.tianyuanapi.com/', '_blank', 'noopener') window.open('https://www.tianyuanapi.com/', '_blank', 'noopener')
break break
case 'profile': case 'profile':
@@ -163,8 +162,13 @@ const handleUserCommand = async (command) => {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning',
}) })
const isSubordinateAccount = userStore.accountKind === 'subordinate'
userStore.logout() userStore.logout()
router.push('/auth/login') router.push(
isSubordinateAccount || isSubPortal
? '/sub/auth/login'
: '/auth/login'
)
} catch { } catch {
// 用户取消 // 用户取消
} }

View File

@@ -1042,10 +1042,10 @@ const handleSubmitRecharge = async () => {
if (rechargeForm.rechargeType === 'transfer') { if (rechargeForm.rechargeType === 'transfer') {
params.transfer_order_id = rechargeForm.transferOrderID params.transfer_order_id = rechargeForm.transferOrderID
await financeApi.transferRecharge(params) await financeApi.adminTransferRecharge(params)
ElMessage.success('对公转账充值成功') ElMessage.success('对公转账充值成功')
} else { } else {
await financeApi.giftRecharge(params) await financeApi.adminGiftRecharge(params)
ElMessage.success('赠送充值成功') ElMessage.success('赠送充值成功')
} }

View File

@@ -622,12 +622,14 @@ import { ElMessage } from 'element-plus'
import { marked } from 'marked' import { marked } from 'marked'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import CodeDisplay from '@/components/common/CodeDisplay.vue' import CodeDisplay from '@/components/common/CodeDisplay.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const userStore = useUserStore()
// 响应式数据 // 响应式数据
@@ -710,6 +712,9 @@ const filteredProducts = computed(() => {
}) })
const canDebug = computed(() => { const canDebug = computed(() => {
if (!userStore.isCertified) {
return false
}
if (!selectedProduct.value || !debugForm.accessId || !debugForm.secretKey) { if (!selectedProduct.value || !debugForm.accessId || !debugForm.secretKey) {
return false return false
} }
@@ -735,6 +740,10 @@ const canDebug = computed(() => {
// 生命周期 // 生命周期
onMounted(async () => { onMounted(async () => {
if (!userStore.isCertified) {
ElMessage.warning('请先完成企业认证后再使用在线调试')
return
}
await loadApiKeys() await loadApiKeys()
await loadUserProducts() await loadUserProducts()
// 自动选择产品的逻辑已在 loadUserProducts 中处理 // 自动选择产品的逻辑已在 loadUserProducts 中处理
@@ -1193,6 +1202,10 @@ const convertFieldTypes = (data) => {
// 加密参数 // 加密参数
const encryptParams = async () => { const encryptParams = async () => {
if (!userStore.isCertified) {
ElMessage.warning('请先完成企业认证后再进行调试')
return
}
if (!canDebug.value) { if (!canDebug.value) {
ElMessage.warning('请先填写完整的必填参数') ElMessage.warning('请先填写完整的必填参数')
return return
@@ -1278,6 +1291,10 @@ const resetForm = () => {
// 开始调试 // 开始调试
const handleDebug = async () => { const handleDebug = async () => {
if (!userStore.isCertified) {
ElMessage.warning('请先完成企业认证后再发起请求')
return
}
if (!canDebug.value) return if (!canDebug.value) return
// 新增:验证动态表单 // 新增:验证动态表单

View File

@@ -3,13 +3,13 @@
<!-- 标题 --> <!-- 标题 -->
<div class="text-center mb-6"> <div class="text-center mb-6">
<h2 class="auth-title">欢迎回来</h2> <h2 class="auth-title">欢迎回来</h2>
<p class="auth-subtitle">请选择登录方式</p> <p class="auth-subtitle">使用账号信息登录控制台</p>
</div> </div>
<!-- 登录方式切换 --> <!-- 登录方式切换 -->
<div class="mb-6"> <div class="mb-6">
<el-radio-group v-model="loginMethod" class="w-full flex bg-gray-100 rounded-lg p-1 auth-radio"> <el-radio-group v-model="loginMethod" class="w-full flex auth-radio auth-method-tabs">
<el-radio-button value="sms" class="flex-1 !border-0 !rounded-md"> <el-radio-button value="sms" class="auth-method-tab !border-0 !rounded-md">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" /> <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
@@ -18,7 +18,7 @@
验证码登录 验证码登录
</div> </div>
</el-radio-button> </el-radio-button>
<el-radio-button value="password" class="flex-1 !border-0 !rounded-md"> <el-radio-button value="password" class="auth-method-tab !border-0 !rounded-md">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" <path fill-rule="evenodd"
@@ -83,7 +83,8 @@
</div> </div>
<!-- 操作链接 --> <!-- 操作链接 -->
<div class="flex items-center justify-end text-sm "> <div class="flex items-center justify-between text-sm">
<span class="text-slate-400">还没有账号</span>
<router-link to="/auth/reset" class="auth-link"> <router-link to="/auth/reset" class="auth-link">
忘记密码 忘记密码
</router-link> </router-link>
@@ -249,47 +250,3 @@ onUnmounted(() => {
}) })
</script> </script>
<style scoped>
/* 自定义radio button样式 */
:deep(.el-radio-button__inner) {
width: 100% !important;
border: none !important;
background: transparent !important;
color: #6b7280 !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: white !important;
color: #3b82f6 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
}
/* 输入框样式优化 */
:deep(.el-input__wrapper) {
border-radius: 8px !important;
transition: all 0.3s ease !important;
}
:deep(.el-input__wrapper:hover) {
border-color: #3b82f6 !important;
}
:deep(.el-input__wrapper.is-focus) {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
}
/* 按钮样式优化 */
:deep(.el-button--primary) {
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
:deep(.el-button--primary:hover) {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
}
</style>

View File

@@ -3,7 +3,7 @@
<!-- 标题 --> <!-- 标题 -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<h2 class="auth-title">创建账号</h2> <h2 class="auth-title">创建账号</h2>
<p class="auth-subtitle">填写以下信息完成注册</p> <p class="auth-subtitle">填写手机号与验证码快速创建控制台账号</p>
</div> </div>
<form class="space-y-4" @submit.prevent="onRegister"> <form class="space-y-4" @submit.prevent="onRegister">
@@ -117,7 +117,7 @@
</div> </div>
<!-- 操作链接 --> <!-- 操作链接 -->
<div class="text-center py-2"> <div class="text-center py-1">
<router-link to="/auth/login" class="auth-link text-sm"> 已有账号去登录 </router-link> <router-link to="/auth/login" class="auth-link text-sm"> 已有账号去登录 </router-link>
</div> </div>
@@ -133,6 +133,10 @@
{{ loading ? '注册中...' : '注册' }} {{ loading ? '注册中...' : '注册' }}
</el-button> </el-button>
</form> </form>
<p class="mt-4 text-xs text-center text-slate-400">
注册即表示您同意平台相关服务条款与隐私政策
</p>
</div> </div>
</template> </template>
@@ -252,31 +256,3 @@ onUnmounted(() => {
}) })
</script> </script>
<style scoped>
/* 输入框样式优化 */
:deep(.el-input__wrapper) {
border-radius: 8px !important;
transition: all 0.3s ease !important;
}
:deep(.el-input__wrapper:hover) {
border-color: #3b82f6 !important;
}
:deep(.el-input__wrapper.is-focus) {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
}
/* 按钮样式优化 */
:deep(.el-button--primary) {
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
:deep(.el-button--primary:hover) {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
}
</style>

View File

@@ -3,7 +3,7 @@
<!-- 标题 --> <!-- 标题 -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<h2 class="auth-title">重置密码</h2> <h2 class="auth-title">重置密码</h2>
<p class="auth-subtitle">请输入手机号和验证码重置密码</p> <p class="auth-subtitle">验证手机号后重新设置登录密码</p>
</div> </div>
<form class="space-y-4" @submit.prevent="onReset"> <form class="space-y-4" @submit.prevent="onReset">
@@ -103,8 +103,8 @@
</div> </div>
<!-- 操作链接 --> <!-- 操作链接 -->
<div class="text-center py-2"> <div class="text-center py-1">
<router-link to="/auth/login" class="auth-link text-sm"> <router-link :to="loginRoutePath" class="auth-link text-sm">
返回登录 返回登录
</router-link> </router-link>
</div> </div>
@@ -121,6 +121,10 @@
{{ loading ? '重置中...' : '重置密码' }} {{ loading ? '重置中...' : '重置密码' }}
</el-button> </el-button>
</form> </form>
<p class="mt-4 text-xs text-center text-slate-400">
为了账号安全请使用强密码并妥善保管
</p>
</div> </div>
</template> </template>
@@ -130,8 +134,12 @@ import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const router = useRouter() const router = useRouter()
const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const { runWithCaptcha } = useAliyunCaptcha() const { runWithCaptcha } = useAliyunCaptcha()
const loginRoutePath = computed(() => {
return route.path.startsWith('/sub/auth') ? '/sub/auth/login' : '/auth/login'
})
// 表单数据 // 表单数据
const form = ref({ const form = ref({
@@ -217,7 +225,7 @@ const onReset = async () => {
const result = await userStore.resetPassword(resetData) const result = await userStore.resetPassword(resetData)
if (result.success) { if (result.success) {
ElMessage.success('密码重置成功') ElMessage.success('密码重置成功')
router.push('/auth/login') router.push(loginRoutePath.value)
} }
} catch (error) { } catch (error) {
console.error('密码重置失败:', error) console.error('密码重置失败:', error)
@@ -234,31 +242,3 @@ onUnmounted(() => {
}) })
</script> </script>
<style scoped>
/* 输入框样式优化 */
:deep(.el-input__wrapper) {
border-radius: 8px !important;
transition: all 0.3s ease !important;
}
:deep(.el-input__wrapper:hover) {
border-color: #3b82f6 !important;
}
:deep(.el-input__wrapper.is-focus) {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
}
/* 按钮样式优化 */
:deep(.el-button--primary) {
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
:deep(.el-button--primary:hover) {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
}
</style>

View File

@@ -0,0 +1,825 @@
<template>
<ListPageLayout
title="下属"
subtitle="管理下属企业信息、余额与订阅配置"
>
<template #filters>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-sm text-gray-600">共找到 {{ total }} 个下属账号</div>
<div class="flex items-center gap-2">
<el-button class="toolbar-btn toolbar-btn-secondary" :loading="listLoading" @click="loadList">刷新列表</el-button>
<el-button class="toolbar-btn toolbar-btn-primary" type="primary" :loading="invLoading" @click="createInvite">生成新邀请</el-button>
</div>
</div>
</div>
</template>
<template #table>
<div v-if="listLoading" class="flex justify-center items-center py-12">
<el-icon class="is-loading text-2xl text-gray-400"><Loading /></el-icon>
</div>
<div v-else-if="list.length === 0" class="text-center py-12 bg-white rounded-lg shadow-sm border border-gray-200">
<el-empty description="暂无下属账号" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="table-container">
<el-table
:data="list"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="child_user_id" label="下属用户ID" min-width="260" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="160">
<template #default="{ row }">
<span class="font-medium text-gray-700">{{ row.phone || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="下属公司" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="row.is_certified" type="success" size="small">{{ row.company_name }}</el-tag>
<el-tag v-else type="info" size="small">未认证</el-tag>
</template>
</el-table-column>
<el-table-column prop="registered_at" label="注册时间" width="220">
<template #default="{ row }">
<div class="text-sm text-gray-600">{{ formatDateTime(row.registered_at) }}</div>
</template>
</el-table-column>
<el-table-column prop="balance" label="余额" width="140">
<template #default="{ row }">
<span class="font-semibold text-emerald-600">¥{{ formatMoney(row.balance) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="flex items-center gap-1">
<el-button
class="table-action-btn action-allocate"
:class="{ 'action-disabled': !row.is_certified }"
text
bg
@click="openAlloc(row)"
>
划款
</el-button>
<el-button
class="table-action-btn action-subscribe"
:class="{ 'action-disabled': !row.is_certified }"
text
bg
@click="openSubscribe(row)"
>
订阅
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<template #pagination>
<div class="pagination-wrapper">
<el-pagination
v-if="total > 0"
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<template #extra>
<el-dialog v-model="allocVisible" title="向下属划款" width="520px" @close="resetAllocForm">
<div class="space-y-3">
<div class="info-grid">
<div class="info-card">
<div class="info-label">我的余额</div>
<div class="info-value text-blue-600">¥{{ formatMoney(allocForm.parentBalance) }}</div>
</div>
<div class="info-card">
<div class="info-label">下属余额</div>
<div class="info-value text-emerald-600">¥{{ formatMoney(allocForm.childBalance) }}</div>
</div>
</div>
<div class="text-sm text-gray-600">下属企业{{ allocForm.childCompany || '未认证' }}</div>
<el-input v-model="allocForm.amount" placeholder="金额(元)">
<template #prefix>¥</template>
</el-input>
<div class="flex gap-2">
<el-input v-model="allocForm.verifyCode" maxlength="6" placeholder="请输入手机验证码" />
<el-button
class="toolbar-btn toolbar-btn-secondary"
:loading="sendCodeLoading"
:disabled="allocCodeCountdown > 0"
@click="sendAllocVerifyCode"
>
{{ allocCodeCountdown > 0 ? `${allocCodeCountdown}s` : '获取验证码' }}
</el-button>
</div>
<div class="flex justify-end">
<el-button class="table-action-btn action-subscribe" text bg @click="openAllocationRecords">
划款记录
</el-button>
</div>
</div>
<template #footer>
<el-button class="dialog-btn dialog-btn-secondary" @click="allocVisible = false">取消</el-button>
<el-button class="dialog-btn dialog-btn-primary" type="primary" :loading="allocLoading" @click="submitAlloc">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="subVisible" title="下属订阅管理" width="860px" @close="resetSubForm">
<div class="space-y-3">
<div class="text-sm text-gray-600">下属企业{{ subForm.childCompany || '未认证' }}</div>
<div class="subscribe-add-panel">
<el-select
v-model="subForm.productId"
placeholder="选择产品后可新增或更新订阅"
filterable
class="product-select"
:loading="productLoading"
>
<el-option
v-for="item in products"
:key="item.id"
:label="`${item.name}(目录价 ¥${formatMoney(item.price)}`"
:value="item.id"
/>
</el-select>
<el-input v-model="subForm.price" placeholder="下属价格" class="price-input">
<template #prefix>¥</template>
</el-input>
<el-button class="toolbar-btn toolbar-btn-primary" type="primary" :loading="subLoading" @click="submitSubscribe">
新增订阅
</el-button>
</div>
<el-table :data="childSubscriptions" border stripe>
<el-table-column prop="product_id" label="产品ID" min-width="180" show-overflow-tooltip />
<el-table-column label="产品名称" min-width="180">
<template #default="{ row }">
{{ getProductName(row.product_id) }}
</template>
</el-table-column>
<el-table-column label="主账号订阅价" width="150">
<template #default="{ row }">
<span class="text-blue-600">¥{{ formatMoney(getParentPrice(row.product_id)) }}</span>
</template>
</el-table-column>
<el-table-column label="下属订阅价" width="180">
<template #default="{ row }">
<el-input v-model="row._editPrice" size="small">
<template #prefix>¥</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button class="table-action-btn action-allocate" text bg :loading="row._saving" @click="updateSubscription(row)">
保存
</el-button>
<el-button class="table-action-btn action-danger" text bg :loading="row._deleting" @click="removeSubscription(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!childSubscriptions.length" class="text-center text-gray-400 py-3">暂无下属订阅可通过上方新增</div>
<div class="text-xs text-gray-500">
规则下属订阅价不能低于主账号订阅价主账号未订阅时可先为主账号订阅再配置下属
</div>
</div>
<template #footer>
<el-button class="dialog-btn dialog-btn-secondary" @click="subVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="allocRecordVisible" title="划款记录" width="760px">
<el-table :data="allocationRecords" border stripe>
<el-table-column prop="amount" label="金额" width="140">
<template #default="{ row }">
<span class="text-emerald-600 font-medium">¥{{ formatMoney(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="business_ref" label="业务单号" min-width="220" show-overflow-tooltip />
<el-table-column prop="created_at" label="划款时间" width="220">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-if="allocationTotal > 0"
v-model:current-page="allocationPage"
v-model:page-size="allocationPageSize"
:total="allocationTotal"
layout="total, prev, pager, next"
@current-change="loadAllocationRecords"
/>
</div>
<template #footer>
<el-button class="dialog-btn dialog-btn-secondary" @click="allocRecordVisible = false">关闭</el-button>
</template>
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { financeApi, productApi, subordinateApi, subscriptionApi } from '@/api'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { useUserStore } from '@/stores/user'
import { Loading } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const userStore = useUserStore()
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const listLoading = ref(false)
const invLoading = ref(false)
const allocVisible = ref(false)
const allocLoading = ref(false)
const allocCodeCountdown = ref(0)
const sendCodeLoading = ref(false)
let allocCodeTimer = null
const allocForm = ref({
childId: '',
childCompany: '',
amount: '',
verifyCode: '',
parentBalance: '0.00',
childBalance: '0.00'
})
const allocRecordVisible = ref(false)
const allocationRecords = ref([])
const allocationTotal = ref(0)
const allocationPage = ref(1)
const allocationPageSize = ref(10)
const subVisible = ref(false)
const subLoading = ref(false)
const productLoading = ref(false)
const products = ref([])
const parentSubscriptions = ref([])
const childSubscriptions = ref([])
const subForm = ref({
childId: '',
childCompany: '',
productId: '',
price: ''
})
const lastInvite = ref('')
const formatDateTime = (value) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
const formatMoney = (value) => {
const n = Number(value)
if (Number.isNaN(n)) return '0.00'
return n.toFixed(2)
}
const getProductName = (productId) => {
const p = products.value.find((item) => item.id === productId)
return p?.name || '-'
}
const getParentPrice = (productId) => {
const sub = parentSubscriptions.value.find((item) => (item.product_id || item.product?.id) === productId)
return Number(sub?.price || 0)
}
const loadList = async () => {
listLoading.value = true
try {
const res = await subordinateApi.listSubordinates({ page: page.value, page_size: pageSize.value })
if (res?.success && res.data) {
list.value = res.data.items || []
total.value = res.data.total || 0
}
} catch (e) {
ElMessage.error(e?.message || '加载失败')
} finally {
listLoading.value = false
}
}
const loadProducts = async () => {
productLoading.value = true
try {
const res = await productApi.getProducts({ page: 1, page_size: 500 })
products.value = res?.data?.items || []
} catch (e) {
ElMessage.error(e?.message || '加载产品失败')
} finally {
productLoading.value = false
}
}
const loadParentSubscriptions = async () => {
try {
const res = await subscriptionApi.getMySubscriptions({ page: 1, page_size: 1000 })
parentSubscriptions.value = res?.data?.items || []
} catch (e) {
ElMessage.error(e?.message || '加载主账号订阅失败')
}
}
const loadChildSubscriptions = async () => {
const res = await subordinateApi.listChildSubscriptions({ child_user_id: subForm.value.childId })
const items = res?.data || []
childSubscriptions.value = items.map((item) => ({
...item,
_editPrice: formatMoney(item.price),
_saving: false,
_deleting: false
}))
}
const createInvite = async () => {
invLoading.value = true
try {
const res = await subordinateApi.createInvitation({})
if (res?.success && res.data) {
lastInvite.value = res.data.invite_url || res.data.invite_token
ElMessage.success('邀请已生成,请复制链接发送给下属')
await navigator.clipboard.writeText(res.data.invite_url || res.data.invite_token).catch(() => {})
}
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '失败')
} finally {
invLoading.value = false
}
}
const resetAllocForm = () => {
allocForm.value = {
childId: '',
childCompany: '',
amount: '',
verifyCode: '',
parentBalance: '0.00',
childBalance: '0.00'
}
}
const ensureCertified = (row, actionName) => {
if (row?.is_certified) return true
ElMessage.warning(`下属未完成企业认证,暂不可${actionName}`)
return false
}
const openAlloc = async (row) => {
if (!ensureCertified(row, '划款')) return
resetAllocForm()
allocForm.value.childId = row.child_user_id
allocForm.value.childCompany = row.company_name
allocForm.value.childBalance = row.balance || '0.00'
try {
const walletRes = await financeApi.getWallet()
allocForm.value.parentBalance = walletRes?.data?.balance || '0.00'
} catch {
allocForm.value.parentBalance = '0.00'
}
allocVisible.value = true
}
const sendAllocVerifyCode = async () => {
if (allocCodeCountdown.value > 0 || sendCodeLoading.value) return
const phone = userStore.userInfo?.phone
if (!phone) {
ElMessage.warning('未获取到当前登录手机号,请重新登录后重试')
return
}
sendCodeLoading.value = true
try {
const result = await userStore.sendCode(phone, 'login')
if (result.success) {
ElMessage.success('验证码已发送')
allocCodeCountdown.value = 60
allocCodeTimer = setInterval(() => {
allocCodeCountdown.value--
if (allocCodeCountdown.value <= 0 && allocCodeTimer) {
clearInterval(allocCodeTimer)
allocCodeTimer = null
}
}, 1000)
} else {
ElMessage.error(result.error?.response?.data?.message || '发送验证码失败')
}
} finally {
sendCodeLoading.value = false
}
}
const submitAlloc = async () => {
if (!allocForm.value.amount) {
ElMessage.warning('请输入金额')
return
}
if (!allocForm.value.verifyCode || allocForm.value.verifyCode.length !== 6) {
ElMessage.warning('请输入6位手机验证码')
return
}
allocLoading.value = true
try {
const res = await subordinateApi.allocate({
child_user_id: allocForm.value.childId,
amount: allocForm.value.amount,
verify_code: allocForm.value.verifyCode
})
if (res?.success) {
ElMessage.success('划款成功')
allocVisible.value = false
loadList()
}
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '划款失败')
} finally {
allocLoading.value = false
}
}
const openAllocationRecords = async () => {
allocationPage.value = 1
await loadAllocationRecords()
allocRecordVisible.value = true
}
const loadAllocationRecords = async () => {
try {
const res = await subordinateApi.listAllocations({
child_user_id: allocForm.value.childId,
page: allocationPage.value,
page_size: allocationPageSize.value
})
allocationRecords.value = res?.data?.items || []
allocationTotal.value = res?.data?.total || 0
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '加载划款记录失败')
}
}
const resetSubForm = () => {
subForm.value = {
childId: '',
childCompany: '',
productId: '',
price: ''
}
childSubscriptions.value = []
}
const openSubscribe = async (row) => {
if (!ensureCertified(row, '订阅')) return
if (!products.value.length) {
await loadProducts()
}
await loadParentSubscriptions()
subForm.value = {
childId: row.child_user_id,
childCompany: row.company_name,
productId: '',
price: ''
}
await loadChildSubscriptions()
subVisible.value = true
}
const ensureParentSubscribed = async (productId) => {
let parentPrice = getParentPrice(productId)
if (parentPrice > 0) return parentPrice
await ElMessageBox.confirm('主账号未订阅该产品,是否先为主账号订阅?', '提示', {
type: 'warning',
confirmButtonText: '先订阅主账号',
cancelButtonText: '取消'
})
const subRes = await productApi.subscribeProduct(productId)
if (!subRes?.success) {
throw new Error('主账号订阅失败,请稍后重试')
}
await loadParentSubscriptions()
parentPrice = getParentPrice(productId)
if (parentPrice <= 0) {
throw new Error('主账号订阅未生效,请稍后重试')
}
return parentPrice
}
const submitSubscribe = async () => {
if (!subForm.value.productId) {
ElMessage.warning('请选择产品')
return
}
const priceNum = Number(subForm.value.price)
if (Number.isNaN(priceNum) || priceNum <= 0) {
ElMessage.warning('请输入有效订阅价格')
return
}
subLoading.value = true
try {
const parentPrice = await ensureParentSubscribed(subForm.value.productId)
if (priceNum < parentPrice) {
ElMessage.warning(`下属订阅价不能低于主账号订阅价(¥${formatMoney(parentPrice)}`)
return
}
const res = await subordinateApi.assignSubscription({
child_user_id: subForm.value.childId,
product_id: subForm.value.productId,
price: priceNum.toFixed(2)
})
if (res?.success) {
ElMessage.success('下属订阅已保存')
subForm.value.productId = ''
subForm.value.price = ''
await loadChildSubscriptions()
}
} catch (e) {
if (e !== 'cancel' && e !== 'close') {
ElMessage.error(e?.response?.data?.message || e?.message || '保存订阅失败')
}
} finally {
subLoading.value = false
}
}
const updateSubscription = async (row) => {
const priceNum = Number(row._editPrice)
if (Number.isNaN(priceNum) || priceNum <= 0) {
ElMessage.warning('请输入有效价格')
return
}
const parentPrice = await ensureParentSubscribed(row.product_id)
if (priceNum < parentPrice) {
ElMessage.warning(`下属订阅价不能低于主账号订阅价(¥${formatMoney(parentPrice)}`)
return
}
row._saving = true
try {
const res = await subordinateApi.assignSubscription({
child_user_id: subForm.value.childId,
product_id: row.product_id,
price: priceNum.toFixed(2)
})
if (res?.success) {
ElMessage.success('订阅价格已更新')
await loadChildSubscriptions()
}
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '更新失败')
} finally {
row._saving = false
}
}
const removeSubscription = async (row) => {
await ElMessageBox.confirm('确定删除该下属订阅吗?', '确认删除', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
row._deleting = true
try {
const res = await subordinateApi.removeChildSubscription(row.id, {
child_user_id: subForm.value.childId
})
if (res?.success) {
ElMessage.success('订阅已删除')
await loadChildSubscriptions()
}
} catch (e) {
if (e !== 'cancel' && e !== 'close') {
ElMessage.error(e?.response?.data?.message || e?.message || '删除失败')
}
} finally {
row._deleting = false
}
}
const handleSizeChange = (size) => {
pageSize.value = size
page.value = 1
loadList()
}
const handleCurrentChange = (current) => {
page.value = current
loadList()
}
onMounted(() => {
loadList()
})
onUnmounted(() => {
if (allocCodeTimer) {
clearInterval(allocCodeTimer)
allocCodeTimer = null
}
})
</script>
<style scoped>
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.pagination-wrapper {
padding: 16px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.toolbar-btn {
border-radius: 8px;
font-weight: 500;
min-width: 92px;
}
.toolbar-btn-primary {
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
}
.toolbar-btn-secondary {
border-color: #d1d5db;
color: #374151;
}
.table-action-btn {
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
}
.action-allocate {
color: #2563eb;
}
.action-subscribe {
color: #059669;
}
.action-danger {
color: #dc2626;
}
.action-disabled {
color: #9ca3af !important;
background-color: #f3f4f6 !important;
}
.dialog-btn {
border-radius: 8px;
min-width: 84px;
font-weight: 500;
}
.dialog-btn-primary {
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.info-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
background: #f9fafb;
}
.info-label {
font-size: 12px;
color: #6b7280;
}
.info-value {
margin-top: 4px;
font-size: 18px;
font-weight: 700;
}
.subscribe-add-panel {
display: flex;
align-items: center;
gap: 10px;
}
.product-select {
flex: 1 1 auto;
min-width: 420px;
}
.price-input {
width: 120px;
}
@media (max-width: 768px) {
:deep(.el-table) {
font-size: 12px;
min-width: 860px;
}
:deep(.el-table th),
:deep(.el-table td) {
padding: 8px 4px;
}
:deep(.el-table .cell) {
padding: 0 4px;
word-break: break-word;
line-height: 1.4;
}
.pagination-wrapper :deep(.el-pagination) {
flex-wrap: nowrap;
min-width: fit-content;
justify-content: flex-start;
}
.pagination-wrapper :deep(.el-pagination__jump) {
display: none;
}
.toolbar-btn {
min-width: 82px;
padding-left: 10px;
padding-right: 10px;
}
.info-grid {
grid-template-columns: 1fr;
}
.subscribe-add-panel {
flex-direction: column;
align-items: stretch;
}
.product-select {
min-width: 0;
width: 100%;
}
.price-input {
width: 100%;
}
}
</style>

View File

@@ -30,6 +30,10 @@
</el-tag> </el-tag>
</div> </div>
</div> </div>
<div class="overview-balance">
<div class="balance-label">当前账户余额</div>
<div class="balance-value">¥{{ walletBalance }}</div>
</div>
</div> </div>
</div> </div>
</el-card> </el-card>
@@ -432,7 +436,7 @@
</template> </template>
<script setup> <script setup>
import { apiKeysApi, balanceAlertApi } from '@/api' import { apiKeysApi, balanceAlertApi, financeApi } from '@/api'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { import {
BellIcon, BellIcon,
@@ -451,6 +455,7 @@ const userInfo = ref(null)
const apiKeysData = ref(null) const apiKeysData = ref(null)
const showApiKeys = ref(false) const showApiKeys = ref(false)
const apiKeysLoading = ref(false) const apiKeysLoading = ref(false)
const walletBalance = ref('0.00')
// 余额预警设置相关 // 余额预警设置相关
const balanceAlertSettings = ref({ const balanceAlertSettings = ref({
@@ -468,6 +473,7 @@ const loadUserInfo = async () => {
const result = await userStore.fetchUserProfile() const result = await userStore.fetchUserProfile()
if (result.success) { if (result.success) {
userInfo.value = result.data userInfo.value = result.data
await loadWalletBalance()
// 只有认证用户才加载余额预警设置 // 只有认证用户才加载余额预警设置
if (result.data.is_certified) { if (result.data.is_certified) {
await loadAlertSettings() await loadAlertSettings()
@@ -483,6 +489,21 @@ const loadUserInfo = async () => {
} }
} }
// 加载当前账户余额
const loadWalletBalance = async () => {
try {
const response = await financeApi.getWallet()
if (response?.success && response?.data?.balance !== undefined) {
const balanceNum = Number(response.data.balance)
walletBalance.value = Number.isFinite(balanceNum) ? balanceNum.toFixed(2) : '0.00'
return
}
walletBalance.value = '0.00'
} catch {
walletBalance.value = '0.00'
}
}
// 加载API密钥 // 加载API密钥
const loadApiKeys = async () => { const loadApiKeys = async () => {
if (showApiKeys.value) return if (showApiKeys.value) return
@@ -725,6 +746,27 @@ onMounted(() => {
gap: 20px; gap: 20px;
} }
.overview-balance {
margin-left: auto;
text-align: right;
padding: 12px 16px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
}
.balance-label {
font-size: 12px;
opacity: 0.9;
margin-bottom: 4px;
}
.balance-value {
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.user-avatar { .user-avatar {
width: 80px; width: 80px;
height: 80px; height: 80px;
@@ -1093,6 +1135,12 @@ onMounted(() => {
gap: 16px; gap: 16px;
} }
.overview-balance {
margin-left: 0;
text-align: center;
width: 100%;
}
.user-avatar { .user-avatar {
width: 60px; width: 60px;
height: 60px; height: 60px;

View File

@@ -0,0 +1,133 @@
<template>
<div class="w-full auth-fade-in">
<div class="text-center mb-6">
<h2 class="auth-title">子账号登录</h2>
<p class="auth-subtitle">适用于主账号邀请的成员访问</p>
</div>
<div class="mb-6">
<el-radio-group v-model="loginMethod" class="w-full flex auth-radio auth-method-tabs">
<el-radio-button value="sms" class="auth-method-tab !border-0 !rounded-md">
<div class="flex items-center justify-center gap-2">验证码登录</div>
</el-radio-button>
<el-radio-button value="password" class="auth-method-tab !border-0 !rounded-md">
<div class="flex items-center justify-center gap-2">密码登录</div>
</el-radio-button>
</el-radio-group>
</div>
<form class="space-y-4" @submit.prevent="onLogin">
<div>
<label class="auth-label">手机号</label>
<el-input v-model="form.phone" placeholder="请输入手机号" size="large" clearable maxlength="11"
:disabled="loading" class="auth-input" />
</div>
<div v-if="loginMethod === 'sms'">
<label class="auth-label">验证码</label>
<div class="flex gap-3">
<el-input v-model="form.code" placeholder="验证码" size="large" maxlength="6" :disabled="loading"
class="auth-input" />
<el-button type="primary" size="large" :disabled="!canSendCode || loading" :loading="sendingCode"
class="!min-w-[120px]" @click="sendCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
</div>
</div>
<div v-if="loginMethod === 'password'">
<label class="auth-label">密码</label>
<el-input v-model="form.password" type="password" show-password size="large" :disabled="loading"
class="auth-input" />
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-400">邀请成员请先完成注册</span>
<router-link to="/sub/auth/reset" class="auth-link" replace>忘记密码</router-link>
<router-link to="/sub/auth/register" class="auth-link" replace>子账号注册</router-link>
</div>
<el-button type="primary" size="large" class="auth-button w-full !h-12" native-type="submit" :loading="loading"
:disabled="!canSubmit">
登录
</el-button>
</form>
</div>
</template>
<script setup name="SubLogin">
import { useUserStore } from '@/stores/user'
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const { runWithCaptcha } = useAliyunCaptcha()
const loginMethod = ref('sms')
const form = ref({ phone: '', password: '', code: '' })
const loading = ref(false)
const sendingCode = ref(false)
const countdown = ref(0)
let countdownTimer = null
const canSendCode = computed(
() => form.value.phone?.length === 11 && countdown.value === 0
)
const canSubmit = computed(() => {
if (loginMethod.value === 'sms') {
return form.value.phone?.length === 11 && form.value.code?.length === 6
}
return form.value.phone?.length === 11 && !!form.value.password
})
const sendCode = async () => {
if (!canSendCode.value) return
sendingCode.value = true
try {
await runWithCaptcha(
async (captchaVerifyParam) => userStore.sendCode(form.value.phone, 'login', captchaVerifyParam),
(res) => {
if (res.success) {
ElMessage.success('验证码发送成功')
countdown.value = 60
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(countdownTimer)
}, 1000)
} else {
ElMessage.error(res?.error?.message || '发送失败')
}
}
)
} finally {
sendingCode.value = false
}
}
const onLogin = async () => {
if (!canSubmit.value) return
loading.value = true
try {
const result = await userStore.login({
method: loginMethod.value,
phone: form.value.phone,
code: form.value.code,
password: form.value.password
})
if (result.success) {
ElMessage.success('登录成功')
await userStore.fetchUserProfile()
router.push('/subscriptions')
} else {
ElMessage.error(result.error?.response?.data?.message || '登录失败')
}
} finally {
loading.value = false
}
}
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="w-full auth-fade-in">
<div class="text-center mb-6">
<h2 class="auth-title">子账号注册</h2>
<p class="auth-subtitle">填写邀请码与手机号完成成员账号开通</p>
</div>
<form class="space-y-4" @submit.prevent="onRegister">
<div>
<label class="auth-label">邀请码 <span class="text-red-500">*</span></label>
<el-input v-model="form.inviteToken" placeholder="请输入主账号提供的邀请码" size="large" clearable :disabled="loading"
class="auth-input" />
</div>
<div>
<label class="auth-label">手机号</label>
<el-input v-model="form.phone" maxlength="11" placeholder="手机号" size="large" :disabled="loading" class="auth-input" />
</div>
<div>
<label class="auth-label">验证码</label>
<div class="flex gap-3">
<el-input v-model="form.code" maxlength="6" placeholder="短信验证码" size="large" :disabled="loading" class="auth-input" />
<el-button type="primary" size="large" :disabled="!canSendCode || loading" :loading="sendingCode"
class="auth-button !min-w-[120px]" @click="sendCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
</div>
</div>
<div>
<label class="auth-label">密码</label>
<el-input v-model="form.password" type="password" show-password size="large" :disabled="loading" class="auth-input" />
</div>
<div>
<label class="auth-label">确认密码</label>
<el-input v-model="form.confirmPassword" type="password" show-password size="large" :disabled="loading"
class="auth-input" />
</div>
<div class="text-center">
<router-link to="/sub/auth/login" class="auth-link text-sm">返回登录</router-link>
</div>
<el-button type="primary" size="large" class="auth-button w-full !h-12" native-type="submit" :loading="loading"
:disabled="!canSubmit">
注册
</el-button>
</form>
<p class="mt-4 text-xs text-center text-slate-400">
请确保邀请码来自可信主账号避免账号安全风险
</p>
</div>
</template>
<script setup>
import { subPortalApi } from '@/api'
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { runWithCaptcha } = useAliyunCaptcha()
const form = ref({
inviteToken: '',
phone: '',
code: '',
password: '',
confirmPassword: ''
})
const loading = ref(false)
const sendingCode = ref(false)
const countdown = ref(0)
let timer = null
// 与主站同仓同构建:链接 ?invite= 或主账号复制的完整 URL 均会打开本页并预填邀请码
watch(
() => route.query.invite,
(inv) => {
if (typeof inv === 'string' && inv) {
form.value.inviteToken = inv
}
},
{ immediate: true }
)
const canSendCode = computed(
() => form.value.phone?.length === 11 && countdown.value === 0
)
const canSubmit = computed(
() =>
form.value.inviteToken &&
form.value.phone?.length === 11 &&
form.value.code?.length === 6 &&
form.value.password?.length >= 6 &&
form.value.password === form.value.confirmPassword
)
const sendCode = async () => {
if (!canSendCode.value) return
sendingCode.value = true
try {
await runWithCaptcha(
async (captcha) => userStore.sendCode(form.value.phone, 'register', captcha),
(res) => {
if (res.success) {
ElMessage.success('验证码已发送')
countdown.value = 60
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
} else {
ElMessage.error(res?.error?.message || '发送失败')
}
}
)
} finally {
sendingCode.value = false
}
}
const onRegister = async () => {
if (!canSubmit.value) return
loading.value = true
try {
const res = await subPortalApi.register({
phone: form.value.phone,
password: form.value.password,
confirm_password: form.value.confirmPassword,
code: form.value.code,
invite_token: form.value.inviteToken
})
if (res?.success) {
ElMessage.success('注册成功,请登录')
router.push('/sub/auth/login')
}
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '注册失败')
} finally {
loading.value = false
}
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>

View File

@@ -1,12 +1,13 @@
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { isCurrentOrigin, isPortalDomainConfigReady, isSubPortal, mainPortalOrigin, subPortalOrigin } from '@/constants/portal'
import { statisticsRoutes } from './modules/statistics' import { statisticsRoutes } from './modules/statistics'
// 路由配置 // 路由配置
const routes = [ const routes = [
{ {
path: '/', path: '/',
redirect: '/dashboard' redirect: () => (isSubPortal ? '/sub/auth/login' : '/dashboard')
}, },
{ {
path: '/auth', path: '/auth',
@@ -51,6 +52,44 @@ const routes = [
} }
] ]
}, },
{
path: '/sub/auth',
component: () => import('@/layouts/AuthLayout.vue'),
meta: { requiresAuth: false },
children: [
{
path: 'login',
name: 'SubLogin',
component: () => import('@/pages/sub-portal/SubLogin.vue'),
meta: { title: '子账号登录' }
},
{
path: 'register',
name: 'SubRegister',
component: () => import('@/pages/sub-portal/SubRegister.vue'),
meta: { title: '子账号注册' }
},
{
path: 'reset',
name: 'SubResetPassword',
component: () => import('@/pages/auth/ResetPassword.vue'),
meta: { title: '重置密码' }
}
]
},
{
path: '/parent/subordinates',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true, title: '下属' },
children: [
{
path: '',
name: 'ParentSubordinates',
component: () => import('@/pages/parent/SubordinateManagement.vue'),
meta: { title: '下属' }
}
]
},
{ {
path: '/products', path: '/products',
component: () => import('@/layouts/MainLayout.vue'), component: () => import('@/layouts/MainLayout.vue'),
@@ -344,9 +383,34 @@ const router = createRouter({
// 路由守卫 // 路由守卫
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const userStore = useUserStore() const userStore = useUserStore()
const loginPathByRoute = to.path.startsWith('/sub/') ? '/sub/auth/login' : '/auth/login'
const isSubAuthRoute = to.path.startsWith('/sub/auth')
const isMainAuthRoute = to.path.startsWith('/auth')
const subAuthToMainAuthPath = to.path.replace('/sub/auth', '/auth')
const mainAuthToSubAuthPath = to.path.replace('/auth', '/sub/auth')
// 域名级认证路由隔离:子域只允许 /sub/auth/*,主域禁止 /sub/auth/*
if (isPortalDomainConfigReady) {
const onSubDomain = isCurrentOrigin(subPortalOrigin)
const onMainDomain = isCurrentOrigin(mainPortalOrigin)
if (onSubDomain && isMainAuthRoute) {
next(mainAuthToSubAuthPath)
return
}
if (onMainDomain && isSubAuthRoute) {
next(subAuthToMainAuthPath)
return
}
} else if (isSubPortal && isMainAuthRoute) {
// 子站壳模式下,即使未配置双域名,也只允许进入 /sub/auth/*
next(mainAuthToSubAuthPath)
return
}
// 对于不需要认证的路由(如登录页),不等待初始化,直接放行 // 对于不需要认证的路由(如登录页),不等待初始化,直接放行
const isAuthRoute = to.path.startsWith('/auth') const isAuthRoute = to.path.startsWith('/auth') || to.path.startsWith('/sub/auth')
const requiresAuth = to.meta.requiresAuth const requiresAuth = to.meta.requiresAuth
// 只有在需要认证的路由上才等待初始化 // 只有在需要认证的路由上才等待初始化
@@ -366,7 +430,7 @@ router.beforeEach(async (to, from, next) => {
// 检查是否需要认证 // 检查是否需要认证
if (requiresAuth && !userStore.isLoggedIn) { if (requiresAuth && !userStore.isLoggedIn) {
next('/auth/login') next(loginPathByRoute)
return return
} }
@@ -376,12 +440,57 @@ router.beforeEach(async (to, from, next) => {
return return
} }
// 登录用户访问认证页面,重定向到数据大厅 // 登录态下强制要求主/子域配置完整,避免混域运行
if (isAuthRoute && userStore.isLoggedIn) { if (userStore.isLoggedIn && !isPortalDomainConfigReady) {
if (import.meta.env.PROD) {
userStore.logout()
next(loginPathByRoute)
return
}
}
// 域名隔离:子账号登录态必须在子账号专属域名
if (userStore.isLoggedIn && userStore.accountKind === 'subordinate' && subPortalOrigin && !isCurrentOrigin(subPortalOrigin)) {
window.location.replace(`${subPortalOrigin}${to.fullPath}`)
return
}
// 域名隔离:普通/管理员账号不应停留在子账号专属域名
if (userStore.isLoggedIn && userStore.accountKind !== 'subordinate' && subPortalOrigin && isCurrentOrigin(subPortalOrigin)) {
if (mainPortalOrigin) {
window.location.replace(`${mainPortalOrigin}${to.fullPath}`)
return
}
next('/products') next('/products')
return return
} }
// 已登录用户访问认证页面,按账号类型重定向到对应首页
if (isAuthRoute && userStore.isLoggedIn) {
next(userStore.accountKind === 'subordinate' ? '/subscriptions' : '/dashboard')
return
}
// 子站壳:禁止进入主站登录/注册路由(可合并子账号入口到 /sub/auth
if (isSubPortal && (to.path.startsWith('/auth/login') || to.path.startsWith('/auth/register'))) {
next(to.path.replace('/auth/', '/sub/auth/'))
return
}
// 主站与「子站壳」共仓:/sub/auth/* 始终可用,子账号注册/登录与主站 /auth 并存,邀请链接不依赖单独构建
// 下属账号不可进入主账号「下属」管理页
if (to.path.startsWith('/parent/subordinates') && userStore.accountKind === 'subordinate') {
next('/subscriptions')
return
}
// 下属账号不允许访问仪表盘
if (to.path.startsWith('/dashboard') && userStore.accountKind === 'subordinate') {
next('/subscriptions')
return
}
next() next()
}) })

View File

@@ -92,6 +92,7 @@
*/ */
import { userApi } from '@/api' import { userApi } from '@/api'
import { isSubPortal } from '@/constants/portal'
import router from '@/router' import router from '@/router'
import { authEventBus } from '@/utils/request' import { authEventBus } from '@/utils/request'
import { generateSMSRequest } from '@/utils/smsSignature' import { generateSMSRequest } from '@/utils/smsSignature'
@@ -133,6 +134,10 @@ export const useUserStore = defineStore('user', () => {
return user.value?.is_certified || false return user.value?.is_certified || false
}) })
const accountKind = computed(() => user.value?.account_kind || 'standalone')
const isSubordinate = computed(() => accountKind.value === 'subordinate')
// 检查用户信息是否完整 // 检查用户信息是否完整
const isUserInfoComplete = computed(() => { const isUserInfoComplete = computed(() => {
return user.value && return user.value &&
@@ -162,6 +167,8 @@ export const useUserStore = defineStore('user', () => {
return userType.value === role return userType.value === role
} }
const loginHomePath = () => (isSubPortal ? '/sub/auth/login' : '/auth/login')
// 监听认证错误事件 // 监听认证错误事件
const handleAuthError = (message) => { const handleAuthError = (message) => {
console.log('用户store收到认证错误事件:', message) console.log('用户store收到认证错误事件:', message)
@@ -173,7 +180,7 @@ export const useUserStore = defineStore('user', () => {
console.log('收到版本退出登录事件:', event.detail) console.log('收到版本退出登录事件:', event.detail)
logout() logout()
ElMessage.error('系统已更新,请重新登录') ElMessage.error('系统已更新,请重新登录')
router.push('/auth/login') router.push(loginHomePath())
} }
// 处理版本刷新事件 // 处理版本刷新事件
@@ -205,7 +212,7 @@ export const useUserStore = defineStore('user', () => {
ElMessage.error('系统已更新,请重新登录') ElMessage.error('系统已更新,请重新登录')
// 跳转到登录页面 // 跳转到登录页面
router.push('/auth/login') router.push(loginHomePath())
return false return false
} }
@@ -521,6 +528,8 @@ export const useUserStore = defineStore('user', () => {
isCertified, // 新增 isCertified, // 新增
isUserInfoComplete, // 新增 isUserInfoComplete, // 新增
hasRole, hasRole,
accountKind,
isSubordinate,
// 方法 // 方法
login, login,

View File

@@ -253,9 +253,12 @@ const handleAuthError = (message) => {
// 显示错误消息 // 显示错误消息
ElMessage.error(message) ElMessage.error(message)
const currentPath = router.currentRoute.value.path || ''
const loginPath = currentPath.startsWith('/sub/') ? '/sub/auth/login' : '/auth/login'
// 跳转到登录页面(如果不在登录页面) // 跳转到登录页面(如果不在登录页面)
if (router.currentRoute.value.path !== '/auth/login') { if (currentPath !== loginPath) {
router.push('/auth/login') router.push(loginPath)
} }
} }