This commit is contained in:
2026-06-06 14:45:29 +08:00
parent 6fda9d1b51
commit 2061b3e361
4 changed files with 377 additions and 14 deletions

View File

@@ -47,11 +47,13 @@ export const subPortalApi = {
export const subordinateApi = {
createInvitation: (data) => request.post('/subordinate/invitations', data || {}),
listSubordinates: (params) => request.get('/subordinate/subordinates', { params }),
updateRemark: (data) => request.patch('/subordinate/subordinates/remark', data),
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 }),
listChildApiCalls: (params) => request.get('/subordinate/child-api-calls', { params }),
listMyQuotas: () => request.get('/subordinate/my-quotas'),
assignSubscription: (data) => request.post('/subordinate/assign-subscription', data),
listChildSubscriptions: (params) => request.get('/subordinate/child-subscriptions', { params }),

View File

@@ -5,13 +5,46 @@
>
<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">
<FilterSection>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<FilterItem label="备注">
<el-input
v-model="filters.remark"
placeholder="模糊搜索备注"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="手机号">
<el-input
v-model="filters.phone"
placeholder="模糊搜索手机号"
clearable
maxlength="11"
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="下属公司">
<el-input
v-model="filters.company_name"
placeholder="模糊搜索公司名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
</div>
<template #stats>
共找到 {{ total }} 个下属账号
</template>
<template #buttons>
<el-button class="toolbar-btn toolbar-btn-secondary" @click="resetFilters">重置</el-button>
<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>
<el-button class="toolbar-btn toolbar-btn-primary" type="primary" :loading="invLoading" @click="createInvite">复制邀请链接</el-button>
</template>
</FilterSection>
</div>
</template>
@@ -46,12 +79,41 @@
<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>
<el-table-column prop="company_name" label="下属公司" min-width="200" 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="remark" label="备注" min-width="180">
<template #default="{ row }">
<el-input
v-model="row.remark"
placeholder="添加备注"
size="small"
clearable
:disabled="row._remarkSaving"
@blur="saveRemark(row)"
@keyup.enter="($event.target)?.blur()"
/>
</template>
</el-table-column>
<el-table-column label="产品与剩余额度" min-width="280">
<template #default="{ row }">
<div v-if="!row.product_quotas?.length" class="text-xs text-gray-400">暂无额度</div>
<div v-else class="flex flex-wrap gap-1">
<el-tag
v-for="item in row.product_quotas"
:key="item.product_id"
size="small"
type="info"
class="quota-tag"
>
{{ item.product_name }}{{ item.available_quota }}
</el-tag>
</div>
</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>
@@ -62,9 +124,17 @@
<span class="font-semibold text-emerald-600">¥{{ formatMoney(row.balance) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="flex items-center gap-1">
<div class="flex flex-wrap items-center gap-1">
<el-button
class="table-action-btn action-calls"
text
bg
@click="openApiCalls(row)"
>
调用记录
</el-button>
<el-button
class="table-action-btn action-allocate"
:class="{ 'action-disabled': !row.is_certified }"
@@ -194,12 +264,116 @@
<el-button class="dialog-btn dialog-btn-secondary" @click="allocRecordVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
v-model="apiCallsVisible"
:title="`下属调用记录${apiCallsTarget.company ? ' - ' + apiCallsTarget.company : ''}`"
width="960px"
@close="resetApiCallsDialog"
>
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<el-input
v-model="apiCallsFilters.transaction_id"
placeholder="交易ID"
clearable
@keyup.enter="searchApiCalls"
/>
<el-input
v-model="apiCallsFilters.product_name"
placeholder="产品名称"
clearable
@keyup.enter="searchApiCalls"
/>
<el-select v-model="apiCallsFilters.status" placeholder="调用状态" clearable class="w-full">
<el-option label="全部" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="处理中" value="pending" />
</el-select>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-gray-500"> {{ apiCallsTotal }} 条记录</span>
<div class="flex gap-2">
<el-button class="toolbar-btn toolbar-btn-secondary" @click="resetApiCallsFilters">重置</el-button>
<el-button class="toolbar-btn toolbar-btn-primary" type="primary" :loading="apiCallsLoading" @click="searchApiCalls">查询</el-button>
</div>
</div>
<el-table v-loading="apiCallsLoading" :data="apiCalls" border stripe max-height="420" size="small">
<el-table-column prop="transaction_id" label="交易ID" min-width="170" show-overflow-tooltip />
<el-table-column prop="product_name" label="接口名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="getApiCallStatusType(row.status)" size="small">{{ getApiCallStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="translated_error_msg" label="错误信息" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.translated_error_msg || row.error_msg || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="client_ip" label="客户端IP" width="130" />
<el-table-column prop="start_at" label="调用时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.start_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="viewApiCallDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper !py-0">
<el-pagination
v-if="apiCallsTotal > 0"
v-model:current-page="apiCallsPage"
v-model:page-size="apiCallsPageSize"
:page-sizes="[10, 20, 50]"
:total="apiCallsTotal"
layout="total, sizes, prev, pager, next"
@size-change="loadChildApiCalls"
@current-change="loadChildApiCalls"
/>
</div>
</div>
<template #footer>
<el-button class="dialog-btn dialog-btn-secondary" @click="apiCallsVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="apiCallDetailVisible" title="调用详情" width="760px">
<div v-if="selectedApiCall" class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-500">交易ID</span>{{ selectedApiCall.transaction_id }}</div>
<div>
<span class="text-gray-500">状态</span>
<el-tag :type="getApiCallStatusType(selectedApiCall.status)" size="small">
{{ getApiCallStatusText(selectedApiCall.status) }}
</el-tag>
</div>
<div><span class="text-gray-500">接口名称</span>{{ selectedApiCall.product_name || '-' }}</div>
<div><span class="text-gray-500">客户端IP</span>{{ selectedApiCall.client_ip || '-' }}</div>
<div><span class="text-gray-500">调用时间</span>{{ formatDateTime(selectedApiCall.start_at) }}</div>
<div><span class="text-gray-500">完成时间</span>{{ selectedApiCall.end_at ? formatDateTime(selectedApiCall.end_at) : '-' }}</div>
<div class="md:col-span-2">
<span class="text-gray-500">错误信息</span>
{{ selectedApiCall.translated_error_msg || selectedApiCall.error_msg || '-' }}
</div>
</div>
<template #footer>
<el-button class="dialog-btn dialog-btn-secondary" @click="apiCallDetailVisible = false">关闭</el-button>
</template>
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { financeApi, 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 { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
import { useUserStore } from '@/stores/user'
@@ -214,6 +388,11 @@ const page = ref(1)
const pageSize = ref(20)
const listLoading = ref(false)
const invLoading = ref(false)
const filters = reactive({
remark: '',
phone: '',
company_name: ''
})
const allocVisible = ref(false)
const allocLoading = ref(false)
@@ -240,6 +419,21 @@ const allocationPageSize = ref(10)
const parentSubscriptions = ref([])
const apiCallsVisible = ref(false)
const apiCallsLoading = ref(false)
const apiCalls = ref([])
const apiCallsTotal = ref(0)
const apiCallsPage = ref(1)
const apiCallsPageSize = ref(10)
const apiCallsTarget = ref({ childId: '', company: '', phone: '' })
const apiCallsFilters = reactive({
transaction_id: '',
product_name: '',
status: ''
})
const apiCallDetailVisible = ref(false)
const selectedApiCall = ref(null)
const lastInvite = ref('')
const allocTotalAmount = computed(() => {
const selected = allocProducts.value.find((item) => item.product_id === allocForm.value.productId)
@@ -270,9 +464,22 @@ const formatMoney = (value) => {
const loadList = async () => {
listLoading.value = true
try {
const res = await subordinateApi.listSubordinates({ page: page.value, page_size: pageSize.value })
const params = {
page: page.value,
page_size: pageSize.value
}
if (filters.remark.trim()) params.remark = filters.remark.trim()
if (filters.phone.trim()) params.phone = filters.phone.trim()
if (filters.company_name.trim()) params.company_name = filters.company_name.trim()
const res = await subordinateApi.listSubordinates(params)
if (res?.success && res.data) {
list.value = res.data.items || []
list.value = (res.data.items || []).map((item) => ({
...item,
remark: item.remark || '',
_remarkOriginal: item.remark || '',
_remarkSaving: false
}))
total.value = res.data.total || 0
}
} catch (e) {
@@ -282,13 +489,51 @@ const loadList = async () => {
}
}
const handleFilterChange = () => {
page.value = 1
loadList()
}
const resetFilters = () => {
filters.remark = ''
filters.phone = ''
filters.company_name = ''
page.value = 1
loadList()
}
const saveRemark = async (row) => {
if (row._remarkSaving) return
const current = (row.remark || '').trim()
const original = (row._remarkOriginal || '').trim()
if (current === original) return
row._remarkSaving = true
try {
const res = await subordinateApi.updateRemark({
child_user_id: row.child_user_id,
remark: current
})
if (res?.success) {
row.remark = current
row._remarkOriginal = current
ElMessage.success('备注已保存')
}
} catch (e) {
row.remark = row._remarkOriginal
ElMessage.error(e?.response?.data?.message || e?.message || '备注保存失败')
} finally {
row._remarkSaving = 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('邀请已生成,请复制链接发送给下属')
ElMessage.success('邀请链接已复制,可邀请多名下属重复使用')
await navigator.clipboard.writeText(res.data.invite_url || res.data.invite_token).catch(() => {})
}
} catch (e) {
@@ -462,6 +707,99 @@ const loadAllocationRecords = async () => {
}
}
const getApiCallStatusType = (status) => {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'danger'
case 'pending':
return 'warning'
default:
return 'info'
}
}
const getApiCallStatusText = (status) => {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'pending':
return '处理中'
default:
return '未知'
}
}
const resetApiCallsDialog = () => {
apiCalls.value = []
apiCallsTotal.value = 0
apiCallsPage.value = 1
apiCallsPageSize.value = 10
apiCallsTarget.value = { childId: '', company: '', phone: '' }
apiCallsFilters.transaction_id = ''
apiCallsFilters.product_name = ''
apiCallsFilters.status = ''
selectedApiCall.value = null
apiCallDetailVisible.value = false
}
const resetApiCallsFilters = () => {
apiCallsFilters.transaction_id = ''
apiCallsFilters.product_name = ''
apiCallsFilters.status = ''
apiCallsPage.value = 1
loadChildApiCalls()
}
const searchApiCalls = () => {
apiCallsPage.value = 1
loadChildApiCalls()
}
const loadChildApiCalls = async () => {
if (!apiCallsTarget.value.childId) return
apiCallsLoading.value = true
try {
const params = {
child_user_id: apiCallsTarget.value.childId,
page: apiCallsPage.value,
page_size: apiCallsPageSize.value
}
if (apiCallsFilters.transaction_id.trim()) params.transaction_id = apiCallsFilters.transaction_id.trim()
if (apiCallsFilters.product_name.trim()) params.product_name = apiCallsFilters.product_name.trim()
if (apiCallsFilters.status) params.status = apiCallsFilters.status
const res = await subordinateApi.listChildApiCalls(params)
if (res?.success && res.data) {
apiCalls.value = res.data.items || []
apiCallsTotal.value = res.data.total || 0
}
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '加载调用记录失败')
} finally {
apiCallsLoading.value = false
}
}
const openApiCalls = async (row) => {
apiCallsTarget.value = {
childId: row.child_user_id,
company: row.is_certified ? row.company_name : '',
phone: row.phone || ''
}
apiCallsPage.value = 1
apiCallsVisible.value = true
await loadChildApiCalls()
}
const viewApiCallDetail = (row) => {
selectedApiCall.value = row
apiCallDetailVisible.value = true
}
const handleSizeChange = (size) => {
pageSize.value = size
page.value = 1
@@ -541,6 +879,10 @@ onUnmounted(() => {
color: #2563eb;
}
.action-calls {
color: #7c3aed;
}
.action-subscribe {
color: #059669;
}
@@ -588,10 +930,19 @@ onUnmounted(() => {
font-weight: 700;
}
.quota-tag {
max-width: 100%;
white-space: normal;
height: auto;
line-height: 1.4;
padding-top: 2px;
padding-bottom: 2px;
}
@media (max-width: 768px) {
:deep(.el-table) {
font-size: 12px;
min-width: 860px;
min-width: 980px;
}
:deep(.el-table th),

View File

@@ -2,7 +2,7 @@
<div class="w-full auth-fade-in">
<div class="text-center mb-6">
<h2 class="auth-title">注册账号</h2>
<p class="auth-subtitle">填写邀请码与手机号完成账号注册</p>
<p class="auth-subtitle">填写主账号邀请码与手机号完成账号注册</p>
</div>
<form class="space-y-4" @submit.prevent="onRegister">

View File

@@ -132,6 +132,16 @@
</template>
</el-table-column>
<el-table-column
v-if="isSubordinate"
label="官方价格"
:width="isMobile ? 100 : 120"
>
<template #default="{ row }">
<span class="font-semibold text-gray-700">¥{{ formatPrice(row.product?.price) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="isSubordinate"
label="剩余额度"