Merge branch 'develop'

This commit is contained in:
luo
2026-08-21 17:23:16 +08:00
328 changed files with 27039 additions and 5117 deletions

View File

@@ -1,3 +1,4 @@
<!--suppress ALL -->
<template>
<ArtTableFullScreen>
<div class="account-page" id="table-full-screen">
@@ -82,9 +83,9 @@
>
<ElOption
v-for="role in availableRolesForCreate"
:key="role.ID"
:key="getRoleId(role)"
:label="role.role_name"
:value="role.ID"
:value="getRoleId(role)"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ role.role_name }}</span>
@@ -133,8 +134,15 @@
<ElEmpty v-if="filteredAvailableRoles.length === 0" description="暂无角色" />
<template v-else-if="isPlatformUser">
<ElCheckboxGroup v-model="rolesToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="role.ID" class="role-item">
<ElCheckbox :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElCheckbox
:label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
@@ -147,8 +155,15 @@
</template>
<template v-else>
<ElRadioGroup v-model="roleToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="role.ID" class="role-item">
<ElRadio :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElRadio
:label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
@@ -190,7 +205,7 @@
<div class="role-list">
<div
v-for="role in filteredAssignedRoles"
:key="role.ID"
:key="getRoleId(role)"
class="role-item assigned-role-item"
>
<span class="role-info">
@@ -199,7 +214,12 @@
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</span>
<ElButton type="danger" size="small" link @click="removeSingleRole(role.ID)">
<ElButton
type="danger"
size="small"
link
@click="removeSingleRole(getRoleId(role))"
>
移除
</ElButton>
</div>
@@ -213,6 +233,74 @@
</div>
</template>
</ElDialog>
<!-- 绑定企微账号对话框 -->
<ElDialog
v-model="wecomBindingDialogVisible"
title="绑定企微账号"
width="520px"
destroy-on-close
@closed="resetWecomBindingForm"
>
<ElForm
ref="wecomBindingFormRef"
:model="wecomBindingForm"
:rules="wecomBindingRules"
label-width="100px"
>
<ElFormItem label="账号名称">
<span>{{ currentWecomAccount?.username || '-' }}</span>
</ElFormItem>
<ElFormItem label="企微应用" prop="application_id">
<ElSelect
v-model="wecomBindingForm.application_id"
placeholder="请选择企微应用"
filterable
clearable
style="width: 100%"
:loading="wecomApplicationsLoading"
@change="handleWecomApplicationChange"
>
<ElOption
v-for="application in wecomApplications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="企微成员" prop="userid">
<ElSelect
v-model="wecomBindingForm.userid"
placeholder="请先选择企微应用"
filterable
clearable
style="width: 100%"
:loading="wecomMembersLoading"
:disabled="!wecomBindingForm.application_id"
>
<ElOption
v-for="member in wecomMembers"
:key="member.userid"
:label="member.name"
:value="member.userid"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="wecomBindingDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="wecomBindingSubmitting"
@click="submitWecomBinding"
>
确认绑定
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -237,21 +325,34 @@
import { useAuth } from '@/composables/useAuth'
import { AccountService } from '@/api/modules/account'
import { RoleService } from '@/api/modules/role'
import { ShopService, EnterpriseService } from '@/api/modules'
import { ShopService, EnterpriseService, WecomService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { PlatformRole } from '@/types/api'
import type { PlatformRole, WecomApplication, WecomMember } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import { getCompatibleNumericId } from '@/utils/business/id'
import {
CommonStatus,
getStatusText,
JULY_PERMISSIONS,
STATUS_SELECT_OPTIONS
} from '@/config/constants'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
const { hasAuth } = useAuth()
const userStore = useUserStore()
const canModifyAccountStatus = hasAuth('account:modify_status')
const route = useRoute()
const dialogType = ref('add')
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
const wecomBindingDialogVisible = ref(false)
const loading = ref(false)
const currentAccountId = ref<number>(0)
const currentAccountName = ref<string>('')
@@ -262,10 +363,28 @@
const roleToAdd = ref<number | undefined>(undefined) // 单选时使用
const leftRoleFilter = ref('')
const rightRoleFilter = ref('')
const currentWecomAccount = ref<any | null>(null)
const wecomApplications = ref<WecomApplication[]>([])
const wecomMembers = ref<WecomMember[]>([])
const wecomApplicationsLoading = ref(false)
const wecomMembersLoading = ref(false)
const wecomBindingSubmitting = ref(false)
const wecomBindingFormRef = ref<FormInstance>()
const wecomBindingForm = reactive({
application_id: undefined as number | undefined,
userid: ''
})
const wecomBindingRules = reactive<FormRules>({
application_id: [{ required: true, message: '请选择企微应用', trigger: 'change' }],
userid: [{ required: true, message: '请选择企微成员', trigger: 'change' }]
})
// 是否为平台用户(平台用户可多选,其他单选)
const isPlatformUser = computed(() => currentAccountType.value === 2)
const getAccountId = (account: any) => getCompatibleNumericId(account) ?? 0
const getRoleId = (role: PlatformRole | any) => getCompatibleNumericId(role) ?? 0
// 定义表单搜索初始值
const initialSearchState = {
name: '',
@@ -400,6 +519,8 @@
{ label: '账号类型', prop: 'user_type' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '企微绑定', prop: 'wecom_bound' },
{ label: '企微用户名称', prop: 'wecom_name' },
...(canModifyAccountStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' }
]
@@ -503,11 +624,27 @@
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150,
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: any) => {
return row.enterprise_name || '-'
}
},
{
prop: 'wecom_bound',
label: '企微绑定',
width: 100,
formatter: (row: any) =>
h(ElTag, { type: row.wecom_bound ? 'success' : 'info', size: 'small' }, () =>
row.wecom_bound ? '已绑定' : '未绑定'
)
},
{
prop: 'wecom_name',
label: '企微用户名称',
minWidth: 130,
formatter: (row: any) => row.wecom_name || '-'
},
...(canModifyAccountStatus
? [
{
@@ -541,6 +678,23 @@
const getActions = (row: any) => {
const actions: any[] = []
if (
isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin) &&
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.info.user_type,
resourceType: 'account',
internalId: row.id
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (hasAuth('account:patch_role')) {
actions.push({
label: '分配角色',
@@ -549,6 +703,14 @@
})
}
if (row.user_type === 2 && hasAuth(JULY_PERMISSIONS.wecom.binding)) {
actions.push({
label: '绑定账号',
handler: () => showWecomBindingDialog(row),
type: 'primary'
})
}
if (hasAuth('account:edit')) {
actions.push({
label: '编辑',
@@ -568,6 +730,104 @@
return actions
}
const showWecomBindingDialog = async (row: any) => {
if (row.user_type !== 2) {
ElMessage.warning('只有平台用户可以绑定企微账号')
return
}
currentWecomAccount.value = row
wecomBindingForm.application_id = undefined
wecomBindingForm.userid = ''
wecomMembers.value = []
wecomBindingDialogVisible.value = true
await loadWecomApplications()
if (row.wecom_bound && row.wecom_corp_id) {
const boundApplication = wecomApplications.value.find(
(application) => application.corp_id === row.wecom_corp_id
)
if (boundApplication) {
wecomBindingForm.application_id = boundApplication.id
await handleWecomApplicationChange(boundApplication.id, row.wecom_userid || '')
}
}
}
const loadWecomApplications = async () => {
wecomApplicationsLoading.value = true
try {
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code === 0) wecomApplications.value = response.data.items || []
} finally {
wecomApplicationsLoading.value = false
}
}
const handleWecomApplicationChange = async (applicationId?: number, selectedUserid = '') => {
wecomBindingForm.userid = selectedUserid
wecomMembers.value = []
if (!applicationId) return
wecomMembersLoading.value = true
try {
const response = await WecomService.getMembers(applicationId, {
page: 1,
page_size: 100
})
if (response.code === 0) {
wecomMembers.value = response.data.items || []
if (
selectedUserid &&
!wecomMembers.value.some((member) => member.userid === selectedUserid)
) {
wecomBindingForm.userid = selectedUserid
}
}
} finally {
wecomMembersLoading.value = false
}
}
const submitWecomBinding = async () => {
const valid = await wecomBindingFormRef.value?.validate().catch(() => false)
if (!valid || !currentWecomAccount.value || !wecomBindingForm.application_id) return
if (currentWecomAccount.value.user_type !== 2) {
ElMessage.error('只有平台用户可以绑定企微账号')
return
}
const accountId = getAccountId(currentWecomAccount.value)
if (!accountId) {
ElMessage.error('当前账号缺少账号标识,无法绑定企微')
return
}
wecomBindingSubmitting.value = true
try {
const response = await AccountService.bindWecom(accountId, {
application_id: wecomBindingForm.application_id,
userid: wecomBindingForm.userid
})
if (response.code === 0) {
ElMessage.success('账号企微绑定成功')
wecomBindingDialogVisible.value = false
await getAccountList()
}
} finally {
wecomBindingSubmitting.value = false
}
}
const resetWecomBindingForm = () => {
currentWecomAccount.value = null
wecomBindingForm.application_id = undefined
wecomBindingForm.userid = ''
wecomMembers.value = []
wecomBindingFormRef.value?.clearValidate()
}
// 表单实例
const formRef = ref<FormInstance>()
@@ -646,7 +906,9 @@
// 计算属性:过滤后的已分配角色
const filteredAssignedRoles = computed(() => {
const assignedRolesList = allRoles.value.filter((role) => selectedRoles.value.includes(role.ID))
const assignedRolesList = allRoles.value.filter((role) =>
selectedRoles.value.includes(getRoleId(role))
)
if (!rightRoleFilter.value) return assignedRolesList
const keyword = rightRoleFilter.value.toLowerCase()
return assignedRolesList.filter((role) => role.role_name.toLowerCase().includes(keyword))
@@ -658,7 +920,12 @@
ElMessage.warning('超级管理员无法分配角色')
return
}
currentAccountId.value = row.id
const accountId = getAccountId(row)
if (!accountId) {
ElMessage.error('当前账号缺少账号标识,无法分配角色')
return
}
currentAccountId.value = accountId
currentAccountName.value = row.username
currentAccountType.value = row.user_type
selectedRoles.value = []
@@ -675,12 +942,12 @@
await loadAllRoles('', roleType)
// 先加载当前账号的角色,再打开对话框
const res = await AccountService.getAccountRoles(row.id)
const res = await AccountService.getAccountRoles(accountId)
if (res.code === 0) {
// 提取角色ID数组
const roles = res.data || []
// 兼容 ID 和 id 两种字段名
selectedRoles.value = roles.map((role: any) => role.ID || role.id)
selectedRoles.value = roles.map((role: any) => getRoleId(role)).filter(Boolean)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
@@ -836,7 +1103,7 @@
// 创建成功后分配角色(如果选择了)
if (createRes.code === 0) {
const accountId = createRes.data?.ID || createRes.data?.id
const accountId = getCompatibleNumericId(createRes.data)
if (formData.role_id && accountId) {
try {
await AccountService.assignRolesToAccount(accountId, [formData.role_id])

View File

@@ -18,6 +18,7 @@
@refresh="handleRefresh"
>
<template #left>
<!--suppress VueUnrecognizedDirective -->
<ElButton @click="showDialog('add')" v-permission="'enterprise_customer:add'"
>新增企业客户</ElButton
>
@@ -204,6 +205,9 @@
import { h, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { EnterpriseService, ShopService } from '@/api/modules'
import { ElMessage, ElSwitch, ElCascader } from 'element-plus'
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
@@ -436,7 +440,7 @@
{
prop: 'contact_name',
label: '联系人姓名',
width: 120
width: 140
},
{
prop: 'contact_phone',
@@ -502,6 +506,24 @@
const getActions = (row: EnterpriseItem) => {
const actions: any[] = []
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.enterpriseEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({
userType,
resourceType: 'enterprise',
internalId: row.id,
businessIdentifier: row.enterprise_code
})
if (auditTarget)
actions.push({
label: userType === 3 ? '活动记录' : '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (hasAuth('enterprise_customer:look_customer')) {
actions.push({
label: '账号列表',
@@ -786,7 +808,7 @@
dialogVisible.value = false
formEl.resetFields()
getTableData()
await getTableData()
} catch (error) {
console.error(error)
} finally {

View File

@@ -1,17 +1,12 @@
<template>
<ElCard shadow="never" class="search-card">
<template #header>
<div class="card-header">
<span>资产查询</span>
<ElButton type="danger" @click="handleGoBack">返回上次搜索</ElButton>
</div>
</template>
<div class="search-area">
<div class="iccid-search">
<div class="iccid-input-wrapper">
<ElInput
v-model="searchIccid"
placeholder="请输入虚拟号、ICCID、IMEI、SN或MSISDN"
clearable
:prefix-icon="Search"
class="asset-search-input"
@focus="iccidInputFocused = true"
@blur="iccidInputFocused = false"
@@ -40,18 +35,19 @@
:icon="Refresh"
:disabled="refreshDisabled"
>
{{ refreshButtonText }}
同步
</ElButton>
<ElButton type="default" plain :icon="Back" @click="handleGoBack"> 返回上次搜索 </ElButton>
</div>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElCard, ElInput, ElButton } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElInput, ElButton } from 'element-plus'
import { Back, Search, Refresh } from '@element-plus/icons-vue'
import { useAssetFormatters } from '../composables/useAssetFormatters'
// Use formatters
@@ -63,19 +59,13 @@
interface Props {
hasCardInfo?: boolean
refreshDisabled?: boolean
refreshRemainingText?: string
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
hasCardInfo: false,
refreshDisabled: false,
refreshRemainingText: ''
refreshDisabled: false
})
const refreshButtonText = computed(() =>
props.refreshRemainingText ? `同步(${props.refreshRemainingText})` : '同步'
)
// Emits
const emit = defineEmits<{
search: [payload: { identifier: string }]
@@ -141,103 +131,87 @@
</script>
<style scoped lang="scss">
.search-card {
.search-area {
position: relative;
z-index: 20;
margin-bottom: 20px;
overflow: visible;
background: var(--el-bg-color, #fff);
}
:deep(.el-card__header) {
padding: 16px 20px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
.iccid-search {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
overflow: visible;
.card-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
}
.iccid-input-wrapper {
position: relative;
flex: 1 1 360px;
min-width: 240px;
:deep(.el-card__body) {
padding: 16px 20px;
overflow: visible;
}
.asset-search-input {
width: 100%;
.iccid-search {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
.iccid-input-wrapper {
position: relative;
flex: 1 1 360px;
min-width: 240px;
.asset-search-input {
width: 100%;
:deep(.el-input__prefix) {
color: var(--el-text-color-secondary);
}
}
.iccid-magnifier {
.iccid-magnifier {
position: absolute;
top: calc(100% + 12px);
left: 0;
z-index: 2000;
white-space: nowrap;
.magnifier-arrow {
position: absolute;
top: calc(100% + 12px);
left: 0;
z-index: 1000;
white-space: nowrap;
top: -9px;
left: 80px;
width: 16px;
height: 16px;
background-color: var(--el-color-primary-light-9);
border-top: 2px solid var(--el-color-primary);
border-left: 2px solid var(--el-color-primary);
transform: rotate(45deg);
}
// 三角箭头 - 上边框样式
.magnifier-arrow {
position: absolute;
top: -9px;
left: 80px;
width: 16px;
height: 16px;
background-color: var(--el-bg-color, #fff);
border-top: 2px solid var(--el-color-primary);
border-left: 2px solid var(--el-color-primary);
transform: rotate(45deg);
}
// 内容区域
.magnifier-content {
max-width: min(720px, calc(100vw - 48px));
padding: 16px 24px;
font-family: 'Courier New', Consolas, monospace;
font-size: 28px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
letter-spacing: 3px;
overflow-wrap: anywhere;
white-space: normal;
background-color: var(--el-bg-color, #fff);
border: 2px solid var(--el-color-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.magnifier-content {
display: inline-block;
width: fit-content;
max-width: min(720px, calc(100vw - 48px));
padding: 14px 22px;
font-family: 'Courier New', Consolas, monospace;
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--el-color-primary);
text-align: center;
letter-spacing: 1.5px;
overflow-wrap: anywhere;
white-space: normal;
background-color: var(--el-color-primary-light-9);
border: 2px solid var(--el-color-primary);
border-radius: 8px;
box-shadow: 0 8px 18px rgb(15 23 42 / 12%);
}
}
}
.operation-button-group {
display: flex;
gap: 12px;
margin-left: auto;
}
.operation-button-group {
display: flex;
gap: 12px;
margin-left: auto;
}
}
// 放大效果的过渡动画
.zoom-fade-enter-active,
.zoom-fade-leave-active {
transition: all 0.2s ease;
}
.zoom-fade-enter-from {
opacity: 0;
transform: scale(0.8) translateY(-10px);
}
.zoom-fade-enter-from,
.zoom-fade-leave-to {
opacity: 0;
transform: scale(0.8) translateY(-10px);
@@ -250,66 +224,47 @@
}
@media (width <= 768px) {
.search-card {
top: 16px;
.search-area {
margin-bottom: 16px;
}
:deep(.el-card__header) {
padding: 12px 16px;
}
.iccid-search {
flex-direction: column;
align-items: stretch;
:deep(.el-card__body) {
padding: 12px 16px;
}
.iccid-input-wrapper {
flex-basis: auto;
width: 100%;
min-width: 0;
:deep(.el-card__header) .card-header {
flex-direction: column;
align-items: stretch;
.iccid-magnifier {
right: 0;
left: 0;
white-space: normal;
.el-button {
width: 100%;
margin-left: 0;
}
}
.magnifier-arrow {
left: 24px;
}
.iccid-search {
flex-direction: column;
align-items: stretch;
.iccid-input-wrapper {
flex-basis: auto;
width: 100%;
min-width: 0;
.iccid-magnifier {
right: 0;
left: 0;
white-space: normal;
.magnifier-arrow {
left: 24px;
}
.magnifier-content {
display: block;
width: 100%;
padding: 12px 14px;
font-size: 18px;
letter-spacing: 1px;
}
.magnifier-content {
display: block;
width: 100%;
padding: 12px 14px;
font-size: 18px;
letter-spacing: 1px;
}
}
}
.search-action-button,
.operation-button-group,
.operation-button-group .el-button {
width: 100%;
margin-left: 0;
}
.search-action-button,
.operation-button-group,
.operation-button-group .el-button {
width: 100%;
margin-left: 0;
}
.operation-button-group {
margin-left: 0;
}
.operation-button-group {
margin-left: 0;
}
}
}

View File

@@ -1,13 +1,8 @@
<template>
<ElCard shadow="never" class="info-card current-package-card">
<template #header>
<div class="card-header">
<span class="header-title">当前生效套餐</span>
<div class="header-actions">
<slot name="header-actions" />
</div>
</div>
</template>
<div class="current-package-section">
<ElDivider class="info-section-divider" content-position="left">
<span class="section-divider-title">当前生效套餐</span>
</ElDivider>
<!-- 绑定设备提示(优先于一切内容) -->
<template v-if="props.boundDeviceId">
@@ -205,35 +200,73 @@
</div>
<!-- 套餐详情表格 -->
<ElDescriptions :column="descriptionsColumn" border class="package-info-table">
<ElDescriptionsItem label="订单号">
<ElDescriptions
class="asset-descriptions"
:column="descriptionsColumn"
direction="vertical"
border
size="large"
>
<ElDescriptionsItem
label="订单号"
label-class-name="asset-label asset-label--package-order"
>
{{ currentPackage.order_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐名称">
<ElDescriptionsItem
label="套餐名称"
label-class-name="asset-label asset-label--package-name"
>
{{ currentPackage.package_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐成本价格" v-if="isAdminOrPlatform">
<ElDescriptionsItem
label="套餐成本价格"
label-class-name="asset-label asset-label--package-cost"
v-if="isAdminOrPlatform"
>
{{ formatAmount(currentPackage.paid_amount || 0) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐零售价">
<ElDescriptionsItem
label="套餐零售价"
label-class-name="asset-label asset-label--package-retail"
>
{{ formatAmount(currentPackage.retail_amount || 0) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐类型">
<ElDescriptionsItem
label="套餐类型"
label-class-name="asset-label asset-label--package-type"
>
{{ getPackageTypeName(currentPackage.package_type) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">
<ElDescriptionsItem
label="开始时间"
label-class-name="asset-label asset-label--package-start"
>
{{ formatDateTime(currentPackage.start_time) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="到期时间">
<ElDescriptionsItem
label="到期时间"
label-class-name="asset-label asset-label--package-expire"
>
{{ formatDateTime(currentPackage.expire_time) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="下单时间">
<ElDescriptionsItem
label="下单时间"
label-class-name="asset-label asset-label--package-created"
>
{{ formatDateTime(currentPackage.created_at) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="currentPackage.duration_days" label="套餐时长">
<ElDescriptionsItem
v-if="currentPackage.duration_days"
label="套餐时长"
label-class-name="asset-label asset-label--package-duration"
>
{{ currentPackage.duration_days }}天
</ElDescriptionsItem>
<ElDescriptionsItem label="到期计时基准">
<ElDescriptionsItem
label="到期计时基准"
label-class-name="asset-label asset-label--package-base"
>
{{ getExpiryBaseLabel(currentPackage.expiry_base) }}
</ElDescriptionsItem>
</ElDescriptions>
@@ -241,12 +274,12 @@
<!-- 空状态 -->
<ElEmpty v-else-if="!loading" :description="errorMsg || '暂无当前生效套餐'" :image-size="100" />
</ElCard>
</div>
</template>
<script setup lang="ts">
import {
ElCard,
ElDivider,
ElButton,
ElDescriptions,
ElDescriptionsItem,
@@ -319,7 +352,7 @@
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
if (width.value <= 1024) return 2
return 4
return 5
})
const emit = defineEmits<{
@@ -345,16 +378,113 @@
</script>
<style scoped lang="scss">
.current-package-card {
.card-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
.header-title {
font-size: 16px;
.current-package-section {
.asset-descriptions {
:deep(.el-descriptions__label.is-vertical-label) {
padding: 8px 15px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: var(--el-text-color-secondary);
background-color: var(--el-fill-color-extra-light);
}
:deep(.asset-label::before) {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 6px;
font-family: iconfont-sys, sans-serif !important;
font-size: 16px;
line-height: 1;
vertical-align: -3px;
color: var(--el-color-primary);
content: '';
opacity: 0.82;
}
:deep(.asset-label--package-order::before) {
content: '\e70f';
}
:deep(.asset-label--package-name::before) {
content: '\e775';
}
:deep(.asset-label--package-cost::before) {
color: var(--el-color-warning);
content: '\e815';
}
:deep(.asset-label--package-retail::before) {
color: var(--el-color-success);
content: '\e6f5';
}
:deep(.asset-label--package-type::before) {
content: '\e723';
}
:deep(.asset-label--package-start::before) {
content: '\e751';
}
:deep(.asset-label--package-expire::before) {
content: '\e76a';
}
:deep(.asset-label--package-created::before) {
content: '\e6ce';
}
:deep(.asset-label--package-duration::before) {
content: '\e7af';
}
:deep(.asset-label--package-base::before) {
content: '\e7b1';
}
:deep(.el-descriptions__content.is-vertical-content) {
padding: 8px 15px;
font-size: 14px;
font-weight: 500;
line-height: 23px;
color: var(--el-text-color-primary);
overflow-wrap: anywhere;
}
}
.info-section-divider {
display: flex;
height: 20px;
align-items: center;
border-top: 0;
:deep(.el-divider__text) {
position: static;
display: inline-flex;
padding: 0;
font-size: 15px;
font-weight: 600;
line-height: 20px;
color: var(--el-text-color-primary);
background-color: transparent;
transform: none;
}
.section-divider-title {
display: inline-flex;
gap: 8px;
align-items: center;
&::before {
width: 3px;
height: 16px;
content: '';
background-color: var(--el-color-primary);
border-radius: 2px;
}
}
}
@@ -473,18 +603,6 @@
}
}
.package-info-table {
margin-top: 0;
}
:deep(.el-descriptions__body) {
overflow-x: auto;
}
:deep(.el-descriptions__cell) {
word-break: break-word;
}
@media (width <= 992px) {
.flow-progress-card {
.flow-stats-container {
@@ -495,15 +613,6 @@
}
@media (width <= 768px) {
.card-header {
flex-direction: column;
align-items: flex-start;
.header-actions {
width: 100%;
}
}
.flow-progress-card {
padding: 14px;

View File

@@ -1,737 +0,0 @@
<template>
<ElCard shadow="never" class="info-card operation-logs-card" v-loading="loading">
<template #header>
<div class="card-header">
<span class="header-title">操作审计日志</span>
<div class="filter-section">
<ElSelect
v-model="filterForm.operation_type"
placeholder="操作类型"
clearable
class="operation-type-select"
@change="handleFilterChange"
>
<ElOption
v-for="option in operationTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect
v-model="filterForm.result_status"
placeholder="执行结果"
clearable
class="result-status-select"
@change="handleFilterChange"
>
<ElOption label="成功" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="拒绝" value="denied" />
</ElSelect>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
<ElTag type="info" size="small">共 {{ total }} 条</ElTag>
</div>
</div>
</template>
<div class="logs-table-wrapper">
<ElTable :data="logList" class="logs-table" border max-height="400">
<ElTableColumn label="操作时间" width="170" align="center">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作类型" width="160" align="center">
<template #default="{ row }">
<ElTag :type="getOperationTypeTag(row.operation_type)" size="small">
{{ row.operation_desc }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作人" width="130" align="center">
<template #default="{ row }">
{{ row.operator_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作人类型" width="110" align="center">
<template #default="{ row }">
{{ getOperatorTypeText(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="执行结果" width="100" align="center">
<template #default="{ row }">
<ElTag :type="getResultStatusTag(row.result_status)" size="small">
{{ getResultStatusText(row.result_status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="变更内容" min-width="320">
<template #default="{ row }">
<div v-if="getDisplayContent(row).length" class="change-content">
<span
v-for="(item, index) in getDisplayContent(row)"
:key="`${item.type}-${item.key}-${index}`"
class="change-item"
>
<span class="field-name">{{ item.fieldName }}</span>
<span class="field-value">
<template v-if="item.type === 'change'">
<span class="new-value">{{ formatFieldValue(item.key, item.afterValue) }}</span>
<ElButton
v-if="canDownloadLogFile && isDownloadableFileKey(item.key, item.afterValue)"
link
type="primary"
@click.stop="handleDownloadLogFile(item.afterValue)"
>
下载文件
</ElButton>
</template>
<span v-else :class="['message-value', item.level === 'error' ? 'is-error' : '']">
{{ item.message }}
</span>
</span>
</span>
</div>
<span v-else>-</span>
</template>
</ElTableColumn>
</ElTable>
<ElPagination
class="logs-pagination"
v-if="total > 0"
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import {
ElButton,
ElCard,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import { AssetService, StorageService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import type { AssetOperationLogItem } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
interface Props {
assetIdentifier?: string
assetType?: string
downloadPermission?: string
}
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
type ResultStatus = '' | 'success' | 'failed' | 'denied'
type OperationLogRow = AssetOperationLogItem & {
operator_type_code?: string
error_code?: string
error_msg?: string
request_id?: string
request_path?: string
request_method?: string
ip_address?: string
user_agent?: string
}
interface ChangeItem {
type: 'change'
key: string
fieldName: string
beforeValue?: any
afterValue?: any
}
interface MessageItem {
type: 'message'
key: string
fieldName: string
message: string
level: 'info' | 'error'
}
type DisplayItem = ChangeItem | MessageItem
const operationTypeOptions = [
{ label: '设备WiFi设置', value: 'device_set_wifi' },
{ label: '设备切卡', value: 'device_switch_card' },
{ label: '设备切卡模式', value: 'device_switch_mode' },
{ label: '设备停机', value: 'device_stop' },
{ label: '设备复机', value: 'device_start' },
{ label: '设备重启', value: 'device_reboot' },
{ label: '设备重置', value: 'device_reset' },
{ label: '设备分配', value: 'device_allocate' },
{ label: '设备回收', value: 'device_recall' },
{ label: '设备绑卡', value: 'device_bind_card' },
{ label: '设备解绑卡', value: 'device_unbind_card' },
{ label: '卡分配', value: 'card_allocate' },
{ label: '卡回收', value: 'card_recall' },
{ label: '卡停机', value: 'card_stop' },
{ label: '卡复机', value: 'card_start' },
{ label: '手动停卡', value: 'card_manual_stop' },
{ label: '手动复机', value: 'card_manual_start' },
{ label: '自动停卡', value: 'card_auto_stop' },
{ label: '自动复机', value: 'card_auto_start' },
{ label: '卡轮询状态', value: 'card_polling_status' },
{ label: '资产轮询状态', value: 'asset_polling_status' },
{ label: '实名策略', value: 'asset_realname_policy' },
{ label: '实名状态', value: 'asset_realname_status' },
{ label: '轮询开关', value: 'asset_polling' },
{ label: 'IoT卡导入任务', value: 'iot_card_import_task_create' }
] as const
const operationTypeTagMap: Record<string, TagType> = {
device_set_wifi: 'primary',
device_switch_card: 'success',
device_switch_mode: 'success',
device_stop: 'danger',
device_start: 'success',
device_reboot: 'warning',
device_reset: 'warning',
device_allocate: 'primary',
device_recall: 'warning',
device_bind_card: 'primary',
device_unbind_card: 'warning',
card_allocate: 'primary',
card_recall: 'warning',
card_stop: 'danger',
card_start: 'success',
card_manual_stop: 'danger',
card_manual_start: 'success',
card_auto_stop: 'danger',
card_auto_start: 'success',
card_polling_status: 'info',
asset_realname_policy: 'info',
asset_realname_status: 'info',
asset_polling: 'info',
asset_polling_status: 'info',
iot_card_import_task_create: 'primary'
}
const operatorTypeMap: Record<string, string> = {
system: '系统',
admin_user: '平台用户',
agent_user: '代理账号',
enterprise_user: '企业用户',
api_user: 'API用户'
}
const fieldNameMap: Record<string, string> = {
asset_type: '资产类型',
batch_no: '批次号',
card_category: '卡类型',
device_imei: '设备IMEI',
device_sn: '设备SN',
device_virtual_no: '设备虚拟号',
device_virtual_nos: '设备虚拟号',
enable_polling: '轮询开关',
file_key: '文件存储键',
first_realname_at: '首次实名时间',
iccid: 'ICCID',
iccids: 'ICCID',
network_status: '网络状态',
real_name_status: '实名状态',
realname_policy: '实名认证策略',
shop_name: '店铺',
source_service: '来源服务',
source_shop_name: '来源店铺',
status: '资产状态',
stop_reason: '停机原因',
switch_mode: '切卡模式',
target_shop_name: '目标店铺',
to_shop_name: '目标店铺'
}
const realnamePolicyMap: Record<string, string> = {
none: '无需实名',
before_order: '先实名后充值/购买',
after_order: '先充值/购买后实名'
}
const stopReasonMap: Record<string, string> = {
'': '无',
no_package: '无可用套餐',
carrier_stopped: '运营商停机',
not_realname: '未实名'
}
const assetTypeMap: Record<string, string> = {
iot_card: 'IoT卡',
device: '设备'
}
const cardCategoryMap: Record<string, string> = {
normal: '普通卡',
industry: '行业卡'
}
const assetStatusMap: Record<string, string> = {
'1': '在库',
'2': '已分销',
'3': '已激活',
'4': '已停用'
}
const newStatusMap: Record<string, string> = {
allocated: '已分配',
recalled: '已回收'
}
const props = defineProps<Props>()
const { hasAuth } = useAuth()
const loading = ref(false)
const logList = ref<OperationLogRow[]>([])
const total = ref(0)
const canDownloadLogFile = computed(() => {
return props.downloadPermission ? hasAuth(props.downloadPermission) : false
})
const pagination = reactive({
page: 1,
page_size: 20
})
const filterForm = reactive({
operation_type: '',
result_status: '' as ResultStatus
})
const loadLogs = async () => {
if (!props.assetIdentifier) return
try {
loading.value = true
const res = await AssetService.getOperationLogs(props.assetIdentifier, {
page: pagination.page,
page_size: pagination.page_size,
operation_type: filterForm.operation_type || undefined,
result_status: filterForm.result_status || undefined
})
if (res.code === 0 && res.data) {
logList.value = res.data.items || []
total.value = res.data.total || 0
}
} catch (error) {
console.error('加载操作日志失败:', error)
ElMessage.error('加载操作日志失败')
} finally {
loading.value = false
}
}
const handleQuery = () => {
pagination.page = 1
loadLogs()
}
const handleReset = () => {
filterForm.operation_type = ''
filterForm.result_status = ''
pagination.page = 1
loadLogs()
}
const handleFilterChange = () => {
handleQuery()
}
const handlePageChange = () => {
loadLogs()
}
const handleSizeChange = () => {
pagination.page = 1
loadLogs()
}
const getOperationTypeTag = (type: string): TagType => {
return operationTypeTagMap[type] || 'info'
}
const getResultStatusTag = (status: string): TagType => {
const tagMap: Record<string, TagType> = {
success: 'success',
failed: 'danger',
denied: 'warning'
}
return tagMap[status] || 'info'
}
const getResultStatusText = (status: string) => {
const textMap: Record<string, string> = {
success: '成功',
failed: '失败',
denied: '拒绝'
}
return textMap[status] || status
}
const isDownloadableFileKey = (key: string, value: unknown) => {
return key === 'file_key' && typeof value === 'string' && value.trim() !== ''
}
const handleDownloadLogFile = async (fileKey: string) => {
try {
await StorageService.downloadFileByKey(fileKey)
ElMessage.success('文件下载已开始')
} catch (error) {
console.error('下载日志文件失败:', error)
ElMessage.error('下载文件失败')
}
}
const getOperatorTypeText = (row: OperationLogRow) => {
if (row.operator_type_code && operatorTypeMap[row.operator_type_code]) {
return operatorTypeMap[row.operator_type_code]
}
if (row.operator_type && operatorTypeMap[row.operator_type]) {
return operatorTypeMap[row.operator_type]
}
return row.operator_type || row.operator_type_code || '-'
}
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '空'
if (typeof value === 'boolean') return value ? '是' : '否'
if (typeof value === 'number') return String(value)
if (typeof value === 'string') return value || '空'
if (Array.isArray(value)) {
if (value.length === 0) return '空'
if (value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) {
return value.map((item) => formatValue(item)).join('、')
}
return JSON.stringify(value)
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
const formatFieldValue = (key: string, value: any): string => {
if (typeof value === 'string' && (key.endsWith('_at') || key.endsWith('_time'))) {
return formatDateTime(value)
}
if (key === 'enable_polling') return value ? '开启' : '关闭'
if (key === 'realname_policy') return realnamePolicyMap[value] || formatValue(value)
if (key === 'real_name_status') return value === 1 ? '已实名' : '未实名'
if (key === 'switch_mode') {
return String(value) === '0' ? '自动' : String(value) === '1' ? '手动' : formatValue(value)
}
if (key === 'new_status') return newStatusMap[value] || formatValue(value)
if (key === 'network_status') {
return Number(value) === 1 ? '正常' : Number(value) === 0 ? '停机' : formatValue(value)
}
if (key === 'stop_reason') return stopReasonMap[String(value ?? '')] ?? formatValue(value)
if (key === 'asset_type') return assetTypeMap[String(value)] || formatValue(value)
if (key === 'card_category') return cardCategoryMap[String(value)] || formatValue(value)
if (key === 'status') return assetStatusMap[String(value)] || formatValue(value)
if (key === 'source_service') return value === 'asset_polling' ? '资产轮询' : formatValue(value)
return formatValue(value)
}
const sanitizeFieldName = (fieldName: string): string => {
return fieldName
.trim()
.replace(/([^)]*)/g, '')
.trim()
}
const shouldHideIdField = (key: string, fieldName: string): boolean => {
const normalizedKey = key.trim()
const normalizedKeyLower = normalizedKey.toLowerCase()
const normalizedFieldName = sanitizeFieldName(fieldName)
const normalizedFieldNameUpper = normalizedFieldName.toUpperCase()
const visibleIdentifierKeys = new Set(['iccid', 'msisdn', 'imei', 'imsi', 'sn'])
const visibleIdentifierNames = ['ICCID', 'MSISDN', 'IMEI', 'IMSI', 'SN']
if (
visibleIdentifierKeys.has(normalizedKeyLower) ||
visibleIdentifierNames.some((name) => normalizedFieldNameUpper.endsWith(name))
) {
return false
}
if (
normalizedKeyLower === 'id' ||
normalizedFieldName === '绑定记录ID' ||
normalizedFieldName === 'ID'
) {
return true
}
return (
normalizedKeyLower.endsWith('_id') ||
/^[a-z][a-z0-9]*Id$/.test(normalizedKey) ||
normalizedFieldName.endsWith('ID')
)
}
const extractOperationContent = (
row: OperationLogRow,
type: 'before' | 'after'
): Record<string, any> => {
const directContent =
type === 'before' ? row.operation_content_before : row.operation_content_after
if (directContent && Object.keys(directContent).length > 0) {
return directContent
}
const fallbackContainer = type === 'before' ? row.before_data : row.after_data
const nestedContent = fallbackContainer?.operation_content
if (nestedContent && Object.keys(nestedContent).length > 0) {
return nestedContent
}
return fallbackContainer || {}
}
const isSameValue = (beforeValue: any, afterValue: any) => {
if (beforeValue === afterValue) return true
return JSON.stringify(beforeValue) === JSON.stringify(afterValue)
}
const getChangeContent = (row: OperationLogRow): ChangeItem[] => {
const before = extractOperationContent(row, 'before')
const after = extractOperationContent(row, 'after')
const desc = row.operation_fields_desc || {}
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
const changes: ChangeItem[] = []
for (const key of keys) {
const fieldName = sanitizeFieldName(desc[key] || fieldNameMap[key] || key)
if (shouldHideIdField(key, fieldName)) continue
const beforeValue = before[key]
const afterValue = after[key]
if (isSameValue(beforeValue, afterValue)) continue
changes.push({
type: 'change',
key,
fieldName,
beforeValue,
afterValue
})
}
return changes
}
const getMessageItems = (row: OperationLogRow): MessageItem[] => {
const items: MessageItem[] = []
const seen = new Set<string>()
const pushMessage = (
key: string,
fieldName: string,
message?: string,
level: 'info' | 'error' = 'info'
) => {
const normalizedMessage = message?.trim()
if (!normalizedMessage) return
const duplicateKey = `${fieldName}:${normalizedMessage}`
if (seen.has(duplicateKey)) return
seen.add(duplicateKey)
items.push({
type: 'message',
key,
fieldName,
message: normalizedMessage,
level
})
}
pushMessage('error_msg', '失败原因', row.error_msg, 'error')
pushMessage('reason', '原因说明', row.reason, row.result_status === 'failed' ? 'error' : 'info')
if (row.fail_count > 0) {
pushMessage('fail_count', '批量结果', `失败 ${row.fail_count} 项`, 'error')
}
row.failed_items?.slice(0, 3).forEach((item, index) => {
const detail = item?.iccid
? `${item.iccid}:${item.reason || '未知原因'}`
: item?.reason || ''
pushMessage(`failed_items_${index}`, '失败明细', detail, 'error')
})
if (!items.length && row.result_status === 'failed') {
pushMessage('result_status', '执行结果', '执行失败', 'error')
}
if (!items.length && row.result_status === 'denied') {
pushMessage('result_status', '执行结果', '操作被拒绝', 'info')
}
return items
}
const getDisplayContent = (row: OperationLogRow): DisplayItem[] => {
return [...getChangeContent(row), ...getMessageItems(row)]
}
watch(
() => props.assetIdentifier,
(newVal) => {
if (newVal) {
loadLogs()
}
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.operation-logs-card {
.card-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
.header-title {
font-size: 16px;
font-weight: 500;
}
.filter-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
min-width: 0;
.operation-type-select {
width: 180px;
}
.result-status-select {
width: 120px;
}
}
}
.logs-table-wrapper {
overflow-x: auto;
.logs-table {
min-width: 980px;
:deep(.el-table__header-wrapper) {
th {
background-color: var(--el-fill-color-light);
}
}
.change-content {
display: flex;
flex-direction: column;
gap: 4px;
.change-item {
display: flex;
gap: 4px;
align-items: center;
font-size: 12px;
.field-name {
min-width: 72px;
color: var(--el-text-color-secondary);
}
.field-value {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
word-break: break-all;
.new-value {
color: var(--el-color-success);
}
.message-value {
color: var(--el-text-color-regular);
&.is-error {
color: var(--el-color-danger);
}
}
}
}
}
}
.logs-pagination {
justify-content: flex-end;
margin-top: 16px;
}
}
@media (width <= 1200px) {
.card-header {
flex-direction: column;
align-items: stretch;
.filter-section {
justify-content: flex-start;
width: 100%;
}
}
}
@media (width <= 768px) {
.card-header {
flex-direction: column;
align-items: stretch;
.filter-section {
flex-direction: column;
align-items: stretch;
:deep(.el-select),
.el-button,
.el-tag {
width: 100% !important;
}
.el-button {
margin-left: 0;
}
}
}
.logs-table-wrapper {
padding: 0 12px;
margin-right: -12px;
margin-left: -12px;
:deep(.el-pagination) {
justify-content: flex-start !important;
overflow-x: auto;
}
}
}
}
</style>

View File

@@ -13,7 +13,7 @@
</div>
</div>
</template>
<div v-loading="loading" class="package-table-wrapper">
<div v-loading="props.loading" class="package-table-wrapper">
<!-- 绑定设备提示 -->
<ElEmpty v-if="props.boundDeviceId" :image-size="100">
<template #description>
@@ -32,15 +32,20 @@
<ElTable :data="packageList" class="package-table" border>
<ElTableColumn label="订单号" width="240">
<template #default="scope">
<ElButton
type="primary"
link
@click="handleOrderNoClick(scope.row)"
v-if="hasAuth('order:package_list_detail')"
>
{{ scope.row.order_no }}
</ElButton>
<span v-else>{{ scope.row.order_no }}</span>
<div class="order-no-cell">
<ElTooltip :content="scope.row.order_no" placement="top" :show-after="300">
<ElButton
class="order-no-link"
type="primary"
link
@click="handleOrderNoClick(scope.row)"
v-if="hasAuth('order:package_list_detail')"
>
{{ scope.row.order_no }}
</ElButton>
<span v-else class="order-no-text">{{ scope.row.order_no }}</span>
</ElTooltip>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="套餐名称" width="200" show-overflow-tooltip>
@@ -291,6 +296,7 @@
v-model="createRefundDialogVisible"
:initial-order-id="currentRefundOrderId"
:initial-order-no="currentRefundOrderNo"
:initial-package-usage-id="currentRefundPackageUsageId"
@success="handleRefundSuccess"
/>
@@ -336,7 +342,7 @@
<ElDialog
v-model="expiresAtDialogVisible"
title="修改过期时间"
width="420px"
width="40%"
@closed="resetExpiresAtForm"
>
<ElForm
@@ -389,7 +395,8 @@
ElInputNumber,
ElDatePicker,
ElSelect,
ElOption
ElOption,
ElTooltip
} from 'element-plus'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -432,6 +439,7 @@
const createRefundDialogVisible = ref(false)
const currentRefundOrderId = ref<number | undefined>(undefined)
const currentRefundOrderNo = ref<string | undefined>(undefined)
const currentRefundPackageUsageId = ref<number | undefined>(undefined)
const selectedPackage = ref<PackageInfo>()
const usedDataDialogVisible = ref(false)
const expiresAtDialogVisible = ref(false)
@@ -490,10 +498,12 @@
}
currentRefundOrderId.value = row.order_id
currentRefundOrderNo.value = row.order_no
currentRefundPackageUsageId.value = row.package_usage_id ?? row.id
createRefundDialogVisible.value = true
}
const handleRefundSuccess = () => {
currentRefundPackageUsageId.value = undefined
emit('refresh')
}
@@ -749,6 +759,35 @@
white-space: nowrap;
}
.order-no-cell {
width: 100%;
min-width: 0;
:deep(.el-tooltip__trigger) {
display: block;
width: 100%;
min-width: 0;
}
.order-no-link,
.order-no-text {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-no-link {
padding: 0;
margin-left: 0;
justify-content: flex-start;
}
}
.package-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@@ -12,21 +12,21 @@
<!-- 钱包余额展示 -->
<div class="wallet-balance-cards">
<div class="balance-card total">
<div class="balance-icon">💰</div>
<div class="balance-icon"><Money /></div>
<div class="balance-info">
<div class="balance-label">总余额</div>
<div class="balance-value">{{ formatAmount(walletInfo.balance) }}</div>
</div>
</div>
<div class="balance-card available">
<div class="balance-icon">✅</div>
<div class="balance-icon"><CircleCheck /></div>
<div class="balance-info">
<div class="balance-label">可用余额</div>
<div class="balance-value">{{ formatAmount(walletInfo.available_balance) }}</div>
</div>
</div>
<div class="balance-card frozen">
<div class="balance-icon">🔒</div>
<div class="balance-icon"><Lock /></div>
<div class="balance-info">
<div class="balance-label">冻结余额</div>
<div class="balance-value">{{ formatAmount(walletInfo.frozen_balance) }}</div>
@@ -40,6 +40,7 @@
<script setup lang="ts">
import { ElCard, ElTag, ElEmpty } from 'element-plus'
import { CircleCheck, Lock, Money } from '@element-plus/icons-vue'
import { useAssetFormatters } from '../composables/useAssetFormatters'
// Use formatters
@@ -91,6 +92,11 @@
flex-shrink: 0;
font-size: 26px;
line-height: 1;
:deep(svg) {
width: 22px;
height: 22px;
}
}
.balance-info {

View File

@@ -1,26 +1,31 @@
<template>
<ElCard shadow="never" class="info-card wallet-info" v-loading="walletLoading">
<ElCard shadow="never" class="info-card wallet-info" v-loading="props.walletLoading">
<template #header>
<div class="card-header wallet-header">
<div class="header-left">
<div class="wallet-header-top">
<span class="wallet-title">钱包</span>
<ElTag v-if="walletInfo" :type="getWalletStatusType(walletInfo.status)" size="small">
<ElTag
v-if="walletInfo"
class="wallet-status-tag"
:type="getWalletStatusType(walletInfo.status)"
size="large"
>
{{ walletInfo.status_text || '-' }}
</ElTag>
<template v-if="walletInfo">
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
>💰 总余额 <strong>{{ formatAmount(walletInfo.balance) }}</strong></span
>
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
>✅ 可用 <strong>{{ formatAmount(walletInfo.available_balance) }}</strong></span
>
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
>🔒 冻结 <strong>{{ formatAmount(walletInfo.frozen_balance) }}</strong></span
>
</template>
</div>
<div v-if="walletInfo" class="wallet-balance-summary">
<div class="balance-item">
<span class="balance-label">总余额</span>
<strong>{{ formatAmount(walletInfo.balance) }}</strong>
</div>
<div class="balance-item">
<span class="balance-label">可用</span>
<strong>{{ formatAmount(walletInfo.available_balance) }}</strong>
</div>
<div class="balance-item">
<span class="balance-label">冻结</span>
<strong>{{ formatAmount(walletInfo.frozen_balance) }}</strong>
</div>
</div>
<div v-if="!props.boundDeviceId" class="filter-section">
<ElSelect
@@ -44,6 +49,25 @@
/>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset()">重置</ElButton>
<ElButton
v-if="
isPlatformAuditUser &&
walletInfo?.wallet_id &&
hasAuth(AUDIT_PERMISSIONS.assetInfoWalletFinance)
"
@click="openWalletAudit"
>资金链路</ElButton
>
<ElButton
v-if="
isPlatformAuditUser &&
walletInfo?.resource_type &&
walletInfo?.resource_id &&
hasAuth(AUDIT_PERMISSIONS.assetInfoWalletAsset)
"
@click="openAssetAudit"
>资产审计</ElButton
>
</div>
</div>
</template>
@@ -65,7 +89,7 @@
<div v-else v-loading="loading" class="wallet-table-wrapper">
<ElTable v-if="hasTransactions" :data="transactionList" class="wallet-table" border>
<ElTableColumn prop="reference_no" label="业务编号">
<ElTableColumn prop="reference_no" label="业务编号" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.reference_no" class="reference-no-link">
{{ scope.row.reference_no }}
@@ -73,14 +97,14 @@
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn label="交易类型" width="100" align="center">
<ElTableColumn label="交易类型" width="100">
<template #default="scope">
<ElTag :type="getTransactionTypeTag(scope.row.transaction_type)" size="small">
{{ scope.row.transaction_type_text }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="变动金额" width="120" align="right">
<ElTableColumn label="变动金额" width="120">
<template #default="scope">
<span
:style="{
@@ -92,17 +116,17 @@
</span>
</template>
</ElTableColumn>
<ElTableColumn label="变动前余额" width="120" align="right">
<ElTableColumn label="变动前余额" width="120">
<template #default="scope">
{{ formatAmount(scope.row.balance_before) }}
</template>
</ElTableColumn>
<ElTableColumn label="变动后余额" width="120" align="right">
<ElTableColumn label="变动后余额" width="120">
<template #default="scope">
{{ formatAmount(scope.row.balance_after) }}
</template>
</ElTableColumn>
<ElTableColumn label="关联业务" width="100" align="center">
<ElTableColumn label="关联业务" width="120">
<template #default="scope">
<ElTag v-if="scope.row.reference_type" type="info" size="small">
{{ scope.row.reference_type === 'recharge' ? '充值' : '订单' }}
@@ -110,7 +134,7 @@
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="150" show-overflow-tooltip>
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.remark || '-' }}
</template>
@@ -154,8 +178,7 @@
ElDatePicker,
ElPagination,
ElEmpty,
ElMessage,
ElDivider
ElMessage
} from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
@@ -167,6 +190,15 @@
AssetWalletTransactionParams,
TransactionType
} from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
interface Props {
assetIdentifier: string
@@ -182,6 +214,23 @@
boundDeviceId: undefined,
boundDeviceNo: ''
})
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isPlatformAuditUser = computed(() =>
isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)
)
const openWalletAudit = () => {
const target = resolveFinanceAuditTarget('wallet_id', props.walletInfo?.wallet_id)
if (target) openAuditInvestigation(target)
}
const openAssetAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: props.walletInfo?.resource_type || '',
internalId: props.walletInfo?.resource_id
})
if (target) openAuditInvestigation(target)
}
const emit = defineEmits<{
(e: 'navigateToDevice', deviceNo: string): void
@@ -339,35 +388,61 @@
&.wallet-info {
.wallet-header {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: space-between;
align-items: stretch;
min-width: 0;
.header-left {
.wallet-header-top {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
width: 100%;
}
.wallet-title {
font-size: 15px;
font-weight: 500;
}
.wallet-title {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
.wallet-divider {
flex-shrink: 0;
}
.wallet-balance-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
width: 100%;
min-width: 0;
}
.balance-item {
.balance-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-width: 0;
padding: 8px 12px;
white-space: nowrap;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.balance-label {
font-size: 13px;
color: var(--el-text-color-regular);
strong {
font-weight: 600;
color: var(--el-text-color-primary);
}
color: var(--el-text-color-secondary);
}
strong {
font-size: 18px;
font-weight: 700;
line-height: 24px;
color: var(--el-text-color-primary);
}
}
.wallet-status-tag {
padding: 8px 14px;
font-size: 14px;
font-weight: 700;
}
.filter-section {
@@ -376,6 +451,7 @@
gap: 10px;
align-items: center;
justify-content: flex-end;
width: 100%;
min-width: 0;
.transaction-type-select {
@@ -399,9 +475,6 @@
}
.wallet-table {
width: max-content;
min-width: 900px;
:deep(.el-table__header) {
th {
font-weight: 600;
@@ -420,30 +493,14 @@
@media (width <= 1200px) {
.wallet-header {
flex-direction: column;
align-items: flex-start;
.header-left,
.filter-section {
width: 100%;
}
.filter-section {
justify-content: flex-start;
justify-content: flex-end;
}
}
}
@media (width <= 768px) {
.wallet-header {
flex-direction: column;
align-items: stretch;
.header-left,
.filter-section {
width: 100%;
}
.filter-section {
flex-direction: column;
align-items: stretch;
@@ -460,18 +517,9 @@
}
}
.header-left {
flex-direction: column;
gap: 6px;
align-items: flex-start;
.wallet-divider {
display: none;
}
.balance-item {
width: 100%;
}
.wallet-balance-summary {
grid-template-columns: 1fr;
gap: 8px;
}
}

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="套餐流量详单" width="900px" destroy-on-close>
<ElDialog
v-model="visible"
title="套餐流量详单"
width="900px"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="data">
<!-- 套餐信息摘要 -->
<ElDescriptions :column="2" border style="margin-bottom: 20px">
@@ -45,13 +51,13 @@
<!-- 每日流量记录表格 -->
<ElTable :data="filteredRecords" border stripe max-height="400" v-loading="loading">
<ElTableColumn prop="date" label="日期" width="120" align="center" />
<ElTableColumn label="当日使用流量" width="150" align="center">
<ElTableColumn prop="date" label="日期" width="120" />
<ElTableColumn label="当日使用流量" width="150">
<template #default="{ row }">
{{ formatDataSize(row.daily_usage_mb) }}
</template>
</ElTableColumn>
<ElTableColumn label="累计流量" width="150" align="center">
<ElTableColumn label="累计流量" width="150">
<template #default="{ row }">
{{ formatDataSize(row.cumulative_usage_mb) }}
</template>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="往期订单" width="60%" destroy-on-close>
<ElDialog
v-model="visible"
title="往期订单"
width="60%"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="loading" style="padding: 40px 0; text-align: center">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
</div>
@@ -132,8 +138,7 @@
ElPagination,
ElAlert,
ElEmpty,
ElIcon,
ElMessage
ElIcon
} from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { AssetService } from '@/api/modules'

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="套餐充值" width="30%" @closed="handleDialogClosed">
<ElDialog
v-model="visible"
title="套餐充值"
width="40%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="资产信息">
<span style="font-weight: bold; color: #409eff">
@@ -22,7 +28,7 @@
<template v-if="!props.seriesId">
<ElOption value="__NO_PACKAGE__" disabled>
<span style="color: var(--el-text-color-warning)">
该设备未关联套餐系列,无法购买套餐
{{ noSeriesMessage }}
</span>
</ElOption>
</template>
@@ -45,7 +51,7 @@
</ElSelect>
<template v-if="noSeriesId">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-warning)">
该设备未关联套餐系列,无法购买套餐
{{ noSeriesMessage }}
</div>
</template>
<template v-else-if="packagesEmptyWithSeriesId">
@@ -126,6 +132,7 @@
import { useUserStore } from '@/store/modules/user'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { normalizeApiError } from '@/utils/business/apiError'
interface Props {
modelValue: boolean
@@ -181,6 +188,9 @@
})
const noSeriesId = computed(() => !props.seriesId)
const noSeriesMessage = computed(
() => `该${props.assetType === 'card' ? '卡' : '设备'}未关联套餐系列,无法购买套餐`
)
const selectedPackageIsGift = computed(() => {
if (!form.package_id) return false
@@ -336,7 +346,11 @@
data.payment_voucher_key = toVoucherKeyList(form.payment_voucher_key)
}
await OrderService.createOrder(data)
const response = await OrderService.createOrder(data)
if (response.code !== 0) {
ElMessage.error(response.msg || '订单创建失败')
return
}
if (form.payment_method === 'wallet') {
ElMessage.success('订单创建成功,已自动完成支付')
@@ -352,6 +366,7 @@
return
}
console.error('创建订单失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}

View File

@@ -1,62 +1,86 @@
<template>
<ElDialog v-model="visible" title="设置限速" width="500px">
<ElDialog
v-model="visible"
title="设置限速"
width="500px"
:lock-scroll="true"
:z-index="4000"
modal-class="asset-information-dialog speed-limit-modal"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="下行速率" prop="download_speed">
<ElInputNumber
v-model="form.download_speed"
:min="1"
:step="128"
controls-position="right"
<ElFormItem label="固定档位" prop="code">
<ElSelect
v-model="form.code"
placeholder="请选择限速档位"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
</ElFormItem>
<ElFormItem label="上行速率" prop="upload_speed">
<ElInputNumber
v-model="form.upload_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
:teleported="true"
append-to="body"
popper-class="speed-limit-select-popper"
:popper-style="{ zIndex: 4001 }"
>
<ElOption
v-for="tier in speedTiers"
:key="tier.code"
:label="tier.label"
:value="tier.code"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认设置 </ElButton>
<ElButton
type="primary"
@click="handleConfirm"
:loading="confirmLoading"
:disabled="confirmLoading"
>
确认设置
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElInputNumber, ElButton } from 'element-plus'
import { ref, reactive, computed, onUnmounted, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
confirmLoading?: boolean
}
type SpeedTierCode = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
(e: 'confirm', data: { code: SpeedTierCode }): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
confirmLoading: false
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const speedTiers = [
{ code: -1, label: '不限速' },
{ code: 0, label: '0kbps' },
{ code: 1, label: '128Kbps' },
{ code: 2, label: '512Kbps' },
{ code: 3, label: '1Mbps' },
{ code: 4, label: '2Mbps' },
{ code: 5, label: '10Mbps' },
{ code: 6, label: '20Mbps' },
{ code: 7, label: '50Mbps' },
{ code: 8, label: '100Mbps' }
] as const
const form = reactive({
download_speed: 1024,
upload_speed: 512
})
const form = reactive<{ code: SpeedTierCode | undefined }>({ code: undefined })
const rules: FormRules = {
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
code: [{ required: true, message: '请选择限速档位', trigger: 'change' }]
}
const visible = computed({
@@ -65,11 +89,25 @@
})
// 监听对话框打开,重置表单
watch(visible, (newVal) => {
if (newVal) {
form.download_speed = 1024
form.upload_speed = 512
}
const setLayoutScrollLock = (locked: boolean) => {
document
.querySelector<HTMLElement>('.layouts')
?.classList.toggle('speed-limit-scroll-locked', locked)
}
watch(
visible,
(newVal) => {
setLayoutScrollLock(newVal)
if (newVal) {
form.code = undefined
}
},
{ immediate: true }
)
onUnmounted(() => {
setLayoutScrollLock(false)
})
const handleCancel = () => {
@@ -77,13 +115,14 @@
}
const handleConfirm = async () => {
if (props.confirmLoading) return
if (!formRef.value) return
try {
await formRef.value.validate()
if (form.code === undefined) return
emit('confirm', {
download_speed: form.download_speed,
upload_speed: form.upload_speed
code: form.code
})
} catch (error) {
console.error('表单验证失败:', error)
@@ -91,4 +130,28 @@
}
</script>
<style lang="scss" scoped></style>
<style lang="scss">
.speed-limit-modal .el-dialog__body {
overflow: visible;
}
.speed-limit-select-popper {
z-index: 4001 !important;
overscroll-behavior: contain;
}
.speed-limit-select-popper .el-select-dropdown__wrap,
.speed-limit-select-popper .el-scrollbar__wrap {
max-height: none !important;
overflow-y: visible !important;
}
.speed-limit-modal {
overflow: hidden;
overscroll-behavior: contain;
}
.layouts.speed-limit-scroll-locked {
overflow: hidden !important;
}
</style>

View File

@@ -1,6 +1,11 @@
<template>
<ElDialog v-model="visible" title="设置切卡模式" width="500px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="96px">
<ElDialog
v-model="visible"
title="设置切卡模式"
width="45%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="设备标识">
<span class="device-identifier">{{ deviceIdentifier || '-' }}</span>
</ElFormItem>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="设置WiFi" width="30%" :append-to-body="true">
<ElDialog
v-model="visible"
title="设置WiFi"
width="40%"
modal-class="asset-information-dialog"
:append-to-body="true"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" autocomplete="off">
<ElFormItem label="WiFi状态" prop="enabled">
<ElRadioGroup v-model="form.enabled">

View File

@@ -6,7 +6,7 @@
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { AssetService } from '@/api/modules'
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
import { FrontendRateLimitError } from '@/utils/business/apiRateLimit'
import type {
AssetBoundCard,
AssetWalletResponse,
@@ -123,6 +123,8 @@ export function useAssetInfo() {
updated_at: data.updated_at,
accumulated_recharge: data.accumulated_recharge,
first_commission_paid: data.first_commission_paid,
polling: data.polling ?? null,
exchange_trace: data.exchange_trace ?? null,
// 卡专属字段 (asset_type === 'card')
iccid: data.iccid || '',
@@ -138,6 +140,11 @@ export function useAssetInfo() {
gateway_extend: data.gateway_extend ?? '',
total_virtual_used_mb: data.total_virtual_used_mb ?? null,
total_virtual_remaining_mb: data.total_virtual_remaining_mb ?? null,
estimated_final_expires_at: data.estimated_final_expires_at ?? null,
days_until_final_expiry: data.days_until_final_expiry ?? null,
expiry_estimate_status: data.expiry_estimate_status,
expiry_estimate_status_name: data.expiry_estimate_status_name ?? null,
is_expiring: data.is_expiring ?? false,
bound_device_id: data.bound_device_id,
bound_device_no: data.bound_device_no || '',
bound_device_name: data.bound_device_name || '',
@@ -430,11 +437,11 @@ export function useAssetInfo() {
return true
} catch (error: any) {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
ElMessage.warning('操作过于频繁,请稍后重试')
return false
}
if (error?.response?.status === 429) {
ElMessage.warning('操作过于频繁,请5分钟后再试')
ElMessage.warning('操作过于频繁,请稍后重试')
return false
}
return false

View File

@@ -11,11 +11,11 @@ import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/ap
export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Promise<void>) {
// Loading states
const enableCardLoading = ref(false)
const startFailureMessage = ref('')
const disableCardLoading = ref(false)
const manualDeactivateCardLoading = ref(false)
const rebootDeviceLoading = ref(false)
const resetDeviceLoading = ref(false)
const speedLimitLoading = ref(false)
const switchCardLoading = ref(false)
const setWiFiLoading = ref(false)
const pollingLoading = ref(false)
@@ -29,22 +29,33 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
type: 'warning'
})
startFailureMessage.value = ''
enableCardLoading.value = true
const res = await AssetService.startAsset(cardInfo.value.iccid)
const res = await AssetService.startAsset(cardInfo.value.iccid, {
requestOptions: { errorMessageMode: 'none' }
})
if (res.code === 0) {
ElMessage.success('启用成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
}
startFailureMessage.value = res.msg || '复机失败,请稍后重试'
return false
} catch (error: any) {
if (error !== 'cancel') {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
return false
}
console.error('启用失败:', error)
const backendMessage = error?.response?.data?.msg
startFailureMessage.value =
typeof backendMessage === 'string' && backendMessage.trim()
? backendMessage
: '复机失败,请稍后重试'
}
return false
} finally {
enableCardLoading.value = false
}
@@ -169,35 +180,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
}
}
/**
* Set device speed limit
*/
const setSpeedLimit = async (form: { download_speed: number; upload_speed: number }) => {
try {
speedLimitLoading.value = true
const res = await DeviceService.setSpeedLimit(cardInfo.value.imei, {
download_speed: form.download_speed,
upload_speed: form.upload_speed
})
if (res.code === 0) {
ElMessage.success('限速设置成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
} else {
ElMessage.error(res.msg || '设置失败')
return false
}
} catch (error: any) {
console.error('设置限速失败:', error)
console.log(error?.message || '设置失败')
return false
} finally {
speedLimitLoading.value = false
}
}
/**
* Switch to different card
*/
@@ -303,11 +285,11 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
return {
// Loading states
enableCardLoading,
startFailureMessage,
disableCardLoading,
manualDeactivateCardLoading,
rebootDeviceLoading,
resetDeviceLoading,
speedLimitLoading,
switchCardLoading,
setWiFiLoading,
pollingLoading,
@@ -320,7 +302,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
// Device operations
rebootDevice,
resetDevice,
setSpeedLimit,
switchCard,
setWiFi,

View File

@@ -1,11 +1,10 @@
<template>
<div class="single-card-page">
<div class="single-card-page asset-information-page">
<!-- 资产查询区域 -->
<AssetSearchCard
ref="assetSearchCardRef"
:has-card-info="!!cardInfo"
:refresh-disabled="refreshDisabled"
:refresh-remaining-text="refreshRemainingText"
@search="handleSearch"
@refresh="handleRefresh"
/>
@@ -13,51 +12,55 @@
<!-- 卡片内容区域 -->
<div v-if="cardInfo" class="card-content-area slide-in">
<div class="main-content-layout">
<!-- 第一行:基本信息 -->
<div class="row full-width">
<BasicInfoCard
:card-info="cardInfo"
:device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard"
@disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate"
@reboot-device="handleRebootDevice"
@reset-device="handleResetDevice"
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
/>
</div>
<!-- 第二行:当前生效套餐 -->
<div class="row full-width">
<CurrentPackageCard
:current-package="currentPackage"
:loading="currentPackageLoading"
:error-msg="currentPackageErrorMsg"
:bound-device-id="cardInfo.bound_device_id"
:bound-device-no="cardInfo.bound_device_no"
:bound-device-name="cardInfo.bound_device_name"
@show-recharge="showRechargeDialog"
@navigate-to-device="handleNavigateToDevice"
/>
<div class="overview-grid">
<!-- 第一层:资产概览与当前生效套餐,按信息高度纵向展开 -->
<div class="row overview-primary">
<BasicInfoCard
:card-info="cardInfo"
:device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:start-error-message="startFailureMessage"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard"
@disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate"
@reboot-device="handleRebootDevice"
@reset-device="handleResetDevice"
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus"
@navigate-to-asset="handleNavigateToAsset"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
>
<template #current-package>
<CurrentPackageCard
:current-package="currentPackage"
:loading="currentPackageLoading"
:error-msg="currentPackageErrorMsg"
:bound-device-id="cardInfo.bound_device_id"
:bound-device-no="cardInfo.bound_device_no"
:bound-device-name="cardInfo.bound_device_name"
@show-recharge="showRechargeDialog"
@navigate-to-device="handleNavigateToDevice"
/>
</template>
</BasicInfoCard>
</div>
</div>
<!-- 第三行:套餐列表 -->
@@ -94,15 +97,6 @@
@navigate-to-device="handleNavigateToDevice"
/>
</div>
<!-- 第五行:操作审计日志 -->
<div class="row full-width">
<OperationLogsCard
:asset-identifier="cardInfo.identifier"
:asset-type="cardInfo.asset_type"
download-permission="asset_info:download_log_file"
/>
</div>
</div>
</div>
@@ -172,6 +166,11 @@
:current-realname-status="bindingCardRealnameStatusValue"
@success="handleBindingCardRealnameStatusSuccess"
/>
<SpeedLimitDialog
v-model="speedLimitDialogVisible"
:confirm-loading="speedTierSubmitting"
@confirm="handleSpeedTierConfirm"
/>
</div>
</template>
@@ -180,7 +179,10 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
import { RoutesAlias } from '@/router/routesAlias'
import { DeviceService } from '@/api/modules'
import { CardService, DeviceService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
import { normalizeApiError } from '@/utils/business/apiError'
import {
formatRemainingTime,
FrontendRateLimitError,
@@ -194,7 +196,6 @@
import CurrentPackageCard from './components/CurrentPackageCard.vue'
import PackageListCard from './components/PackageListCard.vue'
import WalletTransactionCard from './components/WalletTransactionCard.vue'
import OperationLogsCard from './components/OperationLogsCard.vue'
// 引入对话框组件
import {
@@ -202,7 +203,8 @@
WiFiConfigDialog,
PackageRechargeDialog,
DailyRecordsDialog,
OrderHistoryDialog
OrderHistoryDialog,
SpeedLimitDialog
} from './components/dialogs'
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
@@ -231,6 +233,7 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
// ========== 组件引用 ==========
const assetSearchCardRef = ref<InstanceType<typeof AssetSearchCard>>()
@@ -258,6 +261,7 @@
// ========== 资产操作方法(使用 composable) ==========
const {
enableCardLoading,
startFailureMessage,
disableCardLoading,
enableCard: enableCardOp,
disableCard: disableCardOp,
@@ -288,6 +292,8 @@
// 绑定卡实名状态更新
const bindingCardRealnameStatusDialogVisible = ref(false)
const speedLimitDialogVisible = ref(false)
const speedTierSubmitting = ref(false)
const bindingCardRealnameStatusIccid = ref('')
const bindingCardRealnameStatusValue = ref<number>(0)
@@ -332,26 +338,7 @@
const refreshSubmitting = ref(false)
let rateLimitTimer: ReturnType<typeof setInterval> | undefined
const refreshRateLimitState = computed(() => {
const identifier = cardInfo.value?.identifier
if (!identifier) {
return {
limited: false,
remainingMs: 0,
retryAt: 0
}
}
return getAssetActionRateLimitState('refresh', identifier, rateLimitNow.value)
})
const refreshDisabled = computed(
() => refreshSubmitting.value || refreshRateLimitState.value.limited
)
const refreshRemainingText = computed(() =>
refreshRateLimitState.value.limited
? formatRemainingTime(refreshRateLimitState.value.remainingMs)
: ''
)
const refreshDisabled = computed(() => refreshSubmitting.value)
const startRateLimitState = computed(() => {
const identifier = cardInfo.value?.iccid || cardInfo.value?.identifier
@@ -418,6 +405,7 @@
const handleSearch = async ({ identifier }: { identifier: string }) => {
// 更新搜索框的值
assetSearchCardRef.value?.updateSearchValue(identifier)
startFailureMessage.value = ''
// fetchAssetDetail 内部已经会并行加载所有相关数据,不需要重复调用
await fetchAssetDetail(identifier)
@@ -465,7 +453,10 @@
const handleEnableCard = async () => {
if (startDisabled.value) return
try {
await enableCardOp()
const started = await enableCardOp()
if (started && cardInfo.value) {
await fetchAssetDetail(cardInfo.value.identifier)
}
} finally {
updateRateLimitNow()
}
@@ -749,6 +740,13 @@
await handleSearch({ identifier: iccid })
}
/**
* 跳转到任意资产信息
*/
const handleNavigateToAsset = async (identifier: string) => {
await handleSearch({ identifier })
}
/**
* 跳转到绑定设备信息
*/
@@ -807,6 +805,42 @@
handleSearch({ identifier })
}
}
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
if (!hasAuth(JULY_PERMISSIONS.speedTier.update)) {
ElMessage.error('暂无设置限速权限')
return
}
const iccid = cardInfo.value?.iccid
if (!iccid) {
ElMessage.error('当前卡缺少 ICCID,无法设置限速')
return
}
if (speedTierSubmitting.value) return
speedTierSubmitting.value = true
try {
const response = await CardService.setSpeedTier(iccid, data.code)
if (response.code !== 0) {
speedLimitDialogVisible.value = false
await nextTick()
ElMessage.error(response.msg || '限速设置失败')
return
}
speedLimitDialogVisible.value = false
await nextTick()
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
await handleRefresh()
} catch (error) {
console.error('设置限速失败:', error)
speedLimitDialogVisible.value = false
await nextTick()
ElMessage.error(normalizeApiError(error).message)
} finally {
speedTierSubmitting.value = false
}
}
</script>
<style lang="scss" scoped>
@@ -956,6 +990,12 @@
}
// 主内容布局
.overview-grid {
display: flex;
flex-direction: column;
gap: 24px;
}
.main-content-layout {
display: flex;
flex-direction: column;
@@ -1024,6 +1064,10 @@
}
@media (width <= 768px) {
.overview-grid {
gap: 16px;
}
gap: 16px;
.row {
@@ -1603,3 +1647,118 @@
}
}
</style>
<style lang="scss" scoped>
.asset-information-page {
background: #ffffff;
}
</style>
<style lang="scss">
/* Dialogs are teleported to body, so their surface needs a page-level hook. */
.asset-information-dialog.el-dialog {
max-width: calc(100vw - 32px);
overflow: hidden;
border: 1px solid #e5eaf1;
border-radius: 14px;
box-shadow: 0 18px 50px rgb(15 23 42 / 18%);
.el-dialog__header {
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid #e5eaf1;
}
.el-dialog__title {
font-size: 16px;
font-weight: 650;
color: #172033;
letter-spacing: -0.01em;
}
.el-dialog__body {
padding: 22px 24px 20px;
}
.el-dialog__footer {
padding: 14px 24px 20px;
border-top: 1px solid #eef2f7;
}
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
color: #526078;
}
.el-button {
min-height: 34px;
border-radius: 8px;
}
.el-table {
--el-table-header-bg-color: #f8fafc;
--el-table-row-hover-bg-color: #f5f8ff;
--el-table-border-color: #e5eaf1;
border-radius: 9px;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #526078;
background: #f8fafc;
}
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
@media (width <= 768px) {
.asset-information-dialog.el-dialog {
width: calc(100vw - 20px) !important;
.el-dialog__header {
padding: 16px 18px 13px;
}
.el-dialog__body {
max-height: calc(100vh - 150px);
padding: 18px;
overflow-y: auto;
}
.el-dialog__footer {
padding: 12px 18px 16px;
}
}
}
html.dark .asset-information-dialog.el-dialog {
border-color: #2b3a52;
.el-dialog__header,
.el-dialog__footer {
border-color: #2b3a52;
}
.el-dialog__title,
.el-form-item__label {
color: #e5edf8;
}
.el-table {
--el-table-header-bg-color: #202d42;
--el-table-row-hover-bg-color: #223451;
--el-table-border-color: #2b3a52;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #afbdd0;
background: #202d42;
}
}
</style>

View File

@@ -2,6 +2,8 @@
* 资产信息页面类型定义
*/
import type { AssetExchangeTrace, AssetPollingStatus, ExpiryEstimateStatus } from '@/types/api'
// 资产类型枚举
export type AssetType = 'card' | 'device'
@@ -33,10 +35,17 @@ export interface AssetInfo {
gateway_extend?: string
total_virtual_used_mb?: number | null
total_virtual_remaining_mb?: number | null
estimated_final_expires_at?: string | null
days_until_final_expiry?: number | null
expiry_estimate_status?: ExpiryEstimateStatus
expiry_estimate_status_name?: string | null
is_expiring?: boolean
last_sync_time?: string | null
last_data_check_at?: string | null
last_real_name_check_at?: string | null
last_card_status_check_at?: string | null
polling?: AssetPollingStatus | null
exchange_trace?: AssetExchangeTrace | null
}
// 设备绑定卡信息

View File

@@ -40,6 +40,14 @@
>
批量设置套餐系列
</ElButton>
<ElButton
type="primary"
:disabled="!selectedDevices.length"
@click="showBatchRealnamePolicyDialog"
v-permission="'device:realname_policy'"
>
批量修改实名顺序
</ElButton>
<ElButton
type="primary"
:disabled="!selectedDevices.length"
@@ -85,17 +93,17 @@
</ArtTable>
<!-- 批量分配对话框 -->
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="600px">
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="40%">
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
label-width="90px"
>
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span> 台
</ElFormItem>
<ElFormItem label="店铺" prop="target_shop_id">
<ElFormItem label="目标店铺" prop="target_shop_id">
<ElCascader
v-model="allocateForm.target_shop_id"
:options="shopCascadeOptions"
@@ -158,8 +166,8 @@
</ElDialog>
<!-- 批量回收对话框 -->
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px">
<ElForm ref="recallFormRef" :model="recallForm" label-width="120px">
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="40%">
<ElForm ref="recallFormRef" :model="recallForm" label-width="90px">
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #e6a23c">{{ selectedDevices.length }}</span> 台
</ElFormItem>
@@ -217,14 +225,14 @@
<ElDialog
v-model="seriesBindingDialogVisible"
title="批量设置设备套餐系列绑定"
width="35%"
width="40%"
@close="handleCloseSeriesBindingDialog"
>
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span> 台
@@ -389,19 +397,19 @@
>
<ElDatePicker
v-model="seriesBindingForm.created_at_range"
type="daterange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
value-format="YYYY-MM-DDTHH:mm:ssZ"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="目标套餐系列" prop="series_id">
<ElFormItem label="套餐系列" prop="series_id">
<ElSelect
v-model="seriesBindingForm.series_id"
placeholder="请选择或搜索套餐系列"
placeholder="请选择或搜索目标套餐系列"
style="width: 100%"
filterable
remote
@@ -685,24 +693,27 @@
:current-policy="currentRealnamePolicy"
@confirm="handleConfirmRealnamePolicy"
/>
<!-- 操作审计日志弹窗 -->
<OperationLogsDialog
v-model="operationLogsDialogVisible"
:identifier="operationLogsIdentifier"
download-permission="device:download_log_file"
<BatchRealnamePolicyDialog
v-model="batchRealnamePolicyDialogVisible"
:selected-count="selectedDevices.length"
asset-unit="台设备"
:loading="batchRealnamePolicyLoading"
:error-message="batchRealnamePolicyError"
device-policy-hint
@confirm="handleConfirmBatchRealnamePolicy"
/>
<ElDialog
v-model="enterpriseDeviceAuthorizeDialogVisible"
title="授权设备给企业"
width="620px"
width="40%"
@close="handleEnterpriseDeviceAuthorizeDialogClose"
>
<ElForm
ref="enterpriseDeviceAuthorizeFormRef"
:model="enterpriseDeviceAuthorizeForm"
:rules="enterpriseDeviceAuthorizeRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -801,14 +812,14 @@
<ElDialog
v-model="enterpriseDeviceRecallDialogVisible"
title="撤销设备授权"
width="620px"
width="40%"
@close="handleEnterpriseDeviceRecallDialogClose"
>
<ElForm
ref="enterpriseDeviceRecallFormRef"
:model="enterpriseDeviceRecallForm"
:rules="enterpriseDeviceRecallRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -952,6 +963,10 @@
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { useUserStore } from '@/store/modules/user'
import {
DeviceService,
ShopService,
@@ -978,6 +993,11 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import {
getExpiryEstimateText,
getExpiryEstimateTooltip,
getExpiryEstimateClass
} from '@/utils/business/expiryEstimate'
import type { PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type {
@@ -985,8 +1005,9 @@
EnterpriseRecallDevicesResponse
} from '@/types/api/enterpriseDevice'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
import BatchRealnamePolicyDialog from '@/components/business/BatchRealnamePolicyDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import type { AssetRealnamePolicy } from '@/types/api'
defineOptions({ name: 'DeviceList' })
@@ -1003,6 +1024,7 @@
const { hasAuth } = useAuth()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const allocateLoading = ref(false)
@@ -1013,8 +1035,9 @@
const allocateDialogVisible = ref(false)
const recallDialogVisible = ref(false)
const selectedDevices = ref<Device[]>([])
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const batchRealnamePolicyDialogVisible = ref(false)
const batchRealnamePolicyLoading = ref(false)
const batchRealnamePolicyError = ref('')
const exportDialogVisible = ref(false)
const shopCascadeOptions = ref<any[]>([])
const shopCascadeProps = {
@@ -1315,6 +1338,7 @@
device_name: '',
status: undefined as DeviceStatus | undefined,
activation_status: undefined as number | undefined,
real_name_status: undefined as 0 | 1 | undefined,
batch_no: '',
device_type: '',
manufacturer: '',
@@ -1425,6 +1449,19 @@
{ label: '已激活', value: 1 }
]
},
{
label: '实名状态',
prop: 'real_name_status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已实名', value: 1 },
{ label: '未实名', value: 0 }
]
},
{
label: '批次号',
prop: 'batch_no',
@@ -1500,13 +1537,13 @@
{
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -1570,6 +1607,7 @@
{ label: '设备名称', prop: 'device_name' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '套餐系列', prop: 'series_name' },
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
{ label: '设备型号', prop: 'device_model' },
{ label: '设备类型', prop: 'device_type' },
{ label: '状态', prop: 'status' },
@@ -1577,6 +1615,7 @@
{ label: '资产世代', prop: 'generation' },
{ label: '业务状态', prop: 'asset_status' },
{ label: '激活状态', prop: 'activation_status_name' },
{ label: '实名状态', prop: 'real_name_status_name' },
{ label: '在线状态', prop: 'online_status' },
{ label: '实名策略', prop: 'realname_policy' },
{ label: '切卡模式', prop: 'switch_mode' },
@@ -1853,6 +1892,20 @@
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'estimated_final_expires_at',
label: '预计套餐到期时间',
width: 180,
formatter: (row: Device) =>
h(
'span',
{
class: getExpiryEstimateClass(row),
title: getExpiryEstimateTooltip(row)
},
getExpiryEstimateText(row)
)
},
{
prop: 'device_model',
label: '设备型号',
@@ -1912,6 +1965,12 @@
width: 120,
formatter: (row: Device) => row.activation_status_name || '-'
},
{
prop: 'real_name_status_name',
label: '实名状态',
width: 100,
formatter: (row: Device) => row.real_name_status_name || '-'
},
{
prop: 'online_status',
label: '在线状态',
@@ -2104,6 +2163,7 @@
device_name: searchForm.device_name || undefined,
status: searchForm.status,
activation_status: searchForm.activation_status ?? undefined,
real_name_status: searchForm.real_name_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
@@ -2134,6 +2194,7 @@
device_name: searchForm.device_name || undefined,
status: searchForm.status,
activation_status: searchForm.activation_status ?? undefined,
real_name_status: searchForm.real_name_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
@@ -2201,6 +2262,51 @@
selectedDevices.value = selection
}
const showBatchRealnamePolicyDialog = () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要修改的设备')
return
}
batchRealnamePolicyError.value = ''
batchRealnamePolicyDialogVisible.value = true
}
const handleConfirmBatchRealnamePolicy = async (realnamePolicy: AssetRealnamePolicy) => {
const assetIds = selectedDevices.value.map((device) => device.id)
if (assetIds.length === 0) {
batchRealnamePolicyError.value = '请先选择要修改的设备'
return
}
if (assetIds.length > 500) {
batchRealnamePolicyError.value = '单次最多可修改500台设备'
return
}
batchRealnamePolicyError.value = ''
batchRealnamePolicyLoading.value = true
try {
const res = await DeviceService.batchUpdateRealnamePolicy(
{ asset_ids: assetIds, realname_policy: realnamePolicy },
{ requestOptions: { errorMessageMode: 'none' } }
)
if (res.code !== 0) {
batchRealnamePolicyError.value = res.msg || '批量修改实名顺序失败'
return
}
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedDevices.value = []
await getTableData()
} catch (error: any) {
batchRealnamePolicyError.value = error?.response?.data?.msg || '批量修改实名顺序失败'
} finally {
batchRealnamePolicyLoading.value = false
}
}
// 删除设备
const deleteDevice = (row: Device) => {
ElMessageBox.confirm(
@@ -2263,7 +2369,7 @@
})
}
// 批量分配
// 批量分配(设备列表原有操作,和设备批量任务页面并行保留)
const handleBatchAllocate = async () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要分配的设备')
@@ -2326,7 +2432,7 @@
}
}
// 批量回收
// 批量回收(设备列表原有操作,和设备批量任务页面并行保留)
const handleBatchRecall = () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要回收的设备')
@@ -2526,22 +2632,6 @@
})
}
// 批量设置套餐系列
const handleBatchSetSeries = async () => {
seriesBindingResult.value = null
Object.assign(seriesBindingForm, createInitialSeriesBindingState(), {
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
})
await Promise.all([
loadPackageSeriesList(),
loadSearchBatchNoOptions(),
loadSeriesBindingShopCascadeOptions()
])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}
// 加载套餐系列列表(支持名称搜索,默认20条)
const loadPackageSeriesList = async (seriesName?: string) => {
seriesLoading.value = true
@@ -2570,22 +2660,16 @@
await loadPackageSeriesList(query || undefined)
}
const loadSeriesBindingShopCascadeOptions = async () => {
try {
const res = await ShopService.getShopsCascade({
parent_id: undefined,
exclude_self: true
})
if (res.code === 0) {
seriesBindingShopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
}
} catch (error) {
console.error('加载批量设置套餐系列根级店铺失败:', error)
}
// 显示批量设置套餐系列对话框
const handleBatchSetSeries = async () => {
seriesBindingResult.value = null
Object.assign(seriesBindingForm, createInitialSeriesBindingState(), {
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
})
await Promise.all([loadPackageSeriesList(), loadSearchBatchNoOptions()])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}
const buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => {
@@ -3074,17 +3158,27 @@
})
}
if (hasAuth('device:operation_logs')) {
moreActions.push({
label: '操作审计日志',
handler: () => {
operationLogsIdentifier.value = row.virtual_no
operationLogsDialogVisible.value = true
},
type: 'primary'
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3
? AUDIT_PERMISSIONS.agentDeviceActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseDeviceActivity
: AUDIT_PERMISSIONS.deviceEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({
userType,
resourceType: 'device',
internalId: row.id,
businessIdentifier: row.virtual_no
})
if (auditTarget)
moreActions.push({
label: userType >= 3 ? '活动记录' : '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
// 如果有更多操作,添加到 actions 中
if (moreActions.length > 0) {
actions.push(...moreActions)
@@ -3112,4 +3206,22 @@
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.expiry-estimate--critical,
.expiry-estimate--warning,
.expiry-estimate--notice {
font-weight: 500;
}
.expiry-estimate--critical {
color: var(--el-color-danger);
}
.expiry-estimate--warning {
color: #8e44ad;
}
.expiry-estimate--notice {
color: #e83e8c;
}
</style>

View File

@@ -45,6 +45,12 @@
const exchangeDetail = ref<ExchangeResponse | null>(null)
const exchangeId = ref<number>(0)
const formatExchangeShopName = (shopName?: string | null, shopId?: number | null) => {
if (shopName) return shopName
if (shopId === null || shopId === 0) return '平台'
return '--'
}
const getStatusType = (status: number): TagProps['type'] => {
const types: Record<number, TagProps['type']> = {
1: 'warning',
@@ -77,6 +83,11 @@
formatter: (_, data) =>
data.flow_type_name || (flowType === 'direct' ? '直接换货' : '物流换货')
},
{
label: '提交人',
formatter: (value) => value || '--',
prop: 'submitter_name'
},
{
label: '换货原因',
prop: 'exchange_reason',
@@ -96,6 +107,11 @@
label: '新资产标识符',
formatter: (value) => value || '--',
prop: 'new_asset_identifier'
},
{
label: '继承归属店铺',
formatter: (_, data) =>
formatExchangeShopName(data.inherited_shop_name, data.inherited_shop_id)
}
]
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
<template>
<ArtTableFullScreen>
<div class="expiring-assets-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="true"
label-width="100px"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<div class="page-intro">
临期资产 {{ summary.total_count }} 条(卡 {{ summary.card_count }},设备
{{ summary.device_count }}),0-3 天资产优先显示。
</div>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="asset_id"
:loading="loading"
:data="items"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import { AssetService, PackageManageService, ShopService } from '@/api/modules'
import type {
ExpiringAssetItem,
ExpiringAssetSummary,
ExpiringAssetType,
ExpiringAssetQueryParams,
PackageResponse,
ShopResponse
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { RoutesAlias } from '@/router/routesAlias'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'ExpiringAssets' })
const router = useRouter()
const route = useRoute()
const tableRef = ref()
const loading = ref(false)
const shopLoading = ref(false)
const packageLoading = ref(false)
const shopOptions = ref<ShopResponse[]>([])
const packageOptions = ref<PackageResponse[]>([])
const items = ref<ExpiringAssetItem[]>([])
const summary = reactive<ExpiringAssetSummary>({
card_count: 0,
device_count: 0,
total_count: 0,
window_days: 15
})
const searchForm = reactive<ExpiringAssetQueryParams & { expires_range: string[] }>({
asset_type: undefined,
keyword: '',
shop_id: undefined,
package_id: undefined,
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined,
expires_range: []
})
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '资产类型',
prop: 'asset_type',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: [
{ label: '物联网卡', value: 'iot_card' },
{ label: '设备', value: 'device' }
]
},
{
label: '关键词',
prop: 'keyword',
type: 'input',
config: { clearable: true, placeholder: '请输入资产标识' }
},
{
label: '店铺',
prop: 'shop_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
loading: shopLoading.value,
remoteMethod: (query: string) => searchShops(query),
placeholder: '请选择或搜索店铺'
},
options: () => shopOptions.value.map((shop) => ({ label: shop.shop_name, value: shop.id }))
},
{
label: '套餐',
prop: 'package_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
loading: packageLoading.value,
remoteMethod: (query: string) => searchPackages(query),
placeholder: '请选择或搜索套餐'
},
options: () =>
packageOptions.value.map((pkg) => ({
label: `${pkg.package_name} (${pkg.package_code})`,
value: pkg.id
}))
},
{
label: '最小剩余天数',
prop: 'days_min',
type: 'input',
config: {
clearable: true,
type: 'number',
min: 0,
max: 15,
placeholder: '最小天数'
}
},
{
label: '最大剩余天数',
prop: 'days_max',
type: 'input',
config: {
clearable: true,
type: 'number',
min: 0,
max: 15,
placeholder: '最大天数'
}
},
{
label: '预计到期起止',
prop: 'expires_range',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
clearable: true
}
}
])
const columnOptions = [
{ label: '资产类型', prop: 'asset_type' },
{ label: '资产标识', prop: 'identifier' },
{ label: '店铺', prop: 'shop_name' },
{ label: '当前套餐', prop: 'package_name' },
{ label: '预计最终到期', prop: 'estimated_final_expires_at' },
{ label: '剩余天数', prop: 'days_until_final_expiry' }
]
const getExpiryClass = (level?: string | null) => {
if (level === 'red') return 'expiry-critical'
if (level === 'purple') return 'expiry-warning'
if (level === 'pink') return 'expiry-notice'
return ''
}
const getExpiryTagType = (level?: string | null) => {
if (level === 'red') return 'danger'
if (level === 'purple') return 'warning'
return 'info'
}
const searchShops = async (query = '') => {
shopLoading.value = true
try {
const response = await ShopService.getShops({
page: 1,
page_size: 20,
shop_name: query || undefined
})
if (response.code === 0) shopOptions.value = response.data.items || []
} finally {
shopLoading.value = false
}
}
const searchPackages = async (query = '') => {
packageLoading.value = true
try {
const response = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query || undefined
})
if (response.code === 0) packageOptions.value = response.data.items || []
} finally {
packageLoading.value = false
}
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'iot_card' ? '物联网卡' : '设备')
},
{
prop: 'identifier',
label: '资产标识',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) =>
h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => goToAsset(row)
},
() => row.identifier || '-'
)
},
{
prop: 'shop_name',
label: '店铺',
minWidth: 140,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.shop_name || '-'
},
{
prop: 'package_name',
label: '当前套餐',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.package_name || '-'
},
{
prop: 'estimated_final_expires_at',
label: '预计最终到期',
width: 180,
formatter: (row: ExpiringAssetItem) =>
h(
'span',
{ class: getExpiryClass(row.expiry_level) },
formatDateTime(row.estimated_final_expires_at) || '-'
)
},
{
prop: 'days_until_final_expiry',
label: '剩余天数',
width: 100,
formatter: (row: ExpiringAssetItem) =>
h(ElTag, { type: getExpiryTagType(row.expiry_level), size: 'small' }, () =>
row.days_until_final_expiry == null ? '-' : `${row.days_until_final_expiry}天`
)
}
])
const loadAssets = async () => {
loading.value = true
try {
const toInteger = (value: unknown) => {
if (value === undefined || value === null || value === '') return undefined
const numberValue = Number(value)
return Number.isInteger(numberValue) ? numberValue : undefined
}
const params: ExpiringAssetQueryParams = {
page: pagination.currentPage,
page_size: pagination.pageSize
}
if (searchForm.asset_type) params.asset_type = searchForm.asset_type
const keyword = searchForm.keyword?.trim()
if (keyword) params.keyword = keyword
if (searchForm.shop_id !== undefined && searchForm.shop_id !== null) {
params.shop_id = searchForm.shop_id
}
if (searchForm.package_id !== undefined && searchForm.package_id !== null) {
params.package_id = searchForm.package_id
}
const daysMin = toInteger(searchForm.days_min)
const daysMax = toInteger(searchForm.days_max)
if (daysMin !== undefined) params.days_min = daysMin
if (daysMax !== undefined) params.days_max = daysMax
const [expiresFrom, expiresTo] = searchForm.expires_range || []
if (expiresFrom) params.expires_from = expiresFrom
if (expiresTo) params.expires_to = expiresTo
const response = await AssetService.getExpiringAssets(params)
if (response.code === 0 && response.data) {
items.value = response.data.items || []
pagination.total = response.data.total || 0
Object.assign(summary, response.data.summary)
} else {
ElMessage.error(response.msg || '获取临期资产失败')
}
} catch (error) {
console.error('获取临期资产失败:', error)
ElMessage.error('获取临期资产失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.currentPage = 1
void loadAssets()
}
const handleReset = () => {
Object.assign(searchForm, {
asset_type: undefined,
keyword: '',
shop_id: undefined,
package_id: undefined,
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined,
expires_range: []
})
pagination.currentPage = 1
void loadAssets()
}
const handleRefresh = () => void loadAssets()
const handleSizeChange = (value: number) => {
pagination.pageSize = value
void loadAssets()
}
const handleCurrentChange = (value: number) => {
pagination.currentPage = value
void loadAssets()
}
const goToAsset = (row: ExpiringAssetItem) => {
router.push({
path: RoutesAlias.AssetInformation,
query:
row.asset_type === 'iot_card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
})
}
onMounted(() => {
const assetType = route.query.asset_type
if (assetType === 'card' || assetType === 'iot_card' || assetType === 'device') {
searchForm.asset_type = assetType === 'card' ? 'iot_card' : (assetType as ExpiringAssetType)
}
void Promise.all([loadAssets(), searchShops(), searchPackages()])
})
</script>
<style scoped lang="scss">
.expiring-assets-page {
.page-intro {
color: var(--el-text-color-secondary);
}
.expiry-critical,
.expiry-warning,
.expiry-notice {
font-weight: 600;
}
.expiry-critical {
color: #f56c6c;
}
.expiry-warning {
color: #8e44ad;
}
.expiry-notice {
color: #e83e8c;
}
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportAgentRechargeTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportAgentWalletTransactionTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportExchangeTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportPackageTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportRefundTaskList' })
</script>

View File

@@ -14,31 +14,33 @@
<ElButton v-if="canDownloadTask" type="primary" @click="downloadTask">
下载文件
</ElButton>
<ElButton v-if="canCancelTask" type="danger" @click="cancelTask"> 取消任务 </ElButton>
</div>
</div>
<div v-if="loading" class="detail-loading">
<div v-if="polling.loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36">
<Loading />
</ElIcon>
<div>加载中...</div>
</div>
<DetailPage
v-else-if="taskDetail && canViewDetail"
v-if="taskDetail && canViewDetail"
:sections="detailSections"
:data="taskDetail"
/>
<ElEmpty v-else :description="forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'" />
<ElEmpty
v-else
:description="polling.forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'"
/>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { computed, h, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
@@ -48,15 +50,29 @@
import { formatDateTime } from '@/utils/business/format'
import type { ExportTaskDetail } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
defineOptions({ name: 'ExportTaskDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const forbidden = ref(false)
const taskDetail = ref<ExportTaskDetail | null>(null)
const polling = useAsyncTaskPolling<ExportTaskDetail>({
storageKey: `export-task-detail:${String(route.query.id || '')}`,
autoRestore: false,
fetchTask: async (taskId) => {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 403) {
const error = new Error('您没有查看该导出任务详情的权限') as Error & { status?: number }
error.status = 403
throw error
}
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败')
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
})
const taskDetail = polling.task
const taskPermissions = computed(
() => getExportTaskSceneConfig(taskDetail.value?.scene)?.permissions
@@ -87,16 +103,6 @@
return status ? statusTypeMap[status] : 'info'
}
const canCancelTask = computed(
() =>
canViewDetail.value &&
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
taskDetail.value?.status as ExportTaskStatus
) &&
!!taskPermissions.value &&
hasAuth(taskPermissions.value.cancel)
)
const detailSections = computed((): DetailSection[] => [
{
title: '任务基本信息',
@@ -132,11 +138,17 @@
prop: 'completed_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '更新时间',
prop: 'updated_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '错误信息',
prop: 'error_message',
prop: 'error_summary',
fullWidth: true,
formatter: (value: string) => value || '-'
formatter: (value: string, data: ExportTaskDetail) =>
data.error_summary || data.error_message || value || '-'
}
]
},
@@ -144,6 +156,22 @@
title: '处理进度',
fields: [
{ label: '进度', prop: 'progress', formatter: (value: number) => `${value ?? 0}%` },
{
label: '总数',
prop: 'total_count',
formatter: (value: number, data: ExportTaskDetail) =>
String(value ?? data.total_rows ?? 0)
},
{
label: '成功数',
prop: 'success_count',
formatter: (value: number) => String(value ?? 0)
},
{
label: '失败数',
prop: 'failed_count',
formatter: (value: number) => String(value ?? 0)
},
{ label: '总行数', prop: 'total_rows', formatter: (value: number) => String(value ?? 0) },
{
label: '已处理行数',
@@ -203,33 +231,16 @@
const getTaskId = () => Number(route.query.id)
const getTaskDetail = async () => {
const loadTaskDetail = async () => {
const taskId = getTaskId()
if (!taskId) {
ElMessage.error('缺少任务ID参数')
goBack()
return
}
loading.value = true
forbidden.value = false
try {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 0) {
taskDetail.value = res.data
if (!canViewDetail.value) {
forbidden.value = true
ElMessage.warning('您没有查看该导出任务详情的权限')
}
} else {
ElMessage.error(res.msg || '获取导出任务详情失败')
}
} catch (error) {
console.error('获取导出任务详情失败:', error)
ElMessage.error('获取导出任务详情失败')
} finally {
loading.value = false
}
await polling.start(taskId)
if (polling.error.value) ElMessage.error(polling.error.value)
if (polling.forbidden.value) ElMessage.warning('您没有查看该导出任务详情的权限')
}
const downloadTask = () => {
@@ -246,35 +257,8 @@
window.open(taskDetail.value.download_url, '_blank')
}
const cancelTask = () => {
if (!taskDetail.value) return
if (!canCancelTask.value) {
ElMessage.warning('您没有取消该导出任务的权限')
return
}
ElMessageBox.confirm(`确定取消导出任务 ${taskDetail.value.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(taskDetail.value!.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTaskDetail()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
} catch (error) {
console.error('取消导出任务失败:', error)
ElMessage.error('取消导出任务失败')
}
})
}
onMounted(() => {
getTaskDetail()
loadTaskDetail()
})
</script>

View File

@@ -25,7 +25,7 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
:actionsWidth="120"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -41,7 +41,7 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElProgress, ElTag } from 'element-plus'
import { ElMessage, ElProgress, ElTag } from 'element-plus'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -49,8 +49,9 @@
import { RoutesAlias } from '@/router/routesAlias'
import { getExportTaskSceneConfig } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type { ExportTaskItem, ExportTaskScene } from '@/types/api'
import type { ExportTaskDetail, ExportTaskItem, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
defineOptions({ name: 'ExportTaskList' })
@@ -99,15 +100,15 @@
options: () => statusOptions
},
{
label: '创建时间',
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -116,11 +117,15 @@
{ label: '任务编号', prop: 'task_no' },
{ label: '状态', prop: 'status_name' },
{ label: '进度', prop: 'progress' },
{ label: '总数', prop: 'total_count' },
{ label: '成功数', prop: 'success_count' },
{ label: '失败数', prop: 'failed_count' },
{ label: '格式', prop: 'format' },
{ label: '总行数', prop: 'total_rows' },
{ label: '已处理行数', prop: 'processed_rows' },
{ label: '分片', prop: 'total_shards' },
{ label: '错误信息', prop: 'error_message' },
{ label: '错误信息', prop: 'error_summary' },
{ label: '更新时间', prop: 'updated_at' },
{ label: '创建时间', prop: 'created_at' }
]
@@ -153,6 +158,24 @@
width: 150,
formatter: (row: ExportTaskItem) => h(ElProgress, { percentage: row.progress || 0 })
},
{
prop: 'total_count',
label: '总数',
width: 100,
formatter: (row: ExportTaskItem) => row.total_count ?? row.total_rows ?? 0
},
{
prop: 'success_count',
label: '成功数',
width: 100,
formatter: (row: ExportTaskItem) => row.success_count ?? 0
},
{
prop: 'failed_count',
label: '失败数',
width: 100,
formatter: (row: ExportTaskItem) => row.failed_count ?? 0
},
{ prop: 'format', label: '格式', width: 90 },
{ prop: 'total_rows', label: '总行数', width: 110 },
{ prop: 'processed_rows', label: '已处理行数', width: 120 },
@@ -162,12 +185,24 @@
width: 130,
formatter: (row: ExportTaskItem) => `${row.success_shards || 0}/${row.total_shards || 0}`
},
{ prop: 'error_message', label: '错误信息', minWidth: 180, showOverflowTooltip: true },
{
prop: 'error_summary',
label: '错误信息',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ExportTaskItem) => row.error_summary || row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.created_at) || '-'
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.updated_at) || '-'
}
])
@@ -196,6 +231,12 @@
}
}
const toRfc3339 = (date: string, endOfDay = false) => {
if (!date) return undefined
if (date.includes('T')) return date
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}+08:00`
}
const getTableData = async () => {
loading.value = true
try {
@@ -205,8 +246,8 @@
page_size: pagination.pageSize,
scene: currentScene.value,
status: searchForm.status,
start_time: searchForm.start_time || undefined,
end_time: searchForm.end_time || undefined
start_time: toRfc3339(searchForm.start_time),
end_time: toRfc3339(searchForm.end_time, true)
})
if (res.code === 0 && res.data) {
@@ -257,6 +298,25 @@
return res.data
}
const activeTaskPolling = useAsyncTaskPolling<ExportTaskDetail>({
storageKey: computed(() => `export-task-active:${currentScene.value}`).value,
fetchTask: async (taskId) => {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 403) {
const error = new Error('暂无权限查看导出任务') as Error & { status?: number }
error.status = 403
throw error
}
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败')
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
})
watch(activeTaskPolling.task, () => {
if (activeTaskPolling.task.value) getTableData()
})
const getRowPermissions = (row: ExportTaskItem) => {
return getExportTaskSceneConfig(row.scene)?.permissions || currentPermissions.value
}
@@ -269,7 +329,7 @@
router.push({
path: RoutesAlias.ExportTaskDetail,
query: { id: row.id }
query: { id: row.task_id ?? row.id }
})
}
@@ -280,7 +340,7 @@
}
try {
const detail = await getTaskDetail(row.id)
const detail = await getTaskDetail(row.task_id ?? row.id)
if (!detail.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
@@ -293,32 +353,6 @@
}
}
const cancelTask = (row: ExportTaskItem) => {
if (!hasAuth(getRowPermissions(row).cancel)) {
ElMessage.warning('您没有取消导出任务的权限')
return
}
ElMessageBox.confirm(`确定取消导出任务 ${row.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(row.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTableData()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
} catch (error) {
console.error('取消导出任务失败:', error)
ElMessage.error('取消导出任务失败')
}
})
}
const getActions = (row: ExportTaskItem) => {
const actions: any[] = []
const permissions = getRowPermissions(row)
@@ -327,13 +361,6 @@
actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' })
}
if (
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(row.status) &&
hasAuth(permissions.cancel)
) {
actions.push({ label: '取消', handler: () => cancelTask(row), type: 'danger' })
}
return actions
}

View File

@@ -42,6 +42,14 @@
>
批量设置套餐系列
</ElButton>
<ElButton
type="primary"
:disabled="selectedCards.length === 0"
@click="showBatchRealnamePolicyDialog"
v-permission="'iot_card:realname_policy'"
>
批量修改实名顺序
</ElButton>
<ElButton
type="primary"
:disabled="selectedCards.length === 0"
@@ -90,16 +98,16 @@
<ElDialog
v-model="allocateDialogVisible"
title="批量分配"
width="600px"
width="40%"
@close="handleAllocateDialogClose"
>
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="店铺" prop="to_shop_id">
<ElFormItem label="目标店铺" prop="to_shop_id">
<ElCascader
v-model="allocateForm.to_shop_id"
:options="shopCascadeOptions"
@@ -233,10 +241,10 @@
<ElDialog
v-model="recallDialogVisible"
title="批量回收"
width="600px"
width="40%"
@close="handleRecallDialogClose"
>
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="120px">
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="100px">
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="recallForm.selection_type">
<ElRadio label="list">ICCID列表</ElRadio>
@@ -377,14 +385,14 @@
<ElDialog
v-model="enterpriseCardAuthorizeDialogVisible"
title="授权卡给企业"
width="640px"
width="40%"
@close="handleEnterpriseCardAuthorizeDialogClose"
>
<ElForm
ref="enterpriseCardAuthorizeFormRef"
:model="enterpriseCardAuthorizeForm"
:rules="enterpriseCardAuthorizeRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -530,14 +538,14 @@
<ElDialog
v-model="enterpriseCardRecallDialogVisible"
title="回收卡授权"
width="640px"
width="40%"
@close="handleEnterpriseCardRecallDialogClose"
>
<ElForm
ref="enterpriseCardRecallFormRef"
:model="enterpriseCardRecallForm"
:rules="enterpriseCardRecallRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -976,6 +984,14 @@
:current-policy="currentRealnamePolicy"
@confirm="handleConfirmRealnamePolicy"
/>
<BatchRealnamePolicyDialog
v-model="batchRealnamePolicyDialogVisible"
:selected-count="selectedCards.length"
asset-unit="张卡"
:loading="batchRealnamePolicyLoading"
:error-message="batchRealnamePolicyError"
@confirm="handleConfirmBatchRealnamePolicy"
/>
<!-- 更新实名状态对话框 -->
<UpdateRealnameStatusDialog
@@ -985,13 +1001,6 @@
@success="handleUpdateRealnameStatusSuccess"
/>
<!-- 操作审计日志弹窗 -->
<OperationLogsDialog
v-model="operationLogsDialogVisible"
:identifier="operationLogsIdentifier"
download-permission="iot_card:download_log_file"
/>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="iot_card"
@@ -1008,6 +1017,9 @@
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import {
CardService,
ShopService,
@@ -1019,6 +1031,7 @@
import { ElMessage, ElTag, ElIcon, ElMessageBox } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import BatchRealnamePolicyDialog from '@/components/business/BatchRealnamePolicyDialog.vue'
import UpdateRealnameStatusDialog from '@/components/business/UpdateRealnameStatusDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import type { FormInstance, FormRules } from 'element-plus'
@@ -1028,6 +1041,11 @@
import { useUserStore } from '@/store/modules/user'
import { storeToRefs } from 'pinia'
import { formatDateTime } from '@/utils/business/format'
import {
getExpiryEstimateText,
getExpiryEstimateTooltip,
getExpiryEstimateClass
} from '@/utils/business/expiryEstimate'
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
import type {
StandaloneIotCard,
@@ -1039,10 +1057,9 @@
BatchSetCardSeriesBindingResponse
} from '@/types/api/card'
import { CardSelectionType } from '@/types/api/card'
import type { PackageSeriesResponse } from '@/types/api'
import type { AssetRealnamePolicy, PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type { AllocateCardsResponse, RecallCardsResponse } from '@/types/api/enterpriseCard'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
defineOptions({ name: 'StandaloneCardList' })
@@ -1062,6 +1079,9 @@
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>()
const selectedCards = ref<StandaloneIotCard[]>([])
const batchRealnamePolicyDialogVisible = ref(false)
const batchRealnamePolicyLoading = ref(false)
const batchRealnamePolicyError = ref('')
const allocationResult = ref<AllocateStandaloneCardsResponse>({
allocation_no: '',
total_count: 0,
@@ -1091,9 +1111,6 @@
recalled_devices: null
})
// 操作审计日志弹窗
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
// 套餐系列绑定相关
@@ -1536,6 +1553,7 @@
const initialSearchState: {
status: undefined | number
network_status: undefined | number
real_name_status: undefined | 0 | 1
iccid: string
carrier_name: string
msisdn: string
@@ -1552,6 +1570,7 @@
} = {
status: undefined,
network_status: undefined,
real_name_status: undefined,
iccid: '',
carrier_name: '',
msisdn: '',
@@ -1723,6 +1742,19 @@
{ label: '正常', value: 1 }
]
},
{
label: '实名状态',
prop: 'real_name_status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已实名', value: 1 },
{ label: '未实名', value: 0 }
]
},
{
label: '卡虚拟号',
prop: 'virtual_no',
@@ -1911,6 +1943,7 @@
{ label: '绑定设备虚拟号', prop: 'device_virtual_no' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '套餐系列', prop: 'series_name' },
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
{ label: '自然月累计流量(MB)', prop: 'current_month_usage_mb' },
{ label: '本月开始日期', prop: 'current_month_start_date' },
{ label: '上月流量总量(MB)', prop: 'last_month_total_mb' },
@@ -2082,6 +2115,20 @@
showOverflowTooltip: true,
formatter: (row: StandaloneIotCard) => row.series_name || '-'
},
{
prop: 'estimated_final_expires_at',
label: '预计套餐到期时间',
width: 180,
formatter: (row: StandaloneIotCard) =>
h(
'span',
{
class: getExpiryEstimateClass(row),
title: getExpiryEstimateTooltip(row)
},
getExpiryEstimateText(row)
)
},
{
prop: 'status',
label: '状态',
@@ -2136,11 +2183,7 @@
prop: 'real_name_status',
label: '实名状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.real_name_status === 1 ? 'success' : 'warning'
const text = row.real_name_status === 1 ? '已实名' : '未实名'
return h(ElTag, { type }, () => text)
}
formatter: (row: StandaloneIotCard) => row.real_name_status_name || '-'
},
{
prop: 'realname_policy',
@@ -2269,17 +2312,27 @@
})
}
if (hasAuth('iot_card:operation_logs')) {
actions.push({
label: '操作审计日志',
handler: () => {
operationLogsIdentifier.value = row.iccid
operationLogsDialogVisible.value = true
},
type: 'primary'
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3
? AUDIT_PERMISSIONS.agentCardActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseCardActivity
: AUDIT_PERMISSIONS.cardEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({
userType,
resourceType: 'iot_card',
internalId: row.id,
businessIdentifier: row.iccid
})
if (auditTarget)
actions.push({
label: userType >= 3 ? '活动记录' : '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
return actions
}
@@ -2364,6 +2417,51 @@
selectedCards.value = selection
}
const showBatchRealnamePolicyDialog = () => {
if (selectedCards.value.length === 0) {
ElMessage.warning('请先选择要修改的卡')
return
}
batchRealnamePolicyError.value = ''
batchRealnamePolicyDialogVisible.value = true
}
const handleConfirmBatchRealnamePolicy = async (realnamePolicy: AssetRealnamePolicy) => {
const assetIds = selectedCards.value.map((card) => card.id)
if (assetIds.length === 0) {
batchRealnamePolicyError.value = '请先选择要修改的卡'
return
}
if (assetIds.length > 500) {
batchRealnamePolicyError.value = '单次最多可修改500张卡'
return
}
batchRealnamePolicyError.value = ''
batchRealnamePolicyLoading.value = true
try {
const res = await CardService.batchUpdateRealnamePolicy(
{ asset_ids: assetIds, realname_policy: realnamePolicy },
{ requestOptions: { errorMessageMode: 'none' } }
)
if (res.code !== 0) {
batchRealnamePolicyError.value = res.msg || '批量修改实名顺序失败'
return
}
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedCards.value = []
await getTableData()
} catch (error: any) {
batchRealnamePolicyError.value = error?.response?.data?.msg || '批量修改实名顺序失败'
} finally {
batchRealnamePolicyLoading.value = false
}
}
// 显示批量分配对话框
const showAllocateDialog = () => {
if (selectedCards.value.length === 0) {
@@ -2952,4 +3050,22 @@
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.expiry-estimate--critical,
.expiry-estimate--warning,
.expiry-estimate--notice {
font-weight: 500;
}
.expiry-estimate--critical {
color: var(--el-color-danger);
}
.expiry-estimate--warning {
color: #8e44ad;
}
.expiry-estimate--notice {
color: #e83e8c;
}
</style>

View File

@@ -28,6 +28,8 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -51,10 +53,15 @@
import { formatDateTime } from '@/utils/business/format'
import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card'
import { RoutesAlias } from '@/router/routesAlias'
import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'AssetAllocationRecords' })
const { hasAuth } = useAuth()
const userStore = useUserStore()
const router = useRouter()
const loading = ref(false)
@@ -165,16 +172,16 @@
}))
},
{
label: '创建时间',
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
clearable: true,
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -221,6 +228,42 @@
const recordList = ref<AssetAllocationRecord[]>([])
const getActions = (row: AssetAllocationRecord) => {
const actions: Array<{ label: string; handler: () => void; type: 'primary' }> = []
const userType = Number(userStore.info.user_type)
const entryPermission =
userType === 3
? AUDIT_PERMISSIONS.agentAssetAllocationActivity
: AUDIT_PERMISSIONS.assetAllocationEntry
if (!hasAuth(entryPermission) || ![1, 2, 3].includes(userType)) return actions
const recordTarget = resolveAuditResourceTarget({
userType,
resourceType: 'asset_allocation_record',
internalId: row.id,
businessIdentifier: row.allocation_no
})
if (recordTarget)
actions.push({
label: userType === 3 ? '活动记录' : '分配审计',
handler: () => openAuditInvestigation(recordTarget),
type: 'primary'
})
if ([1, 2].includes(userType)) {
const assetTarget = resolveAuditResourceTarget({
userType,
resourceType: row.asset_type,
internalId: row.asset_id
})
if (assetTarget && hasAuth(AUDIT_PERMISSIONS.assetAllocationAssetEntry))
actions.push({
label: '资产审计',
handler: () => openAuditInvestigation(assetTarget),
type: 'primary'
})
}
return actions
}
// 获取分配类型标签类型
const getAllocationTypeType = (type: AllocationTypeEnum) => {
return type === 'allocate' ? 'success' : 'warning'

View File

@@ -1,140 +0,0 @@
<template>
<div class="authorization-record-detail">
<ElCard shadow="never">
<!-- 页面头部 -->
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">授权记录详情</h2>
</div>
<!-- 详情内容 -->
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { AuthorizationService } from '@/api/modules'
import type { AuthorizationItem } from '@/types/api/authorization'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'AuthorizationRecordDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detailData = ref<AuthorizationItem | null>(null)
// 详情页配置
const detailSections: DetailSection[] = [
{
title: '基本信息',
fields: [
{ label: '企业ID', prop: 'enterprise_id' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '卡ID', prop: 'card_id' },
{ label: 'ICCID', prop: 'iccid' },
{ label: '手机号', prop: 'msisdn', formatter: (value) => value || '-' },
{ label: '授权人ID', prop: 'authorized_by' },
{ label: '授权人', prop: 'authorizer_name' },
{
label: '授权人类型',
formatter: (_, data) => {
return data.authorizer_type === 2 ? '平台' : '代理'
}
},
{ label: '授权时间', prop: 'authorized_at', formatter: (value) => formatDateTime(value) },
{
label: '状态',
formatter: (_, data) => {
return data.status === 1 ? '有效' : '已回收'
}
},
{ label: '备注', prop: 'remark', formatter: (value) => value || '-' }
]
}
]
// 返回上一页
const handleBack = () => {
router.back()
}
// 获取详情数据
const fetchDetail = async () => {
const id = Number(route.params.id)
if (!id) {
ElMessage.error('缺少ID参数')
return
}
loading.value = true
try {
const res = await AuthorizationService.getAuthorizationDetail(id)
if (res.code === 0) {
detailData.value = res.data
}
} catch (error) {
console.error(error)
console.log('获取授权记录详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped lang="scss">
.authorization-record-detail {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.loading-container {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 32px;
}
}
</style>

View File

@@ -28,32 +28,19 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
<!-- 修改备注对话框 -->
<ElDialog v-model="remarkDialogVisible" title="修改备注" width="500px">
<ElForm ref="remarkFormRef" :model="remarkForm" :rules="remarkRules" label-width="80px">
<ElDialog v-model="remarkDialogVisible" title="修改备注" width="40%">
<ElForm ref="remarkFormRef" :model="remarkForm" :rules="remarkRules">
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="remarkForm.remark"
@@ -81,40 +68,22 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { AuthorizationService, EnterpriseService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import type {
AuthorizationItem,
AuthorizationStatus,
AuthorizerType
} from '@/types/api/authorization'
import { CommonStatus } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'AuthorizationRecords' })
const { hasAuth } = useAuth()
const router = useRouter()
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const loading = ref(false)
const remarkDialogVisible = ref(false)
@@ -122,8 +91,6 @@
const tableRef = ref()
const remarkFormRef = ref<FormInstance>()
const currentRecordId = ref<number>(0)
const contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentRow = ref<AuthorizationItem | null>(null)
// 企业搜索选项
const enterpriseOptions = ref<any[]>([])
@@ -233,12 +200,12 @@
{
label: '授权时间',
prop: 'dateRange',
type: 'daterange',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -252,7 +219,8 @@
{ label: '授权人类型', prop: 'authorizer_type' },
{ label: '授权时间', prop: 'authorized_at' },
{ label: '状态', prop: 'status' },
{ label: '备注', prop: 'remark' }
{ label: '备注', prop: 'remark' },
{ label: '操作', prop: 'operation' }
]
const authorizationList = ref<AuthorizationItem[]>([])
@@ -284,17 +252,7 @@
label: 'ICCID',
width: 200,
formatter: (row: AuthorizationItem) => {
return h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleNameClick(row)
}
},
row.iccid
)
return row.iccid || '-'
}
},
{
@@ -306,7 +264,8 @@
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'authorizer_name',
@@ -346,6 +305,21 @@
}
])
// 操作列配置,样式与店铺列表保持一致
const getActions = (row: AuthorizationItem) => {
const actions: any[] = []
if (hasAuth('authorization_records:update_remark')) {
actions.push({
label: '修改备注',
handler: () => showRemarkDialog(row),
type: 'primary'
})
}
return actions
}
onMounted(() => {
getTableData()
searchEnterprises('')
@@ -417,22 +391,6 @@
getTableData()
}
// 查看详情
const viewDetail = (row: AuthorizationItem) => {
router.push({
path: `${RoutesAlias.AuthorizationRecords}/detail/${row.id}`
})
}
// 处理名称点击
const handleNameClick = (row: AuthorizationItem) => {
if (hasAuth('authorization_records:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 显示修改备注对话框
const showRemarkDialog = (row: AuthorizationItem) => {
currentRecordId.value = row.id
@@ -462,44 +420,10 @@
}
})
}
// 右键菜单项配置
const contextMenuItems = computed((): MenuItemType[] => {
const items: MenuItemType[] = []
if (hasAuth('authorization_records:update_remark')) {
items.push({ key: 'edit', label: '编辑' })
}
return items
})
// 处理表格行右键菜单
const handleRowContextMenu = (row: AuthorizationItem, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
case 'edit':
showRemarkDialog(currentRow.value)
break
}
}
</script>
<style lang="scss" scoped>
.authorization-records-page {
// Authorization records page styles
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">批量订购任务详情</h2>
<ElTag v-if="taskDetail" :type="getStatusType(taskDetail.status)">
{{ taskDetail.status_name || getStatusName(taskDetail.status) }}
</ElTag>
</div>
<div v-if="loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36"><Loading /></ElIcon>
<div>加载中...</div>
</div>
<template v-else-if="taskDetail">
<ElDescriptions :column="4" border>
<ElDescriptionsItem label="任务号">{{ taskDetail.task_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="订单文件" :span="2">
<span class="ellipsis-value" :title="taskDetail.file_name || '-'">
{{ taskDetail.file_name || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐名称">
<span class="ellipsis-value" :title="taskDetail.package_name || '-'">
{{ taskDetail.package_name || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐编码">
<span class="ellipsis-value" :title="taskDetail.package_code || '-'">
{{ taskDetail.package_code || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">
{{ getPaymentMethodName(taskDetail.payment_method) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ taskDetail.total_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
taskDetail.success_count ?? 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ taskDetail.fail_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建人">{{
taskDetail.creator_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="支付凭证">
{{
taskDetail.voucher_keys?.length
? `已上传 ${taskDetail.voucher_keys.length} 个`
: '未上传'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
formatDateTime(taskDetail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{
formatDateTime(taskDetail.started_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{
formatDateTime(taskDetail.completed_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(taskDetail.updated_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem v-if="taskDetail.error_message" label="错误信息" :span="4">
{{ taskDetail.error_message }}
</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
v-if="taskDetail.error_summary"
class="task-error"
type="error"
:closable="false"
:title="taskDetail.error_summary"
/>
<template v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)">
<div class="items-toolbar">
<ElSelect
v-model="itemStatus"
clearable
placeholder="筛选行状态"
style="width: 140px"
>
<ElOption label="成功" :value="BulkPurchaseTaskStatus.COMPLETED" />
<ElOption label="失败" :value="BulkPurchaseTaskStatus.FAILED" />
</ElSelect>
<ElButton :loading="loading" @click="refreshTask">刷新结果</ElButton>
</div>
<ElTable v-loading="loading" :data="paginatedItems" border>
<ElTableColumn label="行号" width="90">
<template #default="scope">{{ scope.row.line ?? '-' }}</template>
</ElTableColumn>
<ElTableColumn label="资产" min-width="180">
<template #default="scope">{{ getAssetIdentifier(scope.row) }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getItemStatusType(scope.row.status)">
{{ scope.row.status_name || getItemStatusName(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="order_no" label="订单号" min-width="180" show-overflow-tooltip />
<ElTableColumn label="金额" width="120">
<template #default="scope">{{ formatMoney(scope.row.amount) }}</template>
</ElTableColumn>
<ElTableColumn label="处理结果" min-width="240" show-overflow-tooltip>
<template #default="scope">{{ getItemReason(scope.row) }}</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="itemsPage"
v-model:page-size="itemsSize"
layout="total, sizes, prev, pager, next"
:total="filteredItems.length"
@size-change="handleItemSizeChange"
/>
</div>
</template>
</template>
<ElEmpty v-else :description="forbidden ? '暂无查看权限' : '暂无任务详情'" />
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElEmpty,
ElIcon,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { BulkPurchaseService } from '@/api/modules'
import type { BulkPurchaseItem, BulkPurchasePaymentMethod, BulkPurchaseTask } from '@/types/api'
import { BulkPurchaseTaskStatus } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
defineOptions({ name: 'BulkPurchaseDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const itemStatus = ref<BulkPurchaseTaskStatus | undefined>()
const itemsPage = ref(1)
const itemsSize = ref(20)
const taskId = Number(route.params.id)
const polling = useAsyncTaskPolling<BulkPurchaseTask>({
storageKey: `bulk-purchase-detail:${taskId}`,
autoRestore: false,
fetchTask: async (id) => {
const res = await BulkPurchaseService.getTask(id)
if (res.code === 403) {
const error = new Error('暂无查看该批量订购任务详情的权限') as Error & { status?: number }
error.status = 403
throw error
}
if (res.code !== 0 || !res.data) {
throw new Error(res.msg || '获取批量订购任务详情失败')
}
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
})
const taskDetail = polling.task
const loading = polling.loading
const forbidden = polling.forbidden
const loadTaskDetail = async () => {
if (!taskId) {
ElMessage.error('缺少任务 ID 参数')
goBack()
return
}
await polling.start(taskId)
if (polling.error.value) ElMessage.error(polling.error.value)
if (polling.forbidden.value) ElMessage.warning('暂无查看该批量订购任务详情的权限')
}
const goBack = () => {
router.back()
}
const refreshTask = () => {
void polling.retry()
}
const getStatusName = (status?: BulkPurchaseTaskStatus) => {
const names: Record<BulkPurchaseTaskStatus, string> = {
[BulkPurchaseTaskStatus.PENDING]: '待处理',
[BulkPurchaseTaskStatus.PROCESSING]: '处理中',
[BulkPurchaseTaskStatus.COMPLETED]: '已完成',
[BulkPurchaseTaskStatus.FAILED]: '已失败',
[BulkPurchaseTaskStatus.CANCELED]: '已取消'
}
return status ? names[status] || '-' : '-'
}
const getStatusType = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
if (status === BulkPurchaseTaskStatus.PROCESSING) return 'warning'
return 'info'
}
const getPaymentMethodName = (method?: BulkPurchasePaymentMethod) => {
return method === 'offline' ? '线下支付' : method === 'wallet' ? '代理钱包' : '-'
}
const getAssetIdentifier = (item: BulkPurchaseItem) =>
item.asset_identifier || item.iccid || item.virtual_no || '-'
const getItemStatusName = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return '成功'
if (status === BulkPurchaseTaskStatus.FAILED) return '失败'
return '-'
}
const getItemStatusType = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
return 'info'
}
const getItemReason = (item: BulkPurchaseItem) =>
item.reason || item.error_summary || item.error_reason || '-'
const filteredItems = computed(() => {
const items = taskDetail.value?.items || []
if (itemStatus.value === undefined) return items
return items.filter((item) => item.status === itemStatus.value)
})
const paginatedItems = computed(() => {
const start = (itemsPage.value - 1) * itemsSize.value
return filteredItems.value.slice(start, start + itemsSize.value)
})
const handleItemSizeChange = () => {
itemsPage.value = 1
}
watch(itemStatus, () => {
itemsPage.value = 1
})
onMounted(() => {
void loadTaskDetail()
})
</script>
<style scoped lang="scss">
.bulk-purchase-detail-page {
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.detail-loading {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 220px;
color: var(--el-text-color-secondary);
}
.ellipsis-value {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.task-error {
margin-top: 16px;
}
.items-toolbar {
display: flex;
gap: 12px;
align-items: center;
margin: 20px 0 12px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,688 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-page" id="table-full-screen">
<ElDrawer
v-model="createDrawerVisible"
title="批量订购套餐"
direction="rtl"
size="40%"
:close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" class="create-form">
<ElFormItem label="套餐系列" prop="series_id">
<ElSelect
v-model="form.series_id"
filterable
remote
clearable
:remote-method="handleSeriesSearch"
:loading="seriesLoading"
placeholder="请输入系列名称搜索"
style="width: 100%"
@change="handleSeriesChange"
@visible-change="handleSeriesVisibleChange"
>
<ElOption
v-for="series in seriesOptions"
:key="series.id"
:label="series.series_name"
:value="series.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="相关套餐" prop="package_id">
<ElSelect
v-model="form.package_id"
filterable
remote
clearable
:remote-method="handlePackageSearch"
:loading="packageLoading"
:disabled="!form.series_id"
placeholder="请先选择套餐系列"
style="width: 100%"
@visible-change="handlePackageVisibleChange"
>
<ElOption
v-for="pkg in packageOptions"
:key="pkg.id"
:label="pkg.package_name"
:value="pkg.id"
>
<span class="package-option">{{ pkg.package_name }}</span>
</ElOption>
<template #empty>
<div class="package-empty">
{{
packageLoading
? '正在加载套餐...'
: form.series_id
? '该套餐系列下暂无可用套餐'
: '请先选择套餐系列'
}}
</div>
</template>
</ElSelect>
<div
v-if="form.series_id && !packageLoading && packageOptions.length === 0"
class="package-empty-tip"
>
该套餐系列下暂无可用套餐
</div>
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElRadioGroup v-model="form.payment_method" @change="handlePaymentMethodChange">
<ElRadio value="wallet">代理钱包</ElRadio>
<ElRadio value="offline">线下支付</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="订单文件" prop="orderFile">
<VoucherUpload
ref="orderUploadRef"
v-model="orderFileKeys"
voucher-name="订单文件"
:max-count="1"
purpose="batch_purchase"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV,最多 1000 行,最大 10MB"
@uploading-change="orderFileUploading = $event"
@change="formRef?.validateField('orderFile')"
/>
</ElFormItem>
<ElFormItem
v-if="form.payment_method === 'offline'"
label="整批支付凭证"
prop="voucherFile"
>
<VoucherUpload
ref="voucherUploadRef"
v-model="voucherFileKeys"
voucher-name="整批支付凭证"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('voucherFile')"
/>
</ElFormItem>
<ElAlert
v-if="createDrawerBusy"
type="info"
:closable="false"
show-icon
:title="
voucherUploading
? '支付凭证上传中,请稍候'
: orderFileUploading
? '订单文件上传中,请稍候'
: '批量订购文件上传及任务创建中,请勿重复提交'
"
/>
</ElForm>
<template #footer>
<div class="drawer-footer">
<ElButton :disabled="createDrawerBusy" @click="createDrawerVisible = false">
取消
</ElButton>
<ElButton
type="primary"
:loading="createDrawerBusy"
:disabled="!hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
@click="submitTask"
>
创建批量订购任务
</ElButton>
</div>
</template>
</ElDrawer>
<ElCard
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.detail)"
shadow="never"
class="art-table-card task-list-card"
>
<template #header>
<div class="task-list-header">
<span>批量订购任务</span>
<div class="task-list-toolbar">
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
@click="downloadTemplate"
>
下载模板
</ElButton>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
type="primary"
@click="openCreateDrawer"
>
批量订购套餐
</ElButton>
<ElSelect
v-model="taskListStatus"
clearable
placeholder="筛选任务状态"
style="width: 150px"
@change="handleTaskListStatusChange"
>
<ElOption label="待处理" :value="BulkPurchaseTaskStatus.PENDING" />
<ElOption label="处理中" :value="BulkPurchaseTaskStatus.PROCESSING" />
<ElOption label="已完成" :value="BulkPurchaseTaskStatus.COMPLETED" />
<ElOption label="已失败" :value="BulkPurchaseTaskStatus.FAILED" />
<ElOption label="已取消" :value="BulkPurchaseTaskStatus.CANCELED" />
</ElSelect>
<ElButton :loading="taskListLoading" @click="loadTaskList">刷新</ElButton>
</div>
</div>
</template>
<ElTable v-loading="taskListLoading" :data="taskList" border>
<ElTableColumn label="任务号" min-width="200" show-overflow-tooltip>
<template #default="scope">
<ElButton type="primary" link @click="showTask(scope.row)">
{{ scope.row.task_no || '-' }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn prop="file_name" label="订单文件" min-width="180" show-overflow-tooltip />
<ElTableColumn
prop="package_name"
label="套餐名称"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn
prop="package_code"
label="套餐编码"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="支付方式" width="110">
<template #default="scope">{{
getPaymentMethodName(scope.row.payment_method)
}}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getStatusType(scope.row.status)">
{{ scope.row.status_name || getStatusName(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="total_count" label="总数" width="90" />
<ElTableColumn prop="success_count" label="成功数" width="90" />
<ElTableColumn prop="fail_count" label="失败数" width="90" />
<ElTableColumn label="创建时间" width="180">
<template #default="scope">{{ formatDateTime(scope.row.created_at) }}</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="taskListPage"
v-model:page-size="taskListSize"
layout="total, sizes, prev, pager, next"
:total="taskListTotal"
@current-change="loadTaskList"
@size-change="handleTaskListSizeChange"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDrawer,
ElForm,
ElMessage,
ElOption,
ElPagination,
ElRadio,
ElRadioGroup,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BulkPurchaseService, PackageManageService, PackageSeriesService } from '@/api/modules'
import type {
BulkPurchasePaymentMethod,
BulkPurchaseTask,
PackageResponse,
PackageSeriesResponse
} from '@/types/api'
import { BulkPurchaseTaskStatus } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'BulkPurchase' })
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDrawerVisible = ref(false)
const submitting = ref(false)
const orderFileKeys = ref<string[]>([])
const orderFileUploading = ref(false)
const voucherFileKeys = ref<string[]>([])
const voucherUploading = ref(false)
const seriesOptions = ref<PackageSeriesResponse[]>([])
const seriesLoading = ref(false)
const packageOptions = ref<PackageResponse[]>([])
const packageLoading = ref(false)
const taskList = ref<BulkPurchaseTask[]>([])
const taskListLoading = ref(false)
const taskListPage = ref(1)
const taskListSize = ref(20)
const taskListTotal = ref(0)
const taskListStatus = ref<BulkPurchaseTaskStatus | undefined>()
const taskStatusPollInterval = 2000
let taskStatusPollTimer: number | undefined
let taskStatusPollSession = 0
const createDrawerBusy = computed(
() => submitting.value || voucherUploading.value || orderFileUploading.value
)
const downloadTemplate = () => {
try {
const link = document.createElement('a')
link.href = new URL('@/template/批量订购套餐模板.csv', import.meta.url).href
link.download = '批量订购套餐模板.csv'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('模板下载成功')
} catch (error) {
console.error('下载模板失败:', error)
ElMessage.error('模板下载失败')
}
}
const form = reactive<{
series_id?: number
package_id?: number
payment_method: BulkPurchasePaymentMethod
}>({
series_id: undefined,
package_id: undefined,
payment_method: 'wallet'
})
const rules = computed<FormRules>(() => ({
series_id: [{ required: true, message: '请选择套餐系列', trigger: 'change' }],
package_id: [{ required: true, message: '请选择套餐', trigger: 'change' }],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
orderFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (orderFileKeys.value.length === 0) callback(new Error('请上传订单文件'))
else callback()
},
trigger: 'change'
}
],
voucherFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (form.payment_method === 'offline' && voucherFileKeys.value.length === 0) {
callback(new Error('线下支付必须上传整批支付凭证'))
} else callback()
},
trigger: 'change'
}
]
}))
const loadPackages = async (query = '') => {
if (!form.series_id) {
packageOptions.value = []
return
}
packageLoading.value = true
try {
const res = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query.trim() || undefined,
series_id: form.series_id,
status: 1,
shelf_status: 1
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取套餐列表失败')
return
}
packageOptions.value = (res.data?.items || []).filter(
(item) => item.status === 1 && item.shelf_status === 1
)
} catch (error: any) {
ElMessage.error(error?.message || '获取套餐列表失败')
} finally {
packageLoading.value = false
}
}
const handlePackageSearch = (query: string) => {
if (form.series_id) void loadPackages(query)
}
const handlePackageVisibleChange = (visible: boolean) => {
if (visible && form.series_id && packageOptions.value.length === 0) void loadPackages()
}
const openCreateDrawer = () => {
createDrawerVisible.value = true
if (seriesOptions.value.length === 0) void loadSeries()
}
const handleCreateDrawerClosed = () => {
formRef.value?.resetFields()
form.series_id = undefined
form.package_id = undefined
form.payment_method = 'wallet'
orderFileKeys.value = []
voucherFileKeys.value = []
orderUploadRef.value?.clearFiles(false)
voucherUploadRef.value?.clearFiles(false)
orderFileUploading.value = false
voucherUploading.value = false
}
const loadSeries = async (query = '') => {
seriesLoading.value = true
try {
const res = await PackageSeriesService.getPackageSeries({
page: 1,
page_size: 20,
series_name: query.trim() || undefined,
status: 1
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取套餐系列失败')
return
}
seriesOptions.value = res.data?.items || []
} catch (error: any) {
ElMessage.error(error?.message || '获取套餐系列失败')
} finally {
seriesLoading.value = false
}
}
const handleSeriesSearch = (query: string) => {
void loadSeries(query)
}
const handleSeriesVisibleChange = (visible: boolean) => {
if (visible && seriesOptions.value.length === 0) void loadSeries()
}
const handleSeriesChange = () => {
form.package_id = undefined
packageOptions.value = []
if (form.series_id) void loadPackages()
}
const handlePaymentMethodChange = (value: string | number | boolean | undefined) => {
if (value === 'wallet') {
voucherFileKeys.value = []
voucherUploadRef.value?.clearFiles()
formRef.value?.clearValidate('voucherFile')
}
}
const submitTask = async () => {
if (
!hasAuth(BULK_PURCHASE_PERMISSIONS.create) ||
submitting.value ||
voucherUploading.value ||
orderFileUploading.value
)
return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !form.package_id || orderFileKeys.value.length === 0) return
submitting.value = true
try {
const data = {
file_key: orderFileKeys.value[0],
package_id: form.package_id,
payment_method: form.payment_method,
...(form.payment_method === 'offline' ? { voucher_keys: voucherFileKeys.value } : {})
}
const res = await BulkPurchaseService.createTask(data)
const taskId = res.data?.task_id ?? res.data?.id
if (res.code !== 0 || !taskId) {
ElMessage.error(res.msg || '创建批量订购任务失败')
return
}
createDrawerVisible.value = false
await loadTaskList()
startTaskStatusPolling(taskId, res.data?.status ?? BulkPurchaseTaskStatus.PENDING)
ElMessage.success(res.data.message || '批量订购任务已创建')
} catch (error: any) {
ElMessage.error(error?.message || '创建批量订购任务失败')
} finally {
submitting.value = false
}
}
const loadTaskList = async () => {
if (!hasAuth(BULK_PURCHASE_PERMISSIONS.detail)) return
taskListLoading.value = true
try {
const res = await BulkPurchaseService.getTasks({
page: taskListPage.value,
page_size: taskListSize.value,
status: taskListStatus.value
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取批量订购任务列表失败')
return
}
taskList.value = res.data?.items || []
taskListTotal.value = res.data?.total || 0
} catch (error: any) {
ElMessage.error(error?.message || '获取批量订购任务列表失败')
} finally {
taskListLoading.value = false
}
}
const handleTaskListStatusChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const handleTaskListSizeChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const stopTaskStatusPolling = () => {
taskStatusPollSession += 1
if (taskStatusPollTimer !== undefined) {
window.clearTimeout(taskStatusPollTimer)
taskStatusPollTimer = undefined
}
}
const startTaskStatusPolling = (
taskId: number,
initialStatus = BulkPurchaseTaskStatus.PENDING
) => {
stopTaskStatusPolling()
const session = taskStatusPollSession
let lastStatus = initialStatus
const poll = async () => {
if (session !== taskStatusPollSession) return
try {
const response = await BulkPurchaseService.getTasks({
page: 1,
page_size: 100
})
if (response.code === 0) {
const task = response.data.items.find((item) => (item.task_id ?? item.id) === taskId)
if (task) {
if (task.status !== lastStatus) {
lastStatus = task.status
await loadTaskList()
}
if (
session === taskStatusPollSession &&
[
BulkPurchaseTaskStatus.COMPLETED,
BulkPurchaseTaskStatus.FAILED,
BulkPurchaseTaskStatus.CANCELED
].includes(task.status)
) {
stopTaskStatusPolling()
return
}
}
}
} catch (error) {
console.error('轮询批量订购任务状态失败:', error)
}
if (session === taskStatusPollSession) {
taskStatusPollTimer = window.setTimeout(() => void poll(), taskStatusPollInterval)
}
}
void poll()
}
const showTask = (task: BulkPurchaseTask) => {
const taskId = task.task_id ?? task.id
if (!taskId) return
void router.push(`${RoutesAlias.BulkPurchaseDetail}/${taskId}`)
}
const getStatusName = (status: BulkPurchaseTaskStatus) => {
const names: Record<BulkPurchaseTaskStatus, string> = {
[BulkPurchaseTaskStatus.PENDING]: '待处理',
[BulkPurchaseTaskStatus.PROCESSING]: '处理中',
[BulkPurchaseTaskStatus.COMPLETED]: '已完成',
[BulkPurchaseTaskStatus.FAILED]: '已失败',
[BulkPurchaseTaskStatus.CANCELED]: '已取消'
}
return names[status] || '-'
}
const getStatusType = (status: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
if (status === BulkPurchaseTaskStatus.PROCESSING) return 'warning'
return 'info'
}
const getPaymentMethodName = (method?: BulkPurchasePaymentMethod) => {
return method === 'offline' ? '线下支付' : method === 'wallet' ? '代理钱包' : '-'
}
onMounted(() => {
void loadTaskList()
})
onBeforeUnmount(() => {
stopTaskStatusPolling()
})
</script>
<style scoped lang="scss">
.bulk-purchase-page {
.task-list-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.task-list-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.package-option {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.package-empty {
padding: 8px 0;
color: var(--el-color-danger);
text-align: center;
}
.package-empty-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-color-danger);
}
.create-form {
padding-bottom: 8px;
}
.drawer-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
@media (width <= 768px) {
.task-list-header {
flex-direction: column;
align-items: flex-start;
}
.task-list-toolbar {
width: 100%;
.el-select {
flex: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,584 @@
<template>
<ArtTableFullScreen>
<div class="device-batch-allocation-page" id="table-full-screen">
<ElDrawer
v-model="createDrawerVisible"
title="设备批量任务"
direction="rtl"
size="40%"
:close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed"
>
<ElForm ref="formRef" :model="form" class="create-form">
<ElFormItem label="操作类型" prop="operation_type">
<ElSelect
v-model="form.operation_type"
style="width: 100%"
@change="handleOperationTypeChange"
>
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
<ElOption label="批量回收设备" value="recall" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="requiresTargetId" :label="targetLabel" prop="target_id">
<ElSelect
v-model="form.target_id"
filterable
remote
clearable
:remote-method="handleTargetSearch"
:loading="targetLoading"
:placeholder="targetPlaceholder"
style="width: 100%"
@visible-change="handleTargetVisibleChange"
>
<template v-if="form.operation_type === 'assign_shop'">
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/>
</template>
<template v-else>
<ElOption
v-for="series in seriesOptions"
:key="series.id"
:label="series.series_name"
:value="series.id"
/>
</template>
</ElSelect>
</ElFormItem>
<ElFormItem v-else label="回收目标">
<ElTag type="warning">回收到平台库存</ElTag>
</ElFormItem>
<ElFormItem label="设备文件" prop="file_key">
<VoucherUpload
ref="uploadRef"
v-model="fileKeys"
voucher-name="设备标识文件"
:max-count="1"
purpose="device_batch_allocation"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="格式:一行一个唯一标识;仅支持 UTF-8 单列 CSV,最多 1000 行,最大 10MB"
@uploading-change="fileUploading = $event"
/>
</ElFormItem>
<ElAlert
v-if="createDrawerBusy"
type="info"
:closable="false"
show-icon
:title="fileUploading ? '设备文件上传中,请稍候' : '任务创建中,请勿重复提交'"
/>
</ElForm>
<template #footer>
<div class="drawer-footer">
<ElButton :disabled="createDrawerBusy" @click="createDrawerVisible = false">
取消
</ElButton>
<ElButton
type="primary"
:loading="submitting"
:disabled="!hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
@click="submitTask"
>
创建分配任务
</ElButton>
</div>
</template>
</ElDrawer>
<ElCard shadow="never" class="art-table-card task-list-card">
<template #header>
<div class="task-list-header">
<span>设备批量任务</span>
<div class="task-list-toolbar">
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
@click="downloadTemplate"
>
下载模板
</ElButton>
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
type="primary"
@click="openCreateDrawer"
>
设备批量任务
</ElButton>
<ElSelect
v-model="taskListOperationType"
clearable
placeholder="筛选操作类型"
style="width: 170px"
@change="handleTaskListFilterChange"
>
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
<ElOption label="批量回收设备" value="recall" />
</ElSelect>
<ElSelect
v-model="taskListStatus"
clearable
placeholder="筛选任务状态"
style="width: 150px"
@change="handleTaskListFilterChange"
>
<ElOption label="待处理" :value="DeviceImportTaskStatus.PENDING" />
<ElOption label="处理中" :value="DeviceImportTaskStatus.PROCESSING" />
<ElOption label="已完成" :value="DeviceImportTaskStatus.COMPLETED" />
<ElOption label="失败" :value="DeviceImportTaskStatus.FAILED" />
</ElSelect>
<ElButton :loading="taskListLoading" @click="loadTaskList">刷新</ElButton>
</div>
</div>
</template>
<ElTable v-loading="taskListLoading" :data="taskList" border>
<ElTableColumn label="任务号" min-width="225" show-overflow-tooltip>
<template #default="scope">
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.detail)"
type="primary"
link
@click="showTask(scope.row)"
>
{{ scope.row.task_no || '-' }}
</ElButton>
<span v-else>{{ scope.row.task_no || '-' }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作类型" width="150">
<template #default="scope">
<ElTag :type="getOperationTagType(scope.row.operation_type)">
{{ getOperationLabel(scope.row.operation_type) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="operation_name"
label="操作名称"
min-width="170"
show-overflow-tooltip
>
<template #default="scope">
{{ scope.row.operation_name || getOperationLabel(scope.row.operation_type) }}
</template>
</ElTableColumn>
<ElTableColumn label="目标名称" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.target_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getStatusTagType(scope.row.status)">
{{ scope.row.status_name || scope.row.status_text || '-' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="total_count" label="总数" width="90" />
<ElTableColumn prop="success_count" label="成功数" width="90" />
<ElTableColumn prop="fail_count" label="失败数" width="90" />
<ElTableColumn prop="skip_count" label="跳过数" width="90" />
<ElTableColumn label="创建时间" width="180">
<template #default="scope">{{ formatDateTime(scope.row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="完成时间" width="180">
<template #default="scope">
{{ scope.row.completed_at ? formatDateTime(scope.row.completed_at) : '-' }}
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="taskListPage"
v-model:page-size="taskListSize"
layout="total, sizes, prev, pager, next"
:total="taskListTotal"
@current-change="loadTaskList"
@size-change="handleTaskListSizeChange"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDrawer,
ElForm,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import type { FormInstance } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { DeviceService, PackageSeriesService, ShopService } from '@/api/modules'
import { DeviceImportTaskStatus } from '@/types/api/device'
import type {
DeviceBatchAllocationRequest,
DeviceImportTask,
DeviceImportTaskOperationType
} from '@/types/api/device'
import type { PackageSeriesResponse, ShopResponse } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceBatchAllocation' })
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDrawerVisible = ref(false)
const submitting = ref(false)
const fileUploading = ref(false)
const fileKeys = ref<string[]>([])
const taskList = ref<DeviceImportTask[]>([])
const taskListLoading = ref(false)
const taskListPage = ref(1)
const taskListSize = ref(20)
const taskListTotal = ref(0)
const taskListStatus = ref<DeviceImportTaskStatus | undefined>()
const taskListOperationType = ref<Exclude<DeviceImportTaskOperationType, 'import'> | undefined>()
const taskStatusPollInterval = 2000
let taskStatusPollTimer: number | undefined
let taskStatusPollSession = 0
const shopOptions = ref<ShopResponse[]>([])
const seriesOptions = ref<PackageSeriesResponse[]>([])
const targetLoading = ref(false)
const form = reactive<{
operation_type: Exclude<DeviceImportTaskOperationType, 'import'>
target_id?: number
}>({
operation_type: 'assign_shop',
target_id: undefined
})
const requiresTargetId = computed(() =>
['assign_shop', 'assign_series'].includes(form.operation_type)
)
const targetPlaceholder = computed(() =>
form.operation_type === 'assign_shop' ? '请选择启用的目标店铺' : '请选择启用的套餐系列'
)
const targetLabel = computed(() =>
form.operation_type === 'assign_shop' ? '选择店铺' : '套餐系列'
)
const createDrawerBusy = computed(() => submitting.value || fileUploading.value)
const getOperationLabel = (operationType: DeviceImportTaskOperationType) => {
switch (operationType) {
case 'assign_shop':
return '分配目标代理'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '批量回收设备'
default:
return '设备导入'
}
}
const getOperationTagType = (operationType: DeviceImportTaskOperationType) =>
operationType === 'recall' ? 'warning' : 'primary'
const getStatusTagType = (status: DeviceImportTaskStatus) => {
switch (status) {
case DeviceImportTaskStatus.PENDING:
return 'info'
case DeviceImportTaskStatus.PROCESSING:
return 'warning'
case DeviceImportTaskStatus.COMPLETED:
return 'success'
case DeviceImportTaskStatus.FAILED:
return 'danger'
default:
return 'info'
}
}
const openCreateDrawer = () => {
if (!hasAuth(JULY_PERMISSIONS.deviceAllocation.create)) return
createDrawerVisible.value = true
}
const loadTargetOptions = async (query = '') => {
if (form.operation_type === 'recall') return
targetLoading.value = true
try {
if (form.operation_type === 'assign_shop') {
const res = await ShopService.getShops({
page: 1,
page_size: 20,
status: 1,
shop_name: query || undefined
})
if (res.code === 0) shopOptions.value = res.data.items || []
} else {
const res = await PackageSeriesService.getPackageSeries({
page: 1,
page_size: 20,
status: 1,
series_name: query || undefined
})
if (res.code === 0) seriesOptions.value = res.data.items || []
}
} catch (error) {
console.error('获取设备批量任务目标失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
targetLoading.value = false
}
}
const handleTargetSearch = (query: string) => {
void loadTargetOptions(query)
}
const handleTargetVisibleChange = (visible: boolean) => {
if (
visible &&
(form.operation_type === 'assign_shop'
? !shopOptions.value.length
: !seriesOptions.value.length)
) {
void loadTargetOptions()
}
}
const handleOperationTypeChange = () => {
form.target_id = undefined
shopOptions.value = []
seriesOptions.value = []
if (form.operation_type !== 'recall') void loadTargetOptions()
}
const handleCreateDrawerClosed = () => {
formRef.value?.resetFields()
form.operation_type = 'assign_shop'
form.target_id = undefined
shopOptions.value = []
seriesOptions.value = []
fileKeys.value = []
fileUploading.value = false
uploadRef.value?.clearFiles()
}
const downloadTemplate = () => {
try {
const link = document.createElement('a')
link.href = new URL('@/template/设备批量任务.csv', import.meta.url).href
link.download = '设备批量任务.csv'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('模板下载成功')
} catch (error) {
console.error('下载模板失败:', error)
ElMessage.error('模板下载失败')
}
}
const handleTaskListFilterChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const loadTaskList = async () => {
taskListLoading.value = true
try {
const res = await DeviceService.getImportTasks({
page: taskListPage.value,
page_size: taskListSize.value,
status: taskListStatus.value,
operation_type: taskListOperationType.value
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取设备批量任务失败')
return
}
taskList.value = (res.data.items || []).filter((item) => item.operation_type !== 'import')
taskListTotal.value = res.data.total || 0
} catch (error) {
console.error('获取设备批量任务失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
taskListLoading.value = false
}
}
const handleTaskListSizeChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const stopTaskStatusPolling = () => {
taskStatusPollSession += 1
if (taskStatusPollTimer !== undefined) {
window.clearTimeout(taskStatusPollTimer)
taskStatusPollTimer = undefined
}
}
const startTaskStatusPolling = (
taskId: number,
initialStatus = DeviceImportTaskStatus.PENDING
) => {
stopTaskStatusPolling()
const session = taskStatusPollSession
let lastStatus = initialStatus
const poll = async () => {
if (session !== taskStatusPollSession) return
try {
const response = await DeviceService.getImportTasks({
page: 1,
page_size: 100
})
if (response.code === 0) {
const task = response.data.items.find((item) => item.id === taskId)
if (task) {
if (task.status !== lastStatus) {
lastStatus = task.status
await loadTaskList()
}
if (
session === taskStatusPollSession &&
[DeviceImportTaskStatus.COMPLETED, DeviceImportTaskStatus.FAILED].includes(
task.status
)
) {
stopTaskStatusPolling()
return
}
}
}
} catch (error) {
console.error('轮询设备批量任务状态失败:', error)
}
if (session === taskStatusPollSession) {
taskStatusPollTimer = window.setTimeout(() => void poll(), taskStatusPollInterval)
}
}
void poll()
}
const showTask = (row: DeviceImportTask) => {
router.push({
path: RoutesAlias.TaskDetail,
query: { id: row.id, task_type: 'device' }
})
}
const submitTask = async () => {
if (createDrawerBusy.value) return
if (!fileKeys.value[0]) {
ElMessage.warning('请先上传设备标识文件')
return
}
if (requiresTargetId.value && !form.target_id) {
ElMessage.warning('请选择目标店铺或套餐系列')
return
}
const request: DeviceBatchAllocationRequest = {
file_key: fileKeys.value[0],
operation_type: form.operation_type
}
if (requiresTargetId.value) request.target_id = form.target_id
submitting.value = true
try {
const res = await DeviceService.createAllocationTask(request)
if (res.code !== 0) {
ElMessage.error(res.msg || '创建设备批量任务失败')
return
}
createDrawerVisible.value = false
await loadTaskList()
startTaskStatusPolling(res.data.task_id)
ElMessage.success(`任务已创建:${res.data.task_no}`)
} catch (error) {
console.error('创建设备批量任务失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
submitting.value = false
}
}
onMounted(() => {
void loadTaskList()
})
onBeforeUnmount(() => {
stopTaskStatusPolling()
})
</script>
<style lang="scss" scoped>
.device-batch-allocation-page {
.task-list-header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.task-list-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
.drawer-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
@media (width <= 900px) {
.task-list-header {
flex-direction: column;
align-items: flex-start;
}
.task-list-toolbar {
justify-content: flex-start;
}
}
}
</style>

View File

@@ -1,35 +1,37 @@
<template>
<ArtTableFullScreen>
<div class="device-task-page" id="table-full-screen">
<!-- 搜索栏 -->
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
/>
<div v-if="pollingError || pollingForbidden" class="task-polling-error">
<ElAlert
type="warning"
:closable="false"
show-icon
:title="pollingForbidden ? '当前账号无权查看该任务' : pollingError || '任务状态获取失败'"
/>
<ElButton size="small" @click="polling.retry">重试</ElButton>
</div>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
type="primary"
:icon="Upload"
@click="importDialogVisible = true"
v-permission="'device_task:bulk_import'"
>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入设备
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
@@ -51,21 +53,20 @@
</ElCard>
</div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入设备" width="40%" align-center>
<ElDrawer v-model="importDialogVisible" title="批量导入设备" direction="rtl" size="50%">
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明:</strong></p>
<p>1. 请先下载 Excel 模板文件,按照模板格式填写设备信息</p>
<p>2. 仅支持 Excel 格式(.xlsx),单次最多导入 1000 条</p>
<p>3. 列格式请设置为文本格式,避免长数字被转为科学计数法</p>
<p>4. <strong>重要:列顺序固定,不可调整。</strong>系统按位置读取,不识别列名</p>
<p style="color: var(--el-color-primary)">5. 必填列:虚拟号(第1列)</p>
<p>1. 设备导入使用 Excel 模板,文件大小不能超过 300MB。</p>
<p>2. 列格式请设置为文本格式,避免长数字被转为科学计数法。</p>
<p>3. <strong>列顺序固定,不可调整。</strong>系统按位置读取,不识别列名。</p>
<p style="color: var(--el-color-primary)">
4. 必填列:设备标识(第 1 列,支持 VirtualNo、IMEI 或 SN)。
</p>
<p>
6.
可选列:SN、设备名称、设备型号、设备类型、IMEI、制造商、最大SIM槽数(默认4,有效范围1-4)、卡1~卡4
ICCID
5. 可选列:SN、设备名称、设备型号、设备类型、IMEI、制造商、最大 SIM 槽数、卡 1~卡 4
ICCID。
</p>
</div>
</template>
@@ -117,9 +118,9 @@
accept=".xlsx"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将 Excel 文件拖到此处,或<em>点击选择</em></div>
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件,且不超过 300MB</div>
<div class="el-upload__tip">设备导入仅支持 .xlsx 文件</div>
</template>
</ElUpload>
@@ -134,31 +135,42 @@
开始导入
</ElButton>
</template>
</ElDialog>
</ElDrawer>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, onMounted } from 'vue'
import { h, onMounted, reactive, ref, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
import { Download, Upload, UploadFilled } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { StorageService } from '@/api/modules/storage'
import type { DeviceImportTask, DeviceImportTaskStatus, RealnamePolicy } from '@/types/api/device'
import type {
DeviceImportTask,
DeviceImportTaskDetail,
DeviceImportTaskQueryParams,
DeviceImportTaskStatus,
RealnamePolicy
} from '@/types/api/device'
import { RoutesAlias } from '@/router/routesAlias'
import { generatePackageCode } from '@/utils/codeGenerator'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceTask' })
const router = useRouter()
const route = useRoute()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const loading = ref(false)
const tableRef = ref()
@@ -168,38 +180,23 @@
const importDialogVisible = ref(false)
const importForm = reactive({
batch_no: '',
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order'
realname_policy: '' as '' | RealnamePolicy
})
// 搜索表单初始值
const initialSearchState = {
status: undefined,
status: undefined as DeviceImportTaskStatus | undefined,
batch_no: '',
dateRange: undefined as any,
start_time: '',
end_time: ''
dateRange: undefined as string[] | undefined
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: '任务状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
config: { clearable: true, placeholder: '全部' },
options: () => [
{ label: '待处理', value: 1 },
{ label: '处理中', value: 2 },
@@ -211,17 +208,14 @@
label: '批次号',
prop: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
config: { clearable: true, placeholder: '请输入批次号' }
},
{
label: '创建时间',
prop: 'dateRange',
type: 'daterange',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
@@ -229,7 +223,6 @@
}
]
// 列配置
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '任务状态', prop: 'status' },
@@ -246,8 +239,23 @@
]
const taskList = ref<DeviceImportTask[]>([])
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
storageKey: 'device-import-active-task',
autoRestore: false,
fetchTask: async (taskId) => {
const res = await DeviceService.getImportTaskDetail(taskId)
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取设备任务失败')
return res.data
},
isForbidden: (error) => {
const response = (error as { response?: { status?: number; data?: { code?: number } } })
?.response
return response?.status === 403 || response?.data?.code === 403
}
})
const pollingError = polling.error
const pollingForbidden = polling.forbidden
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => {
switch (status) {
case 1:
@@ -263,35 +271,30 @@
}
}
// 查看详情
const viewDetail = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
router.push({
path: RoutesAlias.TaskDetail,
query: {
id: row.id,
task_type: 'device'
}
query: { id: row.id, task_type: 'device' }
})
}
// 处理名称点击
const handleNameClick = (row: DeviceImportTask) => {
if (hasAuth('device_task:view_detail')) {
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'task_no',
label: '任务编号',
width: 220,
showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => {
return h(
formatter: (row: DeviceImportTask) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
@@ -302,48 +305,31 @@
},
row.task_no
)
}
},
{
prop: 'status',
label: '任务状态',
width: 100,
formatter: (row: DeviceImportTask) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text)
}
},
{
prop: 'total_count',
label: '总数',
width: 80
formatter: (row: DeviceImportTask) =>
h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || row.status_text)
},
{ prop: 'total_count', label: '总数', width: 80 },
{
prop: 'success_count',
label: '成功数',
width: 80,
formatter: (row: DeviceImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
formatter: (row: DeviceImportTask) =>
h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
},
{
prop: 'fail_count',
label: '失败数',
width: 80,
formatter: (row: DeviceImportTask) => {
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
prop: 'skip_count',
label: '跳过数',
width: 80
},
{
prop: 'batch_no',
label: '批次号',
width: 180,
showOverflowTooltip: true
formatter: (row: DeviceImportTask) =>
h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
},
{ prop: 'skip_count', label: '跳过数', width: 80 },
{ prop: 'batch_no', label: '批次号', width: 180, showOverflowTooltip: true },
{
prop: 'started_at',
label: '开始时间',
@@ -364,12 +350,7 @@
showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => row.error_message || '-'
},
{
prop: 'file_name',
label: '文件名',
minWidth: 180,
showOverflowTooltip: true
},
{ prop: 'file_name', label: '文件名', minWidth: 180, showOverflowTooltip: true },
{
prop: 'creator_name',
label: '操作人',
@@ -385,79 +366,78 @@
}
])
onMounted(() => {
getTableData()
watch(polling.task, () => {
if (isPlatformAccount.value) void getTableData()
})
onMounted(() => {
if (!isPlatformAccount.value) return
void getTableData()
const routeTaskId = Number(route.query.task_id)
if (routeTaskId) {
void polling.start(routeTaskId)
} else if (polling.taskId.value) {
void polling.retry()
}
})
// 获取设备任务列表
const getTableData = async () => {
if (!isPlatformAccount.value) {
taskList.value = []
pagination.total = 0
return
}
loading.value = true
try {
const params: any = {
const params: DeviceImportTaskQueryParams = {
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status,
operation_type: 'import',
batch_no: searchForm.batch_no || undefined
}
// 处理时间范围
if (searchForm.dateRange && Array.isArray(searchForm.dateRange)) {
params.start_time = searchForm.dateRange[0]
params.end_time = searchForm.dateRange[1]
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null })
.items
taskList.value = taskItems || []
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
void getTableData()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTableData()
void getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
const handleRefresh = () => void getTableData()
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
void getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
void getTableData()
}
// 下载模板
const downloadTemplate = async () => {
const downloadTemplate = () => {
try {
const link = document.createElement('a')
link.href = new URL('@/template/设备导入模板.xlsx', import.meta.url).href
@@ -472,56 +452,49 @@
}
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 300 * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
const file = uploadFile.raw as File | undefined
if (!file) return
if (file.size > 300 * 1024 * 1024) {
ElMessage.error('文件大小不能超过 300MB')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
if (!file.name.toLowerCase().endsWith('.xlsx')) {
ElMessage.error('设备导入只能上传 .xlsx 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
fileList.value = [file]
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
}
// 生成批次号
const handleGenerateBatchNo = () => {
const code = generatePackageCode().replace('PKG', 'DEV')
importForm.batch_no = code
importForm.batch_no = generatePackageCode().replace('PKG', 'DEV')
ElMessage.success('批次号生成成功')
}
// 取消导入
const handleCancelImport = () => {
clearFiles()
importForm.batch_no = ''
importForm.realname_policy = ''
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择 Excel 文件')
if (!isPlatformAccount.value || !fileList.value.length) {
ElMessage.warning('请先选择文件')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
@@ -529,14 +502,12 @@
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
return
}
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(
upload_url,
@@ -544,23 +515,23 @@
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
ElMessage.info('正在创建导入任务...')
ElMessage.info('正在创建设备导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
if (importRes.code !== 0) {
ElMessage.error(importRes.msg || '创建导入任务失败')
return
}
const taskNo = importRes.data.task_no
const taskId = importRes.data.task_id
handleCancelImport()
await router.replace({ path: route.path, query: { task_id: String(taskId) } })
await polling.start(taskId)
await getTableData()
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000,
@@ -568,66 +539,57 @@
})
} catch (error: any) {
console.error('设备导入失败:', error)
console.log(error.message || '设备导入失败')
ElMessage.error(normalizeApiError(error).message || error.message || '设备导入失败')
} finally {
uploading.value = false
}
}
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
const failReasons =
detail.failed_items?.map((item: any) => ({
line: item.line || '-',
deviceNo: item.virtual_no || '-',
message: item.reason || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const headers = ['行号', '设备编号', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.line, `\t${item.deviceNo}`, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `导入失败数据_${row.batch_no}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
if (res.code !== 0 || !res.data) return
const failReasons =
res.data.failed_items?.map((item) => ({
line: item.line || '-',
deviceNo: item.device_identifier || item.virtual_no || '-',
message: item.reason || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const csvRows = [
['行号', '设备编号', '失败原因'].join(','),
...failReasons.map((item) =>
[item.line, `\t${item.deviceNo}`, `"${item.message.replaceAll('"', '""')}"`].join(',')
)
]
const blob = new Blob(['\uFEFF' + csvRows.join('\n')], {
type: 'text/csv;charset=utf-8;'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `导入失败数据_${row.batch_no}.csv`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
} catch (error) {
console.error('下载失败数据失败:', error)
ElMessage.error('下载失败数据失败')
}
}
const downloadTaskFile = async (row: DeviceImportTask) => {
const fileKey = row.file_name?.trim()
if (!isPlatformAccount.value) return
const fileKey = row.file_key || row.file_name?.trim()
if (!fileKey) {
ElMessage.warning('当前任务没有可下载的原始文件')
return
}
try {
await StorageService.downloadFileByKey(fileKey)
ElMessage.success('原始文件下载已开始')
@@ -637,19 +599,12 @@
}
}
// 获取操作按钮
const getActions = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return []
const actions: any[] = []
const showDownloadFileAction = false
if (showDownloadFileAction && row.file_name?.trim() && hasAuth('device_task:download_file')) {
actions.push({
label: '下载文件',
handler: () => downloadTaskFile(row),
type: 'primary'
})
if (row.file_key && hasAuth('device_task:download_file')) {
actions.push({ label: '下载文件', handler: () => downloadTaskFile(row), type: 'primary' })
}
if (row.fail_count > 0 && hasAuth('device_task:download_fail_data')) {
actions.push({
label: '失败数据',
@@ -657,13 +612,19 @@
type: 'danger'
})
}
return actions
}
</script>
<style lang="scss" scoped>
.device-task-page {
.task-polling-error {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;

View File

@@ -97,7 +97,7 @@
</ElDialog>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入IoT卡" width="700px" align-center>
<ElDrawer v-model="importDialogVisible" title="批量导入IoT卡" direction="rtl" size="50%">
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
@@ -210,7 +210,7 @@
开始导入
</ElButton>
</template>
</ElDialog>
</ElDrawer>
</ArtTableFullScreen>
</template>
@@ -329,9 +329,9 @@
{
label: '创建时间',
prop: 'dateRange',
type: 'daterange',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'

View File

@@ -36,8 +36,6 @@
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -47,29 +45,31 @@
</ArtTable>
</ElCard>
<ElDialog
<ElDrawer
v-model="createDialogVisible"
title="新建订单套餐作废任务"
width="560px"
direction="rtl"
size="40%"
:close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDialogClosed"
>
<ElForm ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<ElForm ref="createFormRef" :model="createForm" :rules="createRules">
<ElFormItem label="CSV文件" prop="file">
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
accept=".csv,text/csv"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">将 CSV 文件拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">仅支持 .csv 文件,必填列:order_no</div>
</template>
</ElUpload>
<VoucherUpload
ref="csvUploadRef"
v-model="csvFileKeys"
voucher-name="CSV文件"
:max-count="1"
purpose="attachment"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV,每行一个订单号,最多 1000 行,最大 10MB"
@uploading-change="csvUploading = $event"
@files-change="handleCsvFilesChange"
/>
</ElFormItem>
<ElFormItem label="凭证附件">
<VoucherUpload
@@ -91,17 +91,19 @@
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="createDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="creating || voucherUploading"
:disabled="voucherUploading"
@click="handleCreateTask"
>
{{ voucherUploading ? '凭证上传中...' : '确认创建' }}
</ElButton>
<div class="drawer-footer">
<ElButton @click="createDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="createDrawerBusy"
:disabled="createDrawerBusy"
@click="handleCreateTask"
>
{{ voucherUploading ? '凭证上传中...' : '确认创建' }}
</ElButton>
</div>
</template>
</ElDialog>
</ElDrawer>
<PaymentVoucherDialog :file-keys="voucherPreviewKeys" @close="voucherPreviewKeys = []" />
</div>
@@ -109,22 +111,21 @@
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElButton,
ElCard,
ElDrawer,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElTag,
ElUpload
ElTag
} from 'element-plus'
import { Upload, UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { OrderPackageInvalidateTaskService, StorageService } from '@/api/modules'
import { Upload } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { OrderPackageInvalidateTaskService } from '@/api/modules'
import type {
CreateOrderPackageInvalidateTaskRequest,
OrderPackageInvalidateTask,
@@ -147,15 +148,20 @@
const loading = ref(false)
const creating = ref(false)
const csvUploading = ref(false)
const voucherUploading = ref(false)
const tableRef = ref()
const uploadRef = ref<UploadInstance>()
const csvUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createFormRef = ref<FormInstance>()
const createDialogVisible = ref(false)
const selectedFile = ref<File | null>(null)
const csvFileKeys = ref<string[]>([])
const csvFileName = ref('')
const taskList = ref<OrderPackageInvalidateTask[]>([])
const voucherPreviewKeys = ref<string[]>([])
const createDrawerBusy = computed(
() => creating.value || csvUploading.value || voucherUploading.value
)
const searchForm = reactive<OrderPackageInvalidateTaskQueryParams>({
page: 1,
@@ -384,58 +390,31 @@
getTableData()
}
const handleFileChange = (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!/\.csv$/i.test(file.name)) {
ElMessage.error('仅支持 CSV 文件')
uploadRef.value?.clearFiles()
selectedFile.value = null
createForm.file = ''
return
}
selectedFile.value = file
createForm.file = file.name
createFormRef.value?.validateField('file')
}
const handleFileRemove = () => {
selectedFile.value = null
createForm.file = ''
createFormRef.value?.validateField('file')
}
const uploadCsvFile = async (file: File) => {
const contentType = file.type || 'text/csv'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
throw new Error(uploadUrlRes.msg || '获取上传地址失败')
}
await StorageService.uploadFile(uploadUrlRes.data.upload_url, file, contentType)
return uploadUrlRes.data.file_key
const handleCsvFilesChange = (files: Array<{ file_name?: string }>) => {
csvFileName.value = files[0]?.file_name || ''
createForm.file = csvFileName.value
void createFormRef.value?.validateField('file')
}
const handleCreateTask = async () => {
if (!createFormRef.value) return
if (csvUploading.value) {
ElMessage.warning('CSV 文件上传中,请稍候')
return
}
if (voucherUploading.value) {
ElMessage.warning('凭证上传中,请稍候')
return
}
const valid = await createFormRef.value.validate().catch(() => false)
if (!valid || !selectedFile.value) return
if (!valid || !csvFileKeys.value[0] || !csvFileName.value) return
creating.value = true
try {
const fileKey = await uploadCsvFile(selectedFile.value)
const data: CreateOrderPackageInvalidateTaskRequest = {
file_key: fileKey,
file_name: selectedFile.value.name,
file_key: csvFileKeys.value[0],
file_name: csvFileName.value,
voucher_keys: toVoucherKeyList(createForm.voucher_keys),
remark: createForm.remark || undefined
}
@@ -453,24 +432,26 @@
const handleCreateDialogClosed = () => {
createFormRef.value?.resetFields()
uploadRef.value?.clearFiles()
csvUploadRef.value?.clearFiles(false)
voucherUploadRef.value?.clearFiles(false)
selectedFile.value = null
csvFileKeys.value = []
csvFileName.value = ''
createForm.file = ''
createForm.voucher_keys = []
createForm.remark = ''
csvUploading.value = false
voucherUploading.value = false
}
const getActions = (row: OrderPackageInvalidateTask) => {
const actions: any[] = []
if (hasAuth('order_package_invalidate_task:detail')) {
actions.push({ label: '详情', handler: () => handleNameClick(row), type: 'primary' })
}
return actions
}
onMounted(() => {
getTableData()
})
</script>
<style scoped>
.drawer-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -2,7 +2,6 @@
<ArtTableFullScreen>
<div class="task-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<!-- 页面头部 -->
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
@@ -10,35 +9,59 @@
</template>
返回
</ElButton>
<h2 class="detail-title">任务详情</h2>
<h2 class="detail-title">{{ taskTitle }}</h2>
</div>
<!-- 使用 DetailPage 组件显示任务信息 -->
<DetailPage v-if="taskDetail" :sections="detailSections" :data="taskDetail" />
<!-- 失败记录 -->
<div class="failure-section" v-if="taskDetail?.fail_count && taskDetail.fail_count > 0">
<div v-if="taskDetail?.fail_count && taskDetail.fail_count > 0" class="result-section">
<ElDivider content-position="left">
<span class="section-title">失败记录 ({{ taskDetail.fail_count }})</span>
<span class="section-title"
>{{ resultAction }}失败记录 ({{ taskDetail.fail_count }})</span
>
</ElDivider>
<ElTable :data="taskDetail.failed_items || []" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="virtual_no" label="设备号" min-width="180" />
<ElTableColumn prop="reason" label="失败原因" min-width="300" />
<ElTableColumn v-else label="设备标识" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" :label="`${resultAction}失败原因`" min-width="300" />
</ElTable>
</div>
<!-- 跳过记录 -->
<div class="skipped-section" v-if="taskDetail?.skip_count && taskDetail.skip_count > 0">
<div v-if="taskDetail?.skip_count && taskDetail.skip_count > 0" class="result-section">
<ElDivider content-position="left">
<span class="section-title">跳过记录 ({{ taskDetail.skip_count }})</span>
<span class="section-title"
>{{ resultAction }}跳过记录 ({{ taskDetail.skip_count }})</span
>
</ElDivider>
<ElTable :data="taskDetail.skipped_items || []" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="virtual_no" label="设备号" min-width="180" />
<ElTableColumn prop="reason" label="跳过原因" min-width="300" />
<ElTableColumn v-else label="设备标识" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" :label="`${resultAction}跳过原因`" min-width="300" />
</ElTable>
</div>
<div v-if="warningCount > 0" class="result-section">
<ElDivider content-position="left">
<span class="section-title">{{ resultAction }}警告记录 ({{ warningCount }})</span>
</ElDivider>
<ElTable :data="warningItems" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn label="设备标识" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" label="警告原因" min-width="300" />
</ElTable>
</div>
</ElCard>
@@ -47,34 +70,77 @@
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { CardService, DeviceService } from '@/api/modules'
import { ElDivider, ElIcon, ElMessage, ElTable, ElTableColumn, ElTag } from 'element-plus'
import type { TagProps } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/business/format'
import type { IotCardImportTaskDetail } from '@/types/api/card'
import type { DeviceImportTaskDetail } from '@/types/api/device'
import type { DeviceImportTaskDetail, DeviceImportTaskOperationType } from '@/types/api/device'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import DetailPage from '@/components/common/DetailPage.vue'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'TaskDetail' })
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
type TaskType = 'card' | 'device'
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
const taskDetail = ref<TaskDetail | null>(null)
const loading = ref(false)
const taskType = ref<TaskType>('card')
const loading = ref(false)
const getOperationLabel = (operationType?: DeviceImportTaskOperationType) => {
switch (operationType) {
case 'assign_shop':
return '分配目标代理'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '批量回收设备'
case 'import':
return '导入设备'
default:
return '设备任务'
}
}
const deviceTask = computed(() =>
taskType.value === 'device' ? (taskDetail.value as DeviceImportTaskDetail | null) : null
)
const taskTitle = computed(() =>
taskType.value === 'device'
? `${getOperationLabel(deviceTask.value?.operation_type)}任务详情`
: 'ICCID导入任务详情'
)
const resultAction = computed(() => {
if (taskType.value !== 'device') return '导入'
switch (deviceTask.value?.operation_type) {
case 'assign_shop':
return '分配'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '回收'
default:
return '导入'
}
})
const warningCount = computed(() =>
taskType.value === 'device' ? deviceTask.value?.warning_count || 0 : 0
)
const warningItems = computed(() =>
taskType.value === 'device' ? deviceTask.value?.warning_items || [] : []
)
// 获取状态标签类型
const getStatusType = (status?: number): TagProps['type'] => {
if (!status) return 'info'
switch (status) {
case 1:
return 'info'
@@ -89,8 +155,54 @@
}
}
// 详情页配置
const detailSections = computed((): DetailSection[] => {
const deviceFields: DetailSection['fields'] =
taskType.value === 'device'
? [
{
label: '操作名称',
prop: 'operation_name',
formatter: (value: string) =>
value || getOperationLabel(deviceTask.value?.operation_type)
},
{
label: '目标名称',
prop: 'target_name',
formatter: (value: string | null | undefined) => value || '-'
}
]
: []
const cardFields: DetailSection['fields'] =
taskType.value === 'card'
? [
{
label: '运营商',
prop: 'carrier_name',
formatter: (value: string) => value || '-'
},
{
label: '卡业务类型',
render: (data: any) => {
if (!data.card_category) return h('span', '-')
return h(
ElTag,
{
type: data.card_category === 'industry' ? 'warning' : 'success',
size: 'small'
},
() =>
data.card_category === 'normal'
? '普通卡'
: data.card_category === 'industry'
? '行业卡'
: data.card_category
)
}
}
]
: []
return [
{
title: '任务基本信息',
@@ -98,69 +210,44 @@
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{
label: '任务类型',
render: () => {
return h(
render: () =>
h(
ElTag,
{ type: taskType.value === 'device' ? 'warning' : 'primary', size: 'small' },
() => (taskType.value === 'device' ? '设备导入' : 'ICCID导入')
() =>
taskType.value === 'device'
? getOperationLabel(deviceTask.value?.operation_type)
: 'ICCID导入'
)
}
},
...deviceFields,
{ label: '批次号', prop: 'batch_no', formatter: (value: string) => value || '-' },
{ label: '文件名', prop: 'file_name', formatter: (value: string) => value || '-' },
{
label: '操作人',
prop: 'creator_name',
formatter: (value: string) => value || '-'
},
{ label: '操作人', prop: 'creator_name', formatter: (value: string) => value || '-' },
{
label: '任务状态',
render: (data: TaskDetail) => {
return h(ElTag, { type: getStatusType(data.status) }, () => data.status_text)
}
render: (data: TaskDetail) =>
h(
ElTag,
{ type: getStatusType(data.status) },
() => (data as DeviceImportTaskDetail).status_name || data.status_text || '-'
)
},
...(taskType.value === 'card'
? [
{
label: '运营商',
prop: 'carrier_name',
formatter: (value: any) => value || '-'
},
{
label: '卡业务类型',
render: (data: any) => {
if (!data.card_category) return h('span', '-')
return h(
ElTag,
{
type: data.card_category === 'industry' ? 'warning' : 'success',
size: 'small'
},
() =>
data.card_category === 'normal'
? '普通卡'
: data.card_category === 'industry'
? '行业卡'
: data.card_category
)
}
}
]
: []),
...cardFields,
{
label: '创建时间',
prop: 'created_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-')
formatter: (value: string) => (value ? formatDateTime(value) : '-')
},
{
label: '开始处理时间',
prop: 'started_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-')
formatter: (value: string | null) => (value ? formatDateTime(value) : '-')
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-')
formatter: (value: string | null) => (value ? formatDateTime(value) : '-')
},
...(taskDetail.value?.error_message
? [
@@ -168,13 +255,12 @@
label: '错误信息',
prop: 'error_message',
fullWidth: true,
render: (data: TaskDetail) => {
return h(
render: (data: TaskDetail) =>
h(
'span',
{ style: { color: 'var(--el-color-danger)' } },
data.error_message || ''
)
}
}
]
: [])
@@ -186,89 +272,82 @@
fields: [
{
label: '总数',
render: (data: TaskDetail) => {
return h(
render: (data: TaskDetail) =>
h(
'span',
{
style: {
fontSize: '16px',
fontWeight: 'bold',
color: 'var(--el-color-primary)'
}
style: { fontSize: '16px', fontWeight: 'bold', color: 'var(--el-color-primary)' }
},
String(data.total_count || 0)
)
}
},
{
label: '成功数',
render: (data: TaskDetail) => {
return h(ElTag, { type: 'success' }, () => String(data.success_count || 0))
}
render: (data: TaskDetail) =>
h(ElTag, { type: 'success' }, () => String(data.success_count || 0))
},
{
label: '失败数',
render: (data: TaskDetail) => {
return h(ElTag, { type: 'danger' }, () => String(data.fail_count || 0))
}
render: (data: TaskDetail) =>
h(ElTag, { type: 'danger' }, () => String(data.fail_count || 0))
},
{
label: '跳过数',
render: (data: TaskDetail) => {
return h(ElTag, { type: 'warning' }, () => String(data.skip_count || 0))
}
}
render: (data: TaskDetail) =>
h(ElTag, { type: 'warning' }, () => String(data.skip_count || 0))
},
...(taskType.value === 'device'
? [
{
label: '警告数',
render: (data: TaskDetail) =>
h(ElTag, { type: 'info' }, () =>
String((data as DeviceImportTaskDetail).warning_count || 0)
)
}
]
: [])
]
}
]
})
// 返回列表
const goBack = () => {
router.back()
}
const goBack = () => router.back()
// 获取任务详情
const getTaskDetail = async () => {
const taskId = route.query.id
const queryTaskType = route.query.task_type as TaskType | undefined
if (!taskId) {
ElMessage.error('缺少任务ID参数')
ElMessage.error('缺少任务 ID 参数')
goBack()
return
}
// 设置任务类型
if (queryTaskType) {
taskType.value = queryTaskType
if (queryTaskType) taskType.value = queryTaskType
if (taskType.value === 'device' && !isPlatformAccount.value) {
ElMessage.error('当前账号无权查看设备任务详情')
goBack()
return
}
loading.value = true
try {
if (taskType.value === 'device') {
// 获取设备导入任务详情
const res = await DeviceService.getImportTaskDetail(Number(taskId))
if (res.code === 0) {
taskDetail.value = res.data
}
if (res.code === 0) taskDetail.value = res.data
} else {
// 获取ICCID导入任务详情
const res = await CardService.getIotCardImportTaskDetail(Number(taskId))
if (res.code === 0) {
taskDetail.value = res.data
}
if (res.code === 0) taskDetail.value = res.data
}
} catch (error) {
console.error(error)
console.log('获取任务详情失败')
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
getTaskDetail()
void getTaskDetail()
})
</script>
@@ -288,8 +367,7 @@
}
}
.failure-section,
.skipped-section {
.result-section {
margin-top: 20px;
.section-title {

View File

@@ -0,0 +1,380 @@
<template>
<div class="detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="router.back()"
><template #icon
><ElIcon><ArrowLeft /></ElIcon></template
>返回</ElButton
>
<h2 class="detail-title">审计事件详情</h2>
<ElTag v-if="detail" :type="auditRiskMeta[detail.risk_level]?.type"
>{{ auditRiskMeta[detail.risk_level]?.label || detail.risk_level }}风险</ElTag
>
</div>
<div v-loading="loading">
<template v-if="detail">
<ElCard shadow="never" class="section-card">
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="分类">{{
auditCategoryLabels[detail.category] || detail.category
}}</ElDescriptionsItem>
<ElDescriptionsItem label="动作">{{ detail.action_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="结果"
><ElTag :type="auditResultMeta[detail.result]?.type">{{
auditResultMeta[detail.result]?.label
}}</ElTag></ElDescriptionsItem
>
<ElDescriptionsItem label="来源">{{
auditSourceLabels[detail.source] || detail.source
}}</ElDescriptionsItem>
<ElDescriptionsItem label="发生时间">{{
formatDateTime(detail.occurred_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="写入时间">{{
formatDateTime(detail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="业务范围">{{ scopeDisplay }}</ElDescriptionsItem>
<ElDescriptionsItem label="摘要" :span="3">{{ detail.summary }}</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.error_code || detail.error_summary"
label="错误信息"
:span="3"
>
<code v-if="detail.error_code">{{ detail.error_code }}</code>
<span v-if="detail.error_code && detail.error_summary"> · </span>
<span>{{ detail.error_summary }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<div class="detail-grid" :class="{ 'detail-grid--single': !userStore.isSuperAdmin }">
<ElCard shadow="never"
><template #header>操作者快照</template
><ElDescriptions :column="1"
><ElDescriptionsItem label="名称">{{ detail.actor_name || '-' }}</ElDescriptionsItem
><ElDescriptionsItem label="类型">{{
auditActorKindLabels[detail.actor_kind] || detail.actor_kind
}}</ElDescriptionsItem
><ElDescriptionsItem label="店铺">{{
namedId(detail.actor_shop_name, detail.actor_shop_id)
}}</ElDescriptionsItem
><ElDescriptionsItem label="企业">{{
namedId(detail.actor_enterprise_name, detail.actor_enterprise_id)
}}</ElDescriptionsItem></ElDescriptions
></ElCard
>
<ElCard v-if="userStore.isSuperAdmin" shadow="never"
><template #header>请求上下文</template
><ElDescriptions :column="1"
><ElDescriptionsItem label="请求">{{
[detail.request_method, detail.request_path].filter(Boolean).join(' ') || '-'
}}</ElDescriptionsItem
><ElDescriptionsItem label="request_id">{{
detail.request_id || '-'
}}</ElDescriptionsItem
><ElDescriptionsItem label="correlation_id">{{
detail.correlation_id || '-'
}}</ElDescriptionsItem
><ElDescriptionsItem label="IP">{{ detail.ip_address || '-' }}</ElDescriptionsItem
><ElDescriptionsItem label="User-Agent">{{
detail.user_agent || '-'
}}</ElDescriptionsItem></ElDescriptions
></ElCard
>
</div>
<ElCard v-if="hasBatchData" shadow="never" class="section-card">
<template #header>批次执行统计</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="处理总数">{{
detail.batch_total || 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
detail.success_count || 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ detail.fail_count || 0 }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never" class="section-card"
><template #header>调查引用</template
><AuditInvestigationLinks :refs="detail.investigation_refs" :show-resources="false"
/></ElCard>
<ElCard shadow="never" class="section-card">
<template #header>相关资源与快照</template>
<ElTable :data="detail.resources || []" :row-key="resourceRowKey">
<ElTableColumn
prop="display_name"
label="资源"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="类型" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{
auditResourceTypeLabels[row.resource_type] || row.resource_type
}}</template>
</ElTableColumn>
<ElTableColumn label="关系" width="110">
<template #default="{ row }">{{
auditResourceRelationLabels[row.relation] || row.relation
}}</template>
</ElTableColumn>
<ElTableColumn prop="role" label="业务角色" min-width="160" show-overflow-tooltip />
<ElTableColumn label="主体可见性" min-width="150">
<template #default="{ row }">{{
auditSubjectVisibilityLabels[row.subject_visibility] ||
row.subject_visibility ||
'-'
}}</template>
</ElTableColumn>
<ElTableColumn
prop="subject_summary"
label="主体摘要"
min-width="220"
show-overflow-tooltip
/>
<ElTableColumn label="写入时间" width="180">
<template #default="{ row }">{{
row.created_at ? formatDateTime(row.created_at) : '-'
}}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="220" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" @click="openSnapshotDialog(row)">查看快照</ElButton>
<ElButton
v-if="hasAuth(AUDIT_PERMISSIONS.auditEventResourceTimeline)"
link
type="primary"
:disabled="!row.resource_id"
@click="openResourceTimeline(row)"
>
资源审计时间线
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
<ElCard v-if="userStore.isSuperAdmin" shadow="never">
<template #header>元数据</template>
<pre>{{ auditJson(detail.metadata) }}</pre>
</ElCard>
</template>
<ElEmpty v-else-if="!loading" description="未找到审计事件" />
</div>
</ElCard>
<ElDialog
v-model="snapshotDialogVisible"
:title="snapshotDialogTitle"
width="min(960px, calc(100% - 32px))"
destroy-on-close
>
<div v-if="selectedResource" class="resource-snapshot-grid">
<ElCollapse class="resource-snapshot-section">
<ElCollapseItem title="身份快照" name="identity">
<pre>{{ auditJson(selectedResource.identity_snapshot) }}</pre>
</ElCollapseItem>
</ElCollapse>
<ElCollapse class="resource-snapshot-section">
<ElCollapseItem title="主体安全数据" name="subject">
<pre>{{ auditJson(selectedResource.subject_data) }}</pre>
</ElCollapseItem>
</ElCollapse>
<section class="resource-snapshot-diff">
<ElCollapse class="resource-snapshot-section">
<ElCollapseItem title="变更前" name="before">
<pre>{{ auditJson(selectedResource.before_data) }}</pre>
</ElCollapseItem>
</ElCollapse>
<ElCollapse class="resource-snapshot-section">
<ElCollapseItem title="变更后" name="after">
<pre>{{ auditJson(selectedResource.after_data) }}</pre>
</ElCollapseItem>
</ElCollapse>
</section>
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { AuditService } from '@/api/modules'
import type { AuditEventDetail, AuditResourceView } from '@/types/api'
import {
auditActorKindLabels,
auditCategoryLabels,
auditJson,
auditResourceRelationLabels,
auditResourceTypeLabels,
auditResultMeta,
auditRiskMeta,
auditScopeTypeLabels,
auditSourceLabels,
auditSubjectVisibilityLabels
} from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import AuditInvestigationLinks from '@/components/business/audit/AuditInvestigationLinks.vue'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'AuditEventDetail' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { hasAuth } = useAuth()
const loading = ref(false)
const detail = ref<AuditEventDetail>()
const snapshotDialogVisible = ref(false)
const selectedResource = ref<AuditResourceView>()
const snapshotDialogTitle = computed(() => {
const resource = selectedResource.value
if (!resource) return '资源快照'
const type = auditResourceTypeLabels[resource.resource_type] || resource.resource_type
return `资源快照 · ${resource.display_name || resource.resource_id || type}`
})
const scopeDisplay = computed(() => {
if (!detail.value) return '-'
const type = auditScopeTypeLabels[detail.value.scope_type] || detail.value.scope_type
const identity = detail.value.scope_name || detail.value.scope_id
return identity ? `${type} · ${identity}` : type
})
const hasBatchData = computed(() =>
Boolean(
(detail.value?.batch_total || 0) > 0 ||
(detail.value?.success_count || 0) > 0 ||
(detail.value?.fail_count || 0) > 0
)
)
const namedId = (name?: string | null, id?: number | null) => {
if (name && id !== null && id !== undefined) return `${name}(ID: ${id})`
return name || (id !== null && id !== undefined ? `ID: ${id}` : '-')
}
const resourceRowKey = (row: AuditResourceView) =>
[row.resource_type, row.resource_id || row.resource_key || row.sort_order || row.display_name]
.filter((item) => item !== null && item !== undefined && item !== '')
.join(':')
const openSnapshotDialog = (row: AuditResourceView) => {
selectedResource.value = row
snapshotDialogVisible.value = true
}
const openResourceTimeline = (row: AuditResourceView) => {
if (!row.resource_id) return
openAuditInvestigation({
mode: 'resource',
resourceType: row.resource_type,
id: row.resource_id
})
}
onMounted(async () => {
const id = String(route.params.eventId || '')
if (!id) return
loading.value = true
try {
detail.value = (await AuditService.getEventDetail(id)).data
} catch {
ElMessage.error('审计事件详情加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.detail-header .el-tag {
margin-left: auto;
}
.section-card {
margin-bottom: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.detail-grid--single {
grid-template-columns: 1fr;
}
.resource-snapshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 8px 0;
}
.resource-snapshot-diff {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-column: 1 / -1;
gap: 16px;
}
.resource-snapshot-section {
min-width: 0;
}
.resource-snapshot-section :deep(.el-collapse-item__content) {
padding-bottom: 0;
}
.resource-snapshot-grid pre {
max-height: 360px;
}
code,
pre {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
pre {
padding: 12px;
margin: 0;
overflow: auto;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
}
@media (width <= 768px) {
.detail-page {
padding: 12px;
}
.detail-grid {
grid-template-columns: 1fr;
}
.resource-snapshot-grid {
grid-template-columns: 1fr;
}
.resource-snapshot-diff {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<ArtTableFullScreen>
<div class="audit-events-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
show-expand
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="load"
/>
<ArtTable
row-key="event_id"
:loading="loading"
:data="items"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:margin-top="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import { AuditService } from '@/api/modules'
import type { AuditEventQuery, AuditEventView } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { auditResultMeta, auditRiskMeta } from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'AuditEvents' })
const router = useRouter()
const loading = ref(false)
const items = ref<AuditEventView[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
function createDefaultDateRange() {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
const format = (date: Date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z')
return [format(start), format(end)]
}
const initialSearchState = {
dateRange: createDefaultDateRange(),
category: undefined,
result: undefined,
risk: undefined,
source: undefined,
actor_kind: undefined,
scope_type: undefined
}
let searchForm = reactive({ ...initialSearchState })
const categoryOptions = [
{ label: '配置', value: 'configuration' },
{ label: '可靠性', value: 'reliability' },
{ label: '资产', value: 'asset' },
{ label: '安全', value: 'security' },
{ label: '身份', value: 'identity' },
{ label: '业务', value: 'business' }
]
const resultOptions = [
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
{ label: '拒绝', value: 'denied' },
{ label: '部分成功', value: 'partial' },
{ label: '未知', value: 'unknown' }
]
const riskOptions = [
{ label: '低', value: 'low' },
{ label: '普通', value: 'normal' },
{ label: '高', value: 'high' },
{ label: '严重', value: 'critical' }
]
const sourceOptions = [
{ label: '后台管理 API', value: 'admin_api' },
{ label: '个人客户 API', value: 'personal_api' },
{ label: '代理 OpenAPI', value: 'openapi' },
{ label: '异步 Worker', value: 'worker' },
{ label: '计划任务', value: 'scheduler' },
{ label: '外部系统回调', value: 'callback' }
]
const sourceLabels: Record<string, string> = Object.fromEntries(
sourceOptions.map((item) => [item.value, item.label])
)
const actorOptions = [
{ label: '人工账号', value: 'account' },
{ label: '个人客户', value: 'personal_customer' },
{ label: '开放接口账号', value: 'openapi' },
{ label: '系统任务', value: 'system_task' },
{ label: '计划任务', value: 'scheduled_job' },
{ label: '外部系统', value: 'external_system' }
]
const scopeOptions = [
{ label: '平台', value: 'platform' },
{ label: '店铺', value: 'shop' },
{ label: '个人客户', value: 'personal_customer' }
]
const handleDateRangeChange = (value: unknown) => {
if (!Array.isArray(value) || value.length !== 2) {
searchForm.dateRange = createDefaultDateRange()
}
}
const searchFormItems: SearchFormItem[] = [
{
label: '起止时间',
prop: 'dateRange',
type: 'datetimerange',
onChange: ({ val }) => handleDateRangeChange(val),
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
clearable: false
}
},
{
label: '类别',
prop: 'category',
type: 'select',
options: categoryOptions,
config: { clearable: true }
},
{
label: '结果',
prop: 'result',
type: 'select',
options: resultOptions,
config: { clearable: true }
},
{
label: '风险',
prop: 'risk',
type: 'select',
options: riskOptions,
config: { clearable: true }
},
{
label: '来源',
prop: 'source',
type: 'select',
options: sourceOptions,
config: { clearable: true }
},
{
label: '操作者',
prop: 'actor_kind',
type: 'select',
options: actorOptions,
config: { clearable: true }
},
{
label: '业务范围',
prop: 'scope_type',
type: 'select',
options: scopeOptions,
config: { clearable: true }
}
]
const columnOptions = [
{ label: '发生时间', prop: 'occurred_at' },
{ label: '动作', prop: 'action_name' },
{ label: '摘要', prop: 'summary' },
{ label: '主要资源', prop: 'primary_resource' },
{ label: '操作者', prop: 'actor_name' },
{ label: '来源', prop: 'source' },
{ label: '结果', prop: 'result' },
{ label: '风险', prop: 'risk_level' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'occurred_at',
label: '发生时间',
width: 180,
formatter: (row: AuditEventView) => formatDateTime(row.occurred_at)
},
{
prop: 'action_name',
label: '动作',
minWidth: 220,
formatter: (row: AuditEventView) =>
h(
ElButton,
{ type: 'primary', link: true, onClick: () => openDetail(row) },
() => row.action_name || '-'
)
},
{ prop: 'summary', label: '摘要', minWidth: 240, showOverflowTooltip: true },
{
prop: 'primary_resource',
label: '主要资源',
minWidth: 210,
showOverflowTooltip: true,
formatter: (row: AuditEventView) => {
const resource =
row.resources?.find((item) => item.relation === 'primary') || row.resources?.[0]
if (!resource) return '-'
return resource.display_name || resource.resource_key || resource.resource_id || '-'
}
},
{
prop: 'actor_name',
label: '操作者',
minWidth: 170,
formatter: (row: AuditEventView) => row.actor_name || row.actor_id || '-'
},
{
prop: 'source',
label: '来源',
showOverflowTooltip: true,
width: 130,
formatter: (row: AuditEventView) => sourceLabels[row.source] || row.source
},
{
prop: 'result',
label: '结果',
width: 100,
formatter: (row: AuditEventView) =>
h(
ElTag,
{ type: auditResultMeta[row.result]?.type || 'info' },
() => auditResultMeta[row.result]?.label || row.result
)
},
{
prop: 'risk_level',
label: '风险',
width: 90,
formatter: (row: AuditEventView) =>
h(
ElTag,
{ type: auditRiskMeta[row.risk_level]?.type || 'info', effect: 'plain' },
() => auditRiskMeta[row.risk_level]?.label || row.risk_level
)
}
])
const buildQuery = (): AuditEventQuery => ({
page: pagination.page,
page_size: pagination.pageSize,
created_from: searchForm.dateRange?.[0],
created_to: searchForm.dateRange?.[1],
category: searchForm.category,
result: searchForm.result,
risk: searchForm.risk,
source: searchForm.source,
actor_kind: searchForm.actor_kind,
scope_type: searchForm.scope_type
})
const load = async () => {
loading.value = true
try {
const data = (await AuditService.getEvents(buildQuery())).data
items.value = data.items
pagination.total = data.total
} catch {
ElMessage.error('审计事件加载失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
load()
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState, dateRange: createDefaultDateRange() })
pagination.page = 1
load()
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
load()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
load()
}
const openDetail = (row: AuditEventView) =>
router.push(`/audit/events/${encodeURIComponent(row.event_id)}`)
onMounted(load)
</script>
<style scoped lang="scss">
.audit-events-page {
height: 100%;
}
</style>

View File

@@ -0,0 +1,428 @@
<template>
<div class="integration-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="router.back()">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">外部集成交互详情</h2>
<ElTag
v-if="detail"
class="result-tag"
:type="integrationCategoryMeta[detail.result.category]?.type || 'info'"
>
{{ auditResultDisplay(detail.result.code, detail.result.name) }}
</ElTag>
</div>
<div v-loading="loading">
<template v-if="detail">
<ElCard shadow="never" class="section-card">
<template #header>交互概览</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="提供方">
{{ detail.identity.provider_name || detail.identity.provider }}
</ElDescriptionsItem>
<ElDescriptionsItem label="方向">
{{ detail.identity.direction_name || detail.identity.direction }}
</ElDescriptionsItem>
<ElDescriptionsItem label="操作">
{{ detail.identity.operation_name || detail.identity.operation }}
</ElDescriptionsItem>
<ElDescriptionsItem label="结果">
<ElTag :type="integrationCategoryMeta[detail.result.category]?.type || 'info'">
{{ auditResultDisplay(detail.result.code, detail.result.name) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="结果类别">
{{
integrationCategoryMeta[detail.result.category]?.label || detail.result.category
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="耗时"
>{{ detail.result.duration_ms }} ms</ElDescriptionsItem
>
<ElDescriptionsItem label="HTTP 状态">
{{ detail.result.http_status ?? '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="外部结果码">
{{ detail.result.provider_code || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="状态变更">
{{ booleanText(detail.result.state_changed) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.identity.external_id" label="外部业务标识" :span="3">
{{ detail.identity.external_id }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.result.provider_message"
label="外部结果摘要"
:span="3"
>
{{ detail.result.provider_message }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.result.recovery_strategy"
label="恢复策略说明"
:span="3"
>
{{ detail.result.recovery_strategy }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<div class="detail-grid section-card">
<ElCard v-if="hasResource" shadow="never">
<template #header>关联资源</template>
<ElDescriptions :column="1">
<ElDescriptionsItem label="资源类型">
{{
auditResourceTypeLabels[detail.resource?.type || ''] ||
detail.resource?.type ||
'-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务标识">
{{ detail.resource?.key || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never">
<template #header>调查入口</template>
<div v-if="hasAssociations" class="link-actions">
<ElButton
v-if="canViewRequestTimeline"
type="primary"
plain
@click="openAuditInvestigation({ mode: 'request', id: requestReference || '' })"
>
请求链路
</ElButton>
<ElButton
v-if="canViewCorrelationTimeline"
type="primary"
plain
@click="
openAuditInvestigation({
mode: 'correlation',
id: correlationReference || ''
})
"
>
业务关联链路
</ElButton>
<ElButton
v-if="canViewResourceTimeline"
type="primary"
plain
@click="openResourceTimeline"
>
资源审计时间线
</ElButton>
</div>
<span v-else class="muted">无可用调查引用</span>
<ElAlert
v-if="hasUnavailableFidelity"
class="fidelity-alert"
type="warning"
:closable="false"
title="部分关联缺少可靠标识,已隐藏相应入口。"
/>
</ElCard>
</div>
<div class="detail-grid section-card">
<ElCard shadow="never">
<template #header>触发信息</template>
<ElDescriptions :column="1">
<ElDescriptionsItem label="触发来源">
{{ detail.trigger.source || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务场景">
{{ detail.trigger.scene || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="尝试序号">
{{ detail.trigger.attempt }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never">
<template #header>时间信息</template>
<ElDescriptions :column="1">
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(detail.timestamps.created_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="计划时间">
{{ formatOptionalDate(detail.timestamps.scheduled_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">
{{ formatOptionalDate(detail.timestamps.started_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">
{{ formatDateTime(detail.timestamps.updated_at) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
<ElCard shadow="never" class="section-card">
<template #header>关联可靠性</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="尝试序列可靠">
{{ booleanText(detail.fidelity.attempt_sequence_reliable) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务链路可用">
{{ booleanText(detail.fidelity.correlation_available) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="资源引用可用">
{{ booleanText(detail.fidelity.resource_id_available) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="重试序列可用">
{{ booleanText(detail.fidelity.trigger_series_available) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="外部消息保真度" :span="2">
{{ detail.fidelity.provider_message_fidelity || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<div v-if="hasContentSummary" class="content-grid section-card">
<ElCard v-if="hasJsonContent(detail.content.request_summary)" shadow="never">
<template #header>请求摘要</template>
<pre>{{ auditJson(detail.content.request_summary) }}</pre>
</ElCard>
<ElCard v-if="hasJsonContent(detail.content.response_summary)" shadow="never">
<template #header>响应摘要</template>
<pre>{{ auditJson(detail.content.response_summary) }}</pre>
</ElCard>
<ElCard v-if="hasJsonContent(detail.content.metadata)" shadow="never">
<template #header>扩展信息</template>
<pre>{{ auditJson(detail.content.metadata) }}</pre>
</ElCard>
</div>
<ElCard v-if="detail.attempts?.length" shadow="never">
<template #header>尝试记录</template>
<ArtTable :data="detail.attempts" :pagination="false" :margin-top="0">
<template #default>
<ElTableColumn prop="attempt" label="序号" width="80" />
<ElTableColumn label="创建时间" width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.operation_name || row.operation }}</template>
</ElTableColumn>
<ElTableColumn label="结果" width="110">
<template #default="{ row }">
<ElTag :type="integrationCategoryType(row.result_category)">
{{ auditResultDisplay(row.result, row.result_name) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="耗时" width="100">
<template #default="{ row }">{{ row.duration_ms }} ms</template>
</ElTableColumn>
<ElTableColumn label="已发送" width="90">
<template #default="{ row }">{{ booleanText(row.sent) }}</template>
</ElTableColumn>
<ElTableColumn label="状态变更" width="100">
<template #default="{ row }">{{ booleanText(row.state_changed) }}</template>
</ElTableColumn>
</template>
</ArtTable>
</ElCard>
</template>
<ElEmpty v-else-if="!loading" description="未找到外部集成交互记录" />
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { AuditService } from '@/api/modules'
import type { IntegrationDetailResponse } from '@/types/api'
import {
auditJson,
auditResourceTypeLabels,
auditResultDisplay,
integrationCategoryMeta
} from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { resolveInvestigationReference } from '@/utils/business/auditNavigation'
import { useAuth } from '@/composables/useAuth'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
defineOptions({ name: 'AuditIntegrationDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const detail = ref<IntegrationDetailResponse>()
const hasUnavailableFidelity = computed(() =>
Object.values(detail.value?.fidelity || {}).some((value) => value === false)
)
const hasResource = computed(() =>
Boolean(detail.value?.resource?.type || detail.value?.resource?.key)
)
const requestReference = computed(() =>
resolveInvestigationReference(detail.value?.linkage.request_id)
)
const correlationReference = computed(() =>
resolveInvestigationReference(detail.value?.linkage.correlation_id, {
fidelity: detail.value?.fidelity.correlation_available
})
)
const resourceReference = computed(() =>
resolveInvestigationReference(detail.value?.resource?.id, {
fidelity: detail.value?.fidelity.resource_id_available
})
)
const canViewRequestTimeline = computed(
() =>
Boolean(requestReference.value) && hasAuth(AUDIT_PERMISSIONS.integrationDetailRequestTimeline)
)
const canViewCorrelationTimeline = computed(
() =>
Boolean(correlationReference.value) &&
hasAuth(AUDIT_PERMISSIONS.integrationDetailCorrelationTimeline)
)
const canViewResourceTimeline = computed(
() =>
Boolean(detail.value?.resource?.type && resourceReference.value) &&
hasAuth(AUDIT_PERMISSIONS.integrationDetailResourceTimeline)
)
const hasAssociations = computed(() =>
Boolean(
canViewRequestTimeline.value ||
canViewCorrelationTimeline.value ||
canViewResourceTimeline.value
)
)
const hasJsonContent = (value?: Record<string, unknown> | null) =>
Boolean(value && Object.keys(value).length)
const hasContentSummary = computed(() =>
Boolean(
hasJsonContent(detail.value?.content.request_summary) ||
hasJsonContent(detail.value?.content.response_summary) ||
hasJsonContent(detail.value?.content.metadata)
)
)
const booleanText = (value: boolean) => (value ? '是' : '否')
const integrationCategoryType = (category: unknown) =>
integrationCategoryMeta[category as keyof typeof integrationCategoryMeta]?.type || 'info'
const formatOptionalDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const openResourceTimeline = () => {
if (!detail.value?.resource?.type || !resourceReference.value) return
openAuditInvestigation({
mode: 'resource',
resourceType: detail.value.resource.type,
id: resourceReference.value
})
}
onMounted(async () => {
const id = String(route.params.integrationId || '')
if (!id) return
loading.value = true
try {
detail.value = (await AuditService.getIntegrationDetail(id)).data
} catch {
ElMessage.error('外部集成交互详情加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped lang="scss">
.integration-detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.result-tag {
margin-left: auto;
}
.section-card {
margin-bottom: 16px;
}
.detail-grid,
.content-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.content-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.link-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.muted {
color: var(--el-text-color-secondary);
}
.fidelity-alert {
margin-top: 12px;
}
pre {
max-height: 360px;
padding: 12px;
margin: 0;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--el-fill-color-light);
border-radius: 4px;
}
@media (width <= 992px) {
.content-grid {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.integration-detail-page {
padding: 12px;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,498 @@
<template>
<ArtTableFullScreen class="integration-scroll-container">
<div class="integration-page" id="table-full-screen">
<ElCard shadow="never" class="filter-card">
<ElForm>
<ElRow :gutter="16" class="filter-grid">
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElDatePicker
v-model="dateRange"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ssZ"
start-placeholder="开始时间"
end-placeholder="结束时间"
:clearable="false"
@change="handleDateRangeChange"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.provider" clearable placeholder="请选择提供方">
<ElOption
v-for="item in providerOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.direction" clearable placeholder="请选择方向">
<ElOption
v-for="item in directionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.result" clearable placeholder="请选择结果">
<ElOption
v-for="item in resultOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElButton type="primary" :icon="Search" @click="search">查询</ElButton>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElCard>
<div v-if="overview" class="metric-grid"
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--primary"
><ElIcon><Connection /></ElIcon></span
><div
><span>交互总数</span><strong>{{ overview.total }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--danger"
><ElIcon><WarningFilled /></ElIcon></span
><div
><span>异常数</span><strong class="danger">{{ overview.anomaly_count }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--success"
><ElIcon><Refresh /></ElIcon></span
><div
><span>状态变更</span><strong>{{ overview.state_changed_count }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--warning"
><ElIcon><Timer /></ElIcon></span
><div
><span>P95 耗时</span><strong>{{ overview.p95_duration_ms }} ms</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--info"
><ElIcon><QuestionFilled /></ElIcon></span
><div
><span>结果不明</span><strong>{{ overview.unknown_count }}</strong></div
></div
></ElCard
></div
>
<div v-if="overview" class="overview-grid">
<ElCard shadow="never">
<template #header>
<div class="section-title"
><ElIcon><Box /></ElIcon><span>提供方分布</span></div
>
</template>
<AuditDistributionChart
title="提供方分布"
:data="providerChartData"
@select="apply('provider', $event)"
/>
</ElCard>
<ElCard shadow="never">
<template #header>
<div class="section-title"
><ElIcon><Switch /></ElIcon><span>方向分布</span></div
>
</template>
<AuditDistributionChart
title="方向分布"
:data="directionChartData"
@select="apply('direction', $event)"
/>
</ElCard>
<ElCard shadow="never">
<template #header>
<div class="section-title"
><ElIcon><CircleCheckFilled /></ElIcon><span>结果分布</span></div
>
</template>
<AuditDistributionChart
title="结果分布"
type="bar"
:data="resultChartData"
@select="apply('result', $event)"
/>
</ElCard>
</div>
<ElCard shadow="never" class="art-table-card"
><ArtTableHeader v-model:columns="headerColumns" @refresh="load"
><template #left
><span class="section-title"
><ElIcon><List /></ElIcon><span>外部交互记录</span></span
></template
></ArtTableHeader
><ArtTable
:data="items"
:loading="loading"
row-key="integration_id"
:current-page="query.page"
:page-size="query.page_size"
:total="total"
:margin-top="16"
@size-change="
(size) => {
query.page_size = size
loadList()
}
"
@current-change="
(current) => {
query.page = current
loadList()
}
"
><template #default
><ElTableColumn label="时间" width="180"
><template #default="{ row }">{{
formatDateTime(row.created_at)
}}</template></ElTableColumn
><ElTableColumn label="提供方" min-width="130"
><template #default="{ row }">{{
row.provider_name || row.provider
}}</template></ElTableColumn
><ElTableColumn label="方向" prop="direction_name" width="90" /><ElTableColumn
label="操作"
min-width="190"
><template #default="{ row }"
><ElButton
link
type="primary"
@click="
router.push(`/audit/integrations/${encodeURIComponent(row.integration_id)}`)
"
>{{ row.operation_name || row.operation || '-' }}</ElButton
></template
></ElTableColumn
><ElTableColumn label="结果" width="120"
><template #default="{ row }"
><ElTag :type="categoryMeta(row).type">{{
auditResultDisplay(row.result, row.result_name)
}}</ElTag></template
></ElTableColumn
><ElTableColumn label="耗时" width="100"
><template #default="{ row }">{{
row.duration_ms == null ? '-' : `${row.duration_ms} ms`
}}</template></ElTableColumn
><ElTableColumn label="资源" min-width="150"
><template #default="{ row }">{{
row.resource?.key || row.resource?.id || '-'
}}</template></ElTableColumn
></template
></ArtTable
></ElCard
>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Box,
CircleCheckFilled,
Connection,
List,
QuestionFilled,
Refresh,
Search,
Switch,
Timer,
WarningFilled
} from '@element-plus/icons-vue'
import { AuditService } from '@/api/modules'
import type {
AuditNamedCount,
IntegrationDirection,
IntegrationListItem,
IntegrationOverview,
IntegrationProvider,
IntegrationQuery,
IntegrationResult
} from '@/types/api'
import { auditResultDisplay, integrationCategoryMeta } from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
import AuditDistributionChart from '@/components/business/audit/AuditDistributionChart.vue'
defineOptions({ name: 'AuditIntegrations' })
const router = useRouter()
const loading = ref(false)
const headerColumns = ref([])
const dateRange = ref<string[]>(createDefaultDateRange())
const query = reactive<IntegrationQuery>({ page: 1, page_size: 20 })
const overview = ref<IntegrationOverview>()
const items = ref<IntegrationListItem[]>([])
const total = ref(0)
const providerOptions: Array<{ label: string; value: IntegrationProvider }> = [
{ label: '中国电信', value: 'ctcc' },
{ label: '中国移动', value: 'cmcc' },
{ label: '中国联通', value: 'cucc' },
{ label: '微信支付', value: 'wechat_pay' },
{ label: '支付宝', value: 'alipay' },
{ label: '富友', value: 'fuiou' },
{ label: '企业微信', value: 'wecom' },
{ label: '设备网关', value: 'gateway' }
]
const directionOptions: Array<{ label: string; value: IntegrationDirection }> = [
{ label: '入站', value: 'inbound' },
{ label: '出站', value: 'outbound' }
]
const resultOptions: Array<{ label: string; value: IntegrationResult }> = [
{ label: '待处理', value: 'pending' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
{ label: '结果未知', value: 'unknown' },
{ label: '未找到', value: 'not_found' },
{ label: '无效载荷', value: 'invalid_payload' },
{ label: '冲突', value: 'conflict' },
{ label: '已忽略', value: 'ignored' },
{ label: '已合并', value: 'merged' },
{ label: '已限频', value: 'rate_limited' },
{ label: '已提前完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' }
]
const aggregateChartData = (values: AuditNamedCount[]) => {
const totals = new Map<string, AuditNamedCount>()
values.forEach((item) => {
const key = item.name || item.code
const current = totals.get(key)
if (current) current.count += Number(item.count) || 0
else totals.set(key, { ...item, count: Number(item.count) || 0 })
})
return [...totals.values()].sort((a, b) => b.count - a.count)
}
const providerChartData = computed(() => aggregateChartData(overview.value?.providers || []))
const directionChartData = computed(() => aggregateChartData(overview.value?.directions || []))
const resultChartData = computed(() => aggregateChartData(overview.value?.results || []))
function createDefaultDateRange() {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
const format = (date: Date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z')
return [format(start), format(end)]
}
const handleDateRangeChange = (value: string[] | null) => {
if (!value || value.length !== 2) dateRange.value = createDefaultDateRange()
}
const syncDates = () => {
if (dateRange.value.length !== 2) dateRange.value = createDefaultDateRange()
query.created_from = dateRange.value[0]
query.created_to = dateRange.value[1]
}
const loadList = async () => {
syncDates()
const data = (await AuditService.getIntegrations(query)).data
items.value = data.items
total.value = data.total
}
const load = async () => {
loading.value = true
try {
syncDates()
overview.value = (await AuditService.getIntegrationOverview({ ...query, bucket: 'day' })).data
await loadList()
} catch {
ElMessage.error('外部交互调查加载失败')
} finally {
loading.value = false
}
}
const search = () => {
query.page = 1
load()
}
const apply = (field: 'provider' | 'direction' | 'result', code: string) => {
;(query as Record<string, unknown>)[field] = code
search()
}
const categoryMeta = (row: IntegrationListItem) =>
integrationCategoryMeta[row.result_category] || {
label: row.result_category,
type: 'info' as const
}
onMounted(load)
</script>
<style scoped>
.integration-scroll-container {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.integration-page {
box-sizing: border-box;
flex: none !important;
height: auto !important;
min-height: 100%;
padding: 20px;
}
.filter-card,
.metric-grid,
.overview-grid {
margin-bottom: 16px;
}
.filter-grid {
row-gap: 16px;
}
.filter-card :deep(.el-form-item) {
width: 100%;
margin-right: 0;
margin-bottom: 0;
}
.filter-card :deep(.el-form-item__content) {
min-width: 0;
}
.filter-card :deep(.el-select),
.filter-card :deep(.el-date-editor),
.filter-card :deep(.el-button) {
width: 100% !important;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.metric-grid span {
display: block;
color: var(--el-text-color-secondary);
}
.metric-content {
display: flex;
gap: 14px;
align-items: center;
}
.metric-icon {
display: inline-flex !important;
flex: 0 0 42px;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 20px;
border-radius: 10px;
}
.metric-icon--primary {
color: var(--el-color-primary) !important;
background: var(--el-color-primary-light-9);
}
.metric-icon--danger {
color: var(--el-color-danger) !important;
background: var(--el-color-danger-light-9);
}
.metric-icon--success {
color: var(--el-color-success) !important;
background: var(--el-color-success-light-9);
}
.metric-icon--warning {
color: var(--el-color-warning) !important;
background: var(--el-color-warning-light-9);
}
.metric-icon--info {
color: var(--el-color-info) !important;
background: var(--el-color-info-light-9);
}
.metric-grid strong {
display: block;
margin-top: 8px;
font-size: 24px;
}
.danger {
color: var(--el-color-danger);
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.section-title {
display: inline-flex;
gap: 7px;
align-items: center;
font-weight: 600;
color: var(--el-text-color-primary);
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
@media (width <= 1100px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
.overview-grid {
grid-template-columns: 1fr;
}
}
@media (width >= 1200px) {
.filter-col {
flex: 0 0 20%;
max-width: 20%;
}
}
@media (width <= 520px) {
.integration-page {
padding: 12px;
}
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<ArtTableFullScreen class="risk-scroll-container">
<div class="risk-page" id="table-full-screen">
<ElCard shadow="never" class="filter-card">
<ElForm>
<ElRow :gutter="16" class="filter-grid">
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElDatePicker
v-model="dateRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DDTHH:mm:ssZ"
:clearable="false"
@change="handleDateRangeChange"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.risk" clearable placeholder="请选择风险">
<ElOption
v-for="item in overview?.risks || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.result" clearable placeholder="请选择结果">
<ElOption
v-for="item in overview?.results || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.action" filterable clearable placeholder="请选择动作">
<ElOption
v-for="item in overview?.actions || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem class="query-form-item">
<ElButton type="primary" :icon="Search" @click="search">查询</ElButton>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElCard>
<div v-if="overview" class="metric-grid"
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--primary"
><ElIcon><DataAnalysis /></ElIcon></span
><div
><span>事件总数</span><strong>{{ overview.total }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--danger"
><ElIcon><WarningFilled /></ElIcon></span
><div
><span>高风险信号</span
><strong class="danger">{{ signalCount('high_risk') }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--warning"
><ElIcon><Money /></ElIcon></span
><div
><span>资金信号</span><strong>{{ signalCount('finance') }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--success"
><ElIcon><Lock /></ElIcon></span
><div
><span>安全信号</span><strong>{{ signalCount('security') }}</strong></div
></div
></ElCard
></div
>
<div v-if="overview" class="overview-grid"
><ElCard shadow="never"
><template #header
><div class="section-title"
><ElIcon><WarningFilled /></ElIcon><span>风险分布</span></div
></template
><AuditDistributionChart
title="风险分布"
:data="riskChartData"
@select="apply('risk', $event)" /></ElCard
><ElCard shadow="never"
><template #header
><div class="section-title"
><ElIcon><CircleCheckFilled /></ElIcon><span>结果分布</span></div
></template
><AuditDistributionChart
title="结果分布"
:data="resultChartData"
@select="apply('result', $event)" /></ElCard
><ElCard shadow="never"
><template #header
><div class="section-title"
><ElIcon><Connection /></ElIcon><span>来源分布</span></div
></template
><AuditDistributionChart
title="来源分布"
type="bar"
:data="sourceChartData"
@select="apply('source', $event)" /></ElCard
></div>
<ElCard shadow="never" class="art-table-card" v-loading="loading"
><ArtTableHeader v-model:columns="headerColumns" @refresh="load"
><template #left
><div class="card-title"
><span class="section-title"
><ElIcon><DataAnalysis /></ElIcon><span>风险事件明细</span></span
></div
></template
></ArtTableHeader
><div class="risk-event-table">
<AuditEventTable
:items="events"
:detail-enabled="hasAuth(AUDIT_PERMISSIONS.riskEventDetail)"
@detail="(row) => router.push(`/audit/events/${encodeURIComponent(row.event_id)}`)"
/>
</div>
<div class="pagination"
><ElPagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="loadEvents" /></div
></ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
CircleCheckFilled,
Connection,
DataAnalysis,
Lock,
Money,
Search,
WarningFilled
} from '@element-plus/icons-vue'
import { AuditService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import type {
AuditEventView,
AuditNamedCount,
AuditRiskEventQuery,
AuditRiskOverview
} from '@/types/api'
import AuditEventTable from '@/components/business/audit/AuditEventTable.vue'
import AuditDistributionChart from '@/components/business/audit/AuditDistributionChart.vue'
defineOptions({ name: 'AuditRisks' })
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const headerColumns = ref([])
const dateRange = ref<string[]>(createDefaultDateRange())
const overview = ref<AuditRiskOverview>()
const events = ref<AuditEventView[]>([])
const total = ref(0)
const query = reactive<AuditRiskEventQuery>({ page: 1, page_size: 20 })
const aggregateChartData = (items: AuditNamedCount[]) => {
const totals = new Map<string, AuditNamedCount>()
items.forEach((item) => {
const key = item.name || item.code
const current = totals.get(key)
if (current) current.count += Number(item.count) || 0
else totals.set(key, { ...item, count: Number(item.count) || 0 })
})
return [...totals.values()].sort((a, b) => b.count - a.count)
}
const riskChartData = computed(() => aggregateChartData(overview.value?.risks || []))
const resultChartData = computed(() => aggregateChartData(overview.value?.results || []))
const sourceChartData = computed(() => aggregateChartData(overview.value?.sources || []))
function createDefaultDateRange() {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
const format = (date: Date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z')
return [format(start), format(end)]
}
const handleDateRangeChange = (value: string[] | null) => {
if (!value || value.length !== 2) dateRange.value = createDefaultDateRange()
}
const syncDates = () => {
if (dateRange.value.length !== 2) dateRange.value = createDefaultDateRange()
query.created_from = dateRange.value[0]
query.created_to = dateRange.value[1]
if (
dateRange.value?.length === 2 &&
new Date(dateRange.value[1]).getTime() - new Date(dateRange.value[0]).getTime() >
31 * 86400000
) {
ElMessage.warning('风险查询时间范围最长为 31 天')
return false
}
return true
}
const loadEvents = async () => {
if (!syncDates()) return
const data = (await AuditService.getRiskEvents(query)).data
events.value = data.items
total.value = data.total
}
const load = async () => {
if (!syncDates()) return
loading.value = true
try {
overview.value = (await AuditService.getRiskOverview(query)).data
await loadEvents()
} catch {
ElMessage.error('风险调查数据加载失败')
} finally {
loading.value = false
}
}
const search = () => {
query.page = 1
load()
}
const signalCount = (code: string) =>
overview.value?.signals.find((item) => item.code === code)?.count || 0
const apply = (field: 'risk' | 'result' | 'source', code: string) => {
;(query as Record<string, unknown>)[field] = code
search()
}
onMounted(load)
</script>
<style scoped>
.risk-scroll-container {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.risk-page {
box-sizing: border-box;
flex: none !important;
height: auto !important;
min-height: 100%;
padding: 20px;
}
.card-title {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.filter-card,
.metric-grid,
.overview-grid {
margin-bottom: 16px;
}
.filter-grid {
row-gap: 16px;
}
.filter-card :deep(.el-form-item) {
width: 100%;
margin-right: 0;
margin-bottom: 0;
}
.filter-card :deep(.el-form-item__content) {
min-width: 0;
}
.filter-card :deep(.el-select),
.filter-card :deep(.el-date-editor) {
width: 100% !important;
}
.query-form-item :deep(.el-button) {
width: 100%;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-grid span {
display: block;
color: var(--el-text-color-secondary);
}
.metric-content {
display: flex;
gap: 14px;
align-items: center;
}
.metric-icon {
display: inline-flex !important;
flex: 0 0 42px;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 20px;
border-radius: 10px;
}
.metric-icon--primary {
color: var(--el-color-primary) !important;
background: var(--el-color-primary-light-9);
}
.metric-icon--danger {
color: var(--el-color-danger) !important;
background: var(--el-color-danger-light-9);
}
.metric-icon--warning {
color: var(--el-color-warning) !important;
background: var(--el-color-warning-light-9);
}
.metric-icon--success {
color: var(--el-color-success) !important;
background: var(--el-color-success-light-9);
}
.metric-grid strong {
display: block;
margin-top: 8px;
font-size: 28px;
}
.danger {
color: var(--el-color-danger);
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.section-title {
display: inline-flex;
gap: 7px;
align-items: center;
font-weight: 600;
color: var(--el-text-color-primary);
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.risk-event-table {
margin-top: 16px;
}
@media (width <= 900px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
.overview-grid {
grid-template-columns: 1fr;
}
}
@media (width >= 1200px) {
.filter-col {
flex: 0 0 20%;
max-width: 20%;
}
}
@media (width <= 520px) {
.risk-page {
padding: 12px;
}
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -30,9 +30,9 @@
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actions="getAuditActions"
:actionsWidth="120"
:marginTop="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -48,7 +48,7 @@
<ElDrawer
v-model="detailDrawerVisible"
:title="`${currentShop?.shop_name || ''} - 佣金详情`"
size="60%"
size="65%"
destroy-on-close
>
<ElTabs v-model="activeTab" class="detail-tabs">
@@ -249,11 +249,11 @@
<ElFormItem label="交易日期">
<ElDatePicker
v-model="mainWalletSearchForm.date_range"
type="daterange"
type="datetimerange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
value-format="YYYY-MM-DDTHH:mm:ssZ"
unlink-panels
/>
</ElFormItem>
@@ -269,9 +269,22 @@
<ElFormItem class="main-wallet-filter__actions">
<ElButton type="primary" @click="handleMainWalletSearch">搜索</ElButton>
<ElButton @click="handleMainWalletReset">重置</ElButton>
<ElButton
v-if="hasAuth('agent_wallet_transaction:export')"
@click="mainWalletExportDialogVisible = true"
>
导出
</ElButton>
</ElFormItem>
</div>
</ElForm>
<ExportTaskCreateDialog
v-model="mainWalletExportDialogVisible"
scene="agent_wallet_transaction"
:query="mainWalletExportQuery"
confirm-permission="agent_wallet_transaction:export"
title="导出代理主钱包流水"
/>
<ArtTable
ref="mainWalletTableRef"
row-key="id"
@@ -446,6 +459,10 @@
CommissionSourceMap
} from '@/config/constants/commission'
import { RoutesAlias } from '@/router/routesAlias'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveFinanceAuditTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'AgentCommission' })
@@ -459,6 +476,20 @@
const tableRef = ref()
const summaryList = ref<ShopFundSummaryItem[]>([])
const getAuditActions = (row: ShopFundSummaryItem) => {
if (!hasAuth(AUDIT_PERMISSIONS.financeTimeline)) return []
const target = resolveFinanceAuditTarget('shop_id', row.shop_id)
return target
? [
{
label: '资金链路',
handler: () => openAuditInvestigation(target),
type: 'primary' as const
}
]
: []
}
// 搜索表单
const searchForm = reactive({
shop_name: '',
@@ -543,6 +574,17 @@
date_range: [],
asset_identifier: ''
})
const mainWalletExportDialogVisible = ref(false)
const mainWalletExportQuery = computed(() => {
const [startDate, endDate] = mainWalletSearchForm.date_range || []
return {
shop_id: currentShop.value?.shop_id,
transaction_type: mainWalletSearchForm.transaction_type,
start_date: startDate || undefined,
end_date: endDate || undefined,
asset_identifier: mainWalletSearchForm.asset_identifier.trim() || undefined
}
})
// 列配置
const columnOptions = [
@@ -550,7 +592,14 @@
{ label: '店铺名称', prop: 'shop_name' },
{ label: '用户名', prop: 'username' },
{ label: '手机号', prop: 'phone' },
{ label: '预充值余额', prop: 'main_balance' },
{ label: '账面余额', prop: 'main_balance' },
{ label: '冻结金额', prop: 'main_frozen_balance' },
{ label: '现金可用', prop: 'cash_available_balance' },
{ label: '实际信用额度', prop: 'credit_limit' },
{ label: '总可用金额', prop: 'available_balance' },
{ label: '欠款金额', prop: 'debt_amount' },
{ label: '钱包版本', prop: 'version' },
{ label: '余额预警', prop: 'low_balance_warning' },
{ label: '总佣金', prop: 'total_commission' },
{ label: '可提现', prop: 'available_commission' },
{ label: '冻结中', prop: 'frozen_commission' },
@@ -611,6 +660,7 @@
prop: 'shop_name',
label: '店铺名称',
minWidth: 160,
fixed: 'left',
showOverflowTooltip: true,
formatter: (row: ShopFundSummaryItem) => {
return h(
@@ -638,7 +688,7 @@
},
{
prop: 'main_balance',
label: '预充值余额',
label: '账面余额',
minWidth: 130,
formatter: (row: ShopFundSummaryItem) => {
return h(
@@ -648,6 +698,73 @@
)
}
},
{
prop: 'main_frozen_balance',
label: '冻结金额',
minWidth: 120,
formatter: (row: ShopFundSummaryItem) => formatMoney(row.main_frozen_balance)
},
{
prop: 'cash_available_balance',
label: '现金可用',
minWidth: 120,
formatter: (row: ShopFundSummaryItem) => formatMoney(row.cash_available_balance)
},
{
prop: 'credit_limit',
label: '实际信用额度',
minWidth: 150,
formatter: (row: ShopFundSummaryItem) => {
if (!row.credit_enabled) {
return h('span', { style: 'color: var(--el-text-color-secondary)' }, '未启用 / ¥0.00')
}
return h(
'span',
{ style: 'color: var(--el-color-warning); font-weight: 500' },
formatMoney(row.credit_limit)
)
}
},
{
prop: 'available_balance',
label: '总可用金额',
minWidth: 130,
formatter: (row: ShopFundSummaryItem) => {
return h(
'span',
{ style: 'color: var(--el-color-success); font-weight: 500' },
formatMoney(row.available_balance)
)
}
},
{
prop: 'debt_amount',
label: '欠款金额',
minWidth: 130,
formatter: (row: ShopFundSummaryItem) => {
const amountText = formatMoney(row.debt_amount)
if (!row.is_in_debt) return amountText
return h('span', { style: 'color: var(--el-color-danger); font-weight: 500' }, amountText)
}
},
{
prop: 'version',
label: '钱包版本',
minWidth: 90,
formatter: (row: ShopFundSummaryItem) => row.version ?? '-'
},
{
prop: 'low_balance_warning',
label: '余额预警',
minWidth: 170,
formatter: (row: ShopFundSummaryItem) => {
if (!row.low_balance_warning) return '-'
return h(ElTag, { type: 'danger' }, () => '现金余额不足100元')
}
},
{
prop: 'total_commission',
label: '总佣金',
@@ -698,10 +815,6 @@
}
])
onMounted(() => {
getTableData()
})
// 获取代理商资金汇总列表
const getTableData = async () => {
loading.value = true
@@ -725,6 +838,14 @@
}
}
const loadInitialData = async () => {
await getTableData()
const shopId = Number(route.query.shop_id)
if (!shopId) return
const targetShop = summaryList.value.find((item) => item.shop_id === shopId)
if (targetShop) showDetail(targetShop)
}
// 搜索
const handleSearch = () => {
pagination.page = 1
@@ -773,21 +894,11 @@
loadCommissionRecords()
}
onMounted(() => {
void loadInitialData()
})
// 获取操作按钮
const getActions = (row: ShopFundSummaryItem) => {
const actions: any[] = []
if (hasAuth('agent_commission:detail')) {
actions.push({
label: '查看详情',
handler: () => showDetail(row),
type: 'primary'
})
}
return actions
}
// 监听tab切换
watch(activeTab, (newTab) => {
if (newTab === 'commission') {

View File

@@ -28,6 +28,45 @@
v-permission="'my_commission:add'"
>发起提现</ElButton
>
<div class="commission-summary">
<div class="commission-summary-item">
<span class="summary-label">总佣金</span>
<strong class="summary-value">{{
formatMoney(summary.total_commission, false)
}}</strong>
</div>
<div class="commission-summary-item">
<span class="summary-label">可提现</span>
<strong class="summary-value is-success">{{
formatMoney(summary.available_commission, false)
}}</strong>
</div>
<div class="commission-summary-item">
<span class="summary-label">冻结中</span>
<strong class="summary-value">{{
formatMoney(summary.frozen_commission, false)
}}</strong>
</div>
<div class="commission-summary-item">
<span class="summary-label">提现中</span>
<strong class="summary-value is-warning">{{
formatMoney(summary.withdrawing_commission, false)
}}</strong>
</div>
<div class="commission-summary-item">
<span class="summary-label">已提现</span>
<strong class="summary-value">{{
formatMoney(summary.withdrawn_commission, false)
}}</strong>
</div>
<div class="commission-summary-item">
<span class="summary-label">未提现</span>
<strong class="summary-value">{{
formatMoney(summary.unwithdraw_commission, false)
}}</strong>
</div>
</div>
</template>
</ArtTableHeader>
@@ -121,15 +160,16 @@
<ElFormItem label="提现金额" prop="amount">
<ElInputNumber
v-model="withdrawalForm.amount"
:min="100"
:max="summary.available_commission"
:precision="0"
:step="100"
:min="withdrawalAmountMin"
:max="withdrawalAmountMax"
:precision="2"
:step="1"
:disabled="isWithdrawalAmountDisabled"
style="width: 100%"
placeholder="请输入提现金额(分)"
placeholder="请输入提现金额(元)"
/>
<div style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary)">
金额单位为分,如1元=100分
金额单位为元
</div>
</ElFormItem>
<ElFormItem label="提现方式" prop="withdrawal_method">
@@ -183,7 +223,12 @@
<template #footer>
<div class="dialog-footer">
<ElButton @click="withdrawalDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmitWithdrawal" :loading="submitLoading">
<ElButton
type="primary"
@click="handleSubmitWithdrawal"
:loading="submitLoading"
:disabled="isWithdrawalAmountDisabled"
>
提交
</ElButton>
</div>
@@ -194,7 +239,7 @@
<script setup lang="ts">
import { h } from 'vue'
import { CommissionService } from '@/api/modules'
import { CommissionService, OrderService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
@@ -207,11 +252,12 @@
WithdrawalStatus
} from '@/types/api/commission'
import { WithdrawalMethod } from '@/types/api/commission'
import type { Order, OrderQueryParams } from '@/types/api/order'
import { WithdrawalStatusMap, WithdrawalMethodMap } from '@/config/constants/commission'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { formatDateTime, formatMoney, yuanToFen } from '@/utils/business/format'
defineOptions({ name: 'MyCommission' })
@@ -233,7 +279,8 @@
available_commission: 0,
frozen_commission: 0,
withdrawing_commission: 0,
withdrawn_commission: 0
withdrawn_commission: 0,
unwithdraw_commission: 0
})
// ==================== 佣金明细 ====================
@@ -255,6 +302,34 @@
// 搜索表单
const commissionSearchForm = reactive({ ...initialCommissionSearchState })
const orderOptions = ref<Order[]>([])
const orderSearchLoading = ref(false)
let orderSearchRequestId = 0
const searchCommissionOrders = async (query: string) => {
const requestId = ++orderSearchRequestId
const orderNo = query.trim()
orderSearchLoading.value = true
try {
const params: OrderQueryParams = {
page: 1,
page_size: 20,
order_no: orderNo || undefined
}
const res = await OrderService.getOrders(params)
if (res.code === 0 && requestId === orderSearchRequestId) {
orderOptions.value = res.data.items || []
}
} catch (error) {
console.error('Search commission orders failed:', error)
} finally {
if (requestId === orderSearchRequestId) {
orderSearchLoading.value = false
}
}
}
// 搜索表单配置
const commissionSearchItems = computed<SearchFormItem[]>(() => [
{
@@ -306,20 +381,30 @@
{
label: '订单号',
prop: 'order_no',
type: 'input',
type: 'select',
options: () =>
orderOptions.value.map((order) => ({
label: order.order_no,
value: order.order_no
})),
config: {
clearable: true,
filterable: true,
remote: true,
reserveKeyword: true,
loading: orderSearchLoading.value,
remoteMethod: (query: string) => searchCommissionOrders(query),
placeholder: '请输入订单号'
}
},
{
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
clearable: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期'
},
@@ -394,24 +479,25 @@
{
prop: 'iccid',
label: 'ICCID',
width: 200,
formatter: (row: ShopCommissionRecordItem) => row.iccid || '-'
},
{
prop: 'virtual_no',
label: '虚拟号',
width: 130,
width: 180,
formatter: (row: ShopCommissionRecordItem) => row.virtual_no || '-'
},
{
prop: 'order_no',
label: '订单号',
width: 160,
width: 220,
formatter: (row: ShopCommissionRecordItem) => row.order_no || '-'
},
{
prop: 'seller_shop_name',
label: '卖家店铺',
width: 150,
width: 120,
formatter: (row: ShopCommissionRecordItem) => row.seller_shop_name || '-'
},
{
@@ -525,11 +611,11 @@
{
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
clearable: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期'
},
@@ -554,7 +640,6 @@
// 列配置
const withdrawalColumnOptions = [
{ label: 'ID', prop: 'id' },
{ label: '提现单号', prop: 'withdrawal_no' },
{ label: '金额', prop: 'amount' },
{ label: '手续费', prop: 'fee' },
@@ -571,15 +656,10 @@
// 动态列配置
const { columnChecks: withdrawalColumnChecks, columns: withdrawalColumns } = useCheckedColumns(
() => [
{
prop: 'id',
label: 'ID',
width: 80
},
{
prop: 'withdrawal_no',
label: '提现单号',
minWidth: 160
minWidth: 180
},
{
prop: 'amount',
@@ -623,7 +703,8 @@
{
prop: 'reject_reason',
label: '拒绝原因',
minWidth: 120,
minWidth: 230,
showOverflowTooltip: true,
formatter: (row: WithdrawalRequestItem) => row.reject_reason || '-'
},
{
@@ -703,10 +784,25 @@
const withdrawalDialogVisible = ref(false)
const submitLoading = ref(false)
const withdrawalFormRef = ref<FormInstance>()
const minimumWithdrawalFen = 100
const availableCommissionYuan = computed(() =>
Number((summary.value.available_commission / 100).toFixed(2))
)
const isWithdrawalAmountDisabled = computed(
() => summary.value.available_commission < minimumWithdrawalFen
)
const withdrawalAmountMin = computed(() => (isWithdrawalAmountDisabled.value ? 0 : 1))
const withdrawalAmountMax = computed(() =>
Math.max(withdrawalAmountMin.value, availableCommissionYuan.value)
)
type WithdrawalForm = Omit<SubmitWithdrawalParams, 'amount'> & {
amount: number | undefined
}
// 提现表单
const withdrawalForm = reactive<SubmitWithdrawalParams>({
amount: 0,
const withdrawalForm = reactive<WithdrawalForm>({
amount: undefined,
withdrawal_method: WithdrawalMethod.ALIPAY,
account_name: '',
account_number: '',
@@ -720,9 +816,15 @@
{ required: true, message: '请输入提现金额', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value < 100) {
callback(new Error('提现金额不能小于100分(1元)'))
} else if (value > summary.value.available_commission) {
const amountFen = yuanToFen(value)
if (isWithdrawalAmountDisabled.value) {
callback(new Error('当前无可提现金额'))
} else if (amountFen === undefined) {
callback(new Error('请输入提现金额'))
} else if (amountFen < minimumWithdrawalFen) {
callback(new Error('提现金额不能小于1元'))
} else if (amountFen > summary.value.available_commission) {
callback(new Error('提现金额不能大于可提现佣金'))
} else {
callback()
@@ -756,17 +858,13 @@
// 显示提现对话框
const showWithdrawalDialog = () => {
if (summary.value.available_commission < 100) {
ElMessage.warning('可提现佣金不足100分(1元),无法发起提现')
return
}
withdrawalDialogVisible.value = true
}
// 关闭对话框
const handleDialogClose = () => {
withdrawalFormRef.value?.resetFields()
withdrawalForm.amount = 0
withdrawalForm.amount = undefined
withdrawalForm.withdrawal_method = WithdrawalMethod.ALIPAY
withdrawalForm.account_name = ''
withdrawalForm.account_number = ''
@@ -781,13 +879,23 @@
ElMessage.warning('未找到店铺信息')
return
}
if (isWithdrawalAmountDisabled.value) {
ElMessage.warning('当前无可提现金额')
return
}
await withdrawalFormRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
const amount = yuanToFen(withdrawalForm.amount)
if (amount === undefined) {
ElMessage.warning('请输入提现金额')
return
}
const params: SubmitWithdrawalParams = {
amount: withdrawalForm.amount,
amount,
withdrawal_method: withdrawalForm.withdrawal_method,
account_name: withdrawalForm.account_name,
account_number: withdrawalForm.account_number,
@@ -829,7 +937,8 @@
// 调用 fund-summary 接口,通过 shop_id 过滤获取自己的数据
const res = await CommissionService.getShopFundSummary({
page: 1,
page_size: 1
page_size: 1,
shop_id: currentShopId.value
})
if (res.code === 0 && res.data.items && res.data.items.length > 0) {
// 从结果中找到当前用户的店铺数据
@@ -841,7 +950,8 @@
available_commission: myShopData.available_commission,
frozen_commission: myShopData.frozen_commission,
withdrawing_commission: myShopData.withdrawing_commission,
withdrawn_commission: myShopData.withdrawn_commission
withdrawn_commission: myShopData.withdrawn_commission,
unwithdraw_commission: myShopData.unwithdraw_commission
}
}
}
@@ -866,5 +976,46 @@
}
loadSummary()
getCommissionList()
searchCommissionOrders('')
})
</script>
<style scoped lang="scss">
.commission-summary {
display: inline-flex;
flex-wrap: wrap;
gap: 8px 14px;
align-items: center;
margin-left: 12px;
.commission-summary-item {
display: inline-flex;
gap: 4px;
align-items: center;
padding: 5px 10px;
white-space: nowrap;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
border-radius: 4px;
.summary-label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.summary-value {
font-size: 14px;
font-weight: 600;
color: var(--el-color-primary);
&.is-success {
color: var(--el-color-success);
}
&.is-warning {
color: var(--el-color-warning);
}
}
}
}
</style>

View File

@@ -29,6 +29,9 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:inlineActionsCount="1"
:actionsWidth="160"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -38,8 +41,8 @@
</ArtTable>
<!-- 拒绝提现对话框 -->
<ElDialog v-model="rejectDialogVisible" title="拒绝提现申请" width="500px">
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="100px">
<ElDialog v-model="rejectDialogVisible" title="拒绝提现申请" width="40%">
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules">
<ElFormItem label="拒绝原因" prop="reject_reason">
<ElInput
v-model="rejectForm.reject_reason"
@@ -48,7 +51,7 @@
placeholder="请输入拒绝原因"
/>
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElFormItem label="备注信息" prop="remark">
<ElInput
v-model="rejectForm.remark"
type="textarea"
@@ -83,7 +86,6 @@
} from '@/types/api/commission'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { WithdrawalStatusMap, WithdrawalMethodMap } from '@/config/constants'
import { useI18n } from 'vue-i18n'
@@ -163,13 +165,13 @@
{
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -192,8 +194,7 @@
{ label: '提现方式', prop: 'withdrawal_method' },
{ label: '状态', prop: 'status' },
{ label: '申请时间', prop: 'created_at' },
{ label: '审批时间', prop: 'processed_at' },
{ label: '操作', prop: 'operation' }
{ label: '审批时间', prop: 'processed_at' }
]
const rejectFormRef = ref<FormInstance>()
@@ -277,30 +278,6 @@
label: '审批时间',
width: 180,
formatter: (row: WithdrawalRequestItem) => formatDateTime(row.processed_at)
},
{
prop: 'operation',
label: '操作',
width: 150,
fixed: 'right',
formatter: (row: WithdrawalRequestItem) => {
// 只有待审核状态才显示操作按钮
if (row.status === 1) {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
text: '审批通过',
iconColor: '#67C23A',
onClick: () => handleApprove(row)
}),
h(ArtButtonTable, {
text: '拒绝',
iconColor: '#F56C6C',
onClick: () => showRejectDialog(row)
})
])
}
return '-'
}
}
])
@@ -443,6 +420,24 @@
}
})
}
// 获取操作按钮,使用 ArtTable 统一的文字操作样式
const getActions = (row: WithdrawalRequestItem) => {
if (row.status !== 1) return []
return [
{
label: '审批通过',
handler: () => handleApprove(row),
type: 'primary' as const
},
{
label: '拒绝',
handler: () => showRejectDialog(row),
type: 'danger' as const
}
]
}
</script>
<style lang="scss" scoped>

View File

@@ -34,7 +34,7 @@
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="ID"
row-key="id"
:loading="loading"
:data="accountList"
:currentPage="pagination.page"
@@ -120,8 +120,15 @@
<ElEmpty v-if="filteredAvailableRoles.length === 0" description="暂无角色" />
<template v-else-if="isPlatformUser">
<ElCheckboxGroup v-model="rolesToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="role.ID" class="role-item">
<ElCheckbox :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElCheckbox
:label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
@@ -134,8 +141,15 @@
</template>
<template v-else>
<ElRadioGroup v-model="roleToAdd" class="role-list">
<div v-for="role in filteredAvailableRoles" :key="role.ID" class="role-item">
<ElRadio :label="role.ID" :disabled="selectedRoles.includes(role.ID)">
<div
v-for="role in filteredAvailableRoles"
:key="getRoleId(role)"
class="role-item"
>
<ElRadio
:label="getRoleId(role)"
:disabled="selectedRoles.includes(getRoleId(role))"
>
<span class="role-info">
<span>{{ role.role_name }}</span>
<ElTag :type="role.role_type === 1 ? 'primary' : 'success'" size="small">
@@ -177,7 +191,7 @@
<div class="role-list">
<div
v-for="role in filteredAssignedRoles"
:key="role.ID"
:key="getRoleId(role)"
class="role-item assigned-role-item"
>
<span class="role-info">
@@ -186,7 +200,12 @@
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</span>
<ElButton type="danger" size="small" link @click="removeSingleRole(role.ID)">
<ElButton
type="danger"
size="small"
link
@click="removeSingleRole(getRoleId(role))"
>
移除
</ElButton>
</div>
@@ -231,6 +250,7 @@
import type { SearchFormItem } from '@/types'
import type { PlatformAccount, PlatformRole } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getCompatibleNumericId } from '@/utils/business/id'
import { useAuth } from '@/composables/useAuth'
defineOptions({ name: 'CommonAccountList' })
@@ -287,6 +307,9 @@
// 是否为平台用户(平台用户可多选,其他单选)
const isPlatformUser = computed(() => currentAccountType.value === 2)
const getAccountId = (account: PlatformAccount | any) => getCompatibleNumericId(account) ?? 0
const getRoleId = (role: PlatformRole | any) => getCompatibleNumericId(role) ?? 0
// 搜索表单初始值
const initialSearchState = {
username: '',
@@ -389,6 +412,22 @@
})
}
baseColumns.push(
{
prop: 'wecom_bound',
label: '企微绑定',
formatter: (row: PlatformAccount) =>
h(ElTag, { type: row.wecom_bound ? 'success' : 'info' }, () =>
row.wecom_bound ? '已绑定' : '未绑定'
)
},
{
prop: 'wecom_name',
label: '企微用户名称',
formatter: (row: PlatformAccount) => h('span', row.wecom_name || '-')
}
)
// 添加状态和创建时间
baseColumns.push(
{
@@ -595,7 +634,7 @@
dialogVisible.value = false
// 刷新列表
getTableData()
await getTableData()
} catch (error) {
console.error(error)
console.log('添加账号失败')
@@ -646,7 +685,9 @@
// 计算属性:过滤后的已分配角色
const filteredAssignedRoles = computed(() => {
const assignedRolesList = allRoles.value.filter((role) => selectedRoles.value.includes(role.ID))
const assignedRolesList = allRoles.value.filter((role) =>
selectedRoles.value.includes(getRoleId(role))
)
if (!rightRoleFilter.value) return assignedRolesList
const keyword = rightRoleFilter.value.toLowerCase()
return assignedRolesList.filter((role) => role.role_name.toLowerCase().includes(keyword))
@@ -654,7 +695,12 @@
// 显示分配角色对话框
const showRoleDialog = async (row: PlatformAccount) => {
currentAccountId.value = (row as any).id
const accountId = getAccountId(row)
if (!accountId) {
ElMessage.error('当前账号缺少账号标识,无法分配角色')
return
}
currentAccountId.value = accountId
currentAccountName.value = row.username
currentAccountType.value = row.user_type
selectedRoles.value = []
@@ -671,12 +717,12 @@
await loadAllRoles('', roleType)
// 先加载当前账号的角色,再打开对话框
const res = await AccountService.getAccountRoles((row as any).id)
const res = await AccountService.getAccountRoles(accountId)
if (res.code === 0) {
// 提取角色ID数组
const roles = res.data || []
// 兼容 ID 和 id 两种字段名
selectedRoles.value = roles.map((role: any) => role.ID || role.id)
selectedRoles.value = roles.map((role: any) => getRoleId(role)).filter((id) => id > 0)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="console">
开发中敬请期待...
<ExpiringAssetStats />
<!--<CardList></CardList>-->
<!--<el-row :gutter="20">-->
@@ -29,13 +29,7 @@
</template>
<script setup lang="ts">
import CardList from './widget/CardList.vue'
import ActiveUser from './widget/ActiveUser.vue'
import SalesOverview from './widget/SalesOverview.vue'
import NewUser from './widget/NewUser.vue'
import Dynamic from './widget/Dynamic.vue'
import TodoList from './widget/TodoList.vue'
import AboutProject from './widget/AboutProject.vue'
import ExpiringAssetStats from './widget/ExpiringAssetStats.vue'
import { useCommon } from '@/composables/useCommon'
defineOptions({ name: 'Console' })

View File

@@ -0,0 +1,106 @@
<template>
<ElCard shadow="never" class="expiring-asset-stats">
<template #header>
<div class="card-header">
<span>临期资产</span>
<ElButton link type="primary" @click="() => goToList()">查看列表</ElButton>
</div>
</template>
<div class="stats-grid">
<div class="stat-item stat-item--card" @click="() => goToList('card')">
<div class="stat-label">临期网卡</div>
<div class="stat-value">{{ counts.card.toLocaleString() }}</div>
</div>
<div class="stat-item stat-item--device" @click="() => goToList('device')">
<div class="stat-label">临期设备</div>
<div class="stat-value">{{ counts.device.toLocaleString() }}</div>
</div>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { AssetService } from '@/api/modules'
import type { AssetType, ExpiringAssetType } from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'ExpiringAssetStats' })
const router = useRouter()
const counts = reactive<Record<AssetType, number>>({ card: 0, device: 0 })
const apiAssetTypes: Record<AssetType, ExpiringAssetType> = {
card: 'iot_card',
device: 'device'
}
const loadCount = async (assetType: AssetType) => {
try {
const response = await AssetService.getExpiringAssets({
asset_type: apiAssetTypes[assetType],
page: 1,
page_size: 1
})
if (response.code === 0 && response.data) counts[assetType] = response.data.total || 0
} catch {
counts[assetType] = 0
}
}
const goToList = (assetType?: AssetType) => {
router.push({
path: RoutesAlias.ExpiringAssets,
query: assetType ? { asset_type: apiAssetTypes[assetType] } : {}
})
}
onMounted(() => void Promise.all([loadCount('card'), loadCount('device')]))
</script>
<style scoped lang="scss">
.expiring-asset-stats {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.stat-item {
padding: 20px;
cursor: pointer;
border-radius: 8px;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
}
.stat-item--card {
background: #fff0f6;
}
.stat-item--device {
background: #f4ecff;
}
.stat-label {
color: var(--el-text-color-secondary);
}
.stat-value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: var(--el-text-color-primary);
}
}
</style>

View File

@@ -46,7 +46,6 @@
<!-- <TransactionList />-->
<!-- </el-col>-->
<!--</el-row>-->
</div>
</template>

View File

@@ -12,6 +12,7 @@ interface BuildAgentRechargeActionsOptions {
onViewPaymentVoucher: (row: AgentRecharge) => void
onConfirmPayment: (row: AgentRecharge) => void
onReject: (row: AgentRecharge) => void
onTriggerApproval: (row: AgentRecharge) => void
}
export const buildAgentRechargeActions = (
@@ -19,6 +20,19 @@ export const buildAgentRechargeActions = (
options: BuildAgentRechargeActionsOptions
): AgentRechargeAction[] => {
const actions: AgentRechargeAction[] = []
const hasApprovalRecord =
row.approval_provider === 'wecom' ||
row.approval_source === 'wecom' ||
row.approval_source === 'legacy' ||
(row.approval_instance_id !== undefined && row.approval_instance_id !== null)
const approvalStatusEmpty = row.approval_status === undefined || row.approval_status === null
const canTriggerApproval =
approvalStatusEmpty &&
![
AgentRechargeStatus.COMPLETED,
AgentRechargeStatus.REJECTED,
AgentRechargeStatus.CLOSED
].includes(row.status)
if (
row.payment_method === 'offline' &&
@@ -26,13 +40,14 @@ export const buildAgentRechargeActions = (
options.hasAuth('agent_recharge:view_payment_voucher')
) {
actions.push({
label: '查看支付凭证',
label: '支付凭证',
handler: () => options.onViewPaymentVoucher(row),
type: 'primary'
})
}
if (
!hasApprovalRecord &&
row.status === AgentRechargeStatus.PENDING &&
row.payment_method === 'offline' &&
options.hasAuth('agent_recharge:confirm_payment')
@@ -44,7 +59,20 @@ export const buildAgentRechargeActions = (
})
}
if (row.status === AgentRechargeStatus.PENDING && options.hasAuth('agent_recharge:reject')) {
if (canTriggerApproval && options.hasAuth('agent_recharge:trigger_approval')) {
actions.push({
label: '补发审批',
handler: () => options.onTriggerApproval(row),
type: 'primary'
})
}
if (
!hasApprovalRecord &&
row.status === AgentRechargeStatus.PENDING &&
row.payment_method === 'offline' &&
options.hasAuth('agent_recharge:reject')
) {
actions.push({
label: '拒绝',
handler: () => options.onReject(row),

View File

@@ -0,0 +1,17 @@
import type { AgentRechargeStatus } from '@/types/api'
export const ONLINE_RECHARGE_TERMINAL_STATUSES: AgentRechargeStatus[] = [3, 4, 5, 6]
export const isOnlineRechargeTerminal = (status: AgentRechargeStatus): boolean => {
return ONLINE_RECHARGE_TERMINAL_STATUSES.includes(status)
}
export const createOnlineRechargeRequestId = (): string => {
const randomId = globalThis.crypto?.randomUUID?.()
const suffix = randomId || Math.random().toString(36).slice(2, 12)
return `recharge-${Date.now()}-${suffix}`
}
export const amountYuanToFen = (amount: number): number => {
return Math.round(amount * 100)
}

View File

@@ -41,12 +41,15 @@
import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import { useUserStore } from '@/store/modules/user'
import { formatRejectionReason } from './agentRechargeDisplay'
defineOptions({ name: 'AgentRechargeDetail' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isRestrictedCustomerRole = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
const loading = ref(false)
const detailData = ref<AgentRecharge | null>(null)
@@ -68,129 +71,205 @@
2: 'success', // 已支付
3: 'success', // 已完成
4: 'info', // 已关闭
5: 'danger' // 已退款
5: 'danger', // 已退款
6: 'danger' // 已驳回
}
return statusMap[status] || 'info'
}
// 获取状态文本(优先使用 status_name,否则使用本地映射)
const getStatusText = (status: AgentRechargeStatus, statusName?: string): string => {
if (statusName) return statusName
const statusMap: Record<AgentRechargeStatus, string> = {
1: '待支付',
2: '已支付',
3: '已完成',
4: '已关闭',
5: '已退款'
}
return statusMap[status] || '-'
}
// 获取支付方式文本
const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => {
const methodMap: Record<AgentRechargePaymentMethod, string> = {
wechat: '微信在线支付',
alipay: '支付宝在线支付',
offline: '线下转账'
}
return methodMap[method] || method
}
const getRechargeSourceText = (data: AgentRecharge): string => {
if (data.recharge_source_name) return data.recharge_source_name
if (data.recharge_source === 'agent_online') return '代理在线自充'
if (data.recharge_source === 'platform_offline') return '平台线下代充'
return '-'
}
const getApprovalProviderText = (data: AgentRecharge) => {
if (data.approval_provider === 'wecom' || data.approval_source === 'wecom') return '企微'
if (data.approval_source === 'legacy') return '历史审批'
return data.approval_provider || data.approval_source || '-'
}
// 详情配置
const detailSections = computed((): DetailSection[] => [
{
title: '订单信息',
fields: [
{ label: '充值单号', prop: 'recharge_no' },
{ label: '店铺名称', prop: 'shop_name' },
{
label: '充值金额',
formatter: (_, data) => formatCurrency(data.amount)
},
{
label: '状态',
render: (data) =>
h(ElTag, { type: getStatusType(data.status) }, () =>
getStatusText(data.status, data.status_name)
)
},
{
label: '驳回原因',
prop: 'rejection_reason',
formatter: (value) => formatRejectionReason(value),
fullWidth: true
}
]
},
{
title: '支付信息',
fields: [
{
label: '支付方式',
formatter: (_, data) => getPaymentMethodText(data.payment_method)
},
{
label: '支付通道',
prop: 'payment_channel',
formatter: (value) => value || '-'
},
{
label: '第三方支付流水号',
prop: 'payment_transaction_id',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '支付凭证',
fullWidth: true,
render: (data) =>
hasVoucherKeys(data.payment_voucher_key)
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
paymentVoucherFileKeys.value = toVoucherKeyList(data.payment_voucher_key)
}
},
() => '查看支付凭证'
)
: h('span', '-')
},
{
label: '运营备注',
prop: 'remark',
formatter: (value) => value || '-',
fullWidth: true
}
]
},
{
title: '时间信息',
fields: [
{
label: '创建时间',
prop: 'created_at',
formatter: (value) => formatDateTime(value)
},
{
label: '支付时间',
prop: 'paid_at',
formatter: (value) => (value ? formatDateTime(value) : '-')
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value) => (value ? formatDateTime(value) : '-')
},
{
label: '更新时间',
prop: 'updated_at',
formatter: (value) => formatDateTime(value)
}
]
}
])
const detailSections = computed((): DetailSection[] => {
if (!detailData.value) return []
const isOfflineRecharge =
detailData.value.recharge_source === 'platform_offline' ||
detailData.value.payment_method === 'offline'
const sections: DetailSection[] = [
{
title: '订单信息',
fields: [
{ label: '充值单号', prop: 'recharge_no' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '充值来源', formatter: (_, data) => getRechargeSourceText(data) },
...(!isRestrictedCustomerRole.value
? [
{
label: '提交人',
prop: 'submitter_name',
formatter: (value: string | null | undefined) => value || '-'
}
]
: []),
{
label: '充值金额',
formatter: (_, data) => formatCurrency(data.amount)
},
{
label: '状态',
render: (data) =>
h(ElTag, { type: getStatusType(data.status) }, () => data.status_name || '-')
},
...(!isRestrictedCustomerRole.value
? [
{
label: '驳回原因',
prop: 'rejection_reason',
formatter: (value: string | null | undefined) => formatRejectionReason(value),
fullWidth: true
}
]
: [])
]
},
...(isOfflineRecharge && !isRestrictedCustomerRole.value
? [
{
title: '企微审批信息',
fields: [
{
label: '审批渠道',
formatter: (_: unknown, data: AgentRecharge) => getApprovalProviderText(data)
},
{
label: '审批状态',
formatter: (_: unknown, data: AgentRecharge) => data.approval_status_name || '-'
},
{
label: '当前审批人摘要',
formatter: (_: unknown, data: AgentRecharge) =>
data.current_approver_summary || '-',
fullWidth: true
}
]
}
]
: []),
...(!isRestrictedCustomerRole.value
? [
{
title: '业务处理结果',
fields: [
{
label: '处理状态',
formatter: (_: unknown, data: AgentRecharge) => data.processing_status_name || '-'
}
]
}
]
: []),
{
title: '支付信息',
fields: [
{
label: '支付方式',
formatter: (_, data) => getPaymentMethodText(data.payment_method)
},
{
label: '支付通道',
prop: 'payment_channel',
formatter: (value) => value || '-'
},
{
label: '第三方支付流水号',
prop: 'payment_transaction_id',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '支付单号',
prop: 'payment_no',
formatter: (value) => value || '-',
fullWidth: true
},
...(isOfflineRecharge
? [
{
label: '支付凭证',
fullWidth: true,
render: (data: AgentRecharge) =>
hasVoucherKeys(data.payment_voucher_key)
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
paymentVoucherFileKeys.value = toVoucherKeyList(
data.payment_voucher_key
)
}
},
() => '查看支付凭证'
)
: h('span', '-')
}
]
: []),
...(!isRestrictedCustomerRole.value
? [
{
label: '运营备注',
prop: 'remark',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
}
]
: [])
]
},
{
title: '时间信息',
fields: [
{
label: '创建时间',
prop: 'created_at',
formatter: (value) => formatDateTime(value)
},
{
label: '支付时间',
prop: 'paid_at',
formatter: (value) => (value ? formatDateTime(value) : '-')
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value) => (value ? formatDateTime(value) : '-')
},
{
label: '更新时间',
prop: 'updated_at',
formatter: (value) => formatDateTime(value)
}
]
}
]
return sections
})
// 加载详情数据
const loadDetailData = async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,13 @@
返回
</ElButton>
<h2 class="detail-title">{{ pageTitle }}</h2>
<ElButton
v-if="refund && canResubmit(refund) && hasAuth('refund:resubmit')"
type="primary"
@click="handleShowResubmit"
>
重新申请
</ElButton>
</div>
<!-- 详情内容 -->
@@ -24,90 +31,10 @@
<PaymentVoucherDialog :file-keys="refundVoucherFileKeys" @close="refundVoucherFileKeys = []" />
<!-- 审批通过对话框 -->
<ElDialog
v-model="approveDialogVisible"
title="审批通过"
width="500px"
@closed="handleApproveDialogClosed"
>
<ElForm ref="approveFormRef" :model="approveForm" :rules="approveRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="申请退款金额">
<span>{{ formatCurrency(refund?.requested_refund_amount) }}</span>
</ElFormItem>
<ElFormItem label="实际退款金额" prop="approved_refund_amount">
<ElInputNumber
v-model="approveForm.approved_refund_amount"
:min="1"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则默认使用申请金额"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)">
不填则默认使用申请金额
</div>
</ElFormItem>
<ElFormItem label="审批备注">
<ElInput
v-model="approveForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入审批备注"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="approveDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleApproveRefund" :loading="approveLoading">
确认通过
</ElButton>
</div>
</template>
</ElDialog>
<!-- 审批拒绝对话框 -->
<ElDialog
v-model="rejectDialogVisible"
title="审批拒绝"
width="500px"
@closed="handleRejectDialogClosed"
>
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="拒绝原因" prop="reject_reason">
<ElInput
v-model="rejectForm.reject_reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="rejectDialogVisible = false">取消</ElButton>
<ElButton type="danger" @click="handleRejectRefund" :loading="rejectLoading">
确认拒绝
</ElButton>
</div>
</template>
</ElDialog>
<!-- 重新提交对话框 -->
<ElDialog
v-model="resubmitDialogVisible"
title="重新提交退款申请"
title="重新申请退款"
width="500px"
@closed="handleResubmitDialogClosed"
>
@@ -157,6 +84,7 @@
voucher-name="退款凭证"
@uploading-change="resubmitVoucherUploading = $event"
@change="resubmitFormRef?.validateField('refund_voucher_key')"
@files-change="resubmitForm.attachments = $event"
/>
</ElFormItem>
</ElForm>
@@ -169,7 +97,7 @@
:loading="resubmitLoading || resubmitVoucherUploading"
:disabled="resubmitVoucherUploading"
>
{{ resubmitVoucherUploading ? '凭证上传中...' : '确认提交' }}
{{ resubmitVoucherUploading ? '凭证上传中...' : '确认申请' }}
</ElButton>
</div>
</template>
@@ -180,27 +108,26 @@
<script setup lang="ts">
import { ref, onMounted, computed, h, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ElButton, ElCard, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { RefundService } from '@/api/modules'
import type {
Refund,
RefundStatus,
ApproveRefundRequest,
RejectRefundRequest,
ResubmitRefundRequest
} from '@/types/api'
import { formatDateTime, yuanToFen } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import type { Refund, ResubmitRefundRequest, RefundAttachment } from '@/types/api'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import { toVoucherKeyList, getErrorMessage } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'RefundDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isRestrictedCustomerRole = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
const loading = ref(false)
const refund = ref<Refund | null>(null)
@@ -208,44 +135,27 @@
const pageTitle = computed(() => `退款详情`)
const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const resubmitDialogVisible = ref(false)
const approveLoading = ref(false)
const rejectLoading = ref(false)
const resubmitLoading = ref(false)
const resubmitVoucherUploading = ref(false)
const approveFormRef = ref()
const rejectFormRef = ref()
const resubmitFormRef = ref()
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const approveRules = ref()
const rejectRules = ref({
reject_reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const resubmitRules = ref()
const approveForm = reactive<ApproveRefundRequest>({
approved_refund_amount: undefined,
remark: ''
})
const rejectForm = reactive<RejectRefundRequest>({
reject_reason: ''
})
const resubmitForm = reactive<{
requested_refund_amount?: number
actual_received_amount?: number
refund_voucher_key: string[]
attachments: RefundAttachment[]
refund_reason?: string
}>({
requested_refund_amount: undefined,
actual_received_amount: undefined,
refund_voucher_key: [],
attachments: [],
refund_reason: ''
})
@@ -255,45 +165,89 @@
return `¥${(amount / 100).toFixed(2)}`
}
const getStatusType = (status: RefundStatus): 'warning' | 'success' | 'danger' | 'info' => {
const statusMap: Record<RefundStatus, 'warning' | 'success' | 'danger' | 'info'> = {
1: 'warning',
2: 'success',
3: 'danger',
4: 'info'
}
return statusMap[status] || 'info'
const getApprovalProviderText = (item: Refund) => {
const provider = item.approval_provider || item.approval_source || item.approval?.source
if (provider === 'wecom') return '企微'
if (provider === 'legacy') return '历史审批'
if (provider === 'none') return '无'
return provider || '-'
}
const getStatusText = (status: RefundStatus): string => {
const statusMap: Record<RefundStatus, string> = {
1: '待审批',
2: '已通过',
3: '已拒绝',
4: '已退回'
const getRefundApprovalStatusText = (item: Refund) => {
return item.approval_status_name || item.approval?.status_name || item.approval?.status || '-'
}
const isHiddenIdKey = (key: string) => {
const normalizedKey = key.toLowerCase()
return normalizedKey === 'id' || normalizedKey === 'userid' || normalizedKey.endsWith('_id')
}
const sanitizeStructuredValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map((item) => sanitizeStructuredValue(item))
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !isHiddenIdKey(key))
.map(([key, item]) => [key, sanitizeStructuredValue(item)])
)
}
const formatStructuredValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return '-'
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return statusMap[status] || '-'
return JSON.stringify(value, null, 2)
}
const renderStructuredValue = (value: unknown) =>
h('pre', { class: 'structured-value' }, formatStructuredValue(sanitizeStructuredValue(value)))
const renderTimeline = (timeline: unknown) => {
if (!Array.isArray(timeline) || timeline.length === 0) return h('span', '-')
return h(
'div',
{ class: 'approval-timeline' },
timeline.map((item: any) => {
const title = item.status_name || item.status || item.content || '审批节点'
const operator = item.operator_name || ''
const time = item.time || item.timestamp || ''
const comment = item.comment || item.content || ''
return h('div', { class: 'approval-timeline__item' }, [
h('strong', title),
operator || time ? h('span', `${operator}${operator && time ? ' · ' : ''}${time}`) : null,
comment && comment !== title ? h('div', comment) : null
])
})
)
}
const getRefundAttachmentKeys = (item: Refund) => {
const keys = item.attachments?.map((attachment) => attachment.file_key).filter(Boolean) || []
return keys.length ? keys : toVoucherKeyList(item.refund_voucher_key)
}
const detailSections = computed((): DetailSection[] => [
{
title: '退款信息',
title: '退款业务信息',
fields: [
{ label: '退款单号', prop: 'refund_no' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '订单号', prop: 'order_no' },
{ label: '资产标识符', prop: 'asset_identifier' },
{
label: '资产类型',
formatter: (_, data) =>
data.asset_type === 'device'
? '设备'
: data.asset_type === 'card'
? '单卡'
: data.asset_type === 'iot_card'
? 'IoT卡'
: data.asset_type || '-'
},
{ label: '资产类型', prop: 'asset_type' },
{ label: '退款状态', prop: 'status_name', formatter: (value) => value || '-' },
...(!isRestrictedCustomerRole.value
? [
{
label: '提交人',
prop: 'submitter_name',
formatter: (value: string | null | undefined) => value || '-'
}
]
: []),
{
label: '申请退款金额',
formatter: (_, data) => formatCurrency(data.requested_refund_amount)
@@ -306,23 +260,40 @@
label: '实收金额',
formatter: (_, data) => formatCurrency(data.actual_received_amount)
},
{
label: '状态',
render: (data) =>
h(ElTag, { type: getStatusType(data.status) }, () => getStatusText(data.status))
},
...(!isRestrictedCustomerRole.value
? [
{
label: '退款原因',
prop: 'refund_reason',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
},
{
label: '拒绝原因',
prop: 'reject_reason',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
},
{
label: '备注',
prop: 'remark',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
}
]
: []),
{
label: '退款凭证',
fullWidth: true,
render: (data) =>
hasVoucherKeys(data.refund_voucher_key)
getRefundAttachmentKeys(data).length
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
refundVoucherFileKeys.value = toVoucherKeyList(data.refund_voucher_key)
refundVoucherFileKeys.value = getRefundAttachmentKeys(data)
}
},
() => '查看退款凭证'
@@ -331,60 +302,118 @@
},
{
label: '资产重置',
render: (data) =>
h(ElTag, { type: data.asset_reset ? 'success' : 'info' }, () =>
data.asset_reset ? '是' : '否'
)
formatter: (_, data) => (data.asset_reset ? '是' : '否')
},
{
label: '佣金扣除',
render: (data) =>
h(ElTag, { type: data.commission_deducted ? 'success' : 'info' }, () =>
data.commission_deducted ? '是' : '否'
)
}
]
},
{
title: '退款原因',
fields: [
{
label: '退款原因',
prop: 'refund_reason',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '拒绝原因',
prop: 'reject_reason',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '审批备注',
prop: 'remark',
formatter: (value) => value || '-',
fullWidth: true
}
]
},
{
title: '时间信息',
fields: [
{
label: '创建时间',
prop: 'created_at',
formatter: (value) => formatDateTime(value)
formatter: (_, data) => (data.commission_deducted ? '是' : '否')
},
{ label: '创建时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
{
label: '审批时间',
prop: 'processed_at',
formatter: (value) => (value ? formatDateTime(value) : '-')
},
{ label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) }
]
},
{
title: '企微审批信息',
fields: [
...(!isRestrictedCustomerRole.value
? [
{
label: '审批渠道',
formatter: (_: unknown, data: Refund) => getApprovalProviderText(data)
}
]
: []),
{
label: '更新时间',
prop: 'updated_at',
formatter: (value) => formatDateTime(value)
label: '审批来源',
formatter: (_, data) => data.approval?.source || data.approval_source || '-'
},
{ label: '审批单号', prop: 'approval.sp_no' },
{
label: '审批状态',
formatter: (_, data) => getRefundApprovalStatusText(data)
},
...(!isRestrictedCustomerRole.value
? [
{
label: '当前审批人摘要',
formatter: (_: unknown, data: Refund) => data.current_approver_summary || '-'
}
]
: []),
{ label: '模板版本', prop: 'approval.template_version' },
{
label: '申请人',
render: (data) => renderStructuredValue(data.approval?.applicant)
},
{
label: '审批人',
render: (data) => renderStructuredValue(data.approval?.approvers)
},
{
label: '审批意见',
render: (data) => renderStructuredValue(data.approval?.comments),
fullWidth: true
},
{
label: '审批附件',
render: (data) => {
const keys = (data.approval?.attachments || [])
.map((item: RefundAttachment) => item.file_key)
.filter(Boolean)
return keys.length
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => (refundVoucherFileKeys.value = keys)
},
() => '查看审批附件'
)
: h('span', '-')
}
},
{
label: '审批时间线',
render: (data) => renderTimeline(data.approval?.timeline),
fullWidth: true
}
]
},
{
title: '业务处理结果',
fields: [
...(!isRestrictedCustomerRole.value
? [
{
label: '处理状态',
formatter: (_: unknown, data: Refund) =>
data.processing_status_name || data.processing_status || '-'
}
]
: []),
{
label: '失败摘要',
formatter: (_, data) => data.processing_failure_summary || data.error_summary || '-',
fullWidth: true
},
{
label: '处理提示',
formatter: (_, data) => data.processing_message || '-',
fullWidth: true
},
{
label: '业务处理结果',
render: (data) =>
renderStructuredValue(
data.business_process_result || data.approval?.business_process_result
),
fullWidth: true
}
]
}
@@ -416,72 +445,44 @@
}
})
const handleApproveDialogClosed = () => {
approveFormRef.value?.resetFields()
approveForm.approved_refund_amount = undefined
approveForm.remark = ''
}
const handleRejectDialogClosed = () => {
rejectFormRef.value?.resetFields()
rejectForm.reject_reason = ''
}
const handleResubmitDialogClosed = () => {
resubmitFormRef.value?.resetFields()
resubmitForm.requested_refund_amount = undefined
resubmitForm.actual_received_amount = undefined
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = ''
resubmitVoucherUploading.value = false
resubmitUploadRef.value?.clearFiles(false)
}
const handleApproveRefund = async () => {
if (!approveFormRef.value || !refund.value) return
const canResubmit = (item: Refund) => {
const statusText = [item.approval_status, item.approval_status_name, item.status_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
const refundId = refund.value.id
await approveFormRef.value.validate(async (valid: boolean) => {
if (valid) {
approveLoading.value = true
try {
const data: ApproveRefundRequest = {
approved_refund_amount: yuanToFen(approveForm.approved_refund_amount),
remark: approveForm.remark || undefined
}
await RefundService.approveRefund(refundId, data)
ElMessage.success('审批通过成功')
approveDialogVisible.value = false
await fetchRefundDetail(refundId)
} catch (error) {
console.error(error)
} finally {
approveLoading.value = false
}
}
})
return (
item.status === 3 ||
statusText.includes('reject') ||
statusText.includes('revoke') ||
statusText.includes('delete') ||
statusText.includes('驳回') ||
statusText.includes('撤销') ||
statusText.includes('删除')
)
}
const handleRejectRefund = async () => {
if (!rejectFormRef.value || !refund.value) return
const refundId = refund.value.id
await rejectFormRef.value.validate(async (valid: boolean) => {
if (valid) {
rejectLoading.value = true
try {
await RefundService.rejectRefund(refundId, rejectForm)
ElMessage.success('审批拒绝成功')
rejectDialogVisible.value = false
await fetchRefundDetail(refundId)
} catch (error) {
console.error(error)
} finally {
rejectLoading.value = false
}
}
})
const handleShowResubmit = () => {
if (!refund.value || !canResubmit(refund.value)) return
resubmitForm.requested_refund_amount =
fenToYuan(refund.value.requested_refund_amount) || undefined
resubmitForm.actual_received_amount =
fenToYuan(refund.value.actual_received_amount) || undefined
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = refund.value.refund_reason
resubmitDialogVisible.value = true
}
const handleResubmitRefund = async () => {
@@ -499,18 +500,16 @@
const data: ResubmitRefundRequest = {
requested_refund_amount: yuanToFen(resubmitForm.requested_refund_amount),
actual_received_amount: yuanToFen(resubmitForm.actual_received_amount),
refund_reason: resubmitForm.refund_reason || undefined
}
if (hasVoucherKeys(resubmitForm.refund_voucher_key)) {
data.refund_voucher_key = toVoucherKeyList(resubmitForm.refund_voucher_key)
refund_reason: resubmitForm.refund_reason || undefined,
attachments: resubmitForm.attachments
}
await RefundService.resubmitRefund(refundId, data)
ElMessage.success('重新提交成功')
ElMessage.success('重新申请成功')
resubmitDialogVisible.value = false
await fetchRefundDetail(refundId)
} catch (error) {
console.error(error)
ElMessage.error(getErrorMessage(error, '重新申请失败'))
} finally {
resubmitLoading.value = false
}
@@ -530,6 +529,7 @@
margin-bottom: 24px;
.detail-title {
flex: 1;
margin: 0;
font-size: 20px;
font-weight: 600;
@@ -537,6 +537,33 @@
}
}
.structured-value {
max-width: 100%;
margin: 0;
overflow-x: auto;
font: inherit;
line-height: 1.6;
white-space: pre-wrap;
}
.approval-timeline {
display: flex;
flex-direction: column;
gap: 12px;
&__item {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 12px;
border-left: 2px solid var(--el-color-primary-light-5);
span {
color: var(--el-text-color-secondary);
}
}
}
.loading-container {
display: flex;
flex-direction: column;

View File

@@ -21,6 +21,9 @@
<ElButton type="primary" @click="showCreateDialog" v-if="hasAuth('refund:create')"
>创建退款申请</ElButton
>
<ElButton v-if="hasAuth('refund:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -35,8 +38,8 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="220"
:inlineActionsCount="2"
:actionsWidth="160"
:inlineActionsCount="1"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -48,136 +51,25 @@
<!-- 创建退款申请对话框 -->
<CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" />
<!-- 导出任务对话框 -->
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="refund"
:query="exportQuery"
confirm-permission="refund:export"
title="导出退款"
/>
<!-- 退款凭证预览 -->
<PaymentVoucherDialog
:file-keys="refundVoucherFileKeys"
@close="refundVoucherFileKeys = []"
/>
<!-- 审批通过对话框 -->
<ElDialog
v-model="approveDialogVisible"
title="审批通过"
width="500px"
@closed="handleApproveDialogClosed"
>
<ElForm
ref="approveFormRef"
:model="approveForm"
:rules="approveRules"
label-width="120px"
>
<ElFormItem label="退款单号">
<span>{{ currentRefund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="申请退款金额">
<span>{{ formatCurrency(currentRefund?.requested_refund_amount) }}</span>
</ElFormItem>
<ElFormItem label="实际退款金额" prop="approved_refund_amount">
<ElInputNumber
v-model="approveForm.approved_refund_amount"
:min="0"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则默认使用申请金额"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)">
不填则默认使用申请金额
</div>
</ElFormItem>
<ElFormItem label="审批备注">
<ElInput
v-model="approveForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入审批备注"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="approveDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleApproveRefund" :loading="approveLoading">
确认通过
</ElButton>
</div>
</template>
</ElDialog>
<!-- 审批拒绝对话框 -->
<ElDialog
v-model="rejectDialogVisible"
title="审批拒绝"
width="500px"
@closed="handleRejectDialogClosed"
>
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ currentRefund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="拒绝原因" prop="reject_reason">
<ElInput
v-model="rejectForm.reject_reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="rejectDialogVisible = false">取消</ElButton>
<ElButton type="danger" @click="handleRejectRefund" :loading="rejectLoading">
确认拒绝
</ElButton>
</div>
</template>
</ElDialog>
<!-- 退回对话框 -->
<ElDialog
v-model="returnDialogVisible"
title="驳回申请"
width="30%"
@closed="handleReturnDialogClosed"
>
<ElForm ref="returnFormRef" :model="returnForm" :rules="returnRules" label-width="80">
<ElFormItem label="退款单号">
<span>{{ currentRefund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="退回备注">
<ElInput
v-model="returnForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入退回备注"
/>
</ElFormItem>
</ElForm>
<div class="return-dialog-tip">
驳回后该订单不可再申请退款,若还需申请退款点击审核拒绝。
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="returnDialogVisible = false">取消</ElButton>
<ElButton type="warning" @click="handleReturnRefund" :loading="returnLoading">
确认退回
</ElButton>
</div>
</template>
</ElDialog>
<!-- 重新提交对话框 -->
<ElDialog
v-model="resubmitDialogVisible"
title="重新提交退款申请"
title="重新申请退款"
width="500px"
@closed="handleResubmitDialogClosed"
>
@@ -227,6 +119,7 @@
voucher-name="退款凭证"
@uploading-change="resubmitVoucherUploading = $event"
@change="resubmitFormRef?.validateField('refund_voucher_key')"
@files-change="resubmitForm.attachments = $event"
/>
</ElFormItem>
</ElForm>
@@ -239,7 +132,7 @@
:loading="resubmitLoading || resubmitVoucherUploading"
:disabled="resubmitVoucherUploading"
>
{{ resubmitVoucherUploading ? '凭证上传中...' : '确认提交' }}
{{ resubmitVoucherUploading ? '凭证上传中...' : '确认申请' }}
</ElButton>
</div>
</template>
@@ -250,27 +143,37 @@
</template>
<script setup lang="ts">
import { h } from 'vue'
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
import { RefundService, ShopService, OrderService } from '@/api/modules'
import { ElMessage, ElTag, ElButton } from 'element-plus'
import { ElMessage, ElMessageBox, ElTag, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
Refund,
RefundQueryParams,
import {
RefundStatus,
ApproveRefundRequest,
RejectRefundRequest,
ReturnRefundRequest,
ResubmitRefundRequest
type Refund,
type RefundQueryParams,
type ResubmitRefundRequest,
type RefundAttachment
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import {
getCurrentApproverSummaryText,
getProcessingStatusText
} from '@/utils/business/approvalSummary'
import { toVoucherKeyList } from '@/utils/business'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { useUserStore } from '@/store/modules/user'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { useAuth } from '@/composables/useAuth'
@@ -278,20 +181,29 @@
defineOptions({ name: 'RefundList' })
const router = useRouter()
const userStore = useUserStore()
const { hasAuth } = useAuth()
const isRestrictedCustomerRole = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
const hiddenInternalColumnProps = new Set([
'approval_provider',
'submitter_name',
'current_approver_summary',
'processing_status_name',
'refund_reason',
'reject_reason',
'remark'
])
const shouldShowInternalColumn = (prop?: string) =>
!isRestrictedCustomerRole.value || !hiddenInternalColumnProps.has(prop || '')
const loading = ref(false)
const approveLoading = ref(false)
const rejectLoading = ref(false)
const returnLoading = ref(false)
const resubmitLoading = ref(false)
const resubmitVoucherUploading = ref(false)
const triggerApprovalLoading = ref(false)
const tableRef = ref()
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDialogVisible = ref(false)
const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const returnDialogVisible = ref(false)
const exportDialogVisible = ref(false)
const resubmitDialogVisible = ref(false)
const currentRefund = ref<Refund | null>(null)
const refundVoucherFileKeys = ref<string[]>([])
@@ -391,52 +303,36 @@
{ label: '实际退款金额', prop: 'approved_refund_amount' },
{ label: '实收金额', prop: 'actual_received_amount' },
{ label: '状态', prop: 'status' },
{ label: '审批渠道', prop: 'approval_provider' },
{ label: '提交人', prop: 'submitter_name' },
{ label: '审批状态', prop: 'approval_status' },
{ label: '当前审批人摘要', prop: 'current_approver_summary' },
{ label: '业务处理状态', prop: 'processing_status_name' },
{ label: '退款原因', prop: 'refund_reason' },
{ label: '拒绝原因', prop: 'reject_reason' },
{ label: '审批备注', prop: 'remark' },
{ label: '资产重置', prop: 'asset_reset' },
{ label: '佣金扣除', prop: 'commission_deducted' },
{ label: '创建时间', prop: 'created_at' },
{ label: '审批时间', prop: 'processed_at' }
]
{ label: '审批时间', prop: 'processed_at' },
{ label: '更新时间', prop: 'updated_at' }
].filter(({ prop }) => shouldShowInternalColumn(prop))
const approveFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const returnFormRef = ref<FormInstance>()
const resubmitFormRef = ref<FormInstance>()
const approveRules = reactive<FormRules>({})
const rejectRules = reactive<FormRules>({
reject_reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const returnRules = reactive<FormRules>({})
const resubmitRules = reactive<FormRules>({})
const approveForm = reactive<ApproveRefundRequest>({
approved_refund_amount: undefined,
remark: ''
})
const rejectForm = reactive<RejectRefundRequest>({
reject_reason: ''
})
const returnForm = reactive<ReturnRefundRequest>({
remark: ''
})
const resubmitForm = reactive<{
requested_refund_amount?: number
actual_received_amount?: number
refund_voucher_key: string[]
attachments: RefundAttachment[]
refund_reason?: string
}>({
requested_refund_amount: undefined,
actual_received_amount: undefined,
refund_voucher_key: [],
attachments: [],
refund_reason: ''
})
@@ -460,144 +356,179 @@
return statusMap[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: RefundStatus): string => {
const statusMap: Record<RefundStatus, string> = {
1: '待审批',
2: '已通过',
3: '已拒绝',
4: '已退回'
}
return statusMap[status] || '-'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'refund_no',
label: '退款单号',
minWidth: 210,
formatter: (row: Refund) => {
return h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleNameClick(row)
}
},
row.refund_no
)
const { columnChecks, columns } = useCheckedColumns(() =>
[
{
prop: 'refund_no',
label: '退款单号',
minWidth: 210,
formatter: (row: Refund) => {
return h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleNameClick(row)
}
},
row.refund_no
)
}
},
{
prop: 'shop_name',
label: '店铺名称',
width: 160
},
{
prop: 'order_no',
label: '订单号',
width: 180,
showOverflowTooltip: true
},
{
prop: 'asset_identifier',
label: '资产标识符',
width: 200,
showOverflowTooltip: true
},
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: Refund) =>
row.asset_type === 'device'
? '设备'
: row.asset_type === 'card'
? '单卡'
: row.asset_type === 'iot_card'
? 'IoT卡'
: row.asset_type || '-'
},
{
prop: 'requested_refund_amount',
label: '申请退款金额',
width: 150,
formatter: (row: Refund) => formatCurrency(row.requested_refund_amount)
},
{
prop: 'approved_refund_amount',
label: '实际退款金额',
width: 150,
formatter: (row: Refund) => formatCurrency(row.approved_refund_amount)
},
{
prop: 'actual_received_amount',
label: '实收金额',
width: 120,
formatter: (row: Refund) => formatCurrency(row.actual_received_amount)
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: Refund) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '-')
}
},
{
prop: 'approval_provider',
label: '审批渠道',
width: 110,
formatter: (row: Refund) => {
if (row.approval_provider === 'wecom' || row.approval_source === 'wecom') return '企微'
if (row.approval_source === 'legacy') return '历史审批'
return row.approval_provider || row.approval_source || '-'
}
},
{
prop: 'submitter_name',
label: '提交人',
width: 120,
showOverflowTooltip: true,
formatter: (row: Refund) => row.submitter_name || '-'
},
{
prop: 'approval_status',
label: '审批状态',
width: 120,
formatter: (row: Refund) => row.approval_status_name || '-'
},
{
prop: 'current_approver_summary',
label: '当前审批人摘要',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: Refund) => getCurrentApproverSummaryText(row)
},
{
prop: 'processing_status_name',
label: '业务处理状态',
width: 140,
showOverflowTooltip: true,
formatter: (row: Refund) => getProcessingStatusText(row)
},
{
prop: 'refund_reason',
label: '退款原因',
minWidth: 250,
showOverflowTooltip: true,
formatter: (row: Refund) => row.refund_reason || '-'
},
{
prop: 'reject_reason',
label: '拒绝原因',
minWidth: 200,
formatter: (row: Refund) => row.reject_reason || '-'
},
{
prop: 'remark',
label: '审批备注',
minWidth: 200,
formatter: (row: Refund) => row.remark || '-'
},
{
prop: 'asset_reset',
label: '资产重置',
width: 100,
formatter: (row: Refund) => {
return h(ElTag, { type: row.asset_reset ? 'success' : 'info' }, () =>
row.asset_reset ? '是' : '否'
)
}
},
{
prop: 'commission_deducted',
label: '佣金扣除',
width: 100,
formatter: (row: Refund) => {
return h(ElTag, { type: row.commission_deducted ? 'success' : 'info' }, () =>
row.commission_deducted ? '是' : '否'
)
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: Refund) => formatDateTime(row.created_at)
},
{
prop: 'processed_at',
label: '审批时间',
width: 180,
formatter: (row: Refund) => (row.processed_at ? formatDateTime(row.processed_at) : '-')
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: Refund) => formatDateTime(row.updated_at)
}
},
{
prop: 'shop_name',
label: '店铺名称',
width: 160
},
{
prop: 'order_no',
label: '订单号',
width: 180,
showOverflowTooltip: true
},
{
prop: 'asset_identifier',
label: '资产标识符',
width: 200,
showOverflowTooltip: true
},
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: Refund) =>
row.asset_type === 'device'
? '设备'
: row.asset_type === 'card'
? '单卡'
: row.asset_type === 'iot_card'
? 'IoT卡'
: row.asset_type || '-'
},
{
prop: 'requested_refund_amount',
label: '申请退款金额',
width: 150,
formatter: (row: Refund) => formatCurrency(row.requested_refund_amount)
},
{
prop: 'approved_refund_amount',
label: '实际退款金额',
width: 150,
formatter: (row: Refund) => formatCurrency(row.approved_refund_amount)
},
{
prop: 'actual_received_amount',
label: '实收金额',
width: 120,
formatter: (row: Refund) => formatCurrency(row.actual_received_amount)
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: Refund) => {
return h(ElTag, { type: getStatusType(row.status) }, () => getStatusText(row.status))
}
},
{
prop: 'refund_reason',
label: '退款原因',
minWidth: 200,
formatter: (row: Refund) => row.refund_reason || '-'
},
{
prop: 'reject_reason',
label: '拒绝原因',
minWidth: 200,
formatter: (row: Refund) => row.reject_reason || '-'
},
{
prop: 'remark',
label: '审批备注',
minWidth: 200,
formatter: (row: Refund) => row.remark || '-'
},
{
prop: 'asset_reset',
label: '资产重置',
width: 100,
formatter: (row: Refund) => {
return h(ElTag, { type: row.asset_reset ? 'success' : 'info' }, () =>
row.asset_reset ? '是' : '否'
)
}
},
{
prop: 'commission_deducted',
label: '佣金扣除',
width: 100,
formatter: (row: Refund) => {
return h(ElTag, { type: row.commission_deducted ? 'success' : 'info' }, () =>
row.commission_deducted ? '是' : '否'
)
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: Refund) => formatDateTime(row.created_at)
},
{
prop: 'processed_at',
label: '审批时间',
width: 180,
formatter: (row: Refund) => (row.processed_at ? formatDateTime(row.processed_at) : '-')
}
])
].filter(({ prop }) => shouldShowInternalColumn(prop))
)
onMounted(() => {
getTableData()
@@ -677,6 +608,13 @@
}
}
const exportQuery = computed(() => ({
status: searchForm.status,
order_id: searchForm.order_id,
shop_id: searchForm.shop_id,
asset_identifier: searchForm.asset_identifier?.trim() || undefined
}))
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
@@ -716,119 +654,6 @@
getTableData()
}
// 显示审批通过对话框
const handleShowApprove = (row: Refund) => {
currentRefund.value = row
approveDialogVisible.value = true
}
// 审批通过对话框关闭后的清理
const handleApproveDialogClosed = () => {
approveFormRef.value?.resetFields()
approveForm.approved_refund_amount = undefined
approveForm.remark = ''
currentRefund.value = null
}
// 审批通过
const handleApproveRefund = async () => {
if (!approveFormRef.value || !currentRefund.value) return
await approveFormRef.value.validate(async (valid) => {
if (valid) {
approveLoading.value = true
try {
const data: ApproveRefundRequest = {
approved_refund_amount: yuanToFen(approveForm.approved_refund_amount),
remark: approveForm.remark || undefined
}
const refundId = currentRefund.value?.id
if (!refundId) return
await RefundService.approveRefund(refundId, data)
ElMessage.success('审批通过成功')
approveDialogVisible.value = false
await getTableData()
} catch (error) {
console.error(error)
} finally {
approveLoading.value = false
}
}
})
}
// 显示审批拒绝对话框
const handleShowReject = (row: Refund) => {
currentRefund.value = row
rejectDialogVisible.value = true
}
// 审批拒绝对话框关闭后的清理
const handleRejectDialogClosed = () => {
rejectFormRef.value?.resetFields()
rejectForm.reject_reason = ''
currentRefund.value = null
}
// 审批拒绝
const handleRejectRefund = async () => {
if (!rejectFormRef.value || !currentRefund.value) return
await rejectFormRef.value.validate(async (valid) => {
if (valid) {
rejectLoading.value = true
try {
const refundId = currentRefund.value?.id
if (!refundId) return
await RefundService.rejectRefund(refundId, rejectForm)
ElMessage.success('审批拒绝成功')
rejectDialogVisible.value = false
await getTableData()
} catch (error) {
console.error(error)
} finally {
rejectLoading.value = false
}
}
})
}
// 显示退回对话框
const handleShowReturn = (row: Refund) => {
currentRefund.value = row
returnDialogVisible.value = true
}
// 退回对话框关闭后的清理
const handleReturnDialogClosed = () => {
returnFormRef.value?.resetFields()
returnForm.remark = ''
currentRefund.value = null
}
// 退回
const handleReturnRefund = async () => {
if (!returnFormRef.value || !currentRefund.value) return
await returnFormRef.value.validate(async (valid) => {
if (valid) {
returnLoading.value = true
try {
const refundId = currentRefund.value?.id
if (!refundId) return
await RefundService.returnRefund(refundId, returnForm)
ElMessage.success('退回成功')
returnDialogVisible.value = false
await getTableData()
} catch (error) {
console.error(error)
} finally {
returnLoading.value = false
}
}
})
}
// 显示重新提交对话框
const handleShowResubmit = (row: Refund) => {
currentRefund.value = row
@@ -836,6 +661,7 @@
resubmitForm.requested_refund_amount = fenToYuan(row.requested_refund_amount) || undefined
resubmitForm.actual_received_amount = fenToYuan(row.actual_received_amount) || undefined
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = row.refund_reason
resubmitDialogVisible.value = true
}
@@ -846,6 +672,7 @@
resubmitForm.requested_refund_amount = undefined
resubmitForm.actual_received_amount = undefined
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = ''
resubmitVoucherUploading.value = false
resubmitUploadRef.value?.clearFiles(false)
@@ -869,14 +696,12 @@
const data: ResubmitRefundRequest = {
requested_refund_amount: yuanToFen(resubmitForm.requested_refund_amount),
actual_received_amount: yuanToFen(resubmitForm.actual_received_amount),
refund_reason: resubmitForm.refund_reason || undefined
}
if (hasVoucherKeys(resubmitForm.refund_voucher_key)) {
data.refund_voucher_key = toVoucherKeyList(resubmitForm.refund_voucher_key)
refund_reason: resubmitForm.refund_reason || undefined,
attachments: resubmitForm.attachments
}
await RefundService.resubmitRefund(refundId, data)
ElMessage.success('重新提交成功')
ElMessage.success('重新申请成功')
resubmitDialogVisible.value = false
await getTableData()
} catch (error) {
@@ -904,52 +729,135 @@
})
}
const getRefundAttachmentKeys = (row: Refund) => {
const keys = row.attachments?.map((attachment) => attachment.file_key).filter(Boolean) || []
return keys.length ? keys : toVoucherKeyList(row.refund_voucher_key)
}
const canResubmit = (row: Refund) => {
const statusText = [row.approval_status, row.approval_status_name, row.status_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return (
row.status === 3 ||
statusText.includes('reject') ||
statusText.includes('revoke') ||
statusText.includes('delete') ||
statusText.includes('驳回') ||
statusText.includes('撤销') ||
statusText.includes('删除')
)
}
const canTriggerApproval = (row: Refund) => {
const approvalStatusEmpty = row.approval_status === undefined || row.approval_status === null
return row.status === RefundStatus.PENDING && approvalStatusEmpty
}
// 补发历史退款审批
const handleTriggerApproval = (row: Refund) => {
if (triggerApprovalLoading.value) return
ElMessageBox.confirm(`确定要为退款单号 ${row.refund_no} 补发企微审批吗?`, '补发审批', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
triggerApprovalLoading.value = true
try {
const res = await RefundService.triggerApproval(row.id)
if (res.code !== 0) {
ElMessage.error(res.msg || '补发审批失败')
return
}
ElMessage.success('补发审批成功')
await getTableData()
} catch (error) {
console.error(error)
ElMessage.error('补发审批失败')
} finally {
triggerApprovalLoading.value = false
}
})
.catch(() => {
// 用户取消
})
}
// 获取操作按钮
const getActions = (row: Refund) => {
const actions: any[] = []
const voucherKeys = toVoucherKeyList(row.refund_voucher_key)
// 待审批状态可以打回重填、审批通过或审批拒绝
if (row.status === 1) {
// 审批通过
if (hasAuth('refund:approve')) {
actions.push({
label: '审批通过',
handler: () => handleShowApprove(row),
type: 'primary'
})
}
// 审批拒绝
if (hasAuth('refund:reject')) {
actions.push({
label: '审批拒绝',
handler: () => handleShowReject(row),
type: 'danger'
})
}
// 驳回
if (hasAuth('refund:return')) {
actions.push({
label: '驳回',
handler: () => handleShowReturn(row),
type: 'primary'
})
}
}
// 只有已退回状态可以重新提交
if (row.status === 4 && hasAuth('refund:resubmit')) {
const voucherKeys = getRefundAttachmentKeys(row)
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
voucherKeys.length &&
hasAuth('refund:view_voucher')
) {
actions.push({
label: '重新提交',
handler: () => handleShowResubmit(row),
label: '退款凭证',
handler: () => handleViewRefundVoucher(row),
type: 'primary'
})
}
if (voucherKeys.length && hasAuth('refund:view_voucher')) {
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
hasAuth(AUDIT_PERMISSIONS.refundEntry)
) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'refund',
internalId: row.id
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
if (hasAuth(AUDIT_PERMISSIONS.refundFinanceEntry)) {
const financeTarget = resolveFinanceAuditTarget('refund_id', row.id)
if (financeTarget)
actions.push({
label: '资金链路',
handler: () => openAuditInvestigation(financeTarget),
type: 'primary'
})
}
const approvalTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'approval_instance',
internalId: row.approval_instance_id
})
if (approvalTarget && hasAuth(AUDIT_PERMISSIONS.refundApprovalEntry))
actions.push({
label: '审批审计',
handler: () => openAuditInvestigation(approvalTarget),
type: 'primary'
})
}
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
canTriggerApproval(row) &&
hasAuth('refund:trigger_approval')
) {
actions.push({
label: '查看退款凭证',
handler: () => handleViewRefundVoucher(row),
label: '补发审批',
handler: () => handleTriggerApproval(row),
type: 'primary'
})
}
if (canResubmit(row) && hasAuth('refund:resubmit')) {
actions.push({
label: '重新申请',
handler: () => handleShowResubmit(row),
type: 'primary'
})
}
@@ -958,22 +866,14 @@
}
const handleViewRefundVoucher = (row: Refund) => {
const voucherKey = row.refund_voucher_key
if (!hasVoucherKeys(voucherKey)) return
refundVoucherFileKeys.value = toVoucherKeyList(voucherKey)
const voucherKeys = getRefundAttachmentKeys(row)
if (!voucherKeys.length) return
refundVoucherFileKeys.value = voucherKeys
}
</script>
<style scoped lang="scss">
.refund-page {
height: 100%;
.return-dialog-tip {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
color: var(--el-color-primary);
text-align: center;
}
}
</style>

View File

@@ -104,6 +104,22 @@
return type === 'single_card' ? '单卡购买' : '设备购买'
}
const getAssetTypeText = (type?: string): string => {
if (type === 'card') return 'IoT卡'
if (type === 'device') return '设备'
return '-'
}
const getPaymentMethodText = (method?: string): string => {
const methodMap: Record<string, string> = {
wallet: '钱包',
wechat: '微信',
alipay: '支付宝',
offline: '线下'
}
return method ? methodMap[method] || method : '-'
}
// 获取买家类型文本
const getBuyerTypeText = (type: string): string => {
return type === 'personal' ? '个人客户' : '代理商'
@@ -174,10 +190,19 @@
label: '订单类型',
formatter: (_: any, data: any) => getOrderTypeText(data.order_type)
},
{
label: '资产类型',
formatter: (_, data) => getAssetTypeText(data.asset_type)
},
{
label: '支付状态',
formatter: (_: any, data: any) => data.payment_status_text || '-'
},
{
label: '支付方式',
prop: 'payment_method',
formatter: (value) => getPaymentMethodText(value)
},
{
label: '订单金额',
prop: 'total_amount',
@@ -253,7 +278,12 @@
formatter: (_, data) => (data.is_purchased_by_parent ? '是' : '否')
},
{
label: 'ICCID/VirtualNo',
label: '是否代购',
formatter: (_, data) =>
data.is_purchase_on_behalf === undefined ? '-' : data.is_purchase_on_behalf ? '是' : '否'
},
{
label: '资产标识符',
prop: 'asset_identifier',
formatter: (value) => value || '-'
},

View File

@@ -22,6 +22,13 @@
<ElButton @click="showCreateDialog" v-permission="'orders:add'">{{
'创建订单'
}}</ElButton>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
type="primary"
@click="router.push(RoutesAlias.BulkPurchase)"
>
批量订购
</ElButton>
<ElButton v-if="hasAuth('orders:export')" @click="exportDialogVisible = true">{{
'导出'
}}</ElButton>
@@ -52,7 +59,7 @@
<ElDialog
v-model="createDialogVisible"
:title="'创建订单'"
width="30%"
width="40%"
@closed="handleCreateDialogClosed"
>
<ElForm ref="createFormRef" :model="createForm" :rules="createRules" label-width="80px">
@@ -156,7 +163,7 @@
</ElSelect>
<template v-if="noSeriesId">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-warning)">
该设备未关联套餐系列,无法购买套餐
{{ noSeriesMessage }}
</div>
</template>
<template v-else-if="packagesEmptyWithSeriesId">
@@ -275,9 +282,16 @@
import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
defineOptions({ name: 'OrderList' })
@@ -405,7 +419,7 @@
options: [
{ label: '钱包支付', value: 'wallet' },
{ label: '微信支付', value: 'wechat' },
// { label: '支付宝支付', value: 'alipay' },
{ label: '支付宝支付', value: 'alipay' },
{ label: '线下支付', value: 'offline' }
],
config: {
@@ -460,24 +474,24 @@
placeholder: '请选择订单渠道',
options: [
{ label: '自己购买', value: 'self_purchase' },
// { label: '上级代理购买', value: 'purchased_by_parent' },
{ label: '平台代购', value: 'purchased_by_platform' }
// { label: '给下级购买', value: 'purchase_for_subordinate' }
{ label: '上级代理购买', value: 'purchased_by_parent' },
{ label: '平台代购', value: 'purchased_by_platform' },
{ label: '给下级购买', value: 'purchase_for_subordinate' }
],
config: {
clearable: true
}
},
{
label: '开始至结束',
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -496,17 +510,22 @@
{ label: '买家手机号', prop: 'buyer_phone' },
{ label: '买家昵称', prop: 'buyer_nickname' },
{ label: '订单类型', prop: 'order_type' },
{ label: '资产类型', prop: 'asset_type' },
{ label: '买家类型', prop: 'buyer_type' },
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '订单渠道', prop: 'purchase_role' },
{ label: '购买备注', prop: 'purchase_remark' },
{ label: '是否代购', prop: 'is_purchase_on_behalf' },
{ label: '是否上级代购', prop: 'is_purchased_by_parent' },
{ label: '下单时间', prop: 'created_at' },
{ label: '操作者', prop: 'operator_name' },
{ label: '销售店铺', prop: 'seller_shop_name' },
{ label: '支付状态', prop: 'payment_status' },
{ label: '佣金流程状态', prop: 'commission_status_name' },
{ label: '佣金业务结果', prop: 'commission_result_name' },
{ label: '订单金额', prop: 'total_amount' }
{ label: '订单金额', prop: 'total_amount' },
{ label: '是否过期', prop: 'is_expired' },
{ label: '超时时间', prop: 'expires_at' }
]
// 只有非代理账号和非企业账号才能看到实付金额选项
@@ -603,6 +622,10 @@
return false
})
const noSeriesMessage = computed(
() => `该${createForm.order_type === 'single_card' ? '卡' : '设备'}未关联套餐系列,无法购买套餐`
)
// 检查是否选择了赠送套餐
const selectedPackageIsGift = computed(() => {
if (!createForm.package_id) return false
@@ -846,6 +869,13 @@
)
}
},
{
prop: 'asset_type',
label: '资产类型',
width: 110,
formatter: (row: Order) =>
row.asset_type === 'card' ? 'IoT卡' : row.asset_type === 'device' ? '设备' : '-'
},
{
prop: 'buyer_type',
label: '买家类型',
@@ -868,6 +898,19 @@
)
}
},
{
prop: 'is_expired',
label: '是否过期',
width: 100,
formatter: (row: Order) =>
row.is_expired === undefined ? '-' : row.is_expired ? '是' : '否'
},
{
prop: 'expires_at',
label: '超时时间',
width: 180,
formatter: (row: Order) => (row.expires_at ? formatDateTime(row.expires_at) : '-')
},
{
prop: 'asset_identifier',
label: '资产标识符',
@@ -901,6 +944,20 @@
showOverflowTooltip: true,
formatter: (row: Order) => row.purchase_remark || '-'
},
{
prop: 'is_purchase_on_behalf',
label: '是否代购',
width: 100,
formatter: (row: Order) =>
row.is_purchase_on_behalf === undefined ? '-' : row.is_purchase_on_behalf ? '是' : '否'
},
{
prop: 'is_purchased_by_parent',
label: '是否上级代购',
width: 110,
formatter: (row: Order) =>
row.is_purchased_by_parent === undefined ? '-' : row.is_purchased_by_parent ? '是' : '否'
},
{
prop: 'created_at',
label: '下单时间',
@@ -1300,6 +1357,31 @@
const getActions = (row: Order) => {
const actions: any[] = []
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
hasAuth(AUDIT_PERMISSIONS.orderEntry)
) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'order',
internalId: row.id
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
if (hasAuth(AUDIT_PERMISSIONS.orderFinanceEntry)) {
const financeTarget = resolveFinanceAuditTarget('order_id', row.id)
if (financeTarget)
actions.push({
label: '资金链路',
handler: () => openAuditInvestigation(financeTarget),
type: 'primary'
})
}
}
// 线下支付且有凭证可以查看
if (
row.payment_method === 'offline' &&

View File

@@ -34,7 +34,7 @@
import { PackageManageService } from '@/api/modules'
import type { PackageResponse } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getStatusLabel, getShelfStatusText } from '@/config/constants'
import { getShelfStatusText } from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'PackageDetail' })
@@ -103,7 +103,16 @@
{
label: '价格配置状态',
formatter: (_: unknown, data: PackageResponse) => {
return data.price_config_status_name || '-'
const statusMap: Record<number, string> = {
0: '未配置',
1: '赠送 0 价',
2: '已配置非 0'
}
return (
data.price_config_status_name ||
statusMap[data.price_config_status ?? -1] ||
'-'
)
}
},
{
@@ -168,10 +177,20 @@
return data.expiry_base === 'from_activation' ? '实名激活时起算' : '购买时起算'
}
},
{ label: '套餐默认生效条件', prop: 'default_expiry_base_name' },
{
label: '分配覆盖生效条件',
prop: 'expiry_base_override_name',
formatter: (_: unknown, data: PackageResponse) =>
data.expiry_base_override_name || '跟随套餐默认'
},
{ label: '最终生效条件', prop: 'effective_expiry_base_name' },
{
label: '状态',
formatter: (_: unknown, data: PackageResponse) => {
return getStatusLabel(data.status ?? 0)
if (data.status === 1) return '启用'
if (data.status === 0 || data.status === 2) return '禁用'
return '-'
}
},
{
@@ -216,6 +235,13 @@
}
return `${virtualDataMb} MB`
}
},
{
label: '虚流量比例',
formatter: (_: unknown, data: PackageResponse) =>
data.virtual_ratio === null || data.virtual_ratio === undefined
? '-'
: Number(data.virtual_ratio).toFixed(2)
}
])
]

View File

@@ -21,6 +21,9 @@
<ElButton type="primary" @click="showDialog('add')" v-permission="'package:add'"
>新增套餐</ElButton
>
<ElButton v-if="hasAuth('package:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -45,6 +48,14 @@
</template>
</ArtTable>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="package"
:query="exportQuery"
confirm-permission="package:export"
title="导出套餐"
/>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
@@ -240,11 +251,12 @@
>
<div>
缩减比例:{{ realDataGb }}×(1-{{ virtualRatioPercent }}%) =
{{ calculatedVirtualDataGb }}GB
{{ formatTwoDecimals(calculatedVirtualDataGb) }}GB
</div>
<div v-if="calculatedVirtualDataGb > 0">
增长比例:{{ realDataGb }}GB/{{ calculatedVirtualDataGb }}GB =
{{ calculatedGrowthRatio * 100 }}%
增长比例:{{ realDataGb }}GB/{{
formatTwoDecimals(calculatedVirtualDataGb)
}}GB = {{ formatTwoDecimals(calculatedGrowthRatio * 100) }}%
</div>
</div>
</ElFormItem>
@@ -312,21 +324,6 @@
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="24">
<ElFormItem label="套餐描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入套餐描述(可选)"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
<template #footer>
<div class="dialog-footer">
@@ -407,10 +404,10 @@
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import {
CommonStatus,
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
PACKAGE_TYPE_OPTIONS,
getPackageTypeLabel,
@@ -434,6 +431,7 @@
const shouldHideVirtualTrafficColumns = computed(() => [3, 4].includes(currentUserType.value))
const dialogVisible = ref(false)
const exportDialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const seriesLoading = ref(false)
@@ -589,6 +587,7 @@
]),
{ label: '套餐周期类型', prop: 'calendar_type' },
{ label: '有效期', prop: 'duration_months' },
{ label: '套餐天数', prop: 'duration_days' },
{ label: '总流量', prop: 'real_data_mb' },
...(!shouldHideVirtualTrafficColumns.value
? [
@@ -607,8 +606,8 @@
formatter: (row: any) => {
const map: Record<string, string> = {
daily: '每日',
weekly: '每周',
monthly: '每月',
yearly: '每年',
none: '不重置'
}
return map[row.data_reset_cycle] || row.data_reset_cycle || '-'
@@ -625,8 +624,15 @@
return map[row.expiry_base] || row.expiry_base || '-'
}
},
...(canUpdatePackageShelfStatus ? [{ label: '上架状态', prop: 'shelf_status' }] : []),
...(canUpdatePackageStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '套餐默认生效条件', prop: 'default_expiry_base_name' },
{
label: '分配覆盖生效条件',
prop: 'expiry_base_override_name',
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
},
{ label: '最终生效条件', prop: 'effective_expiry_base_name' },
{ label: '上架状态', prop: 'shelf_status' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'created_at' },
{ label: '更新时间', prop: 'updated_at' }
]
@@ -706,18 +712,17 @@
package_name: '',
series_id: undefined,
package_type: '',
calendar_type: undefined,
calendar_type: null,
duration_days: undefined,
duration_months: 1,
data_reset_cycle: undefined,
data_reset_cycle: null,
enable_virtual_data: false,
expiry_base: 'from_activation',
real_data_mb: 0,
virtual_data_mb: 0,
cost_price: 0,
suggested_retail_price: undefined,
is_gift: false,
description: ''
is_gift: false
})
// GB 输入值(用于界面显示)
@@ -742,6 +747,8 @@
return Number((realDataGb.value / calculatedVirtualDataGb.value).toFixed(2))
})
const formatTwoDecimals = (value: number) => value.toFixed(2)
// GB 转 MB 处理
const handleRealDataChange = (value: number | null | undefined) => {
if (value === null || value === undefined) {
@@ -895,7 +902,17 @@
}
}
]
: []),
: [
{
prop: 'shelf_status',
label: '上架状态',
width: 100,
formatter: (row: PackageResponse) =>
h(ElTag, { type: row.shelf_status === 1 ? 'success' : 'info', size: 'small' }, () =>
getShelfStatusText(row.shelf_status ?? 0)
)
}
]),
...(canUpdatePackageStatus
? [
{
@@ -917,7 +934,24 @@
}
}
]
: []),
: [
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: PackageResponse) => {
const frontendStatus = apiStatusToFrontend(row.status ?? 0)
return h(
ElTag,
{
type: frontendStatus === CommonStatus.ENABLED ? 'success' : 'danger',
size: 'small'
},
() => getStatusText(frontendStatus)
)
}
}
]),
{
prop: 'real_data_mb',
label: '总流量',
@@ -952,28 +986,36 @@
prop: 'virtual_ratio',
label: '虚流量比例',
width: 120,
formatter: (row: PackageResponse) => {
// 如果启用虚流量且真流量大于0,计算虚量百分比
const virtualData = row.virtual_data_mb ?? 0
const realData = row.real_data_mb ?? 0
if (row.enable_virtual_data && realData > 0 && virtualData > 0) {
// 虚量百分比 = (1 - 虚流量/真流量) * 100%
// 例如:真流量100G,虚流量70G,则虚量百分比 = (1 - 70/100) * 100% = 30%
const ratio = (1 - virtualData / realData) * 100
return `${ratio.toFixed(2)}%`
}
// 否则返回 0%
return '0%'
}
formatter: (row: PackageResponse) =>
row.virtual_ratio === null || row.virtual_ratio === undefined
? '-'
: formatTwoDecimals(Number(row.virtual_ratio))
}
]
: []),
{
prop: 'enable_virtual_data',
label: '启用虚流量',
width: 110,
formatter: (row: PackageResponse) => (row.enable_virtual_data ? '是' : '否')
},
{
prop: 'duration_months',
label: '有效期',
width: 100,
formatter: (row: PackageResponse) => `${row.duration_months}月`
},
{
prop: 'duration_days',
label: '套餐天数',
width: 100,
formatter: (row: PackageResponse) =>
row.calendar_type === 'by_day' &&
row.duration_days !== null &&
row.duration_days !== undefined
? `${row.duration_days}天`
: '-'
},
{
prop: 'calendar_type',
label: '套餐周期类型',
@@ -995,8 +1037,8 @@
formatter: (row: PackageResponse) => {
const map: Record<string, string> = {
daily: '每日',
weekly: '每周',
monthly: '每月',
yearly: '每年',
none: '不重置'
}
const dataResetCycle = row.data_reset_cycle
@@ -1018,11 +1060,33 @@
return map[expiryBase] || expiryBase
}
},
{
prop: 'default_expiry_base_name',
label: '套餐默认生效条件',
width: 150
},
{
prop: 'expiry_base_override_name',
label: '分配覆盖生效条件',
width: 150,
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
},
{
prop: 'effective_expiry_base_name',
label: '最终生效条件',
width: 150
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: PackageResponse) => formatDateTime(row.created_at)
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: PackageResponse) => formatDateTime(row.updated_at)
}
])
@@ -1073,7 +1137,7 @@
// 监听流量重置周期变化
watch(
() => form.data_reset_cycle,
(cycle) => {
() => {
// 流量重置周期变化时不需要清空套餐天数,套餐天数只受calendar_type控制
}
)
@@ -1090,7 +1154,7 @@
// 监听 is_gift 变化,自动设置 suggested_retail_price
const handleIsGiftChange = (isGift: string | number | boolean) => {
if (Boolean(isGift)) {
if (isGift) {
form.suggested_retail_price = 0
} else {
form.suggested_retail_price = undefined
@@ -1188,7 +1252,7 @@
series_id: searchForm.series_id || undefined,
package_type: searchForm.package_type || undefined,
shelf_status: searchForm.shelf_status || undefined,
status: searchForm.status || undefined
status: searchForm.status ?? undefined
}
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
@@ -1203,6 +1267,25 @@
}
}
// 导出查询参数:保留 status=0(禁用)等有效零值
const exportQuery = computed(() => {
const query: Record<string, unknown> = {
package_name: searchForm.package_name || undefined,
series_id: searchForm.series_id ?? undefined,
package_type: searchForm.package_type || undefined,
shelf_status: searchForm.shelf_status ?? undefined,
status: searchForm.status ?? undefined
}
Object.keys(query).forEach((key) => {
if (query[key] === undefined || query[key] === null || query[key] === '') {
delete query[key]
}
})
return query
})
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
@@ -1246,10 +1329,10 @@
form.package_name = data.package_name
form.series_id = data.series_id
form.package_type = data.package_type
form.calendar_type = data.calendar_type || undefined
form.calendar_type = data.calendar_type ?? null
form.duration_days = data.duration_days
form.duration_months = data.duration_months
form.data_reset_cycle = data.data_reset_cycle || undefined
form.data_reset_cycle = data.data_reset_cycle ?? null
form.enable_virtual_data = data.enable_virtual_data || false
form.expiry_base = data.expiry_base || 'from_activation'
form.real_data_mb = data.real_data_mb || 0
@@ -1270,7 +1353,6 @@
: undefined
originalSuggestedRetailPrice.value = data.suggested_retail_price ?? null
form.is_gift = data.is_gift || false
form.description = data.description || ''
// 数据填充完成后再显示对话框
dialogVisible.value = true
@@ -1285,10 +1367,10 @@
form.package_name = ''
form.series_id = undefined
form.package_type = ''
form.calendar_type = undefined
form.calendar_type = null
form.duration_days = undefined
form.duration_months = 1
form.data_reset_cycle = undefined
form.data_reset_cycle = null
form.enable_virtual_data = false
form.expiry_base = 'from_activation'
form.real_data_mb = 0
@@ -1298,13 +1380,12 @@
virtualRatioPercent.value = null
form.cost_price = 0
form.suggested_retail_price = undefined
form.description = ''
dialogVisible.value = true
}
// 重置表单验证状态
nextTick(() => {
await nextTick(() => {
formRef.value?.clearValidate()
})
}
@@ -1329,10 +1410,10 @@
form.package_name = ''
form.series_id = undefined
form.package_type = ''
form.calendar_type = undefined
form.calendar_type = null
form.duration_days = undefined
form.duration_months = 1
form.data_reset_cycle = undefined
form.data_reset_cycle = null
form.enable_virtual_data = false
form.expiry_base = 'from_activation'
form.real_data_mb = 0
@@ -1344,7 +1425,6 @@
form.suggested_retail_price = undefined
originalSuggestedRetailPrice.value = null
form.is_gift = false
form.description = ''
}
// 删除套餐
@@ -1380,24 +1460,23 @@
const costPriceInCents = Math.round(form.cost_price * 100)
const data: any = {
package_code: form.package_code,
package_name: form.package_name,
package_type: form.package_type,
duration_months: form.duration_months,
cost_price: costPriceInCents,
calendar_type: form.calendar_type ?? null,
data_reset_cycle: form.data_reset_cycle ?? null,
is_gift: form.is_gift
}
if (dialogType.value === 'add') {
data.package_code = form.package_code
}
// 可选字段
if (form.series_id !== undefined && form.series_id !== null) {
if (form.series_id !== undefined) {
data.series_id = form.series_id
}
if (form.calendar_type) {
data.calendar_type = form.calendar_type
}
if (form.data_reset_cycle) {
data.data_reset_cycle = form.data_reset_cycle
}
// 只有 calendar_type 为 by_day 时才传 duration_days
if (form.calendar_type === 'by_day' && form.duration_days !== undefined) {
data.duration_days = form.duration_days
@@ -1451,7 +1530,7 @@
// 状态切换
const handleStatusChange = async (row: PackageResponse, newFrontendStatus: number) => {
const oldStatus = row.status
const newApiStatus = frontendStatusToApi(newFrontendStatus)
const newApiStatus = newFrontendStatus
row.status = newApiStatus
try {
await PackageManageService.updatePackageStatus(row.id, newApiStatus)

View File

@@ -398,7 +398,6 @@
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
STATUS_SELECT_OPTIONS,
ENABLE_STATUS_OPTIONS,
getEnableStatusText
} from '@/config/constants'
@@ -406,6 +405,11 @@
defineOptions({ name: 'PackageSeries' })
const PACKAGE_SERIES_STATUS_SELECT_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 2 }
]
const { hasAuth } = useAuth()
const canUpdatePackageSeriesStatus = hasAuth('package_series:update_status')
const router = useRouter()
@@ -455,7 +459,7 @@
clearable: true,
placeholder: '请选择状态'
},
options: STATUS_SELECT_OPTIONS
options: PACKAGE_SERIES_STATUS_SELECT_OPTIONS
}
]
@@ -788,7 +792,7 @@
page: pagination.page,
page_size: pagination.page_size,
series_name: searchForm.series_name || undefined,
status: searchForm.status || undefined,
status: searchForm.status ?? undefined,
enable_one_time_commission: searchForm.enable_one_time_commission ?? undefined
}
const res = await PackageSeriesService.getPackageSeries(params)
@@ -1017,11 +1021,14 @@
// 添加强充配置:元 -> 分(仅累计充值时需要)
if (form.one_time_commission_config.trigger_type === 'accumulated_recharge') {
commissionConfig.enable_force_recharge = true
commissionConfig.force_amount =
form.one_time_commission_config.force_amount != null
const enableForceRecharge =
form.one_time_commission_config.enable_force_recharge === true
commissionConfig.enable_force_recharge = enableForceRecharge
commissionConfig.force_amount = enableForceRecharge
? form.one_time_commission_config.force_amount != null
? Math.round(form.one_time_commission_config.force_amount * 100)
: undefined
: 0
commissionConfig.force_calc_type = form.one_time_commission_config.force_calc_type
}

View File

@@ -88,17 +88,8 @@
</template>
</ElTableColumn>
<ElTableColumn label="统计范围" width="120">
<template #default="{ row }">
<ElTag size="small" type="warning">
仅自己
<!--{{-->
<!-- row.stat_scope === 'self'-->
<!-- ? '仅自己'-->
<!-- : row.stat_scope === 'self_and_sub'-->
<!-- ? '自己+下级'-->
<!-- : '-'-->
<!--}}-->
</ElTag>
<template #default>
<ElTag size="small" type="warning">仅自己</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="佣金金额" min-width="100">
@@ -152,30 +143,23 @@
<!-- 套餐列表 -->
<div v-if="detailData.packages && detailData.packages.length > 0" class="package-table">
<ElTable :data="detailData.packages" border stripe>
<ElTableColumn prop="package_name" label="套餐名称" />
<ElTableColumn prop="package_code" label="套餐编码" />
<ElTableColumn label="成本价">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<SeriesGrantPackageTable
:data="detailData.packages"
:loading="loading"
:show-actions="true"
:actions-width="240"
>
<template #actions="{ row }">
<div class="package-table-actions">
<ElButton
v-if="canUpdateExpiryBase"
type="primary"
size="small"
link
@click="showExpiryBaseDialog(row)"
>
修改生效条件
</ElButton>
<ElButton
type="primary"
size="small"
@@ -194,9 +178,9 @@
>
删除
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
</template>
</SeriesGrantPackageTable>
</div>
<ElEmpty v-else description="暂无套餐" :image-size="80" />
</div>
@@ -208,72 +192,58 @@
<span>加载中...</span>
</div>
<!-- 添加/编辑套餐对话框 -->
<ElDialog
<SeriesGrantPackageDialog
v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="500px"
:close-on-click-modal="false"
v-model:form="packageForm"
:dialog-type="packageDialogType"
:rules="packageRules"
:available-packages="availablePackages"
:package-loading="packageLoading"
:submit-loading="submitLoading"
:can-update-expiry-base="canUpdateExpiryBase"
:has-series="Boolean(detailData?.series_id)"
@search="searchPackages"
@submit="handleSavePackage"
@closed="handlePackageDialogClosed"
/>
<ElDialog
v-model="expiryDialogVisible"
title="修改生效条件"
width="460px"
:close-on-click-modal="false"
>
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="100px">
<!-- 添加模式:选择套餐 -->
<ElFormItem label="选择套餐" prop="package_id" v-if="packageDialogType === 'add'">
<ElSelect
v-model="packageForm.package_id"
placeholder="请选择套餐"
style="width: 100%"
filterable
remote
:remote-method="searchPackages"
:loading="packageLoading"
clearable
>
<template
v-if="availablePackages.length === 0 && !packageLoading && detailData?.series_id"
>
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in availablePackages"
:key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id"
:disabled="isPackageAlreadyAdded(pkg.id)"
/>
<ElForm label-width="130px">
<ElFormItem label="套餐名称">
<span class="ellipsis-value" :title="expiryForm.package_name || ''">
{{ expiryForm.package_name || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐编码">
<span class="ellipsis-value" :title="expiryForm.package_code || ''">
{{ expiryForm.package_code || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐默认生效条件">
{{ expiryForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
<ElSelect v-model="expiryForm.expiry_base_override" style="width: 100%">
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
</ElFormItem>
<!-- 编辑模式:显示套餐信息 -->
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_name }}</span>
</ElFormItem>
<ElFormItem label="套餐编码" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_code }}</span>
</ElFormItem>
<!-- 成本价 -->
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价: ¥{{
packageForm.original_cost_price.toFixed(2)
}}
</div>
<ElFormItem label="最终生效条件">
{{ expiryForm.effective_expiry_base_name || '-' }}
</ElFormItem>
<div class="form-tip">仅影响后续新订单,不影响已购买套餐</div>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="packageDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSavePackage" :loading="submitLoading">
<ElButton @click="expiryDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="expirySubmitLoading" @click="saveExpiryBase">
保存
</ElButton>
</div>
@@ -301,28 +271,43 @@
ElDialog,
ElSelect,
ElOption,
ElInputNumber,
ElForm,
ElFormItem,
FormInstance,
FormRules
} from 'element-plus'
import { ArrowLeft, Loading, Setting, List, Plus } from '@element-plus/icons-vue'
import { ShopSeriesGrantService, PackageManageService, PackageSeriesService } from '@/api/modules'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import {
ShopSeriesGrantService,
ShopPackageAllocationService,
PackageManageService,
PackageSeriesService
} from '@/api/modules'
import type {
ShopSeriesGrantResponse,
PackageResponse,
GrantPackageItem,
GrantPackageInfo,
PackageSeriesResponse
PackageAllocationExpiryBaseOverride,
PackageSeriesResponse,
PackageResponse
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getEnableStatusText } from '@/config/constants'
import { getEnableStatusText, JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
import SeriesGrantPackageTable from '@/components/business/SeriesGrantPackageTable.vue'
import SeriesGrantPackageDialog from '@/components/business/SeriesGrantPackageDialog.vue'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate,
type PackageCostFormItem,
type SeriesGrantPackageForm
} from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantsDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
const loading = ref(false)
const submitLoading = ref(false)
@@ -331,52 +316,80 @@
const seriesData = ref<PackageSeriesResponse | null>(null)
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
const availablePackages = ref<PackageResponse[]>([])
const packageFormRef = ref<FormInstance>()
const availablePackages = ref<GrantPackageCandidate[]>([])
const expiryDialogVisible = ref(false)
const expirySubmitLoading = ref(false)
// 套餐表单
const packageForm = ref<{
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
type ExpiryFormState = {
package_id?: number
allocation_id?: number
package_name?: string
package_code?: string
original_cost_price?: number
cost_price_yuan: number
}>({
cost_price_yuan: 0
default_expiry_base_name?: string | null
expiry_base_override: ExpiryBaseSelection
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
}
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const createDefaultPackageForm = (): SeriesGrantPackageForm => ({
package_id: undefined,
allocation_id: undefined,
package_ids: [],
package_name: undefined,
package_code: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default',
packages: []
})
const packageForm = ref<SeriesGrantPackageForm>(createDefaultPackageForm())
// 表单验证规则
const packageRules = computed<FormRules>(() => ({
package_id: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' }
package_ids: [
{
validator: (_rule, value, callback) => {
if (packageDialogType.value !== 'add') {
callback()
return
}
if (!Array.isArray(value) || value.length === 0) {
callback(new Error('请至少选择一个套餐'))
} else if (value.length > 100) {
callback(new Error('最多选择100个套餐'))
} else {
callback()
}
},
trigger: 'change'
}
],
cost_price_yuan: [
{ required: true, message: '请输入成本价', trigger: 'blur' },
{ type: 'number', min: 0, message: '成本价不能小于0', trigger: 'blur' }
{
required: packageDialogType.value === 'edit',
message: '请输入成本价',
trigger: 'blur'
},
{
validator: (_rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('请输入成本价'))
} else if (value < 0) {
callback(new Error('成本价不能小于0'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}))
// 梯度配置表格数据(合并授权数据和系列数据)
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value !== 'add' || !packageId) return
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
if (!selectedPackage) return
const originalCostPrice =
selectedPackage.cost_price !== undefined && selectedPackage.cost_price !== null
? selectedPackage.cost_price / 100
: 0
packageForm.value.package_name = selectedPackage.package_name
packageForm.value.package_code = selectedPackage.package_code
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
)
const tierTableData = computed(() => {
if (
!detailData.value?.commission_tiers ||
@@ -455,16 +468,31 @@
}
}
// 显示添加套餐对话框
const assignPackageForm = (values: Partial<SeriesGrantPackageForm> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
}
const resolvePackagePricing = (row?: GrantPackageInfo) => {
const pkgOption = availablePackages.value.find((pkg) => pkg.id === row?.package_id)
const currentCostPrice = row?.cost_price !== undefined ? row.cost_price / 100 : 0
return {
original_cost_price:
pkgOption?.cost_price !== undefined && pkgOption.cost_price !== null
? pkgOption.cost_price / 100
: currentCostPrice,
suggested_retail_price:
pkgOption?.suggested_retail_price !== undefined && pkgOption.suggested_retail_price !== null
? pkgOption.suggested_retail_price / 100
: undefined
}
}
// 显示添加套餐对话框
const showAddPackageDialog = () => {
packageDialogType.value = 'add'
packageForm.value = {
package_id: undefined,
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0
}
assignPackageForm({ package_ids: [], packages: [] })
// 加载可用套餐
if (detailData.value?.series_id) {
@@ -477,37 +505,75 @@
// 显示编辑套餐对话框
const showEditPackageDialog = (row: GrantPackageInfo) => {
packageDialogType.value = 'edit'
packageForm.value = {
const packagePricing = resolvePackagePricing(row)
assignPackageForm({
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
original_cost_price: row.cost_price / 100,
cost_price_yuan: row.cost_price / 100
}
original_cost_price: packagePricing.original_cost_price,
cost_price_yuan: row.cost_price / 100,
suggested_retail_price: packagePricing.suggested_retail_price,
expiry_base_override: row.expiry_base_override ?? 'default',
initial_expiry_base_override: row.expiry_base_override ?? 'default',
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
})
packageDialogVisible.value = true
}
const showExpiryBaseDialog = (row: GrantPackageInfo) => {
if (row.allocation_id === undefined || row.allocation_id === null) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID,无法修改生效条件')
return
}
expiryForm.value = {
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override: row.expiry_base_override ?? 'default',
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
}
expiryDialogVisible.value = true
}
// 加载可用套餐
const loadAvailablePackages = async (packageName?: string) => {
if (!detailData.value?.series_id) return
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 50,
series_id: detailData.value.series_id
}
const pageSize = 100
const allPackages: PackageResponse[] = []
let page = 1
let total = 0
if (packageName) {
params.package_name = packageName
}
do {
const params: any = {
page,
page_size: pageSize,
series_id: detailData.value.series_id
}
if (packageName) params.package_name = packageName
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
availablePackages.value = res.data.items
}
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
availablePackages.value = mergeGrantPackageCandidates(
allPackages,
detailData.value.packages || []
)
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {
@@ -524,50 +590,139 @@
}
}
// 检查套餐是否已添加
const isPackageAlreadyAdded = (packageId: number) => {
return detailData.value?.packages?.some((p) => p.package_id === packageId) || false
const createPackageCostFormItem = (packageId: number): PackageCostFormItem => {
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
const originalCostPrice = selectedPackage?.cost_price ? selectedPackage.cost_price / 100 : 0
return {
package_id: packageId,
package_name: selectedPackage?.package_name,
package_code: selectedPackage?.package_code,
cost_price_yuan: originalCostPrice,
original_cost_price: originalCostPrice,
suggested_retail_price:
selectedPackage?.suggested_retail_price !== undefined &&
selectedPackage.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
}
}
const getPackageCostPriceMax = (pkg?: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg?.suggested_retail_price !== undefined && pkg.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg?.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
watch(
() => packageForm.value.package_ids,
(packageIds) => {
if (packageDialogType.value !== 'add') return
const existingItems = new Map(
packageForm.value.packages.map((item) => [item.package_id, item])
)
packageForm.value.packages = packageIds.map(
(packageId) => existingItems.get(packageId) || createPackageCostFormItem(packageId)
)
},
{ deep: true }
)
// 保存套餐
const handleSavePackage = async () => {
if (!packageFormRef.value || !detailData.value) return
if (!detailData.value) return
const grantId = detailData.value.id // 保存到常量中避免类型检查问题
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const packages: GrantPackageItem[] = []
if (packageDialogType.value === 'add') {
// 添加模式:添加新套餐
packages.push({
package_id: packageForm.value.package_id,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
})
} else {
// 编辑模式:更新套餐成本价
packages.push({
package_id: packageForm.value.package_id,
cost_price: Math.round(packageForm.value.cost_price_yuan * 100)
})
const grantId = detailData.value.id
submitLoading.value = true
try {
if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐')
return
}
const packages: GrantPackageItem[] = packageForm.value.packages.map((pkg) => {
const currentCostPrice = Number(pkg.cost_price_yuan)
if (!Number.isFinite(currentCostPrice) || currentCostPrice < 0) {
throw new Error(`套餐 ${pkg.package_name || pkg.package_id} 的成本价无效`)
}
const maxCostPrice = getPackageCostPriceMax(pkg)
if (maxCostPrice !== undefined && currentCostPrice > maxCostPrice) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
)
}
if (pkg.original_cost_price !== undefined && currentCostPrice < pkg.original_cost_price) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
)
}
return {
package_id: pkg.package_id,
cost_price: Math.round(currentCostPrice * 100)
}
})
await ShopSeriesGrantService.manageGrantPackages(grantId, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
} else {
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const costPrice = Math.round(currentCostPriceYuan * 100)
const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.initial_expiry_base_override
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID,无法修改生效条件')
return
}
await ShopSeriesGrantService.manageGrantPackages(grantId, { packages })
ElMessage.success(packageDialogType.value === 'add' ? '添加成功' : '更新成功')
packageDialogVisible.value = false
// 刷新详情数据
await fetchDetail()
} catch (error) {
console.error(error)
console.log(packageDialogType.value === 'add' ? '添加失败' : '更新失败')
} finally {
submitLoading.value = false
const packages: GrantPackageItem[] = [
{ package_id: packageForm.value.package_id!, cost_price: costPrice }
]
if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
packageForm.value.allocation_id!,
{
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
}
)
}
await ShopSeriesGrantService.manageGrantPackages(grantId, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
}
})
ElMessage.success(packageDialogType.value === 'add' ? '添加成功' : '更新成功')
packageDialogVisible.value = false
// 刷新详情数据
await fetchDetail()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
} finally {
submitLoading.value = false
}
}
// 删除套餐
@@ -589,7 +744,10 @@
}
]
await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, { packages })
await ShopSeriesGrantService.manageGrantPackages(detailData.value.id, {
expiry_base_override: row.expiry_base_override ?? null,
packages
})
ElMessage.success('删除成功')
// 刷新详情数据
@@ -606,15 +764,35 @@
})
}
const saveExpiryBase = async () => {
if (expiryForm.value.allocation_id === undefined || expiryForm.value.allocation_id === null) {
return
}
expirySubmitLoading.value = true
try {
const response = await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
expiryForm.value.allocation_id,
{
expiry_base_override:
expiryForm.value.expiry_base_override === 'default'
? null
: expiryForm.value.expiry_base_override
}
)
if (response.code === 0) {
ElMessage.success('生效条件更新成功')
expiryDialogVisible.value = false
await fetchDetail()
}
} finally {
expirySubmitLoading.value = false
}
}
// 关闭对话框
const handlePackageDialogClosed = () => {
packageFormRef.value?.resetFields()
packageForm.value = {
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
cost_price_yuan: 0
}
assignPackageForm()
availablePackages.value = []
}
@@ -661,6 +839,13 @@
.package-table {
margin-top: 12px;
.package-table-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
}
.tier-table-wrapper {
@@ -691,6 +876,21 @@
color: var(--el-color-warning);
}
.ellipsis-value {
display: block;
width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-footer {
text-align: right;
}

View File

@@ -113,20 +113,19 @@
<!-- 套餐配置 -->
<div v-if="form.shop_id" class="form-section-title">
<span class="title-text">套餐配置(可选)</span>
<span class="title-text">套餐配置(必选)</span>
</div>
<template v-if="form.shop_id">
<!-- 选择套餐 -->
<ElFormItem label="选择套餐">
<ElFormItem label="选择套餐" prop="packages">
<ElSelect
v-model="selectedPackageIds"
placeholder="请选择套餐"
style="width: 100%"
multiple
:multiple-limit="100"
filterable
remote
:remote-method="searchPackages"
:loading="packageLoading"
clearable
>
@@ -138,15 +137,16 @@
<ElOption
v-for="pkg in packageOptions"
:key="pkg.id"
:label="pkg.package_name"
:label="`${pkg.package_name}${pkg.authorized ? '(已授权)' : ''}`"
:value="pkg.id"
:disabled="pkg.authorized"
/>
</ElSelect>
<div class="form-tip">选择该授权下包含的套餐</div>
<div class="form-tip">首次创建必须选择 1~100 个不重复套餐</div>
</ElFormItem>
<!-- 套餐成本价 -->
<ElFormItem label="套餐成本价" v-if="form.packages.length > 0">
<ElFormItem label="套餐配置" v-if="form.packages.length > 0">
<div class="package-list">
<div
v-for="(pkg, index) in form.packages"
@@ -155,6 +155,7 @@
>
<span class="package-name">{{ getPackageName(pkg.package_id) }}</span>
<div class="cost-price-input-wrapper">
<div class="package-field-label">套餐成本价(元)</div>
<ElInputNumber
v-model="pkg.cost_price"
:min="pkg.original_cost_price || 0"
@@ -179,6 +180,16 @@
>设置每个套餐的成本价(单位:元),不能低于成本价,不能高于建议售价的50%溢价</div
>
</ElFormItem>
<ElFormItem label="套餐生效条件">
<ElSelect v-model="form.expiry_base_override" style="width: 100%">
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
<div class="form-tip"
>本次所有授权套餐共用此选择,仅影响后续新订单,不影响已购买套餐</div
>
</ElFormItem>
</template>
</div>
@@ -253,7 +264,7 @@
</template>
</ElTableColumn>
<ElTableColumn label="统计范围" width="140">
<template #default="{ row }">
<template #default>
<ElTag size="small" type="warning"> 仅自己 </ElTag>
</template>
</ElTableColumn>
@@ -427,7 +438,7 @@
</template>
</ElTableColumn>
<ElTableColumn label="统计范围" width="140">
<template #default="{ row }">
<template #default>
<ElTag size="small" type="warning"> 仅自己 </ElTag>
</template>
</ElTableColumn>
@@ -472,14 +483,14 @@
<ElFormItem label="启用强制充值">
<ElSwitch
v-model="form.enable_force_recharge"
:disabled="form.force_recharge_locked && isAgentAccount"
:disabled="isForceRechargeLockedForAgent"
/>
<ElTag v-if="form.force_recharge_locked" type="danger" size="small">
平台已锁定
</ElTag>
</ElFormItem>
</ElCol>
<ElCol
:span="12"
v-if="form.enable_force_recharge && !(form.force_recharge_locked && isAgentAccount)"
>
<ElCol :span="12" v-if="form.enable_force_recharge">
<ElFormItem label="强充金额(元)" prop="force_recharge_amount">
<ElInputNumber
v-model="form.force_recharge_amount"
@@ -487,6 +498,7 @@
:precision="2"
:step="0.01"
:controls="false"
:disabled="isForceRechargeLockedForAgent"
style="width: 100%"
placeholder="请输入强制充值金额(元)"
/>
@@ -504,6 +516,9 @@
>¥{{ form.series_force_recharge_amount.toFixed(2) }}</span
>
</span>
<span v-if="isForceRechargeLockedForAgent" class="force-recharge-lock-hint">
<br />平台已锁定,代理不可修改强充配置
</span>
</div>
</ElFormItem>
</ElCol>
@@ -559,7 +574,8 @@
ElCol,
ElSteps,
ElStep,
ElEmpty
ElSelect,
ElOption
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
@@ -567,7 +583,8 @@
PackageSeriesResponse,
ShopResponse,
PackageResponse,
CommissionTier
CommissionTier,
PackageAllocationExpiryBaseOverride
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -580,12 +597,16 @@
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
STATUS_SELECT_OPTIONS,
getEnableStatusText
} from '@/config/constants'
defineOptions({ name: 'SeriesGrants' })
const SERIES_GRANT_STATUS_SELECT_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 2 }
]
const { hasAuth } = useAuth()
const canUpdateSeriesGrantStatus = hasAuth('series_grants:update_status')
const router = useRouter()
@@ -604,9 +625,14 @@
const currentStep = ref(0) // 当前步骤,0 为第一步,1 为第二步
const seriesOptions = ref<PackageSeriesResponse[]>([])
const shopOptions = ref<ShopResponse[]>([])
const shopTreeData = ref<ShopResponse[]>([])
const packageOptions = ref<PackageResponse[]>([])
type GrantPackageOptionView = {
id: number
package_name: string
cost_price: number
suggested_retail_price?: number | null
authorized: boolean
}
const packageOptions = ref<GrantPackageOptionView[]>([])
const selectedPackageIds = ref<number[]>([])
const searchSeriesOptions = ref<PackageSeriesResponse[]>([])
const searchShopOptions = ref<ShopResponse[]>([])
@@ -724,7 +750,7 @@
clearable: true,
placeholder: '请选择状态'
},
options: STATUS_SELECT_OPTIONS
options: SERIES_GRANT_STATUS_SELECT_OPTIONS
}
])
@@ -772,6 +798,7 @@
force_recharge_amount: undefined,
force_recharge_locked: false, // 强充是否被套餐系列锁定
series_force_recharge_amount: 0, // 系列强充金额(元)
expiry_base_override: 'default' as PackageAllocationExpiryBaseOverride | 'default',
packages: [] as Array<{
package_id: number
cost_price: number
@@ -780,6 +807,10 @@
}> // 套餐配置
})
const isForceRechargeLockedForAgent = computed(
() => form.force_recharge_locked === true && isAgentAccount.value
)
// 动态验证规则
const rules = computed<FormRules>(() => {
const baseRules: FormRules = {
@@ -787,6 +818,24 @@
shop_id: [{ required: true, message: '请选择店铺', trigger: 'change' }]
}
baseRules.packages = [
{
validator: (_rule, value, callback) => {
const packageIds = (value || []).map((pkg: { package_id: number }) => pkg.package_id)
if (packageIds.length < 1) {
callback(new Error('请至少选择 1 个套餐'))
} else if (packageIds.length > 100) {
callback(new Error('最多选择 100 个套餐'))
} else if (new Set(packageIds).size !== packageIds.length) {
callback(new Error('套餐不能重复'))
} else {
callback()
}
},
trigger: 'change'
}
]
// 只有启用一次性佣金时才添加佣金验证规则
if (form.enable_one_time_commission) {
// 根据佣金类型添加验证规则
@@ -794,7 +843,7 @@
baseRules.one_time_commission_amount = [
{ required: true, message: '请输入固定佣金金额', trigger: 'blur' },
{
validator: (rule, value, callback) => {
validator: (_rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('请输入固定佣金金额'))
} else if (value < 0) {
@@ -818,7 +867,7 @@
} else if (form.commission_type === 'tiered') {
baseRules.commission_tiers = [
{
validator: (rule, value, callback) => {
validator: (_rule, value, callback) => {
if (!value || value.length === 0) {
callback(new Error('请至少添加一个梯度配置'))
return
@@ -1027,39 +1076,6 @@
return actions
}
// 构建树形结构数据
const buildTreeData = (items: ShopResponse[]) => {
const map = new Map<number, ShopResponse & { children?: ShopResponse[] }>()
const tree: ShopResponse[] = []
// 先将所有项放入 map
items.forEach((item) => {
map.set(item.id, { ...item, children: [] })
})
// 构建树形结构
items.forEach((item) => {
const node = map.get(item.id)!
if (item.parent_id && map.has(item.parent_id)) {
// 有父节点,添加到父节点的 children 中
const parent = map.get(item.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
// 没有父节点或父节点不存在,作为根节点
tree.push(node)
}
})
return tree
}
// 店铺树节点过滤方法
const filterShopNode = (value: string, data: any) => {
if (!value) return true
return data.shop_name.toLowerCase().includes(value.toLowerCase())
}
onMounted(() => {
getTableData()
})
@@ -1072,29 +1088,39 @@
isFirstActivation = false
})
// 加载套餐选项(用于新增对话框)
// 加载套餐选项(用于编辑对话框)
const loadPackageOptions = async (packageName?: string) => {
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 20
}
const pageSize = 100
const allPackages: PackageResponse[] = []
let page = 1
let total = 0
// 如果已选择套餐系列,则根据系列ID过滤套餐
if (form.series_id) {
params.series_id = form.series_id
}
do {
const params: any = { page, page_size: pageSize }
if (form.series_id) params.series_id = form.series_id
if (packageName) params.package_name = packageName
if (packageName) {
params.package_name = packageName
}
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
// 过滤掉赠送套餐
packageOptions.value = res.data.items.filter((pkg) => !pkg.is_gift)
}
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
packageOptions.value = allPackages
.filter((pkg) => !pkg.is_gift)
.map(
(pkg): GrantPackageOptionView => ({
id: pkg.id,
package_name: pkg.package_name,
cost_price: pkg.cost_price ?? 0,
suggested_retail_price: pkg.suggested_retail_price,
authorized: false
})
)
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {
@@ -1102,20 +1128,29 @@
}
}
// 搜索套餐
const searchPackages = (query: string) => {
if (query) {
loadPackageOptions(query)
} else {
loadPackageOptions()
const loadGrantPackageOptions = async () => {
const shopId = form.shop_id
const seriesId = form.series_id
if (dialogType.value !== 'add' || !shopId || !seriesId) return
packageLoading.value = true
try {
const res = await ShopSeriesGrantService.getPackageOptions(shopId, seriesId)
if (res.code === 0 && form.shop_id === shopId && form.series_id === seriesId) {
packageOptions.value = res.data.items || []
}
} catch (error) {
console.error('加载授权套餐候选项失败:', error)
} finally {
packageLoading.value = false
}
}
// 移除套餐
const removePackage = (index: number) => {
const removedPackageId = form.packages[index]?.package_id
form.packages.splice(index, 1)
// 同步更新选中的套餐ID
const removedPackageId = form.packages[index]?.package_id
if (removedPackageId) {
const idIndex = selectedPackageIds.value.indexOf(removedPackageId)
if (idIndex > -1) {
@@ -1127,7 +1162,7 @@
// 获取套餐名称
const getPackageName = (packageId: number) => {
const pkg = packageOptions.value.find((p) => p.id === packageId)
return pkg ? pkg.package_name : `套餐ID: ${packageId}`
return pkg ? pkg.package_name : '套餐名称不可用'
}
const getPackageCostPriceMax = (pkg?: {
@@ -1252,8 +1287,8 @@
}
}
// 重新加载该系列下的套餐
loadPackageOptions()
// 仅在已选择目标店铺后加载该组合的授权套餐候选项
await loadGrantPackageOptions()
} else {
// 未选择系列,重置佣金类型
form.series_name = ''
@@ -1267,6 +1302,17 @@
}
)
watch(
() => form.shop_id,
(newShopId, oldShopId) => {
if (dialogType.value !== 'add' || newShopId === oldShopId) return
form.packages = []
selectedPackageIds.value = []
packageOptions.value = []
loadGrantPackageOptions()
}
)
// 监听启用强制充值开关,自动设置默认强充金额
watch(
() => form.enable_force_recharge,
@@ -1454,7 +1500,7 @@
series_id: searchForm.series_id || undefined,
allocator_shop_id:
searchForm.allocator_shop_id !== undefined ? searchForm.allocator_shop_id : undefined,
status: searchForm.status || undefined
status: searchForm.status ?? undefined
}
const res = await ShopSeriesGrantService.getShopSeriesGrants(params)
if (res.code === 0) {
@@ -1571,11 +1617,10 @@
}
// 设置强制充值配置
form.enable_force_recharge = detail.force_recharge_enabled
form.force_recharge_amount = detail.force_recharge_amount
? detail.force_recharge_amount / 100
: undefined
form.force_recharge_locked = detail.force_recharge_locked || false
form.force_recharge_locked = detail.force_recharge_locked ?? false
form.enable_force_recharge = detail.force_recharge_enabled ?? false
form.force_recharge_amount =
detail.force_recharge_amount != null ? detail.force_recharge_amount / 100 : undefined
// 设置套餐数据
if (detail.packages && detail.packages.length > 0) {
@@ -1631,12 +1676,13 @@
form.force_recharge_amount = undefined
form.force_recharge_locked = false
form.packages = []
form.expiry_base_override = 'default'
selectedPackageIds.value = []
packageOptions.value = [] // 清空套餐选项
}
// 重置表单验证状态
nextTick(() => {
await nextTick(() => {
formRef.value?.clearValidate()
})
}
@@ -1654,6 +1700,7 @@
form.series_name = ''
form.shop_name = ''
form.allocator_shop_name = ''
form.expiry_base_override = 'default'
form.enable_one_time_commission = false
form.commission_type = 'fixed'
form.one_time_commission_amount = undefined
@@ -1664,6 +1711,7 @@
form.force_recharge_amount = undefined
form.force_recharge_locked = false
form.packages = []
form.expiry_base_override = 'default'
selectedPackageIds.value = []
}
@@ -1745,18 +1793,26 @@
data.force_recharge_amount = 0
}
// 可选:套餐配置(将元转换为分)
if (form.packages.length > 0) {
// 验证是否包含赠送套餐
for (const pkg of form.packages) {
const pkgInfo = packageOptions.value.find((p) => p.id === pkg.package_id)
if (pkgInfo?.is_gift) {
ElMessage.error(`套餐 ${getPackageName(pkg.package_id)} 是赠送套餐,不能参与授权`)
submitLoading.value = false
return
}
}
const packageItems = form.packages.map((pkg: any) => ({
package_id: pkg.package_id,
cost_price: Math.round((pkg.cost_price ?? 0) * 100)
}))
if (dialogType.value === 'add') {
const packageIds = packageItems.map((pkg: { package_id: number }) => pkg.package_id)
if (packageIds.length < 1 || packageIds.length > 100) {
ElMessage.error('首次创建必须选择 1~100 个套餐')
submitLoading.value = false
return
}
if (new Set(packageIds).size !== packageIds.length) {
ElMessage.error('套餐不能重复')
submitLoading.value = false
return
}
}
if (packageItems.length > 0) {
// 验证成本价是否在允许范围内
for (const pkg of form.packages) {
const costPrice = pkg.cost_price ?? 0
@@ -1778,17 +1834,15 @@
return
}
}
data.packages = form.packages.map((pkg: any) => ({
package_id: pkg.package_id,
cost_price: Math.round((pkg.cost_price ?? 0) * 100) // 将元转换为分
}))
}
if (dialogType.value === 'add') {
// 新增时需要必填字段
data.series_id = form.series_id
data.shop_id = form.shop_id
data.expiry_base_override =
form.expiry_base_override === 'default' ? null : form.expiry_base_override
data.packages = packageItems
await ShopSeriesGrantService.createShopSeriesGrant(data)
ElMessage.success('新增成功')
} else {
@@ -1831,8 +1885,8 @@
let fieldsToValidate: string[] = []
if (currentStep.value === 0) {
// 第一步:验证系列和店铺选择
fieldsToValidate = ['series_id', 'shop_id']
// 第一步:验证系列、店铺和初始套餐
fieldsToValidate = ['series_id', 'shop_id', 'packages']
}
try {
@@ -1888,6 +1942,10 @@
color: var(--el-color-primary);
}
}
.force-recharge-lock-hint {
color: var(--el-color-danger);
}
}
.form-section-title {
@@ -1935,9 +1993,10 @@
}
.package-item {
display: flex;
gap: 12px;
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 56px;
gap: 16px;
align-items: start;
padding: 8px;
margin-bottom: 8px;
background-color: var(--el-fill-color-light);
@@ -1952,20 +2011,97 @@
}
.package-name {
flex: 1;
grid-row: 1;
grid-column: 1 / 3;
min-width: 0;
padding-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.cost-price-input-wrapper {
display: flex;
gap: 8px;
align-items: center;
flex-direction: column;
grid-row: 2;
grid-column: 1;
gap: 6px;
align-items: flex-start;
width: 100%;
:deep(.el-input-number) {
width: 100% !important;
}
.package-field-label {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.min-cost-hint {
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
}
.expiry-base-input-wrapper {
display: flex;
flex-direction: column;
grid-row: 2;
grid-column: 2;
gap: 8px;
align-self: start;
width: 100%;
min-width: 0;
.expiry-base-label {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.expiry-base-select {
width: 100%;
max-width: none;
}
}
:deep(.el-button) {
grid-row: 1;
grid-column: 3;
place-self: start end;
width: 56px;
padding-right: 8px;
padding-left: 8px;
}
}
@media (width <= 1000px) {
.package-item {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 56px;
}
}
@media (width <= 640px) {
.package-item {
grid-template-columns: 1fr;
.expiry-base-input-wrapper {
grid-row: 3;
grid-column: 1 / -1;
}
.cost-price-input-wrapper {
grid-row: 2;
grid-column: 1 / -1;
}
:deep(.el-button) {
grid-row: 1;
grid-column: 3;
justify-self: start;
}
}
}

View File

@@ -21,103 +21,98 @@
</ElButton>
</div>
<ArtTable
ref="tableRef"
row-key="package_id"
:loading="loading"
<SeriesGrantPackageTable
:data="packageList"
:marginTop="10"
:pagination="false"
:actions="getActions"
:actionsWidth="150"
:inlineActionsCount="2"
:loading="loading"
:show-actions="true"
:actions-width="240"
>
<template #default>
<ElTableColumn prop="package_name" label="套餐名称" minWidth="150" />
<ElTableColumn prop="package_code" label="套餐编码" minWidth="120" />
<ElTableColumn label="成本价" width="120">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="上架状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.shelf_status === 1" type="success" size="small">上架</ElTag>
<ElTag v-else-if="row.shelf_status === 2" type="info" size="small">下架</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 1" type="success" size="small">启用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">禁用</ElTag>
<span v-else>-</span>
</template>
</ElTableColumn>
<template #actions="{ row }">
<div class="package-table-actions">
<ElButton
v-if="canUpdateExpiryBase"
type="primary"
size="small"
link
@click="showExpiryBaseDialog(row)"
>
修改生效条件
</ElButton>
<ElButton
v-if="hasAuth('series_grants:edit_packages_list')"
type="primary"
size="small"
link
@click="showEditPackageDialog(row)"
>
编辑
</ElButton>
<ElButton
v-if="hasAuth('series_grants:delete_packages_list')"
type="danger"
size="small"
link
@click="handleDeletePackage(row)"
>
删除
</ElButton>
</div>
</template>
</ArtTable>
</SeriesGrantPackageTable>
</ElCard>
<ElDialog
<SeriesGrantPackageDialog
v-model="packageDialogVisible"
:title="packageDialogType === 'add' ? '添加套餐' : '编辑套餐'"
width="500px"
:close-on-click-modal="false"
v-model:form="packageForm"
:dialog-type="packageDialogType"
:rules="packageRules"
:available-packages="availablePackages"
:package-loading="packageLoading"
:submit-loading="submitLoading"
:can-update-expiry-base="canUpdateExpiryBase"
:has-series="Boolean(seriesId)"
@search="searchAvailablePackages"
@submit="handleSavePackage"
@closed="handlePackageDialogClosed"
/>
<ElDialog
v-model="expiryDialogVisible"
title="修改生效条件"
width="460px"
:close-on-click-modal="false"
>
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="100px">
<ElFormItem label="选择套餐" prop="package_id" v-if="packageDialogType === 'add'">
<ElSelect
v-model="packageForm.package_id"
placeholder="请选择套餐"
style="width: 100%"
filterable
remote
:remote-method="searchAvailablePackages"
:loading="packageLoading"
clearable
>
<template v-if="availablePackages.length === 0 && !packageLoading && seriesId">
<ElOption disabled value="" label="该系列没有可选套餐" />
</template>
<ElOption
v-for="pkg in availablePackages"
:key="pkg.id"
:label="`${pkg.package_name} (${pkg.package_code})`"
:value="pkg.id"
:disabled="isPackageAlreadyAdded(pkg.id)"
/>
<ElForm label-width="130px">
<ElFormItem label="套餐名称">
<span class="ellipsis-value" :title="expiryForm.package_name || ''">
{{ expiryForm.package_name || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐编码">
<span class="ellipsis-value" :title="expiryForm.package_code || ''">
{{ expiryForm.package_code || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐默认生效条件">
{{ expiryForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
<ElSelect v-model="expiryForm.expiry_base_override" style="width: 100%">
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
</ElFormItem>
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_name }}</span>
</ElFormItem>
<ElFormItem label="套餐编码" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_code }}</span>
</ElFormItem>
<ElFormItem label="成本价(元)" prop="cost_price_yuan">
<ElInputNumber
v-model="packageForm.cost_price_yuan"
:precision="2"
:step="0.01"
:controls="false"
style="width: 100%"
placeholder="请输入成本价"
/>
<div v-if="packageForm.original_cost_price" class="form-tip">
请参考{{ packageForm.package_name || '套餐' }} - 套餐成本价: ¥{{
packageForm.original_cost_price.toFixed(2)
}}
</div>
<ElFormItem label="最终生效条件">
{{ expiryForm.effective_expiry_base_name || '-' }}
</ElFormItem>
<div class="form-tip">仅影响后续新订单,不影响已购买套餐</div>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="packageDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSavePackage" :loading="submitLoading">
<ElButton @click="expiryDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="expirySubmitLoading" @click="saveExpiryBase">
保存
</ElButton>
</div>
@@ -128,67 +123,90 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElCard,
ElButton,
ElIcon,
ElTableColumn,
ElTag,
ElDialog,
ElSelect,
ElOption,
ElInputNumber,
ElForm,
ElFormItem,
FormInstance,
ElMessage,
FormRules
} from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ShopSeriesGrantService, PackageManageService } from '@/api/modules'
import type { GrantPackageInfo, GrantPackageItem, PackageResponse } from '@/types/api'
import {
ShopSeriesGrantService,
ShopPackageAllocationService,
PackageManageService
} from '@/api/modules'
import type {
GrantPackageInfo,
GrantPackageItem,
PackageAllocationExpiryBaseOverride
} from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants'
import SeriesGrantPackageTable from '@/components/business/SeriesGrantPackageTable.vue'
import SeriesGrantPackageDialog from '@/components/business/SeriesGrantPackageDialog.vue'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate,
type SeriesGrantPackageForm,
type PackageCostFormItem
} from '@/utils/business/seriesGrantPackage'
defineOptions({ name: 'SeriesGrantPackages' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
const loading = ref(false)
const submitLoading = ref(false)
const packageLoading = ref(false)
const tableRef = ref()
const grantId = computed(() => Number(route.params.id))
const seriesId = ref<number>(0)
const grantName = ref<string>('')
const packageList = ref<GrantPackageInfo[]>([])
const availablePackages = ref<PackageResponse[]>([])
const packageFormRef = ref<FormInstance>()
const availablePackages = ref<GrantPackageCandidate[]>([])
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
const expiryDialogVisible = ref(false)
const expirySubmitLoading = ref(false)
type PackageFormState = {
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
type ExpiryFormState = {
package_id?: number
allocation_id?: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
default_expiry_base_name?: string | null
expiry_base_override: ExpiryBaseSelection
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
}
const createDefaultPackageForm = (): PackageFormState => ({
const createDefaultPackageForm = (): SeriesGrantPackageForm => ({
package_id: undefined,
allocation_id: undefined,
package_ids: [],
package_name: undefined,
package_code: undefined,
cost_price_yuan: 0
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default',
packages: []
})
const packageForm = ref<PackageFormState>(createDefaultPackageForm())
const packageForm = ref<SeriesGrantPackageForm>(createDefaultPackageForm())
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const assignPackageForm = (values: Partial<PackageFormState> = {}) => {
const assignPackageForm = (values: Partial<SeriesGrantPackageForm> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
}
@@ -210,13 +228,32 @@
}
const packageRules = computed<FormRules>(() => ({
package_id: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' }
package_ids: [
{
validator: (_rule, value, callback) => {
if (packageDialogType.value !== 'add') {
callback()
return
}
if (!Array.isArray(value) || value.length === 0) {
callback(new Error('请至少选择一个套餐'))
} else if (value.length > 100) {
callback(new Error('最多选择100个套餐'))
} else {
callback()
}
},
trigger: 'change'
}
],
cost_price_yuan: [
{ required: true, message: '请输入成本价', trigger: 'blur' },
{
validator: (rule, value, callback) => {
required: packageDialogType.value === 'edit',
message: '请输入成本价',
trigger: 'blur'
},
{
validator: (_rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback(new Error('请输入成本价'))
} else if (value < 0) {
@@ -230,28 +267,6 @@
]
}))
const getActions = (row: GrantPackageInfo) => {
const actions: any[] = []
if (hasAuth('series_grants:edit_packages_list')) {
actions.push({
label: '编辑',
handler: () => showEditPackageDialog(row),
type: 'primary'
})
}
if (hasAuth('series_grants:delete_packages_list')) {
actions.push({
label: '删除',
handler: () => handleDeletePackage(row),
type: 'danger'
})
}
return actions
}
// 返回上一页
const handleBack = () => {
router.back()
@@ -277,10 +292,9 @@
const showAddPackageDialog = () => {
packageDialogType.value = 'add'
assignPackageForm()
assignPackageForm({ package_ids: [], packages: [] })
loadAvailablePackages()
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
}
const showEditPackageDialog = (row: GrantPackageInfo) => {
@@ -289,14 +303,37 @@
assignPackageForm({
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
cost_price_yuan: row.cost_price / 100,
original_cost_price: packagePricing.original_cost_price,
suggested_retail_price: packagePricing.suggested_retail_price
suggested_retail_price: packagePricing.suggested_retail_price,
expiry_base_override: row.expiry_base_override ?? 'default',
initial_expiry_base_override: row.expiry_base_override ?? 'default',
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
})
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
}
const showExpiryBaseDialog = (row: GrantPackageInfo) => {
if (row.allocation_id === undefined || row.allocation_id === null) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID,无法修改生效条件')
return
}
expiryForm.value = {
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override: row.expiry_base_override ?? 'default',
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
}
expiryDialogVisible.value = true
}
const loadAvailablePackages = async (packageName?: string) => {
@@ -304,18 +341,20 @@
packageLoading.value = true
try {
const params: any = {
page: 1,
page_size: 50,
series_id: seriesId.value
}
if (packageName) {
params.package_name = packageName
}
const res = await PackageManageService.getPackages(params)
if (res.code === 0) {
availablePackages.value = res.data.items
}
const pageSize = 100
const allPackages: any[] = []
let page = 1
let total = 0
do {
const params: any = { page, page_size: pageSize, series_id: seriesId.value }
if (packageName) params.package_name = packageName
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
availablePackages.value = mergeGrantPackageCandidates(allPackages, packageList.value)
} catch (error) {
console.error('加载套餐选项失败:', error)
} finally {
@@ -331,60 +370,160 @@
}
}
const isPackageAlreadyAdded = (packageId: number) => {
return packageList.value.some((p) => p.package_id === packageId)
const createPackageCostFormItem = (packageId: number): PackageCostFormItem => {
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
const originalCostPrice = selectedPackage?.cost_price ? selectedPackage.cost_price / 100 : 0
return {
package_id: packageId,
package_name: selectedPackage?.package_name,
package_code: selectedPackage?.package_code,
cost_price_yuan: originalCostPrice,
original_cost_price: originalCostPrice,
suggested_retail_price:
selectedPackage?.suggested_retail_price !== undefined &&
selectedPackage?.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
}
}
const getPackageCostPriceMax = (pkg?: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg?.suggested_retail_price !== undefined && pkg.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg?.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
watch(
() => packageForm.value.package_id,
(packageId) => {
if (packageDialogType.value === 'add' && packageId) {
const selectedPackage = availablePackages.value.find((p) => p.id === packageId)
if (selectedPackage) {
const originalCostPrice = selectedPackage.cost_price
? selectedPackage.cost_price / 100
: 0
const suggestedRetailPrice =
selectedPackage.suggested_retail_price !== undefined &&
selectedPackage.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
packageForm.value.original_cost_price = originalCostPrice
packageForm.value.suggested_retail_price = suggestedRetailPrice
packageForm.value.cost_price_yuan = originalCostPrice
}
}
}
() => packageForm.value.package_ids,
(packageIds) => {
if (packageDialogType.value !== 'add') return
const existingItems = new Map(
packageForm.value.packages.map((item) => [item.package_id, item])
)
packageForm.value.packages = packageIds.map(
(packageId) => existingItems.get(packageId) || createPackageCostFormItem(packageId)
)
},
{ deep: true }
)
const handleSavePackage = async () => {
if (!packageFormRef.value || !grantId.value) return
if (!grantId.value) return
await packageFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
submitLoading.value = true
try {
if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐')
return
}
const packages: GrantPackageItem[] = packageForm.value.packages.map((pkg) => {
const currentCostPrice = Number(pkg.cost_price_yuan)
if (!Number.isFinite(currentCostPrice) || currentCostPrice < 0) {
throw new Error(`套餐 ${pkg.package_name || pkg.package_id} 的成本价无效`)
}
const maxCostPrice = getPackageCostPriceMax(pkg)
if (maxCostPrice !== undefined && currentCostPrice > maxCostPrice) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
)
}
if (pkg.original_cost_price !== undefined && currentCostPrice < pkg.original_cost_price) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
)
}
return {
package_id: pkg.package_id,
cost_price: Math.round(currentCostPrice * 100)
}
})
await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
} else {
const currentCostPriceYuan = Number(packageForm.value.cost_price_yuan)
if (Number.isNaN(currentCostPriceYuan)) {
throw new Error('当前成本价无效,无法保存')
}
const packages: GrantPackageItem[] = [
{
package_id: packageForm.value.package_id,
cost_price: Math.round(currentCostPriceYuan * 100)
}
]
const costPrice = Math.round(currentCostPriceYuan * 100)
const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.initial_expiry_base_override
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID,无法修改生效条件')
return
}
await ShopSeriesGrantService.manageGrantPackages(grantId.value, { packages })
packageDialogVisible.value = false
await fetchPackageList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
const packages: GrantPackageItem[] = [
{ package_id: packageForm.value.package_id!, cost_price: costPrice }
]
if (expiryBaseChanged) {
await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
packageForm.value.allocation_id!,
{
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override
}
)
}
await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages
})
}
})
packageDialogVisible.value = false
await fetchPackageList()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
} finally {
submitLoading.value = false
}
}
const saveExpiryBase = async () => {
if (expiryForm.value.allocation_id === undefined || expiryForm.value.allocation_id === null) {
return
}
expirySubmitLoading.value = true
try {
const response = await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
expiryForm.value.allocation_id,
{
expiry_base_override:
expiryForm.value.expiry_base_override === 'default'
? null
: expiryForm.value.expiry_base_override
}
)
if (response.code === 0) {
ElMessage.success('生效条件更新成功')
expiryDialogVisible.value = false
await fetchPackageList()
}
} finally {
expirySubmitLoading.value = false
}
}
const handleDeletePackage = (row: GrantPackageInfo) => {
@@ -403,7 +542,10 @@
remove: true
}
]
await ShopSeriesGrantService.manageGrantPackages(grantId.value, { packages })
await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override: row.expiry_base_override ?? null,
packages
})
await fetchPackageList()
} catch (error) {
console.error(error)
@@ -416,7 +558,6 @@
}
const handlePackageDialogClosed = () => {
packageFormRef.value?.clearValidate()
assignPackageForm()
availablePackages.value = []
}
@@ -464,9 +605,11 @@
}
}
.amount-value {
font-weight: 600;
color: var(--el-color-warning);
.package-table-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.form-tip {
@@ -475,6 +618,15 @@
color: var(--el-text-color-secondary);
}
.ellipsis-value {
display: block;
width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-footer {
text-align: right;
}

View File

@@ -57,7 +57,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { ElTag } from 'element-plus'
import { PollingAlertService } from '@/api/modules'
import type {
PollingAlertHistory,
@@ -264,7 +264,7 @@
loadData()
}
const handleRowContextMenu = (row: PollingAlertHistory, column: any, event: MouseEvent) => {
const handleRowContextMenu = (row: PollingAlertHistory, _column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentClickRow.value = row

View File

@@ -379,7 +379,7 @@
})
dialogType.value = 'edit'
dialogVisible.value = true
nextTick(() => {
await nextTick(() => {
formRef.value?.clearValidate()
})
}
@@ -398,7 +398,7 @@
if (res.code === 0) {
ElMessage.success('创建成功')
dialogVisible.value = false
loadData()
await loadData()
}
} else {
const { id, metric_type, task_type, operator, ...updateData } = form
@@ -406,7 +406,7 @@
if (res.code === 0) {
ElMessage.success('更新成功')
dialogVisible.value = false
loadData()
await loadData()
}
}
} catch (error) {
@@ -422,7 +422,7 @@
const res = await PollingAlertService.updatePollingAlertRule(row.id, { status: newStatus })
if (res.code === 0) {
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
loadData()
await loadData()
}
} catch (error) {
console.log('操作失败')
@@ -439,7 +439,7 @@
const res = await PollingAlertService.deletePollingAlertRule(row.id)
if (res.code === 0) {
ElMessage.success('删除成功')
loadData()
await loadData()
}
} catch (error) {
console.log('删除失败')

View File

@@ -611,7 +611,7 @@
card_status_check_interval: data.card_status_check_interval
})
dialogVisible.value = true
nextTick(() => {
await nextTick(() => {
formRef.value?.clearValidate()
})
} catch {

View File

@@ -49,11 +49,12 @@
</ArtTable>
<!-- 创建/编辑对话框 -->
<ElDialog
<ElDrawer
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增支付配置' : '编辑支付配置'"
width="900px"
:close-on-click-modal="false"
direction="rtl"
size="55%"
:close-on-click-modal="true"
@closed="handleDialogClosed"
>
<ElForm
@@ -352,7 +353,7 @@
</ElButton>
</div>
</template>
</ElDialog>
</ElDrawer>
</ElCard>
</div>
</ArtTableFullScreen>

View File

@@ -0,0 +1,401 @@
<template>
<ArtTableFullScreen>
<div class="system-configs-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
label-width="90px"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="loadConfigs"
/>
<ArtTable
ref="tableRef"
row-key="config_key"
:loading="loading"
:data="configList"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
<ElDialog
v-model="dialogVisible"
title="编辑系统配置"
width="30%"
:close-on-click-modal="false"
>
<ElForm label-width="80px">
<ElFormItem label="配置说明">
<span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem>
<ElFormItem label="配置值">
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
{{ paymentMethodLabels[method] }}
</ElCheckbox>
</ElCheckboxGroup>
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
<ElInputNumber
v-else-if="isIntegerConfig"
v-model="editNumber"
:min="currentConfig?.min ?? undefined"
:max="currentConfig?.max ?? undefined"
:precision="0"
controls-position="right"
style="width: 100%"
/>
<ElSelect v-else-if="isEnumConfig" v-model="editValue" style="width: 100%">
<ElOption
v-for="item in currentConfig?.enum_values"
:key="item"
:value="item"
:label="item"
/>
</ElSelect>
<ElInput
v-else-if="isJsonConfig"
v-model="editValue"
type="textarea"
:rows="6"
placeholder="请输入 JSON 配置"
/>
<ElInput
v-else
v-model="editValue"
:type="currentConfig?.sensitive ? 'password' : 'text'"
:show-password="Boolean(currentConfig?.sensitive)"
placeholder="请输入配置值"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton
type="primary"
:loading="submitLoading"
:disabled="submitLoading"
@click="handleSubmit"
>
保存
</ElButton>
</template>
</ElDialog>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import {
PAYMENT_CONFIG_KEYS,
parsePaymentMethods,
type AllowedPaymentMethod,
type SystemConfigItem,
type SystemConfigModule
} from '@/types/api/systemConfig'
import { formatDateTime } from '@/utils/business/format'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
defineOptions({ name: 'SystemConfigs' })
const { hasAuth } = useAuth()
const route = useRoute()
const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' },
{ label: 'C 端支付方式配置', value: 'c2b.payment' }
]
const searchForm = reactive<{ module?: SystemConfigModule }>({ module: undefined })
const searchFormItems: SearchFormItem[] = [
{
label: '配置模块',
prop: 'module',
type: 'select',
options: moduleOptions,
config: { clearable: true, placeholder: '请选择配置模块' }
}
]
const loading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
const tableRef = ref()
const currentConfig = ref<SystemConfigItem | null>(null)
const configList = ref<SystemConfigItem[]>([])
const editValue = ref('')
const originalValue = ref('')
const editBoolean = ref(false)
const editNumber = ref<number | null>(null)
const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay']
const paymentMethodLabels: Record<AllowedPaymentMethod, string> = {
wallet: '钱包',
wechat: '微信',
alipay: '支付宝'
}
const targetConfigHandled = ref(false)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const columnOptions = [
{ label: '模块', prop: 'module' },
{ label: '配置说明', prop: 'description' },
{ label: '配置值', prop: 'value' },
{ label: '注册状态', prop: 'registered' },
{ label: '只读', prop: 'readonly' },
{ label: '敏感', prop: 'sensitive' },
{ label: '更新时间', prop: 'updated_at' }
]
const moduleLabel = (module: SystemConfigModule) =>
moduleOptions.find((item) => item.value === module)?.label || module
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'module',
label: '模块',
minWidth: 150,
formatter: (row: SystemConfigItem) => moduleLabel(row.module)
},
{ prop: 'description', label: '配置说明', minWidth: 180, showOverflowTooltip: true },
{
prop: 'value',
label: '配置值',
minWidth: 260,
showOverflowTooltip: true,
formatter: (row: SystemConfigItem) => formatConfigValue(row)
},
{
prop: 'registered',
label: '注册状态',
width: 100,
formatter: (row: SystemConfigItem) =>
h(ElTag, { type: row.registered ? 'success' : 'info' }, () =>
row.registered ? '已注册' : '未注册'
)
},
{
prop: 'readonly',
label: '只读',
width: 80,
formatter: (row: SystemConfigItem) => (row.readonly ? '是' : '否')
},
{
prop: 'sensitive',
label: '敏感',
width: 80,
formatter: (row: SystemConfigItem) => (row.sensitive ? '是' : '否')
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: SystemConfigItem) => formatDateTime(row.updated_at)
}
])
const isBooleanConfig = computed(
() => currentConfig.value?.value_type === 'bool' || currentConfig.value?.control === 'switch'
)
const isPaymentConfig = computed(() =>
[PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(
currentConfig.value
?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
)
)
const isIntegerConfig = computed(
() => !isBooleanConfig.value && currentConfig.value?.value_type === 'int'
)
const isEnumConfig = computed(
() =>
!isBooleanConfig.value &&
!isIntegerConfig.value &&
Boolean(currentConfig.value?.enum_values?.length)
)
const isJsonConfig = computed(
() =>
!isBooleanConfig.value && !isIntegerConfig.value && currentConfig.value?.value_type === 'json'
)
const isPaymentConfigKey = (configKey: string) =>
Object.values(PAYMENT_CONFIG_KEYS).includes(
configKey as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
)
const formatConfigValue = (config: SystemConfigItem) => {
if (isPaymentConfigKey(config.config_key)) {
const methods = parsePaymentMethods(config.value)
return methods.length ? methods.map((method) => paymentMethodLabels[method]).join('、') : '-'
}
return config.value || '-'
}
const getSubmittedValue = () => {
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
if (isBooleanConfig.value) return String(editBoolean.value)
if (isIntegerConfig.value) return editNumber.value === null ? '' : String(editNumber.value)
return editValue.value
}
const validateValue = (value: string) => {
const config = currentConfig.value
if (!config) return '配置不存在'
const enumValues = config.enum_values ?? []
if (enumValues.length > 0 && !enumValues.includes(value)) {
return '配置值不在允许的枚举范围内'
}
if (config.value_type === 'int') {
if (!/^-?\d+$/.test(value)) return '请输入整数'
const numberValue = Number(value)
if (config.min !== null && numberValue < config.min) return `配置值不能小于 ${config.min}`
if (config.max !== null && numberValue > config.max) return `配置值不能大于 ${config.max}`
}
if (config.value_type === 'json') {
try {
JSON.parse(value)
} catch {
return '请输入有效的 JSON'
}
}
if (isPaymentConfig.value) {
if (editPaymentMethods.value.length === 0) return '至少保留一种支付方式'
}
if (value === '') return '配置值不能为空'
return ''
}
const getActions = (row: SystemConfigItem) =>
row.readonly ||
([PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(row.config_key as never) &&
!hasAuth(JULY_PERMISSIONS.systemConfig.payment))
? []
: [{ label: '编辑', handler: () => showEditDialog(row), type: 'primary' as const }]
const loadConfigs = async () => {
loading.value = true
try {
const res = await SystemConfigService.getSystemConfigs({
page: pagination.page,
page_size: pagination.page_size,
module: searchForm.module
})
if (res.code === 0) {
configList.value = res.data.list || []
pagination.total = res.data.total || 0
const targetKey = String(route.query.config_key || '')
const targetConfig = configList.value.find((config) => config.config_key === targetKey)
if (targetKey && targetConfig && !targetConfigHandled.value) {
targetConfigHandled.value = true
showEditDialog(targetConfig)
}
}
} catch (error) {
console.error('获取系统配置失败:', error)
} finally {
loading.value = false
}
}
const showEditDialog = (row: SystemConfigItem) => {
currentConfig.value = row
originalValue.value = row.value
editValue.value = row.value
editBoolean.value = row.value === 'true'
editNumber.value = /^-?\d+$/.test(row.value) ? Number(row.value) : null
editPaymentMethods.value = parsePaymentMethods(row.value)
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!currentConfig.value) {
ElMessage.error('未选择要编辑的系统配置')
return
}
if (currentConfig.value.readonly) {
ElMessage.warning('当前配置为只读配置,不能保存')
return
}
const value = getSubmittedValue()
if (currentConfig.value.sensitive && value === originalValue.value) {
ElMessage.warning('敏感配置未输入新值,无需保存')
return
}
const errorMessage = validateValue(value)
if (errorMessage) {
ElMessage.warning(errorMessage)
return
}
submitLoading.value = true
try {
const res = await SystemConfigService.updateSystemConfig(currentConfig.value.config_key, {
key: currentConfig.value.config_key,
value
})
if (res.code === 0) {
ElMessage.success('系统配置更新成功')
dialogVisible.value = false
await loadConfigs()
} else {
ElMessage.error(res.msg || '系统配置更新失败')
}
} catch (error) {
console.error('更新系统配置失败:', error)
} finally {
submitLoading.value = false
}
}
const handleReset = () => {
searchForm.module = undefined
pagination.page = 1
loadConfigs()
}
const handleSearch = () => {
pagination.page = 1
loadConfigs()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
loadConfigs()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadConfigs()
}
onMounted(loadConfigs)
</script>
<style scoped lang="scss">
.system-configs-page {
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<ElDialog
v-model="visible"
:title="isEdit ? '编辑企微应用' : '新增企微应用'"
width="50%"
destroy-on-close
@closed="resetForm"
>
<div class="dialog-description">应用凭据由平台管理员维护,保存后可在应用列表测试连接。</div>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="130px" class="application-form">
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="企业 ID" prop="corp_id">
<ElInput v-model="form.corp_id" placeholder="请输入企业微信 CorpID" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="AgentID" prop="agent_id">
<ElInput v-model="form.agent_id" placeholder="请输入 AgentID" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="应用名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入应用名称" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="Secret" prop="secret">
<ElInput
v-model="form.secret"
type="password"
show-password
placeholder="请输入应用 Secret"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider content-position="left">回调配置</ElDivider>
<ElRow :gutter="24">
<ElCol :span="24">
<ElFormItem label="回调 Token" prop="callback_token">
<ElInput
v-model="form.callback_token"
type="textarea"
:rows="3"
resize="vertical"
placeholder="请输入回调 Token"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
<ElInput
v-model="form.encoding_aes_key"
type="textarea"
:rows="3"
resize="vertical"
placeholder="请输入 43 位 EncodingAESKey"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="启用状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="visible = false">取消</ElButton>
<ElButton v-if="isEdit" :loading="testing" @click="testApplication"> 测试连接 </ElButton>
<ElButton type="primary" :loading="saving" @click="saveApplication"> 保存应用 </ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { WecomService } from '@/api/modules'
import type { WecomApplication } from '@/types/api'
defineOptions({ name: 'WecomApplicationFormDialog' })
const props = defineProps<{
modelValue: boolean
application: WecomApplication | null
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'success'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isEdit = computed(() => Boolean(props.application?.id))
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
corp_id: '',
agent_id: '',
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
})
const rules = reactive<FormRules>({
corp_id: [{ required: true, message: '请输入企业 ID', trigger: 'blur' }],
agent_id: [
{ required: true, message: '请输入 AgentID', trigger: 'blur' },
{ pattern: /^[1-9]\d*$/, message: 'AgentID 必须为正整数', trigger: 'blur' }
],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
secret: [{ required: true, message: '请输入 Secret', trigger: 'blur' }]
})
const fillForm = (application: WecomApplication | null) => {
Object.assign(
form,
application
? {
corp_id: application.corp_id,
agent_id: String(application.agent_id),
name: application.name,
secret: application.secret || '',
callback_token: application.callback_token || '',
encoding_aes_key: application.encoding_aes_key || '',
enabled: application.status === 1
}
: {
corp_id: '',
agent_id: '',
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
}
)
}
const resetForm = () => {
fillForm(null)
formRef.value?.clearValidate()
}
const saveApplication = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const response = await WecomService.saveApplication({
corp_id: form.corp_id,
agent_id: Number(form.agent_id),
name: form.name,
secret: form.secret,
callback_token: form.callback_token,
encoding_aes_key: form.encoding_aes_key,
status: form.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('企微应用保存成功')
emit('success')
visible.value = false
}
} finally {
saving.value = false
}
}
const testApplication = async () => {
if (!props.application?.id) return
testing.value = true
try {
const response = await WecomService.testApplication(props.application.id)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
}
} finally {
testing.value = false
}
}
watch(
() => [props.modelValue, props.application] as const,
([opened]) => {
if (opened) fillForm(props.application)
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.dialog-description {
margin-bottom: 20px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.application-form {
padding-right: 8px;
}
.full-width {
width: 100%;
}
.dialog-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-applications-page">
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="loadApplications"
>
<template #left>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.applicationCreate"
type="primary"
@click="openCreateDialog"
>
新增应用
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="applications"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:margin-top="10"
:actions="getActions"
:actions-width="160"
:inline-actions-count="1"
@size-change="handleSizeChange"
@current-change="loadApplications"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<WecomApplicationFormDialog
v-model="applicationDialogVisible"
:application="editingApplication"
@success="handleApplicationSaved"
/>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { useRouter } from 'vue-router'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import WecomApplicationFormDialog from './components/WecomApplicationFormDialog.vue'
defineOptions({ name: 'WecomApplications' })
const router = useRouter()
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false)
const applications = ref<WecomApplication[]>([])
const applicationDialogVisible = ref(false)
const editingApplication = ref<WecomApplication | null>(null)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const columnOptions = [
{ label: '应用名称', prop: 'name' },
{ label: '凭据状态', prop: 'credentials_set' },
{ label: '启用状态', prop: 'status' },
{ label: '默认发起人', prop: 'default_creator_name' },
{ label: '最近连接', prop: 'last_connected_at' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'name',
label: '应用名称',
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'credentials_set',
label: '凭据状态',
width: 110,
formatter: (row: WecomApplication) =>
h(ElTag, { type: row.credentials_set ? 'success' : 'warning', size: 'small' }, () =>
row.credentials_set ? '已完整' : '未完整'
)
},
{
prop: 'status',
label: '启用状态',
width: 100,
formatter: (row: WecomApplication) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info', size: 'small' },
() => row.status_name
)
},
{
prop: 'default_creator_name',
label: '默认发起人',
minWidth: 150,
formatter: (row: WecomApplication) => row.default_creator_name || '未设置'
},
{
prop: 'last_connected_at',
label: '最近连接',
width: 180,
formatter: (row: WecomApplication) => formatDate(row.last_connected_at)
}
])
const loadApplications = async () => {
loading.value = true
try {
const response = await WecomService.getApplications({
page: pagination.page,
page_size: pagination.page_size
})
if (response.code === 0) {
applications.value = response.data.items || []
pagination.total = response.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadApplications()
}
const openCreateDialog = () => {
editingApplication.value = null
applicationDialogVisible.value = true
}
const openEditDialog = (application: WecomApplication) => {
editingApplication.value = application
applicationDialogVisible.value = true
}
const handleApplicationSaved = () => {
void loadApplications()
}
const goToMembers = (id: number) =>
void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } })
const testApplication = async (id: number) => {
const response = await WecomService.testApplication(id)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
await loadApplications()
}
}
const getActions = (row: WecomApplication) => {
const actions: any[] = []
if (hasAuth(JULY_PERMISSIONS.wecom.applicationTest)) {
actions.push({
label: '测试连接',
handler: () => testApplication(row.id),
type: 'primary'
})
}
if (hasAuth(JULY_PERMISSIONS.wecom.applicationEdit)) {
actions.push({
label: '编辑配置',
handler: () => openEditDialog(row),
type: 'primary'
})
}
if (hasAuth(JULY_PERMISSIONS.wecom.memberManage)) {
actions.push({
label: '成员管理',
handler: () => goToMembers(row.id),
type: 'primary'
})
}
return actions
}
onMounted(() => void loadApplications())
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,411 @@
<template>
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-members-page">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard v-loading="loading" shadow="never" class="art-table-card members-card">
<div class="members-toolbar">
<div class="toolbar-actions">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.memberSync"
:disabled="!selectedApplicationId"
:loading="syncing"
type="primary"
@click="syncMembers"
>
同步成员
</ElButton>
<ElButton @click="goToApplications">返回应用列表</ElButton>
</div>
</div>
<ElEmpty v-if="!members.length && !loading" description="暂无企微成员" />
<div v-else class="member-grid">
<ElCard v-for="member in members" :key="member.userid" shadow="hover" class="member-card">
<div class="member-card__header">
<div class="member-card__identity">
<ElAvatar :size="36" class="member-card__avatar">
{{ getMemberInitial(member.name) }}
</ElAvatar>
<div class="member-card__name" :title="member.name">
{{ member.name || '-' }}
</div>
</div>
<ElTag v-if="isDefaultCreator(member)" type="success" size="small">
默认发起人
</ElTag>
<ElTag
v-else
v-permission="JULY_PERMISSIONS.wecom.memberDefaultCreator"
class="member-card__action-tag"
type="primary"
effect="plain"
size="small"
role="button"
tabindex="0"
@click.stop="setDefaultCreator(member)"
@keydown.enter.stop="setDefaultCreator(member)"
>
设为默认发起人
</ElTag>
</div>
<div class="member-card__info">
<span class="member-card__label">同步时间</span>
<span>{{ formatDateTime(member.synced_at) }}</span>
</div>
</ElCard>
</div>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadMembers"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import type { SearchFormItem } from '@/types'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication, WecomMember } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomMembers' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const syncing = ref(false)
const savingDefault = ref(false)
const pendingDefaultUserid = ref('')
const applications = ref<WecomApplication[]>([])
const members = ref<WecomMember[]>([])
const searchForm = reactive({
application_id: undefined as number | undefined,
keyword: ''
})
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const selectedApplicationId = computed(() => searchForm.application_id)
const selectedApplication = computed(() =>
applications.value.find((application) => application.id === selectedApplicationId.value)
)
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '企微应用',
prop: 'application_id',
type: 'select',
options: applications.value.map((application) => ({
label: application.name,
value: application.id
})),
onChange: () => {
pagination.page = 1
void loadMembers()
},
config: {
filterable: true,
clearable: false,
placeholder: '请选择企微应用'
}
},
{
label: '成员搜索',
prop: 'keyword',
type: 'input',
config: {
clearable: true,
placeholder: '请输入成员姓名'
}
}
])
const isDefaultCreator = (member: WecomMember) =>
member.userid === selectedApplication.value?.default_creator_userid
const getMemberInitial = (name: string) => name.trim().slice(0, 1) || '-'
const loadApplications = async () => {
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code !== 0) return
applications.value = response.data.items || []
const queryApplicationId = Number(route.query.application_id)
const preferredApplication =
applications.value.find((application) => application.id === queryApplicationId) ||
applications.value.find((application) => application.id === searchForm.application_id) ||
applications.value[0]
searchForm.application_id = preferredApplication?.id
}
const loadMembers = async () => {
if (!selectedApplicationId.value) {
members.value = []
pagination.total = 0
return
}
loading.value = true
try {
const response = await WecomService.getMembers(selectedApplicationId.value, {
page: pagination.page,
page_size: pagination.page_size,
keyword: searchForm.keyword.trim() || undefined
})
if (response.code === 0) {
members.value = response.data.items || []
pagination.total = response.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
void loadMembers()
}
const handleReset = () => {
searchForm.keyword = ''
searchForm.application_id = applications.value[0]?.id
pagination.page = 1
void loadMembers()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadMembers()
}
const syncMembers = async () => {
if (!selectedApplicationId.value) return
syncing.value = true
try {
const response = await WecomService.syncMembers(selectedApplicationId.value)
if (response.code === 0) {
ElMessage.success(`已同步 ${response.data.synced_count} 名成员`)
await loadMembers()
}
} finally {
syncing.value = false
}
}
const setDefaultCreator = async (member: WecomMember) => {
if (!selectedApplicationId.value || savingDefault.value) return
try {
await ElMessageBox.confirm(
`确定将 ${member.name} 设置为当前应用的默认发起人吗?`,
'确认设置默认发起人',
{
confirmButtonText: '确定设置',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return
}
savingDefault.value = true
pendingDefaultUserid.value = member.userid
try {
const response = await WecomService.setDefaultCreator(
selectedApplicationId.value,
member.userid
)
if (response.code === 0) {
ElMessage.success('默认发起人已保存')
const application = selectedApplication.value
if (application) {
application.default_creator_userid = member.userid
application.default_creator_name = member.name
}
}
} finally {
savingDefault.value = false
pendingDefaultUserid.value = ''
}
}
const goToApplications = () => void router.push(RoutesAlias.WecomApplications)
onMounted(async () => {
await loadApplications()
await loadMembers()
})
</script>
<style scoped lang="scss">
.wecom-members-page {
.members-card {
min-height: 0;
overflow: hidden;
:deep(> .el-card__body) {
box-sizing: border-box;
min-height: 0;
padding-bottom: 24px;
overflow-y: auto;
}
}
.members-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.member-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding-bottom: 4px;
}
.member-card {
min-width: 0;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
:deep(.el-card__body) {
display: flex;
flex-direction: column;
gap: 10px;
justify-content: space-between;
height: auto;
overflow: visible;
}
}
.member-card__header,
.member-card__info {
display: flex;
align-items: center;
}
.member-card__header {
gap: 12px;
justify-content: space-between;
}
.member-card__identity {
display: flex;
flex: 1;
gap: 10px;
align-items: center;
min-width: 0;
}
.member-card__avatar {
flex-shrink: 0;
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.member-card__name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.member-card__info {
gap: 16px;
justify-content: space-between;
font-size: 13px;
color: var(--el-text-color-regular);
span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.member-card__label {
color: var(--el-text-color-secondary);
}
.member-card__action-tag {
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
@media (width <= 1400px) {
.member-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (width <= 1024px) {
.member-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 640px) {
.member-grid {
grid-template-columns: 1fr;
}
.members-toolbar {
justify-content: flex-start;
}
.pagination-wrapper {
justify-content: center;
overflow-x: auto;
}
}
}
</style>

View File

@@ -0,0 +1,631 @@
<template>
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-scenes-page">
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="loadData"
>
<template #left>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.sceneCreate"
type="primary"
@click="createScene"
>
新增场景配置
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="business_type"
:loading="loading"
:data="scenes"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:margin-top="10"
:actions="getActions"
:actions-width="140"
@size-change="handleSizeChange"
@current-change="handleCurrentPageChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ElDialog
v-model="editorVisible"
:title="editing ? '编辑审批场景' : '新增审批场景'"
width="55%"
class="wecom-scene-dialog"
destroy-on-close
>
<ElForm :model="sceneForm" label-width="80px" class="scene-editor-form">
<div class="dialog-section">
<div class="dialog-section__header">
<div class="dialog-section__title">基础配置</div>
<div class="dialog-section__description">选择审批业务和对应的企业微信模板</div>
</div>
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="业务类型" required>
<ElSelect
v-model="sceneForm.business_type"
:disabled="editing"
class="full-width"
@change="handleBusinessTypeChange"
>
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="企微应用">
<ElSelect
v-model="sceneForm.application_id"
clearable
filterable
class="full-width"
>
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="模板 ID" required>
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID">
<template #append>
<ElButton :loading="inspectingTemplate" @click="syncTemplate()">
同步模板
</ElButton>
</template>
</ElInput>
<div v-if="templateName" class="template-name">当前模板:{{ templateName }}</div>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="启用状态">
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
</ElCol>
</ElRow>
</div>
<div class="dialog-section mapping-section">
<div class="dialog-section__header mapping-heading">
<div>
<div class="dialog-section__title">控件映射</div>
<div class="dialog-section__description">将业务字段绑定到企业微信模板控件</div>
</div>
<ElTag v-if="!templateControls.length" type="info" effect="plain" class="mapping-tip">
请先填写模板 ID 并点击“同步模板”,再选择模板控件。
</ElTag>
</div>
<div v-if="mappingRows.length" class="mapping-list">
<div v-for="mapping in mappingRows" :key="mapping.key" class="mapping-row">
<div class="mapping-row__grid">
<div class="mapping-field">
<span class="mapping-field__label">业务字段</span>
<ElSelect
v-model="mapping.business_field"
filterable
clearable
:loading="fieldsLoading"
placeholder="选择业务字段"
>
<ElOption
v-for="field in businessFields"
:key="field.code"
:label="field.name"
:value="field.code"
/>
</ElSelect>
</div>
<div class="mapping-field">
<span class="mapping-field__label">模板控件</span>
<ElInput
:model-value="mapping.control_title"
readonly
placeholder="模板控件名称"
/>
</div>
<div class="mapping-field">
<span class="mapping-field__label">控件 ID</span>
<ElInput
:model-value="mapping.control_id"
readonly
placeholder="选择模板控件后自动填充"
/>
</div>
<div class="mapping-field">
<span class="mapping-field__label">控件类型</span>
<ElInput
:model-value="mapping.control_type"
readonly
placeholder="同步模板后自动填充"
/>
</div>
</div>
</div>
</div>
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
</div>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="editorVisible = false">取消</ElButton>
<ElButton @click="createScene">清空表单</ElButton>
<ElButton type="primary" :loading="saving" @click="saveScene"> 保存并校验 </ElButton>
</div>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import type {
WecomApplication,
WecomBusinessField,
WecomBusinessType,
WecomScene,
WecomSceneControlMapping,
WecomTemplateControl
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomScenes' })
interface MappingEditor {
key: number
business_field: string
control_title: string
control_id: string
control_type: string
required: boolean
option_mapping: string
}
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false)
const saving = ref(false)
const fieldsLoading = ref(false)
const inspectingTemplate = ref(false)
const editorVisible = ref(false)
const applications = ref<WecomApplication[]>([])
const businessFields = ref<WecomBusinessField[]>([])
const templateControls = ref<WecomTemplateControl[]>([])
const templateName = ref('')
const scenes = ref<WecomScene[]>([])
const selectedBusinessType = ref<WecomBusinessType>()
const nextMappingKey = ref(1)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const sceneForm = reactive({
business_type: 'refund_approval' as WecomBusinessType,
application_id: undefined as number | undefined,
template_id: '',
enabled: true
})
const mappingRows = ref<MappingEditor[]>([])
const editing = computed(() => Boolean(selectedBusinessType.value))
const columnOptions = [
{ label: '业务类型', prop: 'business_type_name' },
{ label: '模板名称', prop: 'template_name' },
{ label: '状态', prop: 'status' },
{ label: '最近校验', prop: 'last_verified_at' }
]
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const loadBusinessFields = async (businessType: WecomBusinessType) => {
fieldsLoading.value = true
try {
const response = await WecomService.getBusinessFields(businessType)
if (response.code === 0) {
businessFields.value = response.data.items || []
}
} finally {
fieldsLoading.value = false
}
}
const handleBusinessTypeChange = () => {
businessFields.value = []
mappingRows.value.forEach((mapping) => {
mapping.business_field = ''
})
void loadBusinessFields(sceneForm.business_type)
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'business_type_name',
label: '业务类型',
minWidth: 180
},
{
prop: 'template_name',
label: '模板名称',
minWidth: 220,
showOverflowTooltip: true
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: WecomScene) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info', size: 'small' },
() => row.status_name
)
},
{
prop: 'last_verified_at',
label: '最近校验',
width: 180,
formatter: (row: WecomScene) => formatDate(row.last_verified_at)
}
])
const fillEditor = (scene?: WecomScene) => {
templateControls.value = []
templateName.value = ''
if (!scene) {
selectedBusinessType.value = undefined
Object.assign(sceneForm, {
business_type: 'refund_approval' as WecomBusinessType,
application_id: applications.value[0]?.id,
template_id: '',
enabled: true
})
mappingRows.value = []
return
}
selectedBusinessType.value = scene.business_type
Object.assign(sceneForm, {
business_type: scene.business_type,
application_id: scene.application_id,
template_id: scene.template_id,
enabled: scene.status === 1
})
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
key: nextMappingKey.value++,
business_field: mapping.business_field,
control_title: '',
control_id: mapping.control_id,
control_type: mapping.control_type,
required: true,
option_mapping: JSON.stringify(mapping.option_mapping || {})
}))
}
const createScene = () => {
fillEditor()
editorVisible.value = true
void loadBusinessFields(sceneForm.business_type)
}
const editScene = (scene: WecomScene) => {
fillEditor(scene)
editorVisible.value = true
void loadBusinessFields(scene.business_type)
void syncTemplate(false)
}
const syncMappingRows = (controls: WecomTemplateControl[]) => {
const existingMappings = new Map(
mappingRows.value.map((mapping) => [mapping.control_id, mapping])
)
mappingRows.value = controls.map((control) => {
const existing = existingMappings.get(control.id)
return {
key: existing?.key || nextMappingKey.value++,
business_field: existing?.business_field || '',
control_title: control.title,
control_id: control.id,
control_type: control.type,
required: control.required,
option_mapping: existing?.option_mapping || '{}'
}
})
}
const syncTemplate = async (showMessage = true) => {
if (!sceneForm.application_id) {
ElMessage.warning('请先选择企微应用')
return
}
if (!sceneForm.template_id.trim()) {
ElMessage.warning('请输入模板 ID')
return
}
inspectingTemplate.value = true
try {
const response = await WecomService.inspectTemplate(sceneForm.application_id, {
template_id: sceneForm.template_id.trim()
})
if (response.code === 0) {
templateControls.value = response.data.controls || []
templateName.value = response.data.name || ''
syncMappingRows(templateControls.value)
if (showMessage) {
ElMessage.success(`模板控件已同步,共 ${templateControls.value.length} 项`)
}
}
} finally {
inspectingTemplate.value = false
}
}
const loadData = async () => {
loading.value = true
try {
const [applicationResponse, sceneResponse] = await Promise.all([
WecomService.getApplications({ page: 1, page_size: 100 }),
WecomService.getScenes({
page: pagination.page,
page_size: pagination.page_size
})
])
if (applicationResponse.code === 0) applications.value = applicationResponse.data.items || []
if (sceneResponse.code === 0) {
scenes.value = sceneResponse.data.items || []
pagination.total = sceneResponse.data.total || 0
if (selectedBusinessType.value) {
const current = scenes.value.find(
(scene) => scene.business_type === selectedBusinessType.value
)
if (current) fillEditor(current)
}
}
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadData()
}
const handleCurrentPageChange = (page: number) => {
pagination.page = page
void loadData()
}
const parseMapping = (): WecomSceneControlMapping[] | undefined => {
const result: WecomSceneControlMapping[] = []
for (const mapping of mappingRows.value) {
if (!mapping.business_field) {
if (mapping.required) {
ElMessage.warning(
`请为必填控件“${mapping.control_title || mapping.control_id}”选择业务字段`
)
return
}
continue
}
if (!mapping.control_id || !mapping.control_type) {
ElMessage.warning(`控件“${mapping.control_title || mapping.control_id}”信息不完整`)
return
}
let optionMapping: Record<string, string>
try {
const parsed = JSON.parse(mapping.option_mapping || '{}')
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
throw new Error('invalid')
optionMapping = parsed as Record<string, string>
} catch {
ElMessage.warning(`第 ${result.length + 1} 条选择项映射不是有效 JSON`)
return
}
result.push({
business_field: mapping.business_field,
control_id: mapping.control_id,
control_type: mapping.control_type,
option_mapping: optionMapping
})
}
return result
}
const saveScene = async () => {
if (!sceneForm.template_id.trim()) {
ElMessage.warning('请输入模板 ID')
return
}
const controlMapping = parseMapping()
if (!controlMapping) return
saving.value = true
try {
const response = await WecomService.saveScene(sceneForm.business_type, {
application_id: sceneForm.application_id,
template_id: sceneForm.template_id.trim(),
control_mapping: controlMapping,
status: sceneForm.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('场景保存并校验成功')
editorVisible.value = false
await loadData()
}
} finally {
saving.value = false
}
}
const getActions = (row: WecomScene) => {
if (!hasAuth(JULY_PERMISSIONS.wecom.sceneEdit)) return []
return [
{
label: '编辑',
handler: () => editScene(row),
type: 'primary' as const
}
]
}
onMounted(() => void loadData())
</script>
<style scoped lang="scss">
.full-width {
width: 100%;
}
.scene-editor-form {
padding-bottom: 12px;
.dialog-section {
padding: 18px 20px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.dialog-section + .dialog-section {
margin-top: 18px;
}
.dialog-section__header {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.dialog-section__title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.dialog-section__description {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.mapping-tip {
flex-shrink: 0;
margin-top: 0;
white-space: nowrap;
}
.template-name {
margin-top: 6px;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.mapping-section {
padding-bottom: 12px;
}
.mapping-heading {
margin-bottom: 0;
}
.mapping-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 420px;
padding: 0 4px 4px;
overflow-y: auto;
}
.mapping-row {
padding: 14px;
margin-top: 6px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.mapping-row__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.mapping-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.mapping-field__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
@media (width <= 768px) {
.dialog-section {
padding: 14px;
}
.dialog-section__header {
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.mapping-tip {
align-self: flex-start;
white-space: normal;
}
.mapping-row__grid {
grid-template-columns: 1fr;
}
}
}
@media (width <= 1200px) {
.mapping-row__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 768px) {
:deep(.wecom-scene-dialog) {
width: calc(100vw - 32px) !important;
}
}
</style>

View File

@@ -87,13 +87,10 @@
import type { FormInstance, FormRules } from 'element-plus'
import type { WithdrawalSettingItem } from '@/types/api/commission'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime, formatMoney, formatFeeRate } from '@/utils/business/format'
defineOptions({ name: 'WithdrawalSettings' })
const { hasAuth } = useAuth()
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
@@ -235,7 +232,7 @@
ElMessage.success('新增配置成功')
dialogVisible.value = false
formEl.resetFields()
loadSettingsList()
await loadSettingsList()
} catch (error) {
console.error(error)
} finally {

View File

@@ -0,0 +1,118 @@
<template>
<div class="shop-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">店铺详情</h2>
</div>
<div v-loading="loading">
<ElDescriptions v-if="shop" :column="2" border>
<ElDescriptionsItem label="店铺名称">{{ shop.shop_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺编号">{{ shop.shop_code || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺层级">{{ shop.level }}</ElDescriptionsItem>
<ElDescriptionsItem label="上级店铺">{{
shop.parent_shop_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="联系人">{{ shop.contact_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="联系电话">{{ shop.contact_phone || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="平台业务员">
{{ formatBusinessOwner(shop) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务员通知状态">
{{
shop.business_owner_account_id
? shop.business_owner_available
? '可用'
: '已停用'
: '-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="省份">{{ shop.province || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="城市">{{ shop.city || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="区县">{{ shop.district || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="详细地址">{{ shop.address || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">{{
shop.status_name || shop.status
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ shop.created_at || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ shop.updated_at || '-' }}</ElDescriptionsItem>
</ElDescriptions>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ShopService } from '@/api/modules'
import type { ShopResponse } from '@/types/api'
defineOptions({ name: 'ShopDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const shop = ref<ShopResponse | null>(null)
const formatBusinessOwner = (value: ShopResponse) => {
if (!value.business_owner_username) return '-'
return value.business_owner_phone_summary
? `${value.business_owner_username} (${value.business_owner_phone_summary})`
: value.business_owner_username
}
const handleBack = () => router.back()
const fetchDetail = async () => {
const id = Number(route.params.id)
if (!id) {
ElMessage.error('缺少店铺 ID')
return
}
loading.value = true
try {
const response = await ShopService.getShopDetail(id)
if (response.code !== 0 || !response.data) {
ElMessage.error(response.msg || '获取店铺详情失败')
return
}
shop.value = response.data
} catch (error: any) {
ElMessage.error(error?.message || '获取店铺详情失败')
} finally {
loading.value = false
}
}
onMounted(fetchDetail)
</script>
<style scoped lang="scss">
.shop-detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
</style>

View File

@@ -7,6 +7,7 @@
:items="searchFormItems"
show-expand
@reset="handleReset"
label-width="100px"
@search="handleSearch"
></ArtSearchBar>
@@ -126,6 +127,38 @@
</ElCol>
</ElRow>
<ElRow v-if="!isAgentAccount" :gutter="20">
<ElCol :span="12">
<ElFormItem label="平台业务员">
<ElSelect
v-model="formData.business_owner_account_id"
filterable
remote
clearable
:value-on-clear="() => null"
placeholder="请选择或搜索平台业务员"
:remote-method="searchPlatformSalespeople"
:loading="salespersonLoading"
style="width: 100%"
>
<ElOption
v-for="salesperson in salespersonOptions"
:key="salesperson.id"
:label="salesperson.username"
:value="salesperson.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ salesperson.username }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ salesperson.phone_summary }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<!-- 新增店铺时的初始账号信息 -->
<template v-if="dialogType === 'add'">
<div class="form-section-title">
@@ -172,9 +205,9 @@
>
<ElOption
v-for="role in defaultRoleList"
:key="role.ID"
:key="role.id"
:label="role.role_name"
:value="role.ID"
:value="role.id"
>
<div
style="display: flex; align-items: center; justify-content: space-between"
@@ -196,6 +229,13 @@
:inactive-value="CommonStatus.DISABLED"
/>
</ElFormItem>
<ElFormItem v-if="dialogType === 'edit'" label="C 端登录">
<ElSwitch
v-model="formData.client_login_disabled"
active-text="禁止新登录"
inactive-text="允许登录"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
@@ -264,6 +304,12 @@
</div>
</template>
</ElDialog>
<ShopCreditLimitDialog
v-model="creditDialogVisible"
:shop="currentCreditShop"
@submitted="getShopList"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -286,18 +332,37 @@
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { generateShopCode } from '@/utils/codeGenerator'
import { ShopService, RoleService } from '@/api/modules'
import { CommissionService, ShopService, RoleService } from '@/api/modules'
import ShopCreditLimitDialog from '@/components/business/ShopCreditLimitDialog.vue'
import type { SearchFormItem } from '@/types'
import type { ShopResponse, ShopRoleResponse } from '@/types/api'
import type {
CreateShopParams,
ShopBusinessOwnerCandidate,
ShopResponse,
ShopRoleResponse,
UpdateShopParams
} from '@/types/api'
import type { ShopFundSummaryItem } from '@/types/api/commission'
import { RoleType, RoleStatus } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getCompatibleNumericId } from '@/utils/business/id'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import { regionData } from '@/utils/constants/regionData'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'Shop' })
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isAgentAccount = computed(() => Number(userStore.info.user_type) === 3)
const canModifyShopStatus = hasAuth('shop:modify_status')
const router = useRouter()
@@ -307,6 +372,12 @@
const submitLoading = ref(false)
const defaultRoleLoading = ref(false)
const defaultRoleList = ref<any[]>([])
const salespersonLoading = ref(false)
const salespersonOptions = ref<ShopBusinessOwnerCandidate[]>([])
const initialBusinessOwnerAccountId = ref<number | null>(null)
const creditDialogVisible = ref(false)
const currentCreditShop = ref<ShopFundSummaryItem | null>(null)
const creditLoadingShopId = ref<number | null>(null)
// 级联选择相关
const parentShopCascadeOptions = ref<any[]>([])
@@ -370,14 +441,50 @@
}
}
const searchPlatformSalespeople = async (query: string) => {
salespersonLoading.value = true
try {
const res = await ShopService.getBusinessOwnerCandidates({
page: 1,
page_size: 20,
keyword: query || undefined
})
if (res.code === 0) {
salespersonOptions.value = res.data.items || []
}
} catch (error) {
console.error('获取平台业务员列表失败:', error)
salespersonOptions.value = []
} finally {
salespersonLoading.value = false
}
}
const formatBusinessOwner = (shop: ShopResponse) => {
if (!shop.business_owner_username) return '-'
return shop.business_owner_phone_summary
? `${shop.business_owner_username} (${shop.business_owner_phone_summary})`
: shop.business_owner_username
}
const handleNameClick = (row: ShopResponse) => {
if (hasAuth('shop:detail')) {
void router.push(`${RoutesAlias.ShopDetail}/${row.id}`)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 定义表单搜索初始值
const initialSearchState = {
shop_name: '',
shop_code: '',
contact_phone: '',
parent_shop_name: '',
parent_id: undefined as number | undefined,
level: undefined as number | undefined,
status: undefined as number | undefined
status: undefined as number | undefined,
business_owner_account_id: undefined as number | undefined
}
// 响应式表单数据
@@ -407,6 +514,10 @@
// 搜索处理
const handleSearch = () => {
if (searchForm.contact_phone && !/^\d{11}$/.test(searchForm.contact_phone)) {
ElMessage.warning('联系电话必须为11位数字')
return
}
pagination.currentPage = 1
getShopList()
}
@@ -431,6 +542,21 @@
placeholder: '请输入店铺编号'
}
},
{
label: '联系电话',
prop: 'contact_phone',
type: 'input',
config: {
clearable: true,
maxlength: 11,
inputmode: 'numeric',
placeholder: '请输入11位联系电话',
onInput: (event: InputEvent) => {
const input = event.target as HTMLInputElement
searchForm.contact_phone = input.value.replace(/\D/g, '').slice(0, 11)
}
}
},
{
label: '上级店铺',
prop: 'parent_id',
@@ -457,7 +583,28 @@
clearable: true,
placeholder: '请选择状态'
}
}
},
...(!isAgentAccount.value
? [
{
label: '平台业务员',
prop: 'business_owner_account_id',
type: 'select' as const,
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: (query: string) => searchPlatformSalespeople(query),
placeholder: '请选择或搜索平台业务员'
},
options: () =>
salespersonOptions.value.map((salesperson) => ({
label: `${salesperson.username} (${salesperson.phone_summary})`,
value: salesperson.id
}))
}
]
: [])
])
// 列配置
@@ -468,6 +615,7 @@
{ label: '所在地区', prop: 'region' },
{ label: '联系人', prop: 'contact_name' },
{ label: '联系电话', prop: 'contact_phone' },
{ label: '平台业务员', prop: 'business_owner_username' },
...(canModifyShopStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
@@ -497,6 +645,9 @@
formData.address = row.address || ''
formData.contact_name = row.contact_name || ''
formData.contact_phone = row.contact_phone || ''
formData.business_owner_account_id = row.business_owner_account_id ?? null
initialBusinessOwnerAccountId.value = row.business_owner_account_id ?? null
formData.client_login_disabled = row.client_login_disabled
formData.status = row.status
formData.init_username = ''
formData.init_password = ''
@@ -514,6 +665,9 @@
formData.address = ''
formData.contact_name = ''
formData.contact_phone = ''
formData.business_owner_account_id = null
initialBusinessOwnerAccountId.value = null
formData.client_login_disabled = false
formData.status = CommonStatus.ENABLED
formData.init_username = ''
formData.init_password = ''
@@ -558,7 +712,25 @@
prop: 'shop_name',
label: '店铺名称',
width: 160,
showOverflowTooltip: true
showOverflowTooltip: true,
formatter: (row: ShopResponse) => {
const hasPermission = hasAuth('shop:detail')
return h(
'span',
{
style: hasPermission
? 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;'
: '',
onClick: hasPermission
? (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
: undefined
},
row.shop_name
)
}
},
{
prop: 'shop_code',
@@ -597,6 +769,19 @@
label: '联系电话',
width: 130
},
{
prop: 'business_owner_username',
label: '平台业务员',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ShopResponse) => formatBusinessOwner(row)
},
{
prop: 'client_login_disabled',
label: 'C 端登录',
width: 110,
formatter: (row: ShopResponse) => (row.client_login_disabled ? '已限制' : '正常')
},
...(canModifyShopStatus
? [
{
@@ -630,6 +815,35 @@
const getActions = (row: ShopResponse) => {
const actions: any[] = []
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.shopEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({
userType,
resourceType: 'shop',
internalId: row.id,
businessIdentifier: row.shop_code
})
if (auditTarget)
actions.push({
label: userType === 3 ? '活动记录' : '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
hasAuth(AUDIT_PERMISSIONS.financeTimeline)
) {
const financeTarget = resolveFinanceAuditTarget('shop_id', row.id)
if (financeTarget)
actions.push({
label: '资金链路',
handler: () => openAuditInvestigation(financeTarget),
type: 'primary'
})
}
if (hasAuth('shop:look_customer')) {
actions.push({
label: '账号列表',
@@ -646,6 +860,14 @@
})
}
if (hasAuth('shop:credit_limit_manage')) {
actions.push({
label: '调整实际信用额度',
handler: () => showCreditDialog(row),
type: 'primary'
})
}
if (hasAuth('shop:edit')) {
actions.push({
label: '编辑',
@@ -665,6 +887,34 @@
return actions
}
const showCreditDialog = async (row: ShopResponse) => {
if (creditLoadingShopId.value === row.id) return
creditLoadingShopId.value = row.id
try {
const res = await CommissionService.getShopFundSummary({
page: 1,
page_size: 100,
shop_name: row.shop_name
})
const summary =
res.code === 0 ? (res.data.items || []).find((item) => item.shop_id === row.id) : null
if (!summary) {
ElMessage.warning('未获取到该店铺的资金概况,请刷新店铺列表后重试')
return
}
currentCreditShop.value = summary
creditDialogVisible.value = true
} catch (error) {
console.error('获取店铺资金概况失败:', error)
ElMessage.error('获取店铺资金概况失败,请稍后重试')
} finally {
creditLoadingShopId.value = null
}
}
// 表单实例
const formRef = ref<FormInstance>()
@@ -685,7 +935,9 @@
init_username: '',
init_password: '',
init_phone: '',
default_role_id: undefined as number | undefined
default_role_id: undefined as number | undefined,
business_owner_account_id: null as number | null,
client_login_disabled: false
})
// 处理编码生成
@@ -717,7 +969,7 @@
try {
const params: any = {
page: 1,
pageSize: 20,
page_size: 20,
role_type: RoleType.CUSTOMER, // 仅客户角色
status: RoleStatus.ENABLED // 仅启用的角色
}
@@ -766,6 +1018,9 @@
loadTopLevelShops() // 加载顶级店铺用于级联选择
searchDefaultRoles('') // 加载初始默认角色列表
searchParentShops('') // 加载上级店铺选项
if (!isAgentAccount.value) {
searchPlatformSalespeople('') // 加载启用的平台业务员选项
}
})
// 获取店铺列表
@@ -777,10 +1032,14 @@
page_size: pagination.pageSize,
shop_name: searchForm.shop_name || undefined,
shop_code: searchForm.shop_code || undefined,
contact_phone: searchForm.contact_phone || undefined,
parent_shop_name: searchForm.parent_shop_name || undefined,
parent_id: searchForm.parent_id,
level: searchForm.level,
status: searchForm.status
status: searchForm.status,
...(!isAgentAccount.value && {
business_owner_account_id: searchForm.business_owner_account_id
})
}
const res = await ShopService.getShops(params)
if (res.code === 0) {
@@ -844,13 +1103,17 @@
submitLoading.value = true
try {
if (dialogType.value === 'add') {
const data: any = {
const data: CreateShopParams = {
shop_name: formData.shop_name,
shop_code: formData.shop_code,
init_username: formData.init_username,
init_password: formData.init_password,
init_phone: formData.init_phone,
default_role_id: formData.default_role_id
default_role_id: formData.default_role_id!,
client_login_disabled: formData.client_login_disabled
}
if (!isAgentAccount.value) {
data.business_owner_account_id = formData.business_owner_account_id
}
// 可选字段 - parent_id 可能是数组(级联选择器)或数字
@@ -869,9 +1132,19 @@
await ShopService.createShop(data)
ElMessage.success('新增成功')
} else {
const data: any = {
const data: UpdateShopParams = {
shop_name: formData.shop_name,
status: formData.status
status: formData.status,
client_login_disabled: formData.client_login_disabled
}
if (!isAgentAccount.value) {
const businessOwnerAccountId =
typeof formData.business_owner_account_id === 'number'
? formData.business_owner_account_id
: null
if (businessOwnerAccountId !== initialBusinessOwnerAccountId.value) {
data.business_owner_account_id = businessOwnerAccountId
}
}
// 可选字段
@@ -979,7 +1252,7 @@
// 将 PlatformRole 转换为与 ShopRoleResponse 兼容的格式
availableRoles.value = (res.data.items || []).map((role) => ({
...role,
role_id: role.ID,
role_id: getCompatibleNumericId(role) ?? 0,
role_name: role.role_name,
role_desc: role.role_desc,
role_type: role.role_type,

View File

@@ -254,7 +254,7 @@
description: [{ max: 500, message: '描述不能超过500个字符', trigger: 'blur' }],
data_reset_day: [
{
validator: (rule: any, value: any, callback: any) => {
validator: (_rule: any, value: any, callback: any) => {
if (value === null || value === undefined || value === '') {
callback() // 允许为空
} else {
@@ -277,7 +277,7 @@
realname_link_template: [
{
required: true,
validator: (rule: any, value: any, callback: any) => {
validator: (_rule: any, value: any, callback: any) => {
if (form.realname_link_type === 'template' && !value) {
callback(new Error('请输入实名链接模板'))
} else {
@@ -307,12 +307,14 @@
{
prop: 'carrier_code',
label: '运营商编码',
minWidth: 160
minWidth: 160,
showOverflowTooltip: true
},
{
prop: 'carrier_name',
label: '运营商名称',
minWidth: 150
minWidth: 150,
showOverflowTooltip: true
},
{
prop: 'carrier_type',

View File

@@ -0,0 +1,114 @@
<template>
<div class="role-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">角色详情</h2>
</div>
<div v-loading="loading">
<ElDescriptions v-if="role" :column="2" border>
<ElDescriptionsItem label="角色名称">{{ role.role_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="角色类型">
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="角色描述">{{ role.role_desc || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
{{ role.status === 1 ? '启用' : '禁用' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="代理默认信用">
{{ role.default_credit_enabled ? '启用' : '未启用' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="默认信用额度(元)">
{{ formatCredit(role.default_credit_limit) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="信用生效范围">
{{ role.default_credit_scope || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(role.created_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">
{{ formatDateTime(role.updated_at) }}
</ElDescriptionsItem>
</ElDescriptions>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { RoleService } from '@/api/modules'
import type { PlatformRole } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'RoleDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const role = ref<PlatformRole | null>(null)
const formatCredit = (value?: number) => {
if (value === undefined || value === null) return '-'
return (value / 100).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const handleBack = () => router.back()
const fetchDetail = async () => {
const id = Number(route.params.id)
if (!id) {
ElMessage.error('缺少角色 ID')
return
}
loading.value = true
try {
const response = await RoleService.getRole(id)
if (response.code !== 0 || !response.data) {
ElMessage.error(response.msg || '获取角色详情失败')
return
}
role.value = response.data
} catch (error: any) {
ElMessage.error(error?.message || '获取角色详情失败')
} finally {
loading.value = false
}
}
onMounted(fetchDetail)
</script>
<style scoped lang="scss">
.role-detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
</style>

View File

@@ -25,7 +25,7 @@
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="ID"
row-key="id"
:loading="loading"
:data="roleList"
:currentPage="pagination.page"
@@ -46,7 +46,7 @@
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
width="30%"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px">
<ElFormItem label="角色名称" prop="role_name">
<ElInput v-model="form.role_name" placeholder="请输入角色名称" />
</ElFormItem>
@@ -80,6 +80,29 @@
:inactive-value="CommonStatus.DISABLED"
/>
</ElFormItem>
<template v-if="isCustomerRoleForm">
<ElFormItem label="代理默认信用">
<ElSwitch
v-model="form.credit_enabled"
@change="handleDefaultCreditEnabledChange"
/>
</ElFormItem>
<ElFormItem label="默认信用额度">
<ElInputNumber
v-model="form.credit_limit_yuan"
:disabled="!form.credit_enabled"
:min="1"
:precision="2"
:step="100"
controls-position="right"
style="width: 100%"
placeholder="请输入默认信用额度"
:formatter="formatCreditInput"
:parser="parseCreditInput"
/>
<div class="credit-form-tip">单位:元,修改后不会影响已有店铺</div>
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
@@ -226,11 +249,15 @@
ElOption
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { PlatformRole, PermissionTreeNode } from '@/types/api'
import { RoleStatus, RoleType } from '@/types/api'
import type { PlatformRole, PermissionTreeNode, PlatformRoleFormData } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import { getCompatibleNumericId } from '@/utils/business/id'
import {
CommonStatus,
getStatusText,
@@ -241,6 +268,7 @@
defineOptions({ name: 'Role' })
const { hasAuth } = useAuth()
const router = useRouter()
const canUpdateRoleStatus = hasAuth('role:update_status')
const dialogVisible = ref(false)
@@ -323,8 +351,10 @@
{ label: '角色名称', prop: 'role_name' },
{ label: '角色描述', prop: 'role_desc' },
{ label: '角色类型', prop: 'role_type' },
{ label: '代理默认信用', prop: 'default_credit_enabled' },
{ label: '默认信用额度(元)', prop: 'default_credit_limit' },
...(canUpdateRoleStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'CreatedAt' }
{ label: '创建时间', prop: 'created_at' }
]
const formRef = ref<FormInstance>()
@@ -335,25 +365,116 @@
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
role_desc: [{ max: 200, message: '描述不能超过200个字符', trigger: 'blur' }],
role_type: [{ required: true, message: '请选择角色类型', trigger: 'change' }]
role_type: [{ required: true, message: '请选择角色类型', trigger: 'change' }],
credit_limit_yuan: [
{
validator: (_rule, value, callback) => {
if (!isCustomerRoleForm.value || !form.credit_enabled) {
callback()
return
}
if (value === undefined || value === null || value <= 0) {
callback(new Error('默认信用额度必须大于0'))
return
}
callback()
},
trigger: 'blur'
}
]
})
const form = reactive<any>({
type RoleFormState = PlatformRoleFormData & {
id: number
credit_enabled: boolean
credit_limit_yuan: number
}
const form = reactive<RoleFormState>({
id: 0,
role_name: '',
role_desc: '',
role_type: 1,
status: CommonStatus.ENABLED
role_type: RoleType.PLATFORM,
status: RoleStatus.ENABLED,
credit_enabled: false,
credit_limit_yuan: 0
})
const isCustomerRoleForm = computed(() => form.role_type === RoleType.CUSTOMER)
const handleDefaultCreditEnabledChange = (enabled: boolean | string | number) => {
if (!enabled) {
form.credit_limit_yuan = 0
}
}
const getDefaultCreditPayload = () => {
const creditEnabled = Boolean(form.credit_enabled)
return {
credit_enabled: creditEnabled,
credit_limit: creditEnabled ? yuanToFen(form.credit_limit_yuan) || 0 : 0
}
}
const formatCreditInput = (value: string | number | undefined) => {
if (value === undefined || value === null || value === '') return ''
const normalizedValue = String(value).replace(/,/g, '')
const [integerPart, decimalPart] = normalizedValue.split('.')
const formattedIntegerPart = (integerPart || '0').replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return decimalPart === undefined
? formattedIntegerPart
: `${formattedIntegerPart}.${decimalPart}`
}
const parseCreditInput = (value: string) => value.replace(/,/g, '')
const formatCreditYuan = (value?: number | null) => {
if (value === undefined || value === null) return '-'
return fenToYuan(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const roleList = ref<PlatformRole[]>([])
const getRoleId = (role: PlatformRole | any) => getCompatibleNumericId(role) ?? 0
const handleNameClick = (row: PlatformRole) => {
if (hasAuth('role:detail')) {
void router.push(`${RoutesAlias.RoleDetail}/${getRoleId(row)}`)
} else {
ElMessage.warning('您没有查看详情的权限')
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'role_name',
label: '角色名称',
width: 180
width: 180,
formatter: (row: PlatformRole) => {
const hasPermission = hasAuth('role:detail')
return h(
'span',
{
style: hasPermission
? 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;'
: '',
onClick: hasPermission
? (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
: undefined
},
row.role_name
)
}
},
{
prop: 'role_type',
@@ -365,6 +486,26 @@
)
}
},
{
prop: 'default_credit_enabled',
label: '代理默认信用',
width: 150,
formatter: (row: PlatformRole) => {
if (row.role_type !== RoleType.CUSTOMER) return '-'
return h(ElTag, { type: row.default_credit_enabled ? 'success' : 'info' }, () =>
row.default_credit_enabled ? '启用' : '未启用'
)
}
},
{
prop: 'default_credit_limit',
label: '默认信用额度(元)',
width: 160,
formatter: (row: PlatformRole) => {
if (row.role_type !== RoleType.CUSTOMER || !row.default_credit_enabled) return '-'
return formatCreditYuan(row.default_credit_limit)
}
},
...(canUpdateRoleStatus
? [
{
@@ -388,13 +529,15 @@
: []),
{
prop: 'role_desc',
label: '角色描述'
label: '角色描述',
minWidth: 240,
showOverflowTooltip: true
},
{
prop: 'CreatedAt',
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: any) => formatDateTime(row.CreatedAt)
formatter: (row: any) => formatDateTime(row.created_at)
},
{
prop: 'actions',
@@ -894,7 +1037,13 @@
// 显示分配权限对话框
const showPermissionDialog = async (row: PlatformRole) => {
currentRoleId.value = row.ID
const roleId = getRoleId(row)
if (!roleId) {
ElMessage.error('缺少角色 ID,无法分配权限')
return
}
currentRoleId.value = roleId
currentRole.value = {
role_name: row.role_name,
role_type: row.role_type
@@ -907,7 +1056,7 @@
try {
// 加载当前角色的权限
const res = await RoleService.getRolePermissions(row.ID)
const res = await RoleService.getRolePermissions(roleId)
if (res.code === 0 || Array.isArray(res.data)) {
// 提取权限ID数组
@@ -916,12 +1065,9 @@
if (Array.isArray(permissions) && permissions.length > 0) {
// 如果返回的是权限对象数组,提取ID或id字段
if (typeof permissions[0] === 'object') {
// 优先使用ID字段,如果没有则使用id字段
if ('ID' in permissions[0]) {
selectedPermissions.value = permissions.map((perm: any) => perm.ID)
} else if ('id' in permissions[0]) {
selectedPermissions.value = permissions.map((perm: any) => perm.id)
}
selectedPermissions.value = permissions
.map((perm: any) => getCompatibleNumericId(perm))
.filter((id): id is number => id !== undefined)
} else {
// 如果返回的是ID数组
selectedPermissions.value = permissions
@@ -997,23 +1143,38 @@
const dialogType = ref('add')
// 显示新增/编辑对话框
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
const showDialog = async (type: string, row?: any) => {
dialogType.value = type
if (type === 'edit' && row) {
form.id = row.ID
form.role_name = row.role_name
form.role_desc = row.role_desc
form.role_type = row.role_type
form.status = row.status
let roleDetail = row
try {
const res = await RoleService.getRole(getRoleId(row))
if (res.code === 0 && res.data) {
roleDetail = res.data
}
} catch (error) {
console.error('获取角色详情失败:', error)
}
form.id = getRoleId(roleDetail)
form.role_name = roleDetail.role_name
form.role_desc = roleDetail.role_desc
form.role_type = roleDetail.role_type
form.status = roleDetail.status
form.credit_enabled = Boolean(roleDetail.default_credit_enabled ?? roleDetail.credit_enabled)
form.credit_limit_yuan = fenToYuan(roleDetail.default_credit_limit ?? roleDetail.credit_limit)
} else {
form.id = 0
form.role_name = ''
form.role_desc = ''
form.role_type = 1
form.status = CommonStatus.ENABLED
form.status = RoleStatus.ENABLED
form.credit_enabled = false
form.credit_limit_yuan = 0
}
dialogVisible.value = true
}
// 删除角色
@@ -1025,7 +1186,7 @@
})
.then(async () => {
try {
await RoleService.deleteRole(row.ID)
await RoleService.deleteRole(getRoleId(row))
ElMessage.success('删除成功')
await getTableData()
} catch (error) {
@@ -1052,17 +1213,25 @@
role_type: form.role_type,
status: form.status
}
await RoleService.createRole(data)
const res = await RoleService.createRole(data)
const createdRoleId =
getCompatibleNumericId(res.data) ??
(res.data?.role_id !== undefined ? Number(res.data.role_id) : undefined)
if (form.role_type === RoleType.CUSTOMER && createdRoleId) {
await RoleService.updateRoleDefaultCredit(createdRoleId, getDefaultCreditPayload())
}
ElMessage.success('新增成功')
} else {
// 更新角色时只发送允许的字段
const data = {
role_name: form.role_name,
role_desc: form.role_desc,
role_type: form.role_type,
status: form.status
}
await RoleService.updateRole(form.id, data)
if (form.role_type === RoleType.CUSTOMER) {
await RoleService.updateRoleDefaultCredit(form.id, getDefaultCreditPayload())
}
ElMessage.success('修改成功')
}
@@ -1084,7 +1253,7 @@
// 先更新UI
row.status = newStatus
try {
await RoleService.updateRoleStatus(row.ID, newStatus as 0 | 1)
await RoleService.updateRoleStatus(getRoleId(row), newStatus as 0 | 1)
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
@@ -1118,6 +1287,20 @@
}
}
.credit-form-tip {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
.credit-form-unit {
margin-bottom: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
.permission-tree-transfer-container {
display: flex;
gap: 20px;