fix: some
This commit is contained in:
@@ -616,7 +616,8 @@
|
||||
{
|
||||
prop: 'shop_name',
|
||||
label: '店铺名称',
|
||||
minWidth: 150,
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: any) => {
|
||||
return row.shop_name || '-'
|
||||
}
|
||||
|
||||
425
src/views/agent-registration/index.vue
Normal file
425
src/views/agent-registration/index.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div class="agent-registration-page">
|
||||
<div class="register-card">
|
||||
<div class="register-header">
|
||||
<h1 class="register-title">代理扫码注册</h1>
|
||||
<p class="register-subtitle">填写资料提交后,审核结果将通过通知告知</p>
|
||||
</div>
|
||||
|
||||
<div v-if="submitted" class="register-result">
|
||||
<ElResult icon="success" title="提交成功" sub-title="待审批,审核结果将通知你">
|
||||
<template #extra>
|
||||
<ElButton type="primary" @click="handleFillAgain">重新填写</ElButton>
|
||||
</template>
|
||||
</ElResult>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
v-else
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
size="large"
|
||||
class="register-form"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<ElFormItem label="分销码" prop="distribution_code">
|
||||
<ElInput
|
||||
v-model="form.distribution_code"
|
||||
:disabled="lockedDistributionCode"
|
||||
placeholder="请输入分销码"
|
||||
maxlength="64"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="手机号" prop="phone">
|
||||
<ElInput v-model="form.phone" placeholder="请输入手机号" maxlength="11" clearable />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<ElFormItem label="短信验证码" prop="code">
|
||||
<div class="code-row">
|
||||
<ElInput v-model="form.code" placeholder="请输入短信验证码" maxlength="6" />
|
||||
<ElButton :disabled="!canSendCode" :loading="sending" @click="handleSendCode">
|
||||
{{ countdownText }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="密码" prop="password">
|
||||
<ElInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码(8-32 位)"
|
||||
maxlength="32"
|
||||
/>
|
||||
<div class="strength-bar" :class="strengthClass">
|
||||
<i v-for="n in 3" :key="n" :class="{ active: passwordStrength >= n }" />
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<div class="form-grid">
|
||||
<ElFormItem label="店铺名称" prop="shop_name">
|
||||
<ElInput
|
||||
v-model="form.shop_name"
|
||||
:disabled="lockedShopName"
|
||||
placeholder="请输入店铺名称"
|
||||
maxlength="50"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="店铺编号" prop="shop_code">
|
||||
<ElInput
|
||||
v-model="form.shop_code"
|
||||
:disabled="lockedShopCode"
|
||||
placeholder="请输入店铺编号"
|
||||
maxlength="50"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<ElFormItem label="用户名" prop="username">
|
||||
<ElInput v-model="form.username" placeholder="请输入登录用户名" maxlength="50" clearable />
|
||||
</ElFormItem>
|
||||
|
||||
<div class="form-grid">
|
||||
<ElFormItem label="联系人" prop="contact_name">
|
||||
<ElInput v-model="form.contact_name" placeholder="选填" maxlength="50" clearable />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="省市区">
|
||||
<ElCascader
|
||||
v-model="form.region"
|
||||
:options="regionOptions"
|
||||
placeholder="请选择省市区"
|
||||
clearable
|
||||
class="region-cascader"
|
||||
@change="handleRegionChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<ElFormItem label="详细地址" prop="address">
|
||||
<ElInput v-model="form.address" placeholder="选填,请输入详细地址" maxlength="200" clearable />
|
||||
</ElFormItem>
|
||||
|
||||
<ElButton
|
||||
type="primary"
|
||||
size="large"
|
||||
class="submit-btn"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交注册
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
AgentDistributionService,
|
||||
type AgentDistributionRegistrationParams
|
||||
} from '@/api/modules/agentDistribution'
|
||||
import { regionData } from '@/utils/constants/regionData'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const regionOptions = regionData as CascaderOption[]
|
||||
|
||||
interface RegisterForm {
|
||||
distribution_code: string
|
||||
phone: string
|
||||
code: string
|
||||
password: string
|
||||
shop_name: string
|
||||
shop_code: string
|
||||
username: string
|
||||
contact_name: string
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
address: string
|
||||
region: string[]
|
||||
}
|
||||
|
||||
const form = reactive<RegisterForm>({
|
||||
distribution_code: (route.query.distribution_code as string) || '',
|
||||
phone: '',
|
||||
code: '',
|
||||
password: '',
|
||||
shop_name: (route.query.shop_name as string) || '',
|
||||
shop_code: (route.query.shop_code as string) || '',
|
||||
username: '',
|
||||
contact_name: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
region: []
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitted = ref(false)
|
||||
const sending = ref(false)
|
||||
const submitting = ref(false)
|
||||
const countdown = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const rules: FormRules = {
|
||||
distribution_code: [{ required: true, message: '请输入分销码', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
|
||||
],
|
||||
code: [{ required: true, message: '请输入短信验证码', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, max: 32, message: '密码长度为 8-32 位', trigger: 'blur' }
|
||||
],
|
||||
shop_name: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }],
|
||||
shop_code: [{ required: true, message: '请输入店铺编号', trigger: 'blur' }],
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const lockedDistributionCode = computed(() => !!route.query.distribution_code)
|
||||
const lockedShopName = computed(() => !!route.query.shop_name)
|
||||
const lockedShopCode = computed(() => !!route.query.shop_code)
|
||||
|
||||
const canSendCode = computed(() => countdown.value <= 0 && !sending.value)
|
||||
|
||||
const countdownText = computed(() =>
|
||||
countdown.value > 0 ? `${countdown.value}s 后重发` : '获取验证码'
|
||||
)
|
||||
|
||||
const startCountdown = () => {
|
||||
countdown.value = 60
|
||||
if (timer) clearInterval(timer)
|
||||
timer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
|
||||
ElMessage.warning('请先输入正确的手机号')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await AgentDistributionService.sendCode({
|
||||
phone: form.phone,
|
||||
scene: 'bind_phone'
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('验证码已发送')
|
||||
startCountdown()
|
||||
}
|
||||
} catch {
|
||||
// 错误提示由请求层统一处理
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const passwordStrength = computed(() => {
|
||||
const pwd = form.password
|
||||
if (!pwd) return 0
|
||||
let score = 0
|
||||
if (pwd.length >= 8) score += 1
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score += 1
|
||||
if (/\d/.test(pwd)) score += 1
|
||||
if (/[^A-Za-z0-9]/.test(pwd)) score += 1
|
||||
if (score >= 4) return 3
|
||||
if (score >= 2) return 2
|
||||
return 1
|
||||
})
|
||||
|
||||
const strengthClass = computed(() => {
|
||||
const map = ['', 'is-weak', 'is-medium', 'is-strong']
|
||||
return map[passwordStrength.value] || ''
|
||||
})
|
||||
|
||||
const handleRegionChange = (value: CascaderValue | null | undefined) => {
|
||||
const parts = Array.isArray(value) ? value.map((item) => String(item)) : []
|
||||
form.province = parts[0] || ''
|
||||
form.city = parts[1] || ''
|
||||
form.district = parts[2] || ''
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const params: AgentDistributionRegistrationParams = {
|
||||
distribution_code: form.distribution_code.trim(),
|
||||
phone: form.phone.trim(),
|
||||
code: form.code.trim(),
|
||||
password: form.password,
|
||||
shop_name: form.shop_name.trim(),
|
||||
shop_code: form.shop_code.trim(),
|
||||
username: form.username.trim()
|
||||
}
|
||||
if (form.contact_name.trim()) params.contact_name = form.contact_name.trim()
|
||||
if (form.province) params.province = form.province
|
||||
if (form.city) params.city = form.city
|
||||
if (form.district) params.district = form.district
|
||||
if (form.address.trim()) params.address = form.address.trim()
|
||||
|
||||
const res = await AgentDistributionService.registerAgent(params)
|
||||
if (res.code === 0) {
|
||||
submitted.value = true
|
||||
} else {
|
||||
ElMessage.error('分销码不可用')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('分销码不可用')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleFillAgain = () => {
|
||||
submitted.value = false
|
||||
formRef.value?.resetFields()
|
||||
form.distribution_code = (route.query.distribution_code as string) || form.distribution_code
|
||||
form.shop_name = (route.query.shop_name as string) || form.shop_name
|
||||
form.shop_code = (route.query.shop_code as string) || form.shop_code
|
||||
form.code = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.agent-registration-page {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 48px 16px 64px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(160deg, #eef3fb 0%, #f7f9fd 40%, #eef3fb 100%);
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
padding: 36px 36px 28px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 30px rgb(93 135 255 / 12%);
|
||||
}
|
||||
|
||||
.register-header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
|
||||
.register-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.register-subtitle {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
flex: 0 0 auto;
|
||||
width: 128px;
|
||||
}
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
|
||||
i {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--el-border-color-lighter);
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-strong i.active {
|
||||
background: var(--el-color-success);
|
||||
}
|
||||
|
||||
&.is-weak i.active {
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.region-cascader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.register-result {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 24px 12px 40px;
|
||||
|
||||
.register-card {
|
||||
padding: 24px 16px 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.code-row .el-button {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -47,6 +47,12 @@
|
||||
border
|
||||
size="large"
|
||||
>
|
||||
<!-- 关联手机号(通用) -->
|
||||
<ElDescriptionsItem
|
||||
label="关联手机号"
|
||||
label-class-name="asset-label asset-label--associated-phones"
|
||||
>{{ (cardInfo?.associated_phones || []).join('、') || '-' }}</ElDescriptionsItem
|
||||
>
|
||||
<!-- 卡专属字段 -->
|
||||
<template v-if="cardInfo?.asset_type === 'card'">
|
||||
<ElDescriptionsItem
|
||||
@@ -698,6 +704,7 @@
|
||||
}
|
||||
|
||||
interface AssetInfo {
|
||||
associated_phones?: string[]
|
||||
asset_id: number
|
||||
asset_type: 'card' | 'device'
|
||||
identifier: string
|
||||
|
||||
@@ -135,6 +135,7 @@ export function useAssetInfo() {
|
||||
// 卡专属字段 (asset_type === 'card')
|
||||
iccid: data.iccid || '',
|
||||
msisdn: data.msisdn || '',
|
||||
associated_phones: data.associated_phones ?? [],
|
||||
imsi: data.imsi || '',
|
||||
carrier_id: data.carrier_id,
|
||||
carrier_type: data.carrier_type || '',
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AssetInfo {
|
||||
iccid?: string
|
||||
imei?: string
|
||||
msisdn?: string
|
||||
associated_phones?: string[] // 当前关联手机号(完整值)
|
||||
carrier_type?: string
|
||||
carrier_name?: string
|
||||
real_name_status?: number
|
||||
|
||||
@@ -1617,6 +1617,7 @@
|
||||
{ label: '设备号', prop: 'virtual_no' },
|
||||
{ label: 'IMEI', prop: 'imei' },
|
||||
{ label: '设备名称', prop: 'device_name' },
|
||||
{ label: '关联手机号', prop: 'associated_phones' },
|
||||
{ label: '店铺名称', prop: 'shop_name' },
|
||||
{ label: '套餐系列', prop: 'series_name' },
|
||||
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
|
||||
@@ -1893,10 +1894,19 @@
|
||||
showOverflowTooltip: true,
|
||||
minWidth: 150
|
||||
},
|
||||
{
|
||||
prop: 'associated_phones',
|
||||
label: '关联手机号',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: Device) =>
|
||||
row.associated_phones?.length ? row.associated_phones.join('、') : '-'
|
||||
},
|
||||
{
|
||||
prop: 'shop_name',
|
||||
label: '店铺名称',
|
||||
minWidth: 120
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'series_name',
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import type { DetailField, DetailSection } from '@/components/common/DetailPage.vue'
|
||||
|
||||
defineOptions({ name: 'ExchangeDetail' })
|
||||
|
||||
@@ -62,6 +62,16 @@
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getMigrationStatusType = (migrationStatus?: string): TagProps['type'] => {
|
||||
const types: Record<string, TagProps['type']> = {
|
||||
not_migrated: 'info',
|
||||
pending: 'warning',
|
||||
migrated: 'success',
|
||||
failed: 'danger'
|
||||
}
|
||||
return types[migrationStatus || ''] || 'info'
|
||||
}
|
||||
|
||||
// 根据流程类型动态生成详情页配置
|
||||
const detailSections = computed<DetailSection[]>(() => {
|
||||
if (!exchangeDetail.value) return []
|
||||
@@ -70,9 +80,7 @@
|
||||
const sections: DetailSection[] = []
|
||||
|
||||
// 基本信息
|
||||
sections.push({
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
const basicFields: DetailField[] = [
|
||||
{ label: '换货单号', prop: 'exchange_no' },
|
||||
{
|
||||
label: '状态',
|
||||
@@ -112,9 +120,28 @@
|
||||
label: '继承归属店铺',
|
||||
formatter: (_, data) =>
|
||||
formatExchangeShopName(data.inherited_shop_name, data.inherited_shop_id)
|
||||
},
|
||||
{
|
||||
label: '迁移状态',
|
||||
render: (data) =>
|
||||
data.migration_status
|
||||
? h(
|
||||
ElTag,
|
||||
{ type: getMigrationStatusType(data.migration_status) },
|
||||
() => data.migration_status_name || data.migration_status
|
||||
)
|
||||
: h('span', '--')
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
if (exchangeDetail.value.migration_status === 'failed') {
|
||||
basicFields.push({
|
||||
label: '迁移失败原因',
|
||||
prop: 'migration_failure_reason',
|
||||
formatter: (value) => value || '--',
|
||||
fullWidth: true
|
||||
})
|
||||
}
|
||||
sections.push({ title: '基本信息', fields: basicFields })
|
||||
|
||||
// 收货信息(仅物流换货显示)
|
||||
if (flowType === 'shipping') {
|
||||
|
||||
@@ -987,6 +987,19 @@
|
||||
formatter: (row: ExchangeResponse) =>
|
||||
h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '--')
|
||||
},
|
||||
{
|
||||
prop: 'migration_status',
|
||||
label: '迁移状态',
|
||||
width: 130,
|
||||
formatter: (row: ExchangeResponse) =>
|
||||
row.migration_status
|
||||
? h(
|
||||
ElTag,
|
||||
{ type: getMigrationStatusType(row.migration_status) },
|
||||
() => row.migration_status_name || '--'
|
||||
)
|
||||
: '--'
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
@@ -1006,6 +1019,18 @@
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getMigrationStatusType = (
|
||||
migrationStatus?: string
|
||||
): 'success' | 'info' | 'warning' | 'danger' | 'primary' => {
|
||||
const types: Record<string, 'success' | 'info' | 'warning' | 'danger' | 'primary'> = {
|
||||
not_migrated: 'info',
|
||||
pending: 'warning',
|
||||
migrated: 'success',
|
||||
failed: 'danger'
|
||||
}
|
||||
return types[migrationStatus || ''] || 'info'
|
||||
}
|
||||
|
||||
const getAssetTypeName = (assetType?: string | null) => {
|
||||
if (assetType === 'iot_card') return '物联网卡'
|
||||
if (assetType === 'device') return '设备'
|
||||
@@ -1638,7 +1663,9 @@
|
||||
|
||||
// 状态3(已发货待确认):确认完成
|
||||
if (status === 3) {
|
||||
if (hasAuth('exchange:complete')) {
|
||||
// migration_status=failed 的重试仅超管/平台账号可操作,代理/企业不渲染入口(后端以 403/1005 兜底)
|
||||
const canRetryFailed = row.migration_status !== 'failed' || userType === 1 || userType === 2
|
||||
if (hasAuth('exchange:complete') && canRetryFailed) {
|
||||
actions.push({
|
||||
label: '确认完成',
|
||||
handler: () => handleCompleteExchange(row),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportCommissionRecordTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportPackageTrafficAlertTaskList' })
|
||||
</script>
|
||||
@@ -1946,6 +1946,7 @@
|
||||
{ label: 'ICCID', prop: 'iccid' },
|
||||
{ label: 'IMEI', prop: 'gateway_card_imei' },
|
||||
{ label: 'MSISDN', prop: 'msisdn' },
|
||||
{ label: '关联手机号', prop: 'associated_phones' },
|
||||
{ label: '卡虚拟号', prop: 'virtual_no' },
|
||||
{ label: '运营商', prop: 'carrier_name' },
|
||||
{ label: '卡业务类型', prop: 'card_category' },
|
||||
@@ -2092,6 +2093,14 @@
|
||||
label: 'MSISDN',
|
||||
width: 160
|
||||
},
|
||||
{
|
||||
prop: 'associated_phones',
|
||||
label: '关联手机号',
|
||||
minWidth: 150,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: StandaloneIotCard) =>
|
||||
row.associated_phones?.length ? row.associated_phones.join('、') : '-'
|
||||
},
|
||||
{
|
||||
prop: 'virtual_no',
|
||||
label: '卡虚拟号',
|
||||
|
||||
750
src/views/asset-management/phone-asset-association/index.vue
Normal file
750
src/views/asset-management/phone-asset-association/index.vue
Normal file
@@ -0,0 +1,750 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="phone-asset-association-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
label-width="110"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<ElButton
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="openBatchUnbindDialog"
|
||||
v-if="canBatchUnbind"
|
||||
>
|
||||
批量解绑
|
||||
</ElButton>
|
||||
<ElButton type="primary" plain @click="openImportDialog" v-if="canImportCreate">
|
||||
CSV解绑导入
|
||||
</ElButton>
|
||||
<ElButton @click="goUnbindImportTasks" v-if="canImportPage">解绑导入任务</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
:loading="loading"
|
||||
:data="associationList"
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="100"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn type="selection" width="55" />
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 单条解绑 -->
|
||||
<ElDialog
|
||||
v-model="unbindDialogVisible"
|
||||
title="解除手机号资产关联"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<ElForm ref="unbindFormRef" :model="unbindForm" :rules="unbindRules" label-width="90px">
|
||||
<ElFormItem label="资产标识">
|
||||
<span>{{ unbindForm.asset_identifier || '-' }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="手机号">
|
||||
<span>{{ unbindForm.phone || '-' }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="解除原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="unbindForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请填写解除原因(1~500 字符)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="二次确认" prop="confirmed">
|
||||
<ElCheckbox v-model="unbindForm.confirmed">我已确认解除该关联关系</ElCheckbox>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="unbindDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="danger" :loading="unbindLoading" @click="confirmUnbind">确认解绑</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 批量解绑 -->
|
||||
<ElDialog
|
||||
v-model="batchUnbindDialogVisible"
|
||||
title="批量解除手机号资产关联"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<ElAlert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
:title="`已选择 ${selectedRows.length} 项资产(按资产类型与ID去重后执行,上限 200)`"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<ElForm ref="batchFormRef" :model="batchForm" :rules="batchRules" label-width="90px">
|
||||
<ElFormItem label="解除原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="batchForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请填写解除原因(1~500 字符)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="二次确认" prop="confirmed">
|
||||
<ElCheckbox v-model="batchForm.confirmed">我已确认解除上述全部关联关系</ElCheckbox>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="batchUnbindDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="danger" :loading="batchUnbindLoading" @click="confirmBatchUnbind">
|
||||
确认批量解绑
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 批量解绑结果 -->
|
||||
<ElDialog v-model="batchResultVisible" title="批量解绑结果" width="560px">
|
||||
<div class="batch-result-summary">
|
||||
<span>成功 {{ batchResult?.success_count ?? 0 }} 项</span>
|
||||
<span class="text-danger">失败 {{ batchResult?.fail_count ?? 0 }} 项</span>
|
||||
</div>
|
||||
<ElTable :data="batchResult?.items || []" max-height="320" size="small" border>
|
||||
<ElTableColumn prop="asset_type" label="资产类型" width="110" />
|
||||
<ElTableColumn prop="asset_id" label="资产ID" width="90" />
|
||||
<ElTableColumn prop="success" label="结果" width="80" />
|
||||
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
|
||||
<ElTableColumn prop="reason" label="失败原因" show-overflow-tooltip />
|
||||
</ElTable>
|
||||
<template #footer>
|
||||
<ElButton type="primary" @click="batchResultVisible = false">关闭</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- CSV 解绑导入 -->
|
||||
<ElDialog
|
||||
v-model="importDialogVisible"
|
||||
title="CSV 解绑导入"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="handleImportDialogClosed"
|
||||
>
|
||||
<div class="import-tips">
|
||||
<span>模板表头:</span>
|
||||
<ElButton link type="primary" @click="downloadTemplate">下载模板</ElButton>
|
||||
<div class="import-tips-detail">
|
||||
资产标识,备注(备注可选);支持 UTF-8(可带 BOM),非 UTF-8 按 GBK 解码;无行数上限。
|
||||
</div>
|
||||
</div>
|
||||
<ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="90px">
|
||||
<ElFormItem label="CSV 文件" prop="file">
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".csv"
|
||||
:on-change="handleFileChange"
|
||||
:on-exceed="handleFileExceed"
|
||||
>
|
||||
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
|
||||
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择 .csv 文件</em></div>
|
||||
</ElUpload>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="解除原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="importForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请填写任务级解除原因(1~500 字符)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="二次确认" prop="confirmed">
|
||||
<ElCheckbox v-model="importForm.confirmed">我已确认按模板批量解除关联关系</ElCheckbox>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="importDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="importLoading" @click="confirmImport"
|
||||
>创建导入任务</ElButton
|
||||
>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ElMessage,
|
||||
ElTag,
|
||||
FormInstance,
|
||||
FormRules,
|
||||
UploadFile,
|
||||
UploadInstance
|
||||
} from 'element-plus'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import { PhoneAssetAssociationService, StorageService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type {
|
||||
BatchUnbindAssetItem,
|
||||
BatchUnbindPhoneAssetAssociationResponse,
|
||||
PhoneAssetAssociation,
|
||||
PhoneAssetAssociationStatus
|
||||
} from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'PhoneAssetAssociation' })
|
||||
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const perms = AUGUST_PERMISSIONS.phoneAssetAssociation
|
||||
|
||||
const canBatchUnbind = computed(() => hasAuth(perms.unbindBatch))
|
||||
const canImportCreate = computed(() => hasAuth(perms.unbindImportCreate))
|
||||
const canImportPage = computed(() => hasAuth(perms.unbindImportPage))
|
||||
const canUnbind = computed(() => hasAuth(perms.unbind))
|
||||
|
||||
const loading = ref(false)
|
||||
const associationList = ref<PhoneAssetAssociation[]>([])
|
||||
const selectedRows = ref<PhoneAssetAssociation[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const initialSearchState = {
|
||||
asset_identifier: '',
|
||||
phone: '',
|
||||
status: undefined as PhoneAssetAssociationStatus | undefined,
|
||||
dateRange: [] as string[],
|
||||
created_at_start: '',
|
||||
created_at_end: ''
|
||||
}
|
||||
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '有效', value: 1 as const },
|
||||
{ label: '已失效', value: 0 as const }
|
||||
]
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'asset_identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: 'ICCID / 虚拟号 / IMEI / SN / 接入号(精确匹配)' }
|
||||
},
|
||||
{
|
||||
label: '手机号',
|
||||
prop: 'phone',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: '完整手机号(精确匹配)' }
|
||||
},
|
||||
{
|
||||
label: '关联状态',
|
||||
prop: 'status',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: () => statusOptions
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'dateRange',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '资产类型', prop: 'asset_type' },
|
||||
{ label: '资产标识', prop: 'asset_identifier' },
|
||||
{ label: '手机号', prop: 'phone' },
|
||||
{ label: '建立时间', prop: 'established_at' },
|
||||
{ label: '来源', prop: 'source' },
|
||||
{ label: '状态', prop: 'status' },
|
||||
{ label: '失效时间', prop: 'invalidated_at' },
|
||||
{ label: '失效方式', prop: 'invalidation_method_name' },
|
||||
{ label: '失效原因', prop: 'invalidation_reason' }
|
||||
]
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'asset_type',
|
||||
label: '资产类型',
|
||||
width: 100,
|
||||
formatter: (row: PhoneAssetAssociation) =>
|
||||
row.asset_type === 'iot_card' ? '物联网卡' : '设备'
|
||||
},
|
||||
{
|
||||
prop: 'asset_identifier',
|
||||
label: '资产标识',
|
||||
minWidth: 200,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PhoneAssetAssociation) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: () => goToAsset(row)
|
||||
},
|
||||
row.asset_identifier
|
||||
)
|
||||
},
|
||||
{ prop: 'phone', label: '手机号', width: 140 },
|
||||
{
|
||||
prop: 'established_at',
|
||||
label: '建立时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.established_at) || '-'
|
||||
},
|
||||
{
|
||||
prop: 'source',
|
||||
label: '来源',
|
||||
width: 120,
|
||||
formatter: () => 'H5 短信验证'
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 90,
|
||||
formatter: (row: PhoneAssetAssociation) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: row.status === 1 ? 'success' : 'info' },
|
||||
() => row.status_name || (row.status === 1 ? '有效' : '已失效')
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'invalidated_at',
|
||||
label: '失效时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.invalidated_at) || '-'
|
||||
},
|
||||
{
|
||||
prop: 'invalidation_method_name',
|
||||
label: '失效方式',
|
||||
width: 130,
|
||||
formatter: (row: PhoneAssetAssociation) => row.invalidation_method_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'invalidation_reason',
|
||||
label: '失效原因',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PhoneAssetAssociation) => row.invalidation_reason || '-'
|
||||
}
|
||||
])
|
||||
|
||||
// ========== 列表数据 ==========
|
||||
|
||||
const syncDateRange = () => {
|
||||
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
|
||||
searchForm.created_at_start = searchForm.dateRange[0] || ''
|
||||
searchForm.created_at_end = searchForm.dateRange[1] || ''
|
||||
} else {
|
||||
searchForm.created_at_start = ''
|
||||
searchForm.created_at_end = ''
|
||||
}
|
||||
}
|
||||
|
||||
const toRfc3339 = (date: string, endOfDay = false) => {
|
||||
if (!date) return undefined
|
||||
if (date.includes('T')) return date
|
||||
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}+08:00`
|
||||
}
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
syncDateRange()
|
||||
const res = await PhoneAssetAssociationService.getAssociations({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
asset_identifier: searchForm.asset_identifier || undefined,
|
||||
phone: searchForm.phone || undefined,
|
||||
status: searchForm.status,
|
||||
created_at_start: toRfc3339(searchForm.created_at_start),
|
||||
created_at_end: toRfc3339(searchForm.created_at_end, true)
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
associationList.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取手机号资产关联列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取手机号资产关联列表失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '获取手机号资产关联列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: PhoneAssetAssociation[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const goToAsset = (row: PhoneAssetAssociation) => {
|
||||
router.push({
|
||||
path: RoutesAlias.AssetInformation,
|
||||
query:
|
||||
row.asset_type === 'iot_card'
|
||||
? { iccid: row.asset_identifier }
|
||||
: { virtual_no: row.asset_identifier }
|
||||
})
|
||||
}
|
||||
|
||||
const getActions = (row: PhoneAssetAssociation) => {
|
||||
const actions: any[] = []
|
||||
if (row.status === 1 && canUnbind.value) {
|
||||
actions.push({ label: '解绑', handler: () => openUnbindDialog(row), type: 'danger' })
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// ========== 单条解绑 ==========
|
||||
|
||||
const unbindDialogVisible = ref(false)
|
||||
const unbindLoading = ref(false)
|
||||
const unbindFormRef = ref<FormInstance>()
|
||||
const unbindForm = reactive<{
|
||||
id: number
|
||||
asset_identifier: string
|
||||
phone: string
|
||||
reason: string
|
||||
confirmed: boolean
|
||||
}>({
|
||||
id: 0,
|
||||
asset_identifier: '',
|
||||
phone: '',
|
||||
reason: '',
|
||||
confirmed: false
|
||||
})
|
||||
|
||||
const unbindRules: FormRules = {
|
||||
reason: [
|
||||
{ required: true, message: '请填写解除原因', trigger: 'blur' },
|
||||
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
|
||||
],
|
||||
confirmed: [
|
||||
{
|
||||
validator: (_rule, value: boolean, callback) => {
|
||||
if (!value) callback(new Error('请先勾选二次确认'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const openUnbindDialog = (row: PhoneAssetAssociation) => {
|
||||
unbindForm.id = row.id
|
||||
unbindForm.asset_identifier = row.asset_identifier
|
||||
unbindForm.phone = row.phone
|
||||
unbindForm.reason = ''
|
||||
unbindForm.confirmed = false
|
||||
unbindDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmUnbind = async () => {
|
||||
if (!(await unbindFormRef.value?.validate().catch(() => false))) return
|
||||
unbindLoading.value = true
|
||||
try {
|
||||
const res = await PhoneAssetAssociationService.unbindAssociation(
|
||||
unbindForm.id,
|
||||
unbindForm.reason.trim(),
|
||||
unbindForm.confirmed
|
||||
)
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error(res.msg || '解除关联失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('解除关联成功')
|
||||
unbindDialogVisible.value = false
|
||||
getTableData()
|
||||
} catch (error) {
|
||||
console.error('解除关联失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '解除关联失败')
|
||||
} finally {
|
||||
unbindLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 批量解绑 ==========
|
||||
|
||||
const batchUnbindDialogVisible = ref(false)
|
||||
const batchUnbindLoading = ref(false)
|
||||
const batchFormRef = ref<FormInstance>()
|
||||
const batchForm = reactive({ reason: '', confirmed: false, assets: [] as BatchUnbindAssetItem[] })
|
||||
const batchResult = ref<BatchUnbindPhoneAssetAssociationResponse | null>(null)
|
||||
const batchResultVisible = ref(false)
|
||||
|
||||
const batchRules: FormRules = {
|
||||
reason: [
|
||||
{ required: true, message: '请填写解除原因', trigger: 'blur' },
|
||||
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
|
||||
],
|
||||
confirmed: [
|
||||
{
|
||||
validator: (_rule, value: boolean, callback) => {
|
||||
if (!value) callback(new Error('请先勾选二次确认'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const buildUniqueAssets = (rows: PhoneAssetAssociation[]): BatchUnbindAssetItem[] => {
|
||||
const map = new Map<string, BatchUnbindAssetItem>()
|
||||
rows.forEach((row) => {
|
||||
if (row.status !== 1) return
|
||||
const key = `${row.asset_type}:${row.asset_id}`
|
||||
if (!map.has(key)) map.set(key, { asset_type: row.asset_type, asset_id: row.asset_id })
|
||||
})
|
||||
return Array.from(map.values()).slice(0, 200)
|
||||
}
|
||||
|
||||
const openBatchUnbindDialog = () => {
|
||||
batchForm.assets = buildUniqueAssets(selectedRows.value)
|
||||
if (batchForm.assets.length === 0) {
|
||||
ElMessage.warning('请选择有效关联的资产')
|
||||
return
|
||||
}
|
||||
batchForm.reason = ''
|
||||
batchForm.confirmed = false
|
||||
batchResult.value = null
|
||||
batchUnbindDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmBatchUnbind = async () => {
|
||||
if (!(await batchFormRef.value?.validate().catch(() => false))) return
|
||||
batchUnbindLoading.value = true
|
||||
try {
|
||||
const res = await PhoneAssetAssociationService.batchUnbind({
|
||||
assets: batchForm.assets,
|
||||
reason: batchForm.reason.trim(),
|
||||
confirmed: batchForm.confirmed
|
||||
})
|
||||
if (res.code !== 0 || !res.data) {
|
||||
ElMessage.error(res.msg || '批量解绑失败')
|
||||
return
|
||||
}
|
||||
batchResult.value = res.data
|
||||
batchUnbindDialogVisible.value = false
|
||||
batchResultVisible.value = true
|
||||
getTableData()
|
||||
} catch (error) {
|
||||
console.error('批量解绑失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '批量解绑失败')
|
||||
} finally {
|
||||
batchUnbindLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== CSV 解绑导入 ==========
|
||||
|
||||
const importDialogVisible = ref(false)
|
||||
const importLoading = ref(false)
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const importFormRef = ref<FormInstance>()
|
||||
const importForm = reactive({
|
||||
file: null as File | null,
|
||||
reason: '',
|
||||
confirmed: false
|
||||
})
|
||||
const importRules: FormRules = {
|
||||
file: [
|
||||
{
|
||||
validator: (_rule, _value, callback) => {
|
||||
if (!importForm.file) callback(new Error('请选择 CSV 文件'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
reason: [
|
||||
{ required: true, message: '请填写任务级解除原因', trigger: 'blur' },
|
||||
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
|
||||
],
|
||||
confirmed: [
|
||||
{
|
||||
validator: (_rule, value: boolean, callback) => {
|
||||
if (!value) callback(new Error('请先勾选二次确认'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const openImportDialog = () => {
|
||||
importForm.file = null
|
||||
importForm.reason = ''
|
||||
importForm.confirmed = false
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportDialogClosed = () => {
|
||||
uploadRef.value?.clearFiles()
|
||||
}
|
||||
|
||||
const handleFileChange = (file: UploadFile) => {
|
||||
const rawFile = file.raw
|
||||
if (!rawFile) return
|
||||
if (!rawFile.name.toLowerCase().endsWith('.csv')) {
|
||||
ElMessage.error('解绑导入仅支持 .csv 文件')
|
||||
uploadRef.value?.clearFiles()
|
||||
importForm.file = null
|
||||
return
|
||||
}
|
||||
importForm.file = rawFile
|
||||
}
|
||||
|
||||
const handleFileExceed = () => {
|
||||
ElMessage.warning('仅支持上传一个 CSV 文件')
|
||||
}
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const content = '\uFEFF资产标识,备注\n'
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '手机号资产解绑导入模板.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const goUnbindImportTasks = () => {
|
||||
router.push(RoutesAlias.PhoneAssetUnbindImportTasks)
|
||||
}
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (!(await importFormRef.value?.validate().catch(() => false))) return
|
||||
const file = importForm.file
|
||||
if (!file) return
|
||||
importLoading.value = true
|
||||
try {
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: 'text/csv',
|
||||
purpose: 'phone_unbind_import'
|
||||
})
|
||||
if (uploadUrlRes.code !== 0 || !uploadUrlRes.data) {
|
||||
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
|
||||
return
|
||||
}
|
||||
const { upload_url, file_key } = uploadUrlRes.data
|
||||
await StorageService.uploadFile(upload_url, file, 'text/csv')
|
||||
|
||||
const res = await PhoneAssetAssociationService.createUnbindImport({
|
||||
file_key,
|
||||
reason: importForm.reason.trim(),
|
||||
confirmed: importForm.confirmed
|
||||
})
|
||||
if (res.code !== 0 || !res.data) {
|
||||
ElMessage.error(res.msg || '创建解绑导入任务失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success(`解绑导入任务已创建!任务编号:${res.data.task_no}`)
|
||||
importDialogVisible.value = false
|
||||
goUnbindImportTasks()
|
||||
} catch (error) {
|
||||
console.error('创建解绑导入任务失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '创建解绑导入任务失败')
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.phone-asset-association-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.batch-result-summary {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.import-tips {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.import-tips-detail {
|
||||
margin-top: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="unbind-import-task-detail-page" id="table-full-screen">
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="goBack">
|
||||
<template #icon>
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</template>
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">解绑导入任务详情</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !taskDetail" class="detail-loading">
|
||||
<ElIcon class="is-loading" :size="36">
|
||||
<Loading />
|
||||
</ElIcon>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<template v-else-if="taskDetail">
|
||||
<DetailPage :sections="detailSections" :data="taskDetail" />
|
||||
|
||||
<div class="row-table-title">逐行结果</div>
|
||||
<ElTable :data="taskDetail.items || []" border stripe size="default" max-height="460">
|
||||
<ElTableColumn prop="line" label="行号" width="80" />
|
||||
<ElTableColumn
|
||||
prop="asset_identifier"
|
||||
label="资产标识(原文)"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn prop="asset_type" label="资产类型" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.asset_type ? (row.asset_type === 'iot_card' ? '物联网卡' : '设备') : '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="asset_id" label="资产ID" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.asset_id ? row.asset_id : '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="status" label="行状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.status === 3 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status_name || (row.status === 3 ? '成功' : '失败') }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="associated_phones"
|
||||
label="解绑时关联手机号"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ (row.associated_phones || []).join('、') || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
|
||||
<ElTableColumn prop="reason" label="失败原因" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.reason || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</template>
|
||||
<ElEmpty v-else description="暂无详情" />
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElTag } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { PhoneAssetAssociationService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants'
|
||||
import type {
|
||||
PhoneAssetUnbindImportTaskDetail,
|
||||
PhoneAssetUnbindImportTaskStatus
|
||||
} from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'PhoneAssetUnbindImportTaskDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const taskId = computed(() => Number(route.query.id || route.params.id || 0))
|
||||
const loading = ref(false)
|
||||
const taskDetail = ref<PhoneAssetUnbindImportTaskDetail | null>(null)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const getStatusTagType = (status?: PhoneAssetUnbindImportTaskStatus) => {
|
||||
const map: Record<number, 'info' | 'warning' | 'success' | 'danger'> = {
|
||||
1: 'info',
|
||||
2: 'warning',
|
||||
3: 'success',
|
||||
4: 'danger'
|
||||
}
|
||||
return status ? map[status] || 'info' : 'info'
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
if (!taskId.value) {
|
||||
ElMessage.error('缺少任务ID')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await PhoneAssetAssociationService.getUnbindImportTaskDetail(taskId.value)
|
||||
if (res.code === 403) {
|
||||
ElMessage.error(res.msg || '无权限查看该任务详情')
|
||||
return
|
||||
}
|
||||
if (res.code !== 0 || !res.data) {
|
||||
ElMessage.error(res.msg || '获取解绑导入任务详情失败')
|
||||
return
|
||||
}
|
||||
taskDetail.value = res.data
|
||||
startPollingIfNeeded()
|
||||
} catch (error) {
|
||||
console.error('获取解绑导入任务详情失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '获取解绑导入任务详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startPollingIfNeeded = () => {
|
||||
const status = taskDetail.value?.status
|
||||
if (status === 1 || status === 2) {
|
||||
if (!pollTimer) {
|
||||
pollTimer = setInterval(fetchDetail, 5000)
|
||||
}
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const detailSections = computed((): DetailSection[] => {
|
||||
const data = taskDetail.value
|
||||
if (!data) return []
|
||||
return [
|
||||
{
|
||||
title: '任务基本信息',
|
||||
fields: [
|
||||
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
|
||||
{ label: '文件名', prop: 'file_name', formatter: (value: string) => value || '-' },
|
||||
{
|
||||
label: '任务状态',
|
||||
render: (task: PhoneAssetUnbindImportTaskDetail) =>
|
||||
h(ElTag, { type: getStatusTagType(task.status) }, () => task.status_name || '-')
|
||||
},
|
||||
{
|
||||
label: '任务级解绑原因',
|
||||
prop: 'unbind_reason',
|
||||
fullWidth: true,
|
||||
formatter: (value: string) => value || '-'
|
||||
},
|
||||
{
|
||||
label: '创建人',
|
||||
prop: 'creator_name',
|
||||
formatter: (value: string) => value || '-'
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'created_at',
|
||||
formatter: (value: string) => formatDateTime(value) || '-'
|
||||
},
|
||||
{
|
||||
label: '开始时间',
|
||||
prop: 'started_at',
|
||||
formatter: (value: string) => formatDateTime(value) || '-'
|
||||
},
|
||||
{
|
||||
label: '完成时间',
|
||||
prop: 'completed_at',
|
||||
formatter: (value: string) => formatDateTime(value) || '-'
|
||||
},
|
||||
{
|
||||
label: '任务级错误',
|
||||
prop: 'error_message',
|
||||
fullWidth: true,
|
||||
formatter: (value: string) => value || '-'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '处理统计',
|
||||
fields: [
|
||||
{
|
||||
label: '数据行总数',
|
||||
prop: 'total_count',
|
||||
formatter: (value: number) => String(value ?? 0)
|
||||
},
|
||||
{
|
||||
label: '成功行数',
|
||||
prop: 'success_count',
|
||||
formatter: (value: number) => String(value ?? 0)
|
||||
},
|
||||
{
|
||||
label: '失败行数',
|
||||
prop: 'fail_count',
|
||||
formatter: (value: number) => String(value ?? 0)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.push(RoutesAlias.PhoneAssetUnbindImportTasks)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!hasAuth(AUGUST_PERMISSIONS.phoneAssetAssociation.unbindImportDetail)) {
|
||||
ElMessage.warning('您没有查看解绑导入任务详情的权限')
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
fetchDetail()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.unbind-import-task-detail-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 60px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.row-table-title {
|
||||
margin: 20px 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="unbind-import-tasks-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
label-width="110"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<ElButton @click="goAssociationList">返回关联列表</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
:loading="loading"
|
||||
:data="taskList"
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="90"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { PhoneAssetAssociationService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { PhoneAssetUnbindImportTask, PhoneAssetUnbindImportTaskStatus } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'PhoneAssetUnbindImportTasks' })
|
||||
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const perms = AUGUST_PERMISSIONS.phoneAssetAssociation
|
||||
|
||||
const canDetail = computed(() => hasAuth(perms.unbindImportDetail))
|
||||
|
||||
const loading = ref(false)
|
||||
const taskList = ref<PhoneAssetUnbindImportTask[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const initialSearchState = {
|
||||
status: undefined as PhoneAssetUnbindImportTaskStatus | undefined
|
||||
}
|
||||
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待处理', value: 1 },
|
||||
{ label: '处理中', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '失败', value: 4 }
|
||||
]
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '任务状态',
|
||||
prop: 'status',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: () => statusOptions
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '任务编号', prop: 'task_no' },
|
||||
{ label: '文件名', prop: 'file_name' },
|
||||
{ label: '状态', prop: 'status' },
|
||||
{ label: '总行数', prop: 'total_count' },
|
||||
{ label: '成功行数', prop: 'success_count' },
|
||||
{ label: '失败行数', prop: 'fail_count' },
|
||||
{ label: '任务级原因', prop: 'unbind_reason' },
|
||||
{ label: '创建人', prop: 'creator_name' },
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '开始时间', prop: 'started_at' },
|
||||
{ label: '完成时间', prop: 'completed_at' },
|
||||
{ label: '任务级错误', prop: 'error_message' }
|
||||
]
|
||||
|
||||
const getStatusTagType = (status?: PhoneAssetUnbindImportTaskStatus) => {
|
||||
const map: Record<number, 'info' | 'warning' | 'success' | 'danger'> = {
|
||||
1: 'info',
|
||||
2: 'warning',
|
||||
3: 'success',
|
||||
4: 'danger'
|
||||
}
|
||||
return status ? map[status] || 'info' : 'info'
|
||||
}
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'task_no',
|
||||
label: '任务编号',
|
||||
minWidth: 170,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: 'task-no-link',
|
||||
onClick: () => goDetail(row)
|
||||
},
|
||||
row.task_no || '-'
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'file_name',
|
||||
label: '文件名',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => row.file_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) =>
|
||||
h(ElTag, { type: getStatusTagType(row.status) }, () => row.status_name || '-')
|
||||
},
|
||||
{
|
||||
prop: 'total_count',
|
||||
label: '总行数',
|
||||
width: 90,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => row.total_count ?? 0
|
||||
},
|
||||
{
|
||||
prop: 'success_count',
|
||||
label: '成功行数',
|
||||
width: 90,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => row.success_count ?? 0
|
||||
},
|
||||
{
|
||||
prop: 'fail_count',
|
||||
label: '失败行数',
|
||||
width: 90,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => row.fail_count ?? 0
|
||||
},
|
||||
{
|
||||
prop: 'unbind_reason',
|
||||
label: '任务级原因',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => row.unbind_reason || '-'
|
||||
},
|
||||
{
|
||||
prop: 'creator_name',
|
||||
label: '创建人',
|
||||
width: 120,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => row.creator_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => formatDateTime(row.created_at) || '-'
|
||||
},
|
||||
{
|
||||
prop: 'started_at',
|
||||
label: '开始时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => formatDateTime(row.started_at) || '-'
|
||||
},
|
||||
{
|
||||
prop: 'completed_at',
|
||||
label: '完成时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => formatDateTime(row.completed_at) || '-'
|
||||
},
|
||||
{
|
||||
prop: 'error_message',
|
||||
label: '任务级错误',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PhoneAssetUnbindImportTask) => row.error_message || '-'
|
||||
}
|
||||
])
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await PhoneAssetAssociationService.getUnbindImportTasks({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
status: searchForm.status
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
taskList.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取解绑导入任务列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取解绑导入任务列表失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '获取解绑导入任务列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const goDetail = (row: PhoneAssetUnbindImportTask) => {
|
||||
if (!canDetail.value) {
|
||||
ElMessage.warning('您没有查看解绑导入任务详情的权限')
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: RoutesAlias.PhoneAssetUnbindImportTaskDetail,
|
||||
query: { id: String(row.id) }
|
||||
})
|
||||
}
|
||||
|
||||
const getActions = (row: PhoneAssetUnbindImportTask) => {
|
||||
const actions: any[] = []
|
||||
if (canDetail.value) {
|
||||
actions.push({ label: '详情', handler: () => goDetail(row), type: 'primary' })
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
const goAssociationList = () => {
|
||||
router.push(RoutesAlias.PhoneAssetAssociation)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.unbind-import-tasks-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.task-no-link) {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -340,7 +340,8 @@
|
||||
{
|
||||
prop: 'to_owner_name',
|
||||
label: '目标所有者',
|
||||
width: 150,
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: AssetAllocationRecord) => row.to_owner_name || '-'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,9 +54,24 @@
|
||||
<ElTabs v-model="activeTab" class="detail-tabs">
|
||||
<!-- 佣金明细 Tab -->
|
||||
<ElTabPane label="佣金明细" name="commission">
|
||||
<div class="commission-export-actions">
|
||||
<ElButton
|
||||
v-if="hasAuth('commission_record:export')"
|
||||
@click="commissionExportDialogVisible = true"
|
||||
>
|
||||
导出
|
||||
</ElButton>
|
||||
</div>
|
||||
<ExportTaskCreateDialog
|
||||
v-model="commissionExportDialogVisible"
|
||||
scene="commission_record"
|
||||
:query="commissionExportQuery"
|
||||
confirm-permission="commission_record:export"
|
||||
title="导出佣金记录"
|
||||
/>
|
||||
<ArtTable
|
||||
ref="commissionTableRef"
|
||||
row-key="id"
|
||||
:row-key="getCommissionRowKey"
|
||||
:loading="commissionLoading"
|
||||
:data="commissionRecords"
|
||||
:currentPage="commissionPagination.page"
|
||||
@@ -70,14 +85,30 @@
|
||||
<template #default>
|
||||
<ElTableColumn label="佣金金额" prop="amount" width="120">
|
||||
<template #default="scope">
|
||||
<span style="font-weight: 500; color: var(--el-color-success)">
|
||||
<span
|
||||
:class="Number(scope.row.amount) < 0 ? 'amount-negative' : 'amount-positive'"
|
||||
>
|
||||
{{ formatMoney(scope.row.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="入账后余额" prop="balance_after" width="120">
|
||||
<ElTableColumn label="记录来源" prop="source" width="150">
|
||||
<template #default="scope">
|
||||
{{ formatMoney(scope.row.balance_after) }}
|
||||
<div class="record-source-cell">
|
||||
<ElTag :type="commissionRecordSourceMeta(scope.row).type" size="small">
|
||||
{{ commissionRecordSourceMeta(scope.row).label }}
|
||||
</ElTag>
|
||||
<ElTag v-if="isCommissionClawbackRecord(scope.row)" type="danger" size="small">
|
||||
不可提现
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="入账后余额" prop="balance_after" width="130">
|
||||
<template #default="scope">
|
||||
<span :class="{ 'amount-negative': Number(scope.row.balance_after) < 0 }">
|
||||
{{ formatMoney(scope.row.balance_after) }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="佣金来源" prop="commission_source" width="120">
|
||||
@@ -133,21 +164,27 @@
|
||||
{{ formatDateTime(scope.row.created_at) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="150" fixed="right">
|
||||
<ElTableColumn label="操作" width="210" fixed="right">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.status === 99" style="display: flex; gap: 8px">
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<ArtButtonTable
|
||||
text="入账"
|
||||
iconColor="#67C23A"
|
||||
@click="handleResolveCommission(scope.row, 'release')"
|
||||
/>
|
||||
<ArtButtonTable
|
||||
text="作废"
|
||||
iconColor="#F56C6C"
|
||||
@click="handleResolveCommission(scope.row, 'invalidate')"
|
||||
text="详情"
|
||||
iconColor="#409EFF"
|
||||
@click="openCommissionRecordDetail(scope.row)"
|
||||
/>
|
||||
<template v-if="scope.row.status === 99">
|
||||
<ArtButtonTable
|
||||
text="入账"
|
||||
iconColor="#67C23A"
|
||||
@click="handleResolveCommission(scope.row, 'release')"
|
||||
/>
|
||||
<ArtButtonTable
|
||||
text="作废"
|
||||
iconColor="#F56C6C"
|
||||
@click="handleResolveCommission(scope.row, 'invalidate')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
@@ -429,6 +466,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 佣金明细详情 -->
|
||||
<CommissionRecordDetailDialog
|
||||
v-model="commissionDetailVisible"
|
||||
:shop-id="currentShop?.shop_id"
|
||||
:record-id="commissionDetailId"
|
||||
:source="commissionDetailSource"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -445,7 +490,8 @@
|
||||
WithdrawalRequestItem,
|
||||
CommissionResolveAction,
|
||||
MainWalletTransactionItem,
|
||||
MainWalletTransactionQueryParams
|
||||
MainWalletTransactionQueryParams,
|
||||
ShopCommissionRecordSource
|
||||
} from '@/types/api/commission'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
@@ -456,10 +502,14 @@
|
||||
CommissionStatusMap,
|
||||
WithdrawalStatusMap,
|
||||
WithdrawalMethodMap,
|
||||
CommissionSourceMap
|
||||
CommissionSourceMap,
|
||||
COMMISSION_RECORD_SOURCE_MAP,
|
||||
getCommissionRecordRowKey,
|
||||
isCommissionClawbackRecord
|
||||
} from '@/config/constants/commission'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import CommissionRecordDetailDialog from '@/components/business/CommissionRecordDetailDialog.vue'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveFinanceAuditTarget } from '@/utils/business/auditNavigation'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
@@ -518,6 +568,27 @@
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 佣金明细详情
|
||||
const commissionDetailVisible = ref(false)
|
||||
const commissionDetailId = ref<number | null>(null)
|
||||
const commissionDetailSource = ref<ShopCommissionRecordSource>('original')
|
||||
|
||||
const getCommissionRowKey = getCommissionRecordRowKey
|
||||
const commissionRecordSourceMeta = (row: ShopCommissionRecordItem) =>
|
||||
COMMISSION_RECORD_SOURCE_MAP[isCommissionClawbackRecord(row) ? 'clawback' : 'original']
|
||||
|
||||
const openCommissionRecordDetail = (row: ShopCommissionRecordItem) => {
|
||||
commissionDetailId.value = row.id
|
||||
commissionDetailSource.value = isCommissionClawbackRecord(row) ? 'clawback' : 'original'
|
||||
commissionDetailVisible.value = true
|
||||
}
|
||||
|
||||
// 导出佣金记录(仅支持 shop_id 筛选)
|
||||
const commissionExportDialogVisible = ref(false)
|
||||
const commissionExportQuery = computed(() => ({
|
||||
shop_id: currentShop.value?.shop_id
|
||||
}))
|
||||
|
||||
// 提现记录状态
|
||||
const withdrawalLoading = ref(false)
|
||||
const withdrawalTableRef = ref()
|
||||
@@ -1204,4 +1275,26 @@
|
||||
:deep(.el-table__row.table-row-with-context-menu) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.amount-positive {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.amount-negative {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.record-source-cell {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.commission-export-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
v-permission="'my_commission:add'"
|
||||
>发起提现</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-permission="'commission_record:export'"
|
||||
@click="exportDialogVisible = true"
|
||||
>导出</ElButton
|
||||
>
|
||||
|
||||
<div class="commission-summary">
|
||||
<div class="commission-summary-item">
|
||||
@@ -73,7 +78,7 @@
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="commissionTableRef"
|
||||
row-key="id"
|
||||
:row-key="getCommissionRowKey"
|
||||
:loading="commissionLoading"
|
||||
:data="commissionList"
|
||||
:currentPage="commissionPagination.page"
|
||||
@@ -122,6 +127,8 @@
|
||||
:currentPage="withdrawalPagination.page"
|
||||
:pageSize="withdrawalPagination.pageSize"
|
||||
:total="withdrawalPagination.total"
|
||||
:actions="getWithdrawalActions"
|
||||
:actionsWidth="150"
|
||||
:marginTop="10"
|
||||
:height="500"
|
||||
@size-change="handleWithdrawalSizeChange"
|
||||
@@ -136,6 +143,49 @@
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
<!-- 提现资料资格 -->
|
||||
<ElTabPane label="提现资料" name="qualification">
|
||||
<ArtTableHeader
|
||||
:columnList="qualificationColumnOptions"
|
||||
v-model:columns="qualificationColumnChecks"
|
||||
@refresh="getQualificationList"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
v-permission="'my_commission:add'"
|
||||
@click="showQualificationDialog"
|
||||
>
|
||||
提交/更新资料
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="qualificationTableRef"
|
||||
row-key="id"
|
||||
:loading="qualificationLoading"
|
||||
:data="qualificationList"
|
||||
:currentPage="qualificationPagination.page"
|
||||
:pageSize="qualificationPagination.pageSize"
|
||||
:total="qualificationPagination.total"
|
||||
:actions="getQualificationActions"
|
||||
:actionsWidth="100"
|
||||
:marginTop="10"
|
||||
:height="500"
|
||||
@size-change="handleQualificationSizeChange"
|
||||
@current-change="handleQualificationCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn
|
||||
v-for="col in qualificationColumns"
|
||||
:key="col.prop || col.type"
|
||||
v-bind="col"
|
||||
/>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCard>
|
||||
|
||||
@@ -234,13 +284,259 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
<!-- 提交/更新提现资料资格对话框 -->
|
||||
<ElDialog
|
||||
v-model="qualificationDialogVisible"
|
||||
title="提现资料资格"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ElForm
|
||||
ref="qualificationFormRef"
|
||||
:model="qualificationForm"
|
||||
:rules="qualificationRules"
|
||||
label-width="110px"
|
||||
>
|
||||
<ElFormItem label="主体类型" prop="subject_type">
|
||||
<ElRadioGroup v-model="qualificationForm.subject_type">
|
||||
<ElRadio label="enterprise">企业</ElRadio>
|
||||
<ElRadio label="individual">个人</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="主体证件号" prop="subject_code">
|
||||
<ElInput
|
||||
v-model="qualificationForm.subject_code"
|
||||
placeholder="统一社会信用代码/证件号"
|
||||
maxlength="50"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="法人身份证号" prop="legal_person_id_card">
|
||||
<ElInput
|
||||
v-model="qualificationForm.legal_person_id_card"
|
||||
placeholder="请输入法人/经营者身份证号"
|
||||
maxlength="18"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="合同" prop="contract_file_key">
|
||||
<VoucherUpload
|
||||
v-model="qualificationForm.contract_file_key"
|
||||
voucher-name="合同"
|
||||
:max-count="1"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="身份证正面" prop="id_card_front_file_key">
|
||||
<VoucherUpload
|
||||
v-model="qualificationForm.id_card_front_file_key"
|
||||
voucher-name="身份证正面"
|
||||
:max-count="1"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="身份证背面" prop="id_card_back_file_key">
|
||||
<VoucherUpload
|
||||
v-model="qualificationForm.id_card_back_file_key"
|
||||
voucher-name="身份证背面"
|
||||
:max-count="1"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="营业执照">
|
||||
<VoucherUpload
|
||||
v-model="qualificationForm.business_license_file_key"
|
||||
voucher-name="营业执照"
|
||||
:max-count="1"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="门头照">
|
||||
<VoucherUpload
|
||||
v-model="qualificationForm.shop_front_file_key"
|
||||
voucher-name="门头照"
|
||||
:max-count="1"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="发票">
|
||||
<VoucherUpload
|
||||
v-model="qualificationForm.invoice_file_key"
|
||||
voucher-name="发票"
|
||||
:max-count="1"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<div class="qualification-row">
|
||||
<ElFormItem label="发票抬头">
|
||||
<ElInput
|
||||
v-model="qualificationForm.invoice_title"
|
||||
placeholder="选填"
|
||||
maxlength="100"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="发票主体证件号">
|
||||
<ElInput
|
||||
v-model="qualificationForm.invoice_subject_code"
|
||||
placeholder="选填"
|
||||
maxlength="50"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="qualificationDialogVisible = false">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="qualificationSubmitting"
|
||||
@click="handleSubmitQualification"
|
||||
>
|
||||
提交
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 提现详情抽屉 -->
|
||||
<ElDrawer v-model="detailDrawerVisible" title="提现申请详情" size="520px">
|
||||
<div v-loading="detailLoading" class="withdrawal-detail">
|
||||
<template v-if="withdrawalDetail">
|
||||
<ElDescriptions :column="1" border>
|
||||
<ElDescriptionsItem label="提现单号">{{ withdrawalDetail.withdrawal_no }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="状态">{{ withdrawalDetail.status_name }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="提现金额">{{ formatMoney(withdrawalDetail.amount) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="实际到账">{{ formatMoney(withdrawalDetail.actual_amount) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="手续费">{{ formatMoney(withdrawalDetail.fee) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="提现方式">
|
||||
{{ withdrawalMethodLabel(withdrawalDetail.withdrawal_method) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="收款账户">
|
||||
{{ withdrawalDetail.account_name }} / {{ withdrawalDetail.account_number }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="驳回原因">{{ withdrawalDetail.reject_reason || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="异常标记">
|
||||
<ElTag :type="withdrawalDetail.anomaly_flag ? 'danger' : 'success'">
|
||||
{{ withdrawalDetail.anomaly_name }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="withdrawalDetail.anomaly_reason" label="异常原因">
|
||||
{{ withdrawalDetail.anomaly_reason }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="尝试记录">
|
||||
<template v-if="withdrawalDetail.attempts && withdrawalDetail.attempts.length > 0">
|
||||
<div
|
||||
v-for="attempt in withdrawalDetail.attempts"
|
||||
:key="attempt.attempt"
|
||||
class="attempt-item"
|
||||
>
|
||||
<span>第 {{ attempt.attempt }} 次</span>
|
||||
<ElTag size="small">{{ attempt.status_name }}</ElTag>
|
||||
<span v-if="attempt.reject_reason" class="attempt-reason">
|
||||
{{ attempt.reject_reason }}
|
||||
</span>
|
||||
<span class="attempt-time">{{ formatDateTime(attempt.created_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</template>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
|
||||
<!-- 重新提交被驳回的提现 -->
|
||||
<ElDialog
|
||||
v-model="resubmitDialogVisible"
|
||||
title="重新提交提现"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ElForm
|
||||
ref="resubmitFormRef"
|
||||
:model="resubmitForm"
|
||||
:rules="resubmitRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<ElFormItem label="提现金额" prop="amount">
|
||||
<ElInputNumber
|
||||
v-model="resubmitForm.amount"
|
||||
:min="1"
|
||||
:max="withdrawalAmountMax"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="提现方式" prop="withdrawal_method">
|
||||
<ElRadioGroup v-model="resubmitForm.withdrawal_method">
|
||||
<ElRadio :label="WithdrawalMethod.ALIPAY">支付宝</ElRadio>
|
||||
<ElRadio :label="WithdrawalMethod.WECHAT">微信</ElRadio>
|
||||
<ElRadio :label="WithdrawalMethod.BANK">银行卡</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<template v-if="resubmitForm.withdrawal_method !== WithdrawalMethod.BANK">
|
||||
<ElFormItem label="账户名" prop="account_name">
|
||||
<ElInput v-model="resubmitForm.account_name" placeholder="请输入账户名" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="账号" prop="account_number">
|
||||
<ElInput v-model="resubmitForm.account_number" placeholder="请输入账号" />
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElFormItem label="银行名称" prop="bank_name">
|
||||
<ElSelect v-model="resubmitForm.bank_name" placeholder="请选择银行" style="width: 100%">
|
||||
<ElOption v-for="bank in bankOptions" :key="bank" :label="bank" :value="bank" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="账户名" prop="account_name">
|
||||
<ElInput v-model="resubmitForm.account_name" placeholder="请输入账户名" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="卡号" prop="account_number">
|
||||
<ElInput v-model="resubmitForm.account_number" placeholder="请输入银行卡号" />
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<ElFormItem label="发票附件">
|
||||
<VoucherUpload
|
||||
v-model="resubmitForm.invoice_keys"
|
||||
voucher-name="发票"
|
||||
:max-count="5"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="resubmitDialogVisible = false">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="resubmitSubmitting"
|
||||
@click="handleSubmitResubmit"
|
||||
>
|
||||
提交
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 佣金明细详情 -->
|
||||
<CommissionRecordDetailDialog
|
||||
v-model="commissionDetailVisible"
|
||||
:shop-id="currentShopId"
|
||||
:record-id="commissionDetailId"
|
||||
:source="commissionDetailSource"
|
||||
/>
|
||||
|
||||
<!-- 导出佣金记录 -->
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="commission_record"
|
||||
:query="exportQuery"
|
||||
confirm-permission="commission_record:export"
|
||||
title="导出佣金记录"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { CommissionService, OrderService } from '@/api/modules'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type {
|
||||
MyCommissionSummary,
|
||||
@@ -249,11 +545,24 @@
|
||||
CommissionRecordQueryParams,
|
||||
WithdrawalRequestQueryParams,
|
||||
SubmitWithdrawalParams,
|
||||
WithdrawalStatus
|
||||
WithdrawalQualificationItem,
|
||||
WithdrawalQualificationSubmitParams,
|
||||
WithdrawalRequestDetail,
|
||||
ResubmitWithdrawalRequestParams,
|
||||
ShopCommissionRecordSource,
|
||||
} from '@/types/api/commission'
|
||||
import { WithdrawalMethod } from '@/types/api/commission'
|
||||
import { WithdrawalMethod, WithdrawalStatus } from '@/types/api/commission'
|
||||
import type { Order, OrderQueryParams } from '@/types/api/order'
|
||||
import { WithdrawalStatusMap, WithdrawalMethodMap } from '@/config/constants/commission'
|
||||
import {
|
||||
WithdrawalStatusMap,
|
||||
WithdrawalMethodMap,
|
||||
COMMISSION_RECORD_SOURCE_MAP,
|
||||
COMMISSION_RECORD_STATUS_OPTIONS,
|
||||
getCommissionRecordRowKey,
|
||||
isCommissionClawbackRecord
|
||||
} from '@/config/constants/commission'
|
||||
import CommissionRecordDetailDialog from '@/components/business/CommissionRecordDetailDialog.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
@@ -349,12 +658,10 @@
|
||||
label: '结算状态',
|
||||
prop: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '已冻结', value: 1 },
|
||||
{ label: '解冻中', value: 2 },
|
||||
{ label: '已发放', value: 3 },
|
||||
{ label: '已失效', value: 4 }
|
||||
],
|
||||
options: COMMISSION_RECORD_STATUS_OPTIONS.map((item) => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
})),
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '请选择状态'
|
||||
@@ -431,6 +738,7 @@
|
||||
const commissionColumnOptions = [
|
||||
{ label: '佣金金额', prop: 'amount' },
|
||||
{ label: '佣金来源', prop: 'commission_source' },
|
||||
{ label: '记录来源', prop: 'source' },
|
||||
{ label: '状态', prop: 'status_name' },
|
||||
{ label: 'ICCID', prop: 'iccid' },
|
||||
{ label: '虚拟号', prop: 'virtual_no' },
|
||||
@@ -442,6 +750,28 @@
|
||||
|
||||
const commissionList = ref<ShopCommissionRecordItem[]>([])
|
||||
|
||||
// 佣金明细详情与导出
|
||||
const commissionDetailVisible = ref(false)
|
||||
const commissionDetailId = ref<number | null>(null)
|
||||
const commissionDetailSource = ref<ShopCommissionRecordSource>('original')
|
||||
const exportDialogVisible = ref(false)
|
||||
|
||||
const getCommissionRowKey = getCommissionRecordRowKey
|
||||
|
||||
const openCommissionDetail = (row: ShopCommissionRecordItem) => {
|
||||
commissionDetailId.value = row.id
|
||||
commissionDetailSource.value = isCommissionClawbackRecord(row) ? 'clawback' : 'original'
|
||||
commissionDetailVisible.value = true
|
||||
}
|
||||
|
||||
// 导出仅支持 shop_id / status / commission_source / order_no
|
||||
const exportQuery = computed(() => ({
|
||||
shop_id: currentShopId.value,
|
||||
status: commissionSearchForm.status,
|
||||
commission_source: commissionSearchForm.commission_source,
|
||||
order_no: commissionSearchForm.order_no || undefined
|
||||
}))
|
||||
|
||||
// 佣金来源映射
|
||||
const CommissionSourceMap = {
|
||||
cost_diff: { label: '成本差价', type: 'primary' as const },
|
||||
@@ -454,8 +784,27 @@
|
||||
{
|
||||
prop: 'amount',
|
||||
label: '佣金金额',
|
||||
width: 120,
|
||||
formatter: (row: ShopCommissionRecordItem) => formatMoney(row.amount)
|
||||
width: 130,
|
||||
formatter: (row: ShopCommissionRecordItem) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: Number(row.amount) < 0 ? 'amount-negative' : '' },
|
||||
formatMoney(row.amount)
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'source',
|
||||
label: '记录来源',
|
||||
width: 150,
|
||||
formatter: (row: ShopCommissionRecordItem) => {
|
||||
const clawback = isCommissionClawbackRecord(row)
|
||||
const meta = COMMISSION_RECORD_SOURCE_MAP[clawback ? 'clawback' : 'original']
|
||||
const children = [h(ElTag, { type: meta.type, size: 'small' }, () => meta.label)]
|
||||
if (clawback) {
|
||||
children.push(h(ElTag, { type: 'danger', size: 'small' }, () => '不可提现'))
|
||||
}
|
||||
return h('span', { class: 'record-source-cell' }, children)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'commission_source',
|
||||
@@ -503,14 +852,30 @@
|
||||
{
|
||||
prop: 'balance_after',
|
||||
label: '入账后余额',
|
||||
width: 120,
|
||||
formatter: (row: ShopCommissionRecordItem) => formatMoney(row.balance_after)
|
||||
width: 130,
|
||||
formatter: (row: ShopCommissionRecordItem) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: Number(row.balance_after) < 0 ? 'amount-negative' : '' },
|
||||
formatMoney(row.balance_after)
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 180,
|
||||
formatter: (row: ShopCommissionRecordItem) => formatDateTime(row.created_at)
|
||||
},
|
||||
{
|
||||
prop: 'actions',
|
||||
label: '操作',
|
||||
width: 90,
|
||||
formatter: (row: ShopCommissionRecordItem) =>
|
||||
h(
|
||||
ElButton,
|
||||
{ link: true, type: 'primary', onClick: () => openCommissionDetail(row) },
|
||||
() => '详情'
|
||||
)
|
||||
}
|
||||
]
|
||||
)
|
||||
@@ -966,9 +1331,418 @@
|
||||
getCommissionList()
|
||||
} else if (newTab === 'withdrawal') {
|
||||
getWithdrawalList()
|
||||
} else if (newTab === 'qualification') {
|
||||
getQualificationList()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ==================== 提现资料资格 ====================
|
||||
|
||||
const qualificationLoading = ref(false)
|
||||
const qualificationTableRef = ref()
|
||||
const qualificationList = ref<WithdrawalQualificationItem[]>([])
|
||||
const qualificationPagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const qualificationColumnOptions = [
|
||||
{ label: '主体类型', prop: 'subject_type_name' },
|
||||
{ label: '主体证件号', prop: 'subject_code_masked' },
|
||||
{ label: '法人身份证', prop: 'legal_person_id_card_masked' },
|
||||
{ label: '状态', prop: 'status_name' },
|
||||
{ label: '审批状态', prop: 'approval_status_name' },
|
||||
{ label: '附件', prop: 'attachments' },
|
||||
{ label: '作废原因', prop: 'invalid_reason' },
|
||||
{ label: '作废时间', prop: 'invalidated_at' },
|
||||
{ label: '创建时间', prop: 'created_at' }
|
||||
]
|
||||
|
||||
const qualificationAttachmentLabels = (row: WithdrawalQualificationItem): string => {
|
||||
const labels: string[] = []
|
||||
if (row.contract_file_key) labels.push('合同')
|
||||
if (row.id_card_front_file_key) labels.push('身份证正面')
|
||||
if (row.id_card_back_file_key) labels.push('身份证背面')
|
||||
if (row.business_license_file_key) labels.push('营业执照')
|
||||
if (row.shop_front_file_key) labels.push('门头照')
|
||||
if (row.invoice_file_key) labels.push('发票')
|
||||
return labels.length > 0 ? labels.join('、') : '-'
|
||||
}
|
||||
|
||||
const {
|
||||
columnChecks: qualificationColumnChecks,
|
||||
columns: qualificationColumns
|
||||
} = useCheckedColumns(() => [
|
||||
{ prop: 'subject_type_name', label: '主体类型', width: 90 },
|
||||
{ prop: 'subject_code_masked', label: '主体证件号', minWidth: 160 },
|
||||
{ prop: 'legal_person_id_card_masked', label: '法人身份证', minWidth: 170 },
|
||||
{
|
||||
prop: 'status_name',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: WithdrawalQualificationItem) => {
|
||||
const tagType = row.status === 0 ? 'warning' : row.status === 1 ? 'success' : 'danger'
|
||||
return h(ElTag, { type: tagType }, () => row.status_name || String(row.status))
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'approval_status_name',
|
||||
label: '审批状态',
|
||||
width: 100,
|
||||
formatter: (row: WithdrawalQualificationItem) => row.approval_status_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'attachments',
|
||||
label: '附件',
|
||||
minWidth: 200,
|
||||
formatter: (row: WithdrawalQualificationItem) => qualificationAttachmentLabels(row)
|
||||
},
|
||||
{
|
||||
prop: 'invalid_reason',
|
||||
label: '作废原因',
|
||||
minWidth: 160,
|
||||
formatter: (row: WithdrawalQualificationItem) => row.invalid_reason || '-'
|
||||
},
|
||||
{
|
||||
prop: 'invalidated_at',
|
||||
label: '作废时间',
|
||||
width: 170,
|
||||
formatter: (row: WithdrawalQualificationItem) =>
|
||||
row.invalidated_at ? formatDateTime(row.invalidated_at) : '-'
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 170,
|
||||
formatter: (row: WithdrawalQualificationItem) => formatDateTime(row.created_at)
|
||||
}
|
||||
])
|
||||
|
||||
const getQualificationList = async () => {
|
||||
if (!currentShopId.value) return
|
||||
qualificationLoading.value = true
|
||||
try {
|
||||
const res = await CommissionService.getWithdrawalQualifications(currentShopId.value, {
|
||||
page: qualificationPagination.page,
|
||||
page_size: qualificationPagination.pageSize
|
||||
})
|
||||
if (res.code === 0) {
|
||||
qualificationList.value = res.data.items || []
|
||||
qualificationPagination.total = res.data.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
qualificationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQualificationSizeChange = (newPageSize: number) => {
|
||||
qualificationPagination.pageSize = newPageSize
|
||||
getQualificationList()
|
||||
}
|
||||
|
||||
const handleQualificationCurrentChange = (newCurrentPage: number) => {
|
||||
qualificationPagination.page = newCurrentPage
|
||||
getQualificationList()
|
||||
}
|
||||
|
||||
const qualificationDialogVisible = ref(false)
|
||||
const qualificationSubmitting = ref(false)
|
||||
const qualificationFormRef = ref<FormInstance>()
|
||||
|
||||
interface QualificationForm {
|
||||
subject_type: string
|
||||
subject_code: string
|
||||
legal_person_id_card: string
|
||||
contract_file_key: string[]
|
||||
id_card_front_file_key: string[]
|
||||
id_card_back_file_key: string[]
|
||||
business_license_file_key: string[]
|
||||
shop_front_file_key: string[]
|
||||
invoice_file_key: string[]
|
||||
invoice_title: string
|
||||
invoice_subject_code: string
|
||||
}
|
||||
|
||||
const qualificationForm = reactive<QualificationForm>({
|
||||
subject_type: 'enterprise',
|
||||
subject_code: '',
|
||||
legal_person_id_card: '',
|
||||
contract_file_key: [],
|
||||
id_card_front_file_key: [],
|
||||
id_card_back_file_key: [],
|
||||
business_license_file_key: [],
|
||||
shop_front_file_key: [],
|
||||
invoice_file_key: [],
|
||||
invoice_title: '',
|
||||
invoice_subject_code: ''
|
||||
})
|
||||
|
||||
const qualificationRules = reactive<FormRules>({
|
||||
subject_type: [{ required: true, message: '请选择主体类型', trigger: 'change' }],
|
||||
subject_code: [{ required: true, message: '请输入主体证件号', trigger: 'blur' }],
|
||||
legal_person_id_card: [{ required: true, message: '请输入法人身份证号', trigger: 'blur' }],
|
||||
contract_file_key: [{ required: true, message: '请上传合同附件', trigger: 'change' }],
|
||||
id_card_front_file_key: [{ required: true, message: '请上传身份证正面', trigger: 'change' }],
|
||||
id_card_back_file_key: [{ required: true, message: '请上传身份证背面', trigger: 'change' }]
|
||||
})
|
||||
|
||||
const showQualificationDialog = () => {
|
||||
qualificationDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmitQualification = async () => {
|
||||
if (!qualificationFormRef.value) return
|
||||
if (!currentShopId.value) {
|
||||
ElMessage.warning('未找到店铺信息')
|
||||
return
|
||||
}
|
||||
await qualificationFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
qualificationSubmitting.value = true
|
||||
try {
|
||||
const params: WithdrawalQualificationSubmitParams = {
|
||||
subject_type: qualificationForm.subject_type,
|
||||
subject_code: qualificationForm.subject_code.trim(),
|
||||
legal_person_id_card: qualificationForm.legal_person_id_card.trim(),
|
||||
contract_file_key: qualificationForm.contract_file_key[0] || '',
|
||||
id_card_front_file_key: qualificationForm.id_card_front_file_key[0] || '',
|
||||
id_card_back_file_key: qualificationForm.id_card_back_file_key[0] || ''
|
||||
}
|
||||
if (qualificationForm.business_license_file_key[0]) {
|
||||
params.business_license_file_key = qualificationForm.business_license_file_key[0]
|
||||
}
|
||||
if (qualificationForm.shop_front_file_key[0]) {
|
||||
params.shop_front_file_key = qualificationForm.shop_front_file_key[0]
|
||||
}
|
||||
if (qualificationForm.invoice_file_key[0]) {
|
||||
params.invoice_file_key = qualificationForm.invoice_file_key[0]
|
||||
}
|
||||
if (qualificationForm.invoice_title.trim()) {
|
||||
params.invoice_title = qualificationForm.invoice_title.trim()
|
||||
}
|
||||
if (qualificationForm.invoice_subject_code.trim()) {
|
||||
params.invoice_subject_code = qualificationForm.invoice_subject_code.trim()
|
||||
}
|
||||
|
||||
const res = await CommissionService.submitWithdrawalQualification(
|
||||
currentShopId.value!,
|
||||
params
|
||||
)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('提现资料资格提交成功')
|
||||
qualificationDialogVisible.value = false
|
||||
getQualificationList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
qualificationSubmitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getQualificationActions = (row: WithdrawalQualificationItem) => {
|
||||
if (!userStore.isSuperAdmin) return []
|
||||
return [
|
||||
{
|
||||
label: '作废',
|
||||
handler: () => handleVoidQualification(row),
|
||||
type: 'danger' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const handleVoidQualification = async (row: WithdrawalQualificationItem) => {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入作废原因', '作废提现资料资格', {
|
||||
confirmButtonText: '确认作废',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '作废原因(必填)',
|
||||
inputValidator: (reason: string) =>
|
||||
reason && reason.trim() ? true : '作废原因必填',
|
||||
type: 'warning'
|
||||
})
|
||||
await CommissionService.voidWithdrawalQualification(row.id, { reason: value.trim() })
|
||||
ElMessage.success('资格已作废')
|
||||
getQualificationList()
|
||||
} catch {
|
||||
// 取消或请求失败
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提现详情与重提 ====================
|
||||
|
||||
const detailDrawerVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const withdrawalDetail = ref<WithdrawalRequestDetail | null>(null)
|
||||
|
||||
const withdrawalMethodLabel = (method: string) => {
|
||||
const config = WithdrawalMethodMap[method as keyof typeof WithdrawalMethodMap]
|
||||
return config ? config.label : method
|
||||
}
|
||||
|
||||
const openWithdrawalDetail = async (row: WithdrawalRequestItem) => {
|
||||
if (!currentShopId.value) return
|
||||
detailDrawerVisible.value = true
|
||||
detailLoading.value = true
|
||||
withdrawalDetail.value = null
|
||||
try {
|
||||
const res = await CommissionService.getWithdrawalRequestDetail(
|
||||
currentShopId.value,
|
||||
row.id
|
||||
)
|
||||
if (res.code === 0) {
|
||||
withdrawalDetail.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getWithdrawalActions = (row: WithdrawalRequestItem) => {
|
||||
const actions: Array<{ label: string; handler: (row: any) => void; type?: 'primary' | 'danger' }> = [
|
||||
{
|
||||
label: '详情',
|
||||
handler: () => openWithdrawalDetail(row),
|
||||
type: 'primary'
|
||||
}
|
||||
]
|
||||
if (row.status === WithdrawalStatus.REJECTED) {
|
||||
actions.push({
|
||||
label: '重新提交',
|
||||
handler: () => openResubmitDialog(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
const resubmitDialogVisible = ref(false)
|
||||
const resubmitSubmitting = ref(false)
|
||||
const resubmitFormRef = ref<FormInstance>()
|
||||
const resubmitTargetId = ref<number | null>(null)
|
||||
const bankOptions = [
|
||||
'中国工商银行',
|
||||
'中国建设银行',
|
||||
'中国农业银行',
|
||||
'中国银行',
|
||||
'招商银行',
|
||||
'交通银行',
|
||||
'中国邮政储蓄银行',
|
||||
'其他银行'
|
||||
]
|
||||
|
||||
const resubmitForm = reactive<{
|
||||
amount: number | undefined
|
||||
withdrawal_method: WithdrawalMethod
|
||||
account_name: string
|
||||
account_number: string
|
||||
bank_name: string
|
||||
invoice_keys: string[]
|
||||
}>({
|
||||
amount: undefined,
|
||||
withdrawal_method: WithdrawalMethod.ALIPAY,
|
||||
account_name: '',
|
||||
account_number: '',
|
||||
bank_name: '',
|
||||
invoice_keys: []
|
||||
})
|
||||
|
||||
const resubmitRules = reactive<FormRules>({
|
||||
amount: [
|
||||
{ required: true, message: '请输入提现金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const amountFen = yuanToFen(value)
|
||||
if (amountFen === undefined || amountFen <= 0) {
|
||||
callback(new Error('请输入正确的提现金额'))
|
||||
} else if (amountFen > summary.value.available_commission) {
|
||||
callback(new Error('提现金额不能大于可提现佣金'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
withdrawal_method: [{ required: true, message: '请选择提现方式', trigger: 'change' }],
|
||||
account_name: [
|
||||
{ required: true, message: '请输入账户名', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
account_number: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||
{ min: 5, max: 50, message: '长度在 5 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
bank_name: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (resubmitForm.withdrawal_method === WithdrawalMethod.BANK && !value) {
|
||||
callback(new Error('请选择银行名称'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const openResubmitDialog = (row: WithdrawalRequestItem) => {
|
||||
resubmitTargetId.value = row.id
|
||||
resubmitForm.amount = Number((row.amount / 100).toFixed(2))
|
||||
resubmitForm.withdrawal_method = row.withdrawal_method || WithdrawalMethod.ALIPAY
|
||||
resubmitForm.account_name = row.account_name || ''
|
||||
resubmitForm.account_number = row.account_number || ''
|
||||
resubmitForm.bank_name = row.bank_name || ''
|
||||
resubmitForm.invoice_keys = []
|
||||
resubmitDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmitResubmit = async () => {
|
||||
if (!resubmitFormRef.value) return
|
||||
if (!currentShopId.value || !resubmitTargetId.value) return
|
||||
await resubmitFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
resubmitSubmitting.value = true
|
||||
try {
|
||||
const amount = yuanToFen(resubmitForm.amount)
|
||||
if (amount === undefined) {
|
||||
ElMessage.warning('请输入提现金额')
|
||||
return
|
||||
}
|
||||
const params: ResubmitWithdrawalRequestParams = {
|
||||
amount,
|
||||
withdrawal_method: resubmitForm.withdrawal_method,
|
||||
account_name: resubmitForm.account_name,
|
||||
account_number: resubmitForm.account_number
|
||||
}
|
||||
if (resubmitForm.invoice_keys.length > 0) {
|
||||
params.invoice_keys = resubmitForm.invoice_keys
|
||||
}
|
||||
await CommissionService.resubmitWithdrawalRequest(
|
||||
currentShopId.value!,
|
||||
resubmitTargetId.value!,
|
||||
params
|
||||
)
|
||||
ElMessage.success('重新提交成功')
|
||||
resubmitDialogVisible.value = false
|
||||
await getWithdrawalList()
|
||||
await loadSummary()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
resubmitSubmitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!hasShopId.value) {
|
||||
ElMessage.warning('当前账号未关联店铺,无法查看佣金信息')
|
||||
@@ -981,6 +1755,30 @@
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.qualification-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.withdrawal-detail {
|
||||
.attempt-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
.attempt-reason {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.attempt-time {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.commission-summary {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1018,4 +1816,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.amount-negative {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.record-source-cell {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -440,6 +440,8 @@
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PlatformAccount) => h('span', formatDateTime((row as any).created_at))
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,7 +56,15 @@
|
||||
<template #header>
|
||||
<div class="block-title">付款凭证</div>
|
||||
</template>
|
||||
<ElButton v-if="voucherKeys.length" @click="voucherVisible = true">查看付款凭证</ElButton>
|
||||
<ElButton
|
||||
v-if="
|
||||
voucherKeys.length &&
|
||||
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationViewVoucher)
|
||||
"
|
||||
@click="voucherVisible = true"
|
||||
>
|
||||
查看付款凭证
|
||||
</ElButton>
|
||||
<span v-else class="empty-text">暂无付款凭证</span>
|
||||
</ElCard>
|
||||
|
||||
@@ -116,6 +124,8 @@
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { EmployeeCollectionService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
import type {
|
||||
EmployeeCollectionAllocation,
|
||||
EmployeeCollectionApplication,
|
||||
@@ -136,6 +146,7 @@
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<EmployeeCollectionApplicationDetailData | null>(null)
|
||||
|
||||
@@ -275,9 +275,6 @@
|
||||
|
||||
const getActions = (row: EmployeeCollectionApplication) => {
|
||||
const actions: any[] = []
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationDetail)) {
|
||||
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
|
||||
}
|
||||
if (
|
||||
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationUpdate) &&
|
||||
canResubmitApplication(row)
|
||||
|
||||
@@ -472,9 +472,6 @@
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billDetail)) {
|
||||
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
|
||||
}
|
||||
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billClose) && canCloseBill(row)) {
|
||||
actions.push({ label: '关闭账单', handler: () => openCloseDialog(row), type: 'danger' })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
@@ -29,11 +30,16 @@
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="filteredPaymentMethods"
|
||||
:pagination="false"
|
||||
:data="paymentMethods"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="pagination.total"
|
||||
:margin-top="10"
|
||||
:actions="getActions"
|
||||
:actions-width="140"
|
||||
:actions-width="120"
|
||||
:always-show-pagination="true"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
@@ -45,7 +51,7 @@
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="editing ? '编辑收款方式' : '新增收款方式'"
|
||||
width="520px"
|
||||
width="35%"
|
||||
destroy-on-close
|
||||
@closed="handleDialogClosed"
|
||||
>
|
||||
@@ -85,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { h, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { EmployeeCollectionService } from '@/api/modules'
|
||||
@@ -100,7 +106,7 @@
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
import { getErrorMessage } from '@/utils/business'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeCollectionList } from '../employeeCollectionDisplay'
|
||||
import { normalizeCollectionPage } from '../employeeCollectionDisplay'
|
||||
|
||||
defineOptions({ name: 'EmployeeCollectionPaymentMethods' })
|
||||
|
||||
@@ -115,6 +121,8 @@
|
||||
const currentId = ref<number | null>(null)
|
||||
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
|
||||
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const initialSearchState = {
|
||||
keyword: '',
|
||||
enabled: undefined as number | undefined
|
||||
@@ -170,21 +178,6 @@
|
||||
}
|
||||
])
|
||||
|
||||
const filteredPaymentMethods = computed(() => {
|
||||
const keyword = searchForm.keyword?.trim().toLowerCase()
|
||||
return paymentMethods.value.filter((item) => {
|
||||
const matchKeyword =
|
||||
!keyword ||
|
||||
(item.name || '').toLowerCase().includes(keyword) ||
|
||||
(item.code || '').toLowerCase().includes(keyword)
|
||||
const matchEnabled =
|
||||
searchForm.enabled === undefined ||
|
||||
searchForm.enabled === null ||
|
||||
(searchForm.enabled === 1 ? item.enabled : !item.enabled)
|
||||
return matchKeyword && matchEnabled
|
||||
})
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
@@ -201,7 +194,10 @@
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: EmployeeCollectionPaymentMethodQueryParams = { page: 1, page_size: 100 }
|
||||
const params: EmployeeCollectionPaymentMethodQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
}
|
||||
if (searchForm.enabled !== undefined && searchForm.enabled !== null) {
|
||||
params.enabled = searchForm.enabled === 1
|
||||
}
|
||||
@@ -210,7 +206,9 @@
|
||||
}
|
||||
const res = await EmployeeCollectionService.getPaymentMethods(params)
|
||||
if (res.code === 0) {
|
||||
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
|
||||
const { list, total } = normalizeCollectionPage<EmployeeCollectionPaymentMethod>(res.data)
|
||||
paymentMethods.value = list
|
||||
pagination.total = total
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
|
||||
@@ -219,10 +217,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => getTableData()
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
void getTableData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
void getTableData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
void getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
void getTableData()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,21 @@
|
||||
>
|
||||
重新申请
|
||||
</ElButton>
|
||||
<template v-if="refund && canManualApproveRefund(refund)">
|
||||
<ElButton
|
||||
v-if="hasAuth('refund:approve')"
|
||||
type="primary"
|
||||
@click="openApprovalDialog('approve')"
|
||||
>
|
||||
通过
|
||||
</ElButton>
|
||||
<ElButton v-if="hasAuth('refund:reject')" type="danger" @click="openApprovalDialog('reject')">
|
||||
拒绝
|
||||
</ElButton>
|
||||
<ElButton v-if="hasAuth('refund:return')" type="primary" @click="openApprovalDialog('return')">
|
||||
退回
|
||||
</ElButton>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 详情内容 -->
|
||||
@@ -31,6 +46,14 @@
|
||||
|
||||
<PaymentVoucherDialog :file-keys="refundVoucherFileKeys" @close="refundVoucherFileKeys = []" />
|
||||
|
||||
<!-- 退款审批操作对话框 -->
|
||||
<RefundApprovalDialog
|
||||
v-model="approvalDialogVisible"
|
||||
:refund="refund"
|
||||
:action="approvalAction"
|
||||
@success="refund && fetchRefundDetail(refund.id)"
|
||||
/>
|
||||
|
||||
<!-- 重新提交对话框 -->
|
||||
<ElDialog
|
||||
v-model="resubmitDialogVisible"
|
||||
@@ -57,16 +80,44 @@
|
||||
placeholder="不填则使用原金额"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="实收金额">
|
||||
<ElInputNumber
|
||||
v-model="resubmitForm.actual_received_amount"
|
||||
:min="1"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
<ElFormItem label="退款方式">
|
||||
<ElSelect
|
||||
v-model="resubmitForm.method"
|
||||
clearable
|
||||
placeholder="不修改则沿用原退款方式"
|
||||
style="width: 100%"
|
||||
placeholder="不填则使用原金额"
|
||||
/>
|
||||
>
|
||||
<ElOption
|
||||
v-for="opt in RefundMethodOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<template v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT">
|
||||
<ElFormItem label="收款账户名" prop="customer_account_name">
|
||||
<ElInput
|
||||
v-model="resubmitForm.customer_account_name"
|
||||
placeholder="请输入收款账户名"
|
||||
maxlength="50"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="收款账号" prop="customer_account_number">
|
||||
<ElInput
|
||||
v-model="resubmitForm.customer_account_number"
|
||||
placeholder="请输入收款账号"
|
||||
maxlength="64"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="开户银行">
|
||||
<ElInput
|
||||
v-model="resubmitForm.customer_bank_name"
|
||||
placeholder="选填"
|
||||
maxlength="50"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<ElFormItem label="退款原因">
|
||||
<ElInput
|
||||
v-model="resubmitForm.refund_reason"
|
||||
@@ -74,18 +125,28 @@
|
||||
:rows="3"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="请输入退款原因"
|
||||
/>
|
||||
placeholder="请输入退款原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="退款凭证" prop="refund_voucher_key">
|
||||
<ElFormItem
|
||||
:label="
|
||||
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT ? '退款凭证(必填)' : '退款凭证'
|
||||
"
|
||||
prop="refund_voucher_key"
|
||||
>
|
||||
<VoucherUpload
|
||||
ref="resubmitUploadRef"
|
||||
v-model="resubmitForm.refund_voucher_key"
|
||||
voucher-name="退款凭证"
|
||||
@uploading-change="resubmitVoucherUploading = $event"
|
||||
@change="resubmitFormRef?.validateField('refund_voucher_key')"
|
||||
@files-change="resubmitForm.attachments = $event"
|
||||
/>
|
||||
<div
|
||||
v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT"
|
||||
class="resubmit-voucher-tip"
|
||||
>
|
||||
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
@@ -109,15 +170,28 @@
|
||||
import { ref, onMounted, computed, h, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElCard, ElIcon, ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { RefundService } from '@/api/modules'
|
||||
import type { Refund, ResubmitRefundRequest, RefundAttachment } from '@/types/api'
|
||||
import {
|
||||
RefundMethod,
|
||||
type Refund,
|
||||
type RefundAttemptItem,
|
||||
type ResubmitRefundRequest,
|
||||
type RefundAttachment
|
||||
} from '@/types/api'
|
||||
import {
|
||||
RefundMethodOptions,
|
||||
canManualApproveRefund,
|
||||
canResubmitRefund
|
||||
} from '@/config/constants/refund'
|
||||
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
|
||||
import { toVoucherKeyList, getErrorMessage } from '@/utils/business'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import RefundApprovalDialog from '@/components/business/RefundApprovalDialog.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
@@ -132,6 +206,14 @@
|
||||
const loading = ref(false)
|
||||
const refund = ref<Refund | null>(null)
|
||||
const refundVoucherFileKeys = ref<string[]>([])
|
||||
const approvalDialogVisible = ref(false)
|
||||
const approvalAction = ref<'approve' | 'reject' | 'return'>('approve')
|
||||
|
||||
const openApprovalDialog = (action: 'approve' | 'reject' | 'return') => {
|
||||
if (!refund.value || !canManualApproveRefund(refund.value)) return
|
||||
approvalAction.value = action
|
||||
approvalDialogVisible.value = true
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => `退款详情`)
|
||||
|
||||
@@ -143,19 +225,56 @@
|
||||
const resubmitFormRef = ref()
|
||||
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
|
||||
const resubmitRules = ref()
|
||||
const requireResubmitCustomerAccount =
|
||||
(message: string) =>
|
||||
(_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT && !value?.trim()) {
|
||||
callback(new Error(message))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const validateResubmitVoucherKey = (
|
||||
_rule: unknown,
|
||||
value: string[],
|
||||
callback: (error?: Error) => void
|
||||
) => {
|
||||
if (
|
||||
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT &&
|
||||
(!Array.isArray(value) || value.length === 0)
|
||||
) {
|
||||
callback(new Error('请上传至少 1 个退款凭证'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const resubmitRules = reactive<FormRules>({
|
||||
customer_account_name: [
|
||||
{ validator: requireResubmitCustomerAccount('请填写收款账户名'), trigger: 'blur' }
|
||||
],
|
||||
customer_account_number: [
|
||||
{ validator: requireResubmitCustomerAccount('请填写收款账号'), trigger: 'blur' }
|
||||
],
|
||||
refund_voucher_key: [{ validator: validateResubmitVoucherKey, trigger: 'change' }]
|
||||
})
|
||||
|
||||
const resubmitForm = reactive<{
|
||||
requested_refund_amount?: number
|
||||
actual_received_amount?: number
|
||||
method?: RefundMethod | ''
|
||||
customer_account_name: string
|
||||
customer_account_number: string
|
||||
customer_bank_name: string
|
||||
refund_voucher_key: string[]
|
||||
attachments: RefundAttachment[]
|
||||
refund_reason?: string
|
||||
}>({
|
||||
requested_refund_amount: undefined,
|
||||
actual_received_amount: undefined,
|
||||
method: '',
|
||||
customer_account_name: '',
|
||||
customer_account_number: '',
|
||||
customer_bank_name: '',
|
||||
refund_voucher_key: [],
|
||||
attachments: [],
|
||||
refund_reason: ''
|
||||
})
|
||||
|
||||
@@ -165,6 +284,11 @@
|
||||
return `¥${(amount / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
const formatMb = (value: number | null | undefined): string => {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) return '-'
|
||||
return `${value} MB`
|
||||
}
|
||||
|
||||
const getApprovalProviderText = (item: Refund) => {
|
||||
const provider = item.approval_provider || item.approval_source || item.approval?.source
|
||||
if (provider === 'wecom') return '企微'
|
||||
@@ -224,6 +348,60 @@
|
||||
)
|
||||
}
|
||||
|
||||
const renderAttempts = (attempts: unknown) => {
|
||||
if (!Array.isArray(attempts) || attempts.length === 0) return h('span', '-')
|
||||
|
||||
const sorted = [...attempts].sort((a, b) => {
|
||||
const timeA = new Date(a.created_at || 0).getTime()
|
||||
const timeB = new Date(b.created_at || 0).getTime()
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'attempt-list' },
|
||||
sorted.map((item: RefundAttemptItem) => {
|
||||
const voucherKeys = toVoucherKeyList(item.customer_voucher_key ?? undefined)
|
||||
return h('div', { class: 'attempt-item' }, [
|
||||
h('div', { class: 'attempt-item__header' }, [
|
||||
h('strong', `第 ${item.attempt_no ?? '-'} 次尝试`),
|
||||
h('span', item.approval_status_name || item.approval_status || '-')
|
||||
]),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'attempt-item__meta' },
|
||||
[
|
||||
`退款方式:${item.method_name || '-'}`,
|
||||
`金额:${formatCurrency(item.refund_amount)}`,
|
||||
item.channel_refund_request_no
|
||||
? `渠道申请号:${item.channel_refund_request_no}`
|
||||
: '',
|
||||
item.created_at ? `提交时间:${formatDateTime(item.created_at)}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
),
|
||||
item.refund_reason
|
||||
? h('div', { class: 'attempt-item__reason' }, `退款原因:${item.refund_reason}`)
|
||||
: null,
|
||||
voucherKeys.length
|
||||
? h(
|
||||
ElButton,
|
||||
{
|
||||
type: 'primary',
|
||||
link: true,
|
||||
onClick: () => {
|
||||
refundVoucherFileKeys.value = voucherKeys
|
||||
}
|
||||
},
|
||||
() => '查看该次凭证'
|
||||
)
|
||||
: null
|
||||
])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getRefundAttachmentKeys = (item: Refund) => {
|
||||
const keys = item.attachments?.map((attachment) => attachment.file_key).filter(Boolean) || []
|
||||
return keys.length ? keys : toVoucherKeyList(item.refund_voucher_key)
|
||||
@@ -260,6 +438,64 @@
|
||||
label: '实收金额',
|
||||
formatter: (_, data) => formatCurrency(data.actual_received_amount)
|
||||
},
|
||||
{
|
||||
label: '退款方式',
|
||||
formatter: (_, data) => data.method_name || '-'
|
||||
},
|
||||
{
|
||||
label: '冻结实收金额',
|
||||
formatter: (_, data) => formatCurrency(data.frozen_actual_received_amount)
|
||||
},
|
||||
{
|
||||
label: '渠道退款状态',
|
||||
formatter: (_, data) => data.channel_refund_status_name || '-'
|
||||
},
|
||||
{
|
||||
label: '渠道退款流水号',
|
||||
formatter: (_, data) => data.channel_refund_no || '-'
|
||||
},
|
||||
{
|
||||
label: '渠道退款申请号',
|
||||
formatter: (_, data) => data.channel_refund_request_no || '-'
|
||||
},
|
||||
{
|
||||
label: '渠道退款金额',
|
||||
formatter: (_, data) => formatCurrency(data.channel_refund_amount)
|
||||
},
|
||||
{
|
||||
label: '渠道退款时间',
|
||||
formatter: (_, data) =>
|
||||
data.channel_refunded_at ? formatDateTime(data.channel_refunded_at) : '-'
|
||||
},
|
||||
{
|
||||
label: '失败分类',
|
||||
formatter: (_, data) => data.failure_reason_name || data.failure_reason || '-'
|
||||
},
|
||||
{
|
||||
label: '失败原因/消息',
|
||||
formatter: (_, data) => data.failure_message || '-',
|
||||
fullWidth: true
|
||||
},
|
||||
{
|
||||
label: '异常标记',
|
||||
formatter: (_, data) =>
|
||||
Number(data.anomaly_flag) === 1
|
||||
? `异常:${data.anomaly_reason || ''}`
|
||||
: '正常'
|
||||
},
|
||||
{
|
||||
label: '套餐已用量/总量',
|
||||
formatter: (_, data) =>
|
||||
`${formatMb(data.refund_package_used_mb)} / ${formatMb(data.refund_package_total_mb)}`
|
||||
},
|
||||
{
|
||||
label: '客户收款信息',
|
||||
fullWidth: true,
|
||||
render: (data) =>
|
||||
data.method === RefundMethod.CUSTOMER_ACCOUNT && data.customer_account_info
|
||||
? renderStructuredValue(data.customer_account_info)
|
||||
: h('span', '-')
|
||||
},
|
||||
...(!isRestrictedCustomerRole.value
|
||||
? [
|
||||
{
|
||||
@@ -416,6 +652,16 @@
|
||||
fullWidth: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '审批尝试历史',
|
||||
fields: [
|
||||
{
|
||||
label: '尝试记录',
|
||||
fullWidth: true,
|
||||
render: (data) => renderAttempts(data.attempts)
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
@@ -448,39 +694,29 @@
|
||||
const handleResubmitDialogClosed = () => {
|
||||
resubmitFormRef.value?.resetFields()
|
||||
resubmitForm.requested_refund_amount = undefined
|
||||
resubmitForm.actual_received_amount = undefined
|
||||
resubmitForm.method = ''
|
||||
resubmitForm.customer_account_name = ''
|
||||
resubmitForm.customer_account_number = ''
|
||||
resubmitForm.customer_bank_name = ''
|
||||
resubmitForm.refund_voucher_key = []
|
||||
resubmitForm.attachments = []
|
||||
resubmitForm.refund_reason = ''
|
||||
resubmitVoucherUploading.value = false
|
||||
resubmitUploadRef.value?.clearFiles(false)
|
||||
}
|
||||
|
||||
const canResubmit = (item: Refund) => {
|
||||
const statusText = [item.approval_status, item.approval_status_name, item.status_name]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return (
|
||||
item.status === 3 ||
|
||||
statusText.includes('reject') ||
|
||||
statusText.includes('revoke') ||
|
||||
statusText.includes('delete') ||
|
||||
statusText.includes('驳回') ||
|
||||
statusText.includes('撤销') ||
|
||||
statusText.includes('删除')
|
||||
)
|
||||
return canResubmitRefund(item)
|
||||
}
|
||||
|
||||
const handleShowResubmit = () => {
|
||||
if (!refund.value || !canResubmit(refund.value)) return
|
||||
resubmitForm.requested_refund_amount =
|
||||
fenToYuan(refund.value.requested_refund_amount) || undefined
|
||||
resubmitForm.actual_received_amount =
|
||||
fenToYuan(refund.value.actual_received_amount) || undefined
|
||||
resubmitForm.method = ''
|
||||
resubmitForm.customer_account_name = ''
|
||||
resubmitForm.customer_account_number = ''
|
||||
resubmitForm.customer_bank_name = ''
|
||||
resubmitForm.refund_voucher_key = []
|
||||
resubmitForm.attachments = []
|
||||
resubmitForm.refund_reason = refund.value.refund_reason
|
||||
resubmitDialogVisible.value = true
|
||||
}
|
||||
@@ -499,9 +735,22 @@
|
||||
try {
|
||||
const data: ResubmitRefundRequest = {
|
||||
requested_refund_amount: yuanToFen(resubmitForm.requested_refund_amount),
|
||||
actual_received_amount: yuanToFen(resubmitForm.actual_received_amount),
|
||||
refund_reason: resubmitForm.refund_reason || undefined,
|
||||
attachments: resubmitForm.attachments
|
||||
refund_reason: resubmitForm.refund_reason || undefined
|
||||
}
|
||||
if (resubmitForm.method) {
|
||||
data.method = resubmitForm.method
|
||||
}
|
||||
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT) {
|
||||
data.customer_account_info = {
|
||||
account_name: resubmitForm.customer_account_name.trim(),
|
||||
account_number: resubmitForm.customer_account_number.trim(),
|
||||
...(resubmitForm.customer_bank_name.trim()
|
||||
? { bank_name: resubmitForm.customer_bank_name.trim() }
|
||||
: {})
|
||||
}
|
||||
data.refund_voucher_key = resubmitForm.refund_voucher_key
|
||||
} else if (resubmitForm.refund_voucher_key.length) {
|
||||
data.refund_voucher_key = resubmitForm.refund_voucher_key
|
||||
}
|
||||
|
||||
await RefundService.resubmitRefund(refundId, data)
|
||||
@@ -564,6 +813,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
.resubmit-voucher-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.attempt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attempt-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__reason {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -51,6 +51,14 @@
|
||||
<!-- 创建退款申请对话框 -->
|
||||
<CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" />
|
||||
|
||||
<!-- 退款审批操作对话框 -->
|
||||
<RefundApprovalDialog
|
||||
v-model="approvalDialogVisible"
|
||||
:refund="currentRefund"
|
||||
:action="approvalAction"
|
||||
@success="getTableData"
|
||||
/>
|
||||
|
||||
<!-- 导出任务对话框 -->
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
@@ -92,16 +100,44 @@
|
||||
placeholder="不填则使用原金额"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="实收金额">
|
||||
<ElInputNumber
|
||||
v-model="resubmitForm.actual_received_amount"
|
||||
:min="1"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
<ElFormItem label="退款方式">
|
||||
<ElSelect
|
||||
v-model="resubmitForm.method"
|
||||
clearable
|
||||
placeholder="不修改则沿用原退款方式"
|
||||
style="width: 100%"
|
||||
placeholder="不填则使用原金额"
|
||||
/>
|
||||
>
|
||||
<ElOption
|
||||
v-for="opt in RefundMethodOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<template v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT">
|
||||
<ElFormItem label="收款账户名" prop="customer_account_name">
|
||||
<ElInput
|
||||
v-model="resubmitForm.customer_account_name"
|
||||
placeholder="请输入收款账户名"
|
||||
maxlength="50"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="收款账号" prop="customer_account_number">
|
||||
<ElInput
|
||||
v-model="resubmitForm.customer_account_number"
|
||||
placeholder="请输入收款账号"
|
||||
maxlength="64"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="开户银行">
|
||||
<ElInput
|
||||
v-model="resubmitForm.customer_bank_name"
|
||||
placeholder="选填"
|
||||
maxlength="50"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<ElFormItem label="退款原因">
|
||||
<ElInput
|
||||
v-model="resubmitForm.refund_reason"
|
||||
@@ -112,15 +148,25 @@
|
||||
placeholder="请输入退款原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="退款凭证" prop="refund_voucher_key">
|
||||
<ElFormItem
|
||||
:label="
|
||||
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT ? '退款凭证(必填)' : '退款凭证'
|
||||
"
|
||||
prop="refund_voucher_key"
|
||||
>
|
||||
<VoucherUpload
|
||||
ref="resubmitUploadRef"
|
||||
v-model="resubmitForm.refund_voucher_key"
|
||||
voucher-name="退款凭证"
|
||||
@uploading-change="resubmitVoucherUploading = $event"
|
||||
@change="resubmitFormRef?.validateField('refund_voucher_key')"
|
||||
@files-change="resubmitForm.attachments = $event"
|
||||
/>
|
||||
<div
|
||||
v-if="resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT"
|
||||
class="resubmit-voucher-tip"
|
||||
>
|
||||
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
@@ -150,11 +196,17 @@
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
RefundStatus,
|
||||
RefundMethod,
|
||||
type Refund,
|
||||
type RefundQueryParams,
|
||||
type ResubmitRefundRequest,
|
||||
type RefundAttachment
|
||||
type ResubmitRefundRequest
|
||||
} from '@/types/api'
|
||||
import {
|
||||
RefundMethodOptions,
|
||||
RefundStatusTypeMap,
|
||||
canResubmitRefund,
|
||||
canManualApproveRefund
|
||||
} from '@/config/constants/refund'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
|
||||
@@ -178,6 +230,7 @@
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
import CreateRefundDialog from '@/components/business/CreateRefundDialog.vue'
|
||||
import RefundApprovalDialog from '@/components/business/RefundApprovalDialog.vue'
|
||||
defineOptions({ name: 'RefundList' })
|
||||
|
||||
const router = useRouter()
|
||||
@@ -207,6 +260,14 @@
|
||||
const resubmitDialogVisible = ref(false)
|
||||
const currentRefund = ref<Refund | null>(null)
|
||||
const refundVoucherFileKeys = ref<string[]>([])
|
||||
const approvalDialogVisible = ref(false)
|
||||
const approvalAction = ref<'approve' | 'reject' | 'return'>('approve')
|
||||
|
||||
const openApprovalDialog = (row: Refund, action: 'approve' | 'reject' | 'return') => {
|
||||
currentRefund.value = row
|
||||
approvalAction.value = action
|
||||
approvalDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 搜索表单初始值
|
||||
const initialSearchState: RefundQueryParams = {
|
||||
@@ -277,7 +338,9 @@
|
||||
{ label: '待审批', value: 1 },
|
||||
{ label: '已通过', value: 2 },
|
||||
{ label: '已拒绝', value: 3 },
|
||||
{ label: '已退回', value: 4 }
|
||||
{ label: '已退回', value: 4 },
|
||||
{ label: '原路退款处理中', value: 5 },
|
||||
{ label: '原路退款失败', value: 6 }
|
||||
],
|
||||
config: {
|
||||
clearable: true
|
||||
@@ -302,6 +365,15 @@
|
||||
{ label: '申请退款金额', prop: 'requested_refund_amount' },
|
||||
{ label: '实际退款金额', prop: 'approved_refund_amount' },
|
||||
{ label: '实收金额', prop: 'actual_received_amount' },
|
||||
{ label: '退款方式', prop: 'method' },
|
||||
{ label: '冻结实收金额', prop: 'frozen_actual_received_amount' },
|
||||
{ label: '渠道退款状态', prop: 'channel_refund_status' },
|
||||
{ label: '渠道退款流水号', prop: 'channel_refund_no' },
|
||||
{ label: '渠道退款金额', prop: 'channel_refund_amount' },
|
||||
{ label: '失败分类', prop: 'failure_reason' },
|
||||
{ label: '异常标记', prop: 'anomaly_flag' },
|
||||
{ label: '套餐已用量(MB)', prop: 'refund_package_used_mb' },
|
||||
{ label: '套餐总量(MB)', prop: 'refund_package_total_mb' },
|
||||
{ label: '状态', prop: 'status' },
|
||||
{ label: '审批渠道', prop: 'approval_provider' },
|
||||
{ label: '提交人', prop: 'submitter_name' },
|
||||
@@ -320,19 +392,56 @@
|
||||
|
||||
const resubmitFormRef = ref<FormInstance>()
|
||||
|
||||
const resubmitRules = reactive<FormRules>({})
|
||||
const requireResubmitCustomerAccount =
|
||||
(message: string) =>
|
||||
(_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT && !value?.trim()) {
|
||||
callback(new Error(message))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const validateResubmitVoucherKey = (
|
||||
_rule: unknown,
|
||||
value: string[],
|
||||
callback: (error?: Error) => void
|
||||
) => {
|
||||
if (
|
||||
resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT &&
|
||||
(!Array.isArray(value) || value.length === 0)
|
||||
) {
|
||||
callback(new Error('请上传至少 1 个退款凭证'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const resubmitRules = reactive<FormRules>({
|
||||
customer_account_name: [
|
||||
{ validator: requireResubmitCustomerAccount('请填写收款账户名'), trigger: 'blur' }
|
||||
],
|
||||
customer_account_number: [
|
||||
{ validator: requireResubmitCustomerAccount('请填写收款账号'), trigger: 'blur' }
|
||||
],
|
||||
refund_voucher_key: [{ validator: validateResubmitVoucherKey, trigger: 'change' }]
|
||||
})
|
||||
|
||||
const resubmitForm = reactive<{
|
||||
requested_refund_amount?: number
|
||||
actual_received_amount?: number
|
||||
method?: RefundMethod | ''
|
||||
customer_account_name: string
|
||||
customer_account_number: string
|
||||
customer_bank_name: string
|
||||
refund_voucher_key: string[]
|
||||
attachments: RefundAttachment[]
|
||||
refund_reason?: string
|
||||
}>({
|
||||
requested_refund_amount: undefined,
|
||||
actual_received_amount: undefined,
|
||||
method: '',
|
||||
customer_account_name: '',
|
||||
customer_account_number: '',
|
||||
customer_bank_name: '',
|
||||
refund_voucher_key: [],
|
||||
attachments: [],
|
||||
refund_reason: ''
|
||||
})
|
||||
|
||||
@@ -345,15 +454,14 @@
|
||||
return `¥${(amount / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
const formatMb = (value: number | null | undefined): string => {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) return '-'
|
||||
return `${value} MB`
|
||||
}
|
||||
|
||||
// 获取状态标签类型
|
||||
const getStatusType = (status: RefundStatus): 'warning' | 'success' | 'danger' | 'info' => {
|
||||
const statusMap: Record<RefundStatus, 'warning' | 'success' | 'danger' | 'info'> = {
|
||||
1: 'warning', // 待审批
|
||||
2: 'success', // 已通过
|
||||
3: 'danger', // 已拒绝
|
||||
4: 'info' // 已退回
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
return RefundStatusTypeMap[status] || 'info'
|
||||
}
|
||||
|
||||
// 动态列配置
|
||||
@@ -425,6 +533,69 @@
|
||||
width: 120,
|
||||
formatter: (row: Refund) => formatCurrency(row.actual_received_amount)
|
||||
},
|
||||
{
|
||||
prop: 'method',
|
||||
label: '退款方式',
|
||||
width: 130,
|
||||
formatter: (row: Refund) => row.method_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'frozen_actual_received_amount',
|
||||
label: '冻结实收金额',
|
||||
width: 140,
|
||||
formatter: (row: Refund) => formatCurrency(row.frozen_actual_received_amount)
|
||||
},
|
||||
{
|
||||
prop: 'channel_refund_status',
|
||||
label: '渠道退款状态',
|
||||
width: 130,
|
||||
formatter: (row: Refund) => row.channel_refund_status_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'channel_refund_no',
|
||||
label: '渠道退款流水号',
|
||||
width: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: Refund) => row.channel_refund_no || '-'
|
||||
},
|
||||
{
|
||||
prop: 'channel_refund_amount',
|
||||
label: '渠道退款金额',
|
||||
width: 140,
|
||||
formatter: (row: Refund) => formatCurrency(row.channel_refund_amount)
|
||||
},
|
||||
{
|
||||
prop: 'failure_reason',
|
||||
label: '失败分类',
|
||||
width: 150,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: Refund) => row.failure_reason_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'anomaly_flag',
|
||||
label: '异常标记',
|
||||
width: 110,
|
||||
formatter: (row: Refund) => {
|
||||
if (Number(row.anomaly_flag) !== 1) return h(ElTag, { type: 'success' }, () => '正常')
|
||||
return h(
|
||||
'span',
|
||||
{ title: row.anomaly_reason || '退款流程异常' },
|
||||
h(ElTag, { type: 'danger' }, () => '异常')
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'refund_package_used_mb',
|
||||
label: '套餐已用量(MB)',
|
||||
width: 140,
|
||||
formatter: (row: Refund) => formatMb(row.refund_package_used_mb)
|
||||
},
|
||||
{
|
||||
prop: 'refund_package_total_mb',
|
||||
label: '套餐总量(MB)',
|
||||
width: 130,
|
||||
formatter: (row: Refund) => formatMb(row.refund_package_total_mb)
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
@@ -659,9 +830,11 @@
|
||||
currentRefund.value = row
|
||||
// 预填充原有数据
|
||||
resubmitForm.requested_refund_amount = fenToYuan(row.requested_refund_amount) || undefined
|
||||
resubmitForm.actual_received_amount = fenToYuan(row.actual_received_amount) || undefined
|
||||
resubmitForm.method = ''
|
||||
resubmitForm.customer_account_name = ''
|
||||
resubmitForm.customer_account_number = ''
|
||||
resubmitForm.customer_bank_name = ''
|
||||
resubmitForm.refund_voucher_key = []
|
||||
resubmitForm.attachments = []
|
||||
resubmitForm.refund_reason = row.refund_reason
|
||||
resubmitDialogVisible.value = true
|
||||
}
|
||||
@@ -670,9 +843,11 @@
|
||||
const handleResubmitDialogClosed = () => {
|
||||
resubmitFormRef.value?.resetFields()
|
||||
resubmitForm.requested_refund_amount = undefined
|
||||
resubmitForm.actual_received_amount = undefined
|
||||
resubmitForm.method = ''
|
||||
resubmitForm.customer_account_name = ''
|
||||
resubmitForm.customer_account_number = ''
|
||||
resubmitForm.customer_bank_name = ''
|
||||
resubmitForm.refund_voucher_key = []
|
||||
resubmitForm.attachments = []
|
||||
resubmitForm.refund_reason = ''
|
||||
resubmitVoucherUploading.value = false
|
||||
resubmitUploadRef.value?.clearFiles(false)
|
||||
@@ -695,9 +870,22 @@
|
||||
if (!refundId) return
|
||||
const data: ResubmitRefundRequest = {
|
||||
requested_refund_amount: yuanToFen(resubmitForm.requested_refund_amount),
|
||||
actual_received_amount: yuanToFen(resubmitForm.actual_received_amount),
|
||||
refund_reason: resubmitForm.refund_reason || undefined,
|
||||
attachments: resubmitForm.attachments
|
||||
refund_reason: resubmitForm.refund_reason || undefined
|
||||
}
|
||||
if (resubmitForm.method) {
|
||||
data.method = resubmitForm.method
|
||||
}
|
||||
if (resubmitForm.method === RefundMethod.CUSTOMER_ACCOUNT) {
|
||||
data.customer_account_info = {
|
||||
account_name: resubmitForm.customer_account_name.trim(),
|
||||
account_number: resubmitForm.customer_account_number.trim(),
|
||||
...(resubmitForm.customer_bank_name.trim()
|
||||
? { bank_name: resubmitForm.customer_bank_name.trim() }
|
||||
: {})
|
||||
}
|
||||
data.refund_voucher_key = resubmitForm.refund_voucher_key
|
||||
} else if (resubmitForm.refund_voucher_key.length) {
|
||||
data.refund_voucher_key = resubmitForm.refund_voucher_key
|
||||
}
|
||||
|
||||
await RefundService.resubmitRefund(refundId, data)
|
||||
@@ -735,20 +923,7 @@
|
||||
}
|
||||
|
||||
const canResubmit = (row: Refund) => {
|
||||
const statusText = [row.approval_status, row.approval_status_name, row.status_name]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return (
|
||||
row.status === 3 ||
|
||||
statusText.includes('reject') ||
|
||||
statusText.includes('revoke') ||
|
||||
statusText.includes('delete') ||
|
||||
statusText.includes('驳回') ||
|
||||
statusText.includes('撤销') ||
|
||||
statusText.includes('删除')
|
||||
)
|
||||
return canResubmitRefund(row)
|
||||
}
|
||||
|
||||
const canTriggerApproval = (row: Refund) => {
|
||||
@@ -854,6 +1029,28 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (canManualApproveRefund(row) && hasAuth('refund:approve')) {
|
||||
actions.push({
|
||||
label: '通过',
|
||||
handler: () => openApprovalDialog(row, 'approve'),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (canManualApproveRefund(row) && hasAuth('refund:reject')) {
|
||||
actions.push({
|
||||
label: '拒绝',
|
||||
handler: () => openApprovalDialog(row, 'reject'),
|
||||
type: 'danger'
|
||||
})
|
||||
}
|
||||
if (canManualApproveRefund(row) && hasAuth('refund:return')) {
|
||||
actions.push({
|
||||
label: '退回',
|
||||
handler: () => openApprovalDialog(row, 'return'),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
|
||||
if (canResubmit(row) && hasAuth('refund:resubmit')) {
|
||||
actions.push({
|
||||
label: '重新申请',
|
||||
@@ -876,4 +1073,11 @@
|
||||
.refund-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resubmit-voucher-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -936,7 +936,8 @@
|
||||
{
|
||||
prop: 'shop_name',
|
||||
label: '店铺名称',
|
||||
minWidth: 140
|
||||
showOverflowTooltip: true,
|
||||
minWidth: 160
|
||||
},
|
||||
{
|
||||
prop: 'allocator_shop_name',
|
||||
|
||||
499
src/views/package-management/traffic-alert-rules/index.vue
Normal file
499
src/views/package-management/traffic-alert-rules/index.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="traffic-alert-rules-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
label-width="90"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<ElButton
|
||||
v-if="canCreate"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="openFormDialog('create')"
|
||||
>
|
||||
新增规则
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
:loading="loading"
|
||||
:data="ruleList"
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="150"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ElDialog
|
||||
v-model="formDialogVisible"
|
||||
:title="formMode === 'create' ? '新增真流量预警规则' : '编辑真流量预警规则'"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="resetForm"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
|
||||
<ElFormItem label="套餐商品" prop="package_id">
|
||||
<ElSelect
|
||||
v-model="form.package_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="searchPackages"
|
||||
:loading="packageLoading"
|
||||
:disabled="formMode === 'edit'"
|
||||
placeholder="输入套餐名称搜索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in packageOptions"
|
||||
:key="item.id"
|
||||
:label="item.package_name"
|
||||
:value="item.id"
|
||||
>
|
||||
<span>{{ item.package_name }}</span>
|
||||
<span class="package-option-extra">真流量 {{ formatMbToGb(item.real_data_mb) }}</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="预警阈值(%)" prop="threshold_percent">
|
||||
<ElInputNumber
|
||||
v-model="form.threshold_percent"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
placeholder="1~100,允许两位小数"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="是否启用" prop="enabled">
|
||||
<ElSwitch v-model="form.enabled" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="备注" prop="remark">
|
||||
<ElInput
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入备注(最多 500 字符)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<ElAlert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="rule-tip-alert"
|
||||
title="同一套餐商品最多一条规则;修改不影响既有预警快照,停用后扫描不再创建新预警,既有预警保留。"
|
||||
/>
|
||||
<template #footer>
|
||||
<ElButton @click="formDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">确定</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, ElTag, FormInstance, FormRules } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { PackageManageService, PackageTrafficAlertService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type {
|
||||
PackageResponse,
|
||||
PackageTrafficAlertRuleItem,
|
||||
UpdatePackageTrafficAlertRuleParams
|
||||
} from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'TrafficAlertRules' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const perms = AUGUST_PERMISSIONS.packageTrafficAlert
|
||||
|
||||
const canCreate = computed(() => hasAuth(perms.ruleCreate))
|
||||
const canUpdate = computed(() => hasAuth(perms.ruleUpdate))
|
||||
|
||||
// ========== 列表数据 ==========
|
||||
|
||||
const loading = ref(false)
|
||||
const ruleList = ref<PackageTrafficAlertRuleItem[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const initialSearchState = {
|
||||
package_id: undefined as number | undefined,
|
||||
enabled: undefined as number | undefined
|
||||
}
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 }
|
||||
]
|
||||
|
||||
const packageOptions = ref<PackageResponse[]>([])
|
||||
const packageLoading = ref(false)
|
||||
|
||||
const searchPackages = async (query: string = '') => {
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const res = await PackageManageService.getPackages({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
package_name: query || undefined
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
packageOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索套餐失败:', error)
|
||||
} finally {
|
||||
packageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchFormItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: '套餐',
|
||||
prop: 'package_id',
|
||||
type: 'select',
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
remoteMethod: searchPackages,
|
||||
loading: packageLoading.value
|
||||
},
|
||||
options: () =>
|
||||
packageOptions.value.map((item) => ({ label: item.package_name, value: item.id }))
|
||||
},
|
||||
{
|
||||
label: '启用状态',
|
||||
prop: 'enabled',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: () => statusOptions
|
||||
}
|
||||
])
|
||||
|
||||
const formatMbToGb = (mb?: number | null): string => {
|
||||
if (mb === undefined || mb === null || Number.isNaN(mb)) return '-'
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'package_name',
|
||||
label: '套餐名称',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'real_data_mb',
|
||||
label: '当前真流量额度',
|
||||
width: 150,
|
||||
formatter: (row: PackageTrafficAlertRuleItem) => formatMbToGb(row.real_data_mb)
|
||||
},
|
||||
{
|
||||
prop: 'threshold_percent',
|
||||
label: '预警阈值',
|
||||
width: 110,
|
||||
formatter: (row: PackageTrafficAlertRuleItem) =>
|
||||
row.threshold_percent === undefined || row.threshold_percent === null
|
||||
? '-'
|
||||
: `${row.threshold_percent}%`
|
||||
},
|
||||
{
|
||||
prop: 'enabled',
|
||||
label: '启用状态',
|
||||
width: 100,
|
||||
formatter: (row: PackageTrafficAlertRuleItem) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: row.enabled ? 'success' : 'info' },
|
||||
() => row.enabled_name || (row.enabled ? '启用' : '停用')
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'remark',
|
||||
label: '备注',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PackageTrafficAlertRuleItem) => row.remark || '-'
|
||||
},
|
||||
{
|
||||
prop: 'updated_at',
|
||||
label: '更新时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: PackageTrafficAlertRuleItem) => formatDateTime(row.updated_at)
|
||||
}
|
||||
])
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '套餐名称', prop: 'package_name' },
|
||||
{ label: '当前真流量额度', prop: 'real_data_mb' },
|
||||
{ label: '预警阈值', prop: 'threshold_percent' },
|
||||
{ label: '启用状态', prop: 'enabled' },
|
||||
{ label: '备注', prop: 'remark' },
|
||||
{ label: '更新时间', prop: 'updated_at' }
|
||||
]
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await PackageTrafficAlertService.getAlertRules({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
package_id: searchForm.package_id ?? undefined,
|
||||
enabled:
|
||||
searchForm.enabled === undefined || searchForm.enabled === null
|
||||
? undefined
|
||||
: searchForm.enabled === 1
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
ruleList.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取真流量预警规则列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取真流量预警规则列表失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '获取真流量预警规则列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const refreshRow = (updated: PackageTrafficAlertRuleItem) => {
|
||||
const index = ruleList.value.findIndex((item) => item.id === updated.id)
|
||||
if (index >= 0) {
|
||||
ruleList.value.splice(index, 1, updated)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 行操作 ==========
|
||||
|
||||
const getActions = (row: PackageTrafficAlertRuleItem) => {
|
||||
const actions: Array<{
|
||||
label: string
|
||||
handler: () => void
|
||||
type?: 'primary' | 'danger'
|
||||
}> = []
|
||||
if (!canUpdate.value) return actions
|
||||
actions.push({ label: '编辑', handler: () => openFormDialog('edit', row) })
|
||||
if (row.enabled) {
|
||||
actions.push({
|
||||
label: '停用',
|
||||
handler: () => handleToggleEnabled(row, false),
|
||||
type: 'danger' as const
|
||||
})
|
||||
} else {
|
||||
actions.push({
|
||||
label: '启用',
|
||||
handler: () => handleToggleEnabled(row, true),
|
||||
type: 'primary' as const
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (row: PackageTrafficAlertRuleItem, enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'停用后扫描不再创建新预警,既有预警保留。确认停用该规则吗?',
|
||||
'停用确认',
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await PackageTrafficAlertService.updateAlertRule(row.id, { enabled })
|
||||
if (res.code === 0 && res.data) {
|
||||
refreshRow(res.data)
|
||||
ElMessage.success(enabled ? '已启用' : '已停用,不再创建新预警')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新真流量预警规则失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 创建 / 编辑 ==========
|
||||
|
||||
const formDialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const formRef = ref<FormInstance>()
|
||||
const editingRuleId = ref<number | null>(null)
|
||||
|
||||
const emptyForm = () => ({
|
||||
package_id: undefined as number | undefined,
|
||||
threshold_percent: undefined as number | undefined,
|
||||
enabled: true,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, emptyForm())
|
||||
editingRuleId.value = null
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const formRules: FormRules = {
|
||||
package_id: [{ required: true, message: '请选择套餐商品', trigger: 'change' }],
|
||||
threshold_percent: [
|
||||
{ required: true, message: '请输入预警阈值', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: number | undefined, callback) => {
|
||||
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||||
callback(new Error('请输入预警阈值'))
|
||||
} else if (Number(value) < 1 || Number(value) > 100) {
|
||||
callback(new Error('阈值取值 1 至 100'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const openFormDialog = (mode: 'create' | 'edit', row?: PackageTrafficAlertRuleItem) => {
|
||||
formMode.value = mode
|
||||
resetForm()
|
||||
if (mode === 'edit' && row) {
|
||||
editingRuleId.value = row.id
|
||||
form.package_id = row.package_id
|
||||
form.threshold_percent = row.threshold_percent
|
||||
form.enabled = row.enabled
|
||||
form.remark = row.remark || ''
|
||||
}
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
let res
|
||||
if (formMode.value === 'create') {
|
||||
res = await PackageTrafficAlertService.createAlertRule({
|
||||
package_id: form.package_id as number,
|
||||
threshold_percent: form.threshold_percent as number,
|
||||
enabled: form.enabled,
|
||||
remark: form.remark || undefined
|
||||
})
|
||||
} else {
|
||||
const payload: UpdatePackageTrafficAlertRuleParams = {
|
||||
threshold_percent: form.threshold_percent as number,
|
||||
enabled: form.enabled,
|
||||
remark: form.remark
|
||||
}
|
||||
res = await PackageTrafficAlertService.updateAlertRule(
|
||||
editingRuleId.value as number,
|
||||
payload
|
||||
)
|
||||
}
|
||||
if (res.code === 0 && res.data) {
|
||||
ElMessage.success(formMode.value === 'create' ? '创建成功' : '修改成功')
|
||||
formDialogVisible.value = false
|
||||
if (formMode.value === 'create') {
|
||||
getTableData()
|
||||
} else {
|
||||
refreshRow(res.data)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存真流量预警规则失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.traffic-alert-rules-page {
|
||||
.package-option-extra {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rule-tip-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
196
src/views/package-management/traffic-alerts/detail.vue
Normal file
196
src/views/package-management/traffic-alerts/detail.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="traffic-alert-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<div class="detail-header">
|
||||
<ElButton @click="handleBack">
|
||||
<template #icon>
|
||||
<ElIcon><ArrowLeft /></ElIcon>
|
||||
</template>
|
||||
返回
|
||||
</ElButton>
|
||||
<h2 class="detail-title">真流量达量预警详情</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<ElIcon class="is-loading"><Loading /></ElIcon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<ElAlert
|
||||
v-if="detail.shop_changed_since_trigger || detail.owner_changed_since_trigger"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="ownership-change-alert"
|
||||
>
|
||||
<template #title>{{ changeAlertText }}</template>
|
||||
</ElAlert>
|
||||
<DetailPage :sections="detailSections" :data="detail" />
|
||||
</template>
|
||||
|
||||
<ElResult
|
||||
v-else
|
||||
icon="warning"
|
||||
title="无法查看该预警记录"
|
||||
sub-title="预警记录不存在或您没有查看权限"
|
||||
/>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import { PackageTrafficAlertService } from '@/api/modules'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import {
|
||||
getTrafficAlertAssetTypeName,
|
||||
getTrafficAlertNotificationStatusOption
|
||||
} from '@/config/constants'
|
||||
import type { PackageTrafficAlertRecordDetail } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'TrafficAlertDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<PackageTrafficAlertRecordDetail | null>(null)
|
||||
|
||||
const recordId = computed(() => Number(route.params.id))
|
||||
|
||||
const changeAlertText = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
const parts: string[] = []
|
||||
if (detail.value.shop_changed_since_trigger) parts.push('触发后店铺归属已变化')
|
||||
if (detail.value.owner_changed_since_trigger) parts.push('触发后业务员归属已变化')
|
||||
return `${parts.join(',')},以下店铺/业务员信息为触发时快照。`
|
||||
})
|
||||
|
||||
const detailSections: DetailSection[] = [
|
||||
{
|
||||
title: '触发信息',
|
||||
fields: [
|
||||
{ label: '套餐', prop: 'package_name' },
|
||||
{
|
||||
label: '触发阈值',
|
||||
prop: 'threshold_percent',
|
||||
formatter: (value: unknown) => (value === undefined || value === null ? '-' : `${value}%`)
|
||||
},
|
||||
{
|
||||
label: '触发时间',
|
||||
prop: 'triggered_at',
|
||||
formatter: (value: unknown) => formatDateTime(value as string)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '触发时归属快照',
|
||||
fields: [
|
||||
{ label: '店铺', prop: 'shop_name' },
|
||||
{ label: '业务员', prop: 'business_owner_username' },
|
||||
{
|
||||
label: '业务用户组(当前)',
|
||||
prop: 'business_user_group_names',
|
||||
formatter: (value: unknown) =>
|
||||
Array.isArray(value) && value.length ? (value as string[]).join('、') : '-'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '资产信息',
|
||||
fields: [
|
||||
{
|
||||
label: '资产类型',
|
||||
prop: 'asset_type',
|
||||
formatter: (value: unknown) => getTrafficAlertAssetTypeName(value as never)
|
||||
},
|
||||
{ label: '资产标识', prop: 'asset_identifier' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '通知结果',
|
||||
fields: [
|
||||
{
|
||||
label: '通知状态',
|
||||
render: (data: PackageTrafficAlertRecordDetail) => {
|
||||
const option = getTrafficAlertNotificationStatusOption(data.notification_status)
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: option?.tagType || 'info' },
|
||||
() => data.notification_status_name || option?.label || '-'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const fetchDetail = async () => {
|
||||
loading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
const res = await PackageTrafficAlertService.getAlertRecordDetail(recordId.value)
|
||||
if (res.code === 0 && res.data) {
|
||||
detail.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeApiError(error)
|
||||
// 403/404 统一按资源不可见处理,不额外弹错
|
||||
if (normalized.status !== 403 && normalized.status !== 404) {
|
||||
ElMessage.error(normalized.message || '获取预警记录详情失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!Number.isFinite(recordId.value) || recordId.value <= 0) {
|
||||
return
|
||||
}
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.traffic-alert-detail-page {
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ownership-change-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
533
src/views/package-management/traffic-alerts/index.vue
Normal file
533
src/views/package-management/traffic-alerts/index.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="traffic-alerts-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
label-width="90"
|
||||
show-expand
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<ElButton v-if="canExport" :icon="Download" @click="openExportDialog"> 导出 </ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
:loading="loading"
|
||||
:data="recordList"
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="100"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ElDialog
|
||||
v-model="exportDialogVisible"
|
||||
title="导出真流量达量预警"
|
||||
width="40%"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<ElAlert type="info" :closable="false" show-icon class="export-rule-alert">
|
||||
<template #title>导出将基于当前筛选条件全量导出,不会仅导出当前分页数据。</template>
|
||||
</ElAlert>
|
||||
<ElForm label-width="80px" class="export-task-form">
|
||||
<ElFormItem label="导出格式">
|
||||
<ElRadioGroup v-model="exportFormat">
|
||||
<ElRadio
|
||||
v-for="item in TRAFFIC_ALERT_EXPORT_FORMAT_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="exportDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="exportSubmitting" @click="handleExportSubmit">
|
||||
创建导出任务
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElRadio, ElRadioGroup, ElTag } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { PackageManageService, PackageTrafficAlertService, ShopService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import {
|
||||
AUGUST_PERMISSIONS,
|
||||
TRAFFIC_ALERT_ASSET_TYPE_OPTIONS,
|
||||
TRAFFIC_ALERT_EXPORT_FORMAT_OPTIONS,
|
||||
TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS,
|
||||
getTrafficAlertAssetTypeName,
|
||||
getTrafficAlertNotificationStatusOption
|
||||
} from '@/config/constants'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type {
|
||||
PackageResponse,
|
||||
PackageTrafficAlertAssetType,
|
||||
PackageTrafficAlertNotificationStatus,
|
||||
PackageTrafficAlertRecordItem,
|
||||
PackageTrafficAlertRecordQueryParams,
|
||||
ShopBusinessOwnerCandidate,
|
||||
ShopResponse
|
||||
} from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'TrafficAlerts' })
|
||||
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
const perms = AUGUST_PERMISSIONS.packageTrafficAlert
|
||||
|
||||
const canViewDetail = computed(() => hasAuth(perms.recordDetail))
|
||||
const canExport = computed(() => hasAuth(perms.export))
|
||||
|
||||
// ========== 列表数据 ==========
|
||||
|
||||
const loading = ref(false)
|
||||
const recordList = ref<PackageTrafficAlertRecordItem[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const initialSearchState = {
|
||||
package_id: undefined as number | undefined,
|
||||
shop_id: undefined as number | undefined,
|
||||
business_owner_account_id: undefined as number | undefined,
|
||||
asset_type: undefined as PackageTrafficAlertAssetType | undefined,
|
||||
asset_identifier: '',
|
||||
threshold_percent: '',
|
||||
dateRange: [] as string[],
|
||||
notification_status: undefined as PackageTrafficAlertNotificationStatus | undefined,
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
}
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
|
||||
// 筛选下拉选项
|
||||
const packageOptions = ref<PackageResponse[]>([])
|
||||
const packageLoading = ref(false)
|
||||
const shopOptions = ref<ShopResponse[]>([])
|
||||
const shopLoading = ref(false)
|
||||
const ownerOptions = ref<ShopBusinessOwnerCandidate[]>([])
|
||||
const ownerLoading = ref(false)
|
||||
|
||||
const searchPackages = async (query: string = '') => {
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const res = await PackageManageService.getPackages({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
package_name: query || undefined
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
packageOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索套餐失败:', error)
|
||||
} finally {
|
||||
packageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 && res.data) {
|
||||
shopOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索店铺失败:', error)
|
||||
} finally {
|
||||
shopLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchOwners = async (query: string = '') => {
|
||||
ownerLoading.value = true
|
||||
try {
|
||||
const res = await ShopService.getBusinessOwnerCandidates({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: query || undefined
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
ownerOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索业务员失败:', error)
|
||||
} finally {
|
||||
ownerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchFormItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: '套餐',
|
||||
prop: 'package_id',
|
||||
type: 'select',
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
remoteMethod: searchPackages,
|
||||
loading: packageLoading.value,
|
||||
placeholder: '请输入套餐名称搜索'
|
||||
},
|
||||
options: () =>
|
||||
packageOptions.value.map((item) => ({ label: item.package_name, value: item.id }))
|
||||
},
|
||||
{
|
||||
label: '店铺',
|
||||
prop: 'shop_id',
|
||||
type: 'select',
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
remoteMethod: searchShops,
|
||||
loading: shopLoading.value,
|
||||
placeholder: '请输入店铺名称搜索'
|
||||
},
|
||||
options: () => shopOptions.value.map((item) => ({ label: item.shop_name, value: item.id }))
|
||||
},
|
||||
{
|
||||
label: '业务员',
|
||||
prop: 'business_owner_account_id',
|
||||
type: 'select',
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
remoteMethod: searchOwners,
|
||||
loading: ownerLoading.value,
|
||||
placeholder: '请输入账号名或手机号搜索'
|
||||
},
|
||||
options: () => ownerOptions.value.map((item) => ({ label: item.username, value: item.id }))
|
||||
},
|
||||
{
|
||||
label: '资产类型',
|
||||
prop: 'asset_type',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: TRAFFIC_ALERT_ASSET_TYPE_OPTIONS
|
||||
},
|
||||
{
|
||||
label: '资产关键词',
|
||||
prop: 'asset_identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: '资产标识/卡标识关键词' }
|
||||
},
|
||||
{
|
||||
label: '触发阈值(%)',
|
||||
prop: 'threshold_percent',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: '1~100,允许两位小数' }
|
||||
},
|
||||
{
|
||||
label: '触发时间',
|
||||
prop: 'dateRange',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '通知状态',
|
||||
prop: 'notification_status',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: TRAFFIC_ALERT_NOTIFICATION_STATUS_OPTIONS
|
||||
}
|
||||
])
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'package_name',
|
||||
label: '套餐名称',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) => row.package_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'shop_name',
|
||||
label: '店铺',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) => row.shop_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'business_owner_username',
|
||||
label: '业务员',
|
||||
width: 130,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) => row.business_owner_username || '-'
|
||||
},
|
||||
{
|
||||
prop: 'business_user_group_names',
|
||||
label: '业务用户组',
|
||||
minWidth: 150,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) =>
|
||||
row.business_user_group_names?.length ? row.business_user_group_names.join('、') : '-'
|
||||
},
|
||||
{
|
||||
prop: 'asset_type',
|
||||
label: '资产类型',
|
||||
width: 100,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) =>
|
||||
getTrafficAlertAssetTypeName(row.asset_type)
|
||||
},
|
||||
{
|
||||
prop: 'asset_identifier',
|
||||
label: '资产标识',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) => row.asset_identifier || '-'
|
||||
},
|
||||
{
|
||||
prop: 'threshold_percent',
|
||||
label: '触发阈值',
|
||||
width: 100,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) =>
|
||||
row.threshold_percent === undefined || row.threshold_percent === null
|
||||
? '-'
|
||||
: `${row.threshold_percent}%`
|
||||
},
|
||||
{
|
||||
prop: 'triggered_at',
|
||||
label: '触发时间',
|
||||
width: 180,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) => formatDateTime(row.triggered_at)
|
||||
},
|
||||
{
|
||||
prop: 'notification_status',
|
||||
label: '通知状态',
|
||||
width: 180,
|
||||
formatter: (row: PackageTrafficAlertRecordItem) => {
|
||||
const option = getTrafficAlertNotificationStatusOption(row.notification_status)
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: option?.tagType || 'info' },
|
||||
() => row.notification_status_name || option?.label || '-'
|
||||
)
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '套餐名称', prop: 'package_name' },
|
||||
{ label: '店铺', prop: 'shop_name' },
|
||||
{ label: '业务员', prop: 'business_owner_username' },
|
||||
{ label: '业务用户组', prop: 'business_user_group_names' },
|
||||
{ label: '资产类型', prop: 'asset_type' },
|
||||
{ label: '资产标识', prop: 'asset_identifier' },
|
||||
{ label: '触发阈值', prop: 'threshold_percent' },
|
||||
{ label: '触发时间', prop: 'triggered_at' },
|
||||
{ label: '通知状态', prop: 'notification_status' }
|
||||
]
|
||||
|
||||
const buildQueryParams = (): PackageTrafficAlertRecordQueryParams => ({
|
||||
package_id: searchForm.package_id,
|
||||
shop_id: searchForm.shop_id,
|
||||
business_owner_account_id: searchForm.business_owner_account_id,
|
||||
asset_type: searchForm.asset_type,
|
||||
asset_identifier: searchForm.asset_identifier.trim() || undefined,
|
||||
threshold_percent: searchForm.threshold_percent
|
||||
? Number(searchForm.threshold_percent)
|
||||
: undefined,
|
||||
start_time: searchForm.start_time || undefined,
|
||||
end_time: searchForm.end_time || undefined,
|
||||
notification_status: searchForm.notification_status
|
||||
})
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await PackageTrafficAlertService.getAlertRecords({
|
||||
...buildQueryParams(),
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
recordList.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取真流量达量预警记录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取真流量达量预警记录失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '获取真流量达量预警记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validateThreshold = (): boolean => {
|
||||
const raw = searchForm.threshold_percent.trim()
|
||||
if (!raw) return true
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(raw) || Number(raw) < 1 || Number(raw) > 100) {
|
||||
ElMessage.warning('触发阈值须为 1 至 100 之间的数字,允许两位小数')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const syncDateRange = () => {
|
||||
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
|
||||
searchForm.start_time = searchForm.dateRange[0]
|
||||
searchForm.end_time = searchForm.dateRange[1]
|
||||
} else {
|
||||
searchForm.start_time = ''
|
||||
searchForm.end_time = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!validateThreshold()) return
|
||||
syncDateRange()
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
|
||||
// ========== 行操作 ==========
|
||||
|
||||
const goDetail = (row: PackageTrafficAlertRecordItem) => {
|
||||
router.push(`${RoutesAlias.TrafficAlertDetail}/${row.id}`)
|
||||
}
|
||||
|
||||
const getActions = (row: PackageTrafficAlertRecordItem) => {
|
||||
if (!canViewDetail.value) return []
|
||||
return [
|
||||
{
|
||||
label: '详情',
|
||||
handler: () => goDetail(row),
|
||||
type: 'primary' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ========== 导出 ==========
|
||||
|
||||
const exportDialogVisible = ref(false)
|
||||
const exportSubmitting = ref(false)
|
||||
const exportFormat = ref<'xlsx' | 'csv'>('xlsx')
|
||||
|
||||
const openExportDialog = () => {
|
||||
if (!validateThreshold()) return
|
||||
exportFormat.value = 'xlsx'
|
||||
exportDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleExportSubmit = async () => {
|
||||
exportSubmitting.value = true
|
||||
try {
|
||||
syncDateRange()
|
||||
const res = await PackageTrafficAlertService.exportAlertRecords({
|
||||
format: exportFormat.value,
|
||||
...buildQueryParams()
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
exportDialogVisible.value = false
|
||||
if (res.data.task_id) {
|
||||
localStorage.setItem('export-task-active:package_traffic_alert', String(res.data.task_id))
|
||||
}
|
||||
ElMessageBox.alert(
|
||||
res.data.message || '导出任务已创建,请在导出任务列表中下载',
|
||||
'导出任务已创建',
|
||||
{
|
||||
confirmButtonText: '前往导出任务列表',
|
||||
type: 'success'
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
router.push(RoutesAlias.ExportPackageTrafficAlertTaskList)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
} else {
|
||||
ElMessage.error(res.msg || '创建导出任务失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建真流量达量预警导出任务失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '创建导出任务失败')
|
||||
} finally {
|
||||
exportSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
searchPackages('')
|
||||
searchShops('')
|
||||
searchOwners('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.traffic-alerts-page {
|
||||
.export-rule-alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.export-task-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
810
src/views/settings/h5-popup-configuration/index.vue
Normal file
810
src/views/settings/h5-popup-configuration/index.vue
Normal file
@@ -0,0 +1,810 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="h5-popup-configuration-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
label-width="110"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<ElButton v-if="canCreate" type="primary" :icon="Plus" @click="openCreateDialog">
|
||||
新增配置
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
:loading="loading"
|
||||
:data="configurationList"
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="200"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 创建 / 编辑 -->
|
||||
<ElDialog
|
||||
v-model="formDialogVisible"
|
||||
:title="formMode === 'create' ? '新增运营弹窗配置' : '编辑运营弹窗配置'"
|
||||
width="680px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="resetForm"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="110px">
|
||||
<ElFormItem label="弹窗标题" prop="title">
|
||||
<ElInput
|
||||
v-model="form.title"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
placeholder="请输入弹窗标题(1~100 字符)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="弹窗正文" prop="content">
|
||||
<ElInput
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="请输入弹窗正文(1~2000 字符,不接受 HTML、URL 或前端路由)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="命中页面" prop="pages">
|
||||
<ElCheckboxGroup v-model="form.pages">
|
||||
<ElCheckbox v-for="item in PAGE_OPTIONS" :key="item.value" :label="item.value">
|
||||
{{ item.label }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="投放频率" prop="frequency">
|
||||
<ElSelect v-model="form.frequency" placeholder="请选择投放频率" style="width: 100%">
|
||||
<ElOption
|
||||
v-for="item in FREQUENCY_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="生效时间" prop="timeRange">
|
||||
<ElDatePicker
|
||||
v-model="form.timeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElInputNumber
|
||||
v-model="form.priority"
|
||||
:min="0"
|
||||
:max="1000000"
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="是否启用" prop="enabled">
|
||||
<ElSwitch v-model="form.enabled" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="受控动作" prop="action_type">
|
||||
<ElSelect v-model="form.action_type" placeholder="无" style="width: 100%">
|
||||
<ElOption
|
||||
v-for="item in ACTION_TYPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="店铺范围" prop="shop_ids">
|
||||
<ElSelect
|
||||
v-model="form.shop_ids"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchShops"
|
||||
:loading="shopLoading"
|
||||
placeholder="选择店铺(不选表示全量投放)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="shop in shopOptions"
|
||||
:key="shop.id"
|
||||
:label="shop.shop_name"
|
||||
:value="shop.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="设备类型范围" prop="device_types">
|
||||
<ElSelect
|
||||
v-model="form.device_types"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="选择或输入设备类型(不选表示全量投放)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="卡类型范围" prop="card_types">
|
||||
<ElSelect
|
||||
v-model="form.card_types"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="选择卡类型(不选表示全量投放)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in CARD_TYPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="formDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="formLoading" @click="handleSubmit">
|
||||
{{ formMode === 'create' ? '创建' : '保存' }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 详情 -->
|
||||
<ElDialog v-model="detailDialogVisible" title="运营弹窗配置详情" width="680px">
|
||||
<div v-if="detailLoading" class="detail-loading">加载中...</div>
|
||||
<ElDescriptions v-else-if="detail" :column="2" border>
|
||||
<ElDescriptionsItem label="配置ID">{{ detail.id }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="版本">{{ detail.version }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="标题" :span="2">{{ detail.title }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="正文" :span="2">
|
||||
<span class="detail-content">{{ detail.content }}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="命中页面" :span="2">
|
||||
{{ formatPages(detail.pages) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="店铺范围">
|
||||
{{ formatScope(detail.shop_ids) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="设备类型范围">
|
||||
{{ formatScope(detail.device_types) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="卡类型范围">
|
||||
{{ formatCardTypes(detail.card_types) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="优先级">{{ detail.priority }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="投放频率">
|
||||
{{ detail.frequency_text || FREQUENCY_NAMES[detail.frequency] || detail.frequency }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="启停状态">
|
||||
{{ detail.enabled_text || (detail.enabled ? '启用' : '停用') }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="受控动作">
|
||||
{{ ACTION_TYPE_NAMES[detail.action_type] || '无' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="生效开始" :span="2">
|
||||
{{ formatDateTime(detail.starts_at) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="生效结束" :span="2">
|
||||
{{ formatDateTime(detail.ends_at) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="创建人">{{ detail.creator }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="创建时间">{{
|
||||
formatDateTime(detail.created_at)
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近更新人">{{ detail.updater }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近更新时间">{{
|
||||
formatDateTime(detail.updated_at)
|
||||
}}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElDialog>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, ElTag, FormInstance, FormRules } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { H5PopupConfigurationService, ShopService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type {
|
||||
H5PopupActionType,
|
||||
H5PopupCardType,
|
||||
H5PopupConfiguration,
|
||||
H5PopupFrequency,
|
||||
H5PopupPage
|
||||
} from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'H5PopupConfiguration' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const perms = AUGUST_PERMISSIONS.h5PopupConfiguration
|
||||
|
||||
const canCreate = computed(() => hasAuth(perms.create))
|
||||
const canUpdate = computed(() => hasAuth(perms.update))
|
||||
const canEnable = computed(() => hasAuth(perms.enable))
|
||||
const canDisable = computed(() => hasAuth(perms.disable))
|
||||
|
||||
const PAGE_NAMES: Record<H5PopupPage, string> = {
|
||||
home: '首页',
|
||||
asset_detail: '资产详情',
|
||||
package_purchase: '套餐购买',
|
||||
asset_wallet_recharge: '资产钱包充值'
|
||||
}
|
||||
const PAGE_OPTIONS: Array<{ label: string; value: H5PopupPage }> = [
|
||||
{ label: '首页', value: 'home' },
|
||||
{ label: '资产详情', value: 'asset_detail' },
|
||||
{ label: '套餐购买', value: 'package_purchase' },
|
||||
{ label: '资产钱包充值', value: 'asset_wallet_recharge' }
|
||||
]
|
||||
const FREQUENCY_NAMES: Record<H5PopupFrequency, string> = {
|
||||
once: '每客户每配置版本仅一次',
|
||||
daily: '每客户每配置版本每个上海自然日一次'
|
||||
}
|
||||
const FREQUENCY_OPTIONS: Array<{ label: string; value: H5PopupFrequency }> = [
|
||||
{ label: '仅一次(每配置版本)', value: 'once' },
|
||||
{ label: '每个上海自然日一次(每配置版本)', value: 'daily' }
|
||||
]
|
||||
const ACTION_TYPE_NAMES: Record<string, string> = {
|
||||
'': '无',
|
||||
package_purchase: '套餐购买',
|
||||
asset_wallet_recharge: '资产钱包充值'
|
||||
}
|
||||
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: '无', value: '' },
|
||||
{ label: '套餐购买', value: 'package_purchase' },
|
||||
{ label: '资产钱包充值', value: 'asset_wallet_recharge' }
|
||||
]
|
||||
const CARD_TYPE_NAMES: Record<H5PopupCardType, string> = {
|
||||
CMCC: '中国移动',
|
||||
CUCC: '中国联通',
|
||||
CTCC: '中国电信',
|
||||
CBN: '中国广电'
|
||||
}
|
||||
const CARD_TYPE_OPTIONS: Array<{ label: string; value: H5PopupCardType }> = [
|
||||
{ label: '中国移动', value: 'CMCC' },
|
||||
{ label: '中国联通', value: 'CUCC' },
|
||||
{ label: '中国电信', value: 'CTCC' },
|
||||
{ label: '中国广电', value: 'CBN' }
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
const configurationList = ref<H5PopupConfiguration[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const initialSearchState = {
|
||||
enabled: undefined as number | undefined
|
||||
}
|
||||
const searchForm = reactive({ ...initialSearchState })
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 }
|
||||
]
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '启停状态',
|
||||
prop: 'enabled',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: () => statusOptions
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '标题', prop: 'title' },
|
||||
{ label: '命中页面', prop: 'pages' },
|
||||
{ label: '范围', prop: 'scope' },
|
||||
{ label: '优先级', prop: 'priority' },
|
||||
{ label: '频率', prop: 'frequency_text' },
|
||||
{ label: '启停状态', prop: 'enabled' },
|
||||
{ label: '受控动作', prop: 'action_type' },
|
||||
{ label: '生效时间', prop: 'effective_time' },
|
||||
{ label: '版本', prop: 'version' },
|
||||
{ label: '创建人', prop: 'creator' },
|
||||
{ label: '最近更新人', prop: 'updater' },
|
||||
{ label: '最近更新时间', prop: 'updated_at' }
|
||||
]
|
||||
|
||||
const formatPages = (pages: H5PopupPage[] | null | undefined): string => {
|
||||
if (!pages || pages.length === 0) return '全部'
|
||||
return pages.map((page) => PAGE_NAMES[page] || page).join('、')
|
||||
}
|
||||
|
||||
const formatScope = (values: string[] | number[] | null | undefined): string => {
|
||||
if (!values || values.length === 0) return '全部'
|
||||
const text = values
|
||||
.slice(0, 3)
|
||||
.map((item) => (typeof item === 'number' && item !== 0 ? `#${item}` : String(item)))
|
||||
.join('、')
|
||||
return values.length > 3 ? `${text} 等${values.length}项` : text
|
||||
}
|
||||
|
||||
const formatCardTypes = (values: H5PopupCardType[] | null | undefined): string => {
|
||||
if (!values || values.length === 0) return '全部'
|
||||
return values.map((item) => CARD_TYPE_NAMES[item] || item).join('、')
|
||||
}
|
||||
|
||||
const formatScopeColumn = (row: H5PopupConfiguration): string => {
|
||||
const parts: string[] = []
|
||||
parts.push(`店铺:${row.shop_ids?.length ? `${row.shop_ids.length}家` : '全部'}`)
|
||||
parts.push(
|
||||
`设备:${
|
||||
row.device_types?.length
|
||||
? row.device_types.slice(0, 3).join('、') + (row.device_types.length > 3 ? ' 等' : '')
|
||||
: '全部'
|
||||
}`
|
||||
)
|
||||
parts.push(`卡型:${formatCardTypes(row.card_types)}`)
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{ prop: 'title', label: '标题', minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'pages',
|
||||
label: '命中页面',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: H5PopupConfiguration) => formatPages(row.pages)
|
||||
},
|
||||
{
|
||||
prop: 'scope',
|
||||
label: '范围',
|
||||
minWidth: 200,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: H5PopupConfiguration) => formatScopeColumn(row)
|
||||
},
|
||||
{ prop: 'priority', label: '优先级', width: 90 },
|
||||
{
|
||||
prop: 'frequency_text',
|
||||
label: '频率',
|
||||
minWidth: 150,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: H5PopupConfiguration) =>
|
||||
row.frequency_text || FREQUENCY_NAMES[row.frequency] || row.frequency
|
||||
},
|
||||
{
|
||||
prop: 'enabled',
|
||||
label: '启停状态',
|
||||
width: 90,
|
||||
formatter: (row: H5PopupConfiguration) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: row.enabled ? 'success' : 'info' },
|
||||
() => row.enabled_text || (row.enabled ? '启用' : '停用')
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'action_type',
|
||||
label: '受控动作',
|
||||
width: 120,
|
||||
formatter: (row: H5PopupConfiguration) => ACTION_TYPE_NAMES[row.action_type] || '无'
|
||||
},
|
||||
{
|
||||
prop: 'effective_time',
|
||||
label: '生效时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: H5PopupConfiguration) =>
|
||||
`${formatDateTime(row.starts_at)} ~ ${formatDateTime(row.ends_at)}`
|
||||
},
|
||||
{ prop: 'version', label: '版本', width: 70 },
|
||||
{
|
||||
prop: 'creator',
|
||||
label: '创建人',
|
||||
width: 90,
|
||||
formatter: (row: H5PopupConfiguration) => String(row.creator || 0)
|
||||
},
|
||||
{
|
||||
prop: 'updater',
|
||||
label: '最近更新人',
|
||||
width: 110,
|
||||
formatter: (row: H5PopupConfiguration) => String(row.updater || 0)
|
||||
},
|
||||
{
|
||||
prop: 'updated_at',
|
||||
label: '最近更新时间',
|
||||
minWidth: 170,
|
||||
formatter: (row: H5PopupConfiguration) => formatDateTime(row.updated_at)
|
||||
}
|
||||
])
|
||||
|
||||
// ========== 列表数据 ==========
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await H5PopupConfigurationService.getConfigurations({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
enabled:
|
||||
searchForm.enabled === undefined || searchForm.enabled === null
|
||||
? undefined
|
||||
: searchForm.enabled === 1
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
configurationList.value = res.data.items || []
|
||||
pagination.total = res.data.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取运营弹窗配置列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取运营弹窗配置列表失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '获取运营弹窗配置列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const refreshRow = (updated: H5PopupConfiguration) => {
|
||||
const index = configurationList.value.findIndex((item) => item.id === updated.id)
|
||||
if (index >= 0) {
|
||||
configurationList.value.splice(index, 1, updated)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 行操作 ==========
|
||||
|
||||
const getActions = (row: H5PopupConfiguration) => {
|
||||
const actions: any[] = [
|
||||
{ label: '详情', handler: () => openDetailDialog(row), type: 'primary' }
|
||||
]
|
||||
if (canUpdate.value) {
|
||||
actions.push({ label: '编辑', handler: () => openEditDialog(row) })
|
||||
}
|
||||
if (canEnable.value && !row.enabled) {
|
||||
actions.push({ label: '启用', handler: () => handleEnable(row), type: 'success' })
|
||||
}
|
||||
if (canDisable.value && row.enabled) {
|
||||
actions.push({ label: '停用', handler: () => handleDisable(row), type: 'danger' })
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
const handleEnable = async (row: H5PopupConfiguration) => {
|
||||
try {
|
||||
const res = await H5PopupConfigurationService.enableConfiguration(row.id)
|
||||
if (res.code === 0 && res.data) {
|
||||
refreshRow(res.data)
|
||||
ElMessage.success('启用后参与候选匹配')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '启用失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启用运营弹窗配置失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '启用失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async (row: H5PopupConfiguration) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'停用后停止新投放,历史通知在展示期内仍可见。确认停用该配置吗?',
|
||||
'停用确认',
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await H5PopupConfigurationService.disableConfiguration(row.id)
|
||||
if (res.code === 0 && res.data) {
|
||||
refreshRow(res.data)
|
||||
ElMessage.success('停用后停止新投放,历史通知在展示期内仍可见')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '停用失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停用运营弹窗配置失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '停用失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 详情 ==========
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<H5PopupConfiguration | null>(null)
|
||||
|
||||
const openDetailDialog = async (row: H5PopupConfiguration) => {
|
||||
detail.value = null
|
||||
detailDialogVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await H5PopupConfigurationService.getConfiguration(row.id)
|
||||
if (res.code === 0 && res.data) {
|
||||
detail.value = res.data
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取配置详情失败')
|
||||
detailDialogVisible.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取运营弹窗配置详情失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '获取配置详情失败')
|
||||
detailDialogVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 创建 / 编辑 ==========
|
||||
|
||||
const formDialogVisible = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const formRef = ref<FormInstance>()
|
||||
const shopOptions = ref<Array<{ id: number; shop_name: string }>>([])
|
||||
const shopLoading = ref(false)
|
||||
|
||||
const emptyForm = (): {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
pages: H5PopupPage[]
|
||||
priority: number
|
||||
frequency: H5PopupFrequency
|
||||
enabled: boolean
|
||||
action_type: H5PopupActionType
|
||||
shop_ids: number[]
|
||||
device_types: string[]
|
||||
card_types: H5PopupCardType[]
|
||||
timeRange: string[]
|
||||
} => ({
|
||||
id: 0,
|
||||
title: '',
|
||||
content: '',
|
||||
pages: [],
|
||||
priority: 0,
|
||||
frequency: 'once',
|
||||
enabled: true,
|
||||
action_type: '',
|
||||
shop_ids: [],
|
||||
device_types: [],
|
||||
card_types: [],
|
||||
timeRange: []
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, emptyForm())
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const toPickerTime = (iso: string): string => {
|
||||
if (!iso) return ''
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}+08:00`
|
||||
}
|
||||
|
||||
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 || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索店铺失败:', error)
|
||||
} finally {
|
||||
shopLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadShopNames = async (ids: number[]) => {
|
||||
const missing = ids.filter((id) => !shopOptions.value.some((shop) => shop.id === id))
|
||||
if (missing.length === 0) return
|
||||
await Promise.all(
|
||||
missing.slice(0, 50).map(async (id) => {
|
||||
try {
|
||||
const res = await ShopService.getShops({ id, page: 1, page_size: 1 })
|
||||
if (res.code === 0 && res.data.items?.length) {
|
||||
shopOptions.value = [...shopOptions.value, res.data.items[0]]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取店铺信息失败:', error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const formRules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入弹窗标题', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '弹窗标题长度为 1~100 字符', trigger: 'blur' }
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入弹窗正文', trigger: 'blur' },
|
||||
{ min: 1, max: 2000, message: '弹窗正文长度为 1~2000 字符', trigger: 'blur' }
|
||||
],
|
||||
pages: [
|
||||
{
|
||||
validator: (_rule, value: H5PopupPage[], callback) => {
|
||||
if (!value || value.length === 0) callback(new Error('请至少选择一个命中页面'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
frequency: [{ required: true, message: '请选择投放频率', trigger: 'change' }],
|
||||
timeRange: [
|
||||
{
|
||||
validator: (_rule, value: string[] | null, callback) => {
|
||||
if (!value || value.length !== 2) {
|
||||
callback(new Error('请选择生效时间'))
|
||||
return
|
||||
}
|
||||
if (new Date(value[1]).getTime() < new Date(value[0]).getTime()) {
|
||||
callback(new Error('生效结束时间不得早于开始时间'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
formMode.value = 'create'
|
||||
resetForm()
|
||||
formDialogVisible.value = true
|
||||
shopOptions.value = []
|
||||
searchShops('')
|
||||
}
|
||||
|
||||
const openEditDialog = async (row: H5PopupConfiguration) => {
|
||||
formMode.value = 'edit'
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
pages: [...(row.pages || [])],
|
||||
priority: row.priority,
|
||||
frequency: row.frequency,
|
||||
enabled: row.enabled,
|
||||
action_type: (row.action_type || '') as H5PopupActionType,
|
||||
shop_ids: [...(row.shop_ids || [])],
|
||||
device_types: [...(row.device_types || [])],
|
||||
card_types: [...(row.card_types || [])],
|
||||
timeRange: [toPickerTime(row.starts_at), toPickerTime(row.ends_at)]
|
||||
})
|
||||
formDialogVisible.value = true
|
||||
shopOptions.value = []
|
||||
await Promise.all([searchShops(''), loadShopNames(form.shop_ids)])
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return
|
||||
formLoading.value = true
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
pages: [...form.pages],
|
||||
priority: form.priority,
|
||||
frequency: form.frequency,
|
||||
starts_at: form.timeRange[0],
|
||||
ends_at: form.timeRange[1],
|
||||
enabled: form.enabled,
|
||||
action_type: form.action_type,
|
||||
shop_ids: [...form.shop_ids],
|
||||
device_types: [...form.device_types],
|
||||
card_types: [...form.card_types]
|
||||
}
|
||||
try {
|
||||
if (formMode.value === 'create') {
|
||||
const res = await H5PopupConfigurationService.createConfiguration(payload)
|
||||
if (res.code === 0 && res.data) {
|
||||
ElMessage.success('创建成功')
|
||||
formDialogVisible.value = false
|
||||
getTableData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '创建失败')
|
||||
}
|
||||
} else {
|
||||
const res = await H5PopupConfigurationService.updateConfiguration(form.id, {
|
||||
id: form.id,
|
||||
...payload
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
ElMessage.success(
|
||||
'保存成功:每次更新版本递增,旧版本通知保留原快照,新版本可向原命中客户按频率重新投放一次'
|
||||
)
|
||||
formDialogVisible.value = false
|
||||
getTableData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存运营弹窗配置失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message || '保存失败')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.h5-popup-configuration-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
@refresh="loadMerchants"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
|
||||
<ElButton v-if="canCreateMerchant" type="primary" :icon="Plus" @click="showCreateDrawer">
|
||||
新增支付商户
|
||||
</ElButton>
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="160"
|
||||
:actionsWidth="120"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -48,7 +48,7 @@
|
||||
destroy-on-close
|
||||
@closed="clearSensitiveForm"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
|
||||
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<ElFormItem label="商户名称" prop="name">
|
||||
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户名称" />
|
||||
</ElFormItem>
|
||||
@@ -146,6 +146,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<ElButton type="primary" plain :icon="Plus" @click="addCredential">添加凭证字段</ElButton>
|
||||
<template v-if="form.provider_type === 'wechat_v2'">
|
||||
<ElDivider content-position="left">退款专用可选凭证(原路退款)</ElDivider>
|
||||
<div class="refund-credential-fields">
|
||||
<ElFormItem label="客户端证书内容">
|
||||
<ElInput
|
||||
:model-value="refundCredentialValue('wx_client_cert_content')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="选填,PEM 格式客户端证书内容(仅原路退款需要)"
|
||||
@update:model-value="setRefundCredentialValue('wx_client_cert_content', $event)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="客户端私钥内容">
|
||||
<ElInput
|
||||
:model-value="refundCredentialValue('wx_client_key_content')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="选填,PEM 格式客户端私钥内容(仅原路退款需要)"
|
||||
@update:model-value="setRefundCredentialValue('wx_client_key_content', $event)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
<div v-if="refundCredentialIncomplete" class="credential-tip">
|
||||
提示:仅填写证书或私钥其一,该商户暂不可用于原路退款,不影响支付/查单/回调。
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="credentialError" class="field-error" role="alert">
|
||||
{{ credentialError }}
|
||||
</div>
|
||||
@@ -156,7 +182,7 @@
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="!canManage"
|
||||
:disabled="formMode === 'create' ? !canCreateMerchant : !canEditMerchant"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
保存
|
||||
@@ -174,7 +200,8 @@
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
|
||||
import type { SearchFormItem } from '@/types/component'
|
||||
import {
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
@@ -198,16 +225,24 @@
|
||||
|
||||
type FilterVo = string | number | undefined | null | unknown[]
|
||||
|
||||
const { isPlatformAccount } = usePermission()
|
||||
const router = useRouter()
|
||||
const canManage = computed(() => isPlatformAccount.value)
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const canCreateMerchant = computed(() =>
|
||||
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantCreate)
|
||||
)
|
||||
const canEditMerchant = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantEdit))
|
||||
const canToggleMerchant = computed(() =>
|
||||
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantToggle)
|
||||
)
|
||||
const canDeleteMerchant = computed(() =>
|
||||
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantDelete)
|
||||
)
|
||||
const canViewMerchantDetail = computed(() =>
|
||||
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.merchantDetail)
|
||||
)
|
||||
|
||||
const handleNameClick = (row: PaymentMerchant) => {
|
||||
if (!canManage.value) {
|
||||
ElMessage.warning('您没有查看支付商户详情的权限')
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `${RoutesAlias.PaymentMerchantPoolsDetail}/${row.id}`
|
||||
})
|
||||
@@ -262,17 +297,19 @@
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PaymentMerchant) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
handleNameClick(row)
|
||||
}
|
||||
},
|
||||
row.name
|
||||
)
|
||||
canViewMerchantDetail.value
|
||||
? h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
handleNameClick(row)
|
||||
}
|
||||
},
|
||||
row.name
|
||||
)
|
||||
: row.name
|
||||
},
|
||||
{
|
||||
label: '支付方式',
|
||||
@@ -322,7 +359,6 @@
|
||||
|
||||
// 列表加载
|
||||
const loadMerchants = async () => {
|
||||
if (!canManage.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params: PaymentMerchantQueryParams = {
|
||||
@@ -458,7 +494,7 @@
|
||||
}
|
||||
|
||||
const showCreateDrawer = () => {
|
||||
if (!canManage.value) return
|
||||
if (!canCreateMerchant.value) return
|
||||
Object.assign(form, initialFormState())
|
||||
credentialTemplateKeys.value = []
|
||||
formDrawerVisible.value = true
|
||||
@@ -468,7 +504,7 @@
|
||||
}
|
||||
|
||||
const startCredentialReplacement = () => {
|
||||
if (!canManage.value) return
|
||||
if (!canEditMerchant.value) return
|
||||
form.credentials = []
|
||||
if (credentialTemplateKeys.value.length > 0) {
|
||||
form.credentials = credentialTemplateKeys.value.map((key) => ({
|
||||
@@ -490,6 +526,33 @@
|
||||
form.credentials.splice(index, 1)
|
||||
}
|
||||
|
||||
const refundCredentialValue = (key: string): string => {
|
||||
return form.credentials.find((entry) => entry.key === key)?.value || ''
|
||||
}
|
||||
|
||||
const setRefundCredentialValue = (key: string, value: string) => {
|
||||
const index = form.credentials.findIndex((entry) => entry.key === key)
|
||||
const trimmed = value.trim()
|
||||
if (index >= 0) {
|
||||
if (trimmed) {
|
||||
form.credentials[index].value = trimmed
|
||||
} else {
|
||||
form.credentials.splice(index, 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (trimmed) {
|
||||
form.credentials.push({ localId: generateLocalId(), key, value: trimmed })
|
||||
}
|
||||
}
|
||||
|
||||
const refundCredentialIncomplete = computed(() => {
|
||||
if (form.provider_type !== 'wechat_v2') return false
|
||||
const cert = refundCredentialValue('wx_client_cert_content')
|
||||
const key = refundCredentialValue('wx_client_key_content')
|
||||
return Boolean(cert) !== Boolean(key)
|
||||
})
|
||||
|
||||
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
|
||||
form.payment_method = value
|
||||
const firstMatch = PAYMENT_PROVIDER_OPTIONS.find((option) => option.paymentMethod === value)
|
||||
@@ -499,7 +562,7 @@
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canManage.value) return
|
||||
if (formMode.value === 'create' ? !canCreateMerchant.value : !canEditMerchant.value) return
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
@@ -532,6 +595,9 @@
|
||||
})
|
||||
ElMessage.success('支付凭证更新成功')
|
||||
}
|
||||
if (refundCredentialIncomplete.value) {
|
||||
ElMessage.warning('该商户退款凭证不完整,不可用于原路退款(不影响支付/查单/回调)')
|
||||
}
|
||||
formDrawerVisible.value = false
|
||||
clearSensitiveForm()
|
||||
await loadMerchants()
|
||||
@@ -565,7 +631,7 @@
|
||||
const tableRef = ref()
|
||||
|
||||
const openEditDrawer = async (id: number) => {
|
||||
if (!canManage.value) return
|
||||
if (!canEditMerchant.value) return
|
||||
try {
|
||||
const res = await PaymentMerchantPoolsService.getPaymentMerchantById(id)
|
||||
if (res.code !== 0) return
|
||||
@@ -589,7 +655,7 @@
|
||||
}
|
||||
|
||||
const toggleEnabled = async (merchant: PaymentMerchant) => {
|
||||
if (!canManage.value) return
|
||||
if (!canToggleMerchant.value) return
|
||||
const target = !merchant.enabled
|
||||
const action = target ? '启用' : '停用'
|
||||
try {
|
||||
@@ -613,7 +679,7 @@
|
||||
}
|
||||
|
||||
const confirmDeleteMerchant = async (merchant: PaymentMerchant) => {
|
||||
if (!canManage.value) return
|
||||
if (!canDeleteMerchant.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除支付商户 “${merchant.name}”?该操作不可恢复。`,
|
||||
@@ -642,26 +708,24 @@
|
||||
label: '编辑',
|
||||
type: 'primary' as const,
|
||||
handler: () => openEditDrawer(row.id),
|
||||
permission: canManage.value ? '' : 'hidden'
|
||||
permission: canEditMerchant.value ? '' : 'hidden'
|
||||
},
|
||||
{
|
||||
label: row.enabled ? '停用' : '启用',
|
||||
type: 'primary' as const,
|
||||
handler: () => toggleEnabled(row),
|
||||
permission: canManage.value ? '' : 'hidden'
|
||||
permission: canToggleMerchant.value ? '' : 'hidden'
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'danger' as const,
|
||||
handler: () => confirmDeleteMerchant(row),
|
||||
permission: canManage.value ? '' : 'hidden'
|
||||
permission: canDeleteMerchant.value ? '' : 'hidden'
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
if (canManage.value) {
|
||||
loadMerchants()
|
||||
}
|
||||
loadMerchants()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -700,6 +764,13 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.refund-credential-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@refresh="loadPools"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton v-if="canManage" type="primary" :icon="Plus" @click="showCreateDrawer">
|
||||
<ElButton v-if="canCreatePool" type="primary" :icon="Plus" @click="showCreateDrawer">
|
||||
新增商户池
|
||||
</ElButton>
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="220"
|
||||
:actionsWidth="120"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -195,7 +195,7 @@
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="!canManage"
|
||||
:disabled="formMode === 'create' ? !canCreatePool : !canEditPool"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
保存
|
||||
@@ -214,7 +214,8 @@
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
|
||||
import type { SearchFormItem } from '@/types/component'
|
||||
import {
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
@@ -239,16 +240,15 @@
|
||||
|
||||
type FilterVo = string | number | undefined | null | unknown[]
|
||||
|
||||
const { isPlatformAccount } = usePermission()
|
||||
const router = useRouter()
|
||||
const canManage = computed(() => isPlatformAccount.value)
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const canCreatePool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolCreate))
|
||||
const canEditPool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolEdit))
|
||||
const canTogglePool = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolToggle))
|
||||
const canViewPoolDetail = computed(() => hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.poolDetail))
|
||||
|
||||
const handleNameClick = (row: PaymentMerchantPool) => {
|
||||
if (!canManage.value) {
|
||||
ElMessage.warning('您没有查看商户池详情的权限')
|
||||
return
|
||||
}
|
||||
|
||||
router.push({ path: `${RoutesAlias.PaymentMerchantPoolDetail}/${row.id}` })
|
||||
}
|
||||
|
||||
@@ -282,17 +282,19 @@
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: PaymentMerchantPool) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
handleNameClick(row)
|
||||
}
|
||||
},
|
||||
row.name
|
||||
)
|
||||
canViewPoolDetail.value
|
||||
? h(
|
||||
'span',
|
||||
{
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
handleNameClick(row)
|
||||
}
|
||||
},
|
||||
row.name
|
||||
)
|
||||
: row.name
|
||||
},
|
||||
{
|
||||
label: '支付方式',
|
||||
@@ -357,7 +359,6 @@
|
||||
}
|
||||
|
||||
const loadPools = async () => {
|
||||
if (!canManage.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
@@ -674,7 +675,7 @@
|
||||
}
|
||||
|
||||
const showCreateDrawer = async () => {
|
||||
if (!canManage.value) return
|
||||
if (!canCreatePool.value) return
|
||||
Object.assign(form, initialFormState())
|
||||
formDrawerVisible.value = true
|
||||
formMode.value = 'create'
|
||||
@@ -682,7 +683,7 @@
|
||||
}
|
||||
|
||||
const openEditDrawer = async (pool: PaymentMerchantPool) => {
|
||||
if (!canManage.value) return
|
||||
if (!canEditPool.value) return
|
||||
await loadAvailableMerchants(pool.payment_method)
|
||||
Object.assign(form, initialFormState(), {
|
||||
id: pool.id,
|
||||
@@ -711,7 +712,7 @@
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canManage.value) return
|
||||
if (formMode.value === 'create' ? !canCreatePool.value : !canEditPool.value) return
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
@@ -741,7 +742,7 @@
|
||||
const tableRef = ref()
|
||||
|
||||
const toggleEnabled = async (pool: PaymentMerchantPool) => {
|
||||
if (!canManage.value) return
|
||||
if (!canTogglePool.value) return
|
||||
const target = !pool.enabled
|
||||
const action = target ? '启用' : '停用'
|
||||
try {
|
||||
@@ -769,20 +770,18 @@
|
||||
label: '编辑',
|
||||
type: 'primary' as const,
|
||||
handler: () => openEditDrawer(row),
|
||||
permission: canManage.value ? '' : 'hidden'
|
||||
permission: canEditPool.value ? '' : 'hidden'
|
||||
},
|
||||
{
|
||||
label: row.enabled ? '停用' : '启用',
|
||||
type: 'primary' as const,
|
||||
handler: () => toggleEnabled(row),
|
||||
permission: canManage.value ? '' : 'hidden'
|
||||
permission: canTogglePool.value ? '' : 'hidden'
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
if (canManage.value) {
|
||||
loadPools()
|
||||
}
|
||||
loadPools()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="wechat-authorization-management">
|
||||
<ElCard v-if="canManage" shadow="never" class="art-table-card">
|
||||
<ElCard v-if="canEditWechatAuth" shadow="never" class="art-table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">微信授权配置</span>
|
||||
@@ -93,7 +93,8 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { PaymentMerchantPoolsService } from '@/api/modules'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
|
||||
import type {
|
||||
UpdateWechatAuthorizationRequest,
|
||||
WechatAuthorizationConfig
|
||||
@@ -101,8 +102,10 @@
|
||||
|
||||
defineOptions({ name: 'WechatAuthorizationManagement' })
|
||||
|
||||
const { isPlatformAccount } = usePermission()
|
||||
const canManage = computed(() => isPlatformAccount.value)
|
||||
const { hasAuth } = useAuth()
|
||||
const canEditWechatAuth = computed(() =>
|
||||
hasAuth(PAYMENT_MERCHANT_POOL_PERMISSIONS.wechatAuthEdit)
|
||||
)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitLoading = ref(false)
|
||||
@@ -139,7 +142,6 @@
|
||||
})
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (!canManage.value) return
|
||||
try {
|
||||
const res = await PaymentMerchantPoolsService.getWechatAuthorization()
|
||||
if (res.code === 0) {
|
||||
@@ -151,7 +153,7 @@
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canManage.value) return
|
||||
if (!canEditWechatAuth.value) return
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
@@ -201,7 +203,7 @@
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canManage.value) {
|
||||
if (canEditWechatAuth.value) {
|
||||
loadConfig()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -215,6 +215,16 @@
|
||||
label: '支付密钥',
|
||||
prop: 'wx_key_content',
|
||||
formatter: (value) => getPaymentConfigStatus(value)
|
||||
},
|
||||
{
|
||||
label: '客户端证书内容',
|
||||
prop: 'wx_client_cert_content',
|
||||
formatter: (value) => getPaymentConfigStatus(value)
|
||||
},
|
||||
{
|
||||
label: '客户端私钥内容',
|
||||
prop: 'wx_client_key_content',
|
||||
formatter: (value) => getPaymentConfigStatus(value)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -288,6 +288,28 @@
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="客户端证书内容" prop="wx_client_cert_content">
|
||||
<ElInput
|
||||
v-model="form.wx_client_cert_content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,PEM 格式客户端证书内容,仅原路退款需要"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="客户端私钥内容" prop="wx_client_key_content">
|
||||
<ElInput
|
||||
v-model="form.wx_client_key_content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,PEM 格式客户端私钥内容,仅原路退款需要"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</template>
|
||||
|
||||
<template v-if="form.provider_type === 'fuiou'">
|
||||
@@ -405,6 +427,8 @@
|
||||
wx_serial_no: string
|
||||
wx_cert_content: string
|
||||
wx_key_content: string
|
||||
wx_client_cert_content: string
|
||||
wx_client_key_content: string
|
||||
ali_app_id: string
|
||||
ali_private_key: string
|
||||
ali_public_key: string
|
||||
@@ -448,6 +472,8 @@
|
||||
'wx_api_v3_key',
|
||||
'wx_cert_content',
|
||||
'wx_key_content',
|
||||
'wx_client_cert_content',
|
||||
'wx_client_key_content',
|
||||
'ali_private_key',
|
||||
'ali_public_key',
|
||||
'fy_private_key',
|
||||
@@ -486,6 +512,8 @@
|
||||
wx_serial_no: '',
|
||||
wx_cert_content: '',
|
||||
wx_key_content: '',
|
||||
wx_client_cert_content: '',
|
||||
wx_client_key_content: '',
|
||||
ali_app_id: '',
|
||||
ali_private_key: '',
|
||||
ali_public_key: '',
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
<ElOption label="退款审批" value="refund_approval" />
|
||||
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
|
||||
<ElOption label="员工代收款审批" value="employee_collection_approval" />
|
||||
|
||||
<ElOption label="代理扫码分销注册审批" value="agent_distribution_approval" />
|
||||
<ElOption label="提现资料资格审批" value="withdrawal_qualification_approval" />
|
||||
<ElOption label="佣金提现终审" value="commission_withdrawal_approval" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
666
src/views/shop-management/business-owner-import/index.vue
Normal file
666
src/views/shop-management/business-owner-import/index.vue
Normal file
@@ -0,0 +1,666 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="business-owner-import-page" id="table-full-screen">
|
||||
<!-- 导入入口卡片 -->
|
||||
<ElCard shadow="never" class="import-entry-card">
|
||||
<div class="import-entry">
|
||||
<div class="import-tip">
|
||||
<p>通过 CSV 批量交接/清空店铺平台业务员。</p>
|
||||
<p
|
||||
>表头固定:<code>店铺编码,操作类型,业务员登录账号,备注</code>;操作类型仅支持「换绑」或「清空」。</p
|
||||
>
|
||||
<p>支持 UTF-8(可带 BOM),一行失败不影响其他行。</p>
|
||||
</div>
|
||||
<div class="import-actions">
|
||||
<ElButton
|
||||
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importPage"
|
||||
@click="handleDownloadTemplate"
|
||||
>
|
||||
下载模板
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importCreate"
|
||||
@click="importDialogVisible = true"
|
||||
>
|
||||
导入业务员
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<ElSelect
|
||||
v-model="searchForm.status"
|
||||
placeholder="状态:全部"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in importStatusOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:actions="getActions"
|
||||
:actionsWidth="120"
|
||||
:marginTop="10"
|
||||
:height="400"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 新建导入弹窗 -->
|
||||
<ElDialog v-model="importDialogVisible" title="导入业务员" width="560px" destroy-on-close>
|
||||
<ElAlert type="info" :closable="false" show-icon class="import-alert">
|
||||
请先下载模板,按模板填写店铺编码与操作类型后再上传 CSV。
|
||||
</ElAlert>
|
||||
<div class="upload-tip-row">
|
||||
<ElButton @click="handleDownloadTemplate">下载模板</ElButton>
|
||||
</div>
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".csv"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
:file-list="fileList"
|
||||
>
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<div class="upload-text">
|
||||
<div>将文件拖到此处,或 <em>点击选择 CSV</em></div>
|
||||
<div class="upload-tip">仅支持 .csv 文件(UTF-8 可带 BOM,自动兼容 GBK)</div>
|
||||
</div>
|
||||
</ElUpload>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="importDialogVisible = false">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="!selectedFile"
|
||||
@click="handleStartImport"
|
||||
>
|
||||
开始导入
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 导入结果抽屉 -->
|
||||
<ElDrawer
|
||||
v-model="detailDrawerVisible"
|
||||
:title="`导入结果 - ${currentTask?.id || ''}`"
|
||||
size="70%"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="currentTask" class="import-detail">
|
||||
<ElAlert
|
||||
:type="detailAlertType"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="detailAlertTitle"
|
||||
class="detail-alert"
|
||||
>
|
||||
<template v-if="currentTask.error_message" #default>
|
||||
<div class="detail-error">{{ currentTask.error_message }}</div>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<div class="detail-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总数</span>
|
||||
<strong>{{ currentTask.total_count ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">成功</span>
|
||||
<strong class="stat-success">{{ currentTask.success_count ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">失败</span>
|
||||
<strong class="stat-fail">{{ currentTask.fail_count ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">状态</span>
|
||||
<strong>{{ currentTask.status_name || importStatusLabel(currentTask.status) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">任务编号</span>
|
||||
<span class="meta-value">{{ currentTask.task_no || '--' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">文件名</span>
|
||||
<span class="meta-value">{{ currentTask.file_name || '--' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">创建人</span>
|
||||
<span class="meta-value">{{ currentTask.creator_name || '--' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">开始时间</span>
|
||||
<span class="meta-value">
|
||||
{{ currentTask.started_at ? formatDateTime(currentTask.started_at) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">完成时间</span>
|
||||
<span class="meta-value">
|
||||
{{ currentTask.completed_at ? formatDateTime(currentTask.completed_at) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElTable v-if="rowItems.length" :data="rowItems" border stripe max-height="420">
|
||||
<ElTableColumn label="行号" prop="line" width="80" align="center" />
|
||||
<ElTableColumn label="店铺编码" prop="shop_code" min-width="140" />
|
||||
<ElTableColumn label="操作类型" prop="operation_type" width="100" align="center" />
|
||||
<ElTableColumn label="结果" width="110" align="center">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status === 3 ? 'success' : 'danger'" size="small">
|
||||
{{ scope.row.status_name || (scope.row.status === 3 ? '成功' : '失败') }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="失败原因" prop="reason" min-width="220" show-overflow-tooltip />
|
||||
</ElTable>
|
||||
<ElEmpty
|
||||
v-else-if="!detailLoading && !rowItems.length && currentTask.status === 4"
|
||||
description="任务级失败,无行级明细"
|
||||
/>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import type { UploadFile, UploadInstance, UploadUserFile } from 'element-plus'
|
||||
import { ShopService, StorageService } from '@/api/modules'
|
||||
import type { BusinessOwnerImportDetail } from '@/types/api'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { SHOP_BUSINESS_OWNER_PERMISSIONS } from '@/config/constants'
|
||||
|
||||
defineOptions({ name: 'BusinessOwnerImport' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const importStatusOptions = [
|
||||
{ label: '待处理', value: 1 },
|
||||
{ label: '处理中', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '失败', value: 4 }
|
||||
]
|
||||
|
||||
const importStatusLabel = (status?: number) =>
|
||||
importStatusOptions.find((item) => item.value === status)?.label || '-'
|
||||
|
||||
// ==================== 任务列表 ====================
|
||||
const loading = ref(false)
|
||||
const tableRef = ref()
|
||||
const tableData = ref<BusinessOwnerImportDetail[]>([])
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const searchForm = reactive({
|
||||
status: undefined as number | undefined
|
||||
})
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '任务ID', prop: 'id', width: 90 },
|
||||
{ label: '任务编号', prop: 'task_no', width: 160 },
|
||||
{ label: '文件名', prop: 'file_name', minWidth: 160, showOverflowTooltip: true },
|
||||
{ label: '创建人', prop: 'creator_name', width: 100 },
|
||||
{ label: '状态', prop: 'status', width: 100 },
|
||||
{ label: '总数', prop: 'total_count', width: 80 },
|
||||
{ label: '成功', prop: 'success_count', width: 80 },
|
||||
{ label: '失败', prop: 'fail_count', width: 80 },
|
||||
{ label: '开始时间', prop: 'started_at', width: 170 },
|
||||
{ label: '完成时间', prop: 'completed_at', width: 170 },
|
||||
{ label: '创建时间', prop: 'created_at', width: 170 }
|
||||
]
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'id',
|
||||
label: '任务ID',
|
||||
width: 90
|
||||
},
|
||||
{
|
||||
prop: 'task_no',
|
||||
label: '任务编号',
|
||||
width: 160,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'file_name',
|
||||
label: '文件名',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'creator_name',
|
||||
label: '创建人',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: BusinessOwnerImportDetail) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: `color: ${row.status === 4 ? 'var(--el-color-danger)' : 'var(--el-color-primary)'}`
|
||||
},
|
||||
row.status_name || importStatusLabel(row.status)
|
||||
)
|
||||
},
|
||||
{ prop: 'total_count', label: '总数', width: 80 },
|
||||
{ prop: 'success_count', label: '成功', width: 80 },
|
||||
{ prop: 'fail_count', label: '失败', width: 80 },
|
||||
{
|
||||
prop: 'started_at',
|
||||
label: '开始时间',
|
||||
width: 170,
|
||||
formatter: (row: BusinessOwnerImportDetail) =>
|
||||
row.started_at ? formatDateTime(row.started_at) : '--'
|
||||
},
|
||||
{
|
||||
prop: 'completed_at',
|
||||
label: '完成时间',
|
||||
width: 170,
|
||||
formatter: (row: BusinessOwnerImportDetail) =>
|
||||
row.completed_at ? formatDateTime(row.completed_at) : '--'
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 170,
|
||||
formatter: (row: BusinessOwnerImportDetail) => formatDateTime(row.created_at)
|
||||
}
|
||||
])
|
||||
|
||||
const getActions = (row: BusinessOwnerImportDetail) => {
|
||||
if (!hasAuth(SHOP_BUSINESS_OWNER_PERMISSIONS.importPage)) return []
|
||||
return [
|
||||
{
|
||||
label: '查看结果',
|
||||
handler: () => openDetail(row),
|
||||
type: 'primary' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await ShopService.getBusinessOwnerImportList({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
status: searchForm.status
|
||||
})
|
||||
tableData.value = res.data?.items || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} catch {
|
||||
// 错误提示由请求层统一处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size
|
||||
pagination.page = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getList()
|
||||
}
|
||||
|
||||
// ==================== 模板下载 ====================
|
||||
const handleDownloadTemplate = () => {
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = new URL('@/template/业务负责人导入模板.csv', import.meta.url).href
|
||||
link.download = '业务负责人导入模板.csv'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
ElMessage.success('模板下载成功')
|
||||
} catch (error) {
|
||||
console.error('下载模板失败:', error)
|
||||
ElMessage.error('下载模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新建导入 ====================
|
||||
const importDialogVisible = ref(false)
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const fileList = ref<UploadUserFile[]>([])
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
const handleFileChange = (file: UploadFile) => {
|
||||
selectedFile.value = file.raw || null
|
||||
}
|
||||
|
||||
const handleFileRemove = () => {
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
const handleStartImport = async () => {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请先选择 CSV 文件')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
// 1. 获取预签名上传地址
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: selectedFile.value.name,
|
||||
content_type: 'text/csv',
|
||||
purpose: 'shop_import'
|
||||
})
|
||||
const { upload_url, file_key } = uploadUrlRes.data
|
||||
|
||||
// 2. 预签名 PUT 直传
|
||||
await StorageService.uploadFile(upload_url, selectedFile.value, 'text/csv')
|
||||
|
||||
// 3. 创建导入任务
|
||||
const createRes = await ShopService.createBusinessOwnerImport({ file_key })
|
||||
const taskId = createRes.data?.id
|
||||
importDialogVisible.value = false
|
||||
selectedFile.value = null
|
||||
fileList.value = []
|
||||
|
||||
if (taskId) {
|
||||
await openDetailById(taskId)
|
||||
} else {
|
||||
getList()
|
||||
}
|
||||
ElMessage.success('导入任务已创建')
|
||||
} catch (error) {
|
||||
console.error('导入失败:', error)
|
||||
ElMessage.error('导入失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 结果抽屉 ====================
|
||||
const detailDrawerVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const currentTask = ref<BusinessOwnerImportDetail | null>(null)
|
||||
const rowItems = ref<NonNullable<BusinessOwnerImportDetail['items']>>([])
|
||||
|
||||
let pollTimer: number | undefined
|
||||
|
||||
const isTaskRunning = (status?: number) => status === 1 || status === 2
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer !== undefined) {
|
||||
window.clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const detailAlertType = computed(() => {
|
||||
const status = currentTask.value?.status
|
||||
if (status === 4) return 'error'
|
||||
if (status === 3) return (currentTask.value?.fail_count ?? 0) > 0 ? 'warning' : 'success'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const detailAlertTitle = computed(() => {
|
||||
const status = currentTask.value?.status
|
||||
if (status === 4) return `任务失败:${currentTask.value?.error_message || '未知原因'}`
|
||||
if (status === 3) {
|
||||
const failed = currentTask.value?.fail_count ?? 0
|
||||
return failed > 0
|
||||
? `任务完成,但 ${failed} 行失败(失败行保留原负责人)`
|
||||
: '任务完成,全部成功'
|
||||
}
|
||||
return '任务处理中,请稍候…'
|
||||
})
|
||||
|
||||
const refreshDetail = async () => {
|
||||
if (!currentTask.value) return
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await ShopService.getBusinessOwnerImportDetail(currentTask.value.id)
|
||||
currentTask.value = res.data
|
||||
rowItems.value = res.data.items || []
|
||||
if (isTaskRunning(res.data.status)) {
|
||||
schedulePoll()
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败保留当前状态
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePoll = () => {
|
||||
stopPolling()
|
||||
if (!currentTask.value || !isTaskRunning(currentTask.value.status)) return
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = undefined
|
||||
void refreshDetail()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const openDetail = (row: BusinessOwnerImportDetail) => {
|
||||
currentTask.value = row
|
||||
rowItems.value = row.items || []
|
||||
detailDrawerVisible.value = true
|
||||
if (isTaskRunning(row.status)) {
|
||||
schedulePoll()
|
||||
}
|
||||
}
|
||||
|
||||
const openDetailById = async (taskId: number) => {
|
||||
currentTask.value = null
|
||||
rowItems.value = []
|
||||
detailDrawerVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await ShopService.getBusinessOwnerImportDetail(taskId)
|
||||
currentTask.value = res.data
|
||||
rowItems.value = res.data.items || []
|
||||
if (isTaskRunning(res.data.status)) {
|
||||
schedulePoll()
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取导入结果失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
getList()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-entry-card {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.import-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.import-tip {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.8;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.import-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-tip-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.import-detail {
|
||||
.detail-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-error {
|
||||
margin-top: 6px;
|
||||
color: var(--el-color-danger);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.stat-fail {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px 24px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
630
src/views/shop-management/business-user-groups/index.vue
Normal file
630
src/views/shop-management/business-user-groups/index.vue
Normal file
@@ -0,0 +1,630 @@
|
||||
<template>
|
||||
<div class="business-user-groups-page">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
:show-expand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
v-permission="BUSINESS_USER_GROUP_PERMISSIONS.create"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
新建用户组
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="tableData"
|
||||
:currentPage="pagination.page"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:actions="getActions"
|
||||
:actionsWidth="120"
|
||||
:marginTop="10"
|
||||
:height="500"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新建用户组' : '编辑用户组'"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<ElFormItem label="稳定编码" prop="code">
|
||||
<ElInput
|
||||
v-model="form.code"
|
||||
:disabled="dialogType === 'edit'"
|
||||
placeholder="1-64 字符,创建后不可修改"
|
||||
maxlength="64"
|
||||
show-word-limit
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="名称" prop="name">
|
||||
<ElInput v-model="form.name" placeholder="请输入名称" maxlength="100" show-word-limit />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="业务线" prop="business_line">
|
||||
<ElSelect v-model="form.business_line" placeholder="不设置" clearable style="width: 100%">
|
||||
<ElOption
|
||||
v-for="option in businessLineOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序" prop="sort">
|
||||
<ElInputNumber v-model="form.sort" :min="0" :precision="0" style="width: 200px" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用" prop="enabled">
|
||||
<ElSwitch v-model="form.enabled" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="备注" prop="remark">
|
||||
<ElInput
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="最多 500 字符"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="handleSubmit">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<!-- 成员管理弹窗 -->
|
||||
<ElDialog
|
||||
v-model="memberDialogVisible"
|
||||
:title="`成员管理 - ${currentGroup?.name || ''}`"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="currentGroup" class="member-group-info">
|
||||
<ElTag :type="currentGroup.enabled ? 'success' : 'danger'" size="small">
|
||||
{{ currentGroup.enabled ? '启用' : '已停用' }}
|
||||
</ElTag>
|
||||
<span class="member-group-code">{{ currentGroup.code }}</span>
|
||||
<span v-if="!currentGroup.enabled" class="member-group-tip">
|
||||
停用组保留现有成员,不能作为批量目标
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ElDivider content-position="left">设置成员(整体替换)</ElDivider>
|
||||
<ElAlert type="warning" :closable="false" show-icon class="member-alert">
|
||||
保存后将以所选账号整体替换该组现有成员;账号必须是启用平台用户。
|
||||
</ElAlert>
|
||||
<ElSelect
|
||||
v-model="memberAccountIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchMemberCandidates"
|
||||
:loading="memberCandidatesLoading"
|
||||
placeholder="搜索并选择平台账号"
|
||||
style="width: 100%; margin-top: 12px"
|
||||
>
|
||||
<ElOption
|
||||
v-for="candidate in memberCandidates"
|
||||
:key="candidate.id"
|
||||
:label="candidate.username"
|
||||
:value="candidate.id"
|
||||
>
|
||||
<div class="member-option">
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="member-option-phone">{{ candidate.phone_summary }}</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
<div class="member-actions">
|
||||
<ElButton type="primary" :loading="settingMembers" @click="handleSetMembers">
|
||||
保存成员
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElDivider content-position="left">移出成员(回未分组)</ElDivider>
|
||||
<ElAlert type="info" :closable="false" show-icon class="member-alert">
|
||||
选择要移出用户组的平台账号,清空后回未分组。
|
||||
</ElAlert>
|
||||
<ElSelect
|
||||
v-model="clearAccountIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchMemberCandidates"
|
||||
:loading="memberCandidatesLoading"
|
||||
placeholder="搜索并选择要移出的平台账号"
|
||||
style="width: 100%; margin-top: 12px"
|
||||
>
|
||||
<ElOption
|
||||
v-for="candidate in memberCandidates"
|
||||
:key="candidate.id"
|
||||
:label="candidate.username"
|
||||
:value="candidate.id"
|
||||
>
|
||||
<div class="member-option">
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="member-option-phone">{{ candidate.phone_summary }}</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
<div class="member-actions">
|
||||
<ElButton type="danger" :loading="clearingMembers" @click="handleClearMembers">
|
||||
移出所选账号
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { BusinessUserGroupService, ShopService } from '@/api/modules'
|
||||
import type {
|
||||
BusinessUserGroupBusinessLine,
|
||||
BusinessUserGroupItem,
|
||||
CreateBusinessUserGroupParams,
|
||||
UpdateBusinessUserGroupParams,
|
||||
ShopBusinessOwnerCandidate
|
||||
} from '@/types/api'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
BUSINESS_USER_GROUP_BUSINESS_LINE_MAP,
|
||||
BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS,
|
||||
BUSINESS_USER_GROUP_PERMISSIONS
|
||||
} from '@/config/constants'
|
||||
|
||||
defineOptions({ name: 'BusinessUserGroups' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const businessLineOptions = BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS
|
||||
|
||||
// ==================== 列表 ====================
|
||||
const loading = ref(false)
|
||||
const tableRef = ref()
|
||||
const tableData = ref<BusinessUserGroupItem[]>([])
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
enabled: undefined as number | undefined,
|
||||
business_line: undefined as BusinessUserGroupBusinessLine | undefined
|
||||
})
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '名称',
|
||||
prop: 'keyword',
|
||||
type: 'input',
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '按稳定编码或名称模糊搜索'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'enabled',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 }
|
||||
],
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '全部'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '业务线',
|
||||
prop: 'business_line',
|
||||
type: 'select',
|
||||
options: businessLineOptions.map((item) => ({ label: item.label, value: item.value })),
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '全部'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '稳定编码', prop: 'code', minWidth: 140, showOverflowTooltip: true },
|
||||
{ label: '名称', prop: 'name', minWidth: 160, showOverflowTooltip: true },
|
||||
{ label: '业务线', prop: 'business_line', width: 110 },
|
||||
{ label: '状态', prop: 'enabled', width: 90 },
|
||||
{ label: '排序', prop: 'sort', width: 80 },
|
||||
{ label: '备注', prop: 'remark', minWidth: 160, showOverflowTooltip: true },
|
||||
{ label: '创建时间', prop: 'created_at', width: 170 }
|
||||
]
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'code',
|
||||
label: '稳定编码',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'name',
|
||||
label: '名称',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'business_line',
|
||||
label: '业务线',
|
||||
width: 110,
|
||||
formatter: (row: BusinessUserGroupItem) => {
|
||||
if (!row.business_line) return '-'
|
||||
const config =
|
||||
BUSINESS_USER_GROUP_BUSINESS_LINE_MAP[row.business_line as BusinessUserGroupBusinessLine]
|
||||
return config ? config.label : row.business_line
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'enabled',
|
||||
label: '状态',
|
||||
width: 90,
|
||||
formatter: (row: BusinessUserGroupItem) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: `color: ${row.enabled ? 'var(--el-color-success)' : 'var(--el-color-danger)'}` },
|
||||
row.enabled ? '启用' : '已停用'
|
||||
)
|
||||
},
|
||||
{ prop: 'sort', label: '排序', width: 80 },
|
||||
{ prop: 'remark', label: '备注', minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 170,
|
||||
formatter: (row: BusinessUserGroupItem) => formatDateTime(row.created_at)
|
||||
}
|
||||
])
|
||||
|
||||
const getActions = (row: BusinessUserGroupItem) => {
|
||||
const actions: Array<{ label: string; handler: () => void; type?: 'primary' | 'danger' }> = []
|
||||
if (hasAuth(BUSINESS_USER_GROUP_PERMISSIONS.update)) {
|
||||
actions.push({
|
||||
label: '编辑',
|
||||
handler: () => openEditDialog(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (hasAuth(BUSINESS_USER_GROUP_PERMISSIONS.members)) {
|
||||
actions.push({
|
||||
label: '成员',
|
||||
handler: () => openMemberDialog(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
if (hasAuth(BUSINESS_USER_GROUP_PERMISSIONS.delete)) {
|
||||
actions.push({
|
||||
label: '删除',
|
||||
handler: () => handleDelete(row),
|
||||
type: 'danger'
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await BusinessUserGroupService.getGroups({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
enabled: searchForm.enabled === undefined ? undefined : searchForm.enabled === 1,
|
||||
keyword: searchForm.keyword.trim() || undefined,
|
||||
business_line: searchForm.business_line
|
||||
})
|
||||
tableData.value = res.data?.items || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} catch {
|
||||
// 错误提示由请求层统一处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
pagination.page = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size
|
||||
pagination.page = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getList()
|
||||
}
|
||||
|
||||
// ==================== 新建/编辑 ====================
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref<'add' | 'edit'>('add')
|
||||
const submitting = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const currentGroup = ref<BusinessUserGroupItem | null>(null)
|
||||
|
||||
const form = reactive<CreateBusinessUserGroupParams>({
|
||||
code: '',
|
||||
name: '',
|
||||
business_line: undefined,
|
||||
sort: 0,
|
||||
enabled: true,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
code:
|
||||
dialogType.value === 'add'
|
||||
? [
|
||||
{ required: true, message: '请输入稳定编码', trigger: 'blur' },
|
||||
{ min: 1, max: 64, message: '长度 1-64 字符', trigger: 'blur' }
|
||||
]
|
||||
: [],
|
||||
name: [
|
||||
{ required: true, message: '请输入名称', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '长度 1-100 字符', trigger: 'blur' }
|
||||
]
|
||||
}))
|
||||
|
||||
const resetForm = () => {
|
||||
form.code = ''
|
||||
form.name = ''
|
||||
form.business_line = undefined
|
||||
form.sort = 0
|
||||
form.enabled = true
|
||||
form.remark = ''
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
dialogType.value = 'add'
|
||||
currentGroup.value = null
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (row: BusinessUserGroupItem) => {
|
||||
dialogType.value = 'edit'
|
||||
currentGroup.value = row
|
||||
form.code = row.code
|
||||
form.name = row.name
|
||||
form.business_line = (row.business_line || undefined) as
|
||||
| BusinessUserGroupBusinessLine
|
||||
| undefined
|
||||
form.sort = row.sort
|
||||
form.enabled = row.enabled
|
||||
form.remark = row.remark
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: CreateBusinessUserGroupParams | UpdateBusinessUserGroupParams = {
|
||||
name: form.name,
|
||||
// 三态语义:清空选择器后回退为 ''(清空),枚举值(设置),其余字段缺失=不改
|
||||
business_line: form.business_line ?? '',
|
||||
sort: form.sort,
|
||||
enabled: form.enabled,
|
||||
remark: form.remark
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (dialogType.value === 'add') {
|
||||
await BusinessUserGroupService.createGroup({
|
||||
...(payload as CreateBusinessUserGroupParams),
|
||||
code: form.code
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
} else {
|
||||
await BusinessUserGroupService.updateGroup(currentGroup.value!.id, payload)
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 删除 ====================
|
||||
const handleDelete = async (row: BusinessUserGroupItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除用户组「${row.name}」吗?仅无成员的用户组可删除。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await BusinessUserGroupService.deleteGroup(row.id, { confirm: true })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch {
|
||||
// 有成员等场景由后端提示
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 成员管理 ====================
|
||||
const memberDialogVisible = ref(false)
|
||||
const memberCandidates = ref<ShopBusinessOwnerCandidate[]>([])
|
||||
const memberCandidatesLoading = ref(false)
|
||||
const memberAccountIds = ref<number[]>([])
|
||||
const clearAccountIds = ref<number[]>([])
|
||||
const settingMembers = ref(false)
|
||||
const clearingMembers = ref(false)
|
||||
|
||||
const searchMemberCandidates = async (keyword?: string) => {
|
||||
memberCandidatesLoading.value = true
|
||||
try {
|
||||
const res = await ShopService.getBusinessOwnerCandidates({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
keyword: keyword?.trim() || undefined
|
||||
})
|
||||
memberCandidates.value = res.data.items || []
|
||||
} catch {
|
||||
memberCandidates.value = []
|
||||
} finally {
|
||||
memberCandidatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openMemberDialog = (row: BusinessUserGroupItem) => {
|
||||
currentGroup.value = row
|
||||
memberAccountIds.value = []
|
||||
clearAccountIds.value = []
|
||||
memberCandidates.value = []
|
||||
memberDialogVisible.value = true
|
||||
setTimeout(() => {
|
||||
searchMemberCandidates()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleSetMembers = async () => {
|
||||
if (!currentGroup.value) return
|
||||
if (!memberAccountIds.value.length) {
|
||||
ElMessage.warning('请至少选择一个平台账号')
|
||||
return
|
||||
}
|
||||
settingMembers.value = true
|
||||
try {
|
||||
const res = await BusinessUserGroupService.setGroupMembers(currentGroup.value.id, {
|
||||
account_ids: memberAccountIds.value
|
||||
})
|
||||
ElMessage.success(`已维护 ${res.data?.account_ids?.length ?? 0} 个账号归属`)
|
||||
memberDialogVisible.value = false
|
||||
getList()
|
||||
} finally {
|
||||
settingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearMembers = async () => {
|
||||
if (!clearAccountIds.value.length) {
|
||||
ElMessage.warning('请至少选择一个平台账号')
|
||||
return
|
||||
}
|
||||
clearingMembers.value = true
|
||||
try {
|
||||
const res = await BusinessUserGroupService.clearGroupMembers({
|
||||
account_ids: clearAccountIds.value
|
||||
})
|
||||
ElMessage.success(`已清空 ${res.data?.account_ids?.length ?? 0} 个账号归属`)
|
||||
memberDialogVisible.value = false
|
||||
getList()
|
||||
} finally {
|
||||
clearingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-group-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.member-group-code {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.member-group-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.member-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.member-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.member-option-phone {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@
|
||||
<ElDescriptions v-if="shop" :column="2" border>
|
||||
<ElDescriptionsItem label="店铺名称">{{ shop.shop_name || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="店铺编号">{{ shop.shop_code || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="分销码">{{
|
||||
shop.distribution_code || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="店铺层级">{{ shop.level }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="上级店铺">{{
|
||||
shop.parent_shop_name || '-'
|
||||
@@ -33,6 +36,12 @@
|
||||
: '-'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务用户组">
|
||||
{{ formatBusinessUserGroup(shop) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务用户组业务线">
|
||||
{{ getBusinessUserGroupBusinessLineLabel(shop.business_user_group_business_line) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="省份">{{ shop.province || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="城市">{{ shop.city || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="区县">{{ shop.district || '-' }}</ElDescriptionsItem>
|
||||
@@ -43,18 +52,30 @@
|
||||
<ElDescriptionsItem label="创建时间">{{ shop.created_at || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="更新时间">{{ shop.updated_at || '-' }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<div v-if="shop?.distribution_code" class="register-qrcode">
|
||||
<div class="qrcode-box">
|
||||
<QrcodeVue :value="registerUrl" :size="168" level="H" />
|
||||
</div>
|
||||
<div class="qrcode-tip">
|
||||
<span>扫码注册:将该二维码提供给下级代理扫码注册,或直接分享下方链接。</span>
|
||||
<code>{{ registerUrl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { ShopService } from '@/api/modules'
|
||||
import type { ShopResponse } from '@/types/api'
|
||||
import { getBusinessUserGroupBusinessLineLabel } from '@/config/constants'
|
||||
|
||||
defineOptions({ name: 'ShopDetail' })
|
||||
|
||||
@@ -63,6 +84,14 @@
|
||||
const loading = ref(false)
|
||||
const shop = ref<ShopResponse | null>(null)
|
||||
|
||||
const registerUrl = computed(() => {
|
||||
const shopData = shop.value
|
||||
const code = shopData?.distribution_code
|
||||
if (!shopData || !code) return ''
|
||||
const base = `${window.location.origin}${window.location.pathname}`
|
||||
return `${base}#/agent-registration?distribution_code=${encodeURIComponent(code)}&shop_name=${encodeURIComponent(shopData.shop_name)}&shop_code=${encodeURIComponent(shopData.shop_code)}`
|
||||
})
|
||||
|
||||
const formatBusinessOwner = (value: ShopResponse) => {
|
||||
if (!value.business_owner_username) return '-'
|
||||
return value.business_owner_phone_summary
|
||||
@@ -70,6 +99,14 @@
|
||||
: value.business_owner_username
|
||||
}
|
||||
|
||||
const formatBusinessUserGroup = (value: ShopResponse) => {
|
||||
if (!value.business_user_group_id) return '-'
|
||||
const text = value.business_user_group_code
|
||||
? `${value.business_user_group_name || '-'}(${value.business_user_group_code})`
|
||||
: value.business_user_group_name || '-'
|
||||
return value.business_user_group_enabled ? text : `${text}(已停用)`
|
||||
}
|
||||
|
||||
const handleBack = () => router.back()
|
||||
|
||||
const fetchDetail = async () => {
|
||||
@@ -115,4 +152,41 @@
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.register-qrcode {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
padding: 24px;
|
||||
margin-top: 20px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
|
||||
.qrcode-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qrcode-tip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
code {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
>
|
||||
<template #left>
|
||||
<ElButton @click="showDialog('add')" v-permission="'shop:add'">新增店铺</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.batch"
|
||||
@click="openBatchTransferDialog"
|
||||
>
|
||||
批量交接
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="SHOP_BUSINESS_OWNER_PERMISSIONS.importPage"
|
||||
@click="goToImportPage"
|
||||
>
|
||||
导入业务员
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -40,6 +53,7 @@
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-if="canBatchTransfer" type="selection" width="55" />
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
@@ -310,6 +324,60 @@
|
||||
:shop="currentCreditShop"
|
||||
@submitted="getShopList"
|
||||
/>
|
||||
|
||||
<!-- 批量交接平台业务员弹窗 -->
|
||||
<ElDialog
|
||||
v-model="batchTransferDialogVisible"
|
||||
title="批量交接平台业务员"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ElAlert type="info" :closable="false" show-icon class="batch-transfer-alert">
|
||||
已选择 {{ selectedRows.length }} 家店铺(单次最多 500 家)
|
||||
</ElAlert>
|
||||
<ElForm label-width="110px" class="batch-transfer-form">
|
||||
<ElFormItem label="交接方式">
|
||||
<ElRadioGroup v-model="batchTransferMode">
|
||||
<ElRadio label="rebind">换绑业务员</ElRadio>
|
||||
<ElRadio label="clear">清空负责人</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="batchTransferMode === 'rebind'" label="目标业务员">
|
||||
<ElSelect
|
||||
v-model="batchTransferAccountId"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:loading="batchOwnerLoading"
|
||||
:remote-method="searchBatchOwners"
|
||||
placeholder="请选择或搜索平台业务员"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="owner in batchOwnerOptions"
|
||||
:key="owner.id"
|
||||
:label="owner.username"
|
||||
:value="owner.id"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<span>{{ owner.username }}</span>
|
||||
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
|
||||
{{ owner.phone_summary }}
|
||||
</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="batchTransferDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="batchSubmitting" @click="handleBatchTransfer">
|
||||
确认交接
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
@@ -334,10 +402,17 @@
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { generateShopCode } from '@/utils/codeGenerator'
|
||||
import { CommissionService, ShopService, RoleService } from '@/api/modules'
|
||||
import {
|
||||
BusinessUserGroupService,
|
||||
CommissionService,
|
||||
RoleService,
|
||||
ShopService
|
||||
} from '@/api/modules'
|
||||
import ShopCreditLimitDialog from '@/components/business/ShopCreditLimitDialog.vue'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type {
|
||||
BatchUpdateBusinessOwnerParams,
|
||||
BusinessUserGroupItem,
|
||||
CreateShopParams,
|
||||
ShopBusinessOwnerCandidate,
|
||||
ShopResponse,
|
||||
@@ -348,7 +423,14 @@
|
||||
import { RoleType, RoleStatus } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getCompatibleNumericId } from '@/utils/business/id'
|
||||
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
|
||||
import {
|
||||
BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS,
|
||||
CommonStatus,
|
||||
getBusinessUserGroupBusinessLineLabel,
|
||||
getStatusText,
|
||||
SHOP_BUSINESS_OWNER_PERMISSIONS,
|
||||
STATUS_SELECT_OPTIONS
|
||||
} from '@/config/constants'
|
||||
import { regionData } from '@/utils/constants/regionData'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
@@ -484,7 +566,10 @@
|
||||
parent_id: undefined as number | undefined,
|
||||
level: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
business_owner_account_id: undefined as number | undefined
|
||||
business_owner_account_id: undefined as number | undefined,
|
||||
business_user_group_id: undefined as number | undefined,
|
||||
business_line: undefined as string | undefined,
|
||||
ungrouped: undefined as number | undefined
|
||||
}
|
||||
|
||||
// 响应式表单数据
|
||||
@@ -505,6 +590,22 @@
|
||||
// 选中的行数据
|
||||
const selectedRows = ref<any[]>([])
|
||||
|
||||
// 业务用户组筛选选项(非代理账号可见)
|
||||
const groupOptions = ref<BusinessUserGroupItem[]>([])
|
||||
const groupLoading = ref(false)
|
||||
|
||||
// 批量交接平台业务员
|
||||
const batchTransferDialogVisible = ref(false)
|
||||
const batchTransferMode = ref<'rebind' | 'clear'>('rebind')
|
||||
const batchTransferAccountId = ref<number | null>(null)
|
||||
const batchOwnerOptions = ref<ShopBusinessOwnerCandidate[]>([])
|
||||
const batchOwnerLoading = ref(false)
|
||||
const batchSubmitting = ref(false)
|
||||
|
||||
const canBatchTransfer = computed(
|
||||
() => !isAgentAccount.value && hasAuth(SHOP_BUSINESS_OWNER_PERMISSIONS.batch)
|
||||
)
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
@@ -602,6 +703,50 @@
|
||||
label: `${salesperson.username} (${salesperson.phone_summary})`,
|
||||
value: salesperson.id
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: '业务用户组',
|
||||
prop: 'business_user_group_id',
|
||||
type: 'select' as const,
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
loading: groupLoading.value,
|
||||
placeholder: '请选择业务用户组'
|
||||
},
|
||||
options: () =>
|
||||
groupOptions.value.map((group) => ({
|
||||
label: group.business_line_name
|
||||
? `${group.name}(${group.business_line_name})`
|
||||
: group.name,
|
||||
value: group.id
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: '业务线',
|
||||
prop: 'business_line',
|
||||
type: 'select' as const,
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '请选择业务线'
|
||||
},
|
||||
options: BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: '未分组',
|
||||
prop: 'ungrouped',
|
||||
type: 'select' as const,
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '是=无负责人或负责人无分组'
|
||||
},
|
||||
options: [
|
||||
{ label: '是', value: 1 },
|
||||
{ label: '否', value: 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -616,6 +761,7 @@
|
||||
{ label: '联系人', prop: 'contact_name' },
|
||||
{ label: '联系电话', prop: 'contact_phone' },
|
||||
{ label: '平台业务员', prop: 'business_owner_username' },
|
||||
{ label: '业务用户组', prop: 'business_user_group_name' },
|
||||
...(canModifyShopStatus ? [{ label: '状态', prop: 'status' }] : []),
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '操作', prop: 'operation' }
|
||||
@@ -776,6 +922,23 @@
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ShopResponse) => formatBusinessOwner(row)
|
||||
},
|
||||
{
|
||||
prop: 'business_user_group_name',
|
||||
label: '业务用户组',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ShopResponse) => {
|
||||
if (!row.business_user_group_id) return '-'
|
||||
const name = row.business_user_group_name || row.business_user_group_code || '-'
|
||||
const businessLine = getBusinessUserGroupBusinessLineLabel(
|
||||
row.business_user_group_business_line
|
||||
)
|
||||
const text =
|
||||
businessLine && businessLine !== '-' ? `${name}(${businessLine})` : name
|
||||
if (row.business_user_group_enabled) return text
|
||||
return h(ElTag, { type: 'danger', size: 'small' }, { default: () => `${text}(已停用)` })
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'client_login_disabled',
|
||||
label: 'C 端登录',
|
||||
@@ -1020,6 +1183,7 @@
|
||||
searchParentShops('') // 加载上级店铺选项
|
||||
if (!isAgentAccount.value) {
|
||||
searchPlatformSalespeople('') // 加载启用的平台业务员选项
|
||||
loadGroupOptions() // 加载业务用户组选项
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1038,7 +1202,11 @@
|
||||
level: searchForm.level,
|
||||
status: searchForm.status,
|
||||
...(!isAgentAccount.value && {
|
||||
business_owner_account_id: searchForm.business_owner_account_id
|
||||
business_owner_account_id: searchForm.business_owner_account_id,
|
||||
business_user_group_id: searchForm.business_user_group_id,
|
||||
business_line: searchForm.business_line || undefined,
|
||||
ungrouped:
|
||||
searchForm.ungrouped === undefined ? undefined : searchForm.ungrouped === 1
|
||||
})
|
||||
}
|
||||
const res = await ShopService.getShops(params)
|
||||
@@ -1062,6 +1230,89 @@
|
||||
selectedRows.value = selection
|
||||
}
|
||||
|
||||
// 打开批量交接弹窗
|
||||
const openBatchTransferDialog = () => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning('请先勾选需要交接的店铺')
|
||||
return
|
||||
}
|
||||
if (selectedRows.value.length > 500) {
|
||||
ElMessage.warning('单次最多可交接 500 家店铺')
|
||||
return
|
||||
}
|
||||
batchTransferMode.value = 'rebind'
|
||||
batchTransferAccountId.value = null
|
||||
void searchBatchOwners('')
|
||||
batchTransferDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 搜索批量交接目标业务员
|
||||
const searchBatchOwners = async (query: string) => {
|
||||
batchOwnerLoading.value = true
|
||||
try {
|
||||
const res = await ShopService.getBusinessOwnerCandidates({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: query || undefined
|
||||
})
|
||||
if (res.code === 0) {
|
||||
batchOwnerOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取平台业务员列表失败:', error)
|
||||
batchOwnerOptions.value = []
|
||||
} finally {
|
||||
batchOwnerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交批量交接
|
||||
const handleBatchTransfer = async () => {
|
||||
if (batchTransferMode.value === 'rebind' && !batchTransferAccountId.value) {
|
||||
ElMessage.warning('请选择目标业务员')
|
||||
return
|
||||
}
|
||||
batchSubmitting.value = true
|
||||
try {
|
||||
const data: BatchUpdateBusinessOwnerParams = {
|
||||
shop_ids: Array.from(new Set(selectedRows.value.map((row: ShopResponse) => row.id))),
|
||||
business_owner_account_id:
|
||||
batchTransferMode.value === 'clear' ? null : batchTransferAccountId.value
|
||||
}
|
||||
const res = await ShopService.batchUpdateBusinessOwner(data)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(`批量交接成功,共处理 ${res.data?.shop_count ?? 0} 家店铺`)
|
||||
batchTransferDialogVisible.value = false
|
||||
selectedRows.value = []
|
||||
await getShopList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量交接失败:', error)
|
||||
} finally {
|
||||
batchSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到业务负责人导入页
|
||||
const goToImportPage = () => {
|
||||
router.push(RoutesAlias.BusinessOwnerImport)
|
||||
}
|
||||
|
||||
// 加载业务用户组选项(非代理账号)
|
||||
const loadGroupOptions = async () => {
|
||||
groupLoading.value = true
|
||||
try {
|
||||
const res = await BusinessUserGroupService.getGroups({ page: 1, page_size: 100 })
|
||||
if (res.code === 0) {
|
||||
groupOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载业务用户组失败:', error)
|
||||
} finally {
|
||||
groupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
const rules = reactive<FormRules>({
|
||||
shop_name: [
|
||||
|
||||
Reference in New Issue
Block a user