feat: 产品迭代7月份
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m51s

This commit is contained in:
luo
2026-07-27 16:30:29 +08:00
parent cc6fc9243e
commit f0a2b84e53
75 changed files with 2926 additions and 534 deletions

View File

@@ -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>()

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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>

View File

@@ -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()

View File

@@ -83,6 +83,11 @@
formatter: (_, data) =>
data.flow_type_name || (flowType === 'direct' ? '直接换货' : '物流换货')
},
{
label: '提交人',
formatter: (value) => value || '--',
prop: 'submitter_name'
},
{
label: '换货原因',
prop: 'exchange_reason',

View File

@@ -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

View File

@@ -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}`
)
},

View File

@@ -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()

View File

@@ -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;

View File

@@ -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') {

View File

@@ -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

View File

@@ -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] || '-'
}

View File

@@ -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] || '-'
}

View File

@@ -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)
})

View File

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

View File

@@ -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: '下单时间',

View File

@@ -34,7 +34,7 @@
import { PackageManageService } from '@/api/modules'
import type { PackageResponse } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getStatusLabel, getShelfStatusText } from '@/config/constants'
import { getShelfStatusText } from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'PackageDetail' })
@@ -103,7 +103,16 @@
{
label: '价格配置状态',
formatter: (_: unknown, data: PackageResponse) => {
return data.price_config_status_name || '-'
const statusMap: Record<number, string> = {
0: '未配置',
1: '赠送 0 价',
2: '已配置非 0'
}
return (
data.price_config_status_name ||
statusMap[data.price_config_status ?? -1] ||
'-'
)
}
},
{
@@ -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)
}
])
]

View File

@@ -21,6 +21,9 @@
<ElButton type="primary" @click="showDialog('add')" v-permission="'package:add'"
>新增套餐</ElButton
>
<ElButton v-if="hasAuth('package:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -45,6 +48,14 @@
</template>
</ArtTable>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="package"
:query="exportQuery"
confirm-permission="package:export"
title="导出套餐"
/>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
@@ -240,11 +251,12 @@
>
<div>
缩减比例{{ realDataGb }}×(1-{{ virtualRatioPercent }}%) =
{{ calculatedVirtualDataGb }}GB
{{ formatTwoDecimals(calculatedVirtualDataGb) }}GB
</div>
<div v-if="calculatedVirtualDataGb > 0">
增长比例{{ realDataGb }}GB/{{ calculatedVirtualDataGb }}GB =
{{ calculatedGrowthRatio * 100 }}%
增长比例{{ realDataGb }}GB/{{
formatTwoDecimals(calculatedVirtualDataGb)
}}GB = {{ formatTwoDecimals(calculatedGrowthRatio * 100) }}%
</div>
</div>
</ElFormItem>
@@ -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)

View File

@@ -398,7 +398,6 @@
getStatusText,
frontendStatusToApi,
apiStatusToFrontend,
STATUS_SELECT_OPTIONS,
ENABLE_STATUS_OPTIONS,
getEnableStatusText
} from '@/config/constants'
@@ -406,6 +405,11 @@
defineOptions({ name: 'PackageSeries' })
const PACKAGE_SERIES_STATUS_SELECT_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 2 }
]
const { hasAuth } = useAuth()
const canUpdatePackageSeriesStatus = hasAuth('package_series:update_status')
const router = useRouter()
@@ -455,7 +459,7 @@
clearable: true,
placeholder: '请选择状态'
},
options: STATUS_SELECT_OPTIONS
options: PACKAGE_SERIES_STATUS_SELECT_OPTIONS
}
]
@@ -788,7 +792,7 @@
page: pagination.page,
page_size: pagination.page_size,
series_name: searchForm.series_name || undefined,
status: searchForm.status || undefined,
status: searchForm.status ?? undefined,
enable_one_time_commission: searchForm.enable_one_time_commission ?? undefined
}
const res = await PackageSeriesService.getPackageSeries(params)

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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
}

View 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>

View 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>

View 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>

View 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>

View File

@@ -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
}
// 可选字段

View File

@@ -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
}