This commit is contained in:
2026-04-25 20:48:08 +08:00
parent 138a0dc288
commit 02e63dd25a
11 changed files with 226 additions and 378 deletions

4
.env
View File

@@ -4,6 +4,6 @@ VITE_API_URL="https://api.tianyuanapi.com"
VITE_CAPTCHA_SCENE_ID="wynt39to"
VITE_CAPTCHA_ENCRYPTED_MODE=false
# 子账号专属前端域名(生产建议配置,如 https://subsole.tianyuanapi.com
VITE_SUB_PORTAL_BASE_URL=""
VITE_SUB_PORTAL_BASE_URL="https://subsole.tianyuanapi.com"
# 主控制台前端域名(用于从子域回跳主域,如 https://console.tianyuanapi.com
VITE_MAIN_PORTAL_BASE_URL=""
VITE_MAIN_PORTAL_BASE_URL="https://console.tianyuanapi.com"

1
components.d.ts vendored
View File

@@ -58,7 +58,6 @@ 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']

View File

@@ -49,6 +49,10 @@ export const subordinateApi = {
listSubordinates: (params) => request.get('/subordinate/subordinates', { params }),
allocate: (data) => request.post('/subordinate/allocate', data),
listAllocations: (params) => request.get('/subordinate/allocations', { params }),
purchaseQuota: (data) => request.post('/subordinate/purchase-quota', data),
listQuotaPurchases: (params) => request.get('/subordinate/quota-purchases', { params }),
listChildQuotas: (params) => request.get('/subordinate/child-quotas', { params }),
listMyQuotas: () => request.get('/subordinate/my-quotas'),
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 })

View File

@@ -107,7 +107,6 @@ export const getMenuItems = (userType = 'user') => {
// 子账号/子站壳:受限侧栏(不展示主站运营外链入口由布局控制)
export const getSubordinateMenuItems = () => {
const financeGroup = userMenuItems.find((g) => g.group === '财务管理')?.children || []
const devGroup = userMenuItems.find((g) => g.group === '开发者中心')?.children || []
return [
@@ -126,11 +125,6 @@ export const getSubordinateMenuItems = () => {
{ name: '企业入驻', path: '/profile/certification', icon: ShieldCheck }
]
},
{
group: '财务管理',
icon: Wallet,
children: financeGroup.filter((item) => item.path === '/finance/transactions')
},
{
group: '开发者中心',
icon: Setting,

View File

@@ -73,7 +73,7 @@
<div v-else-if="transactions.length === 0" class="text-center py-12">
<el-empty description="暂无消费记录">
<el-button type="primary" @click="$router.push('/finance/wallet')">
<el-button v-if="!isSubordinate" type="primary" @click="$router.push('/finance/wallet')">
前往钱包充值
</el-button>
</el-empty>
@@ -203,9 +203,12 @@ import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { useMobileTable } from '@/composables/useMobileTable'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
// 移动端检测
const { isMobile } = useMobileTable()
const userStore = useUserStore()
const isSubordinate = computed(() => userStore.accountKind === 'subordinate')
// 响应式数据
const loading = ref(false)

View File

@@ -1,7 +1,7 @@
<template>
<ListPageLayout
title="下属"
subtitle="管理下属企业信息、余额与订阅配置"
subtitle="管理下属企业信息与额度购买"
>
<template #filters>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-4">
@@ -62,7 +62,7 @@
<span class="font-semibold text-emerald-600">¥{{ formatMoney(row.balance) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<div class="flex items-center gap-1">
<el-button
@@ -72,16 +72,7 @@
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>
@@ -107,7 +98,7 @@
</template>
<template #extra>
<el-dialog v-model="allocVisible" title="下属划款" width="520px" @close="resetAllocForm">
<el-dialog v-model="allocVisible" title="下属购买额度" width="560px" @close="resetAllocForm">
<div class="space-y-3">
<div class="info-grid">
<div class="info-card">
@@ -115,15 +106,34 @@
<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 class="info-label">预计扣款</div>
<div class="info-value text-emerald-600">¥{{ formatMoney(allocTotalAmount) }}</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="text-sm font-medium text-gray-700">当前已订阅产品与剩余额度</div>
<el-table :data="subscribedProductRows" border stripe size="small" max-height="220">
<el-table-column prop="product_name" label="产品" min-width="180" show-overflow-tooltip />
<el-table-column label="剩余额度" width="120">
<template #default="{ row }">
<span class="text-emerald-600 font-medium">{{ row.available_quota }}</span>
</template>
</el-table-column>
</el-table>
<div v-if="!subscribedProductRows.length" class="text-xs text-gray-500">
当前下属暂无已订阅产品可直接在下方选择产品购买并自动新增
</div>
<el-select v-model="allocForm.productId" placeholder="请选择产品" filterable class="w-full">
<el-option
v-for="item in allocProducts"
:key="item.product_id"
:label="`${item.product_name || item.product_id}(单价 ¥${formatMoney(item.price)} / 次,剩余 ${item.available_quota}`"
:value="item.product_id"
/>
</el-select>
<el-input-number v-model="allocForm.callCount" :min="1" :step="1" controls-position="right" class="w-full" />
<div class="flex gap-2">
<el-input v-model="allocForm.verifyCode" maxlength="6" placeholder="请输入手机验证码" />
@@ -139,7 +149,7 @@
<div class="flex justify-end">
<el-button class="table-action-btn action-subscribe" text bg @click="openAllocationRecords">
划款记录
额度购买记录
</el-button>
</div>
</div>
@@ -149,82 +159,22 @@
</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">
<el-table-column prop="product_id" label="产品ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="call_count" label="购买次数" width="120" />
<el-table-column prop="unit_price" label="单价" width="120">
<template #default="{ row }">
<span class="text-emerald-600 font-medium">¥{{ formatMoney(row.amount) }}</span>
<span class="text-blue-600 font-medium">¥{{ formatMoney(row.unit_price) }}</span>
</template>
</el-table-column>
<el-table-column prop="total_amount" label="总金额" width="140">
<template #default="{ row }">
<span class="text-emerald-600 font-medium">¥{{ formatMoney(row.total_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">
<el-table-column prop="created_at" label="购买时间" width="220">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
@@ -249,13 +199,15 @@
</template>
<script setup>
import { financeApi, productApi, subordinateApi, subscriptionApi } from '@/api'
import { financeApi, subordinateApi, subscriptionApi } from '@/api'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
import { useUserStore } from '@/stores/user'
import { Loading } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
const userStore = useUserStore()
const { runWithCaptcha } = useAliyunCaptcha()
const list = ref([])
const total = ref(0)
const page = ref(1)
@@ -271,11 +223,14 @@ let allocCodeTimer = null
const allocForm = ref({
childId: '',
childCompany: '',
amount: '',
productId: '',
callCount: 1,
verifyCode: '',
parentBalance: '0.00',
childBalance: '0.00'
parentBalance: '0.00'
})
const allocProducts = ref([])
const childSubscriptions = ref([])
const childQuotaAccounts = ref([])
const allocRecordVisible = ref(false)
const allocationRecords = ref([])
@@ -283,20 +238,21 @@ 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 allocTotalAmount = computed(() => {
const selected = allocProducts.value.find((item) => item.product_id === allocForm.value.productId)
if (!selected) return '0.00'
const count = Number(allocForm.value.callCount || 0)
const unit = Number(selected.price || 0)
if (Number.isNaN(count) || Number.isNaN(unit) || count <= 0 || unit <= 0) return '0.00'
return (unit * count).toFixed(2)
})
const subscribedProductRows = computed(() => {
return allocProducts.value.filter((item) => item.is_subscribed)
})
const formatDateTime = (value) => {
if (!value) return '-'
@@ -311,16 +267,6 @@ const formatMoney = (value) => {
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 {
@@ -336,38 +282,6 @@ const loadList = async () => {
}
}
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 {
@@ -388,11 +302,14 @@ const resetAllocForm = () => {
allocForm.value = {
childId: '',
childCompany: '',
amount: '',
productId: '',
callCount: 1,
verifyCode: '',
parentBalance: '0.00',
childBalance: '0.00'
parentBalance: '0.00'
}
allocProducts.value = []
childSubscriptions.value = []
childQuotaAccounts.value = []
}
const ensureCertified = (row, actionName) => {
@@ -402,16 +319,58 @@ const ensureCertified = (row, actionName) => {
}
const openAlloc = async (row) => {
if (!ensureCertified(row, '划款')) return
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()
const walletRes = await financeApi.getWallet().catch(() => null)
allocForm.value.parentBalance = walletRes?.data?.balance || '0.00'
const [parentSubResult, childSubResult, childQuotaResult] = await Promise.allSettled([
subscriptionApi.getMySubscriptions({ page: 1, page_size: 1000 }),
subordinateApi.listChildSubscriptions({ child_user_id: row.child_user_id }),
subordinateApi.listChildQuotas({ child_user_id: row.child_user_id, page: 1, page_size: 1000 })
])
parentSubscriptions.value = parentSubResult.status === 'fulfilled' ? (parentSubResult.value?.data?.items || []) : []
childSubscriptions.value = childSubResult.status === 'fulfilled' ? (childSubResult.value?.data || []) : []
childQuotaAccounts.value = childQuotaResult.status === 'fulfilled'
? (childQuotaResult.value?.data?.items || childQuotaResult.value?.data || [])
: []
const childSubProductSet = new Set(
childSubscriptions.value
.map((item) => item.product_id || item.product?.id)
.filter(Boolean)
)
const quotaMap = new Map(
childQuotaAccounts.value.map((item) => [
item.product_id,
{
available_quota: Number(item.available_quota || 0),
total_quota: Number(item.total_quota || 0)
}
])
)
allocProducts.value = parentSubscriptions.value
.map((item) => ({
product_id: item.product_id || item.product?.id,
product_name: item.product_name || item.product?.name || item.product?.title || item.product_id,
price: item.price,
is_subscribed: childSubProductSet.has(item.product_id || item.product?.id),
available_quota: quotaMap.get(item.product_id || item.product?.id)?.available_quota ?? 0,
total_quota: quotaMap.get(item.product_id || item.product?.id)?.total_quota ?? 0
}))
.filter((item) => item.product_id && Number(item.price) > 0)
if (!allocProducts.value.some((item) => item.product_id === allocForm.value.productId)) {
const subscribedFirst = allocProducts.value.find((item) => item.is_subscribed)
allocForm.value.productId = subscribedFirst?.product_id || allocProducts.value[0]?.product_id || ''
}
} catch {
allocForm.value.parentBalance = '0.00'
allocProducts.value = []
childSubscriptions.value = []
childQuotaAccounts.value = []
}
allocVisible.value = true
}
@@ -425,7 +384,11 @@ const sendAllocVerifyCode = async () => {
}
sendCodeLoading.value = true
try {
const result = await userStore.sendCode(phone, 'login')
await runWithCaptcha(
async (captchaVerifyParam) => {
return await userStore.sendCode(phone, 'login', captchaVerifyParam)
},
(result) => {
if (result.success) {
ElMessage.success('验证码已发送')
allocCodeCountdown.value = 60
@@ -439,14 +402,20 @@ const sendAllocVerifyCode = async () => {
} else {
ElMessage.error(result.error?.response?.data?.message || '发送验证码失败')
}
}
)
} finally {
sendCodeLoading.value = false
}
}
const submitAlloc = async () => {
if (!allocForm.value.amount) {
ElMessage.warning('请输入金额')
if (!allocForm.value.productId) {
ElMessage.warning('请选择产品')
return
}
if (!allocForm.value.callCount || Number(allocForm.value.callCount) <= 0) {
ElMessage.warning('请输入有效购买次数')
return
}
if (!allocForm.value.verifyCode || allocForm.value.verifyCode.length !== 6) {
@@ -455,18 +424,19 @@ const submitAlloc = async () => {
}
allocLoading.value = true
try {
const res = await subordinateApi.allocate({
const res = await subordinateApi.purchaseQuota({
child_user_id: allocForm.value.childId,
amount: allocForm.value.amount,
product_id: allocForm.value.productId,
call_count: Number(allocForm.value.callCount),
verify_code: allocForm.value.verifyCode
})
if (res?.success) {
ElMessage.success('划款成功')
ElMessage.success('额度购买成功')
allocVisible.value = false
loadList()
}
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '划款失败')
ElMessage.error(e?.response?.data?.message || e?.message || '额度购买失败')
} finally {
allocLoading.value = false
}
@@ -480,7 +450,7 @@ const openAllocationRecords = async () => {
const loadAllocationRecords = async () => {
try {
const res = await subordinateApi.listAllocations({
const res = await subordinateApi.listQuotaPurchases({
child_user_id: allocForm.value.childId,
page: allocationPage.value,
page_size: allocationPageSize.value
@@ -488,145 +458,7 @@ const loadAllocationRecords = async () => {
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
ElMessage.error(e?.response?.data?.message || e?.message || '加载额度购买记录失败')
}
}
@@ -756,21 +588,6 @@ onUnmounted(() => {
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;
@@ -808,18 +625,5 @@ onUnmounted(() => {
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,7 +30,7 @@
</el-tag>
</div>
</div>
<div class="overview-balance">
<div v-if="!isSubordinateUser" class="overview-balance">
<div class="balance-label">当前账户余额</div>
<div class="balance-value">¥{{ walletBalance }}</div>
</div>
@@ -84,10 +84,10 @@
</div>
<!-- 分隔线仅认证用户显示 -->
<el-divider v-if="userInfo.is_certified" />
<el-divider v-if="userInfo.is_certified && !isSubordinateUser" />
<!-- 下半部分余额预警配置仅认证用户显示 -->
<div v-if="userInfo.is_certified" class="balance-alert-section">
<div v-if="userInfo.is_certified && !isSubordinateUser" class="balance-alert-section">
<div class="section-header">
<div class="flex items-center">
<el-icon class="mr-2 text-orange-600">
@@ -449,6 +449,7 @@ import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const isSubordinateUser = computed(() => userStore.accountKind === 'subordinate')
const loading = ref(false)
const userInfo = ref(null)
@@ -473,9 +474,13 @@ const loadUserInfo = async () => {
const result = await userStore.fetchUserProfile()
if (result.success) {
userInfo.value = result.data
if (!isSubordinateUser.value) {
await loadWalletBalance()
} else {
walletBalance.value = '0.00'
}
// 只有认证用户才加载余额预警设置
if (result.data.is_certified) {
if (result.data.is_certified && !isSubordinateUser.value) {
await loadAlertSettings()
}
} else {

View File

@@ -1,8 +1,8 @@
<template>
<div class="w-full auth-fade-in">
<div class="text-center mb-6">
<h2 class="auth-title">子账号登录</h2>
<p class="auth-subtitle">适用于主账号邀请的成员访问</p>
<h2 class="auth-title">欢迎登录</h2>
<p class="auth-subtitle">请输入账号信息继续访问控制台</p>
</div>
<div class="mb-6">
@@ -42,9 +42,9 @@
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-400">邀请成员请先完成注册</span>
<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>
<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"

View File

@@ -1,14 +1,14 @@
<template>
<div class="w-full auth-fade-in">
<div class="text-center mb-6">
<h2 class="auth-title">子账号注册</h2>
<p class="auth-subtitle">填写邀请码与手机号完成成员账号开通</p>
<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"
<el-input v-model="form.inviteToken" placeholder="请输入邀请码" size="large" clearable :disabled="loading"
class="auth-input" />
</div>
<div>
@@ -44,7 +44,7 @@
</form>
<p class="mt-4 text-xs text-center text-slate-400">
请确邀请码来可信主账号避免账号安全风险
请确邀请码来可信避免账号安全风险
</p>
</div>
</template>

View File

@@ -67,7 +67,7 @@
<div v-else-if="subscriptions.length === 0" class="text-center py-12">
<el-empty description="暂无订阅数据">
<el-button type="primary" @click="$router.push('/products')">
<el-button v-if="!isSubordinate" type="primary" @click="$router.push('/products')">
去数据大厅订阅产品
</el-button>
</el-empty>
@@ -122,6 +122,7 @@
</el-table-column>
<el-table-column
v-if="!isSubordinate"
prop="price"
label="订阅价格"
:width="isMobile ? 100 : 120"
@@ -131,6 +132,16 @@
</template>
</el-table-column>
<el-table-column
v-if="isSubordinate"
label="剩余额度"
:width="isMobile ? 110 : 130"
>
<template #default="{ row }">
<span class="font-semibold text-blue-600">{{ getRemainingQuota(row) }} </span>
</template>
</el-table-column>
<!-- <el-table-column prop="api_used" label="API调用次数" width="140">
<template #default="{ row }">
<span class="font-medium">{{ row.api_used || 0 }}</span>
@@ -258,7 +269,7 @@
<div class="usage-stat-value">{{ usageData?.api_used || 0 }}</div>
<div class="usage-stat-label">API调用次数</div>
</div>
<div class="usage-stat-card">
<div v-if="!isSubordinate" class="usage-stat-card">
<div class="usage-stat-value">¥{{ formatPrice(selectedSubscription.price) }}</div>
<div class="usage-stat-label">订阅价格</div>
</div>
@@ -281,16 +292,19 @@
</template>
<script setup>
import { subscriptionApi } from '@/api'
import { subordinateApi, subscriptionApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { useMobileTable } from '@/composables/useMobileTable'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const { isMobile } = useMobileTable()
const userStore = useUserStore()
const isSubordinate = computed(() => userStore.accountKind === 'subordinate')
// 响应式数据
const loading = ref(false)
@@ -302,6 +316,7 @@ const usageDialogVisible = ref(false)
const selectedSubscription = ref(null)
const usageData = ref(null)
const loadingUsage = ref(false)
const myQuotaMap = ref({})
// 统计数据
const stats = ref({
@@ -344,6 +359,9 @@ const loadSubscriptions = async () => {
const response = await subscriptionApi.getMySubscriptions(params)
subscriptions.value = response.data?.items || []
total.value = response.data?.total || 0
if (isSubordinate.value) {
await loadMyQuotas()
}
} catch (error) {
console.error('加载订阅失败:', error)
ElMessage.error('加载订阅失败')
@@ -352,6 +370,30 @@ const loadSubscriptions = async () => {
}
}
// 加载我的额度账户(子账号按次数扣减时展示剩余额度)
const loadMyQuotas = async () => {
try {
const res = await subordinateApi.listMyQuotas()
const items = res?.data || []
const map = {}
items.forEach((item) => {
map[item.product_id] = item.available_quota
})
myQuotaMap.value = map
} catch (error) {
console.error('加载我的额度账户失败:', error)
myQuotaMap.value = {}
}
}
const getRemainingQuota = (row) => {
const productID = row?.product_id || row?.product?.id
if (!productID) return '-'
const value = myQuotaMap.value[productID]
if (value === undefined || value === null) return '0'
return String(value)
}
// 加载统计数据
const loadStats = async () => {
try {

View File

@@ -1,6 +1,6 @@
import { isCurrentOrigin, isPortalDomainConfigReady, isSubPortal, mainPortalOrigin, subPortalOrigin } from '@/constants/portal'
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'
// 路由配置
@@ -61,13 +61,13 @@ const routes = [
path: 'login',
name: 'SubLogin',
component: () => import('@/pages/sub-portal/SubLogin.vue'),
meta: { title: '子账号登录' }
meta: { title: '登录' }
},
{
path: 'register',
name: 'SubRegister',
component: () => import('@/pages/sub-portal/SubRegister.vue'),
meta: { title: '子账号注册' }
meta: { title: '注册' }
},
{
path: 'reset',
@@ -440,13 +440,10 @@ router.beforeEach(async (to, from, next) => {
return
}
// 登录态下强制要求主/子域配置完整,避免混域运行
if (userStore.isLoggedIn && !isPortalDomainConfigReady) {
if (import.meta.env.PROD) {
userStore.logout()
next(loginPathByRoute)
return
}
// 登录态下若未配置主/子域名,降级为单域运行(不强制登出)
// 否则会出现“登录成功后立即被清会话”的问题。
if (userStore.isLoggedIn && !isPortalDomainConfigReady && import.meta.env.PROD) {
console.warn('[router] 未配置主/子域名,当前以单域模式运行,跳过域名隔离跳转')
}
// 域名隔离:子账号登录态必须在子账号专属域名
@@ -467,7 +464,7 @@ router.beforeEach(async (to, from, next) => {
// 已登录用户访问认证页面,按账号类型重定向到对应首页
if (isAuthRoute && userStore.isLoggedIn) {
next(userStore.accountKind === 'subordinate' ? '/subscriptions' : '/dashboard')
next(userStore.accountKind === 'subordinate' ? '/subscriptions' : '/products')
return
}