Merge branch 'develop'
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 设备绑定卡信息
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
411
src/views/asset-management/expiring-assets/index.vue
Normal file
411
src/views/asset-management/expiring-assets/index.vue
Normal 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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportAgentRechargeTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportAgentWalletTransactionTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportExchangeTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportPackageTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportRefundTaskList' })
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
380
src/views/audit/events/detail.vue
Normal file
380
src/views/audit/events/detail.vue
Normal 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>
|
||||
310
src/views/audit/events/index.vue
Normal file
310
src/views/audit/events/index.vue
Normal 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>
|
||||
428
src/views/audit/integrations/detail.vue
Normal file
428
src/views/audit/integrations/detail.vue
Normal 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>
|
||||
498
src/views/audit/integrations/index.vue
Normal file
498
src/views/audit/integrations/index.vue
Normal 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>
|
||||
423
src/views/audit/risks/index.vue
Normal file
423
src/views/audit/risks/index.vue
Normal 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>
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
106
src/views/dashboard/console/widget/ExpiringAssetStats.vue
Normal file
106
src/views/dashboard/console/widget/ExpiringAssetStats.vue
Normal 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>
|
||||
@@ -46,7 +46,6 @@
|
||||
<!-- <TransactionList />-->
|
||||
<!-- </el-col>-->
|
||||
<!--</el-row>-->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
17
src/views/finance/agent-recharge/agentRechargeOnline.ts
Normal file
17
src/views/finance/agent-recharge/agentRechargeOnline.ts
Normal 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)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '-'
|
||||
},
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('删除失败')
|
||||
|
||||
@@ -611,7 +611,7 @@
|
||||
card_status_check_interval: data.card_status_check_interval
|
||||
})
|
||||
dialogVisible.value = true
|
||||
nextTick(() => {
|
||||
await nextTick(() => {
|
||||
formRef.value?.clearValidate()
|
||||
})
|
||||
} catch {
|
||||
|
||||
@@ -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>
|
||||
|
||||
401
src/views/settings/system-configs/index.vue
Normal file
401
src/views/settings/system-configs/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
203
src/views/settings/wecom/applications/index.vue
Normal file
203
src/views/settings/wecom/applications/index.vue
Normal 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>
|
||||
411
src/views/settings/wecom/members/index.vue
Normal file
411
src/views/settings/wecom/members/index.vue
Normal 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>
|
||||
631
src/views/settings/wecom/scenes/index.vue
Normal file
631
src/views/settings/wecom/scenes/index.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
118
src/views/shop-management/detail/index.vue
Normal file
118
src/views/shop-management/detail/index.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
114
src/views/system/role/detail/index.vue
Normal file
114
src/views/system/role/detail/index.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user