This commit is contained in:
@@ -213,6 +213,75 @@
|
||||
</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
|
||||
v-permission="JULY_PERMISSIONS.wecom.binding"
|
||||
type="primary"
|
||||
:loading="wecomBindingSubmitting"
|
||||
@click="submitWecomBinding"
|
||||
>
|
||||
确认绑定
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
@@ -237,11 +306,16 @@
|
||||
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 {
|
||||
CommonStatus,
|
||||
getStatusText,
|
||||
JULY_PERMISSIONS,
|
||||
STATUS_SELECT_OPTIONS
|
||||
} from '@/config/constants'
|
||||
|
||||
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
|
||||
|
||||
@@ -252,6 +326,7 @@
|
||||
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,6 +337,21 @@
|
||||
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)
|
||||
@@ -541,6 +631,14 @@
|
||||
const getActions = (row: any) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (hasAuth(JULY_PERMISSIONS.wecom.binding)) {
|
||||
actions.push({
|
||||
label: '绑定账号',
|
||||
handler: () => showWecomBindingDialog(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
|
||||
if (hasAuth('account:patch_role')) {
|
||||
actions.push({
|
||||
label: '分配角色',
|
||||
@@ -568,6 +666,75 @@
|
||||
return actions
|
||||
}
|
||||
|
||||
const showWecomBindingDialog = (row: any) => {
|
||||
currentWecomAccount.value = row
|
||||
wecomBindingForm.application_id = undefined
|
||||
wecomBindingForm.userid = ''
|
||||
wecomMembers.value = []
|
||||
wecomBindingDialogVisible.value = true
|
||||
void loadWecomApplications()
|
||||
}
|
||||
|
||||
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) => {
|
||||
wecomBindingForm.userid = ''
|
||||
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 || []
|
||||
} finally {
|
||||
wecomMembersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitWecomBinding = async () => {
|
||||
const valid = await wecomBindingFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !currentWecomAccount.value || !wecomBindingForm.application_id) return
|
||||
|
||||
const accountId = Number(currentWecomAccount.value.id ?? currentWecomAccount.value.ID)
|
||||
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
|
||||
}
|
||||
} finally {
|
||||
wecomBindingSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetWecomBindingForm = () => {
|
||||
currentWecomAccount.value = null
|
||||
wecomBindingForm.application_id = undefined
|
||||
wecomBindingForm.userid = ''
|
||||
wecomMembers.value = []
|
||||
wecomBindingFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 表单实例
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<ElButton
|
||||
v-if="cardInfo?.asset_type === 'card' && hasAuth(JULY_PERMISSIONS.speedTier.view)"
|
||||
v-if="
|
||||
cardInfo?.asset_type === 'card' &&
|
||||
hasAuth(JULY_PERMISSIONS.speedTier.view) &&
|
||||
hasAuth(JULY_PERMISSIONS.speedTier.update)
|
||||
"
|
||||
type="primary"
|
||||
link
|
||||
@click="emit('showSpeedLimit')"
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
v-model="createRefundDialogVisible"
|
||||
:initial-order-id="currentRefundOrderId"
|
||||
:initial-order-no="currentRefundOrderNo"
|
||||
:initial-package-usage-id="currentRefundPackageUsageId"
|
||||
@success="handleRefundSuccess"
|
||||
/>
|
||||
|
||||
@@ -279,12 +280,7 @@
|
||||
width="30%"
|
||||
@closed="resetUsedDataForm"
|
||||
>
|
||||
<ElForm
|
||||
ref="usedDataFormRef"
|
||||
:model="usedDataForm"
|
||||
:rules="usedDataRules"
|
||||
label-width="90px"
|
||||
>
|
||||
<ElForm ref="usedDataFormRef" :model="usedDataForm" :rules="usedDataRules" label-width="90px">
|
||||
<ElFormItem label="套餐名称">
|
||||
<span>{{ selectedPackage?.package_name || '-' }}</span>
|
||||
</ElFormItem>
|
||||
@@ -416,6 +412,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)
|
||||
@@ -474,10 +471,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')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,66 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="设置限速" width="500px">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="设置限速"
|
||||
width="500px"
|
||||
:lock-scroll="true"
|
||||
:z-index="4000"
|
||||
modal-class="speed-limit-modal"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<ElFormItem label="固定档位" prop="code">
|
||||
<ElSelect v-model="form.code" style="width: 100%">
|
||||
<ElOption v-for="tier in speedTiers" :key="tier.code" :label="tier.label" :value="tier.code" />
|
||||
<ElSelect
|
||||
v-model="form.code"
|
||||
style="width: 100%"
|
||||
: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 { 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
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
|
||||
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): 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' },
|
||||
@@ -59,10 +86,25 @@
|
||||
})
|
||||
|
||||
// 监听对话框打开,重置表单
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
form.code = 3
|
||||
}
|
||||
const setLayoutScrollLock = (locked: boolean) => {
|
||||
document
|
||||
.querySelector<HTMLElement>('.layouts')
|
||||
?.classList.toggle('speed-limit-scroll-locked', locked)
|
||||
}
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(newVal) => {
|
||||
setLayoutScrollLock(newVal)
|
||||
if (newVal) {
|
||||
form.code = 3
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
setLayoutScrollLock(false)
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -70,6 +112,7 @@
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (props.confirmLoading) return
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
@@ -83,4 +126,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>
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
@show-switch-card="showSwitchCardDialog"
|
||||
@show-switch-mode="showSwitchModeDialog"
|
||||
@show-set-wifi="showSetWiFiDialog"
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-speed-limit="speedLimitDialogVisible = true"
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-speed-limit="speedLimitDialogVisible = true"
|
||||
@show-update-realname-status="showUpdateRealnameStatusDialog"
|
||||
@enable-binding-card="handleEnableBindingCard"
|
||||
@disable-binding-card="handleDisableBindingCard"
|
||||
@@ -174,7 +174,11 @@
|
||||
:current-realname-status="bindingCardRealnameStatusValue"
|
||||
@success="handleBindingCardRealnameStatusSuccess"
|
||||
/>
|
||||
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" />
|
||||
<SpeedLimitDialog
|
||||
v-model="speedLimitDialogVisible"
|
||||
:confirm-loading="speedTierSubmitting"
|
||||
@confirm="handleSpeedTierConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -183,7 +187,10 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { CardService, 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,
|
||||
@@ -204,9 +211,9 @@
|
||||
SwitchModeDialog,
|
||||
WiFiConfigDialog,
|
||||
PackageRechargeDialog,
|
||||
DailyRecordsDialog,
|
||||
OrderHistoryDialog,
|
||||
SpeedLimitDialog
|
||||
DailyRecordsDialog,
|
||||
OrderHistoryDialog,
|
||||
SpeedLimitDialog
|
||||
} from './components/dialogs'
|
||||
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
|
||||
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
|
||||
@@ -235,6 +242,7 @@
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
// ========== 组件引用 ==========
|
||||
const assetSearchCardRef = ref<InstanceType<typeof AssetSearchCard>>()
|
||||
@@ -294,6 +302,7 @@
|
||||
// 绑定卡实名状态更新
|
||||
const bindingCardRealnameStatusDialogVisible = ref(false)
|
||||
const speedLimitDialogVisible = ref(false)
|
||||
const speedTierSubmitting = ref(false)
|
||||
const bindingCardRealnameStatusIccid = ref('')
|
||||
const bindingCardRealnameStatusValue = ref<number>(0)
|
||||
|
||||
@@ -806,12 +815,34 @@
|
||||
}
|
||||
}
|
||||
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
|
||||
if (!cardInfo.value?.iccid) return
|
||||
const response = await CardService.setSpeedTier(cardInfo.value.iccid, data.code)
|
||||
if (response.code === 0) {
|
||||
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) {
|
||||
ElMessage.error(response.msg || '限速设置失败')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
|
||||
speedLimitDialogVisible.value = false
|
||||
await handleRefresh()
|
||||
} catch (error) {
|
||||
console.error('设置限速失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
speedTierSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,29 +17,6 @@
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="handleBatchAllocate"
|
||||
:disabled="!selectedDevices.length"
|
||||
v-permission="'device:batch_allocate'"
|
||||
>
|
||||
批量分配
|
||||
</ElButton>
|
||||
<ElButton
|
||||
@click="handleBatchRecall"
|
||||
:disabled="!selectedDevices.length"
|
||||
v-permission="'device:batch_recall'"
|
||||
>
|
||||
批量回收
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="info"
|
||||
:disabled="!selectedDevices.length"
|
||||
@click="handleBatchSetSeries"
|
||||
v-permission="'device:batch_set_series'"
|
||||
>
|
||||
批量设置套餐系列
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!selectedDevices.length"
|
||||
@@ -2299,8 +2276,8 @@
|
||||
return
|
||||
}
|
||||
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedDevices.value = []
|
||||
await getTableData()
|
||||
@@ -2373,19 +2350,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
// 批量分配
|
||||
const handleBatchAllocate = async () => {
|
||||
if (selectedDevices.value.length === 0) {
|
||||
ElMessage.warning('请先选择要分配的设备')
|
||||
return
|
||||
}
|
||||
allocateForm.target_shop_id = undefined
|
||||
allocateForm.remark = ''
|
||||
allocateResult.value = null
|
||||
shopCascadeOptions.value = []
|
||||
allocateDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认批量分配
|
||||
const handleConfirmAllocate = async () => {
|
||||
if (!allocateFormRef.value) return
|
||||
@@ -2436,17 +2400,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 批量回收
|
||||
const handleBatchRecall = () => {
|
||||
if (selectedDevices.value.length === 0) {
|
||||
ElMessage.warning('请先选择要回收的设备')
|
||||
return
|
||||
}
|
||||
recallForm.remark = ''
|
||||
recallResult.value = null
|
||||
recallDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认批量回收
|
||||
const handleConfirmRecall = async () => {
|
||||
recallLoading.value = true
|
||||
@@ -2636,22 +2589,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
|
||||
@@ -2680,24 +2617,6 @@
|
||||
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 buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => {
|
||||
const request: BatchSetDeviceSeriesBindingRequest = {
|
||||
selection_type: seriesBindingForm.selection_type,
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<ElButton type="primary" @click="showCreateDialog" v-permission="'exchange:create'">
|
||||
创建换货单
|
||||
</ElButton>
|
||||
<ElButton v-if="hasAuth('exchange:export')" @click="exportDialogVisible = true">
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -41,6 +44,14 @@
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="exchange"
|
||||
:query="exportQuery"
|
||||
confirm-permission="exchange:export"
|
||||
title="导出换货"
|
||||
/>
|
||||
|
||||
<!-- 创建换货单对话框 -->
|
||||
<ElDialog
|
||||
v-model="createDialogVisible"
|
||||
@@ -393,6 +404,7 @@
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
getApprovalStatusText,
|
||||
@@ -413,6 +425,7 @@
|
||||
const shipFormRef = ref<FormInstance>()
|
||||
const createDialogVisible = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const exportDialogVisible = ref(false)
|
||||
const tableRef = ref()
|
||||
const createFormRef = ref<FormInstance>()
|
||||
|
||||
@@ -438,6 +451,18 @@
|
||||
created_at_end: ''
|
||||
})
|
||||
|
||||
const exportQuery = computed(() => {
|
||||
const [startDate, endDate] = searchForm.created_at_range || []
|
||||
return {
|
||||
status: searchForm.status,
|
||||
flow_type: searchForm.flow_type,
|
||||
old_asset_keyword: searchForm.old_asset_keyword || undefined,
|
||||
new_asset_keyword: searchForm.new_asset_keyword || undefined,
|
||||
created_at_start: startDate || searchForm.created_at_start || undefined,
|
||||
created_at_end: endDate || searchForm.created_at_end || undefined
|
||||
}
|
||||
})
|
||||
|
||||
// 创建换货单表单
|
||||
const createForm = reactive<{
|
||||
exchange_reason: string
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
:show-expand="true"
|
||||
label-width="85"
|
||||
label-width="100px"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
@@ -17,7 +17,10 @@
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<div class="page-intro"> 仅展示可预计且尚未过期的资产,0-3 天资产优先显示。 </div>
|
||||
<div class="page-intro">
|
||||
临期资产 {{ summary.total_count }} 条(卡 {{ summary.card_count }},设备
|
||||
{{ summary.device_count }}),0-3 天资产优先显示。
|
||||
</div>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -47,11 +50,12 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElTag } from 'element-plus'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { AssetService, PackageManageService, ShopService } from '@/api/modules'
|
||||
import type {
|
||||
AssetType,
|
||||
ExpiringAssetItem,
|
||||
ExpiringAssetSummary,
|
||||
ExpiringAssetType,
|
||||
ExpiringAssetQueryParams,
|
||||
PackageResponse,
|
||||
ShopResponse
|
||||
@@ -72,6 +76,12 @@
|
||||
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>({
|
||||
asset_type: undefined,
|
||||
keyword: '',
|
||||
@@ -91,7 +101,7 @@
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: [
|
||||
{ label: '网卡', value: 'card' },
|
||||
{ label: '物联网卡', value: 'iot_card' },
|
||||
{ label: '设备', value: 'device' }
|
||||
]
|
||||
},
|
||||
@@ -137,13 +147,25 @@
|
||||
label: '最小剩余天数',
|
||||
prop: 'days_min',
|
||||
type: 'input',
|
||||
config: { clearable: true, inputmode: 'numeric', placeholder: '最小天数' }
|
||||
config: {
|
||||
clearable: true,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 15,
|
||||
placeholder: '最小天数'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '最大剩余天数',
|
||||
prop: 'days_max',
|
||||
type: 'input',
|
||||
config: { clearable: true, inputmode: 'numeric', placeholder: '最大天数' }
|
||||
config: {
|
||||
clearable: true,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 15,
|
||||
placeholder: '最大天数'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '预计到期起始',
|
||||
@@ -170,15 +192,15 @@
|
||||
]
|
||||
|
||||
const getExpiryClass = (level?: string | null) => {
|
||||
if (level === 'critical' || level === '0_3') return 'expiry-critical'
|
||||
if (level === 'warning' || level === '4_7') return 'expiry-warning'
|
||||
if (level === 'notice' || level === '8_15') return 'expiry-notice'
|
||||
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 === 'critical' || level === '0_3') return 'danger'
|
||||
if (level === 'warning' || level === '4_7') return 'warning'
|
||||
if (level === 'red') return 'danger'
|
||||
if (level === 'purple') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
@@ -215,7 +237,7 @@
|
||||
prop: 'asset_type',
|
||||
label: '资产类型',
|
||||
width: 100,
|
||||
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'card' ? '网卡' : '设备')
|
||||
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'iot_card' ? '物联网卡' : '设备')
|
||||
},
|
||||
{
|
||||
prop: 'identifier',
|
||||
@@ -246,7 +268,7 @@
|
||||
h(
|
||||
'span',
|
||||
{ class: getExpiryClass(row.expiry_level) },
|
||||
formatDateTime(row.estimated_final_expires_at)
|
||||
formatDateTime(row.estimated_final_expires_at) || '-'
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -254,10 +276,8 @@
|
||||
label: '剩余天数',
|
||||
width: 100,
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
|
||||
() => `${row.days_until_final_expiry}天`
|
||||
h(ElTag, { type: getExpiryTagType(row.expiry_level), size: 'small' }, () =>
|
||||
row.days_until_final_expiry == null ? '-' : `${row.days_until_final_expiry}天`
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -271,15 +291,43 @@
|
||||
const loadAssets = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await AssetService.getExpiringAssets({
|
||||
...searchForm,
|
||||
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,
|
||||
size: pagination.pageSize
|
||||
})
|
||||
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
|
||||
if (searchForm.expires_from) params.expires_from = searchForm.expires_from
|
||||
if (searchForm.expires_to) params.expires_to = searchForm.expires_to
|
||||
|
||||
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
|
||||
}
|
||||
@@ -318,21 +366,20 @@
|
||||
const goToAsset = (row: ExpiringAssetItem) => {
|
||||
router.push({
|
||||
path: RoutesAlias.AssetInformation,
|
||||
query: row.asset_type === 'card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
|
||||
query:
|
||||
row.asset_type === 'iot_card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
|
||||
})
|
||||
}
|
||||
|
||||
const getActions = (row: ExpiringAssetItem) => [
|
||||
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) },
|
||||
...(row.can_renew
|
||||
? [{ label: '续费', type: 'primary' as const, handler: () => goToAsset(row) }]
|
||||
: [])
|
||||
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) }
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
const assetType = route.query.asset_type
|
||||
if (assetType === 'card' || assetType === 'device')
|
||||
searchForm.asset_type = assetType as AssetType
|
||||
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>
|
||||
|
||||
@@ -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,7 +14,6 @@
|
||||
<ElButton v-if="canDownloadTask" type="primary" @click="downloadTask">
|
||||
下载文件
|
||||
</ElButton>
|
||||
<ElButton v-if="canCancelTask" type="danger" @click="cancelTask"> 取消任务 </ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +40,7 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
@@ -104,22 +103,11 @@
|
||||
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: '任务基本信息',
|
||||
fields: [
|
||||
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
|
||||
{ label: '任务ID', prop: 'task_id', formatter: (value: number) => String(value ?? '-') },
|
||||
{
|
||||
label: '导出场景',
|
||||
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene))
|
||||
@@ -269,35 +257,6 @@
|
||||
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!.task_id ?? taskDetail.value!.id
|
||||
)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.data?.message || '任务取消成功')
|
||||
polling.retry()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '取消导出任务失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消导出任务失败:', error)
|
||||
ElMessage.error('取消导出任务失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTaskDetail()
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -231,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 {
|
||||
@@ -240,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) {
|
||||
@@ -347,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.task_id ?? 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)
|
||||
@@ -381,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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,506 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="bulk-purchase-page" id="table-full-screen">
|
||||
<ElDrawer
|
||||
v-model="createDrawerVisible"
|
||||
title="批量订购套餐"
|
||||
direction="rtl"
|
||||
size="520px"
|
||||
:close-on-click-modal="!createDrawerBusy"
|
||||
:close-on-press-escape="!createDrawerBusy"
|
||||
@closed="handleCreateDrawerClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
|
||||
<ElFormItem label="套餐" prop="package_id">
|
||||
<ElSelect
|
||||
v-model="form.package_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="handlePackageSearch"
|
||||
:loading="packageLoading"
|
||||
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>
|
||||
</ElSelect>
|
||||
</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)"
|
||||
tag="a"
|
||||
href="/templates/bulk-purchase-template.csv"
|
||||
download="批量订购套餐模板.csv"
|
||||
>
|
||||
下载模板
|
||||
</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, 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 } from '@/api/modules'
|
||||
import type { BulkPurchasePaymentMethod, BulkPurchaseTask, PackageResponse } 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 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 createDrawerBusy = computed(
|
||||
() => submitting.value || voucherUploading.value || orderFileUploading.value
|
||||
)
|
||||
|
||||
const form = reactive<{
|
||||
package_id?: number
|
||||
payment_method: BulkPurchasePaymentMethod
|
||||
}>({
|
||||
package_id: undefined,
|
||||
payment_method: 'wallet'
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
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 = '') => {
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const res = await PackageManageService.getPackages({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
package_name: query.trim() || undefined,
|
||||
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) => {
|
||||
void loadPackages(query)
|
||||
}
|
||||
|
||||
const handlePackageVisibleChange = (visible: boolean) => {
|
||||
if (visible && packageOptions.value.length === 0) void loadPackages()
|
||||
}
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
createDrawerVisible.value = true
|
||||
if (packageOptions.value.length === 0) void loadPackages()
|
||||
}
|
||||
|
||||
const handleCreateDrawerClosed = () => {
|
||||
formRef.value?.resetFields()
|
||||
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 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
|
||||
}
|
||||
|
||||
ElMessage.success(res.data.message || '批量订购任务已创建')
|
||||
createDrawerVisible.value = false
|
||||
await router.push(`${RoutesAlias.BulkPurchaseDetail}/${taskId}`)
|
||||
} 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 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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-purchase-page {
|
||||
.task-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-list-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.package-option {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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 (max-width: 768px) {
|
||||
.task-list-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-list-toolbar {
|
||||
width: 100%;
|
||||
|
||||
.el-select {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="device-batch-allocation-page" id="table-full-screen">
|
||||
<ElDrawer
|
||||
v-model="createDrawerVisible"
|
||||
title="设备批量分配"
|
||||
direction="rtl"
|
||||
size="520px"
|
||||
:close-on-click-modal="!createDrawerBusy"
|
||||
:close-on-press-escape="!createDrawerBusy"
|
||||
@closed="handleCreateDrawerClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" label-width="110px" 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="目标 ID" 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)"
|
||||
tag="a"
|
||||
href="/templates/device-batch-allocation-template.csv"
|
||||
download="设备批量分配模板.csv"
|
||||
>
|
||||
下载模板
|
||||
</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="210" 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="目标 ID" width="110">
|
||||
<template #default="scope">{{ scope.row.target_id ?? '-' }}</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, 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 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 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 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 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()
|
||||
ElMessage.success(`任务已创建:${res.data.task_no}`)
|
||||
} catch (error) {
|
||||
console.error('创建设备批量分配任务失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTaskList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-batch-allocation-page {
|
||||
.task-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-list-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.task-list-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-list-toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<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
|
||||
@@ -21,26 +20,18 @@
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
|
||||
type="primary"
|
||||
:icon="Upload"
|
||||
@click="importDialogVisible = true"
|
||||
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
|
||||
>
|
||||
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
|
||||
批量导入设备
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
@@ -62,21 +53,20 @@
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 导入对话框 -->
|
||||
<ElDialog v-model="importDialogVisible" title="批量导入设备" width="40%" align-center>
|
||||
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
|
||||
<template #title>
|
||||
<div style="line-height: 1.8">
|
||||
<p><strong>导入说明:</strong></p>
|
||||
<p>1. 设备分配使用单列 UTF-8 CSV,导入设备仍使用 Excel 模板</p>
|
||||
<p>2. CSV 单次最多 1000 条,文件不超过 10MB</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>
|
||||
@@ -88,23 +78,7 @@
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="任务类型">
|
||||
<ElSelect v-model="importForm.operation_type" style="width: 100%">
|
||||
<ElOption label="导入设备" value="import" />
|
||||
<ElOption label="分配目标代理" value="assign_shop" />
|
||||
<ElOption label="设置套餐系列" value="assign_series" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol v-if="importForm.operation_type !== 'import'" :span="12">
|
||||
<ElFormItem label="目标 ID">
|
||||
<ElInput v-model="importForm.target_id" placeholder="代理店铺或套餐系列 ID" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="批次号" prop="batch_no">
|
||||
<ElInput
|
||||
@@ -135,18 +109,18 @@
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElUpload
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:limit="1"
|
||||
accept=".xlsx,.csv"
|
||||
>
|
||||
accept=".xlsx"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">导入设备支持 .xlsx;批量分配支持单列 .csv</div>
|
||||
<div class="el-upload__tip">设备导入仅支持 .xlsx 文件</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
|
||||
@@ -166,12 +140,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { h, onMounted, reactive, ref, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
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'
|
||||
@@ -189,7 +162,6 @@
|
||||
} from '@/types/api/device'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { generatePackageCode } from '@/utils/codeGenerator'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
defineOptions({ name: 'DeviceTask' })
|
||||
@@ -208,40 +180,23 @@
|
||||
const importDialogVisible = ref(false)
|
||||
const importForm = reactive({
|
||||
batch_no: '',
|
||||
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order',
|
||||
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
|
||||
target_id: ''
|
||||
realname_policy: '' as '' | RealnamePolicy
|
||||
})
|
||||
|
||||
// 搜索表单初始值
|
||||
const initialSearchState = {
|
||||
status: undefined,
|
||||
status: undefined as DeviceImportTaskStatus | undefined,
|
||||
batch_no: '',
|
||||
dateRange: undefined as string[] | undefined,
|
||||
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 },
|
||||
@@ -253,10 +208,7 @@
|
||||
label: '批次号',
|
||||
prop: 'batch_no',
|
||||
type: 'input',
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '请输入批次号'
|
||||
}
|
||||
config: { clearable: true, placeholder: '请输入批次号' }
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
@@ -271,7 +223,6 @@
|
||||
}
|
||||
]
|
||||
|
||||
// 列配置
|
||||
const columnOptions = [
|
||||
{ label: '任务编号', prop: 'task_no' },
|
||||
{ label: '任务状态', prop: 'status' },
|
||||
@@ -288,7 +239,6 @@
|
||||
]
|
||||
|
||||
const taskList = ref<DeviceImportTask[]>([])
|
||||
|
||||
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
|
||||
storageKey: 'device-import-active-task',
|
||||
autoRestore: false,
|
||||
@@ -306,7 +256,6 @@
|
||||
const pollingError = polling.error
|
||||
const pollingForbidden = polling.forbidden
|
||||
|
||||
// 获取状态标签类型
|
||||
const getStatusType = (status: DeviceImportTaskStatus) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
@@ -322,19 +271,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
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 (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
|
||||
viewDetail(row)
|
||||
@@ -343,15 +287,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 动态列配置
|
||||
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;',
|
||||
@@ -362,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: '开始时间',
|
||||
@@ -424,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: '操作人',
|
||||
@@ -460,7 +381,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
// 获取设备任务列表
|
||||
const getTableData = async () => {
|
||||
if (!isPlatformAccount.value) {
|
||||
taskList.value = []
|
||||
@@ -473,10 +393,9 @@
|
||||
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]
|
||||
@@ -484,9 +403,7 @@
|
||||
|
||||
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) {
|
||||
@@ -497,37 +414,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
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
|
||||
@@ -542,107 +452,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 文件选择变化
|
||||
const handleFileChange = async (uploadFile: any) => {
|
||||
const isCsvAllocation = importForm.operation_type !== 'import'
|
||||
const maxSize = (isCsvAllocation ? 10 : 300) * 1024 * 1024
|
||||
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
|
||||
const handleFileChange = (uploadFile: any) => {
|
||||
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 && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) {
|
||||
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .xlsx 文件')
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
ElMessage.error('设备导入只能上传 .xlsx 文件')
|
||||
uploadRef.value?.clearFiles()
|
||||
fileList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (isCsvAllocation && uploadFile.raw) {
|
||||
const rows = (await uploadFile.raw.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
|
||||
if (rows.length > 1001 || rows.some((row: string) => row.includes(','))) {
|
||||
ElMessage.error('设备分配 CSV 必须是单列且最多 1000 行数据')
|
||||
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 = ''
|
||||
importForm.operation_type = 'import'
|
||||
importForm.target_id = ''
|
||||
importDialogVisible.value = false
|
||||
}
|
||||
// 提交上传
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!isPlatformAccount.value || !hasAuth(JULY_PERMISSIONS.deviceAllocation.page)) return
|
||||
if (!fileList.value.length) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
if (importForm.operation_type !== 'import' && !Number(importForm.target_id)) {
|
||||
ElMessage.warning('请输入有效的目标 ID')
|
||||
return
|
||||
}
|
||||
if (!isPlatformAccount.value || !fileList.value.length) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const file = fileList.value[0]
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
ElMessage.info('正在准备上传...')
|
||||
const isAllocation = importForm.operation_type !== 'import'
|
||||
const contentType = isAllocation ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: contentType,
|
||||
purpose: isAllocation ? 'device_batch_allocation' : 'iot_import'
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
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, file, contentType)
|
||||
|
||||
ElMessage.info(isAllocation ? '正在创建分配任务...' : '正在创建导入任务...')
|
||||
const importRes = isAllocation
|
||||
? await DeviceService.createAllocationTask({
|
||||
file_key,
|
||||
operation_type: importForm.operation_type as 'assign_shop' | 'assign_series',
|
||||
target_id: Number(importForm.target_id)
|
||||
})
|
||||
: await DeviceService.importDevices({
|
||||
file_key,
|
||||
batch_no: importForm.batch_no || undefined,
|
||||
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
|
||||
})
|
||||
await StorageService.uploadFile(
|
||||
upload_url,
|
||||
file,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
|
||||
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
|
||||
@@ -650,12 +528,10 @@
|
||||
|
||||
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,
|
||||
@@ -669,62 +545,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 从行数据下载失败数据
|
||||
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) => {
|
||||
if (!isPlatformAccount.value) return
|
||||
const fileKey = row.file_name?.trim()
|
||||
const fileKey = row.file_key || row.file_name?.trim()
|
||||
if (!fileKey) {
|
||||
ElMessage.warning('当前任务没有可下载的原始文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StorageService.downloadFileByKey(fileKey)
|
||||
ElMessage.success('原始文件下载已开始')
|
||||
@@ -734,20 +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: '失败数据',
|
||||
@@ -755,7 +612,6 @@
|
||||
type: 'danger'
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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,16 +70,15 @@
|
||||
</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'
|
||||
@@ -72,12 +94,53 @@
|
||||
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'
|
||||
@@ -92,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: '目标 ID',
|
||||
prop: 'target_id',
|
||||
formatter: (value: number | null) => String(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: '任务基本信息',
|
||||
@@ -101,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
|
||||
? [
|
||||
@@ -171,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 || ''
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -189,64 +272,57 @@
|
||||
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()
|
||||
@@ -256,28 +332,22 @@
|
||||
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>
|
||||
|
||||
@@ -285,8 +355,8 @@
|
||||
.task-detail-page {
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 16px;
|
||||
|
||||
.detail-title {
|
||||
@@ -297,8 +367,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.failure-section,
|
||||
.skipped-section {
|
||||
.result-section {
|
||||
margin-top: 20px;
|
||||
|
||||
.section-title {
|
||||
|
||||
@@ -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"
|
||||
@@ -508,6 +521,7 @@
|
||||
} from '@/config/constants/commission'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
|
||||
defineOptions({ name: 'AgentCommission' })
|
||||
|
||||
@@ -619,10 +633,10 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (value <= 0) {
|
||||
callback(new Error('实际信用额度必须大于0'))
|
||||
return
|
||||
}
|
||||
if (value <= 0) {
|
||||
callback(new Error('实际信用额度必须大于0'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
},
|
||||
@@ -649,6 +663,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 = [
|
||||
@@ -878,10 +903,6 @@
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
|
||||
// 获取代理商资金汇总列表
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
@@ -905,6 +926,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
|
||||
@@ -953,6 +982,10 @@
|
||||
loadCommissionRecords()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadInitialData()
|
||||
})
|
||||
|
||||
// 获取操作按钮
|
||||
const getActions = (row: ShopFundSummaryItem) => {
|
||||
const actions: any[] = []
|
||||
|
||||
@@ -23,20 +23,24 @@
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AssetService } from '@/api/modules'
|
||||
import type { AssetType } from '@/types/api'
|
||||
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: assetType,
|
||||
asset_type: apiAssetTypes[assetType],
|
||||
page: 1,
|
||||
size: 1
|
||||
page_size: 1
|
||||
})
|
||||
if (response.code === 0 && response.data) counts[assetType] = response.data.total || 0
|
||||
} catch {
|
||||
@@ -47,7 +51,7 @@
|
||||
const goToList = (assetType?: AssetType) => {
|
||||
router.push({
|
||||
path: RoutesAlias.ExpiringAssets,
|
||||
query: assetType ? { asset_type: assetType } : {}
|
||||
query: assetType ? { asset_type: apiAssetTypes[assetType] } : {}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ 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)
|
||||
|
||||
if (
|
||||
row.payment_method === 'offline' &&
|
||||
@@ -33,6 +38,7 @@ export const buildAgentRechargeActions = (
|
||||
}
|
||||
|
||||
if (
|
||||
!hasApprovalRecord &&
|
||||
row.status === AgentRechargeStatus.PENDING &&
|
||||
row.payment_method === 'offline' &&
|
||||
options.hasAuth('agent_recharge:confirm_payment')
|
||||
@@ -44,7 +50,11 @@ export const buildAgentRechargeActions = (
|
||||
})
|
||||
}
|
||||
|
||||
if (row.status === AgentRechargeStatus.PENDING && options.hasAuth('agent_recharge:reject')) {
|
||||
if (
|
||||
!hasApprovalRecord &&
|
||||
row.status === AgentRechargeStatus.PENDING &&
|
||||
options.hasAuth('agent_recharge:reject')
|
||||
) {
|
||||
actions.push({
|
||||
label: '拒绝',
|
||||
handler: () => options.onReject(row),
|
||||
|
||||
@@ -68,26 +68,12 @@
|
||||
2: 'success', // 已支付
|
||||
3: 'success', // 已完成
|
||||
4: 'info', // 已关闭
|
||||
5: 'danger', // 已退款
|
||||
6: '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: '已退款',
|
||||
6: '已驳回'
|
||||
}
|
||||
return statusMap[status] || '-'
|
||||
}
|
||||
|
||||
// 获取支付方式文本
|
||||
const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => {
|
||||
const methodMap: Record<AgentRechargePaymentMethod, string> = {
|
||||
@@ -97,6 +83,12 @@
|
||||
return methodMap[method] || method
|
||||
}
|
||||
|
||||
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[] => [
|
||||
{
|
||||
@@ -104,6 +96,7 @@
|
||||
fields: [
|
||||
{ label: '充值单号', prop: 'recharge_no' },
|
||||
{ label: '店铺名称', prop: 'shop_name' },
|
||||
{ label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' },
|
||||
{
|
||||
label: '充值金额',
|
||||
formatter: (_, data) => formatCurrency(data.amount)
|
||||
@@ -111,9 +104,7 @@
|
||||
{
|
||||
label: '状态',
|
||||
render: (data) =>
|
||||
h(ElTag, { type: getStatusType(data.status) }, () =>
|
||||
getStatusText(data.status, data.status_name)
|
||||
)
|
||||
h(ElTag, { type: getStatusType(data.status) }, () => data.status_name || '-')
|
||||
},
|
||||
{
|
||||
label: '驳回原因',
|
||||
@@ -123,6 +114,30 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '企微审批信息',
|
||||
fields: [
|
||||
{ label: '审批渠道', formatter: (_, data) => getApprovalProviderText(data) },
|
||||
{
|
||||
label: '审批状态',
|
||||
formatter: (_, data) => data.approval_status_name || '-'
|
||||
},
|
||||
{
|
||||
label: '当前审批人摘要',
|
||||
formatter: (_, data) => data.current_approver_summary || '-',
|
||||
fullWidth: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '业务处理结果',
|
||||
fields: [
|
||||
{
|
||||
label: '处理状态',
|
||||
formatter: (_, data) => data.processing_status_name || '-'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '支付信息',
|
||||
fields: [
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
v-if="hasAuth('agent_recharge:create')"
|
||||
>创建充值订单</ElButton
|
||||
>
|
||||
<ElButton v-if="hasAuth('agent_recharge:export')" @click="exportDialogVisible = true">
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -66,7 +69,7 @@
|
||||
placeholder="请输入充值金额(元)"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)">
|
||||
最小: ¥0.01,最大: 不限制
|
||||
最小: ¥0.01,最大: ¥1,000,000.00
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="支付方式" prop="payment_method">
|
||||
@@ -129,6 +132,15 @@
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 导出任务对话框 -->
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="agent_recharge"
|
||||
:query="exportQuery"
|
||||
confirm-permission="agent_recharge:export"
|
||||
title="导出代理充值"
|
||||
/>
|
||||
|
||||
<!-- 确认线下支付对话框 -->
|
||||
<ElDialog
|
||||
v-model="confirmPayDialogVisible"
|
||||
@@ -245,6 +257,7 @@
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import { buildAgentRechargeActions } from './agentRechargeActions'
|
||||
import { formatRejectionReason } from './agentRechargeDisplay'
|
||||
|
||||
@@ -260,6 +273,7 @@
|
||||
const rejectLoading = ref(false)
|
||||
const tableRef = ref()
|
||||
const createDialogVisible = ref(false)
|
||||
const exportDialogVisible = ref(false)
|
||||
const confirmPayDialogVisible = ref(false)
|
||||
const rejectDialogVisible = ref(false)
|
||||
const currentRecharge = ref<AgentRecharge | null>(null)
|
||||
@@ -336,8 +350,11 @@
|
||||
placeholder: '请选择状态',
|
||||
options: [
|
||||
{ label: '待支付', value: 1 },
|
||||
{ label: '已完成', value: 2 },
|
||||
{ label: '已取消', value: 3 }
|
||||
{ label: '已支付', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '已关闭', value: 4 },
|
||||
{ label: '已退款', value: 5 },
|
||||
{ label: '已驳回', value: 6 }
|
||||
],
|
||||
config: {
|
||||
clearable: true
|
||||
@@ -370,16 +387,19 @@
|
||||
{ label: '店铺名称', prop: 'shop_name' },
|
||||
{ label: '充值金额', prop: '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: 'payment_method' },
|
||||
{ label: '支付通道', prop: 'payment_channel' },
|
||||
{ label: '运营备注', prop: 'remark' },
|
||||
{ label: '驳回原因', prop: 'rejection_reason' },
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '支付时间', prop: 'paid_at' },
|
||||
{ label: '完成时间', prop: 'completed_at' }
|
||||
{ label: '完成时间', prop: 'completed_at' },
|
||||
{ label: '更新时间', prop: 'updated_at' }
|
||||
]
|
||||
|
||||
const createFormRef = ref<FormInstance>()
|
||||
@@ -387,6 +407,7 @@
|
||||
const rejectFormRef = ref<FormInstance>()
|
||||
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const MIN_RECHARGE_AMOUNT = 0.01
|
||||
const MAX_RECHARGE_AMOUNT = 1_000_000
|
||||
|
||||
const createRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
@@ -402,6 +423,10 @@
|
||||
callback(new Error(`充值金额最小为 ¥${MIN_RECHARGE_AMOUNT.toFixed(2)}`))
|
||||
return
|
||||
}
|
||||
if (Number(value) > MAX_RECHARGE_AMOUNT) {
|
||||
callback(new Error(`充值金额最大为 ¥${MAX_RECHARGE_AMOUNT.toFixed(2)}`))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
@@ -464,26 +489,12 @@
|
||||
2: 'success', // 已支付
|
||||
3: 'success', // 已完成
|
||||
4: 'info', // 已关闭
|
||||
5: 'danger', // 已退款
|
||||
6: '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: '已退款',
|
||||
6: '已驳回'
|
||||
}
|
||||
return statusMap[status] || '-'
|
||||
}
|
||||
|
||||
// 获取支付方式文本
|
||||
const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => {
|
||||
const methodMap: Record<AgentRechargePaymentMethod, string> = {
|
||||
@@ -530,9 +541,17 @@
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: AgentRecharge) => {
|
||||
return h(ElTag, { type: getStatusType(row.status) }, () =>
|
||||
getStatusText(row.status, row.status_name)
|
||||
)
|
||||
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '-')
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'approval_provider',
|
||||
label: '审批渠道',
|
||||
width: 110,
|
||||
formatter: (row: AgentRecharge) => {
|
||||
if (row.approval_provider === 'wecom' || row.approval_source === 'wecom') return '企微'
|
||||
if (row.approval_source === 'legacy') return '历史审批'
|
||||
return row.approval_provider || row.approval_source || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -605,6 +624,12 @@
|
||||
label: '完成时间',
|
||||
width: 180,
|
||||
formatter: (row: AgentRecharge) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
|
||||
},
|
||||
{
|
||||
prop: 'updated_at',
|
||||
label: '更新时间',
|
||||
width: 180,
|
||||
formatter: (row: AgentRecharge) => formatDateTime(row.updated_at)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -711,6 +736,13 @@
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const exportQuery = computed(() => ({
|
||||
shop_id: searchForm.shop_id,
|
||||
status: searchForm.status,
|
||||
start_date: searchForm.start_date || searchForm.dateRange?.[0],
|
||||
end_date: searchForm.end_date || searchForm.dateRange?.[1]
|
||||
}))
|
||||
|
||||
// 刷新表格
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
|
||||
@@ -162,6 +162,34 @@
|
||||
return `¥${(amount / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
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 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') {
|
||||
@@ -171,7 +199,7 @@
|
||||
}
|
||||
|
||||
const renderStructuredValue = (value: unknown) =>
|
||||
h('pre', { class: 'structured-value' }, formatStructuredValue(value))
|
||||
h('pre', { class: 'structured-value' }, formatStructuredValue(sanitizeStructuredValue(value)))
|
||||
|
||||
const renderTimeline = (timeline: unknown) => {
|
||||
if (!Array.isArray(timeline) || timeline.length === 0) return h('span', '-')
|
||||
@@ -181,7 +209,7 @@
|
||||
{ class: 'approval-timeline' },
|
||||
timeline.map((item: any) => {
|
||||
const title = item.status_name || item.status || item.content || '审批节点'
|
||||
const operator = item.operator_name || item.operator || ''
|
||||
const operator = item.operator_name || ''
|
||||
const time = item.time || item.timestamp || ''
|
||||
const comment = item.comment || item.content || ''
|
||||
return h('div', { class: 'approval-timeline__item' }, [
|
||||
@@ -207,6 +235,8 @@
|
||||
{ label: '订单号', prop: 'order_no' },
|
||||
{ label: '资产标识符', prop: 'asset_identifier' },
|
||||
{ label: '资产类型', prop: 'asset_type' },
|
||||
{ label: '退款状态', prop: 'status_name', formatter: (value) => value || '-' },
|
||||
{ label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' },
|
||||
{
|
||||
label: '申请退款金额',
|
||||
formatter: (_, data) => formatCurrency(data.requested_refund_amount)
|
||||
@@ -225,6 +255,12 @@
|
||||
formatter: (value) => value || '-',
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '拒绝原因',
|
||||
prop: 'reject_reason',
|
||||
formatter: (value) => value || '-',
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '备注',
|
||||
prop: 'remark',
|
||||
@@ -258,17 +294,30 @@
|
||||
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: [
|
||||
{ label: '审批来源', prop: 'approval.source' },
|
||||
{ label: '审批渠道', formatter: (_, data) => getApprovalProviderText(data) },
|
||||
{
|
||||
label: '审批来源',
|
||||
formatter: (_, data) => data.approval?.source || data.approval_source || '-'
|
||||
},
|
||||
{ label: '审批单号', prop: 'approval.sp_no' },
|
||||
{
|
||||
label: '审批状态',
|
||||
formatter: (_, data) => data.approval?.status_name || data.approval?.status || '-'
|
||||
formatter: (_, data) => getRefundApprovalStatusText(data)
|
||||
},
|
||||
{
|
||||
label: '当前审批人摘要',
|
||||
formatter: (_, data) => data.current_approver_summary || '-'
|
||||
},
|
||||
{ label: '模板版本', prop: 'approval.template_version' },
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -48,6 +51,15 @@
|
||||
<!-- 创建退款申请对话框 -->
|
||||
<CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" />
|
||||
|
||||
<!-- 导出任务对话框 -->
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="refund"
|
||||
:query="exportQuery"
|
||||
confirm-permission="refund:export"
|
||||
title="导出退款"
|
||||
/>
|
||||
|
||||
<!-- 退款凭证预览 -->
|
||||
<PaymentVoucherDialog
|
||||
:file-keys="refundVoucherFileKeys"
|
||||
@@ -147,7 +159,6 @@
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
|
||||
import {
|
||||
getApprovalStatusText,
|
||||
getCurrentApproverSummaryText,
|
||||
getProcessingStatusText
|
||||
} from '@/utils/business/approvalSummary'
|
||||
@@ -155,6 +166,7 @@
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
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'
|
||||
|
||||
@@ -170,6 +182,7 @@
|
||||
const tableRef = ref()
|
||||
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const createDialogVisible = ref(false)
|
||||
const exportDialogVisible = ref(false)
|
||||
const resubmitDialogVisible = ref(false)
|
||||
const currentRefund = ref<Refund | null>(null)
|
||||
const refundVoucherFileKeys = ref<string[]>([])
|
||||
@@ -269,6 +282,7 @@
|
||||
{ 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' },
|
||||
@@ -279,7 +293,8 @@
|
||||
{ label: '资产重置', prop: 'asset_reset' },
|
||||
{ label: '佣金扣除', prop: 'commission_deducted' },
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '审批时间', prop: 'processed_at' }
|
||||
{ label: '审批时间', prop: 'processed_at' },
|
||||
{ label: '更新时间', prop: 'updated_at' }
|
||||
]
|
||||
|
||||
const resubmitFormRef = ref<FormInstance>()
|
||||
@@ -320,17 +335,6 @@
|
||||
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(() => [
|
||||
{
|
||||
@@ -404,7 +408,17 @@
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: Refund) => {
|
||||
return h(ElTag, { type: getStatusType(row.status) }, () => getStatusText(row.status))
|
||||
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 || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -418,7 +432,7 @@
|
||||
prop: 'approval_status',
|
||||
label: '审批状态',
|
||||
width: 120,
|
||||
formatter: (row: Refund) => getApprovalStatusText(row)
|
||||
formatter: (row: Refund) => row.approval_status_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'current_approver_summary',
|
||||
@@ -483,6 +497,12 @@
|
||||
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)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -564,6 +584,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 })
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="notification-center-page" id="table-full-screen">
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>通知中心</h2>
|
||||
<p>查看余额预警、临期提醒、审批结果和系统告警。</p>
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="hasAuth(NOTIFICATION_PERMISSIONS.readAll)"
|
||||
:loading="markingAllRead"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
全部已读
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filters">
|
||||
<ElSelect v-model="filters.category" clearable placeholder="通知分类" @change="reload">
|
||||
<ElOption label="临期提醒" value="expiry" />
|
||||
<ElOption label="审批" value="approval" />
|
||||
<ElOption label="同步" value="sync" />
|
||||
<ElOption label="系统" value="system" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.type" clearable placeholder="通知类型" @change="reload">
|
||||
<ElOption v-for="type in typeOptions" :key="type" :label="type" :value="type" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.severity" clearable placeholder="严重级别" @change="reload">
|
||||
<ElOption label="提示" value="info" />
|
||||
<ElOption label="警告" value="warning" />
|
||||
<ElOption label="严重" value="error" />
|
||||
<ElOption label="严重" value="critical" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.is_read" clearable placeholder="已读状态" @change="reload">
|
||||
<ElOption label="未读" :value="false" />
|
||||
<ElOption label="已读" :value="true" />
|
||||
</ElSelect>
|
||||
<ElButton @click="loadNotifications">刷新</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="notifications" row-key="id" class="notification-table">
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="scope">
|
||||
<span :class="['read-dot', { unread: !isRead(scope.row) }]" />
|
||||
{{ isRead(scope.row) ? '已读' : '未读' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="title" label="通知标题" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn label="分类" width="120">
|
||||
<template #default="scope">{{ getCategoryLabel(scope.row.category) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="严重级别" width="110">
|
||||
<template #default="scope">
|
||||
<ElTag :type="getSeverityType(scope.row.severity)">
|
||||
{{ getSeverityLabel(scope.row.severity) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="body" label="通知内容" min-width="300" show-overflow-tooltip />
|
||||
<ElTableColumn prop="read_at" label="已读时间" width="180" />
|
||||
<ElTableColumn prop="created_at" label="时间" width="180" />
|
||||
<ElTableColumn label="操作" width="100" fixed="right">
|
||||
<template #default="scope">
|
||||
<ElButton
|
||||
v-if="hasAuth(NOTIFICATION_PERMISSIONS.read)"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleNotificationClick(scope.row)"
|
||||
>
|
||||
查看
|
||||
</ElButton>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<ElPagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
@current-change="loadNotifications"
|
||||
@size-change="reload"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { NotificationService } from '@/api/modules'
|
||||
import type { NotificationItem, NotificationQueryParams } from '@/types/api'
|
||||
import { useNotificationStore } from '@/store/modules/notification'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
|
||||
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
|
||||
|
||||
defineOptions({ name: 'NotificationCenter' })
|
||||
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { hasAuth } = useAuth()
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const loading = ref(false)
|
||||
const markingAllRead = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const typeOptions = ref<string[]>([])
|
||||
const filters = reactive<NotificationQueryParams>({
|
||||
category: undefined,
|
||||
type: undefined,
|
||||
severity: undefined,
|
||||
is_read: undefined
|
||||
})
|
||||
|
||||
const isRead = (item: NotificationItem) => item.is_read
|
||||
|
||||
const getCategoryLabel = (category?: string | null) =>
|
||||
({
|
||||
balance: '余额预警',
|
||||
expiry: '临期提醒',
|
||||
expiring: '临期提醒',
|
||||
approval: '审批',
|
||||
sync: '同步/系统',
|
||||
system: '同步/系统'
|
||||
})[category || ''] ||
|
||||
category ||
|
||||
'-'
|
||||
|
||||
const getSeverityLabel = (severity?: string | null) =>
|
||||
({ info: '提示', warning: '警告', error: '严重', critical: '严重' })[severity || ''] ||
|
||||
severity ||
|
||||
'-'
|
||||
|
||||
const getSeverityType = (severity?: string | null) => {
|
||||
if (severity === 'error') return 'danger'
|
||||
if (severity === 'warning') return 'warning'
|
||||
if (severity === 'critical') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const loadNotifications = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await NotificationService.getNotifications({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
...filters
|
||||
})
|
||||
if (response.code !== 0 || !response.data) {
|
||||
ElMessage.error(response.msg || '获取通知列表失败')
|
||||
return
|
||||
}
|
||||
notifications.value = response.data.items || []
|
||||
total.value = response.data.total || 0
|
||||
typeOptions.value = Array.from(
|
||||
new Set(
|
||||
notifications.value
|
||||
.map((item) => item.type)
|
||||
.filter((type): type is string => Boolean(type))
|
||||
)
|
||||
)
|
||||
await notificationStore.refreshUnreadCount()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获取通知列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
page.value = 1
|
||||
void loadNotifications()
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
markingAllRead.value = true
|
||||
try {
|
||||
const response = await notificationStore.markAllRead()
|
||||
if (response.code !== 0) ElMessage.error(response.msg || '全部已读失败')
|
||||
else await loadNotifications()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '全部已读失败')
|
||||
} finally {
|
||||
markingAllRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (item: NotificationItem) => {
|
||||
try {
|
||||
const targetResponse = await NotificationService.getTarget(item.id)
|
||||
const readResponse = await notificationStore.markRead(item.id)
|
||||
if (readResponse.code !== 0) {
|
||||
ElMessage.error(readResponse.msg || '标记已读失败')
|
||||
return
|
||||
}
|
||||
if (targetResponse.code !== 0 || !targetResponse.data) {
|
||||
ElMessage.info(item.body || '该通知暂无可跳转目标')
|
||||
await loadNotifications()
|
||||
return
|
||||
}
|
||||
if (!navigateNotificationTarget(router, targetResponse.data)) {
|
||||
ElMessage.info(item.body || '该通知暂无可跳转目标')
|
||||
}
|
||||
await loadNotifications()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '处理通知失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadNotifications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notification-center-page {
|
||||
.page-header,
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.el-select {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.read-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
background: var(--el-border-color);
|
||||
border-radius: 50%;
|
||||
|
||||
&.unread {
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,446 +0,0 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="bulk-purchase-page" id="table-full-screen">
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>批量订购套餐</h2>
|
||||
<p>同一批次只能选择一种支付方式,系统按任务结果逐行处理订单。</p>
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
|
||||
tag="a"
|
||||
href="/templates/bulk-purchase-template.csv"
|
||||
download="批量订购套餐模板.csv"
|
||||
>
|
||||
下载模板
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
|
||||
<ElFormItem label="套餐 ID" prop="package_id">
|
||||
<ElInputNumber v-model="form.package_id" :min="1" controls-position="right" />
|
||||
</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="submitting || voucherUploading || orderFileUploading"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="
|
||||
voucherUploading
|
||||
? '支付凭证上传中,请稍候'
|
||||
: orderFileUploading
|
||||
? '订单文件上传中,请稍候'
|
||||
: '批量订购文件上传及任务创建中,请勿重复提交'
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitting || voucherUploading || orderFileUploading"
|
||||
:disabled="
|
||||
voucherUploading || orderFileUploading || !hasAuth(BULK_PURCHASE_PERMISSIONS.create)
|
||||
"
|
||||
@click="submitTask"
|
||||
>
|
||||
创建批量订购任务
|
||||
</ElButton>
|
||||
<ElButton v-if="taskDetail" @click="resetTask">重新创建</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
|
||||
<ElCard
|
||||
v-if="taskDetail && hasAuth(BULK_PURCHASE_PERMISSIONS.detail)"
|
||||
shadow="never"
|
||||
class="art-table-card task-card"
|
||||
>
|
||||
<template #header>
|
||||
<div class="task-header">
|
||||
<span>任务结果</span>
|
||||
<ElTag :type="getStatusType(taskDetail.status)">
|
||||
{{ taskDetail.status_name || getStatusName(taskDetail.status) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElDescriptions :column="4" border>
|
||||
<ElDescriptionsItem label="任务号">{{
|
||||
taskDetail.task_no || taskDetail.task_id
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="总数">{{ taskDetail.total_count ?? 0 }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="成功数">{{
|
||||
taskDetail.success_count ?? 0
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="失败数">{{ taskDetail.failed_count ?? 0 }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="总金额">{{
|
||||
formatAmount(taskDetail.total_amount)
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="成功金额">{{
|
||||
formatAmount(taskDetail.success_amount)
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="失败金额">{{
|
||||
formatAmount(taskDetail.failed_amount)
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="更新时间">{{
|
||||
formatDateTime(taskDetail.updated_at)
|
||||
}}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElAlert
|
||||
v-if="taskDetail.error_summary"
|
||||
class="task-error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="taskDetail.error_summary"
|
||||
/>
|
||||
|
||||
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="items-toolbar">
|
||||
<ElSelect v-model="itemStatus" clearable placeholder="筛选行状态" @change="reloadItems">
|
||||
<ElOption label="成功" value="success" />
|
||||
<ElOption label="失败" value="failed" />
|
||||
<ElOption label="处理中" value="processing" />
|
||||
</ElSelect>
|
||||
<ElButton @click="reloadItems">刷新结果</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)"
|
||||
v-loading="itemsLoading"
|
||||
:data="items"
|
||||
border
|
||||
>
|
||||
<ElTableColumn label="行号" width="90">
|
||||
<template #default="scope">{{
|
||||
scope.row.row_number ?? scope.row.line ?? '-'
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="资产" min-width="180">
|
||||
<template #default="scope">{{ getAssetIdentifier(scope.row) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="package_code" label="套餐编码" min-width="150" />
|
||||
<ElTableColumn label="状态" width="110">
|
||||
<template #default="scope">{{
|
||||
scope.row.status_name || scope.row.status || '-'
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="错误原因" min-width="240" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.error_summary || scope.row.error_reason || scope.row.error_code || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="pagination-wrapper">
|
||||
<ElPagination
|
||||
v-model:current-page="itemsPage"
|
||||
v-model:page-size="itemsSize"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="itemsTotal"
|
||||
@current-change="loadItems"
|
||||
@size-change="reloadItems"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElInputNumber,
|
||||
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 } 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: 'BulkPurchase' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const formRef = ref<FormInstance>()
|
||||
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const submitting = ref(false)
|
||||
const orderFileKeys = ref<string[]>([])
|
||||
const orderFileUploading = ref(false)
|
||||
const voucherFileKeys = ref<string[]>([])
|
||||
const voucherUploading = ref(false)
|
||||
const itemStatus = ref<string | undefined>()
|
||||
const items = ref<BulkPurchaseItem[]>([])
|
||||
const itemsLoading = ref(false)
|
||||
const itemsPage = ref(1)
|
||||
const itemsSize = ref(20)
|
||||
const itemsTotal = ref(0)
|
||||
const requestId = ref('')
|
||||
|
||||
const form = reactive<{
|
||||
package_id?: number
|
||||
payment_method: BulkPurchasePaymentMethod
|
||||
}>({
|
||||
package_id: undefined,
|
||||
payment_method: 'wallet'
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
package_id: [{ required: true, message: '请输入套餐 ID', 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 polling = useAsyncTaskPolling<BulkPurchaseTask>({
|
||||
storageKey: 'bulk-purchase-active-task',
|
||||
fetchTask: async (taskId) => {
|
||||
const res = await BulkPurchaseService.getTask(taskId)
|
||||
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取批量订购任务失败')
|
||||
return res.data
|
||||
}
|
||||
})
|
||||
const taskDetail = polling.task
|
||||
|
||||
const getRequestId = () => {
|
||||
if (requestId.value) return requestId.value
|
||||
requestId.value =
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
return requestId.value
|
||||
}
|
||||
|
||||
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)
|
||||
if (res.code !== 0 || !res.data?.task_id) {
|
||||
ElMessage.error(res.msg || '创建批量订购任务失败')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success(res.data.message || '批量订购任务已创建')
|
||||
requestId.value = ''
|
||||
await router.replace({
|
||||
path: route.path,
|
||||
query: { task_id: String(res.data.task_id) }
|
||||
})
|
||||
await polling.start(res.data.task_id)
|
||||
await loadItems()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '创建批量订购任务失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadItems = async () => {
|
||||
const taskId = taskDetail.value?.id || taskDetail.value?.task_id
|
||||
if (!taskId || !hasAuth(BULK_PURCHASE_PERMISSIONS.items)) return
|
||||
items.value = taskDetail.value?.items || []
|
||||
}
|
||||
|
||||
const reloadItems = () => {
|
||||
itemsPage.value = 1
|
||||
void loadItems()
|
||||
}
|
||||
|
||||
const resetTask = () => {
|
||||
polling.clear()
|
||||
requestId.value = ''
|
||||
items.value = []
|
||||
itemsTotal.value = 0
|
||||
itemsPage.value = 1
|
||||
router.replace({ path: route.path })
|
||||
}
|
||||
|
||||
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 formatAmount = (amount?: number) => (amount === undefined ? '-' : formatMoney(amount))
|
||||
|
||||
const getAssetIdentifier = (item: BulkPurchaseItem) =>
|
||||
item.asset_identifier || item.iccid || item.virtual_no || '-'
|
||||
|
||||
watch(taskDetail, () => {
|
||||
itemsPage.value = 1
|
||||
void loadItems()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const routeTaskId = Number(route.query.task_id)
|
||||
if (routeTaskId && routeTaskId !== polling.taskId.value) void polling.start(routeTaskId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-purchase-page {
|
||||
.page-header,
|
||||
.task-header,
|
||||
.form-actions,
|
||||
.items-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.items-toolbar {
|
||||
justify-content: flex-start;
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -274,7 +274,7 @@
|
||||
}}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="生效条件">
|
||||
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
|
||||
<ElSelect
|
||||
v-model="packageForm.expiry_base_override"
|
||||
placeholder="请选择生效条件"
|
||||
@@ -352,7 +352,8 @@
|
||||
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 {
|
||||
mergeGrantPackageCandidates,
|
||||
type GrantPackageCandidate
|
||||
@@ -362,6 +363,8 @@
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
@@ -378,11 +381,11 @@
|
||||
// 套餐表单
|
||||
const packageForm = ref<{
|
||||
package_id?: number
|
||||
allocation_id?: number
|
||||
package_name?: string
|
||||
package_code?: string
|
||||
original_cost_price?: number
|
||||
cost_price_yuan: number
|
||||
allocation_id?: number
|
||||
expiry_base_override: ExpiryBaseSelection
|
||||
initial_expiry_base_override: ExpiryBaseSelection
|
||||
default_expiry_base_name?: string | null
|
||||
@@ -509,6 +512,7 @@
|
||||
packageDialogType.value = 'add'
|
||||
packageForm.value = {
|
||||
package_id: undefined,
|
||||
allocation_id: undefined,
|
||||
package_name: undefined,
|
||||
package_code: undefined,
|
||||
original_cost_price: undefined,
|
||||
@@ -530,11 +534,11 @@
|
||||
packageDialogType.value = 'edit'
|
||||
packageForm.value = {
|
||||
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,
|
||||
allocation_id: row.allocation_id,
|
||||
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,
|
||||
@@ -619,8 +623,12 @@
|
||||
const expiryBaseChanged =
|
||||
packageForm.value.expiry_base_override !==
|
||||
packageForm.value.initial_expiry_base_override
|
||||
if (expiryBaseChanged && !packageForm.value.allocation_id) {
|
||||
ElMessage.error('该套餐分配记录缺少ID,无法更新生效条件')
|
||||
if (
|
||||
expiryBaseChanged &&
|
||||
(packageForm.value.allocation_id === undefined ||
|
||||
packageForm.value.allocation_id === null)
|
||||
) {
|
||||
ElMessage.error('该套餐分配记录缺少套餐分配 ID,无法修改生效条件')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,22 @@
|
||||
:marginTop="10"
|
||||
:pagination="false"
|
||||
:actions="getActions"
|
||||
:actionsWidth="150"
|
||||
:inlineActionsCount="2"
|
||||
:actionsWidth="240"
|
||||
:inlineActionsCount="3"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn prop="package_name" label="套餐名称" minWidth="150" />
|
||||
<ElTableColumn prop="package_code" label="套餐编码" minWidth="120" />
|
||||
<ElTableColumn
|
||||
prop="package_name"
|
||||
label="套餐名称"
|
||||
minWidth="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="package_code"
|
||||
label="套餐编码"
|
||||
minWidth="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="成本价" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
|
||||
@@ -81,16 +91,18 @@
|
||||
@closed="handlePackageDialogClosed"
|
||||
>
|
||||
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="140px">
|
||||
<ElFormItem label="选择套餐" prop="package_id" v-if="packageDialogType === 'add'">
|
||||
<ElFormItem label="选择套餐" prop="package_ids" v-if="packageDialogType === 'add'">
|
||||
<ElSelect
|
||||
v-model="packageForm.package_id"
|
||||
placeholder="请选择套餐"
|
||||
v-model="packageForm.package_ids"
|
||||
placeholder="请选择套餐(可多选)"
|
||||
style="width: 100%"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchAvailablePackages"
|
||||
:loading="packageLoading"
|
||||
clearable
|
||||
multiple
|
||||
:multiple-limit="100"
|
||||
>
|
||||
<template v-if="availablePackages.length === 0 && !packageLoading && seriesId">
|
||||
<ElOption disabled value="" label="该系列没有可选套餐" />
|
||||
@@ -105,6 +117,33 @@
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="packageDialogType === 'add' && packageForm.packages.length"
|
||||
label="套餐配置"
|
||||
prop="packages"
|
||||
>
|
||||
<div class="package-config-list">
|
||||
<div
|
||||
v-for="pkg in packageForm.packages"
|
||||
:key="pkg.package_id"
|
||||
class="package-config-item"
|
||||
>
|
||||
<div class="package-config-name" :title="pkg.package_name || ''">
|
||||
{{ pkg.package_name || '套餐名称不可用' }}
|
||||
</div>
|
||||
<ElInputNumber
|
||||
v-model="pkg.cost_price_yuan"
|
||||
:min="pkg.original_cost_price || 0"
|
||||
:max="getPackageCostPriceMax(pkg)"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
placeholder="成本价(元)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
|
||||
<span>{{ packageForm.package_name }}</span>
|
||||
</ElFormItem>
|
||||
@@ -127,7 +166,7 @@
|
||||
}}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="生效条件">
|
||||
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
|
||||
<ElSelect
|
||||
v-model="packageForm.expiry_base_override"
|
||||
placeholder="请选择生效条件"
|
||||
@@ -161,6 +200,49 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog
|
||||
v-model="expiryDialogVisible"
|
||||
title="修改生效条件"
|
||||
width="460px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<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="最终生效条件">
|
||||
{{ expiryForm.effective_expiry_base_name || '-' }}
|
||||
</ElFormItem>
|
||||
<div class="form-tip">仅影响后续新订单,不影响已购买套餐</div>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="expiryDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="expirySubmitLoading" @click="saveExpiryBase">
|
||||
保存
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
@@ -196,6 +278,7 @@
|
||||
PackageAllocationExpiryBaseOverride
|
||||
} from '@/types/api'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import {
|
||||
mergeGrantPackageCandidates,
|
||||
type GrantPackageCandidate
|
||||
@@ -206,6 +289,7 @@
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
@@ -221,33 +305,61 @@
|
||||
|
||||
const packageDialogVisible = ref(false)
|
||||
const packageDialogType = ref<'add' | 'edit'>('add')
|
||||
const expiryDialogVisible = ref(false)
|
||||
const expirySubmitLoading = ref(false)
|
||||
|
||||
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
|
||||
|
||||
type PackageFormState = {
|
||||
package_id?: number
|
||||
type PackageCostFormItem = {
|
||||
package_id: number
|
||||
package_name?: string
|
||||
package_code?: string
|
||||
cost_price_yuan: number
|
||||
original_cost_price?: number
|
||||
suggested_retail_price?: number
|
||||
}
|
||||
|
||||
type PackageFormState = {
|
||||
package_id?: number
|
||||
allocation_id?: number
|
||||
package_ids: number[]
|
||||
package_name?: string
|
||||
package_code?: string
|
||||
cost_price_yuan: number
|
||||
original_cost_price?: number
|
||||
suggested_retail_price?: number
|
||||
expiry_base_override: ExpiryBaseSelection
|
||||
initial_expiry_base_override: ExpiryBaseSelection
|
||||
default_expiry_base_name?: string | null
|
||||
expiry_base_override_name?: string | null
|
||||
effective_expiry_base_name?: string | null
|
||||
packages: PackageCostFormItem[]
|
||||
}
|
||||
|
||||
type ExpiryFormState = {
|
||||
package_id?: number
|
||||
allocation_id?: number
|
||||
package_name?: string
|
||||
package_code?: string
|
||||
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 => ({
|
||||
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'
|
||||
initial_expiry_base_override: 'default',
|
||||
packages: []
|
||||
})
|
||||
const packageForm = ref<PackageFormState>(createDefaultPackageForm())
|
||||
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
|
||||
|
||||
const assignPackageForm = (values: Partial<PackageFormState> = {}) => {
|
||||
Object.assign(packageForm.value, createDefaultPackageForm(), values)
|
||||
@@ -271,11 +383,30 @@
|
||||
}
|
||||
|
||||
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' },
|
||||
{
|
||||
required: packageDialogType.value === 'edit',
|
||||
message: '请输入成本价',
|
||||
trigger: 'blur'
|
||||
},
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
@@ -294,6 +425,14 @@
|
||||
const getActions = (row: GrantPackageInfo) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (canUpdateExpiryBase) {
|
||||
actions.push({
|
||||
label: '修改生效条件',
|
||||
handler: () => showExpiryBaseDialog(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
|
||||
if (hasAuth('series_grants:edit_packages_list')) {
|
||||
actions.push({
|
||||
label: '编辑',
|
||||
@@ -338,7 +477,7 @@
|
||||
|
||||
const showAddPackageDialog = () => {
|
||||
packageDialogType.value = 'add'
|
||||
assignPackageForm()
|
||||
assignPackageForm({ package_ids: [], packages: [] })
|
||||
loadAvailablePackages()
|
||||
packageDialogVisible.value = true
|
||||
nextTick(() => packageFormRef.value?.clearValidate())
|
||||
@@ -350,12 +489,12 @@
|
||||
|
||||
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,
|
||||
allocation_id: row.allocation_id,
|
||||
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,
|
||||
@@ -366,25 +505,43 @@
|
||||
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) => {
|
||||
if (!seriesId.value) return
|
||||
|
||||
packageLoading.value = true
|
||||
try {
|
||||
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)
|
||||
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 {
|
||||
@@ -400,6 +557,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => packageForm.value.package_id,
|
||||
(packageId) => {
|
||||
@@ -436,19 +637,51 @@
|
||||
}
|
||||
const costPrice = Math.round(currentCostPriceYuan * 100)
|
||||
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: [{ package_id: packageForm.value.package_id!, cost_price: costPrice }]
|
||||
packages
|
||||
})
|
||||
} else {
|
||||
const expiryBaseChanged =
|
||||
packageForm.value.expiry_base_override !==
|
||||
packageForm.value.initial_expiry_base_override
|
||||
if (expiryBaseChanged && !packageForm.value.allocation_id) {
|
||||
ElMessage.error('该套餐分配记录缺少ID,无法更新生效条件')
|
||||
if (
|
||||
expiryBaseChanged &&
|
||||
(packageForm.value.allocation_id === undefined ||
|
||||
packageForm.value.allocation_id === null)
|
||||
) {
|
||||
ElMessage.error('该套餐分配记录缺少套餐分配 ID,无法修改生效条件')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -477,13 +710,39 @@
|
||||
packageDialogVisible.value = false
|
||||
await fetchPackageList()
|
||||
} catch (error) {
|
||||
console.error(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) => {
|
||||
import('element-plus').then(({ ElMessageBox }) => {
|
||||
ElMessageBox.confirm(`确定删除套餐 ${row.package_name} 的授权吗?`, '删除确认', {
|
||||
@@ -572,6 +831,38 @@
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.package-config-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.package-config-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.package-config-name,
|
||||
.ellipsis-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-config-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ellipsis-value {
|
||||
display: block;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -44,16 +44,13 @@
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<ElForm label-width="120px">
|
||||
<ElFormItem label="配置 Key">
|
||||
<ElInput :model-value="currentConfig?.config_key || '-'" disabled />
|
||||
</ElFormItem>
|
||||
<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">
|
||||
{{ method }}
|
||||
{{ paymentMethodLabels[method] }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
|
||||
@@ -89,7 +86,6 @@
|
||||
placeholder="请输入配置值"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<div v-if="valueHint" class="config-hint">{{ valueHint }}</div>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
@@ -103,6 +99,7 @@
|
||||
|
||||
<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'
|
||||
@@ -120,6 +117,7 @@
|
||||
|
||||
defineOptions({ name: 'SystemConfigs' })
|
||||
const { hasAuth } = useAuth()
|
||||
const route = useRoute()
|
||||
|
||||
const moduleOptions = [
|
||||
{ label: '运营商回调配置', value: 'carrier_callback' },
|
||||
@@ -149,14 +147,18 @@
|
||||
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: '配置 Key', prop: 'config_key' },
|
||||
{ label: '模块', prop: 'module' },
|
||||
{ label: '配置说明', prop: 'description' },
|
||||
{ label: '配置值', prop: 'value' },
|
||||
{ label: '控件', prop: 'control' },
|
||||
{ label: '注册状态', prop: 'registered' },
|
||||
{ label: '只读', prop: 'readonly' },
|
||||
{ label: '敏感', prop: 'sensitive' },
|
||||
@@ -167,7 +169,6 @@
|
||||
moduleOptions.find((item) => item.value === module)?.label || module
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{ prop: 'config_key', label: '配置 Key', minWidth: 220, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'module',
|
||||
label: '模块',
|
||||
@@ -178,10 +179,10 @@
|
||||
{
|
||||
prop: 'value',
|
||||
label: '配置值',
|
||||
minWidth: 150,
|
||||
formatter: (row: SystemConfigItem) => row.value || '-'
|
||||
minWidth: 260,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: SystemConfigItem) => formatConfigValue(row)
|
||||
},
|
||||
{ prop: 'control', label: '控件', width: 100 },
|
||||
{
|
||||
prop: 'registered',
|
||||
label: '注册状态',
|
||||
@@ -216,7 +217,8 @@
|
||||
)
|
||||
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]
|
||||
currentConfig.value
|
||||
?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
|
||||
)
|
||||
)
|
||||
const isIntegerConfig = computed(
|
||||
@@ -233,14 +235,18 @@
|
||||
!isBooleanConfig.value && !isIntegerConfig.value && currentConfig.value?.value_type === 'json'
|
||||
)
|
||||
|
||||
const valueHint = computed(() => {
|
||||
if (!currentConfig.value) return ''
|
||||
const { min, max } = currentConfig.value
|
||||
if (min !== null && max !== null) return `取值范围:${min}~${max}`
|
||||
if (min !== null) return `最小值:${min}`
|
||||
if (max !== null) return `最大值:${max}`
|
||||
return ''
|
||||
})
|
||||
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)
|
||||
@@ -293,6 +299,12 @@
|
||||
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)
|
||||
@@ -370,10 +382,5 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-configs-page {
|
||||
.config-hint {
|
||||
margin: -12px 0 0 120px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<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">
|
||||
<ElInputNumber
|
||||
v-model="form.agent_id"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</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"
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
:loading="testing"
|
||||
@click="testApplication"
|
||||
>
|
||||
测试连接
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
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 { JULY_PERMISSIONS } from '@/config/constants'
|
||||
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: 0,
|
||||
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: 'change' }],
|
||||
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: 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: 0,
|
||||
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: 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;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.application-form {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,253 +0,0 @@
|
||||
<template>
|
||||
<div class="wecom-application-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">{{ isEdit ? '编辑企微应用' : '新增企微应用' }}</div>
|
||||
<div class="page-description"
|
||||
>应用凭据由平台管理员维护,保存后可在应用列表测试连接。</div
|
||||
>
|
||||
</div>
|
||||
<ElButton @click="goBack">返回应用列表</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
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">
|
||||
<ElInputNumber
|
||||
v-model="form.agent_id"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</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 :xs="24" :sm="12">
|
||||
<ElFormItem label="回调 Token" prop="callback_token">
|
||||
<ElInput v-model="form.callback_token" placeholder="请输入回调 Token" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12">
|
||||
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
|
||||
<ElInput v-model="form.encoding_aes_key" placeholder="请输入 43 位 EncodingAESKey" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElFormItem label="启用状态">
|
||||
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
|
||||
</ElFormItem>
|
||||
|
||||
<div class="form-actions">
|
||||
<ElButton @click="goBack">取消</ElButton>
|
||||
<ElButton
|
||||
v-if="isEdit"
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
:loading="testing"
|
||||
@click="testApplication"
|
||||
>
|
||||
测试连接
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="saveApplication"
|
||||
>
|
||||
保存应用
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { WecomService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import type { WecomApplication } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'WecomApplicationDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const applicationId = computed(() => Number(route.params.id) || 0)
|
||||
const isEdit = computed(() => applicationId.value > 0)
|
||||
const form = reactive({
|
||||
corp_id: '',
|
||||
agent_id: 0,
|
||||
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: 'change' }],
|
||||
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
||||
secret: [{ required: true, message: '请输入 Secret', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const fillForm = (application?: WecomApplication) => {
|
||||
Object.assign(
|
||||
form,
|
||||
application
|
||||
? {
|
||||
corp_id: application.corp_id,
|
||||
agent_id: 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: 0,
|
||||
name: '',
|
||||
secret: '',
|
||||
callback_token: '',
|
||||
encoding_aes_key: '',
|
||||
enabled: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const loadApplication = async () => {
|
||||
if (!isEdit.value) {
|
||||
fillForm()
|
||||
return
|
||||
}
|
||||
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
|
||||
if (response.code === 0) {
|
||||
const application = response.data.items?.find((item) => item.id === applicationId.value)
|
||||
if (application) fillForm(application)
|
||||
else {
|
||||
ElMessage.error('未找到对应的企微应用')
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: 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('企微应用保存成功')
|
||||
await router.push(RoutesAlias.WecomApplications)
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testApplication = async () => {
|
||||
if (!applicationId.value) return
|
||||
testing.value = true
|
||||
try {
|
||||
const response = await WecomService.testApplication(applicationId.value)
|
||||
if (response.code === 0) {
|
||||
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
|
||||
else ElMessage.warning('连接失败,请检查应用凭据')
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => void router.push(RoutesAlias.WecomApplications)
|
||||
|
||||
onMounted(() => void loadApplication())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-application-detail-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.application-form {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,107 +1,128 @@
|
||||
<template>
|
||||
<div class="wecom-applications-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">企微应用管理</div>
|
||||
<div class="page-description">管理企业微信应用凭据,并查看连接和默认发起人状态。</div>
|
||||
</div>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
type="primary"
|
||||
@click="goToCreate"
|
||||
>
|
||||
新增应用
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTable v-loading="loading" :data="applications" border>
|
||||
<ElTableColumn prop="name" label="应用名称" min-width="160" show-overflow-tooltip />
|
||||
<ElTableColumn label="凭据状态" width="110">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.credentials_set ? 'success' : 'warning'" size="small">
|
||||
{{ scope.row.credentials_set ? '已完整' : '未完整' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="启用状态" width="100">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status_name }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="默认发起人" min-width="150">
|
||||
<template #default="scope">{{ scope.row.default_creator_name || '未设置' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="最近连接" min-width="170">
|
||||
<template #default="scope">{{ formatDate(scope.row.last_connected_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="300" fixed="right">
|
||||
<template #default="scope">
|
||||
<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.application"
|
||||
link
|
||||
type="primary"
|
||||
@click="testApplication(scope.row.id)"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
测试连接
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
link
|
||||
type="primary"
|
||||
@click="goToMembers(scope.row.id)"
|
||||
>
|
||||
成员管理
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
link
|
||||
@click="goToDetail(scope.row.id)"
|
||||
>
|
||||
编辑配置
|
||||
新增应用
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ArtTableHeader>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<ElPagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="applications"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:margin-top="10"
|
||||
:actions="getActions"
|
||||
:actions-width="160"
|
||||
:inline-actions-count="1"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="loadApplications"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
>
|
||||
<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 { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 {
|
||||
@@ -124,8 +145,20 @@
|
||||
void loadApplications()
|
||||
}
|
||||
|
||||
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`)
|
||||
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`)
|
||||
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) } })
|
||||
|
||||
@@ -138,34 +171,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
const getActions = (row: WecomApplication) => {
|
||||
const actions: any[] = []
|
||||
if (hasAuth(JULY_PERMISSIONS.wecom.application)) {
|
||||
actions.push({
|
||||
label: '测试连接',
|
||||
handler: () => testApplication(row.id),
|
||||
type: 'primary'
|
||||
})
|
||||
actions.push({
|
||||
label: '编辑配置',
|
||||
handler: () => openEditDialog(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (hasAuth(JULY_PERMISSIONS.wecom.member)) {
|
||||
actions.push({
|
||||
label: '成员管理',
|
||||
handler: () => goToMembers(row.id),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
onMounted(() => void loadApplications())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-applications-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -1,167 +1,94 @@
|
||||
<template>
|
||||
<div class="wecom-members-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">企微成员管理</div>
|
||||
<div class="page-description"
|
||||
>同步应用可见成员,设置默认审批发起人,并完成平台账号绑定。</div
|
||||
>
|
||||
</div>
|
||||
<ElButton @click="goToApplications">返回应用列表</ElButton>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
|
||||
<ElForm inline class="search-form" @submit.prevent>
|
||||
<ElFormItem label="企微应用">
|
||||
<ElSelect
|
||||
v-model="selectedApplicationId"
|
||||
filterable
|
||||
class="application-select"
|
||||
placeholder="请选择企微应用"
|
||||
@change="handleApplicationChange"
|
||||
>
|
||||
<ElOption
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
:label="application.name"
|
||||
:value="application.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="成员搜索">
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="姓名"
|
||||
class="keyword-input"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem>
|
||||
<ElButton type="primary" @click="handleSearch">查询</ElButton>
|
||||
<ElButton @click="handleReset">重置</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="selected-application">
|
||||
当前应用:<span>{{ selectedApplication?.name || '未选择' }}</span>
|
||||
<ElTag v-if="selectedApplication?.default_creator_name" type="success" size="small">
|
||||
默认发起人:{{ selectedApplication.default_creator_name }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
:disabled="!selectedApplicationId"
|
||||
:loading="syncing"
|
||||
@click="syncMembers"
|
||||
>
|
||||
同步成员
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="members"
|
||||
row-key="userid"
|
||||
highlight-current-row
|
||||
border
|
||||
@row-click="selectMember"
|
||||
>
|
||||
<ElTableColumn label="选择" width="70" align="center">
|
||||
<template #default="scope">
|
||||
<ElRadio v-model="selectedUserid" :label="scope.row.userid">
|
||||
<span class="sr-only">选择 {{ scope.row.name }}</span>
|
||||
</ElRadio>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="name" label="成员姓名" min-width="150" />
|
||||
<ElTableColumn prop="synced_at" label="同步时间" min-width="180" />
|
||||
<ElTableColumn label="操作" width="230" fixed="right">
|
||||
<template #default="scope">
|
||||
<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.member"
|
||||
link
|
||||
:disabled="!selectedApplicationId"
|
||||
:loading="syncing"
|
||||
type="primary"
|
||||
@click.stop="setDefaultCreator(scope.row)"
|
||||
@click="syncMembers"
|
||||
>
|
||||
设为默认发起人
|
||||
同步成员
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.binding"
|
||||
link
|
||||
@click.stop="selectMember(scope.row)"
|
||||
>
|
||||
绑定账号
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElButton @click="goToApplications">返回应用列表</ElButton>
|
||||
</div>
|
||||
</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>
|
||||
<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.member"
|
||||
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>
|
||||
|
||||
<ElCard shadow="never" class="operation-card">
|
||||
<template #header>当前成员操作</template>
|
||||
<ElAlert
|
||||
v-if="selectedMember"
|
||||
:title="`已选择:${selectedMember.name}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElEmpty v-else description="请先在上方列表选择成员" :image-size="70" />
|
||||
<div class="member-card__info">
|
||||
<span class="member-card__label">同步时间</span>
|
||||
<span>{{ formatDateTime(member.synced_at) }}</span>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="operation-row">
|
||||
<span class="operation-label">绑定平台账号</span>
|
||||
<ElInputNumber
|
||||
v-model="bindingAccountId"
|
||||
:min="1"
|
||||
:disabled="!selectedMember"
|
||||
controls-position="right"
|
||||
placeholder="账号 ID"
|
||||
/>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.binding"
|
||||
type="primary"
|
||||
:disabled="!selectedMember || !bindingAccountId"
|
||||
:loading="binding"
|
||||
@click="bindAccount"
|
||||
>
|
||||
绑定账号
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
:disabled="!selectedMember"
|
||||
:loading="savingDefault"
|
||||
@click="saveSelectedDefaultCreator"
|
||||
>
|
||||
保存为默认发起人
|
||||
</ElButton>
|
||||
</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 } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AccountService, WecomService } from '@/api/modules'
|
||||
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' })
|
||||
|
||||
@@ -170,30 +97,65 @@
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const savingDefault = ref(false)
|
||||
const binding = ref(false)
|
||||
const pendingDefaultUserid = ref('')
|
||||
const applications = ref<WecomApplication[]>([])
|
||||
const members = ref<WecomMember[]>([])
|
||||
const selectedApplicationId = ref<number>()
|
||||
const selectedUserid = ref('')
|
||||
const bindingAccountId = ref<number>()
|
||||
const keyword = ref('')
|
||||
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 selectedMember = computed(() =>
|
||||
members.value.find((member) => member.userid === selectedUserid.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)
|
||||
selectedApplicationId.value =
|
||||
applications.value.find((application) => application.id === queryApplicationId)?.id ||
|
||||
applications.value[0]?.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 () => {
|
||||
@@ -207,33 +169,25 @@
|
||||
const response = await WecomService.getMembers(selectedApplicationId.value, {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
keyword: keyword.value.trim() || undefined
|
||||
keyword: searchForm.keyword.trim() || undefined
|
||||
})
|
||||
if (response.code === 0) {
|
||||
members.value = response.data.items || []
|
||||
pagination.total = response.data.total || 0
|
||||
if (!members.value.some((member) => member.userid === selectedUserid.value)) {
|
||||
selectedUserid.value = ''
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplicationChange = () => {
|
||||
pagination.page = 1
|
||||
selectedUserid.value = ''
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
keyword.value = ''
|
||||
searchForm.keyword = ''
|
||||
searchForm.application_id = applications.value[0]?.id
|
||||
pagination.page = 1
|
||||
void loadMembers()
|
||||
}
|
||||
@@ -244,10 +198,6 @@
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const selectMember = (member: WecomMember) => {
|
||||
selectedUserid.value = member.userid
|
||||
}
|
||||
|
||||
const syncMembers = async () => {
|
||||
if (!selectedApplicationId.value) return
|
||||
syncing.value = true
|
||||
@@ -263,49 +213,40 @@
|
||||
}
|
||||
|
||||
const setDefaultCreator = async (member: WecomMember) => {
|
||||
if (!selectedApplicationId.value) return
|
||||
selectedUserid.value = member.userid
|
||||
await saveSelectedDefaultCreator()
|
||||
}
|
||||
if (!selectedApplicationId.value || savingDefault.value) return
|
||||
|
||||
const saveSelectedDefaultCreator = async () => {
|
||||
if (!selectedApplicationId.value || !selectedMember.value) {
|
||||
ElMessage.warning('请选择要设置的成员')
|
||||
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,
|
||||
selectedMember.value.userid
|
||||
member.userid
|
||||
)
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('默认发起人已保存')
|
||||
const application = selectedApplication.value
|
||||
if (application) {
|
||||
application.default_creator_userid = selectedMember.value.userid
|
||||
application.default_creator_name = selectedMember.value.name
|
||||
application.default_creator_userid = member.userid
|
||||
application.default_creator_name = member.name
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
savingDefault.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const bindAccount = async () => {
|
||||
if (!selectedApplicationId.value || !selectedMember.value || !bindingAccountId.value) {
|
||||
ElMessage.warning('请选择成员并输入账号 ID')
|
||||
return
|
||||
}
|
||||
binding.value = true
|
||||
try {
|
||||
const response = await AccountService.bindWecom(bindingAccountId.value, {
|
||||
application_id: selectedApplicationId.value,
|
||||
userid: selectedMember.value.userid
|
||||
})
|
||||
if (response.code === 0) ElMessage.success('账号企微绑定成功')
|
||||
} finally {
|
||||
binding.value = false
|
||||
pendingDefaultUserid.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,105 +260,152 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-members-page {
|
||||
.page-header,
|
||||
.toolbar,
|
||||
.operation-row {
|
||||
.members-card {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(> .el-card__body) {
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
height: auto;
|
||||
min-height: 108px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.member-card__header,
|
||||
.member-card__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.toolbar {
|
||||
.member-card__header {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
.member-card__identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.member-card__avatar {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
.member-card__name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-card__info {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
|
||||
span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-top: 4px;
|
||||
padding: 14px 16px 0;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
.member-card__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.application-select {
|
||||
width: 280px;
|
||||
}
|
||||
.member-card__action-tag {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.keyword-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin: 20px 0 12px;
|
||||
|
||||
.selected-application {
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
span {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
margin-left: 10px;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.operation-card {
|
||||
margin-top: 16px;
|
||||
@media (max-width: 1400px) {
|
||||
.member-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.operation-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
@media (max-width: 1024px) {
|
||||
.member-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.operation-label {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header,
|
||||
.toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
@media (max-width: 640px) {
|
||||
.member-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-select,
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
.members-toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +1,240 @@
|
||||
<template>
|
||||
<div class="wecom-scenes-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">企微审批场景</div>
|
||||
<div class="page-description"
|
||||
>分别维护退款和线下代充值审批模板,并提交后由后端校验控件映射。</div
|
||||
<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.scene"
|
||||
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>
|
||||
<ElButton v-permission="JULY_PERMISSIONS.wecom.scene" type="primary" @click="createScene">
|
||||
新增场景配置
|
||||
|
||||
<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
|
||||
v-permission="JULY_PERMISSIONS.wecom.scene"
|
||||
: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 class="mapping-field mapping-field--option">
|
||||
<span class="mapping-field__label">选择项映射</span>
|
||||
<ElInput v-model="mapping.option_mapping" placeholder="JSON,可为空对象" />
|
||||
</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
|
||||
v-permission="JULY_PERMISSIONS.wecom.scene"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="saveScene"
|
||||
>
|
||||
保存并校验
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="scene-layout">
|
||||
<div class="scene-list">
|
||||
<div class="section-heading">场景配置列表</div>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="scenes"
|
||||
row-key="business_type"
|
||||
highlight-current-row
|
||||
border
|
||||
@row-click="selectScene"
|
||||
>
|
||||
<ElTableColumn prop="business_type_name" label="业务类型" min-width="150" />
|
||||
<ElTableColumn
|
||||
prop="template_name"
|
||||
label="模板名称"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status_name }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="last_verified_at" label="最近校验" min-width="170" />
|
||||
<ElTableColumn label="操作" width="90" fixed="right">
|
||||
<template #default="scope">
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.scene"
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="selectScene(scope.row)"
|
||||
>
|
||||
编辑
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="editor-card">
|
||||
<template #header>
|
||||
<div class="editor-header">
|
||||
<span>{{ editing ? '编辑审批场景' : '新增审批场景' }}</span>
|
||||
<ElTag v-if="editing" size="small" type="info">{{ sceneForm.business_type }}</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm :model="sceneForm" label-width="110px">
|
||||
<ElFormItem label="业务类型" required>
|
||||
<ElSelect v-model="sceneForm.business_type" :disabled="editing" class="full-width">
|
||||
<ElOption label="退款审批" value="refund_approval" />
|
||||
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<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>
|
||||
<ElFormItem label="模板 ID" required>
|
||||
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用状态">
|
||||
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElDivider content-position="left">控件映射</ElDivider>
|
||||
<div class="mapping-heading">
|
||||
<span>业务字段与模板控件的对应关系</span>
|
||||
<ElButton text type="primary" @click="addMapping()">新增映射</ElButton>
|
||||
</div>
|
||||
<div v-if="mappingRows.length" class="mapping-list">
|
||||
<div v-for="(mapping, index) in mappingRows" :key="mapping.key" class="mapping-row">
|
||||
<ElInput v-model="mapping.business_field" placeholder="业务字段,如 refund_no" />
|
||||
<ElInput v-model="mapping.control_id" placeholder="控件 ID" />
|
||||
<ElInput v-model="mapping.control_type" placeholder="控件类型,如 Text" />
|
||||
<ElInput
|
||||
v-model="mapping.option_mapping"
|
||||
placeholder="选择项映射 JSON,可为空对象"
|
||||
/>
|
||||
<ElButton text type="danger" @click="removeMapping(index)">删除</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
|
||||
|
||||
<div class="editor-actions">
|
||||
<ElButton @click="createScene">清空表单</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.scene"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="saveScene"
|
||||
>
|
||||
保存并校验
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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
|
||||
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,
|
||||
@@ -162,19 +244,69 @@
|
||||
const mappingRows = ref<MappingEditor[]>([])
|
||||
const editing = computed(() => Boolean(selectedBusinessType.value))
|
||||
|
||||
const addMapping = (mapping?: Partial<MappingEditor>) => {
|
||||
mappingRows.value.push({
|
||||
key: nextMappingKey.value++,
|
||||
business_field: mapping?.business_field || '',
|
||||
control_id: mapping?.control_id || '',
|
||||
control_type: mapping?.control_type || '',
|
||||
option_mapping: mapping?.option_mapping || '{}'
|
||||
})
|
||||
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 removeMapping = (index: number) => mappingRows.value.splice(index, 1)
|
||||
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, {
|
||||
@@ -184,7 +316,6 @@
|
||||
enabled: true
|
||||
})
|
||||
mappingRows.value = []
|
||||
addMapping()
|
||||
return
|
||||
}
|
||||
selectedBusinessType.value = scene.business_type
|
||||
@@ -197,41 +328,124 @@
|
||||
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 selectScene = (scene: WecomScene) => fillEditor(scene)
|
||||
const createScene = () => fillEditor()
|
||||
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: 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 || []
|
||||
const current = scenes.value.find(
|
||||
(scene) => scene.business_type === selectedBusinessType.value
|
||||
)
|
||||
fillEditor(current || scenes.value[0])
|
||||
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 && !mapping.control_id && !mapping.control_type) continue
|
||||
if (!mapping.business_field || !mapping.control_id || !mapping.control_type) {
|
||||
ElMessage.warning('请完整填写控件映射字段')
|
||||
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>
|
||||
@@ -271,6 +485,7 @@
|
||||
})
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('场景保存并校验成功')
|
||||
editorVisible.value = false
|
||||
await loadData()
|
||||
}
|
||||
} finally {
|
||||
@@ -278,98 +493,164 @@
|
||||
}
|
||||
}
|
||||
|
||||
const getActions = (row: WecomScene) => {
|
||||
if (!hasAuth(JULY_PERMISSIONS.wecom.scene)) return []
|
||||
return [
|
||||
{
|
||||
label: '编辑',
|
||||
handler: () => editScene(row),
|
||||
type: 'primary' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
onMounted(() => void loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-scenes-page {
|
||||
.page-header,
|
||||
.editor-header,
|
||||
.mapping-heading,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-editor-form {
|
||||
padding-bottom: 12px;
|
||||
|
||||
.dialog-section {
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.editor-header,
|
||||
.mapping-heading {
|
||||
.dialog-section + .dialog-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.dialog-section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
.dialog-section__title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
.dialog-section__description {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scene-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr);
|
||||
gap: 16px;
|
||||
.mapping-tip {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
.template-name {
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
.mapping-section {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.mapping-heading {
|
||||
margin-bottom: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
max-height: 420px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
.mapping-row__grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(120px, 1.1fr) minmax(140px, 1.3fr) minmax(120px, 1fr) minmax(100px, 0.8fr)
|
||||
minmax(140px, 1.2fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mapping-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mapping-field__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.scene-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
.dialog-section {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
.dialog-section__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mapping-tip {
|
||||
align-self: flex-start;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.mapping-row__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mapping-field--option {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.mapping-row__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mapping-field--option {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.wecom-scene-dialog) {
|
||||
width: calc(100vw - 32px) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user