f
This commit is contained in:
8
.env
8
.env
@@ -1,3 +1,9 @@
|
||||
# 与主站同仓;/sub/auth/* 子账号登注册不依赖本开关。为 true 时根路径进 /sub/auth/login、侧栏为子账号受限菜单
|
||||
VITE_IS_SUB_PORTAL=false
|
||||
VITE_API_URL="https://api.tianyuanapi.com"
|
||||
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=""
|
||||
@@ -366,7 +366,6 @@
|
||||
"useThrottleFn": true,
|
||||
"useThrottledRefHistory": true,
|
||||
"useTimeAgo": true,
|
||||
"useTimeAgoIntl": true,
|
||||
"useTimeout": true,
|
||||
"useTimeoutFn": true,
|
||||
"useTimeoutPoll": true,
|
||||
|
||||
1
auto-imports.d.ts
vendored
1
auto-imports.d.ts
vendored
@@ -817,7 +817,6 @@ declare module 'vue' {
|
||||
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
|
||||
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
|
||||
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 useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
||||
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
||||
|
||||
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -58,6 +58,7 @@ declare module 'vue' {
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
|
||||
@@ -40,6 +40,20 @@ export const userApi = {
|
||||
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 = {
|
||||
// 获取加密场景 ID,用于前端加密模式初始化滑块
|
||||
@@ -145,8 +159,8 @@ export const financeApi = {
|
||||
getAlipayOrderStatus: (params) => request.get('/finance/wallet/alipay-order-status', { params }),
|
||||
|
||||
// 管理员充值功能
|
||||
transferRecharge: (data) => request.post('/admin/finance/transfer-recharge', data),
|
||||
giftRecharge: (data) => request.post('/admin/finance/gift-recharge', data),
|
||||
adminTransferRecharge: (data) => request.post('/admin/finance/transfer-recharge', data),
|
||||
adminGiftRecharge: (data) => request.post('/admin/finance/gift-recharge', data),
|
||||
|
||||
// 充值记录相关接口
|
||||
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),
|
||||
|
||||
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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 |
@@ -2,33 +2,36 @@
|
||||
|
||||
/* 输入框样式优化 */
|
||||
.auth-input :deep(.el-input__wrapper) {
|
||||
border-radius: 8px !important;
|
||||
border-radius: 10px !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) {
|
||||
border-color: #3b82f6 !important;
|
||||
border-color: #7aa9f8 !important;
|
||||
}
|
||||
|
||||
.auth-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
border-color: #2563eb !important;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13) !important;
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
.auth-button :deep(.el-button--primary) {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
border-radius: 10px !important;
|
||||
font-weight: 600 !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;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.25) !important;
|
||||
}
|
||||
|
||||
.auth-button :deep(.el-button--primary:hover) {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%) !important;
|
||||
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.32) !important;
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%) !important;
|
||||
}
|
||||
|
||||
.auth-button :deep(.el-button--primary:active) {
|
||||
@@ -36,19 +39,61 @@
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
width: 100% !important;
|
||||
border: none !important;
|
||||
border-radius: 10px !important;
|
||||
background: transparent !important;
|
||||
color: #6b7280 !important;
|
||||
font-weight: 500 !important;
|
||||
color: #475569 !important;
|
||||
font-weight: 600 !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) {
|
||||
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;
|
||||
background: #ffffff !important;
|
||||
color: #0f172a !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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@apply text-2xl font-bold text-gray-900 mb-2;
|
||||
@apply text-2xl font-bold text-slate-900 mb-2;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
@apply text-gray-600 text-sm;
|
||||
@apply text-slate-500 text-sm;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</el-icon>
|
||||
个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="home">
|
||||
<el-dropdown-item v-if="showHomeEntry" command="home">
|
||||
<el-icon class="dropdown-item-icon">
|
||||
<Home />
|
||||
</el-icon>
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
UserIcon as User
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -115,11 +115,10 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['user-command'])
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const showNotifications = ref(false)
|
||||
const showHomeEntry = computed(() => userStore.accountKind !== 'subordinate')
|
||||
|
||||
// 处理用户菜单命令
|
||||
const handleUserCommand = async (command) => {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</template>
|
||||
</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">
|
||||
<el-icon class="menu-icon">
|
||||
<HomeIcon />
|
||||
@@ -56,6 +56,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'user', // 'user' | 'admin'
|
||||
validator: (value) => ['user', 'admin'].includes(value)
|
||||
},
|
||||
showHomeLink: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -105,11 +105,68 @@ export const getMenuItems = (userType = 'user') => {
|
||||
return userMenuItems
|
||||
}
|
||||
|
||||
// 新增:获取用户可访问的菜单项(包含管理员菜单)
|
||||
export const getUserAccessibleMenuItems = (userType = 'user') => {
|
||||
const baseMenuItems = [...userMenuItems]
|
||||
// 子账号/子站壳:受限侧栏(不展示主站运营外链入口由布局控制)
|
||||
export const getSubordinateMenuItems = () => {
|
||||
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') {
|
||||
baseMenuItems.push({
|
||||
group: '管理后台',
|
||||
|
||||
27
src/constants/portal.js
Normal file
27
src/constants/portal.js
Normal 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
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
<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="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">
|
||||
<!-- Logo与标题 -->
|
||||
<div class="flex flex-col items-center mb-8">
|
||||
<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">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<div class="auth-shell">
|
||||
<div class="auth-main-grid">
|
||||
<aside class="auth-brand-panel">
|
||||
<img class="auth-brand-logo" src="@/assets/logo.png" alt="天远数据 Logo" />
|
||||
<p class="auth-panel-kicker auth-panel-kicker-left">欢迎访问</p>
|
||||
<h2 class="auth-brand-heading">账号中心</h2>
|
||||
<p class="auth-brand-kicker">ENTERPRISE DATA CONSOLE</p>
|
||||
<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">
|
||||
© 2026 天远数据控制台. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="auth-title mb-1">天远数据控制台</h1>
|
||||
</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">
|
||||
© 2024 天远数据控制台. All rights reserved.
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,34 +27,127 @@
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 性能优化:减少动画效果 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background:
|
||||
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) {
|
||||
.min-h-\[480px\] {
|
||||
min-height: 320px !important;
|
||||
.auth-shell {
|
||||
padding: 10px;
|
||||
}
|
||||
.px-8 {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
.mx-4 {
|
||||
margin-left: 0.5rem !important;
|
||||
margin-right: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 性能优化:简化背景渐变 */
|
||||
@media (max-width: 768px), (max-device-pixel-ratio: 1) {
|
||||
.bg-gradient-to-br {
|
||||
background: #f8fafc !important;
|
||||
.auth-main-grid {
|
||||
min-height: calc(100vh - 20px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
<el-container>
|
||||
<!-- 桌面端显示侧边栏,移动端隐藏(移动端使用抽屉式侧边栏) -->
|
||||
<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>
|
||||
<!-- 移动端也渲染侧边栏,但使用固定定位的抽屉式效果 -->
|
||||
<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 }">
|
||||
<div class="content-wrapper">
|
||||
<!-- 企业认证提示 - 根据当前页面路径显示 -->
|
||||
@@ -42,7 +42,7 @@
|
||||
/>
|
||||
|
||||
<!-- 右侧浮动联系客服 -->
|
||||
<FloatingCustomerService />
|
||||
<FloatingCustomerService v-if="!isSubPortal" />
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
@@ -52,6 +52,7 @@ import FloatingCustomerService from '@/components/common/FloatingCustomerService
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import NotificationPanel from '@/components/layout/NotificationPanel.vue'
|
||||
import { isSubPortal } from '@/constants/portal'
|
||||
import { getCurrentPageCertificationConfig, getUserAccessibleMenuItems } from '@/constants/menu'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -63,16 +64,6 @@ const userStore = useUserStore()
|
||||
|
||||
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)
|
||||
|
||||
@@ -106,7 +97,10 @@ const notificationTheme = 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
|
||||
})
|
||||
|
||||
const showHomeLink = computed(() => !(userStore.accountKind === 'subordinate' || isSubPortal))
|
||||
|
||||
// 处理用户菜单命令
|
||||
const handleUserCommand = async (command) => {
|
||||
switch (command) {
|
||||
case 'home':
|
||||
if (isSubPortal) {
|
||||
return
|
||||
}
|
||||
window.open('https://www.tianyuanapi.com/', '_blank', 'noopener')
|
||||
break
|
||||
case 'profile':
|
||||
@@ -163,8 +162,13 @@ const handleUserCommand = async (command) => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
const isSubordinateAccount = userStore.accountKind === 'subordinate'
|
||||
userStore.logout()
|
||||
router.push('/auth/login')
|
||||
router.push(
|
||||
isSubordinateAccount || isSubPortal
|
||||
? '/sub/auth/login'
|
||||
: '/auth/login'
|
||||
)
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
|
||||
@@ -1042,10 +1042,10 @@ const handleSubmitRecharge = async () => {
|
||||
|
||||
if (rechargeForm.rechargeType === 'transfer') {
|
||||
params.transfer_order_id = rechargeForm.transferOrderID
|
||||
await financeApi.transferRecharge(params)
|
||||
await financeApi.adminTransferRecharge(params)
|
||||
ElMessage.success('对公转账充值成功')
|
||||
} else {
|
||||
await financeApi.giftRecharge(params)
|
||||
await financeApi.adminGiftRecharge(params)
|
||||
ElMessage.success('赠送充值成功')
|
||||
}
|
||||
|
||||
|
||||
@@ -622,12 +622,14 @@ import { ElMessage } from 'element-plus'
|
||||
import { marked } from 'marked'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import CodeDisplay from '@/components/common/CodeDisplay.vue'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
||||
// 响应式数据
|
||||
@@ -710,6 +712,9 @@ const filteredProducts = computed(() => {
|
||||
})
|
||||
|
||||
const canDebug = computed(() => {
|
||||
if (!userStore.isCertified) {
|
||||
return false
|
||||
}
|
||||
if (!selectedProduct.value || !debugForm.accessId || !debugForm.secretKey) {
|
||||
return false
|
||||
}
|
||||
@@ -735,6 +740,10 @@ const canDebug = computed(() => {
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
if (!userStore.isCertified) {
|
||||
ElMessage.warning('请先完成企业认证后再使用在线调试')
|
||||
return
|
||||
}
|
||||
await loadApiKeys()
|
||||
await loadUserProducts()
|
||||
// 自动选择产品的逻辑已在 loadUserProducts 中处理
|
||||
@@ -1193,6 +1202,10 @@ const convertFieldTypes = (data) => {
|
||||
|
||||
// 加密参数
|
||||
const encryptParams = async () => {
|
||||
if (!userStore.isCertified) {
|
||||
ElMessage.warning('请先完成企业认证后再进行调试')
|
||||
return
|
||||
}
|
||||
if (!canDebug.value) {
|
||||
ElMessage.warning('请先填写完整的必填参数')
|
||||
return
|
||||
@@ -1278,6 +1291,10 @@ const resetForm = () => {
|
||||
|
||||
// 开始调试
|
||||
const handleDebug = async () => {
|
||||
if (!userStore.isCertified) {
|
||||
ElMessage.warning('请先完成企业认证后再发起请求')
|
||||
return
|
||||
}
|
||||
if (!canDebug.value) return
|
||||
|
||||
// 新增:验证动态表单
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="auth-title">欢迎回来</h2>
|
||||
<p class="auth-subtitle">请选择登录方式</p>
|
||||
<p class="auth-subtitle">使用账号信息登录控制台</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
<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-button value="sms" class="flex-1 !border-0 !rounded-md">
|
||||
<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">
|
||||
<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" />
|
||||
@@ -18,7 +18,7 @@
|
||||
验证码登录
|
||||
</div>
|
||||
</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">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
@@ -83,7 +83,8 @@
|
||||
</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>
|
||||
@@ -249,47 +250,3 @@ onUnmounted(() => {
|
||||
})
|
||||
</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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="auth-title">创建账号</h2>
|
||||
<p class="auth-subtitle">请填写以下信息完成注册</p>
|
||||
<p class="auth-subtitle">填写手机号与验证码,快速创建控制台账号</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="onRegister">
|
||||
@@ -117,7 +117,7 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,10 @@
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</el-button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-xs text-center text-slate-400">
|
||||
注册即表示您同意平台相关服务条款与隐私政策
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -252,31 +256,3 @@ onUnmounted(() => {
|
||||
})
|
||||
</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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="auth-title">重置密码</h2>
|
||||
<p class="auth-subtitle">请输入手机号和验证码重置密码</p>
|
||||
<p class="auth-subtitle">验证手机号后重新设置登录密码</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="onReset">
|
||||
@@ -103,8 +103,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 操作链接 -->
|
||||
<div class="text-center py-2">
|
||||
<router-link to="/auth/login" class="auth-link text-sm">
|
||||
<div class="text-center py-1">
|
||||
<router-link :to="loginRoutePath" class="auth-link text-sm">
|
||||
返回登录
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -121,6 +121,10 @@
|
||||
{{ loading ? '重置中...' : '重置密码' }}
|
||||
</el-button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-xs text-center text-slate-400">
|
||||
为了账号安全,请使用强密码并妥善保管
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -130,8 +134,12 @@ import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const { runWithCaptcha } = useAliyunCaptcha()
|
||||
const loginRoutePath = computed(() => {
|
||||
return route.path.startsWith('/sub/auth') ? '/sub/auth/login' : '/auth/login'
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
@@ -217,7 +225,7 @@ const onReset = async () => {
|
||||
const result = await userStore.resetPassword(resetData)
|
||||
if (result.success) {
|
||||
ElMessage.success('密码重置成功')
|
||||
router.push('/auth/login')
|
||||
router.push(loginRoutePath.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('密码重置失败:', error)
|
||||
@@ -234,31 +242,3 @@ onUnmounted(() => {
|
||||
})
|
||||
</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>
|
||||
|
||||
825
src/pages/parent/SubordinateManagement.vue
Normal file
825
src/pages/parent/SubordinateManagement.vue
Normal 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>
|
||||
@@ -30,6 +30,10 @@
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-balance">
|
||||
<div class="balance-label">当前账户余额</div>
|
||||
<div class="balance-value">¥{{ walletBalance }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -432,7 +436,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { apiKeysApi, balanceAlertApi } from '@/api'
|
||||
import { apiKeysApi, balanceAlertApi, financeApi } from '@/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
BellIcon,
|
||||
@@ -451,6 +455,7 @@ const userInfo = ref(null)
|
||||
const apiKeysData = ref(null)
|
||||
const showApiKeys = ref(false)
|
||||
const apiKeysLoading = ref(false)
|
||||
const walletBalance = ref('0.00')
|
||||
|
||||
// 余额预警设置相关
|
||||
const balanceAlertSettings = ref({
|
||||
@@ -468,6 +473,7 @@ const loadUserInfo = async () => {
|
||||
const result = await userStore.fetchUserProfile()
|
||||
if (result.success) {
|
||||
userInfo.value = result.data
|
||||
await loadWalletBalance()
|
||||
// 只有认证用户才加载余额预警设置
|
||||
if (result.data.is_certified) {
|
||||
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密钥
|
||||
const loadApiKeys = async () => {
|
||||
if (showApiKeys.value) return
|
||||
@@ -725,6 +746,27 @@ onMounted(() => {
|
||||
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 {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
@@ -1093,6 +1135,12 @@ onMounted(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-balance {
|
||||
margin-left: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
|
||||
133
src/pages/sub-portal/SubLogin.vue
Normal file
133
src/pages/sub-portal/SubLogin.vue
Normal 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>
|
||||
|
||||
148
src/pages/sub-portal/SubRegister.vue
Normal file
148
src/pages/sub-portal/SubRegister.vue
Normal 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>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { isCurrentOrigin, isPortalDomainConfigReady, isSubPortal, mainPortalOrigin, subPortalOrigin } from '@/constants/portal'
|
||||
import { statisticsRoutes } from './modules/statistics'
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
redirect: () => (isSubPortal ? '/sub/auth/login' : '/dashboard')
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
@@ -344,9 +383,34 @@ const router = createRouter({
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
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
|
||||
|
||||
// 只有在需要认证的路由上才等待初始化
|
||||
@@ -366,7 +430,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
|
||||
// 检查是否需要认证
|
||||
if (requiresAuth && !userStore.isLoggedIn) {
|
||||
next('/auth/login')
|
||||
next(loginPathByRoute)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -376,12 +440,57 @@ router.beforeEach(async (to, from, next) => {
|
||||
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')
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
*/
|
||||
|
||||
import { userApi } from '@/api'
|
||||
import { isSubPortal } from '@/constants/portal'
|
||||
import router from '@/router'
|
||||
import { authEventBus } from '@/utils/request'
|
||||
import { generateSMSRequest } from '@/utils/smsSignature'
|
||||
@@ -133,6 +134,10 @@ export const useUserStore = defineStore('user', () => {
|
||||
return user.value?.is_certified || false
|
||||
})
|
||||
|
||||
const accountKind = computed(() => user.value?.account_kind || 'standalone')
|
||||
|
||||
const isSubordinate = computed(() => accountKind.value === 'subordinate')
|
||||
|
||||
// 检查用户信息是否完整
|
||||
const isUserInfoComplete = computed(() => {
|
||||
return user.value &&
|
||||
@@ -162,6 +167,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
return userType.value === role
|
||||
}
|
||||
|
||||
const loginHomePath = () => (isSubPortal ? '/sub/auth/login' : '/auth/login')
|
||||
|
||||
// 监听认证错误事件
|
||||
const handleAuthError = (message) => {
|
||||
console.log('用户store收到认证错误事件:', message)
|
||||
@@ -173,7 +180,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
console.log('收到版本退出登录事件:', event.detail)
|
||||
logout()
|
||||
ElMessage.error('系统已更新,请重新登录')
|
||||
router.push('/auth/login')
|
||||
router.push(loginHomePath())
|
||||
}
|
||||
|
||||
// 处理版本刷新事件
|
||||
@@ -205,7 +212,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
ElMessage.error('系统已更新,请重新登录')
|
||||
|
||||
// 跳转到登录页面
|
||||
router.push('/auth/login')
|
||||
router.push(loginHomePath())
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -521,6 +528,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
isCertified, // 新增
|
||||
isUserInfoComplete, // 新增
|
||||
hasRole,
|
||||
accountKind,
|
||||
isSubordinate,
|
||||
|
||||
// 方法
|
||||
login,
|
||||
|
||||
@@ -253,9 +253,12 @@ const handleAuthError = (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') {
|
||||
router.push('/auth/login')
|
||||
if (currentPath !== loginPath) {
|
||||
router.push(loginPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user