chore:拆分大文件
This commit is contained in:
1 parent
c9b6d1947c
commit
8a7c670e8f
7 files changed
+1390
-1275
No files matched your search
@@ -161,7 +161,7 @@ const columns: TableColumnData[] = [
|
||||
title: '用时', dataIndex: 'latency_ms', width: 90,
|
||||
render({ record }) {
|
||||
const ms = record.latency_ms
|
||||
const color = ms < 1000 ? 'green' : ms < 3000 ? 'orange' : 'red'
|
||||
const color = ms < 10000 ? 'green' : ms < 30000 ? 'orange' : 'red'
|
||||
return h(Tag, { color, size: 'small' }, () => formatMs(ms))
|
||||
},
|
||||
},
|
||||
@@ -170,7 +170,7 @@ const columns: TableColumnData[] = [
|
||||
render({ record }) {
|
||||
const ms = record.first_token_ms
|
||||
if (!ms) return h("span", { style: "color: #c9cdd4" }, "-")
|
||||
const color = ms < 500 ? "green" : ms < 1500 ? "orange" : "red"
|
||||
const color = ms < 2000 ? "green" : ms < 5000 ? "orange" : "red"
|
||||
return h(Tag, { color, size: "small" }, () => formatMs(ms))
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
detail: any | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh-detail': []
|
||||
}>()
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const statusTagColor: Record<string, string> = {
|
||||
active: 'green',
|
||||
suspended: 'orangered',
|
||||
closed: undefined,
|
||||
}
|
||||
const statusTagLabel: Record<string, string> = {
|
||||
active: '正常',
|
||||
suspended: '暂停',
|
||||
closed: '已关闭',
|
||||
}
|
||||
|
||||
const editLoading = ref(false)
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
level: null as number | null,
|
||||
max_members: null as number | null,
|
||||
max_concurrency: null as number | null,
|
||||
})
|
||||
|
||||
// 等级配置选项(后端接口)
|
||||
const levelOptions = ref<any[]>([])
|
||||
|
||||
async function fetchLevelOptions() {
|
||||
try {
|
||||
const res: any = await request.get('/admin/tenant-level-configs')
|
||||
levelOptions.value = res.data?.data?.list || res.data?.list || []
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
// 生效的最大成员数(自定义值优先,回退等级配置)
|
||||
function getEffectiveMaxMembers() {
|
||||
if (props.detail?.max_members != null) return props.detail.max_members
|
||||
if (props.detail?.level_config?.max_members != null) return props.detail.level_config.max_members
|
||||
const cfg = levelOptions.value.find((l: any) => l.level === props.detail?.level)
|
||||
return cfg?.max_members ?? null
|
||||
}
|
||||
|
||||
// 生效的并发上限(自定义值优先,回退等级配置)
|
||||
function getEffectiveMaxConcurrency() {
|
||||
if (props.detail?.max_concurrency != null) return props.detail.max_concurrency
|
||||
if (props.detail?.level_config?.max_concurrency != null) return props.detail.level_config.max_concurrency
|
||||
const cfg = levelOptions.value.find((l: any) => l.level === props.detail?.level)
|
||||
return cfg?.max_concurrency ?? null
|
||||
}
|
||||
|
||||
// detail 就绪或刷新后回填编辑表单(原版 onMounted 未 await fetchDetail 就回填导致表单永远为空)
|
||||
watch(() => props.detail, (d) => {
|
||||
if (!d) return
|
||||
editForm.name = d.name || ''
|
||||
editForm.level = d.level ?? null
|
||||
editForm.max_members = d.max_members ?? null
|
||||
editForm.max_concurrency = d.max_concurrency ?? null
|
||||
}, { immediate: true })
|
||||
|
||||
async function saveEdit() {
|
||||
editLoading.value = true
|
||||
try {
|
||||
await request.put(`/admin/tenants/${props.tenantId}`, editForm)
|
||||
Message.success('更新成功')
|
||||
emit('refresh-detail')
|
||||
} catch {
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchLevelOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<ACard :bordered="false" title="租户信息">
|
||||
<ADescriptions :column="isMobile ? 1 : 2" bordered size="medium">
|
||||
<ADescriptionsItem label="ID">{{ detail.id }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="租户名称">{{ detail.name }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="租户代码">
|
||||
<ATag size="small">{{ detail.code }}</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="状态">
|
||||
<ATag :color="statusTagColor[detail.status]" size="small">
|
||||
{{ statusTagLabel[detail.status] || detail.status }}
|
||||
</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="所有者">{{ detail.owner_name || '-' }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="成员数">
|
||||
{{ detail.member_count || 0 }} / {{ getEffectiveMaxMembers() ?? '不限' }}
|
||||
<ATag v-if="detail.max_members != null" size="small" color="arcoblue" style="margin-left: 4px">自定义</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="钱包余额">
|
||||
<span class="money">${{ parseFloat(detail.wallet_balance || '0').toFixed(2) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="并发上限">
|
||||
{{ getEffectiveMaxConcurrency() ?? '不限' }}
|
||||
<ATag v-if="detail.max_concurrency != null" size="small" color="arcoblue" style="margin-left: 4px">自定义</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="等级">
|
||||
<template v-if="detail.level_config">
|
||||
{{ detail.level_config.name }}
|
||||
<ATag v-if="detail.level_config.price_multiplier" size="small" color="green" style="margin-left: 4px">
|
||||
{{ (detail.level_config.price_multiplier * 100).toFixed(0) }}%
|
||||
</ATag>
|
||||
</template>
|
||||
<template v-else-if="detail.level">
|
||||
Lv.{{ detail.level }}
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="创建时间">{{ detail.created_at }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="更新时间">{{ detail.updated_at }}</ADescriptionsItem>
|
||||
</ADescriptions>
|
||||
</ACard>
|
||||
|
||||
<ACard :bordered="false" title="编辑租户">
|
||||
<AForm :model="editForm" :auto-label-width="true" layout="vertical">
|
||||
<AFormItem label="租户名称">
|
||||
<AInput v-model="editForm.name" class="w-full" />
|
||||
</AFormItem>
|
||||
<AFormItem label="最大成员数">
|
||||
<AInputNumber v-model="editForm.max_members" :min="1" allow-clear placeholder="跟随等级" class="w-full" />
|
||||
</AFormItem>
|
||||
<AFormItem label="并发上限">
|
||||
<AInputNumber v-model="editForm.max_concurrency" :min="0" allow-clear placeholder="跟随等级" class="w-full" />
|
||||
</AFormItem>
|
||||
<AFormItem label="等级">
|
||||
<ASelect v-model="editForm.level" allow-clear placeholder="选择等级" class="w-full">
|
||||
<AOption v-for="opt in levelOptions" :key="opt.level" :value="opt.level">
|
||||
{{ opt.name }}({{ (opt.price_multiplier * 100).toFixed(0) }}%)
|
||||
</AOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<AButton type="primary" :loading="editLoading" @click="saveEdit">保存</AButton>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</ACard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
</style>
|
||||
@@ -0,0 +1,597 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, h } from 'vue'
|
||||
import { Tag, Button, Space, Popconfirm, Message } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
// === 独立模型列表 ===
|
||||
const modelsLoading = ref(false)
|
||||
const modelsData = ref<any[]>([])
|
||||
const allModels = ref<any[]>([])
|
||||
|
||||
const modelColumns: TableColumnData[] = [
|
||||
{ title: '模型标识', dataIndex: 'model_code', width: 180, ellipsis: true },
|
||||
{ title: '显示名', dataIndex: 'model_name', width: 150, ellipsis: true },
|
||||
{ title: '分类', dataIndex: 'category', width: 80 },
|
||||
{
|
||||
title: '启用', dataIndex: 'enabled', width: 70,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: record.enabled ? 'green' : undefined, size: 'small' }, () => record.enabled ? '是' : '否')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '计费', dataIndex: 'billing_mode', width: 80,
|
||||
render({ record }) {
|
||||
if (record.billing_mode === 'per_request') return h(Tag, { color: 'purple', size: 'small' }, () => '按次')
|
||||
return h(Tag, { color: 'arcoblue', size: 'small' }, () => '默认')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '折扣', dataIndex: 'discount_ratio', width: 80,
|
||||
render({ record }) {
|
||||
if (!record.discount_ratio || record.discount_ratio === 1) return '-'
|
||||
return `${(record.discount_ratio * 100).toFixed(0)}%`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '并发', dataIndex: 'max_concurrency', width: 70,
|
||||
render({ record }) { return record.max_concurrency || '-' },
|
||||
},
|
||||
{ title: '版本', dataIndex: 'version', width: 60 },
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 160, fixed: 'right',
|
||||
render({ record }) {
|
||||
return h(Space, { size: 4 }, () => [
|
||||
h(Button, { size: 'small', onClick: () => openEditModel(record) }, () => '编辑'),
|
||||
h(Popconfirm, { content: '确定移除该模型?', onOk: () => removeModel(record) }, () =>
|
||||
h(Button, { size: 'small', status: 'danger' }, () => '移除')
|
||||
),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function fetchTenantModels() {
|
||||
modelsLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/tenants/${props.tenantId}/models`)
|
||||
modelsData.value = res.data?.data?.list || res.data?.list || []
|
||||
} catch {
|
||||
} finally {
|
||||
modelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllModels() {
|
||||
try {
|
||||
const res: any = await request.get('/admin/models', { params: { page: 1, page_size: 100, status: 'active' } })
|
||||
allModels.value = res.data?.data?.list || res.data?.list || []
|
||||
} catch (err: any) {
|
||||
console.error('fetchAllModels failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// === 分配模型弹窗 ===
|
||||
const showAssignModal = ref(false)
|
||||
const assignLoading = ref(false)
|
||||
const selectedModelIds = ref<string[]>([])
|
||||
|
||||
const transferOptions = computed(() => {
|
||||
const assignedIds = new Set(modelsData.value.map((m: any) => m.model_id))
|
||||
return allModels.value
|
||||
.filter((m: any) => !assignedIds.has(m.id))
|
||||
.map((m: any) => ({
|
||||
value: String(m.id),
|
||||
label: `${m.model_id}${m.model_name ? ` (${m.model_name})` : ''}`,
|
||||
}))
|
||||
})
|
||||
|
||||
function openAssignModal() {
|
||||
selectedModelIds.value = []
|
||||
showAssignModal.value = true
|
||||
if (allModels.value.length === 0) {
|
||||
fetchAllModels()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign(done: () => void) {
|
||||
if (selectedModelIds.value.length === 0) {
|
||||
Message.warning('请选择要分配的模型')
|
||||
return
|
||||
}
|
||||
assignLoading.value = true
|
||||
try {
|
||||
const assignments = selectedModelIds.value.map(id => ({ model_id: Number(id), enabled: true }))
|
||||
const res: any = await request.post(`/admin/tenants/${props.tenantId}/models`, { assignments })
|
||||
const assigned = res.data?.data?.assigned ?? res.data?.assigned ?? 0
|
||||
Message.success(`成功分配 ${assigned} 个模型`)
|
||||
done()
|
||||
fetchTenantModels()
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
assignLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 编辑模型抽屉 ===
|
||||
const showEditModelModal = ref(false)
|
||||
const editModelLoading = ref(false)
|
||||
const editingModel = ref<any>(null)
|
||||
const editModelForm = reactive({
|
||||
enabled: true,
|
||||
billing_mode: null as string | null,
|
||||
per_request_price: null as number | null,
|
||||
discount_ratio: null as number | null,
|
||||
max_concurrency: 5 as number | null,
|
||||
custom_input_price: null as number | null,
|
||||
custom_output_price: null as number | null,
|
||||
custom_cache_read_price: null as number | null,
|
||||
custom_cache_creation_price: null as number | null,
|
||||
custom_pricing_tiers: [] as any[],
|
||||
})
|
||||
|
||||
let suppressBillingWatch = false
|
||||
|
||||
function openEditModel(record: any) {
|
||||
suppressBillingWatch = true
|
||||
editingModel.value = record
|
||||
editModelForm.enabled = record.enabled
|
||||
editModelForm.billing_mode = record.billing_mode || null
|
||||
editModelForm.per_request_price = record.per_request_price || null
|
||||
editModelForm.discount_ratio = record.discount_ratio || null
|
||||
editModelForm.max_concurrency = record.max_concurrency ?? 5
|
||||
editModelForm.custom_input_price = record.custom_input_price || null
|
||||
editModelForm.custom_output_price = record.custom_output_price || null
|
||||
editModelForm.custom_cache_read_price = record.custom_cache_read_price || null
|
||||
editModelForm.custom_cache_creation_price = record.custom_cache_creation_price || null
|
||||
editModelForm.custom_pricing_tiers = record.custom_pricing_tiers?.length > 0 ? [...record.custom_pricing_tiers] : []
|
||||
showEditModelModal.value = true
|
||||
suppressBillingWatch = false
|
||||
}
|
||||
|
||||
function addTenantTier() {
|
||||
const tiers = editModelForm.custom_pricing_tiers
|
||||
const last = tiers[tiers.length - 1]
|
||||
const newMin = last?.max_tokens ?? 0
|
||||
tiers.push({
|
||||
min_tokens: newMin,
|
||||
max_tokens: null,
|
||||
input_price: 0,
|
||||
output_price: 0,
|
||||
cache_read_price: 0,
|
||||
cache_creation_price: 0,
|
||||
})
|
||||
if (last && last.max_tokens === null) {
|
||||
last.max_tokens = newMin
|
||||
}
|
||||
}
|
||||
|
||||
// 计费模式切换时清理互斥字段
|
||||
watch(() => editModelForm.billing_mode, (mode) => {
|
||||
if (suppressBillingWatch) return
|
||||
if (!mode) {
|
||||
// 默认模式:清空自定义价格,保留折扣
|
||||
editModelForm.custom_input_price = null
|
||||
editModelForm.custom_output_price = null
|
||||
editModelForm.custom_cache_read_price = null
|
||||
editModelForm.custom_cache_creation_price = null
|
||||
editModelForm.per_request_price = null
|
||||
editModelForm.custom_pricing_tiers = []
|
||||
} else {
|
||||
// 自定义价格模式:清空折扣比例
|
||||
editModelForm.discount_ratio = null
|
||||
}
|
||||
})
|
||||
|
||||
async function handleEditModel() {
|
||||
if (!editingModel.value) return
|
||||
editModelLoading.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
...editModelForm,
|
||||
version: editingModel.value.version,
|
||||
}
|
||||
await request.put(`/admin/tenants/${props.tenantId}/models/${editingModel.value.model_id}`, payload)
|
||||
Message.success('更新成功')
|
||||
showEditModelModal.value = false
|
||||
fetchTenantModels()
|
||||
} catch {
|
||||
// 错误已由拦截器统一提示
|
||||
} finally {
|
||||
editModelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeModel(record: any) {
|
||||
try {
|
||||
await request.delete(`/admin/tenants/${props.tenantId}/models/${record.model_id}`)
|
||||
Message.success('已移除')
|
||||
fetchTenantModels()
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
// === 模型分组 ===
|
||||
const groupsLoading = ref(false)
|
||||
const tenantGroups = ref<any[]>([])
|
||||
const allGroups = ref<any[]>([])
|
||||
const showGroupModal = ref(false)
|
||||
const groupAssignLoading = ref(false)
|
||||
const selectedGroupIds = ref<number[]>([])
|
||||
|
||||
const groupColumns: TableColumnData[] = [
|
||||
{ title: '分组名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '标识', dataIndex: 'code', width: 140, ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', width: 80,
|
||||
render({ record }) {
|
||||
const color = record.status === 'active' ? 'green' : undefined
|
||||
const label = record.status === 'active' ? '启用' : '禁用'
|
||||
return h(Tag, { color, size: 'small' }, () => label)
|
||||
},
|
||||
},
|
||||
{ title: '模型数', dataIndex: 'model_count', width: 80 },
|
||||
]
|
||||
|
||||
async function fetchTenantGroups() {
|
||||
groupsLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/tenants/${props.tenantId}/groups`)
|
||||
tenantGroups.value = res.data?.data?.list || res.data?.list || []
|
||||
} catch {
|
||||
} finally {
|
||||
groupsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllGroups() {
|
||||
try {
|
||||
const res: any = await request.get('/admin/model-groups/options')
|
||||
allGroups.value = res.data?.data?.list || res.data?.list || []
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const groupTransferOptions = computed(() => {
|
||||
return allGroups.value.map((g: any) => ({
|
||||
value: g.id,
|
||||
label: `${g.name}(${g.code})${g.model_count ? ` - ${g.model_count}个模型` : ''}`,
|
||||
}))
|
||||
})
|
||||
|
||||
function openGroupModal() {
|
||||
selectedGroupIds.value = tenantGroups.value.map((g: any) => g.group_id)
|
||||
showGroupModal.value = true
|
||||
if (allGroups.value.length === 0) {
|
||||
fetchAllGroups()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveGroups(done: () => void) {
|
||||
groupAssignLoading.value = true
|
||||
try {
|
||||
await request.put(`/admin/tenants/${props.tenantId}/groups`, { group_ids: selectedGroupIds.value })
|
||||
Message.success('分组更新成功')
|
||||
done()
|
||||
// 分组变化会影响独立模型表(分组模型并入可用集),两张表都刷新
|
||||
fetchTenantGroups()
|
||||
fetchTenantModels()
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
groupAssignLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 可用模型预览 ===
|
||||
const showPreviewModal = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewData = ref<any[]>([])
|
||||
|
||||
const previewColumns: TableColumnData[] = [
|
||||
{ title: '模型标识', dataIndex: 'model_id', width: 180, ellipsis: true },
|
||||
{ title: '显示名', dataIndex: 'model_name', width: 150, ellipsis: true },
|
||||
{ title: '分类', dataIndex: 'category', width: 80 },
|
||||
{ title: '上下文', dataIndex: 'max_context_tokens', width: 100,
|
||||
render({ record }) { return record.max_context_tokens ? record.max_context_tokens.toLocaleString() : '-' },
|
||||
},
|
||||
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100,
|
||||
render({ record }) { return record.max_output_tokens ? record.max_output_tokens.toLocaleString() : '-' },
|
||||
},
|
||||
{ title: '来源', dataIndex: 'source', width: 80,
|
||||
render({ record }) {
|
||||
const color = record.source === 'explicit' ? 'arcoblue' : 'green'
|
||||
const label = record.source === 'explicit' ? '独立分配' : '分组'
|
||||
return h(Tag, { color, size: 'small' }, () => label)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function fetchAvailableModels() {
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/tenants/${props.tenantId}/available-models`)
|
||||
previewData.value = res.data?.data?.list || res.data?.list || []
|
||||
} catch {
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPreviewModal() {
|
||||
showPreviewModal.value = true
|
||||
fetchAvailableModels()
|
||||
}
|
||||
|
||||
// 激活时刷新:模型列表 + 全量模型 + 分组(复刻原 onTabChange('models') 三连请求)
|
||||
function refresh() {
|
||||
fetchTenantModels()
|
||||
fetchAllModels()
|
||||
fetchTenantGroups()
|
||||
}
|
||||
|
||||
// 首挂拉取一次;之后每次切回该 Tab 都刷新
|
||||
onMounted(refresh)
|
||||
watch(() => props.active, (v) => { if (v) refresh() })
|
||||
|
||||
// 供父级 ATabs #extra「查看可用模型」按钮调用
|
||||
defineExpose({ openPreviewModal })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ACard :bordered="false" class="mb-4" title="模型分组">
|
||||
<template #extra>
|
||||
<AButton type="primary" size="small" @click="openGroupModal">分配分组</AButton>
|
||||
</template>
|
||||
<div v-if="tenantGroups.length === 0" style="color: var(--ta-text-tertiary)">
|
||||
暂未分配模型分组,租户可通过分组获取可用模型
|
||||
</div>
|
||||
<ResponsiveTable
|
||||
v-else
|
||||
:columns="groupColumns"
|
||||
:data="tenantGroups"
|
||||
:loading="groupsLoading"
|
||||
:stripe="true"
|
||||
row-key="group_id"
|
||||
size="small"
|
||||
card-title-key="name"
|
||||
card-badge-key="status"
|
||||
:card-fields="['code', 'model_count']"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="tenantGroups.length" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
<ACard :bordered="false" title="独立模型">
|
||||
<template #extra>
|
||||
<AButton type="primary" size="small" @click="openAssignModal">分配模型</AButton>
|
||||
</template>
|
||||
<div v-if="modelsData.length === 0" style="color: var(--ta-text-tertiary)">
|
||||
暂无独立分配的模型
|
||||
</div>
|
||||
<ResponsiveTable
|
||||
v-else
|
||||
:columns="modelColumns"
|
||||
:data="modelsData"
|
||||
:loading="modelsLoading"
|
||||
:stripe="true"
|
||||
row-key="id"
|
||||
:scroll="{ x: 1100 }"
|
||||
card-title-key="model_code"
|
||||
card-subtitle-key="model_name"
|
||||
card-badge-key="enabled"
|
||||
:card-fields="['category', 'billing_mode', 'discount_ratio', 'max_concurrency', 'version']"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="modelsData.length" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
<!-- 分配分组弹窗 -->
|
||||
<AModal
|
||||
v-model:visible="showGroupModal"
|
||||
title="分配模型分组"
|
||||
:width="700"
|
||||
:on-before-ok="handleSaveGroups"
|
||||
:ok-loading="groupAssignLoading"
|
||||
>
|
||||
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
|
||||
选择分组后,分组内的所有模型自动对该租户可用
|
||||
</div>
|
||||
<ATransfer
|
||||
v-model="selectedGroupIds"
|
||||
:data="groupTransferOptions"
|
||||
:title="['可选分组', '已选分组']"
|
||||
searchable
|
||||
/>
|
||||
</AModal>
|
||||
|
||||
<!-- 分配模型弹窗 -->
|
||||
<AModal
|
||||
v-model:visible="showAssignModal"
|
||||
title="分配模型"
|
||||
:width="750"
|
||||
:on-before-ok="handleAssign"
|
||||
:ok-loading="assignLoading"
|
||||
>
|
||||
<ATransfer
|
||||
v-model="selectedModelIds"
|
||||
:data="transferOptions"
|
||||
:title="['可分配模型', '已选模型']"
|
||||
searchable
|
||||
/>
|
||||
</AModal>
|
||||
|
||||
<!-- 可用模型预览弹窗 -->
|
||||
<AModal v-model:visible="showPreviewModal" title="租户可用模型" :width="800" :footer="false">
|
||||
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
|
||||
共 {{ previewData.length }} 个模型
|
||||
</div>
|
||||
<ATable
|
||||
:columns="previewColumns"
|
||||
:data="previewData"
|
||||
:loading="previewLoading"
|
||||
:bordered="false"
|
||||
:stripe="true"
|
||||
:pagination="false"
|
||||
row-key="model_id"
|
||||
size="small"
|
||||
/>
|
||||
</AModal>
|
||||
|
||||
<!-- 编辑模型配置抽屉 -->
|
||||
<ADrawer
|
||||
v-model:visible="showEditModelModal"
|
||||
:title="`编辑模型 - ${editingModel?.model_name || ''}`"
|
||||
:width="640"
|
||||
:mask-closable="false"
|
||||
:footer="true"
|
||||
>
|
||||
<AForm :model="editModelForm" :auto-label-width="true" layout="vertical">
|
||||
<AFormItem label="启用">
|
||||
<ASwitch v-model="editModelForm.enabled" />
|
||||
</AFormItem>
|
||||
<AFormItem label="单模型并发上限">
|
||||
<AInputNumber v-model="editModelForm.max_concurrency" :min="0" placeholder="默认5" class="w-full" />
|
||||
</AFormItem>
|
||||
|
||||
<AFormItem label="计费模式">
|
||||
<ARadioGroup v-model="editModelForm.billing_mode" type="button">
|
||||
<ARadio value="">默认</ARadio>
|
||||
<ARadio value="token">Token</ARadio>
|
||||
<ARadio value="per_request">按次</ARadio>
|
||||
<ARadio value="tiered">阶梯</ARadio>
|
||||
</ARadioGroup>
|
||||
</AFormItem>
|
||||
|
||||
<!-- 默认模式:仅折扣比例 -->
|
||||
<template v-if="!editModelForm.billing_mode">
|
||||
<ADivider margin="8px">折扣</ADivider>
|
||||
<div style="color: var(--ta-text-tertiary); font-size: 12px; margin-bottom: 12px">使用模型基础定价 × 折扣比例</div>
|
||||
<AFormItem label="折扣比例">
|
||||
<AInputNumber v-model="editModelForm.discount_ratio" :min="0" :max="1" :step="0.05" :precision="2" placeholder="如 0.8 = 8折" class="w-full" />
|
||||
</AFormItem>
|
||||
</template>
|
||||
|
||||
<!-- Token 模式:自定义价格 -->
|
||||
<template v-if="editModelForm.billing_mode === 'token'">
|
||||
<ADivider margin="8px">自定义价格</ADivider>
|
||||
<div style="color: var(--ta-text-tertiary); font-size: 12px; margin-bottom: 12px">留空则使用模型基础定价</div>
|
||||
<div class="grid grid-cols-2 gap-x-3">
|
||||
<AFormItem label="输入价格">
|
||||
<AInputNumber v-model="editModelForm.custom_input_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
<AInputNumber v-model="editModelForm.custom_output_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存读取价格">
|
||||
<AInputNumber v-model="editModelForm.custom_cache_read_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
<AInputNumber v-model="editModelForm.custom_cache_creation_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 按次计费 -->
|
||||
<template v-if="editModelForm.billing_mode === 'per_request'">
|
||||
<ADivider margin="8px">按次定价</ADivider>
|
||||
<AFormItem label="按次单价">
|
||||
<AInputNumber v-model="editModelForm.per_request_price" :min="0" :precision="4" placeholder="每次调用价格" class="w-full">
|
||||
<template #suffix>$ / 次</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</template>
|
||||
|
||||
<!-- 阶梯计费 -->
|
||||
<template v-if="editModelForm.billing_mode === 'tiered'">
|
||||
<ADivider margin="8px">阶梯定价</ADivider>
|
||||
<div style="color: var(--ta-text-tertiary); font-size: 12px; margin-bottom: 12px">按 Token 用量分段设置不同价格,留空则使用模型基础阶梯定价</div>
|
||||
<div v-for="(tier, index) in editModelForm.custom_pricing_tiers" :key="index" class="tier-card">
|
||||
<div class="tier-header">
|
||||
<span class="tier-label">第 {{ index + 1 }} 梯</span>
|
||||
<AButton v-if="editModelForm.custom_pricing_tiers.length > 1" size="mini" status="danger" @click="editModelForm.custom_pricing_tiers.splice(index, 1)">删除</AButton>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-3">
|
||||
<AFormItem label="起始 Token">
|
||||
<AInputNumber v-model="tier.min_tokens" :min="0" :step="1000" placeholder="0" class="w-full" />
|
||||
</AFormItem>
|
||||
<AFormItem label="结束 Token">
|
||||
<AInputNumber v-if="index < editModelForm.custom_pricing_tiers.length - 1" v-model="tier.max_tokens" :min="0" :step="1000" placeholder="上限" class="w-full" />
|
||||
<AInput v-else model-value="无上限" disabled class="w-full" />
|
||||
</AFormItem>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 mt-2">
|
||||
<AFormItem label="输入价格">
|
||||
<AInputNumber v-model="tier.input_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
<AInputNumber v-model="tier.output_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 mt-2">
|
||||
<AFormItem label="缓存读取价格">
|
||||
<AInputNumber v-model="tier.cache_read_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
<AInputNumber v-model="tier.cache_creation_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
</div>
|
||||
<AButton type="dashed" long @click="addTenantTier" class="mt-2">+ 添加梯度</AButton>
|
||||
</template>
|
||||
|
||||
</AForm>
|
||||
<template #footer>
|
||||
<ASpace>
|
||||
<AButton @click="showEditModelModal = false">取消</AButton>
|
||||
<AButton type="primary" :loading="editModelLoading" @click="handleEditModel">保存</AButton>
|
||||
</ASpace>
|
||||
</template>
|
||||
</ADrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
.tier-card {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tier-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tier-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ta-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted, h } from 'vue'
|
||||
import { Tag } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const txLoading = ref(false)
|
||||
const txData = ref<any[]>([])
|
||||
const txPagination = reactive({
|
||||
current: 1, pageSize: 20, total: 0,
|
||||
})
|
||||
const txFilterType = ref('')
|
||||
|
||||
const txTypeLabel: Record<string, string> = {
|
||||
recharge: '充值', redemption: '兑换码', consume: '消费', pre_deduct: '预扣', settle: '结算',
|
||||
refund: '退款', adjust: '调整', freeze: '冻结', unfreeze: '解冻',
|
||||
}
|
||||
const txTypeColor: Record<string, string> = {
|
||||
recharge: 'green', redemption: 'green', consume: 'orangered', adjust: 'arcoblue', refund: 'cyan',
|
||||
pre_deduct: 'orangered', settle: 'orange', freeze: 'red', unfreeze: 'purple',
|
||||
}
|
||||
|
||||
// 交易类型筛选选项(桌面端 RadioGroup 与移动端 ASelect 共用)
|
||||
const txFilterOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '充值', value: 'recharge' },
|
||||
{ label: '兑换码', value: 'redemption' },
|
||||
{ label: '消费', value: 'consume' },
|
||||
{ label: '调整', value: 'adjust' },
|
||||
{ label: '预扣', value: 'pre_deduct' },
|
||||
{ label: '结算', value: 'settle' },
|
||||
{ label: '退款', value: 'refund' },
|
||||
{ label: '冻结', value: 'freeze' },
|
||||
{ label: '解冻', value: 'unfreeze' },
|
||||
]
|
||||
|
||||
function onTxFilterChange() {
|
||||
txPagination.current = 1
|
||||
fetchWalletTransactions()
|
||||
}
|
||||
|
||||
const txColumns: TableColumnData[] = [
|
||||
{ title: '时间', dataIndex: 'created_at', width: 170 },
|
||||
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 80,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: txTypeColor[record.type] || undefined, size: 'small' }, () => txTypeLabel[record.type] || record.type)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额', dataIndex: 'amount', width: 140,
|
||||
render({ record }) {
|
||||
const val = parseFloat(record.amount) || 0
|
||||
const color = val >= 0 ? 'rgb(var(--green-6))' : 'rgb(var(--red-6))'
|
||||
const prefix = val >= 0 ? '+' : ''
|
||||
return h('span', { style: { fontWeight: 600, color } }, `${prefix}${val.toFixed(6)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balance_after', width: 140,
|
||||
render({ record }) { return `$${(parseFloat(record.balance_after) || 0).toFixed(6)}` },
|
||||
},
|
||||
{
|
||||
title: '用户', dataIndex: 'username', width: 120,
|
||||
render({ record }) { return record.username || '--' },
|
||||
},
|
||||
{ title: '请求ID', dataIndex: 'request_id', width: 200, ellipsis: true },
|
||||
{ title: '模型', dataIndex: 'model_name', width: 200 },
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
]
|
||||
|
||||
async function fetchWalletTransactions() {
|
||||
txLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/wallets/${props.tenantId}/transactions`, {
|
||||
params: { page: txPagination.current, page_size: txPagination.pageSize, type: txFilterType.value || undefined },
|
||||
})
|
||||
const raw = res.data?.data
|
||||
txData.value = raw?.list || []
|
||||
txPagination.total = Number(raw?.total) || 0
|
||||
} catch {
|
||||
} finally {
|
||||
txLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 首挂拉取一次;之后每次切回该 Tab 都刷新(保持原 onTabChange 语义)
|
||||
onMounted(fetchWalletTransactions)
|
||||
watch(() => props.active, (v) => { if (v) fetchWalletTransactions() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ACard :bordered="false" title="交易记录">
|
||||
<template #extra>
|
||||
<ARadioGroup
|
||||
v-if="!isMobile"
|
||||
v-model="txFilterType"
|
||||
type="button"
|
||||
size="small"
|
||||
@change="onTxFilterChange"
|
||||
>
|
||||
<ARadio v-for="opt in txFilterOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</ARadio>
|
||||
</ARadioGroup>
|
||||
<ASelect
|
||||
v-else
|
||||
v-model="txFilterType"
|
||||
size="small"
|
||||
:options="txFilterOptions"
|
||||
placeholder="筛选类型"
|
||||
style="width: 140px"
|
||||
@change="onTxFilterChange"
|
||||
/>
|
||||
</template>
|
||||
<ResponsiveTable
|
||||
:columns="txColumns"
|
||||
:data="txData"
|
||||
:loading="txLoading"
|
||||
:stripe="true"
|
||||
row-key="id"
|
||||
size="small"
|
||||
card-title-key="model_name"
|
||||
card-badge-key="type"
|
||||
:card-fields="[{ key: 'amount', full: true }, { key: 'balance_after' }, { key: 'username' }, { key: 'request_id', full: true }, { key: 'description', full: true }, { key: 'created_at', full: true }]"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="txPagination.total" />
|
||||
<APagination
|
||||
v-model:current="txPagination.current"
|
||||
v-model:page-size="txPagination.pageSize"
|
||||
:total="txPagination.total"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
:show-page-size="!isMobile"
|
||||
:show-jumper="!isMobile"
|
||||
:simple="isMobile"
|
||||
@change="fetchWalletTransactions"
|
||||
@page-size-change="() => { txPagination.current = 1; fetchWalletTransactions() }"
|
||||
/>
|
||||
</div>
|
||||
</ACard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted, h } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh-detail': []
|
||||
}>()
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// === 钱包信息 ===
|
||||
const walletInfo = ref<any>(null)
|
||||
const walletLoading = ref(false)
|
||||
|
||||
const showRechargeModal = ref(false)
|
||||
const rechargeForm = reactive({ amount: 0, description: '' })
|
||||
const rechargeLoading = ref(false)
|
||||
const showOfflineRechargeModal = ref(false)
|
||||
const offlineRechargeForm = reactive({ amount: 0, transaction_no: '', description: '' })
|
||||
const offlineRechargeLoading = ref(false)
|
||||
const showThresholdModal = ref(false)
|
||||
const thresholdForm = reactive({ threshold: 0 })
|
||||
const thresholdLoading = ref(false)
|
||||
|
||||
async function fetchWalletInfo() {
|
||||
try {
|
||||
const res: any = await request.get(`/admin/wallets/${props.tenantId}`)
|
||||
walletInfo.value = res.data?.data || null
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
// === 冻结明细(预扣追踪,按笔释放)===
|
||||
const frozenItems = ref<any[]>([])
|
||||
const frozenLoading = ref(false)
|
||||
const showReleaseModal = ref(false)
|
||||
const releaseTarget = ref<any>(null)
|
||||
const releaseForm = reactive({ reason: '' })
|
||||
const releaseLoading = ref(false)
|
||||
|
||||
function formatFrozenAge(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds} 秒`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟`
|
||||
return `${Math.floor(seconds / 3600)} 小时 ${Math.floor((seconds % 3600) / 60)} 分`
|
||||
}
|
||||
|
||||
const frozenColumns: TableColumnData[] = [
|
||||
{ title: '请求ID', dataIndex: 'request_id', width: 300, ellipsis: true, tooltip: true },
|
||||
{
|
||||
title: '模型', dataIndex: 'model_name', width: 160,
|
||||
render({ record }) { return record.model_name || '--' },
|
||||
},
|
||||
{
|
||||
title: '冻结金额', dataIndex: 'amount', width: 130,
|
||||
render({ record }) {
|
||||
return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, `$${(parseFloat(record.amount) || 0).toFixed(6)}`)
|
||||
},
|
||||
},
|
||||
{ title: '冻结时间', dataIndex: 'created_at', width: 280 },
|
||||
{
|
||||
title: '已冻结时长', dataIndex: 'age_seconds', width: 110,
|
||||
render({ record }) { return formatFrozenAge(record.age_seconds) },
|
||||
},
|
||||
{ title: '状态', slotName: 'frozenStatus' },
|
||||
{ title: '操作', slotName: 'frozenAction', width: 110 },
|
||||
]
|
||||
|
||||
async function fetchFrozenItems() {
|
||||
frozenLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/wallets/${props.tenantId}/frozen-items`)
|
||||
frozenItems.value = res.data?.data?.list || []
|
||||
} catch {
|
||||
} finally {
|
||||
frozenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openRelease(item: any) {
|
||||
releaseTarget.value = item
|
||||
releaseForm.reason = ''
|
||||
showReleaseModal.value = true
|
||||
}
|
||||
|
||||
async function handleRelease(done: (closed: boolean) => void) {
|
||||
if (!releaseForm.reason.trim()) { Message.warning('请填写释放原因'); return }
|
||||
releaseLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post(`/admin/wallets/${props.tenantId}/frozen-items/release`, {
|
||||
request_id: releaseTarget.value.request_id,
|
||||
force: !!releaseTarget.value.need_force,
|
||||
reason: releaseForm.reason.trim(),
|
||||
})
|
||||
const amt = parseFloat(res.data?.data?.released_amount || 0)
|
||||
Message.success(`已释放冻结 $${amt.toFixed(6)}`)
|
||||
done(true)
|
||||
await refreshAll()
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
releaseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 一键释放全部冻结 ===
|
||||
const showReleaseAllModal = ref(false)
|
||||
const releaseAllForm = reactive({ reason: '', force: false })
|
||||
const releaseAllLoading = ref(false)
|
||||
|
||||
function openReleaseAll() {
|
||||
releaseAllForm.reason = ''
|
||||
releaseAllForm.force = false
|
||||
showReleaseAllModal.value = true
|
||||
}
|
||||
|
||||
async function handleReleaseAll(done: (closed: boolean) => void) {
|
||||
if (!releaseAllForm.reason.trim()) { Message.warning('请填写释放原因'); return }
|
||||
releaseAllLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post(`/admin/wallets/${props.tenantId}/frozen-items/release-all`, {
|
||||
force: releaseAllForm.force,
|
||||
reason: releaseAllForm.reason.trim(),
|
||||
})
|
||||
const data = res.data?.data || {}
|
||||
const amt = parseFloat(data.released_amount || 0)
|
||||
const skipped = parseInt(data.skipped_count || 0, 10)
|
||||
Message.success(`已释放 ${data.released_count || 0} 笔冻结,合计 $${amt.toFixed(6)}${skipped ? `,跳过 ${skipped} 笔` : ''}`)
|
||||
if (skipped) {
|
||||
Message.warning(`跳过原因:${(data.skipped_reasons || []).join(';')}`)
|
||||
}
|
||||
done(true)
|
||||
await refreshAll()
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
releaseAllLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 调整余额 ===
|
||||
function openRecharge() {
|
||||
rechargeForm.amount = 0
|
||||
rechargeForm.description = ''
|
||||
showRechargeModal.value = true
|
||||
}
|
||||
|
||||
async function handleRecharge(done: (closed: boolean) => void) {
|
||||
if (rechargeForm.amount === 0) { Message.warning('金额不能为零'); return }
|
||||
rechargeLoading.value = true
|
||||
try {
|
||||
await request.post(`/admin/wallets/${props.tenantId}/adjust`, {
|
||||
amount: rechargeForm.amount,
|
||||
description: rechargeForm.description || (rechargeForm.amount > 0 ? '管理员增加余额' : '管理员扣减余额'),
|
||||
})
|
||||
Message.success(rechargeForm.amount > 0 ? '余额已增加' : '余额已扣减')
|
||||
done(true)
|
||||
await refreshAll()
|
||||
// 余额变动需同步父级 detail(页头/信息 Tab 的余额展示)
|
||||
emit('refresh-detail')
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 线下充值入账 ===
|
||||
function openOfflineRecharge() {
|
||||
offlineRechargeForm.amount = 0
|
||||
offlineRechargeForm.transaction_no = ''
|
||||
offlineRechargeForm.description = ''
|
||||
showOfflineRechargeModal.value = true
|
||||
}
|
||||
|
||||
async function handleOfflineRecharge(done: (closed: boolean) => void) {
|
||||
if (!offlineRechargeForm.amount || offlineRechargeForm.amount <= 0) { Message.warning('请输入大于 0 的入账金额'); return }
|
||||
if (!offlineRechargeForm.description.trim()) { Message.warning('请填写入账说明'); return }
|
||||
offlineRechargeLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post(`/admin/wallets/${props.tenantId}/offline-recharge`, {
|
||||
amount: offlineRechargeForm.amount,
|
||||
transaction_no: offlineRechargeForm.transaction_no || undefined,
|
||||
description: offlineRechargeForm.description,
|
||||
})
|
||||
const data = res.data?.data || {}
|
||||
Message.success(`入账成功,到账 $${parseFloat(data.credited_usd || 0).toFixed(2)}(汇率 ${data.rate})`)
|
||||
done(true)
|
||||
await refreshAll()
|
||||
emit('refresh-detail')
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
offlineRechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 预警阈值 ===
|
||||
function openThreshold() {
|
||||
thresholdForm.threshold = walletInfo.value?.warning_threshold ? parseFloat(walletInfo.value.warning_threshold) : 0
|
||||
showThresholdModal.value = true
|
||||
}
|
||||
|
||||
async function handleThreshold(done: (closed: boolean) => void) {
|
||||
thresholdLoading.value = true
|
||||
try {
|
||||
await request.put(`/admin/wallets/${props.tenantId}/warning-threshold`, { threshold: thresholdForm.threshold })
|
||||
Message.success('预警阈值已更新')
|
||||
done(true)
|
||||
await refreshAll()
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
thresholdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新钱包信息 + 冻结明细(交易流水由 TransactionsTab 自管)
|
||||
async function refreshAll() {
|
||||
walletLoading.value = true
|
||||
try {
|
||||
await Promise.all([fetchWalletInfo(), fetchFrozenItems()])
|
||||
} finally {
|
||||
walletLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 首挂拉取一次;之后每次切回该 Tab 都刷新(保持原 onTabChange 语义)
|
||||
onMounted(refreshAll)
|
||||
watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ASpin :loading="walletLoading">
|
||||
<template v-if="walletInfo">
|
||||
<!-- 钱包信息卡 -->
|
||||
<ACard :bordered="false" title="钱包信息" class="mb-4">
|
||||
<template #extra>
|
||||
<ASpace :wrap="isMobile" :size="isMobile ? 'small' : 'medium'">
|
||||
<AButton type="primary" @click="openRecharge">调整余额</AButton>
|
||||
<AButton status="success" @click="openOfflineRecharge">线下入账</AButton>
|
||||
<AButton @click="openThreshold">预警设置</AButton>
|
||||
</ASpace>
|
||||
</template>
|
||||
<ADescriptions :column="isMobile ? 1 : 3" bordered size="medium">
|
||||
<ADescriptionsItem label="可用余额">
|
||||
<span class="money">${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="总余额">
|
||||
${{ parseFloat(walletInfo.balance).toFixed(6) }}
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="冻结余额">
|
||||
<span style="color: rgb(var(--orange-6))">${{ parseFloat(walletInfo.frozen_balance).toFixed(6) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="预警阈值">
|
||||
{{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(6)}` : '关闭' }}
|
||||
</ADescriptionsItem>
|
||||
</ADescriptions>
|
||||
</ACard>
|
||||
|
||||
<!-- 冻结明细(预扣冻结明细,运维逃生舱) -->
|
||||
<ACard :bordered="false" class="mb-4" title="冻结明细" :body-style="{ minHeight: '200px' }">
|
||||
<template #extra>
|
||||
<ASpace>
|
||||
<ATooltip v-if="hasPermission('billing:refund')" content="一次性释放全部可释放的冻结项;任务关联/待结算自动跳过,保护期内需勾选强制">
|
||||
<AButton size="small" :disabled="!frozenItems.length" @click="openReleaseAll">一键释放</AButton>
|
||||
</ATooltip>
|
||||
<AButton size="small" @click="fetchFrozenItems">刷新</AButton>
|
||||
</ASpace>
|
||||
</template>
|
||||
<AAlert v-if="frozenItems.length" type="warning" class="mb-3">
|
||||
以下金额处于预扣冻结中。正常情况下请求结束即自动释放,异常滞留最长约 2 小时由系统自动清理;仅当用户反馈冻结长时间未释放时才需手动处理。
|
||||
</AAlert>
|
||||
<!-- 冻结笔数多时卡片内滚动,不无限拉伸页面:桌面端走 ATable scroll.y(表头吸顶),移动端卡片列表靠该容器限高滚动 -->
|
||||
<div class="max-md:max-h-[480px] max-md:overflow-y-auto">
|
||||
<ResponsiveTable
|
||||
:columns="frozenColumns"
|
||||
:data="frozenItems"
|
||||
:loading="frozenLoading"
|
||||
:stripe="true"
|
||||
row-key="request_id"
|
||||
size="small"
|
||||
:scroll="{ y: 480 }"
|
||||
:card-fields="['amount', 'age_seconds', 'created_at']"
|
||||
>
|
||||
<!-- 桌面端:状态/操作列 slot 透传给内部 ATable -->
|
||||
<template #frozenStatus="{ record }">
|
||||
<ASpace size="mini">
|
||||
<ATag v-if="record.task_status" color="arcoblue" size="small">任务 {{ record.task_status }}</ATag>
|
||||
<span v-if="record.block_reason" style="color: var(--color-text-3); font-size: 12px">{{ record.block_reason }}</span>
|
||||
<ATag v-else-if="record.need_force" color="orangered" size="small">保护期内</ATag>
|
||||
<ATag v-else color="green" size="small">可释放</ATag>
|
||||
</ASpace>
|
||||
</template>
|
||||
<template #frozenAction="{ record }">
|
||||
<ATooltip v-if="hasPermission('billing:refund')" :content="record.block_reason || (record.need_force ? '冻结不足 10 分钟,对应请求可能仍在进行中' : '释放该笔冻结,金额退回可用余额')">
|
||||
<AButton size="mini" :status="record.need_force ? 'danger' : 'warning'" :disabled="!record.releasable" @click="openRelease(record)">
|
||||
{{ record.need_force ? '强制释放' : '释放' }}
|
||||
</AButton>
|
||||
</ATooltip>
|
||||
</template>
|
||||
<!-- 移动端:头部展示请求ID+模型+状态,操作栏放释放按钮 -->
|
||||
<template #card-header="{ row }">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-mono text-sm text-[var(--color-text-1)]">{{ row.request_id }}</div>
|
||||
<div class="mt-0.5 truncate text-xs text-[var(--color-text-3)]">{{ row.model_name || '模型未知' }}</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center">
|
||||
<ATag v-if="row.task_status" color="arcoblue" size="small">任务 {{ row.task_status }}</ATag>
|
||||
<ATag v-else-if="row.block_reason" color="gray" size="small">阻塞</ATag>
|
||||
<ATag v-else-if="row.need_force" color="orangered" size="small">保护期</ATag>
|
||||
<ATag v-else color="green" size="small">可释放</ATag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.block_reason" class="mt-2 text-xs text-[var(--color-text-3)]">{{ row.block_reason }}</div>
|
||||
</template>
|
||||
<template #card-actions="{ row }">
|
||||
<AButton v-if="hasPermission('billing:refund')" size="small" :status="row.need_force ? 'danger' : 'warning'" :disabled="!row.releasable" @click="openRelease(row)">
|
||||
{{ row.need_force ? '强制释放' : '释放' }}
|
||||
</AButton>
|
||||
</template>
|
||||
</ResponsiveTable>
|
||||
</div>
|
||||
</ACard>
|
||||
</template>
|
||||
</ASpin>
|
||||
|
||||
<!-- 调整余额弹窗 -->
|
||||
<AModal v-model:visible="showRechargeModal" title="调整余额" :width="440" :on-before-ok="handleRecharge" :ok-loading="rechargeLoading">
|
||||
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
|
||||
手动调整该租户的美元钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
|
||||
<br />
|
||||
总余额 <span class="money">${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(6) : '0.000000' }}</span>
|
||||
| 可用余额 <span class="money">${{ walletInfo ? (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) : '0.000000' }}</span>
|
||||
<span style="color: var(--ta-text-tertiary)">(扣减上限)</span>
|
||||
</div>
|
||||
<AForm :model="rechargeForm" layout="vertical">
|
||||
<AFormItem label="调整金额(USD)" required>
|
||||
<AInputNumber v-model="rechargeForm.amount" :precision="2" :step="1" placeholder="正数=增加余额,负数=扣减余额" class="w-full">
|
||||
<template #prefix>$</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="调整说明">
|
||||
<ATextarea v-model="rechargeForm.description" placeholder="如:运营补偿 / 余额纠错。建议填写,便于对账与审计" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
|
||||
提示:扣减金额不能超过可用余额(不含冻结部分);如需人民币线下转账到账,请使用「线下入账」
|
||||
</div>
|
||||
</AModal>
|
||||
|
||||
<!-- 线下充值入账弹窗 -->
|
||||
<AModal v-model:visible="showOfflineRechargeModal" title="线下充值入账" :width="440" :on-before-ok="handleOfflineRecharge" :ok-loading="offlineRechargeLoading">
|
||||
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
|
||||
用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。
|
||||
</div>
|
||||
<AForm :model="offlineRechargeForm" layout="vertical">
|
||||
<AFormItem label="入账金额(人民币)" required>
|
||||
<AInputNumber v-model="offlineRechargeForm.amount" :min="0.01" :precision="2" placeholder="如 100" class="w-full">
|
||||
<template #prefix>¥</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="转账流水号">
|
||||
<AInput v-model="offlineRechargeForm.transaction_no" placeholder="银行转账流水号(选填,用于对账与防重复入账)" />
|
||||
</AFormItem>
|
||||
<AFormItem label="入账说明" required>
|
||||
<ATextarea v-model="offlineRechargeForm.description" placeholder="如:客户名称 / 转账原因" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
|
||||
到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数
|
||||
</div>
|
||||
</AModal>
|
||||
|
||||
<!-- 预警阈值弹窗 -->
|
||||
<AModal v-model:visible="showThresholdModal" title="设置余额预警阈值" :width="400" :on-before-ok="handleThreshold" :ok-loading="thresholdLoading">
|
||||
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
|
||||
当可用余额低于阈值时触发预警通知
|
||||
</div>
|
||||
<AForm :model="thresholdForm" layout="vertical">
|
||||
<AFormItem label="预警阈值">
|
||||
<AInputNumber v-model="thresholdForm.threshold" :precision="2" :min="0" placeholder="设为 0 表示不预警" class="w-full">
|
||||
<template #prefix>$</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</AModal>
|
||||
|
||||
<!-- 一键释放全部冻结弹窗 -->
|
||||
<AModal v-model:visible="showReleaseAllModal" title="一键释放全部冻结" :width="520" :on-before-ok="handleReleaseAll" :ok-loading="releaseAllLoading" :ok-button-props="{ status: 'danger' }" ok-text="确认释放">
|
||||
<AAlert type="warning" class="mb-3">
|
||||
将一次性释放该租户全部<b>可释放</b>的冻结项(金额从冻结退回可用余额,不改变总余额)。
|
||||
任务关联/待结算的冻结自动跳过;保护期内(冻结不足 10 分钟)的冻结默认跳过。
|
||||
</AAlert>
|
||||
<AForm :model="releaseAllForm" layout="vertical">
|
||||
<AFormItem label="释放原因" required>
|
||||
<ATextarea v-model="releaseAllForm.reason" placeholder="如:用户工单反馈冻结未释放(工单号)/ 实例故障后遗留" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<ACheckbox v-model="releaseAllForm.force">同时强制释放保护期内的冻结项(对应请求可能仍在进行中,释放后将失去超扣保护)</ACheckbox>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
|
||||
释放为逐笔幂等操作:若某请求恰好同时完成结算,以结算结果为准,不会重复释放
|
||||
</div>
|
||||
</AModal>
|
||||
|
||||
<!-- 逐笔释放冻结弹窗 -->
|
||||
<AModal v-model:visible="showReleaseModal" :title="releaseTarget?.need_force ? '强制释放冻结' : '释放冻结'" :width="460" :on-before-ok="handleRelease" :ok-loading="releaseLoading" :ok-button-props="{ status: releaseTarget?.need_force ? 'danger' : 'warning' }" :ok-text="releaseTarget?.need_force ? '确认强制释放' : '确认释放'">
|
||||
<template v-if="releaseTarget">
|
||||
<AAlert v-if="releaseTarget.need_force" type="error" class="mb-3">
|
||||
该笔冻结仅 {{ formatFrozenAge(releaseTarget.age_seconds) }},对应请求(长流式/实时会话)可能仍在进行中。释放后该请求将失去超扣保护,并发消费可能导致余额扣成负数。请确认无在途请求后再操作。
|
||||
</AAlert>
|
||||
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
|
||||
请求ID:<span style="font-family: monospace">{{ releaseTarget.request_id }}</span>
|
||||
<br />
|
||||
冻结金额:<span style="color: rgb(var(--orange-6)); font-weight: 600">${{ (parseFloat(releaseTarget.amount) || 0).toFixed(6) }}</span>
|
||||
| 已冻结 {{ formatFrozenAge(releaseTarget.age_seconds) }}
|
||||
</div>
|
||||
<AForm :model="releaseForm" layout="vertical">
|
||||
<AFormItem label="释放原因" required>
|
||||
<ATextarea v-model="releaseForm.reason" placeholder="如:用户工单反馈冻结未释放(工单号)/ 实例故障后遗留" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
|
||||
释放为逐笔幂等操作:金额从冻结退回可用余额,不改变总余额;若该请求恰好同时完成结算,以结算结果为准,不会重复释放
|
||||
</div>
|
||||
</template>
|
||||
</AModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
.money {
|
||||
font-weight: 600;
|
||||
color: var(--color-success-6);
|
||||
}
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
.table-footer .table-stats {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
Reference in new issue
Block a user