This commit is contained in:
@@ -9,6 +9,14 @@
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<ElButton
|
||||
v-if="cardInfo?.asset_type === 'card' && hasAuth(JULY_PERMISSIONS.speedTier.view)"
|
||||
type="primary"
|
||||
link
|
||||
@click="emit('showSpeedLimit')"
|
||||
>
|
||||
设置限速
|
||||
</ElButton>
|
||||
<ElTooltip content="开启后系统将自动轮询更新资产状态" placement="top">
|
||||
<div v-if="canShowPolling" class="polling-switch-wrapper">
|
||||
<span class="polling-label">自动轮询</span>
|
||||
@@ -194,33 +202,6 @@
|
||||
</template>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElDivider content-position="left">同步状态</ElDivider>
|
||||
<ElDescriptions :column="descriptionsColumn" border>
|
||||
<ElDescriptionsItem label="轮询状态">
|
||||
<ElTag
|
||||
v-if="cardInfo?.polling"
|
||||
:type="cardInfo.polling.enabled ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ cardInfo.polling.enabled ? '已启用' : '未启用' }}
|
||||
</ElTag>
|
||||
<span v-else>-</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最后活跃时间">
|
||||
{{ formatDateTime(cardInfo?.polling?.last_activity_at) || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="同步轨迹">
|
||||
<ElButton
|
||||
v-permission="'asset_info:view_sync_trail'"
|
||||
type="primary"
|
||||
link
|
||||
@click="emit('viewSyncTrail')"
|
||||
>
|
||||
查看同步轨迹
|
||||
</ElButton>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<template v-if="previousExchangeAsset || nextExchangeAsset">
|
||||
<ElDivider content-position="left">换货链路</ElDivider>
|
||||
<ElDescriptions :column="descriptionsColumn" border>
|
||||
@@ -596,6 +577,7 @@
|
||||
} from '@/types/api'
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
@@ -798,7 +780,6 @@
|
||||
(e: 'navigateToCard', iccid: string): void
|
||||
(e: 'navigateToDevice', deviceNo: string): void
|
||||
(e: 'navigateToAsset', identifier: string): void
|
||||
(e: 'viewSyncTrail'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
@@ -336,7 +337,11 @@
|
||||
data.payment_voucher_key = toVoucherKeyList(form.payment_voucher_key)
|
||||
}
|
||||
|
||||
await OrderService.createOrder(data)
|
||||
const response = await OrderService.createOrder(data)
|
||||
if (response.code !== 0) {
|
||||
ElMessage.error(response.msg || '订单创建失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.payment_method === 'wallet') {
|
||||
ElMessage.success('订单创建成功,已自动完成支付')
|
||||
@@ -352,6 +357,7 @@
|
||||
return
|
||||
}
|
||||
console.error('创建订单失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="设置限速" width="500px">
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<ElFormItem label="下行速率" prop="download_speed">
|
||||
<ElInputNumber
|
||||
v-model="form.download_speed"
|
||||
:min="1"
|
||||
:step="128"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="上行速率" prop="upload_speed">
|
||||
<ElInputNumber
|
||||
v-model="form.upload_speed"
|
||||
:min="1"
|
||||
:step="128"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
|
||||
<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>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
@@ -31,7 +16,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElDialog, ElForm, ElFormItem, ElInputNumber, ElButton } from 'element-plus'
|
||||
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
@@ -40,7 +25,7 @@
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
|
||||
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -49,14 +34,23 @@
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
download_speed: 1024,
|
||||
upload_speed: 512
|
||||
})
|
||||
const speedTiers = [
|
||||
{ code: -1, label: '不限速' },
|
||||
{ code: 0, label: '0kbps' },
|
||||
{ code: 1, label: '128Kbps' },
|
||||
{ code: 2, label: '512Kbps' },
|
||||
{ code: 3, label: '1Mbps' },
|
||||
{ code: 4, label: '2Mbps' },
|
||||
{ code: 5, label: '10Mbps' },
|
||||
{ code: 6, label: '20Mbps' },
|
||||
{ code: 7, label: '50Mbps' },
|
||||
{ code: 8, label: '100Mbps' }
|
||||
] as const
|
||||
|
||||
const form = reactive({ code: 3 as (typeof speedTiers)[number]['code'] })
|
||||
|
||||
const rules: FormRules = {
|
||||
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
|
||||
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
|
||||
code: [{ required: true, message: '请选择限速档位', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const visible = computed({
|
||||
@@ -67,8 +61,7 @@
|
||||
// 监听对话框打开,重置表单
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
form.download_speed = 1024
|
||||
form.upload_speed = 512
|
||||
form.code = 3
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,8 +75,7 @@
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
emit('confirm', {
|
||||
download_speed: form.download_speed,
|
||||
upload_speed: form.upload_speed
|
||||
code: form.code
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
|
||||
@@ -16,7 +16,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
const manualDeactivateCardLoading = ref(false)
|
||||
const rebootDeviceLoading = ref(false)
|
||||
const resetDeviceLoading = ref(false)
|
||||
const speedLimitLoading = ref(false)
|
||||
const switchCardLoading = ref(false)
|
||||
const setWiFiLoading = ref(false)
|
||||
const pollingLoading = ref(false)
|
||||
@@ -181,35 +180,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set device speed limit
|
||||
*/
|
||||
const setSpeedLimit = async (form: { download_speed: number; upload_speed: number }) => {
|
||||
try {
|
||||
speedLimitLoading.value = true
|
||||
const res = await DeviceService.setSpeedLimit(cardInfo.value.imei, {
|
||||
download_speed: form.download_speed,
|
||||
upload_speed: form.upload_speed
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('限速设置成功')
|
||||
if (refreshAssetFn) {
|
||||
await refreshAssetFn()
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
ElMessage.error(res.msg || '设置失败')
|
||||
return false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('设置限速失败:', error)
|
||||
console.log(error?.message || '设置失败')
|
||||
return false
|
||||
} finally {
|
||||
speedLimitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to different card
|
||||
*/
|
||||
@@ -320,7 +290,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
manualDeactivateCardLoading,
|
||||
rebootDeviceLoading,
|
||||
resetDeviceLoading,
|
||||
speedLimitLoading,
|
||||
switchCardLoading,
|
||||
setWiFiLoading,
|
||||
pollingLoading,
|
||||
@@ -333,7 +302,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
// Device operations
|
||||
rebootDevice,
|
||||
resetDevice,
|
||||
setSpeedLimit,
|
||||
switchCard,
|
||||
setWiFi,
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
@show-switch-card="showSwitchCardDialog"
|
||||
@show-switch-mode="showSwitchModeDialog"
|
||||
@show-set-wifi="showSetWiFiDialog"
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-speed-limit="speedLimitDialogVisible = true"
|
||||
@show-update-realname-status="showUpdateRealnameStatusDialog"
|
||||
@enable-binding-card="handleEnableBindingCard"
|
||||
@disable-binding-card="handleDisableBindingCard"
|
||||
@@ -44,7 +45,6 @@
|
||||
@navigate-to-asset="handleNavigateToAsset"
|
||||
@navigate-to-card="handleNavigateToCard"
|
||||
@navigate-to-device="handleNavigateToDevice"
|
||||
@view-sync-trail="handleViewSyncTrail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
:current-realname-status="bindingCardRealnameStatusValue"
|
||||
@success="handleBindingCardRealnameStatusSuccess"
|
||||
/>
|
||||
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -182,7 +183,7 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { DeviceService } from '@/api/modules'
|
||||
import { CardService, DeviceService } from '@/api/modules'
|
||||
import {
|
||||
formatRemainingTime,
|
||||
FrontendRateLimitError,
|
||||
@@ -203,8 +204,9 @@
|
||||
SwitchModeDialog,
|
||||
WiFiConfigDialog,
|
||||
PackageRechargeDialog,
|
||||
DailyRecordsDialog,
|
||||
OrderHistoryDialog
|
||||
DailyRecordsDialog,
|
||||
OrderHistoryDialog,
|
||||
SpeedLimitDialog
|
||||
} from './components/dialogs'
|
||||
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
|
||||
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
|
||||
@@ -291,6 +293,7 @@
|
||||
|
||||
// 绑定卡实名状态更新
|
||||
const bindingCardRealnameStatusDialogVisible = ref(false)
|
||||
const speedLimitDialogVisible = ref(false)
|
||||
const bindingCardRealnameStatusIccid = ref('')
|
||||
const bindingCardRealnameStatusValue = ref<number>(0)
|
||||
|
||||
@@ -444,18 +447,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewSyncTrail = () => {
|
||||
if (!cardInfo.value) return
|
||||
|
||||
router.push({
|
||||
path: '/audit/integrations',
|
||||
query: {
|
||||
resource_type: cardInfo.value.asset_type,
|
||||
resource_key: cardInfo.value.identifier
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT卡操作 - 启用卡
|
||||
*/
|
||||
@@ -814,6 +805,15 @@
|
||||
handleSearch({ identifier })
|
||||
}
|
||||
}
|
||||
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) {
|
||||
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
|
||||
speedLimitDialogVisible.value = false
|
||||
await handleRefresh()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2299,7 +2299,8 @@
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('批量修改实名顺序成功')
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedDevices.value = []
|
||||
await getTableData()
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
formatter: (_, data) =>
|
||||
data.flow_type_name || (flowType === 'direct' ? '直接换货' : '物流换货')
|
||||
},
|
||||
{
|
||||
label: '提交人',
|
||||
formatter: (value) => value || '--',
|
||||
prop: 'submitter_name'
|
||||
},
|
||||
{
|
||||
label: '换货原因',
|
||||
prop: 'exchange_reason',
|
||||
|
||||
@@ -380,7 +380,13 @@
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ExchangeService, CardService, DeviceService } from '@/api/modules'
|
||||
import type { ExchangeResponse } from '@/api/modules/exchange'
|
||||
import type {
|
||||
CreateExchangeRequest,
|
||||
ExchangeAssetType,
|
||||
ExchangeFlowType,
|
||||
ExchangeQueryParams,
|
||||
ExchangeResponse
|
||||
} from '@/api/modules/exchange'
|
||||
import type { StandaloneIotCard, Device } from '@/types/api'
|
||||
import { ElMessage, ElTag, ElButton, ElMessageBox, ElSwitch } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -422,7 +428,7 @@
|
||||
]
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
const searchForm = reactive<ExchangeQueryParams & { created_at_range: string[] }>({
|
||||
status: undefined,
|
||||
flow_type: undefined, // 流程类型筛选
|
||||
old_asset_keyword: '',
|
||||
@@ -433,7 +439,16 @@
|
||||
})
|
||||
|
||||
// 创建换货单表单
|
||||
const createForm = reactive({
|
||||
const createForm = reactive<{
|
||||
exchange_reason: string
|
||||
exchange_reason_other: string
|
||||
old_asset_type: ExchangeAssetType | ''
|
||||
old_identifier: string
|
||||
flow_type: ExchangeFlowType
|
||||
new_identifier: string
|
||||
migrate_data: boolean
|
||||
remark: string
|
||||
}>({
|
||||
exchange_reason: '',
|
||||
exchange_reason_other: '',
|
||||
old_asset_type: '',
|
||||
@@ -819,7 +834,7 @@
|
||||
loading.value = true
|
||||
try {
|
||||
// 过滤掉空值参数
|
||||
const params: any = {
|
||||
const params: ExchangeQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
}
|
||||
@@ -1328,9 +1343,9 @@
|
||||
? createForm.exchange_reason_other.trim()
|
||||
: createForm.exchange_reason
|
||||
|
||||
const requestData: any = {
|
||||
const requestData: CreateExchangeRequest = {
|
||||
exchange_reason: exchangeReason,
|
||||
old_asset_type: createForm.old_asset_type,
|
||||
old_asset_type: createForm.old_asset_type as ExchangeAssetType,
|
||||
old_identifier: createForm.old_identifier,
|
||||
flow_type: createForm.flow_type,
|
||||
remark: createForm.remark || undefined
|
||||
@@ -1351,7 +1366,7 @@
|
||||
}
|
||||
|
||||
ElMessage.error(res.msg || '换货单创建失败')
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('创建换货单失败:', error)
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
|
||||
@@ -169,16 +169,16 @@
|
||||
{ label: '临期等级', prop: 'expiry_level_name' }
|
||||
]
|
||||
|
||||
const getExpiryClass = (days: number) => {
|
||||
if (days <= 3) return 'expiry-critical'
|
||||
if (days <= 7) return 'expiry-warning'
|
||||
if (days <= 15) return 'expiry-notice'
|
||||
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'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getExpiryTagType = (days: number) => {
|
||||
if (days <= 3) return 'danger'
|
||||
if (days <= 7) return 'warning'
|
||||
const getExpiryTagType = (level?: string | null) => {
|
||||
if (level === 'critical' || level === '0_3') return 'danger'
|
||||
if (level === 'warning' || level === '4_7') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: getExpiryClass(row.days_until_final_expiry) },
|
||||
{ class: getExpiryClass(row.expiry_level) },
|
||||
formatDateTime(row.estimated_final_expires_at)
|
||||
)
|
||||
},
|
||||
@@ -256,7 +256,7 @@
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: getExpiryTagType(row.days_until_final_expiry), size: 'small' },
|
||||
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
|
||||
() => `${row.days_until_final_expiry}天`
|
||||
)
|
||||
},
|
||||
@@ -2448,7 +2448,8 @@
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('批量修改实名顺序成功')
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedCards.value = []
|
||||
await getTableData()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="device-task-page" id="table-full-screen">
|
||||
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
@@ -10,6 +10,16 @@
|
||||
@search="handleSearch"
|
||||
></ArtSearchBar>
|
||||
|
||||
<div v-if="pollingError || pollingForbidden" class="task-polling-error">
|
||||
<ElAlert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="pollingForbidden ? '当前账号无权查看该任务' : pollingError || '任务状态获取失败'"
|
||||
/>
|
||||
<ElButton size="small" @click="polling.retry">重试</ElButton>
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
@@ -19,10 +29,11 @@
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
|
||||
type="primary"
|
||||
:icon="Upload"
|
||||
@click="importDialogVisible = true"
|
||||
v-permission="'device_task:bulk_import'"
|
||||
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
|
||||
>
|
||||
批量导入设备
|
||||
</ElButton>
|
||||
@@ -57,8 +68,8 @@
|
||||
<template #title>
|
||||
<div style="line-height: 1.8">
|
||||
<p><strong>导入说明:</strong></p>
|
||||
<p>1. 请先下载 Excel 模板文件,按照模板格式填写设备信息</p>
|
||||
<p>2. 仅支持 Excel 格式(.xlsx),单次最多导入 1000 条</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>
|
||||
@@ -77,7 +88,23 @@
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<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">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="批次号" prop="batch_no">
|
||||
<ElInput
|
||||
@@ -108,18 +135,18 @@
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElUpload
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:limit="1"
|
||||
accept=".xlsx"
|
||||
>
|
||||
accept=".xlsx,.csv"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">将 Excel 文件拖到此处,或<em>点击选择</em></div>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件,且不超过 300MB</div>
|
||||
<div class="el-upload__tip">导入设备支持 .xlsx;批量分配支持单列 .csv</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
|
||||
@@ -140,8 +167,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, reactive, onMounted } 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'
|
||||
@@ -149,16 +176,29 @@
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { StorageService } from '@/api/modules/storage'
|
||||
import type { DeviceImportTask, DeviceImportTaskStatus, RealnamePolicy } from '@/types/api/device'
|
||||
import type {
|
||||
DeviceImportTask,
|
||||
DeviceImportTaskDetail,
|
||||
DeviceImportTaskQueryParams,
|
||||
DeviceImportTaskStatus,
|
||||
RealnamePolicy
|
||||
} from '@/types/api/device'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { generatePackageCode } from '@/utils/codeGenerator'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
defineOptions({ name: 'DeviceTask' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
|
||||
const loading = ref(false)
|
||||
const tableRef = ref()
|
||||
@@ -168,14 +208,16 @@
|
||||
const importDialogVisible = ref(false)
|
||||
const importForm = reactive({
|
||||
batch_no: '',
|
||||
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order'
|
||||
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order',
|
||||
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
|
||||
target_id: ''
|
||||
})
|
||||
|
||||
// 搜索表单初始值
|
||||
const initialSearchState = {
|
||||
status: undefined,
|
||||
batch_no: '',
|
||||
dateRange: undefined as any,
|
||||
dateRange: undefined as string[] | undefined,
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
}
|
||||
@@ -247,6 +289,23 @@
|
||||
|
||||
const taskList = ref<DeviceImportTask[]>([])
|
||||
|
||||
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
|
||||
storageKey: 'device-import-active-task',
|
||||
autoRestore: false,
|
||||
fetchTask: async (taskId) => {
|
||||
const res = await DeviceService.getImportTaskDetail(taskId)
|
||||
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取设备任务失败')
|
||||
return res.data
|
||||
},
|
||||
isForbidden: (error) => {
|
||||
const response = (error as { response?: { status?: number; data?: { code?: number } } })
|
||||
?.response
|
||||
return response?.status === 403 || response?.data?.code === 403
|
||||
}
|
||||
})
|
||||
const pollingError = polling.error
|
||||
const pollingForbidden = polling.forbidden
|
||||
|
||||
// 获取状态标签类型
|
||||
const getStatusType = (status: DeviceImportTaskStatus) => {
|
||||
switch (status) {
|
||||
@@ -265,6 +324,7 @@
|
||||
|
||||
// 查看详情
|
||||
const viewDetail = (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return
|
||||
router.push({
|
||||
path: RoutesAlias.TaskDetail,
|
||||
query: {
|
||||
@@ -276,7 +336,7 @@
|
||||
|
||||
// 处理名称点击
|
||||
const handleNameClick = (row: DeviceImportTask) => {
|
||||
if (hasAuth('device_task:view_detail')) {
|
||||
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
|
||||
viewDetail(row)
|
||||
} else {
|
||||
ElMessage.warning('您没有查看详情的权限')
|
||||
@@ -385,15 +445,31 @@
|
||||
}
|
||||
])
|
||||
|
||||
watch(polling.task, () => {
|
||||
if (isPlatformAccount.value) void getTableData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
if (!isPlatformAccount.value) return
|
||||
void getTableData()
|
||||
const routeTaskId = Number(route.query.task_id)
|
||||
if (routeTaskId) {
|
||||
void polling.start(routeTaskId)
|
||||
} else if (polling.taskId.value) {
|
||||
void polling.retry()
|
||||
}
|
||||
})
|
||||
|
||||
// 获取设备任务列表
|
||||
const getTableData = async () => {
|
||||
if (!isPlatformAccount.value) {
|
||||
taskList.value = []
|
||||
pagination.total = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
const params: DeviceImportTaskQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
status: searchForm.status,
|
||||
@@ -406,13 +482,6 @@
|
||||
params.end_time = searchForm.dateRange[1]
|
||||
}
|
||||
|
||||
// 清理空值
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (params[key] === '' || params[key] === undefined) {
|
||||
delete params[key]
|
||||
}
|
||||
})
|
||||
|
||||
const res = await DeviceService.getImportTasks(params)
|
||||
if (res.code === 0) {
|
||||
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null })
|
||||
@@ -422,6 +491,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -473,8 +543,9 @@
|
||||
}
|
||||
|
||||
// 文件选择变化
|
||||
const handleFileChange = (uploadFile: any) => {
|
||||
const maxSize = 300 * 1024 * 1024
|
||||
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) {
|
||||
ElMessage.error('文件大小不能超过 300MB')
|
||||
uploadRef.value?.clearFiles()
|
||||
@@ -482,13 +553,23 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
|
||||
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
|
||||
if (uploadFile.raw && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) {
|
||||
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .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] : []
|
||||
}
|
||||
|
||||
@@ -510,24 +591,33 @@
|
||||
clearFiles()
|
||||
importForm.batch_no = ''
|
||||
importForm.realname_policy = ''
|
||||
importForm.operation_type = 'import'
|
||||
importForm.target_id = ''
|
||||
importDialogVisible.value = false
|
||||
}
|
||||
// 提交上传
|
||||
const submitUpload = async () => {
|
||||
if (!fileList.value.length) {
|
||||
ElMessage.warning('请先选择 Excel 文件')
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
const file = fileList.value[0]
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
ElMessage.info('正在准备上传...')
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
purpose: 'iot_import'
|
||||
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'
|
||||
})
|
||||
|
||||
if (uploadUrlRes.code !== 0) {
|
||||
@@ -538,18 +628,20 @@
|
||||
const { upload_url, file_key } = uploadUrlRes.data
|
||||
|
||||
ElMessage.info('正在上传文件...')
|
||||
await StorageService.uploadFile(
|
||||
upload_url,
|
||||
file,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
await StorageService.uploadFile(upload_url, file, contentType)
|
||||
|
||||
ElMessage.info('正在创建导入任务...')
|
||||
const importRes = await DeviceService.importDevices({
|
||||
file_key,
|
||||
batch_no: importForm.batch_no || undefined,
|
||||
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
if (importRes.code !== 0) {
|
||||
ElMessage.error(importRes.msg || '创建导入任务失败')
|
||||
@@ -557,8 +649,11 @@
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -568,7 +663,7 @@
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('设备导入失败:', error)
|
||||
console.log(error.message || '设备导入失败')
|
||||
ElMessage.error(normalizeApiError(error).message || error.message || '设备导入失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
@@ -576,6 +671,7 @@
|
||||
|
||||
// 从行数据下载失败数据
|
||||
const downloadFailDataByRow = async (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return
|
||||
try {
|
||||
const res = await DeviceService.getImportTaskDetail(row.id)
|
||||
if (res.code === 0 && res.data) {
|
||||
@@ -622,6 +718,7 @@
|
||||
}
|
||||
|
||||
const downloadTaskFile = async (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return
|
||||
const fileKey = row.file_name?.trim()
|
||||
if (!fileKey) {
|
||||
ElMessage.warning('当前任务没有可下载的原始文件')
|
||||
@@ -639,6 +736,7 @@
|
||||
|
||||
// 获取操作按钮
|
||||
const getActions = (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return []
|
||||
const actions: any[] = []
|
||||
const showDownloadFileAction = false
|
||||
|
||||
@@ -664,6 +762,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-task-page {
|
||||
.task-polling-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-icon--upload) {
|
||||
margin-bottom: 16px;
|
||||
font-size: 67px;
|
||||
|
||||
@@ -59,11 +59,14 @@
|
||||
import type { DeviceImportTaskDetail } from '@/types/api/device'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'TaskDetail' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
|
||||
type TaskType = 'card' | 'device'
|
||||
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
|
||||
@@ -244,6 +247,12 @@
|
||||
taskType.value = queryTaskType
|
||||
}
|
||||
|
||||
if (taskType.value === 'device' && !isPlatformAccount.value) {
|
||||
ElMessage.error('当前账号无权查看设备任务详情')
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (taskType.value === 'device') {
|
||||
|
||||
@@ -507,6 +507,7 @@
|
||||
CommissionSourceMap
|
||||
} from '@/config/constants/commission'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
defineOptions({ name: 'AgentCommission' })
|
||||
|
||||
@@ -1021,9 +1022,18 @@
|
||||
}
|
||||
|
||||
const handleCreditConflict = async () => {
|
||||
ElMessage.warning('资金概况已被更新,已刷新最新数据,请重新调整')
|
||||
creditDialogVisible.value = false
|
||||
const shopId = currentCreditShop.value?.shop_id
|
||||
await getTableData()
|
||||
|
||||
if (shopId) {
|
||||
const latest = summaryList.value.find((item) => item.shop_id === shopId)
|
||||
if (latest) {
|
||||
// 仅刷新后端版本和当前余额,保留用户刚填写的表单值,方便确认后重试。
|
||||
currentCreditShop.value = latest
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
|
||||
}
|
||||
|
||||
const handleCreditSubmit = async () => {
|
||||
@@ -1058,6 +1068,7 @@
|
||||
await handleCreditConflict()
|
||||
} else {
|
||||
console.error('实际信用额度调整失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
}
|
||||
} finally {
|
||||
creditSubmitting.value = false
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
2: 'success', // 已支付
|
||||
3: 'success', // 已完成
|
||||
4: 'info', // 已关闭
|
||||
5: 'danger' // 已退款
|
||||
5: 'danger', // 已退款
|
||||
6: 'danger' // 已驳回
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
@@ -81,7 +82,8 @@
|
||||
2: '已支付',
|
||||
3: '已完成',
|
||||
4: '已关闭',
|
||||
5: '已退款'
|
||||
5: '已退款',
|
||||
6: '已驳回'
|
||||
}
|
||||
return statusMap[status] || '-'
|
||||
}
|
||||
|
||||
@@ -464,7 +464,8 @@
|
||||
2: 'success', // 已支付
|
||||
3: 'success', // 已完成
|
||||
4: 'info', // 已关闭
|
||||
5: 'danger' // 已退款
|
||||
5: 'danger', // 已退款
|
||||
6: 'danger' // 已驳回
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
@@ -477,7 +478,8 @@
|
||||
2: '已支付',
|
||||
3: '已完成',
|
||||
4: '已关闭',
|
||||
5: '已退款'
|
||||
5: '已退款',
|
||||
6: '已驳回'
|
||||
}
|
||||
return statusMap[status] || '-'
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<ElButton
|
||||
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
|
||||
tag="a"
|
||||
href="/templates/bulk-purchase-template.xlsx"
|
||||
download="批量订购模板.xlsx"
|
||||
href="/templates/bulk-purchase-template.csv"
|
||||
download="批量订购套餐模板.csv"
|
||||
>
|
||||
下载模板
|
||||
</ElButton>
|
||||
@@ -20,25 +20,8 @@
|
||||
</template>
|
||||
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
|
||||
<ElFormItem label="代理商" prop="shop_id">
|
||||
<ElSelect
|
||||
v-model="form.shop_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
placeholder="请选择代理商"
|
||||
:remote-method="searchShops"
|
||||
:loading="shopLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="shop in shopOptions"
|
||||
:key="shop.id"
|
||||
:label="`${shop.shop_name} (${shop.shop_code})`"
|
||||
:value="shop.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElFormItem label="套餐 ID" prop="package_id">
|
||||
<ElInputNumber v-model="form.package_id" :min="1" controls-position="right" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="支付方式" prop="payment_method">
|
||||
@@ -53,9 +36,13 @@
|
||||
ref="orderUploadRef"
|
||||
v-model="orderFileKeys"
|
||||
voucher-name="订单文件"
|
||||
:max-count="1"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
tip="支持 .xlsx、.xls 或 .csv 文件"
|
||||
: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')"
|
||||
/>
|
||||
@@ -70,7 +57,7 @@
|
||||
ref="voucherUploadRef"
|
||||
v-model="voucherFileKeys"
|
||||
voucher-name="整批支付凭证"
|
||||
@uploading-change="voucherUploading = $event"
|
||||
@uploading-change="voucherUploading = $event"
|
||||
@change="formRef?.validateField('voucherFile')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
@@ -209,7 +196,8 @@
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElDescriptionsItem,
|
||||
ElInputNumber,
|
||||
ElForm,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
@@ -223,12 +211,11 @@
|
||||
} from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import { BulkPurchaseService, ShopService } from '@/api/modules'
|
||||
import { BulkPurchaseService } from '@/api/modules'
|
||||
import type {
|
||||
BulkPurchaseItem,
|
||||
BulkPurchasePaymentMethod,
|
||||
BulkPurchaseTask,
|
||||
ShopResponse
|
||||
BulkPurchaseTask
|
||||
} from '@/types/api'
|
||||
import { BulkPurchaseTaskStatus } from '@/types/api'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
@@ -244,8 +231,6 @@
|
||||
const formRef = ref<FormInstance>()
|
||||
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const shopOptions = ref<ShopResponse[]>([])
|
||||
const shopLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const orderFileKeys = ref<string[]>([])
|
||||
const orderFileUploading = ref(false)
|
||||
@@ -260,15 +245,15 @@
|
||||
const requestId = ref('')
|
||||
|
||||
const form = reactive<{
|
||||
shop_id?: number
|
||||
package_id?: number
|
||||
payment_method: BulkPurchasePaymentMethod
|
||||
}>({
|
||||
shop_id: undefined,
|
||||
package_id: undefined,
|
||||
payment_method: 'wallet'
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
shop_id: [{ required: true, message: '请选择代理商', trigger: 'change' }],
|
||||
package_id: [{ required: true, message: '请输入套餐 ID', trigger: 'change' }],
|
||||
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
|
||||
orderFile: [
|
||||
{
|
||||
@@ -310,20 +295,6 @@
|
||||
return requestId.value
|
||||
}
|
||||
|
||||
const searchShops = async (query: string) => {
|
||||
shopLoading.value = true
|
||||
try {
|
||||
const res = await ShopService.getShops({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
shop_name: query || undefined
|
||||
})
|
||||
if (res.code === 0) shopOptions.value = res.data.items || []
|
||||
} finally {
|
||||
shopLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentMethodChange = (value: string | number | boolean | undefined) => {
|
||||
if (value === 'wallet') {
|
||||
voucherFileKeys.value = []
|
||||
@@ -341,18 +312,16 @@
|
||||
)
|
||||
return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !form.shop_id || orderFileKeys.value.length === 0) return
|
||||
if (!valid || !form.package_id || orderFileKeys.value.length === 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = new FormData()
|
||||
data.append('shop_id', String(form.shop_id))
|
||||
data.append('payment_method', form.payment_method)
|
||||
data.append('file', orderFileKeys.value[0])
|
||||
data.append('request_id', getRequestId())
|
||||
if (form.payment_method === 'offline' && voucherFileKeys.value.length > 0) {
|
||||
data.append('voucher_file', voucherFileKeys.value[0])
|
||||
}
|
||||
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) {
|
||||
@@ -367,7 +336,7 @@
|
||||
query: { task_id: String(res.data.task_id) }
|
||||
})
|
||||
await polling.start(res.data.task_id)
|
||||
await loadItems()
|
||||
await loadItems()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '创建批量订购任务失败')
|
||||
} finally {
|
||||
@@ -376,26 +345,9 @@
|
||||
}
|
||||
|
||||
const loadItems = async () => {
|
||||
const taskId = taskDetail.value?.task_id
|
||||
const taskId = taskDetail.value?.id || taskDetail.value?.task_id
|
||||
if (!taskId || !hasAuth(BULK_PURCHASE_PERMISSIONS.items)) return
|
||||
itemsLoading.value = true
|
||||
try {
|
||||
const res = await BulkPurchaseService.getItems(taskId, {
|
||||
page: itemsPage.value,
|
||||
size: itemsSize.value,
|
||||
status: itemStatus.value
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
items.value = res.data.items || []
|
||||
itemsTotal.value = res.data.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取批量订购明细失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获取批量订购明细失败')
|
||||
} finally {
|
||||
itemsLoading.value = false
|
||||
}
|
||||
items.value = taskDetail.value?.items || []
|
||||
}
|
||||
|
||||
const reloadItems = () => {
|
||||
@@ -441,7 +393,6 @@
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (hasAuth(BULK_PURCHASE_PERMISSIONS.create)) void searchShops('')
|
||||
const routeTaskId = Number(route.query.task_id)
|
||||
if (routeTaskId && routeTaskId !== polling.taskId.value) void polling.start(routeTaskId)
|
||||
})
|
||||
|
||||
@@ -104,6 +104,22 @@
|
||||
return type === 'single_card' ? '单卡购买' : '设备购买'
|
||||
}
|
||||
|
||||
const getAssetTypeText = (type?: string): string => {
|
||||
if (type === 'card') return 'IoT卡'
|
||||
if (type === 'device') return '设备'
|
||||
return '-'
|
||||
}
|
||||
|
||||
const getPaymentMethodText = (method?: string): string => {
|
||||
const methodMap: Record<string, string> = {
|
||||
wallet: '钱包',
|
||||
wechat: '微信',
|
||||
alipay: '支付宝',
|
||||
offline: '线下'
|
||||
}
|
||||
return method ? methodMap[method] || method : '-'
|
||||
}
|
||||
|
||||
// 获取买家类型文本
|
||||
const getBuyerTypeText = (type: string): string => {
|
||||
return type === 'personal' ? '个人客户' : '代理商'
|
||||
@@ -174,10 +190,19 @@
|
||||
label: '订单类型',
|
||||
formatter: (_: any, data: any) => getOrderTypeText(data.order_type)
|
||||
},
|
||||
{
|
||||
label: '资产类型',
|
||||
formatter: (_, data) => getAssetTypeText(data.asset_type)
|
||||
},
|
||||
{
|
||||
label: '支付状态',
|
||||
formatter: (_: any, data: any) => data.payment_status_text || '-'
|
||||
},
|
||||
{
|
||||
label: '支付方式',
|
||||
prop: 'payment_method',
|
||||
formatter: (value) => getPaymentMethodText(value)
|
||||
},
|
||||
{
|
||||
label: '订单金额',
|
||||
prop: 'total_amount',
|
||||
@@ -253,7 +278,12 @@
|
||||
formatter: (_, data) => (data.is_purchased_by_parent ? '是' : '否')
|
||||
},
|
||||
{
|
||||
label: 'ICCID/VirtualNo',
|
||||
label: '是否代购',
|
||||
formatter: (_, data) =>
|
||||
data.is_purchase_on_behalf === undefined ? '-' : data.is_purchase_on_behalf ? '是' : '否'
|
||||
},
|
||||
{
|
||||
label: '资产标识符',
|
||||
prop: 'asset_identifier',
|
||||
formatter: (value) => value || '-'
|
||||
},
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
options: [
|
||||
{ label: '钱包支付', value: 'wallet' },
|
||||
{ label: '微信支付', value: 'wechat' },
|
||||
// { label: '支付宝支付', value: 'alipay' },
|
||||
{ label: '支付宝支付', value: 'alipay' },
|
||||
{ label: '线下支付', value: 'offline' }
|
||||
],
|
||||
config: {
|
||||
@@ -468,9 +468,9 @@
|
||||
placeholder: '请选择订单渠道',
|
||||
options: [
|
||||
{ label: '自己购买', value: 'self_purchase' },
|
||||
// { label: '上级代理购买', value: 'purchased_by_parent' },
|
||||
{ label: '平台代购', value: 'purchased_by_platform' }
|
||||
// { label: '给下级购买', value: 'purchase_for_subordinate' }
|
||||
{ label: '上级代理购买', value: 'purchased_by_parent' },
|
||||
{ label: '平台代购', value: 'purchased_by_platform' },
|
||||
{ label: '给下级购买', value: 'purchase_for_subordinate' }
|
||||
],
|
||||
config: {
|
||||
clearable: true
|
||||
@@ -504,17 +504,22 @@
|
||||
{ label: '买家手机号', prop: 'buyer_phone' },
|
||||
{ label: '买家昵称', prop: 'buyer_nickname' },
|
||||
{ label: '订单类型', prop: 'order_type' },
|
||||
{ label: '资产类型', prop: 'asset_type' },
|
||||
{ label: '买家类型', prop: 'buyer_type' },
|
||||
{ label: '资产标识符', prop: 'asset_identifier' },
|
||||
{ label: '订单渠道', prop: 'purchase_role' },
|
||||
{ label: '购买备注', prop: 'purchase_remark' },
|
||||
{ label: '是否代购', prop: 'is_purchase_on_behalf' },
|
||||
{ label: '是否上级代购', prop: 'is_purchased_by_parent' },
|
||||
{ label: '下单时间', prop: 'created_at' },
|
||||
{ label: '操作者', prop: 'operator_name' },
|
||||
{ label: '销售店铺', prop: 'seller_shop_name' },
|
||||
{ label: '支付状态', prop: 'payment_status' },
|
||||
{ label: '佣金流程状态', prop: 'commission_status_name' },
|
||||
{ label: '佣金业务结果', prop: 'commission_result_name' },
|
||||
{ label: '订单金额', prop: 'total_amount' }
|
||||
{ label: '订单金额', prop: 'total_amount' },
|
||||
{ label: '是否过期', prop: 'is_expired' },
|
||||
{ label: '超时时间', prop: 'expires_at' }
|
||||
]
|
||||
|
||||
// 只有非代理账号和非企业账号才能看到实付金额选项
|
||||
@@ -854,6 +859,13 @@
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'asset_type',
|
||||
label: '资产类型',
|
||||
width: 110,
|
||||
formatter: (row: Order) =>
|
||||
row.asset_type === 'card' ? 'IoT卡' : row.asset_type === 'device' ? '设备' : '-'
|
||||
},
|
||||
{
|
||||
prop: 'buyer_type',
|
||||
label: '买家类型',
|
||||
@@ -876,6 +888,18 @@
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'is_expired',
|
||||
label: '是否过期',
|
||||
width: 100,
|
||||
formatter: (row: Order) => (row.is_expired === undefined ? '-' : row.is_expired ? '是' : '否')
|
||||
},
|
||||
{
|
||||
prop: 'expires_at',
|
||||
label: '超时时间',
|
||||
width: 180,
|
||||
formatter: (row: Order) => (row.expires_at ? formatDateTime(row.expires_at) : '-')
|
||||
},
|
||||
{
|
||||
prop: 'asset_identifier',
|
||||
label: '资产标识符',
|
||||
@@ -909,6 +933,20 @@
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: Order) => row.purchase_remark || '-'
|
||||
},
|
||||
{
|
||||
prop: 'is_purchase_on_behalf',
|
||||
label: '是否代购',
|
||||
width: 100,
|
||||
formatter: (row: Order) =>
|
||||
row.is_purchase_on_behalf === undefined ? '-' : row.is_purchase_on_behalf ? '是' : '否'
|
||||
},
|
||||
{
|
||||
prop: 'is_purchased_by_parent',
|
||||
label: '是否上级代购',
|
||||
width: 110,
|
||||
formatter: (row: Order) =>
|
||||
row.is_purchased_by_parent === undefined ? '-' : row.is_purchased_by_parent ? '是' : '否'
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '下单时间',
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
import { PackageManageService } from '@/api/modules'
|
||||
import type { PackageResponse } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getStatusLabel, getShelfStatusText } from '@/config/constants'
|
||||
import { getShelfStatusText } from '@/config/constants'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'PackageDetail' })
|
||||
@@ -103,7 +103,16 @@
|
||||
{
|
||||
label: '价格配置状态',
|
||||
formatter: (_: unknown, data: PackageResponse) => {
|
||||
return data.price_config_status_name || '-'
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '未配置',
|
||||
1: '赠送 0 价',
|
||||
2: '已配置非 0'
|
||||
}
|
||||
return (
|
||||
data.price_config_status_name ||
|
||||
statusMap[data.price_config_status ?? -1] ||
|
||||
'-'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -179,7 +188,9 @@
|
||||
{
|
||||
label: '状态',
|
||||
formatter: (_: unknown, data: PackageResponse) => {
|
||||
return getStatusLabel(data.status ?? 0)
|
||||
if (data.status === 1) return '启用'
|
||||
if (data.status === 0 || data.status === 2) return '禁用'
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -224,6 +235,13 @@
|
||||
}
|
||||
return `${virtualDataMb} MB`
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '虚流量比例',
|
||||
formatter: (_: unknown, data: PackageResponse) =>
|
||||
data.virtual_ratio === null || data.virtual_ratio === undefined
|
||||
? '-'
|
||||
: Number(data.virtual_ratio).toFixed(2)
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<ElButton type="primary" @click="showDialog('add')" v-permission="'package:add'"
|
||||
>新增套餐</ElButton
|
||||
>
|
||||
<ElButton v-if="hasAuth('package:export')" @click="exportDialogVisible = true">
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -45,6 +48,14 @@
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="package"
|
||||
:query="exportQuery"
|
||||
confirm-permission="package:export"
|
||||
title="导出套餐"
|
||||
/>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
@@ -240,11 +251,12 @@
|
||||
>
|
||||
<div>
|
||||
缩减比例:{{ realDataGb }}×(1-{{ virtualRatioPercent }}%) =
|
||||
{{ calculatedVirtualDataGb }}GB
|
||||
{{ formatTwoDecimals(calculatedVirtualDataGb) }}GB
|
||||
</div>
|
||||
<div v-if="calculatedVirtualDataGb > 0">
|
||||
增长比例:{{ realDataGb }}GB/{{ calculatedVirtualDataGb }}GB =
|
||||
{{ calculatedGrowthRatio * 100 }}%
|
||||
增长比例:{{ realDataGb }}GB/{{
|
||||
formatTwoDecimals(calculatedVirtualDataGb)
|
||||
}}GB = {{ formatTwoDecimals(calculatedGrowthRatio * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
@@ -407,10 +419,10 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import {
|
||||
CommonStatus,
|
||||
getStatusText,
|
||||
frontendStatusToApi,
|
||||
apiStatusToFrontend,
|
||||
PACKAGE_TYPE_OPTIONS,
|
||||
getPackageTypeLabel,
|
||||
@@ -434,6 +446,7 @@
|
||||
const shouldHideVirtualTrafficColumns = computed(() => [3, 4].includes(currentUserType.value))
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const exportDialogVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const seriesLoading = ref(false)
|
||||
@@ -589,6 +602,7 @@
|
||||
]),
|
||||
{ label: '套餐周期类型', prop: 'calendar_type' },
|
||||
{ label: '有效期', prop: 'duration_months' },
|
||||
{ label: '套餐天数', prop: 'duration_days' },
|
||||
{ label: '总流量', prop: 'real_data_mb' },
|
||||
...(!shouldHideVirtualTrafficColumns.value
|
||||
? [
|
||||
@@ -607,8 +621,8 @@
|
||||
formatter: (row: any) => {
|
||||
const map: Record<string, string> = {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
yearly: '每年',
|
||||
none: '不重置'
|
||||
}
|
||||
return map[row.data_reset_cycle] || row.data_reset_cycle || '-'
|
||||
@@ -632,8 +646,8 @@
|
||||
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
|
||||
},
|
||||
{ label: '最终生效条件', prop: 'effective_expiry_base_name' },
|
||||
...(canUpdatePackageShelfStatus ? [{ label: '上架状态', prop: 'shelf_status' }] : []),
|
||||
...(canUpdatePackageStatus ? [{ label: '状态', prop: 'status' }] : []),
|
||||
{ label: '上架状态', prop: 'shelf_status' },
|
||||
{ label: '状态', prop: 'status' },
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '更新时间', prop: 'updated_at' }
|
||||
]
|
||||
@@ -749,6 +763,8 @@
|
||||
return Number((realDataGb.value / calculatedVirtualDataGb.value).toFixed(2))
|
||||
})
|
||||
|
||||
const formatTwoDecimals = (value: number) => value.toFixed(2)
|
||||
|
||||
// GB 转 MB 处理
|
||||
const handleRealDataChange = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -902,7 +918,17 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
: [
|
||||
{
|
||||
prop: 'shelf_status',
|
||||
label: '上架状态',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) =>
|
||||
h(ElTag, { type: row.shelf_status === 1 ? 'success' : 'info', size: 'small' }, () =>
|
||||
getShelfStatusText(row.shelf_status ?? 0)
|
||||
)
|
||||
}
|
||||
]),
|
||||
...(canUpdatePackageStatus
|
||||
? [
|
||||
{
|
||||
@@ -924,7 +950,24 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
: [
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) => {
|
||||
const frontendStatus = apiStatusToFrontend(row.status ?? 0)
|
||||
return h(
|
||||
ElTag,
|
||||
{
|
||||
type: frontendStatus === CommonStatus.ENABLED ? 'success' : 'danger',
|
||||
size: 'small'
|
||||
},
|
||||
() => getStatusText(frontendStatus)
|
||||
)
|
||||
}
|
||||
}
|
||||
]),
|
||||
{
|
||||
prop: 'real_data_mb',
|
||||
label: '总流量',
|
||||
@@ -959,28 +1002,36 @@
|
||||
prop: 'virtual_ratio',
|
||||
label: '虚流量比例',
|
||||
width: 120,
|
||||
formatter: (row: PackageResponse) => {
|
||||
// 如果启用虚流量且真流量大于0,计算虚量百分比
|
||||
const virtualData = row.virtual_data_mb ?? 0
|
||||
const realData = row.real_data_mb ?? 0
|
||||
if (row.enable_virtual_data && realData > 0 && virtualData > 0) {
|
||||
// 虚量百分比 = (1 - 虚流量/真流量) * 100%
|
||||
// 例如:真流量100G,虚流量70G,则虚量百分比 = (1 - 70/100) * 100% = 30%
|
||||
const ratio = (1 - virtualData / realData) * 100
|
||||
return `${ratio.toFixed(2)}%`
|
||||
}
|
||||
// 否则返回 0%
|
||||
return '0%'
|
||||
}
|
||||
formatter: (row: PackageResponse) =>
|
||||
row.virtual_ratio === null || row.virtual_ratio === undefined
|
||||
? '-'
|
||||
: formatTwoDecimals(Number(row.virtual_ratio))
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
prop: 'enable_virtual_data',
|
||||
label: '启用虚流量',
|
||||
width: 110,
|
||||
formatter: (row: PackageResponse) => (row.enable_virtual_data ? '是' : '否')
|
||||
},
|
||||
{
|
||||
prop: 'duration_months',
|
||||
label: '有效期',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) => `${row.duration_months}月`
|
||||
},
|
||||
{
|
||||
prop: 'duration_days',
|
||||
label: '套餐天数',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) =>
|
||||
row.calendar_type === 'by_day' &&
|
||||
row.duration_days !== null &&
|
||||
row.duration_days !== undefined
|
||||
? `${row.duration_days}天`
|
||||
: '-'
|
||||
},
|
||||
{
|
||||
prop: 'calendar_type',
|
||||
label: '套餐周期类型',
|
||||
@@ -1002,8 +1053,8 @@
|
||||
formatter: (row: PackageResponse) => {
|
||||
const map: Record<string, string> = {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
yearly: '每年',
|
||||
none: '不重置'
|
||||
}
|
||||
const dataResetCycle = row.data_reset_cycle
|
||||
@@ -1025,11 +1076,33 @@
|
||||
return map[expiryBase] || expiryBase
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'default_expiry_base_name',
|
||||
label: '套餐默认生效条件',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
prop: 'expiry_base_override_name',
|
||||
label: '分配覆盖生效条件',
|
||||
width: 150,
|
||||
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
|
||||
},
|
||||
{
|
||||
prop: 'effective_expiry_base_name',
|
||||
label: '最终生效条件',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 180,
|
||||
formatter: (row: PackageResponse) => formatDateTime(row.created_at)
|
||||
},
|
||||
{
|
||||
prop: 'updated_at',
|
||||
label: '更新时间',
|
||||
width: 180,
|
||||
formatter: (row: PackageResponse) => formatDateTime(row.updated_at)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1080,7 +1153,7 @@
|
||||
// 监听流量重置周期变化
|
||||
watch(
|
||||
() => form.data_reset_cycle,
|
||||
(cycle) => {
|
||||
() => {
|
||||
// 流量重置周期变化时不需要清空套餐天数,套餐天数只受calendar_type控制
|
||||
}
|
||||
)
|
||||
@@ -1097,7 +1170,7 @@
|
||||
|
||||
// 监听 is_gift 变化,自动设置 suggested_retail_price
|
||||
const handleIsGiftChange = (isGift: string | number | boolean) => {
|
||||
if (Boolean(isGift)) {
|
||||
if (isGift) {
|
||||
form.suggested_retail_price = 0
|
||||
} else {
|
||||
form.suggested_retail_price = undefined
|
||||
@@ -1195,7 +1268,7 @@
|
||||
series_id: searchForm.series_id || undefined,
|
||||
package_type: searchForm.package_type || undefined,
|
||||
shelf_status: searchForm.shelf_status || undefined,
|
||||
status: searchForm.status || undefined
|
||||
status: searchForm.status ?? undefined
|
||||
}
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
@@ -1210,6 +1283,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 导出查询参数:保留 status=0(禁用)等有效零值
|
||||
const exportQuery = computed(() => {
|
||||
const query: Record<string, unknown> = {
|
||||
package_name: searchForm.package_name || undefined,
|
||||
series_id: searchForm.series_id ?? undefined,
|
||||
package_type: searchForm.package_type || undefined,
|
||||
shelf_status: searchForm.shelf_status ?? undefined,
|
||||
status: searchForm.status ?? undefined
|
||||
}
|
||||
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (query[key] === undefined || query[key] === null || query[key] === '') {
|
||||
delete query[key]
|
||||
}
|
||||
})
|
||||
|
||||
return query
|
||||
})
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
@@ -1387,7 +1479,6 @@
|
||||
const costPriceInCents = Math.round(form.cost_price * 100)
|
||||
|
||||
const data: any = {
|
||||
package_code: form.package_code,
|
||||
package_name: form.package_name,
|
||||
package_type: form.package_type,
|
||||
duration_months: form.duration_months,
|
||||
@@ -1395,8 +1486,12 @@
|
||||
is_gift: form.is_gift
|
||||
}
|
||||
|
||||
if (dialogType.value === 'add') {
|
||||
data.package_code = form.package_code
|
||||
}
|
||||
|
||||
// 可选字段
|
||||
if (form.series_id !== undefined && form.series_id !== null) {
|
||||
if (form.series_id !== undefined) {
|
||||
data.series_id = form.series_id
|
||||
}
|
||||
if (form.calendar_type) {
|
||||
@@ -1458,7 +1553,7 @@
|
||||
// 状态切换
|
||||
const handleStatusChange = async (row: PackageResponse, newFrontendStatus: number) => {
|
||||
const oldStatus = row.status
|
||||
const newApiStatus = frontendStatusToApi(newFrontendStatus)
|
||||
const newApiStatus = newFrontendStatus
|
||||
row.status = newApiStatus
|
||||
try {
|
||||
await PackageManageService.updatePackageStatus(row.id, newApiStatus)
|
||||
|
||||
@@ -398,7 +398,6 @@
|
||||
getStatusText,
|
||||
frontendStatusToApi,
|
||||
apiStatusToFrontend,
|
||||
STATUS_SELECT_OPTIONS,
|
||||
ENABLE_STATUS_OPTIONS,
|
||||
getEnableStatusText
|
||||
} from '@/config/constants'
|
||||
@@ -406,6 +405,11 @@
|
||||
|
||||
defineOptions({ name: 'PackageSeries' })
|
||||
|
||||
const PACKAGE_SERIES_STATUS_SELECT_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 2 }
|
||||
]
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const canUpdatePackageSeriesStatus = hasAuth('package_series:update_status')
|
||||
const router = useRouter()
|
||||
@@ -455,7 +459,7 @@
|
||||
clearable: true,
|
||||
placeholder: '请选择状态'
|
||||
},
|
||||
options: STATUS_SELECT_OPTIONS
|
||||
options: PACKAGE_SERIES_STATUS_SELECT_OPTIONS
|
||||
}
|
||||
]
|
||||
|
||||
@@ -788,7 +792,7 @@
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
series_name: searchForm.series_name || undefined,
|
||||
status: searchForm.status || undefined,
|
||||
status: searchForm.status ?? undefined,
|
||||
enable_one_time_commission: searchForm.enable_one_time_commission ?? undefined
|
||||
}
|
||||
const res = await PackageSeriesService.getPackageSeries(params)
|
||||
|
||||
@@ -348,7 +348,8 @@
|
||||
GrantPackageItem,
|
||||
GrantPackageInfo,
|
||||
PackageAllocationExpiryBaseOverride,
|
||||
PackageSeriesResponse
|
||||
PackageSeriesResponse,
|
||||
PackageResponse
|
||||
} from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getEnableStatusText } from '@/config/constants'
|
||||
@@ -550,23 +551,31 @@
|
||||
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
series_id: detailData.value.series_id
|
||||
}
|
||||
const pageSize = 100
|
||||
const allPackages: PackageResponse[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
if (packageName) {
|
||||
params.package_name = packageName
|
||||
}
|
||||
do {
|
||||
const params: any = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
series_id: detailData.value.series_id
|
||||
}
|
||||
if (packageName) params.package_name = packageName
|
||||
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
availablePackages.value = mergeGrantPackageCandidates(
|
||||
res.data.items,
|
||||
detailData.value.packages || []
|
||||
)
|
||||
}
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code !== 0) break
|
||||
|
||||
allPackages.push(...(res.data.items || []))
|
||||
total = res.data.total || allPackages.length
|
||||
page += 1
|
||||
} while (allPackages.length < total)
|
||||
|
||||
availablePackages.value = mergeGrantPackageCandidates(
|
||||
allPackages,
|
||||
detailData.value.packages || []
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('加载套餐选项失败:', error)
|
||||
} finally {
|
||||
|
||||
@@ -594,12 +594,16 @@
|
||||
getStatusText,
|
||||
frontendStatusToApi,
|
||||
apiStatusToFrontend,
|
||||
STATUS_SELECT_OPTIONS,
|
||||
getEnableStatusText
|
||||
} from '@/config/constants'
|
||||
|
||||
defineOptions({ name: 'SeriesGrants' })
|
||||
|
||||
const SERIES_GRANT_STATUS_SELECT_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 2 }
|
||||
]
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const canUpdateSeriesGrantStatus = hasAuth('series_grants:update_status')
|
||||
const router = useRouter()
|
||||
@@ -736,7 +740,7 @@
|
||||
clearable: true,
|
||||
placeholder: '请选择状态'
|
||||
},
|
||||
options: STATUS_SELECT_OPTIONS
|
||||
options: SERIES_GRANT_STATUS_SELECT_OPTIONS
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1074,25 +1078,26 @@
|
||||
const loadPackageOptions = async (packageName?: string) => {
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: 1,
|
||||
page_size: 20
|
||||
}
|
||||
const pageSize = 100
|
||||
const allPackages: PackageResponse[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
// 如果已选择套餐系列,则根据系列ID过滤套餐
|
||||
if (form.series_id) {
|
||||
params.series_id = form.series_id
|
||||
}
|
||||
do {
|
||||
const params: any = { page, page_size: pageSize }
|
||||
if (form.series_id) params.series_id = form.series_id
|
||||
if (packageName) params.package_name = packageName
|
||||
|
||||
if (packageName) {
|
||||
params.package_name = packageName
|
||||
}
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code !== 0) break
|
||||
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
// 过滤掉赠送套餐
|
||||
packageOptions.value = res.data.items.filter((pkg) => !pkg.is_gift)
|
||||
}
|
||||
allPackages.push(...(res.data.items || []))
|
||||
total = res.data.total || allPackages.length
|
||||
page += 1
|
||||
} while (allPackages.length < total)
|
||||
|
||||
// 过滤掉赠送套餐
|
||||
packageOptions.value = allPackages.filter((pkg) => !pkg.is_gift)
|
||||
} catch (error) {
|
||||
console.error('加载套餐选项失败:', error)
|
||||
} finally {
|
||||
@@ -1125,7 +1130,7 @@
|
||||
// 获取套餐名称
|
||||
const getPackageName = (packageId: number) => {
|
||||
const pkg = packageOptions.value.find((p) => p.id === packageId)
|
||||
return pkg ? pkg.package_name : `套餐ID: ${packageId}`
|
||||
return pkg ? pkg.package_name : '套餐名称不可用'
|
||||
}
|
||||
|
||||
const getPackageCostPriceMax = (pkg?: {
|
||||
@@ -1452,7 +1457,7 @@
|
||||
series_id: searchForm.series_id || undefined,
|
||||
allocator_shop_id:
|
||||
searchForm.allocator_shop_id !== undefined ? searchForm.allocator_shop_id : undefined,
|
||||
status: searchForm.status || undefined
|
||||
status: searchForm.status ?? undefined
|
||||
}
|
||||
const res = await ShopSeriesGrantService.getShopSeriesGrants(params)
|
||||
if (res.code === 0) {
|
||||
|
||||
@@ -371,18 +371,20 @@
|
||||
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
series_id: seriesId.value
|
||||
}
|
||||
if (packageName) {
|
||||
params.package_name = packageName
|
||||
}
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
availablePackages.value = mergeGrantPackageCandidates(res.data.items, 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 {
|
||||
|
||||
@@ -51,7 +51,12 @@
|
||||
<span>{{ currentConfig?.description || '-' }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="配置值">
|
||||
<ElSwitch v-if="isBooleanConfig" v-model="editBoolean" />
|
||||
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
|
||||
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
|
||||
{{ method }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
|
||||
<ElInputNumber
|
||||
v-else-if="isIntegerConfig"
|
||||
v-model="editNumber"
|
||||
@@ -101,11 +106,20 @@
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { SystemConfigService } from '@/api/modules'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { SystemConfigItem, SystemConfigModule } from '@/types/api/systemConfig'
|
||||
import {
|
||||
PAYMENT_CONFIG_KEYS,
|
||||
parsePaymentMethods,
|
||||
type AllowedPaymentMethod,
|
||||
type SystemConfigItem,
|
||||
type SystemConfigModule
|
||||
} from '@/types/api/systemConfig'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
defineOptions({ name: 'SystemConfigs' })
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const moduleOptions = [
|
||||
{ label: '运营商回调配置', value: 'carrier_callback' },
|
||||
@@ -133,6 +147,8 @@
|
||||
const originalValue = ref('')
|
||||
const editBoolean = ref(false)
|
||||
const editNumber = ref<number | null>(null)
|
||||
const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
|
||||
const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay']
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const columnOptions = [
|
||||
@@ -198,6 +214,11 @@
|
||||
const isBooleanConfig = computed(
|
||||
() => currentConfig.value?.value_type === 'bool' || currentConfig.value?.control === 'switch'
|
||||
)
|
||||
const isPaymentConfig = computed(() =>
|
||||
[PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(
|
||||
currentConfig.value?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
|
||||
)
|
||||
)
|
||||
const isIntegerConfig = computed(
|
||||
() => !isBooleanConfig.value && currentConfig.value?.value_type === 'int'
|
||||
)
|
||||
@@ -222,6 +243,7 @@
|
||||
})
|
||||
|
||||
const getSubmittedValue = () => {
|
||||
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
|
||||
if (isBooleanConfig.value) return String(editBoolean.value)
|
||||
if (isIntegerConfig.value) return editNumber.value === null ? '' : String(editNumber.value)
|
||||
return editValue.value
|
||||
@@ -246,12 +268,17 @@
|
||||
return '请输入有效的 JSON'
|
||||
}
|
||||
}
|
||||
if (isPaymentConfig.value) {
|
||||
if (editPaymentMethods.value.length === 0) return '至少保留一种支付方式'
|
||||
}
|
||||
if (value === '') return '配置值不能为空'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getActions = (row: SystemConfigItem) =>
|
||||
row.readonly
|
||||
row.readonly ||
|
||||
([PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(row.config_key as never) &&
|
||||
!hasAuth(JULY_PERMISSIONS.systemConfig.payment))
|
||||
? []
|
||||
: [{ label: '编辑', handler: () => showEditDialog(row), type: 'primary' as const }]
|
||||
|
||||
@@ -280,6 +307,7 @@
|
||||
editValue.value = row.value
|
||||
editBoolean.value = row.value === 'true'
|
||||
editNumber.value = /^-?\d+$/.test(row.value) ? Number(row.value) : null
|
||||
editPaymentMethods.value = parsePaymentMethods(row.value)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
||||
253
src/views/settings/wecom/applications/detail.vue
Normal file
253
src/views/settings/wecom/applications/detail.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<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>
|
||||
171
src/views/settings/wecom/applications/index.vue
Normal file
171
src/views/settings/wecom/applications/index.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<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">
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
link
|
||||
type="primary"
|
||||
@click="testApplication(scope.row.id)"
|
||||
>
|
||||
测试连接
|
||||
</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>
|
||||
|
||||
<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="loadApplications"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { 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'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
defineOptions({ name: 'WecomApplications' })
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const applications = ref<WecomApplication[]>([])
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
|
||||
|
||||
const loadApplications = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await WecomService.getApplications({
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
})
|
||||
if (response.code === 0) {
|
||||
applications.value = response.data.items || []
|
||||
pagination.total = response.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
void loadApplications()
|
||||
}
|
||||
|
||||
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`)
|
||||
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`)
|
||||
const goToMembers = (id: number) =>
|
||||
void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } })
|
||||
|
||||
const testApplication = async (id: number) => {
|
||||
const response = await WecomService.testApplication(id)
|
||||
if (response.code === 0) {
|
||||
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
|
||||
else ElMessage.warning('连接失败,请检查应用凭据')
|
||||
await loadApplications()
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
424
src/views/settings/wecom/members/index.vue
Normal file
424
src/views/settings/wecom/members/index.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="setDefaultCreator(scope.row)"
|
||||
>
|
||||
设为默认发起人
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.binding"
|
||||
link
|
||||
@click.stop="selectMember(scope.row)"
|
||||
>
|
||||
绑定账号
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AccountService, WecomService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import type { WecomApplication, WecomMember } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'WecomMembers' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const savingDefault = ref(false)
|
||||
const binding = ref(false)
|
||||
const applications = ref<WecomApplication[]>([])
|
||||
const members = ref<WecomMember[]>([])
|
||||
const selectedApplicationId = ref<number>()
|
||||
const selectedUserid = ref('')
|
||||
const bindingAccountId = ref<number>()
|
||||
const keyword = ref('')
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const selectedApplication = computed(() =>
|
||||
applications.value.find((application) => application.id === selectedApplicationId.value)
|
||||
)
|
||||
const selectedMember = computed(() =>
|
||||
members.value.find((member) => member.userid === selectedUserid.value)
|
||||
)
|
||||
|
||||
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 loadMembers = async () => {
|
||||
if (!selectedApplicationId.value) {
|
||||
members.value = []
|
||||
pagination.total = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await WecomService.getMembers(selectedApplicationId.value, {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
keyword: keyword.value.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 = ''
|
||||
pagination.page = 1
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const selectMember = (member: WecomMember) => {
|
||||
selectedUserid.value = member.userid
|
||||
}
|
||||
|
||||
const syncMembers = async () => {
|
||||
if (!selectedApplicationId.value) return
|
||||
syncing.value = true
|
||||
try {
|
||||
const response = await WecomService.syncMembers(selectedApplicationId.value)
|
||||
if (response.code === 0) {
|
||||
ElMessage.success(`已同步 ${response.data.synced_count} 名成员`)
|
||||
await loadMembers()
|
||||
}
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDefaultCreator = async (member: WecomMember) => {
|
||||
if (!selectedApplicationId.value) return
|
||||
selectedUserid.value = member.userid
|
||||
await saveSelectedDefaultCreator()
|
||||
}
|
||||
|
||||
const saveSelectedDefaultCreator = async () => {
|
||||
if (!selectedApplicationId.value || !selectedMember.value) {
|
||||
ElMessage.warning('请选择要设置的成员')
|
||||
return
|
||||
}
|
||||
savingDefault.value = true
|
||||
try {
|
||||
const response = await WecomService.setDefaultCreator(
|
||||
selectedApplicationId.value,
|
||||
selectedMember.value.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
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
const goToApplications = () => void router.push(RoutesAlias.WecomApplications)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadApplications()
|
||||
await loadMembers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-members-page {
|
||||
.page-header,
|
||||
.toolbar,
|
||||
.operation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.toolbar {
|
||||
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;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-top: 4px;
|
||||
padding: 14px 16px 0;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.application-select {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.operation-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.operation-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.application-select,
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
375
src/views/settings/wecom/scenes/index.vue
Normal file
375
src/views/settings/wecom/scenes/index.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
<ElButton v-permission="JULY_PERMISSIONS.wecom.scene" type="primary" @click="createScene">
|
||||
新增场景配置
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { WecomService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import type {
|
||||
WecomApplication,
|
||||
WecomBusinessType,
|
||||
WecomScene,
|
||||
WecomSceneControlMapping
|
||||
} from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'WecomScenes' })
|
||||
|
||||
interface MappingEditor {
|
||||
key: number
|
||||
business_field: string
|
||||
control_id: string
|
||||
control_type: string
|
||||
option_mapping: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const applications = ref<WecomApplication[]>([])
|
||||
const scenes = ref<WecomScene[]>([])
|
||||
const selectedBusinessType = ref<WecomBusinessType>()
|
||||
const nextMappingKey = ref(1)
|
||||
const sceneForm = reactive({
|
||||
business_type: 'refund_approval' as WecomBusinessType,
|
||||
application_id: undefined as number | undefined,
|
||||
template_id: '',
|
||||
enabled: true
|
||||
})
|
||||
const mappingRows = ref<MappingEditor[]>([])
|
||||
const editing = computed(() => Boolean(selectedBusinessType.value))
|
||||
|
||||
const 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 removeMapping = (index: number) => mappingRows.value.splice(index, 1)
|
||||
|
||||
const fillEditor = (scene?: WecomScene) => {
|
||||
if (!scene) {
|
||||
selectedBusinessType.value = undefined
|
||||
Object.assign(sceneForm, {
|
||||
business_type: 'refund_approval' as WecomBusinessType,
|
||||
application_id: applications.value[0]?.id,
|
||||
template_id: '',
|
||||
enabled: true
|
||||
})
|
||||
mappingRows.value = []
|
||||
addMapping()
|
||||
return
|
||||
}
|
||||
selectedBusinessType.value = scene.business_type
|
||||
Object.assign(sceneForm, {
|
||||
business_type: scene.business_type,
|
||||
application_id: scene.application_id,
|
||||
template_id: scene.template_id,
|
||||
enabled: scene.status === 1
|
||||
})
|
||||
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
|
||||
key: nextMappingKey.value++,
|
||||
business_field: mapping.business_field,
|
||||
control_id: mapping.control_id,
|
||||
control_type: mapping.control_type,
|
||||
option_mapping: JSON.stringify(mapping.option_mapping || {})
|
||||
}))
|
||||
}
|
||||
|
||||
const selectScene = (scene: WecomScene) => fillEditor(scene)
|
||||
const createScene = () => fillEditor()
|
||||
|
||||
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 })
|
||||
])
|
||||
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])
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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('请完整填写控件映射字段')
|
||||
return
|
||||
}
|
||||
let optionMapping: Record<string, string>
|
||||
try {
|
||||
const parsed = JSON.parse(mapping.option_mapping || '{}')
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
||||
throw new Error('invalid')
|
||||
optionMapping = parsed as Record<string, string>
|
||||
} catch {
|
||||
ElMessage.warning(`第 ${result.length + 1} 条选择项映射不是有效 JSON`)
|
||||
return
|
||||
}
|
||||
result.push({
|
||||
business_field: mapping.business_field,
|
||||
control_id: mapping.control_id,
|
||||
control_type: mapping.control_type,
|
||||
option_mapping: optionMapping
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const saveScene = async () => {
|
||||
if (!sceneForm.template_id.trim()) {
|
||||
ElMessage.warning('请输入模板 ID')
|
||||
return
|
||||
}
|
||||
const controlMapping = parseMapping()
|
||||
if (!controlMapping) return
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await WecomService.saveScene(sceneForm.business_type, {
|
||||
application_id: sceneForm.application_id,
|
||||
template_id: sceneForm.template_id.trim(),
|
||||
control_mapping: controlMapping,
|
||||
status: sceneForm.enabled ? 1 : 0
|
||||
})
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('场景保存并校验成功')
|
||||
await loadData()
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-scenes-page {
|
||||
.page-header,
|
||||
.editor-header,
|
||||
.mapping-heading,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.editor-header,
|
||||
.mapping-heading {
|
||||
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;
|
||||
}
|
||||
|
||||
.scene-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-heading {
|
||||
margin-bottom: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
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;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -228,6 +228,13 @@
|
||||
:inactive-value="CommonStatus.DISABLED"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="dialogType === 'edit'" label="C 端登录">
|
||||
<ElSwitch
|
||||
v-model="formData.client_login_disabled"
|
||||
active-text="禁止新登录"
|
||||
inactive-text="允许登录"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -322,9 +329,11 @@
|
||||
import { ShopService, RoleService } from '@/api/modules'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type {
|
||||
CreateShopParams,
|
||||
ShopBusinessOwnerCandidate,
|
||||
ShopResponse,
|
||||
ShopRoleResponse
|
||||
ShopRoleResponse,
|
||||
UpdateShopParams
|
||||
} from '@/types/api'
|
||||
import { RoleType, RoleStatus } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
@@ -609,7 +618,8 @@
|
||||
formData.address = row.address || ''
|
||||
formData.contact_name = row.contact_name || ''
|
||||
formData.contact_phone = row.contact_phone || ''
|
||||
formData.business_owner_account_id = row.business_owner_account_id ?? null
|
||||
formData.business_owner_account_id = row.business_owner_account_id ?? null
|
||||
formData.client_login_disabled = row.client_login_disabled
|
||||
formData.status = row.status
|
||||
formData.init_username = ''
|
||||
formData.init_password = ''
|
||||
@@ -627,7 +637,8 @@
|
||||
formData.address = ''
|
||||
formData.contact_name = ''
|
||||
formData.contact_phone = ''
|
||||
formData.business_owner_account_id = null
|
||||
formData.business_owner_account_id = null
|
||||
formData.client_login_disabled = false
|
||||
formData.status = CommonStatus.ENABLED
|
||||
formData.init_username = ''
|
||||
formData.init_password = ''
|
||||
@@ -729,13 +740,19 @@
|
||||
label: '联系电话',
|
||||
width: 130
|
||||
},
|
||||
{
|
||||
prop: 'business_owner_username',
|
||||
label: '平台业务员',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ShopResponse) => formatBusinessOwner(row)
|
||||
},
|
||||
{
|
||||
prop: 'business_owner_username',
|
||||
label: '平台业务员',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ShopResponse) => formatBusinessOwner(row)
|
||||
},
|
||||
{
|
||||
prop: 'client_login_disabled',
|
||||
label: 'C 端登录',
|
||||
width: 110,
|
||||
formatter: (row: ShopResponse) => (row.client_login_disabled ? '已限制' : '正常')
|
||||
},
|
||||
...(canModifyShopStatus
|
||||
? [
|
||||
{
|
||||
@@ -825,7 +842,8 @@
|
||||
init_password: '',
|
||||
init_phone: '',
|
||||
default_role_id: undefined as number | undefined,
|
||||
business_owner_account_id: null as number | null
|
||||
business_owner_account_id: null as number | null,
|
||||
client_login_disabled: false
|
||||
})
|
||||
|
||||
// 处理编码生成
|
||||
@@ -987,14 +1005,15 @@
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (dialogType.value === 'add') {
|
||||
const data: any = {
|
||||
const data: CreateShopParams = {
|
||||
shop_name: formData.shop_name,
|
||||
shop_code: formData.shop_code,
|
||||
init_username: formData.init_username,
|
||||
init_password: formData.init_password,
|
||||
init_phone: formData.init_phone,
|
||||
default_role_id: formData.default_role_id,
|
||||
business_owner_account_id: formData.business_owner_account_id
|
||||
default_role_id: formData.default_role_id!,
|
||||
business_owner_account_id: formData.business_owner_account_id,
|
||||
client_login_disabled: formData.client_login_disabled
|
||||
}
|
||||
|
||||
// 可选字段 - parent_id 可能是数组(级联选择器)或数字
|
||||
@@ -1013,10 +1032,11 @@
|
||||
await ShopService.createShop(data)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
const data: any = {
|
||||
const data: UpdateShopParams = {
|
||||
shop_name: formData.shop_name,
|
||||
status: formData.status,
|
||||
business_owner_account_id: formData.business_owner_account_id
|
||||
business_owner_account_id: formData.business_owner_account_id,
|
||||
client_login_disabled: formData.client_login_disabled
|
||||
}
|
||||
|
||||
// 可选字段
|
||||
|
||||
@@ -247,8 +247,8 @@
|
||||
ElOption
|
||||
} from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { RoleType } from '@/types/api'
|
||||
import type { PlatformRole, PermissionTreeNode } from '@/types/api'
|
||||
import { RoleStatus, RoleType } from '@/types/api'
|
||||
import type { PlatformRole, PermissionTreeNode, PlatformRoleFormData } from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
@@ -381,12 +381,18 @@
|
||||
]
|
||||
})
|
||||
|
||||
const form = reactive<any>({
|
||||
type RoleFormState = PlatformRoleFormData & {
|
||||
id: number
|
||||
credit_enabled: boolean
|
||||
credit_limit_yuan: number
|
||||
}
|
||||
|
||||
const form = reactive<RoleFormState>({
|
||||
id: 0,
|
||||
role_name: '',
|
||||
role_desc: '',
|
||||
role_type: 1,
|
||||
status: CommonStatus.ENABLED,
|
||||
role_type: RoleType.PLATFORM,
|
||||
status: RoleStatus.ENABLED,
|
||||
credit_enabled: false,
|
||||
credit_limit_yuan: 0
|
||||
})
|
||||
@@ -1138,7 +1144,7 @@
|
||||
form.role_name = ''
|
||||
form.role_desc = ''
|
||||
form.role_type = 1
|
||||
form.status = CommonStatus.ENABLED
|
||||
form.status = RoleStatus.ENABLED
|
||||
form.credit_enabled = false
|
||||
form.credit_limit_yuan = 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user