This commit is contained in:
198
src/components/business/BusinessEntitySelect.vue
Normal file
198
src/components/business/BusinessEntitySelect.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="selection"
|
||||
class="business-entity-select"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled || !allowed"
|
||||
:clearable="clearable"
|
||||
:placeholder="placeholder || `搜索并选择${entityLabel}`"
|
||||
filterable
|
||||
remote
|
||||
remote-show-suffix
|
||||
:remote-method="search"
|
||||
:loading="loading"
|
||||
:no-data-text="error || '暂无匹配结果,请更换关键词'"
|
||||
@visible-change="(visible) => visible && search('')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
<template v-if="hasMore || error" #footer>
|
||||
<span v-if="error" role="alert">{{ error }}</span>
|
||||
<ElButton v-if="error" link :loading="loading" @click="load(1)">重试</ElButton>
|
||||
<ElButton v-else link :loading="loading" @click="load(page + 1)">加载更多</ElButton>
|
||||
</template>
|
||||
</ElSelect>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
searchBusinessOptions,
|
||||
resolveBusinessOption,
|
||||
BUSINESS_OPTION_LABELS,
|
||||
type BusinessOptionKind,
|
||||
type BusinessOption
|
||||
} from '@/api/modules/businessOptions'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { pollingScope } from '@/utils/business/pollingScheduler'
|
||||
import { gatewayError } from '@/utils/business/gateway'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | number | (string | number)[] | null
|
||||
entity: BusinessOptionKind
|
||||
multiple?: boolean
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
allowed?: boolean
|
||||
placeholder?: string
|
||||
context?: { asset_identifier?: string; package_id?: number }
|
||||
includeSystem?: boolean
|
||||
}>(),
|
||||
{ clearable: true, allowed: true }
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | (string | number)[] | undefined]
|
||||
}>()
|
||||
const user = useUserStore(),
|
||||
candidates = ref<BusinessOption[]>([]),
|
||||
retained = ref<BusinessOption[]>([])
|
||||
const loading = ref(false),
|
||||
error = ref(''),
|
||||
hasMore = ref(false),
|
||||
page = ref(1),
|
||||
keyword = ref('')
|
||||
const entityLabel = computed(() => BUSINESS_OPTION_LABELS[props.entity])
|
||||
const selection = computed({
|
||||
get: () => props.modelValue ?? undefined,
|
||||
set: (value) => {
|
||||
const selected = Array.isArray(value) ? value : [value]
|
||||
retained.value = options.value.filter((x) => selected.includes(x.value))
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
})
|
||||
const options = computed(() => {
|
||||
const selected = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]
|
||||
const merged = new Map(
|
||||
[...retained.value.filter((x) => selected.includes(x.value)), ...candidates.value].map(
|
||||
(x) => [x.value, x]
|
||||
)
|
||||
)
|
||||
if (props.entity === 'account' && props.includeSystem)
|
||||
merged.set(0, { value: 0, label: '系统' })
|
||||
for (const value of selected) {
|
||||
if (value == null || value === '' || merged.has(value)) continue
|
||||
merged.set(value, {
|
||||
value,
|
||||
label:
|
||||
typeof value === 'string' && props.entity === 'asset'
|
||||
? value
|
||||
: `${entityLabel.value}名称待加载`
|
||||
})
|
||||
}
|
||||
return [...merged.values()]
|
||||
})
|
||||
let active = true,
|
||||
sequence = 0,
|
||||
version = 0
|
||||
async function search(value: string) {
|
||||
keyword.value = value
|
||||
await load(1)
|
||||
}
|
||||
async function load(nextPage: number) {
|
||||
if (!active || !props.allowed || props.disabled) return
|
||||
const current = ++sequence,
|
||||
currentVersion = version
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await searchBusinessOptions(
|
||||
props.entity,
|
||||
keyword.value,
|
||||
nextPage,
|
||||
props.context
|
||||
)
|
||||
if (!active || current !== sequence || currentVersion !== version) return
|
||||
const selected = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]
|
||||
retained.value = options.value.filter((x) => selected.includes(x.value))
|
||||
candidates.value = nextPage === 1 ? result.options : [...candidates.value, ...result.options]
|
||||
page.value = nextPage
|
||||
hasMore.value = result.hasMore
|
||||
error.value = result.warning || ''
|
||||
} catch (e) {
|
||||
if (active && current === sequence && currentVersion === version)
|
||||
error.value = gatewayError(e)
|
||||
} finally {
|
||||
if (current === sequence) loading.value = false
|
||||
}
|
||||
}
|
||||
async function hydrate() {
|
||||
if (!active || !props.allowed || !['package', 'series'].includes(props.entity)) return
|
||||
const currentVersion = version
|
||||
const values = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]
|
||||
await Promise.all(
|
||||
values.map(async (value) => {
|
||||
if (typeof value !== 'number' || retained.value.some((x) => x.value === value)) return
|
||||
let option: BusinessOption
|
||||
try {
|
||||
option = await resolveBusinessOption(props.entity, value)
|
||||
} catch {
|
||||
option = { value, label: `${entityLabel.value}名称加载失败` }
|
||||
}
|
||||
if (active && currentVersion === version) retained.value.push(option)
|
||||
})
|
||||
)
|
||||
}
|
||||
function invalidate() {
|
||||
version++
|
||||
sequence++
|
||||
candidates.value = []
|
||||
retained.value = []
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
hasMore.value = false
|
||||
page.value = 1
|
||||
keyword.value = ''
|
||||
}
|
||||
watch(
|
||||
() => [
|
||||
props.entity,
|
||||
props.allowed,
|
||||
props.disabled,
|
||||
pollingScope(user),
|
||||
JSON.stringify(props.context)
|
||||
],
|
||||
() => {
|
||||
invalidate()
|
||||
void hydrate()
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => void hydrate(),
|
||||
{ immediate: true }
|
||||
)
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
version++
|
||||
sequence++
|
||||
loading.value = false
|
||||
})
|
||||
onActivated(() => {
|
||||
active = true
|
||||
void hydrate()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
invalidate()
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.business-entity-select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -117,6 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { assertSilenceResponse } from '@/utils/business/packageSilence'
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -356,7 +357,15 @@
|
||||
} else if (formData.refund_voucher_key.length) {
|
||||
data.refund_voucher_key = formData.refund_voucher_key
|
||||
}
|
||||
await RefundService.createRefund(data)
|
||||
const response = await RefundService.createRefund(data)
|
||||
if (response.code !== 0) {
|
||||
try {
|
||||
assertSilenceResponse(response)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '退款申请失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
ElMessage.success('退款申请创建成功')
|
||||
dialogVisible.value = false
|
||||
emit('success')
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import { ExportTaskService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { getExportTaskSceneName } from '@/config/constants'
|
||||
import { isPollingPlatformExport } from '@/config/constants/exportTask'
|
||||
import { isPollingPlatformExport, canAccessExportTaskScene } from '@/config/constants/exportTask'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { isPollingPlatform } from '@/utils/business/pollingScheduler'
|
||||
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
|
||||
@@ -89,14 +89,21 @@
|
||||
}
|
||||
)
|
||||
|
||||
const getNormalizedQuery = (): Record<string, string> => {
|
||||
const query: Record<string, string> = {}
|
||||
const getNormalizedQuery = (): Record<string, string | Record<string, unknown>> => {
|
||||
const query: Record<string, string | Record<string, unknown>> = {}
|
||||
|
||||
Object.entries(props.query || {}).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
if (Array.isArray(value) && value.length === 0) return
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (
|
||||
key === 'filters' &&
|
||||
['order_platform', 'order_agent', 'order_online'].includes(props.scene) &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
query[key] = value as Record<string, unknown>
|
||||
} else if (typeof value === 'string') {
|
||||
query[key] = value
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
query[key] = String(value)
|
||||
@@ -111,6 +118,10 @@
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!canAccessExportTaskScene(props.scene, user.info.user_type)) {
|
||||
ElMessage.warning('当前账号无权导出该类订单')
|
||||
return
|
||||
}
|
||||
if (isPollingPlatformExport(props.scene) && !isPollingPlatform(user.info.user_type)) {
|
||||
ElMessage.warning('仅平台账号可创建该导出任务')
|
||||
return
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { canEditRestrictedAsset } from '@/utils/business/packageSilence'
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -79,10 +81,11 @@
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canEditRestrictedAsset(useUserStore().info.user_type)) return
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
if (valid && canEditRestrictedAsset(useUserStore().info.user_type)) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await AssetService.updateRealnameStatus(props.assetIdentifier, {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
>{{ time ? formatDateTime(time) : '-' }}
|
||||
<ElButton v-if="assetId" link type="primary" @click="visible = true">记录</ElButton></div
|
||||
>
|
||||
<SilenceRecordsDialog
|
||||
v-if="visible"
|
||||
:asset-id="assetId"
|
||||
:asset-type="assetType"
|
||||
@close="visible = false"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
|
||||
const props = defineProps<{
|
||||
assetId: number
|
||||
assetType: 'card' | 'device'
|
||||
time?: string | null
|
||||
}>(),
|
||||
visible = ref(false)
|
||||
watch(
|
||||
() => [props.assetId, props.assetType],
|
||||
() => {
|
||||
visible.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<template v-if="packageType === 'formal'">
|
||||
<ElFormItem label="默认沉默期(天)" :required="required || expiryBase === 'from_activation'">
|
||||
<ElInputNumber
|
||||
v-model="days"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
placeholder="1–90 天;购买即生效可留空"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="支持付费延期"><ElSwitch v-model="enabled" /></ElFormItem>
|
||||
</template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ packageType: string; expiryBase?: string; required?: boolean }>()
|
||||
const days = defineModel<number | null>('days', { required: true })
|
||||
const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
</script>
|
||||
284
src/components/business/package-silence/SilenceDetail.vue
Normal file
284
src/components/business/package-silence/SilenceDetail.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="silence-detail">
|
||||
<div class="detail-header"
|
||||
><ElButton @click="router.back()">返回</ElButton
|
||||
><h2>{{ kind === 'orders' ? '延期订单详情' : '延期批次详情' }}</h2
|
||||
><ElButton :loading="loading" @click="load">刷新</ElButton></div
|
||||
>
|
||||
<ElAlert
|
||||
v-if="error || actionError"
|
||||
:title="error || actionError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
/>
|
||||
<template v-if="data">
|
||||
<ElDescriptions :column="width < 640 ? 1 : 2" border
|
||||
><ElDescriptionsItem v-for="field in fields" :key="field.prop" :label="field.label">{{
|
||||
silenceDisplay(data, field.prop)
|
||||
}}</ElDescriptionsItem></ElDescriptions
|
||||
>
|
||||
<div
|
||||
v-if="kind === 'orders' && data.status === 'pending_payment' && data.qr_content"
|
||||
class="silence-qr"
|
||||
><QrcodeVue :value="data.qr_content" :size="200" /><p
|
||||
>支付截止:{{ data.expire_at ? formatDateTime(data.expire_at) : '-' }}</p
|
||||
></div
|
||||
>
|
||||
<ElAlert
|
||||
v-if="kind === 'orders' && data.status === 'pending_approval'"
|
||||
title="审批中,尚未生效。请以订单完成状态及实际截止日为准。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElButton v-if="data.package_usage_id" @click="recordsId = data.package_usage_id"
|
||||
>沉默期流水</ElButton
|
||||
>
|
||||
<SilenceTable
|
||||
v-if="kind === 'batches'"
|
||||
:rows="data.items || []"
|
||||
:definitions="itemFields"
|
||||
@refresh="load"
|
||||
><ElTableColumn label="异常处理" width="190"
|
||||
><template #default="{ row }"
|
||||
><template
|
||||
v-if="
|
||||
allowed &&
|
||||
hasAuth(P.exception) &&
|
||||
(row.failure_code || row.failure_summary || row.status === 'exception')
|
||||
"
|
||||
><ElButton link :disabled="handling" @click="handle(row, 'retry')">重试</ElButton
|
||||
><ElButton link type="danger" :disabled="handling" @click="handle(row, 'close')"
|
||||
>关闭</ElButton
|
||||
></template
|
||||
></template
|
||||
></ElTableColumn
|
||||
></SilenceTable
|
||||
>
|
||||
<h3 v-if="data.evidence_refs?.length">申请依据</h3
|
||||
><PaymentVoucherDialog
|
||||
v-if="data.evidence_refs?.length"
|
||||
inline
|
||||
:file-keys="data.evidence_refs"
|
||||
/>
|
||||
<h3 v-if="data.payment_voucher_keys?.length">付款凭证</h3
|
||||
><PaymentVoucherDialog
|
||||
v-if="data.payment_voucher_keys?.length"
|
||||
inline
|
||||
:file-keys="data.payment_voucher_keys"
|
||||
/>
|
||||
</template>
|
||||
<SilenceRecordsDialog
|
||||
v-if="recordsId"
|
||||
:usage-id="recordsId"
|
||||
:allowed="allowed"
|
||||
@close="recordsId = undefined"
|
||||
/>
|
||||
</ElCard>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount, onDeactivated, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
|
||||
import {
|
||||
SILENCE_PAGES,
|
||||
PACKAGE_SILENCE_PERMISSIONS as P
|
||||
} from '@/config/constants/packageSilence'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
|
||||
import { silenceData, silenceDisplay, silenceId } from '@/utils/business/silenceExtension'
|
||||
import { pollingScope } from '@/utils/business/pollingScheduler'
|
||||
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
|
||||
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
|
||||
import SilenceTable from './SilenceTable.vue'
|
||||
const props = defineProps<{ kind: 'orders' | 'batches' }>(),
|
||||
route = useRoute(),
|
||||
router = useRouter(),
|
||||
{ width } = useWindowSize(),
|
||||
{ hasAuth } = useAuth(),
|
||||
user = useUserStore()
|
||||
const allowed = computed(() => {
|
||||
const p = SILENCE_PAGES[props.kind]
|
||||
return hasAuth(p.page) && hasAuth(p.detailPage) && hasAuth(p.detail)
|
||||
})
|
||||
const { data, error, loading, load } = useSchedulerRequest<Record<string, any>>(
|
||||
async () => {
|
||||
const id = silenceId(Number(route.params.id))
|
||||
return props.kind === 'orders'
|
||||
? silenceData(await Service.order(id))
|
||||
: silenceData(await Service.batch(id))
|
||||
},
|
||||
() => allowed.value
|
||||
)
|
||||
const recordsId = ref<number>(),
|
||||
actionError = ref(''),
|
||||
handling = ref(false)
|
||||
const maps = {
|
||||
orders: [
|
||||
['order_no', '订单号'],
|
||||
['status_name', '状态'],
|
||||
['asset_identifier', '资产'],
|
||||
['package_name', '套餐'],
|
||||
['package_usage_id', '套餐记录 ID'],
|
||||
['source_name', '来源'],
|
||||
['input_days', '输入天数'],
|
||||
['extension_days', '实际延长天数'],
|
||||
['amount', '应付金额'],
|
||||
['paid_amount', '实收金额'],
|
||||
['payment_method', '支付方式'],
|
||||
['payment_merchant_name', '收款商户'],
|
||||
['third_party_trade_no', '交易流水号'],
|
||||
['offline_payment_method_name', '线下收款方式'],
|
||||
['offline_trade_no', '线下交易单号'],
|
||||
['paid_at', '支付时间'],
|
||||
['before_deadline_at', '原截止时间'],
|
||||
['expected_deadline_at', '预计新截止'],
|
||||
['completed_at', '实际生效时间'],
|
||||
['policy_name', '政策快照'],
|
||||
['approval_required', '需要审批'],
|
||||
['approval_instance_id', '审批实例'],
|
||||
['exception_code', '异常码'],
|
||||
['exception_summary', '异常说明'],
|
||||
['refund_no', '退款单号'],
|
||||
['refund_amount', '退款金额'],
|
||||
['refunded_at', '退款时间'],
|
||||
['created_at', '创建时间']
|
||||
],
|
||||
batches: [
|
||||
['batch_no', '批次号'],
|
||||
['path', '办理路径'],
|
||||
['extension_type', '延期类型'],
|
||||
['status', '批次状态'],
|
||||
['approval_required', '需要审批'],
|
||||
['approval_status', '审批状态'],
|
||||
['approval_instance_id', '审批实例'],
|
||||
['reason', '原因'],
|
||||
['basis', '依据'],
|
||||
['amount', '费用合计'],
|
||||
['requested_count', '明细数'],
|
||||
['succeeded_count', '成功数'],
|
||||
['failed_count', '失败数'],
|
||||
['requested_by', '发起人'],
|
||||
['requested_at', '发起时间'],
|
||||
['completed_at', '完成时间']
|
||||
]
|
||||
}
|
||||
const fields = computed(() => maps[props.kind].map(([prop, label]) => ({ prop, label })))
|
||||
const itemFields = [
|
||||
['row_no', '行号'],
|
||||
['id', '明细 ID'],
|
||||
['asset_identifier', '资产标识'],
|
||||
['package_name', '套餐'],
|
||||
['extension_type', '延期类型'],
|
||||
['extension_days', '延长天数'],
|
||||
['amount', '金额'],
|
||||
['status', '明细状态'],
|
||||
['before_deadline_at', '原截止'],
|
||||
['after_deadline_at', '实际新截止'],
|
||||
['executed_at', '执行时间'],
|
||||
['failure_code', '异常码'],
|
||||
['failure_summary', '异常原因']
|
||||
].map(([prop, label]) => ({ prop, label }))
|
||||
let timer: ReturnType<typeof setTimeout> | undefined,
|
||||
active = true
|
||||
function stop() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
watch(data, (value) => {
|
||||
stop()
|
||||
if (
|
||||
active &&
|
||||
props.kind === 'orders' &&
|
||||
['pending_payment', 'paid'].includes(value?.status) &&
|
||||
allowed.value
|
||||
)
|
||||
timer = setTimeout(() => void load(), 3000)
|
||||
})
|
||||
watch(
|
||||
() => [route.fullPath, allowed.value, pollingScope(user)],
|
||||
() => {
|
||||
stop()
|
||||
recordsId.value = undefined
|
||||
actionError.value = ''
|
||||
void load()
|
||||
}
|
||||
)
|
||||
async function handle(row: Record<string, any>, action: 'retry' | 'close') {
|
||||
if (handling.value) return
|
||||
const scope = pollingScope(user),
|
||||
path = route.fullPath,
|
||||
id = silenceId(row.id)
|
||||
handling.value = true
|
||||
try {
|
||||
requireGatewayAccess(allowed.value && hasAuth(P.exception))
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
action === 'close'
|
||||
? '填写关闭原因。在线订单关闭可能按冻结商户原路退款,请确认后继续。'
|
||||
: '填写重试原因,将按原快照重新执行。',
|
||||
'异常处理',
|
||||
{ inputValidator: (value) => !!value?.trim() || '请填写原因', type: 'warning' }
|
||||
)
|
||||
requireGatewayAccess(
|
||||
active &&
|
||||
allowed.value &&
|
||||
hasAuth(P.exception) &&
|
||||
scope === pollingScope(user) &&
|
||||
path === route.fullPath
|
||||
)
|
||||
silenceData(await Service.handle(id, { id, action, reason: value.trim() }))
|
||||
if (active && scope === pollingScope(user) && path === route.fullPath) await load()
|
||||
} catch (e) {
|
||||
if (active && e !== 'cancel' && e !== 'close') actionError.value = gatewayError(e)
|
||||
} finally {
|
||||
handling.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
stop()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
stop()
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.silence-detail {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.silence-qr {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__cell) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.silence-detail {
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
595
src/components/business/package-silence/SilenceExtendDialog.vue
Normal file
595
src/components/business/package-silence/SilenceExtendDialog.vue
Normal file
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
title="办理沉默期延期"
|
||||
width="min(760px, 96vw)"
|
||||
destroy-on-close
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<ElAlert
|
||||
title="金额和延期截止时间以服务端试算及处理结果为准;待审批或已支付不代表延期已生效。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElAlert
|
||||
v-if="error || eligibilityError"
|
||||
:title="error || eligibilityError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElForm label-width="140px" :disabled="submitting || !!order || !!batch">
|
||||
<ElFormItem label="办理方式"
|
||||
><ElSelect v-model="mode"
|
||||
><ElOption
|
||||
v-if="hasAuth(P.extend) && usageId"
|
||||
value="online"
|
||||
label="扫码付费延期" /><ElOption
|
||||
v-if="hasAuth(P.offline) && usageId"
|
||||
value="offline"
|
||||
label="登记线下付款" /><ElOption
|
||||
v-if="hasAuth(P.extend)"
|
||||
value="special"
|
||||
label="特殊延期" /></ElSelect
|
||||
></ElFormItem>
|
||||
<template v-if="usageId">
|
||||
<ElFormItem label="套餐记录"
|
||||
>{{ usageId }}
|
||||
<ElButton link :loading="eligibilityLoading" @click="loadEligibility"
|
||||
>刷新资格</ElButton
|
||||
></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="eligibility" label="延期资格"
|
||||
><span
|
||||
>{{
|
||||
eligibility.reason || (eligibility.can_extend ? '可以付费延期' : '不能付费延期')
|
||||
}};特殊延期:{{
|
||||
eligibility.special_extension_available ? '可申请' : '不可申请'
|
||||
}}</span
|
||||
></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="eligibility?.pending_order_no" label="待处理订单"
|
||||
>{{ eligibility.pending_order_no }}
|
||||
<ElButton link :disabled="submitting" @click="resumeOrder">查看订单</ElButton></ElFormItem
|
||||
>
|
||||
</template>
|
||||
<ElFormItem label="延期天数" required
|
||||
><ElInputNumber v-model="days" :min="1" :precision="0" :step="1" /><span
|
||||
v-if="mode !== 'special' && eligibility"
|
||||
>({{ eligibility.min_days ?? '-' }}~{{ eligibility.max_days ?? '-' }} 天)</span
|
||||
></ElFormItem
|
||||
>
|
||||
<template v-if="mode !== 'special'">
|
||||
<ElFormItem label="后端试算"
|
||||
><ElButton :loading="quoteLoading" :disabled="!eligibility?.can_extend" @click="loadQuote"
|
||||
>试算</ElButton
|
||||
><span v-if="quote"
|
||||
>实际延长 {{ quote.billed_days ?? '-' }} 天,{{ silenceAmount(quote.amount) }},新截止日
|
||||
{{ quote.new_deadline || '-' }}(当日 23:59:59)</span
|
||||
></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="mode === 'online'" label="支付方式"
|
||||
><ElSelect v-model="paymentMethod"
|
||||
><ElOption value="wechat" label="微信支付" /><ElOption
|
||||
value="alipay"
|
||||
label="支付宝" /></ElSelect
|
||||
></ElFormItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElFormItem label="延期类型" required
|
||||
><ElSelect v-model="specialType"
|
||||
><ElOption v-for="item in specialTypes" :key="item.value" v-bind="item" /></ElSelect
|
||||
></ElFormItem>
|
||||
<ElFormItem label="原因" required><ElInput v-model="reason" type="textarea" /></ElFormItem>
|
||||
<ElFormItem label="依据" required><ElInput v-model="basis" type="textarea" /></ElFormItem>
|
||||
<ElFormItem v-if="hasAuth(P.batch)" label="批量 CSV"
|
||||
><ElUpload :auto-upload="false" :show-file-list="false" accept=".csv" :on-change="readCsv"
|
||||
><ElButton>选择 CSV 明细</ElButton></ElUpload
|
||||
><p
|
||||
>表头 package_usage_id,days;每行一笔。选中后将替代当前单项。天数由后端按 30
|
||||
天向上取整。</p
|
||||
></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="batchItems.length" label="导入明细"
|
||||
><span>{{ batchItems.length }} 项</span
|
||||
><ElButton link @click="batchItems = []">清除</ElButton></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="specialType === 'discount'" label="实收合计(元)" required
|
||||
><ElInput v-model="paidYuan" inputmode="decimal"
|
||||
/></ElFormItem>
|
||||
</template>
|
||||
<template v-if="requiresReceipt">
|
||||
<ElFormItem label="线下收款方式" required
|
||||
><ElSelect
|
||||
v-model="methodId"
|
||||
filterable
|
||||
:loading="methodsLoading"
|
||||
@visible-change="(value) => value && loadMethods()"
|
||||
><ElOption
|
||||
v-for="item in methods"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
:label="item.name" /></ElSelect
|
||||
></ElFormItem>
|
||||
<ElFormItem label="交易单号" required><ElInput v-model="tradeNo" /></ElFormItem>
|
||||
<ElFormItem v-if="mode === 'offline'" label="实收金额(元)" required
|
||||
><ElInput v-model="paidYuan" inputmode="decimal"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="付款时间" required
|
||||
><ElDatePicker
|
||||
v-model="paidAt"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="收款凭证" required
|
||||
><VoucherUpload
|
||||
v-model="vouchers"
|
||||
:key="`vouchers-${mode}`"
|
||||
:max-count="5"
|
||||
@uploading-change="(value) => (uploadingReceipt = value)"
|
||||
/></ElFormItem>
|
||||
</template>
|
||||
<ElFormItem label="申请记录/截图"
|
||||
><VoucherUpload
|
||||
v-model="evidence"
|
||||
:max-count="5"
|
||||
@uploading-change="(value) => (uploadingEvidence = value)"
|
||||
/></ElFormItem>
|
||||
</ElForm>
|
||||
<ElAlert
|
||||
v-if="order"
|
||||
:title="`订单 ${order.order_no || '-'}:${order.status_name || order.status || '-'};实际生效以完成状态为准`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<div v-if="order?.status === 'pending_payment' && order.qr_content" class="silence-qr"
|
||||
><QrcodeVue :value="order.qr_content" :size="200" level="M" /><p
|
||||
>支付截止:{{ order.expire_at ? formatDateTime(order.expire_at) : '-' }}</p
|
||||
></div
|
||||
>
|
||||
<ElButton v-if="order?.id" :loading="checking" @click="refreshOrder">刷新订单状态</ElButton>
|
||||
<ElAlert
|
||||
v-if="batch"
|
||||
:title="`批次 ${batch.batch_no || '-'}:${batch.status || '-'};审批 ${batch.approval_status || '-'};成功 ${batch.succeeded_count ?? '-'},失败 ${batch.failed_count ?? '-'}`"
|
||||
:closable="false"
|
||||
/>
|
||||
<SilenceTable v-if="batch?.items?.length" :rows="batch.items" :definitions="resultFields" />
|
||||
<ElTable v-if="rowErrors.length" :data="rowErrors"
|
||||
><ElTableColumn label="行号"
|
||||
><template #default="{ row }">{{ row.row_no ?? row.row ?? '-' }}</template></ElTableColumn
|
||||
><ElTableColumn label="失败原因"
|
||||
><template #default="{ row }">{{
|
||||
row.reason || row.message || '-'
|
||||
}}</template></ElTableColumn
|
||||
></ElTable
|
||||
>
|
||||
<template #footer
|
||||
><ElButton @click="$emit('close')">关闭</ElButton
|
||||
><ElButton
|
||||
v-if="!order && !batch"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit || uploadingReceipt || uploadingEvidence"
|
||||
@click="submit"
|
||||
>确认提交</ElButton
|
||||
></template
|
||||
>
|
||||
</ElDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, onDeactivated } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
|
||||
import { EmployeeCollectionService } from '@/api/modules/employeeCollection'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
|
||||
import { pollingScope } from '@/utils/business/pollingScheduler'
|
||||
import { PACKAGE_SILENCE_PERMISSIONS as P } from '@/config/constants/packageSilence'
|
||||
import {
|
||||
silenceData,
|
||||
silenceId,
|
||||
silenceDays,
|
||||
silenceFen,
|
||||
silenceDate,
|
||||
silenceAmount,
|
||||
parseSilenceCsv,
|
||||
silenceRowsError,
|
||||
SILENCE_SPECIAL_TYPES,
|
||||
silenceOptions
|
||||
} from '@/utils/business/silenceExtension'
|
||||
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import type {
|
||||
SilenceExtensionOrderResponse,
|
||||
SilenceBatchResponse,
|
||||
SilenceSpecialItem
|
||||
} from '@/types/api/packageSilenceContract'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import SilenceTable from './SilenceTable.vue'
|
||||
const props = defineProps<{ usageId?: number; allowed?: boolean; batchMode?: boolean }>()
|
||||
const emit = defineEmits<{ close: []; changed: [] }>()
|
||||
const { hasAuth } = useAuth(),
|
||||
user = useUserStore()
|
||||
const mode = ref<'online' | 'offline' | 'special'>(
|
||||
props.batchMode ? 'special' : hasAuth(P.extend) ? 'online' : 'offline'
|
||||
)
|
||||
const days = ref<number | undefined>(),
|
||||
paymentMethod = ref('wechat'),
|
||||
specialType = ref('free'),
|
||||
reason = ref(''),
|
||||
basis = ref('')
|
||||
const methodId = ref<number>(),
|
||||
tradeNo = ref(''),
|
||||
paidYuan = ref(''),
|
||||
paidAt = ref(''),
|
||||
vouchers = ref<string[]>([]),
|
||||
evidence = ref<string[]>([])
|
||||
const batchItems = ref<SilenceSpecialItem[]>([]),
|
||||
specialUncertain = ref(false),
|
||||
error = ref(''),
|
||||
submitting = ref(false),
|
||||
checking = ref(false)
|
||||
const uploadingReceipt = ref(false),
|
||||
uploadingEvidence = ref(false),
|
||||
methodsLoading = ref(false),
|
||||
methods = ref<{ id: number; name: string }[]>([])
|
||||
const order = ref<SilenceExtensionOrderResponse>(),
|
||||
batch = ref<SilenceBatchResponse>(),
|
||||
rowErrors = ref<ReturnType<typeof silenceRowsError>>([])
|
||||
const specialTypes = silenceOptions(SILENCE_SPECIAL_TYPES)
|
||||
const allowed = () =>
|
||||
props.allowed !== false &&
|
||||
(mode.value === 'offline' ? hasAuth(P.offline) : hasAuth(P.extend)) &&
|
||||
(!props.batchMode || hasAuth(P.batch))
|
||||
const eligibilityAllowed = computed(() => allowed() && !!props.usageId)
|
||||
const {
|
||||
data: eligibility,
|
||||
loading: eligibilityLoading,
|
||||
error: eligibilityError,
|
||||
load: loadEligibility,
|
||||
clear: clearEligibility
|
||||
} = useSchedulerRequest(
|
||||
async () => silenceData(await Service.eligibility(silenceId(props.usageId))),
|
||||
() => eligibilityAllowed.value
|
||||
)
|
||||
const {
|
||||
data: quote,
|
||||
loading: quoteLoading,
|
||||
load: requestQuote,
|
||||
clear: clearQuote,
|
||||
error: quoteError
|
||||
} = useSchedulerRequest(
|
||||
async () => {
|
||||
const input = silenceDays(days.value),
|
||||
usage = silenceId(props.usageId)
|
||||
return { ...silenceData(await Service.quote(usage, input)), forDays: input, forUsage: usage }
|
||||
},
|
||||
() => eligibilityAllowed.value && !!eligibility.value?.can_extend
|
||||
)
|
||||
const requiresReceipt = computed(
|
||||
() =>
|
||||
mode.value === 'offline' ||
|
||||
(mode.value === 'special' && specialType.value === 'discount' && Number(paidYuan.value) > 0)
|
||||
)
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
allowed() &&
|
||||
!specialUncertain.value &&
|
||||
(mode.value === 'special'
|
||||
? batchItems.value.length
|
||||
? hasAuth(P.batch)
|
||||
: !!eligibility.value?.special_extension_available
|
||||
: !!eligibility.value?.can_extend &&
|
||||
!!quote.value?.valid &&
|
||||
quote.value.forDays === days.value &&
|
||||
quote.value.forUsage === props.usageId &&
|
||||
Number.isSafeInteger(quote.value.amount))
|
||||
)
|
||||
let active = true,
|
||||
version = 0,
|
||||
timer: ReturnType<typeof setTimeout> | undefined
|
||||
const requestIds = new Map<string, string>()
|
||||
function stop() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
function invalidate() {
|
||||
version++
|
||||
stop()
|
||||
order.value = undefined
|
||||
batch.value = undefined
|
||||
rowErrors.value = []
|
||||
error.value = ''
|
||||
clearQuote()
|
||||
clearEligibility()
|
||||
vouchers.value = []
|
||||
evidence.value = []
|
||||
methods.value = []
|
||||
methodsLoading.value = false
|
||||
days.value = undefined
|
||||
reason.value = ''
|
||||
basis.value = ''
|
||||
methodId.value = undefined
|
||||
tradeNo.value = ''
|
||||
paidYuan.value = ''
|
||||
paidAt.value = ''
|
||||
batchItems.value = []
|
||||
}
|
||||
watch(
|
||||
() => [props.usageId, props.allowed, pollingScope(user)],
|
||||
() => {
|
||||
invalidate()
|
||||
if (props.usageId && allowed()) void loadEligibility()
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
watch(
|
||||
() => [mode.value, days.value],
|
||||
() => {
|
||||
clearQuote()
|
||||
error.value = ''
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
watch(
|
||||
() => allowed(),
|
||||
(value) => {
|
||||
if (!value) invalidate()
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
watch(quoteError, (value) => {
|
||||
if (value) error.value = value
|
||||
})
|
||||
async function loadQuote() {
|
||||
await requestQuote()
|
||||
}
|
||||
async function loadMethods() {
|
||||
if (!allowed() || methodsLoading.value) return
|
||||
const current = version,
|
||||
scope = pollingScope(user)
|
||||
methodsLoading.value = true
|
||||
try {
|
||||
const result = silenceData(
|
||||
await EmployeeCollectionService.getPaymentMethods({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
enabled: true
|
||||
})
|
||||
)
|
||||
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
|
||||
const values = Array.isArray(result) ? result : result.items || []
|
||||
methods.value = values.map((item) => ({ id: item.id, name: item.name }))
|
||||
} catch (e) {
|
||||
if (current === version) error.value = gatewayError(e)
|
||||
} finally {
|
||||
if (current === version) methodsLoading.value = false
|
||||
}
|
||||
}
|
||||
async function readCsv(file: UploadFile) {
|
||||
try {
|
||||
requireGatewayAccess(allowed() && hasAuth(P.batch))
|
||||
if (!file.raw || !file.name.toLowerCase().endsWith('.csv')) throw new Error('请选择 CSV 文件')
|
||||
const current = version,
|
||||
items = parseSilenceCsv(await file.raw.text())
|
||||
if (active && current === version && allowed() && hasAuth(P.batch)) batchItems.value = items
|
||||
} catch (e) {
|
||||
error.value = gatewayError(e)
|
||||
}
|
||||
}
|
||||
async function resumeOrder() {
|
||||
const no = eligibility.value?.pending_order_no,
|
||||
current = version
|
||||
if (!no || !allowed()) return
|
||||
try {
|
||||
const result = silenceData(await Service.orders({ order_no: no, page: 1, page_size: 20 }))
|
||||
if (active && current === version && allowed()) {
|
||||
order.value = result.items?.find((item) => item.order_no === no)
|
||||
schedule()
|
||||
}
|
||||
} catch (e) {
|
||||
if (current === version) error.value = gatewayError(e)
|
||||
}
|
||||
}
|
||||
function schedule() {
|
||||
stop()
|
||||
if (active && allowed() && ['pending_payment', 'paid'].includes(order.value?.status || ''))
|
||||
timer = setTimeout(() => void refreshOrder(), 3000)
|
||||
}
|
||||
async function refreshOrder() {
|
||||
stop()
|
||||
const id = order.value?.id,
|
||||
current = version,
|
||||
scope = pollingScope(user)
|
||||
if (!id || checking.value || !allowed()) return
|
||||
checking.value = true
|
||||
try {
|
||||
const result = silenceData(await Service.order(id))
|
||||
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
|
||||
order.value = result
|
||||
if (result.status === 'completed') emit('changed')
|
||||
schedule()
|
||||
} catch (e) {
|
||||
if (current === version) error.value = gatewayError(e)
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const current = version,
|
||||
scope = pollingScope(user),
|
||||
currentMode = mode.value
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
rowErrors.value = []
|
||||
let specialSent = false
|
||||
try {
|
||||
requireGatewayAccess(canSubmit.value && !uploadingReceipt.value && !uploadingEvidence.value)
|
||||
const input =
|
||||
currentMode === 'special' && batchItems.value.length ? 1 : silenceDays(days.value),
|
||||
usage =
|
||||
batchItems.value.length && currentMode === 'special'
|
||||
? undefined
|
||||
: silenceId(props.usageId)
|
||||
const common = { package_usage_id: usage!, days: input, evidence_refs: [...evidence.value] }
|
||||
const receipt = requiresReceipt.value
|
||||
? {
|
||||
offline_payment_method_id: silenceId(methodId.value, '收款方式'),
|
||||
offline_trade_no: tradeNo.value.trim(),
|
||||
paid_at: silenceDate(paidAt.value),
|
||||
payment_voucher_keys: [...vouchers.value]
|
||||
}
|
||||
: undefined
|
||||
if (
|
||||
receipt &&
|
||||
(!receipt.offline_trade_no ||
|
||||
receipt.payment_voucher_keys.length < 1 ||
|
||||
receipt.payment_voucher_keys.length > 5)
|
||||
)
|
||||
throw new Error('请填写交易单号,并上传 1~5 张收款凭证')
|
||||
const amount =
|
||||
currentMode === 'offline' || (currentMode === 'special' && specialType.value === 'discount')
|
||||
? silenceFen(paidYuan.value)
|
||||
: 0
|
||||
if (currentMode === 'offline' && amount !== quote.value?.amount)
|
||||
throw new Error('实收金额必须等于当前后端试算金额')
|
||||
const items = batchItems.value.length
|
||||
? batchItems.value.map((item) => ({ ...item }))
|
||||
: [{ package_usage_id: usage!, days: input }]
|
||||
if (currentMode === 'special' && (!reason.value.trim() || !basis.value.trim()))
|
||||
throw new Error('请填写特殊延期原因和依据')
|
||||
if (currentMode === 'special' && items.length > 1) requireGatewayAccess(hasAuth(P.batch))
|
||||
const body =
|
||||
currentMode === 'special'
|
||||
? {
|
||||
extension_type: specialType.value,
|
||||
items,
|
||||
reason: reason.value.trim(),
|
||||
basis: basis.value.trim(),
|
||||
amount,
|
||||
evidence_refs: [...evidence.value],
|
||||
...(receipt
|
||||
? {
|
||||
offline_payment_method_id: receipt.offline_payment_method_id,
|
||||
offline_trade_no: receipt.offline_trade_no,
|
||||
offline_paid_at: receipt.paid_at,
|
||||
payment_voucher_keys: receipt.payment_voucher_keys
|
||||
}
|
||||
: {})
|
||||
}
|
||||
: currentMode === 'offline'
|
||||
? { ...common, ...receipt!, paid_amount: amount }
|
||||
: { ...common, payment_method: paymentMethod.value }
|
||||
const fingerprint = JSON.stringify([currentMode, body])
|
||||
if (!requestIds.has(fingerprint)) requestIds.set(fingerprint, crypto.randomUUID())
|
||||
await ElMessageBox.confirm(
|
||||
currentMode === 'special'
|
||||
? `确认提交 ${items.length} 项特殊延期申请?结果以审批和逐项执行为准。`
|
||||
: '确认提交延期订单?待支付或待审批并不代表已生效。',
|
||||
'提交确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
requireGatewayAccess(
|
||||
active &&
|
||||
current === version &&
|
||||
scope === pollingScope(user) &&
|
||||
currentMode === mode.value &&
|
||||
canSubmit.value
|
||||
)
|
||||
// 表单在确认框期间被锁定;同一快照重试复用幂等键,不自动重发写请求。
|
||||
specialSent = currentMode === 'special'
|
||||
const result =
|
||||
currentMode === 'special'
|
||||
? silenceData(await Service.special(body as Parameters<typeof Service.special>[0]))
|
||||
: currentMode === 'offline'
|
||||
? silenceData(
|
||||
await Service.createOffline({
|
||||
...body,
|
||||
request_id: requestIds.get(fingerprint)!
|
||||
} as Parameters<typeof Service.createOffline>[0])
|
||||
)
|
||||
: silenceData(
|
||||
await Service.createQrcode({
|
||||
...body,
|
||||
request_id: requestIds.get(fingerprint)!
|
||||
} as Parameters<typeof Service.createQrcode>[0])
|
||||
)
|
||||
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
|
||||
if (currentMode === 'special') batch.value = result as SilenceBatchResponse
|
||||
else {
|
||||
order.value = result as SilenceExtensionOrderResponse
|
||||
schedule()
|
||||
}
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
if (active && current === version && e !== 'cancel' && e !== 'close') {
|
||||
error.value = gatewayError(e)
|
||||
rowErrors.value = silenceRowsError(e)
|
||||
if (
|
||||
specialSent &&
|
||||
!(e as { code?: unknown; response?: unknown })?.code &&
|
||||
!(e as { response?: unknown })?.response
|
||||
) {
|
||||
specialUncertain.value = true
|
||||
error.value += '。提交结果未确认,请先到延期批次列表核对,不要重复提交。'
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
const resultFields = [
|
||||
['row_no', '行号'],
|
||||
['asset_identifier', '资产'],
|
||||
['package_name', '套餐'],
|
||||
['status', '处理状态'],
|
||||
['after_deadline_at', '延期后截止'],
|
||||
['failure_summary', '失败原因']
|
||||
].map(([prop, label]) => ({ prop, label }))
|
||||
onMounted(() => {
|
||||
if (props.usageId) void loadEligibility()
|
||||
})
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
invalidate()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
invalidate()
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.silence-qr {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-form-item p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
:deep(.el-form-item) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
justify-content: flex-start;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
flex-basis: 100%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
633
src/components/business/package-silence/SilenceManagement.vue
Normal file
633
src/components/business/package-silence/SilenceManagement.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<template>
|
||||
<ArtTableFullScreen
|
||||
><div id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="searchItems.length >= 4"
|
||||
label-width="100px"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ElAlert
|
||||
v-if="error || actionError"
|
||||
:title="error || actionError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
/>
|
||||
<SilenceTable
|
||||
:rows="displayRows"
|
||||
:row-key="kind === 'usages' ? 'package_usage_id' : 'id'"
|
||||
:definitions="fields"
|
||||
:loading="loading"
|
||||
:total="kind === 'policies' ? undefined : total"
|
||||
:page="page"
|
||||
:size="size"
|
||||
@refresh="load"
|
||||
@page="
|
||||
(value) => {
|
||||
page = value
|
||||
load()
|
||||
}
|
||||
"
|
||||
@size="
|
||||
(value) => {
|
||||
size = value
|
||||
search()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #toolbar>
|
||||
<ElButton
|
||||
v-if="kind === 'policies' && canManagePolicy"
|
||||
type="primary"
|
||||
@click="editPolicy()"
|
||||
>新增政策</ElButton
|
||||
>
|
||||
<ElButton v-if="kind === 'policies' && canManagePolicy" @click="matchVisible = true"
|
||||
>查询套餐匹配政策</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="kind === 'batches' && allowed && hasAuth(P.extend) && hasAuth(P.batch)"
|
||||
type="primary"
|
||||
@click="batchVisible = true"
|
||||
>批量特殊延期</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="exportPermission && allowed && hasAuth(exportPermission)"
|
||||
@click="openExport"
|
||||
>导出</ElButton
|
||||
>
|
||||
</template>
|
||||
<ElTableColumn label="操作" fixed="right" min-width="200"
|
||||
><template #default="{ row }">
|
||||
<ElButton v-if="canDetail" type="primary" link @click="detail(row)">详情</ElButton>
|
||||
<template v-if="kind === 'usages' && row.silence">
|
||||
<ElButton link type="primary" @click="recordsId = row.package_usage_id"
|
||||
>沉默期流水</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="row.silence.extendable && (hasAuth(P.extend) || hasAuth(P.offline))"
|
||||
link
|
||||
type="primary"
|
||||
@click="extendId = row.package_usage_id"
|
||||
>延期</ElButton
|
||||
>
|
||||
</template>
|
||||
<template v-if="kind === 'policies' && canManagePolicy"
|
||||
><ElButton link type="primary" @click="editPolicy(row)">编辑</ElButton
|
||||
><ElButton link :disabled="changing" @click="togglePolicy(row)">{{
|
||||
row.status === 1 ? '停用' : '启用'
|
||||
}}</ElButton></template
|
||||
>
|
||||
</template></ElTableColumn
|
||||
>
|
||||
</SilenceTable>
|
||||
<div v-if="kind === 'policies'" class="policy-pager">
|
||||
<span>第 {{ page }} 页(接口未返回总数)</span>
|
||||
<ElButton :disabled="page <= 1 || loading" @click="previousPolicyPage">上一页</ElButton>
|
||||
<ElButton :disabled="rows.length < size || loading" @click="nextPolicyPage"
|
||||
>下一页</ElButton
|
||||
>
|
||||
</div>
|
||||
</ElCard>
|
||||
<ElDialog
|
||||
v-if="matchVisible"
|
||||
:model-value="true"
|
||||
title="查询套餐匹配政策"
|
||||
width="min(600px,96vw)"
|
||||
@close="closeMatch"
|
||||
>
|
||||
<ElForm label-width="80px">
|
||||
<ElFormItem label="套餐" required>
|
||||
<BusinessEntitySelect
|
||||
v-model="matchPackage"
|
||||
entity="package"
|
||||
:allowed="canManagePolicy"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="closeMatch">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!matchPackage || !canManagePolicy"
|
||||
:loading="matching"
|
||||
@click="matchPolicy"
|
||||
>查询</ElButton
|
||||
>
|
||||
</template>
|
||||
</ElDialog>
|
||||
<ElDialog
|
||||
v-if="matchResult"
|
||||
:model-value="true"
|
||||
title="当前匹配政策"
|
||||
width="min(600px,96vw)"
|
||||
@close="matchResult = undefined"
|
||||
><ElDescriptions :column="1" border
|
||||
><ElDescriptionsItem label="是否匹配">{{
|
||||
matchResult.matched ? '是' : '否'
|
||||
}}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="政策">{{
|
||||
matchResult.policy?.name || '-'
|
||||
}}</ElDescriptionsItem
|
||||
><ElDescriptionsItem label="月单价">{{
|
||||
silenceAmount(matchResult.policy?.unit_price)
|
||||
}}</ElDescriptionsItem></ElDescriptions
|
||||
></ElDialog
|
||||
>
|
||||
<SilenceExtendDialog
|
||||
v-if="extendId || batchVisible"
|
||||
:usage-id="extendId"
|
||||
:batch-mode="batchVisible"
|
||||
:allowed="allowed"
|
||||
@close="closeExtension"
|
||||
@changed="load"
|
||||
/>
|
||||
<SilenceRecordsDialog
|
||||
v-if="recordsId"
|
||||
:usage-id="recordsId"
|
||||
:allowed="allowed"
|
||||
@close="recordsId = undefined"
|
||||
/>
|
||||
<SilencePolicyDialog
|
||||
v-if="policyVisible"
|
||||
:id="policyId"
|
||||
:allowed="canManagePolicy"
|
||||
@close="policyVisible = false"
|
||||
@saved="load"
|
||||
/>
|
||||
<ExportTaskCreateDialog
|
||||
v-if="exportVisible && exportScene"
|
||||
v-model="exportVisible"
|
||||
:scene="exportScene"
|
||||
:query="exportQuery"
|
||||
:confirm-permission="exportPermission"
|
||||
/> </div
|
||||
></ArtTableFullScreen>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
|
||||
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { pollingScope } from '@/utils/business/pollingScheduler'
|
||||
import {
|
||||
silenceData,
|
||||
silenceRange,
|
||||
silenceDate,
|
||||
silenceId,
|
||||
silenceAmount,
|
||||
silenceOptions,
|
||||
SILENCE_ORDER_STATUS,
|
||||
SILENCE_SPECIAL_TYPES
|
||||
} from '@/utils/business/silenceExtension'
|
||||
import { compactGatewayQuery, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
|
||||
import {
|
||||
SILENCE_PAGES,
|
||||
PACKAGE_SILENCE_PERMISSIONS as P
|
||||
} from '@/config/constants/packageSilence'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { ExportTaskScene } from '@/types/api/exportTask'
|
||||
import type { SilencePolicyMatchResponse } from '@/types/api/packageSilenceContract'
|
||||
import SilenceTable from './SilenceTable.vue'
|
||||
import SilenceExtendDialog from './SilenceExtendDialog.vue'
|
||||
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
|
||||
import SilencePolicyDialog from './SilencePolicyDialog.vue'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import BusinessEntitySelect from '@/components/business/BusinessEntitySelect.vue'
|
||||
const props = defineProps<{ kind: 'orders' | 'batches' | 'usages' | 'policies' }>()
|
||||
const router = useRouter(),
|
||||
user = useUserStore(),
|
||||
{ hasAuth } = useAuth(),
|
||||
page = ref(1),
|
||||
size = ref(20)
|
||||
const allowed = computed(
|
||||
() =>
|
||||
hasAuth(SILENCE_PAGES[props.kind].page) && (props.kind !== 'policies' || hasAuth(P.policy))
|
||||
)
|
||||
const canManagePolicy = computed(() => allowed.value && hasAuth(P.policy))
|
||||
const canDetail = computed(
|
||||
() =>
|
||||
allowed.value &&
|
||||
(props.kind === 'orders' || props.kind === 'batches') &&
|
||||
hasAuth(SILENCE_PAGES[props.kind as 'orders' | 'batches'].detail) &&
|
||||
hasAuth(SILENCE_PAGES[props.kind as 'orders' | 'batches'].detailPage)
|
||||
)
|
||||
const filters = reactive<Record<string, any>>({}),
|
||||
actionError = ref(''),
|
||||
changing = ref(false)
|
||||
const recordsId = ref<number>(),
|
||||
extendId = ref<number>(),
|
||||
batchVisible = ref(false),
|
||||
policyVisible = ref(false),
|
||||
policyId = ref<number>(),
|
||||
matchResult = ref<SilencePolicyMatchResponse>()
|
||||
const matchVisible = ref(false),
|
||||
matchPackage = ref<number>(),
|
||||
matching = ref(false)
|
||||
let matchSequence = 0
|
||||
const exportVisible = ref(false),
|
||||
exportQuery = ref<Record<string, unknown>>({})
|
||||
const exportScene = computed<ExportTaskScene | undefined>(() =>
|
||||
props.kind === 'orders'
|
||||
? 'package_silence_order'
|
||||
: props.kind === 'usages'
|
||||
? 'package_silence_usage'
|
||||
: undefined
|
||||
)
|
||||
const exportPermission = computed(() =>
|
||||
props.kind === 'orders'
|
||||
? SILENCE_PAGES.orders.export
|
||||
: props.kind === 'usages'
|
||||
? SILENCE_PAGES.usages.export
|
||||
: ''
|
||||
)
|
||||
const queryKeys = {
|
||||
orders: [
|
||||
'package_usage_id',
|
||||
'package_id',
|
||||
'asset_identifier',
|
||||
'order_no',
|
||||
'status',
|
||||
'source',
|
||||
'payment_method',
|
||||
'merchant_name',
|
||||
'third_party_trade_no'
|
||||
],
|
||||
batches: ['batch_no', 'path', 'extension_type', 'status', 'has_exception'],
|
||||
usages: ['view', 'within_days', 'asset_identifier', 'package_id', 'shop_id'],
|
||||
policies: ['name', 'scope_type', 'status']
|
||||
}
|
||||
function query() {
|
||||
const value: Record<string, unknown> = {}
|
||||
for (const key of queryKeys[props.kind]) {
|
||||
const raw = filters[key]
|
||||
value[key] =
|
||||
['package_usage_id', 'package_id', 'shop_id', 'within_days'].includes(key) &&
|
||||
raw != null &&
|
||||
raw !== ''
|
||||
? silenceId(Number(raw), key)
|
||||
: raw
|
||||
}
|
||||
if (props.kind === 'usages')
|
||||
Object.assign(value, silenceRange(filters.time_range, 'deadline_start', 'deadline_end'))
|
||||
if (props.kind === 'orders')
|
||||
Object.assign(value, silenceRange(filters.time_range, 'paid_start', 'paid_end'))
|
||||
if (props.kind === 'policies' && filters.effective_at)
|
||||
value.effective_at = silenceDate(filters.effective_at)
|
||||
return compactGatewayQuery(value)
|
||||
}
|
||||
const { data, loading, error, load, clear } = useSchedulerRequest(
|
||||
async () => {
|
||||
const params = { ...query(), page: page.value, page_size: size.value }
|
||||
if (props.kind === 'orders') return silenceData(await Service.orders(params))
|
||||
if (props.kind === 'batches') return silenceData(await Service.batches(params))
|
||||
if (props.kind === 'usages') return silenceData(await Service.usages(params))
|
||||
return silenceData(await Service.policies(params))
|
||||
},
|
||||
() => allowed.value
|
||||
)
|
||||
const rows = computed(() => (Array.isArray(data.value) ? data.value : data.value?.items || []))
|
||||
const total = computed(() =>
|
||||
Array.isArray(data.value) ? data.value.length : data.value?.total || 0
|
||||
)
|
||||
const input = (prop: string, label: string): SearchFormItem => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'input',
|
||||
config: { clearable: true }
|
||||
})
|
||||
const select = (prop: string, label: string, map: Record<string, string>): SearchFormItem => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'select',
|
||||
options: silenceOptions(map),
|
||||
config: { clearable: true }
|
||||
})
|
||||
const number = (prop: string, label: string): SearchFormItem => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'input',
|
||||
config: { inputmode: 'numeric', clearable: true }
|
||||
})
|
||||
const entity = (prop: string, label: string, kind: string): SearchFormItem => ({
|
||||
prop,
|
||||
label,
|
||||
type: 'business-select',
|
||||
config: {
|
||||
entity: kind,
|
||||
allowed: allowed.value,
|
||||
...(kind === 'usage'
|
||||
? {
|
||||
context: {
|
||||
asset_identifier: filters.asset_identifier || undefined,
|
||||
package_id: filters.package_id || undefined
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
const time: SearchFormItem = {
|
||||
prop: 'time_range',
|
||||
label: props.kind === 'orders' ? '支付起止时间' : '截止起止时间',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
clearable: true
|
||||
}
|
||||
}
|
||||
const searchItems = computed<SearchFormItem[]>(() =>
|
||||
props.kind === 'orders'
|
||||
? [
|
||||
entity('asset_identifier', '资产', 'asset'),
|
||||
input('order_no', '订单号'),
|
||||
select('status', '订单状态', SILENCE_ORDER_STATUS),
|
||||
select('source', '来源', { client: '客户端', admin: '后台', offline: '线下' }),
|
||||
select('payment_method', '支付方式', {
|
||||
wechat: '微信',
|
||||
alipay: '支付宝',
|
||||
offline: '线下'
|
||||
}),
|
||||
input('merchant_name', '收款商户'),
|
||||
input('third_party_trade_no', '交易单号'),
|
||||
entity('package_usage_id', '套餐记录', 'usage'),
|
||||
entity('package_id', '套餐', 'package'),
|
||||
time
|
||||
]
|
||||
: props.kind === 'batches'
|
||||
? [
|
||||
input('batch_no', '批次号'),
|
||||
select('path', '办理路径', {
|
||||
online: '在线',
|
||||
offline: '线下',
|
||||
special: '特殊延期',
|
||||
adjust: '购买调整'
|
||||
}),
|
||||
select('extension_type', '延期类型', SILENCE_SPECIAL_TYPES),
|
||||
input('status', '批次状态编码'),
|
||||
{
|
||||
prop: 'has_exception',
|
||||
label: '含异常',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '是', value: true },
|
||||
{ label: '否', value: false }
|
||||
],
|
||||
config: { clearable: true }
|
||||
}
|
||||
]
|
||||
: props.kind === 'usages'
|
||||
? [
|
||||
select('view', '视图', { expiring: '即将过期', expired: '已过期', all: '全部' }),
|
||||
number('within_days', '临期天数'),
|
||||
entity('asset_identifier', '资产', 'asset'),
|
||||
entity('package_id', '套餐', 'package'),
|
||||
entity('shop_id', '店铺', 'shop'),
|
||||
time
|
||||
]
|
||||
: [
|
||||
input('name', '政策名称'),
|
||||
select('scope_type', '适用范围', {
|
||||
all: '全部套餐',
|
||||
series: '套餐系列',
|
||||
package: '指定套餐'
|
||||
}),
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 }
|
||||
],
|
||||
config: { clearable: true }
|
||||
},
|
||||
{
|
||||
prop: 'effective_at',
|
||||
label: '生效于',
|
||||
type: 'datetime',
|
||||
config: { type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm:ssZ', clearable: true }
|
||||
}
|
||||
]
|
||||
)
|
||||
const definitions = {
|
||||
orders: [
|
||||
['order_no', '订单号'],
|
||||
['asset_identifier', '资产标识'],
|
||||
['package_name', '套餐'],
|
||||
['status_name', '状态'],
|
||||
['source_name', '来源'],
|
||||
['extension_days', '延长天数'],
|
||||
['amount', '应付'],
|
||||
['paid_amount', '实付'],
|
||||
['payment_method', '支付方式'],
|
||||
['payment_merchant_name', '收款商户'],
|
||||
['third_party_trade_no', '交易流水号'],
|
||||
['paid_at', '支付时间'],
|
||||
['expected_deadline_at', '预计截止'],
|
||||
['completed_at', '生效时间'],
|
||||
['created_at', '创建时间']
|
||||
],
|
||||
batches: [
|
||||
['batch_no', '批次号'],
|
||||
['path', '办理路径'],
|
||||
['extension_type', '延期类型'],
|
||||
['status', '状态'],
|
||||
['approval_status', '审批状态'],
|
||||
['requested_count', '总项数'],
|
||||
['succeeded_count', '成功'],
|
||||
['failed_count', '失败'],
|
||||
['amount', '金额'],
|
||||
['requested_at', '发起时间'],
|
||||
['completed_at', '完成时间']
|
||||
],
|
||||
usages: [
|
||||
['asset_identifier', '资产标识'],
|
||||
['asset_type', '资产类型'],
|
||||
['shop_name', '店铺'],
|
||||
['package_name', '套餐'],
|
||||
['package_usage_id', '套餐记录 ID'],
|
||||
['silence_status_name', '沉默期状态'],
|
||||
['silence_deadline_at', '截止时间'],
|
||||
['silence_extended_days', '累计延期天数'],
|
||||
['order_no', '购买订单'],
|
||||
['created_at', '购买时间']
|
||||
],
|
||||
policies: [
|
||||
['name', '政策名称'],
|
||||
['scope_type_name', '适用范围'],
|
||||
['scope_ids', '范围 ID'],
|
||||
['unit_price', '月单价'],
|
||||
['free_extension_limit', '免费次数'],
|
||||
['paid_max_months', '付费累计最多月数'],
|
||||
['single_max_months', '单次最多月数'],
|
||||
['effective_from', '生效时间'],
|
||||
['effective_to', '失效时间'],
|
||||
['status', '状态'],
|
||||
['updated_at', '更新时间']
|
||||
]
|
||||
}
|
||||
const fields = computed(() => definitions[props.kind].map(([prop, label]) => ({ prop, label })))
|
||||
const displayRows = computed(() =>
|
||||
rows.value.map((row) =>
|
||||
props.kind === 'usages' ? { ...row, ...('silence' in row ? row.silence : {}) } : row
|
||||
)
|
||||
)
|
||||
// 用扁平显示行,同时保留原 silence 对象供操作入口判定。
|
||||
function search() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function previousPolicyPage() {
|
||||
page.value--
|
||||
void load()
|
||||
}
|
||||
function nextPolicyPage() {
|
||||
page.value++
|
||||
void load()
|
||||
}
|
||||
function closeExtension() {
|
||||
extendId.value = undefined
|
||||
batchVisible.value = false
|
||||
}
|
||||
function reset() {
|
||||
clear()
|
||||
Object.keys(filters).forEach((key) => delete filters[key])
|
||||
if (props.kind === 'usages') filters.view = 'expiring'
|
||||
search()
|
||||
}
|
||||
function detail(row: Record<string, any>) {
|
||||
requireGatewayAccess(canDetail.value)
|
||||
void router.push(`/package-management/silence-${props.kind}/detail/${silenceId(row.id)}`)
|
||||
}
|
||||
function editPolicy(row?: Record<string, any>) {
|
||||
if (!canManagePolicy.value) return
|
||||
policyId.value = row?.id
|
||||
policyVisible.value = true
|
||||
}
|
||||
function openExport() {
|
||||
try {
|
||||
requireGatewayAccess(allowed.value && hasAuth(exportPermission.value))
|
||||
exportQuery.value = { filters: query() }
|
||||
exportVisible.value = true
|
||||
} catch (e) {
|
||||
actionError.value = gatewayError(e)
|
||||
}
|
||||
}
|
||||
async function matchPolicy() {
|
||||
if (!canManagePolicy.value || !matchPackage.value || matching.value) return
|
||||
const scope = pollingScope(user),
|
||||
current = ++matchSequence,
|
||||
packageId = matchPackage.value
|
||||
matching.value = true
|
||||
try {
|
||||
requireGatewayAccess(active && canManagePolicy.value && scope === pollingScope(user))
|
||||
const result = silenceData(await Service.match(silenceId(packageId, '套餐')))
|
||||
if (
|
||||
active &&
|
||||
canManagePolicy.value &&
|
||||
scope === pollingScope(user) &&
|
||||
current === matchSequence &&
|
||||
packageId === matchPackage.value
|
||||
) {
|
||||
matchResult.value = result
|
||||
closeMatch()
|
||||
}
|
||||
} catch (e) {
|
||||
if (active && current === matchSequence && scope === pollingScope(user))
|
||||
actionError.value = gatewayError(e)
|
||||
} finally {
|
||||
if (current === matchSequence) matching.value = false
|
||||
}
|
||||
}
|
||||
function closeMatch() {
|
||||
matchSequence++
|
||||
matchVisible.value = false
|
||||
matchPackage.value = undefined
|
||||
matching.value = false
|
||||
}
|
||||
watch(
|
||||
() => [filters.asset_identifier, filters.package_id],
|
||||
() => {
|
||||
delete filters.package_usage_id
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
async function togglePolicy(row: Record<string, any>) {
|
||||
if (changing.value) return
|
||||
const scope = pollingScope(user),
|
||||
id = silenceId(row.id),
|
||||
status = row.status === 1 ? 0 : 1
|
||||
changing.value = true
|
||||
try {
|
||||
requireGatewayAccess(canManagePolicy.value)
|
||||
await ElMessageBox.confirm(`确认${status ? '启用' : '停用'} ${row.name}?`, '状态确认', {
|
||||
type: 'warning'
|
||||
})
|
||||
requireGatewayAccess(active && canManagePolicy.value && scope === pollingScope(user))
|
||||
silenceData(await Service.policyStatus(id, status))
|
||||
if (active && scope === pollingScope(user)) await load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel' && e !== 'close') actionError.value = gatewayError(e)
|
||||
} finally {
|
||||
changing.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [allowed.value, pollingScope(user)],
|
||||
() => {
|
||||
recordsId.value = undefined
|
||||
extendId.value = undefined
|
||||
batchVisible.value = false
|
||||
policyVisible.value = false
|
||||
exportVisible.value = false
|
||||
matchResult.value = undefined
|
||||
closeMatch()
|
||||
actionError.value = ''
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
let active = true
|
||||
function closeDialogs() {
|
||||
recordsId.value = undefined
|
||||
closeExtension()
|
||||
policyVisible.value = false
|
||||
exportVisible.value = false
|
||||
matchResult.value = undefined
|
||||
closeMatch()
|
||||
}
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
closeDialogs()
|
||||
})
|
||||
onActivated(() => {
|
||||
active = true
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
closeDialogs()
|
||||
})
|
||||
onMounted(() => {
|
||||
if (props.kind === 'usages') filters.view = 'expiring'
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
246
src/components/business/package-silence/SilencePolicyDialog.vue
Normal file
246
src/components/business/package-silence/SilencePolicyDialog.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
:title="id ? '编辑延期政策' : '新增延期政策'"
|
||||
width="min(780px, 96vw)"
|
||||
destroy-on-close
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<ElAlert
|
||||
title="同级范围及生效时间不能重叠;变更只影响之后发起的业务,不改变历史订单和审批快照。金额输入单位为元。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElAlert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<ElForm v-loading="loading" label-width="160px" :disabled="saving || loading">
|
||||
<ElFormItem label="政策名称" required><ElInput v-model="form.name" /></ElFormItem>
|
||||
<ElFormItem label="适用范围" required
|
||||
><ElSelect v-model="form.scope_type" @change="form.scope_ids = []"
|
||||
><ElOption value="all" label="全部套餐" /><ElOption
|
||||
value="series"
|
||||
label="套餐系列" /><ElOption value="package" label="指定套餐" /></ElSelect
|
||||
></ElFormItem>
|
||||
<ElFormItem v-if="form.scope_type !== 'all'" label="适用系列/套餐" required
|
||||
><BusinessEntitySelect
|
||||
v-model="form.scope_ids"
|
||||
:entity="form.scope_type === 'package' ? 'package' : 'series'"
|
||||
:allowed="allowed"
|
||||
multiple
|
||||
/>
|
||||
<p>范围优先级:指定套餐 > 套餐系列 > 全部套餐。</p></ElFormItem
|
||||
>
|
||||
<ElFormItem label="月单价(元)" required
|
||||
><ElInput v-model="unitYuan" inputmode="decimal"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="免费延期次数"
|
||||
><ElInputNumber v-model="form.free_extension_limit" :min="0" :precision="0"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="付费累计最多月数"
|
||||
><ElInputNumber v-model="form.paid_max_months" :min="0" :precision="0"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="限制单次月数"><ElSwitch v-model="singleLimited" /></ElFormItem>
|
||||
<ElFormItem v-if="singleLimited" label="单次最多月数"
|
||||
><ElInputNumber v-model="form.single_max_months" :min="1" :precision="0"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="生效时间" required
|
||||
><ElDatePicker
|
||||
v-model="form.effective_from"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="失效时间"
|
||||
><ElDatePicker
|
||||
v-model="form.effective_to"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
clearable
|
||||
/><p>不填表示长期。</p></ElFormItem
|
||||
>
|
||||
<ElFormItem label="免审批场景"
|
||||
><ElSelect
|
||||
v-model="form.approval_exempt_types"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="空表示全部需要审批"
|
||||
><ElOption value="offline_paid" label="线下付费延期" /><ElOption
|
||||
value="batch"
|
||||
label="批量特殊延期" /><ElOption
|
||||
v-for="value in form.approval_exempt_types.filter(
|
||||
(x) => !['offline_paid', 'batch'].includes(x)
|
||||
)"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:label="value" /></ElSelect
|
||||
><p>接口未枚举完整场景;其他场景须填写后端提供的精确编码,不填写显示文案。</p></ElFormItem
|
||||
>
|
||||
<ElFormItem label="启用"
|
||||
><ElSwitch v-model="form.status" :active-value="1" :inactive-value="0"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="备注"><ElInput v-model="form.remark" type="textarea" /></ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer
|
||||
><ElButton @click="$emit('close')">取消</ElButton
|
||||
><ElButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="loading || !allowed || !loaded"
|
||||
@click="submit"
|
||||
>保存</ElButton
|
||||
></template
|
||||
>
|
||||
</ElDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, onBeforeUnmount } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
|
||||
import BusinessEntitySelect from '@/components/business/BusinessEntitySelect.vue'
|
||||
import {
|
||||
silenceData,
|
||||
silenceDate,
|
||||
silenceFen,
|
||||
silenceId,
|
||||
validateSilencePolicy
|
||||
} from '@/utils/business/silenceExtension'
|
||||
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
|
||||
import { pollingScope } from '@/utils/business/pollingScheduler'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SilencePolicyRequest } from '@/types/api/packageSilenceContract'
|
||||
const props = defineProps<{ id?: number; allowed: boolean }>(),
|
||||
emit = defineEmits<{ close: []; saved: [] }>(),
|
||||
user = useUserStore()
|
||||
const form = reactive({
|
||||
name: '',
|
||||
scope_type: 'all',
|
||||
scope_ids: [] as (number | string)[],
|
||||
free_extension_limit: 0,
|
||||
paid_max_months: 0,
|
||||
single_max_months: 1,
|
||||
effective_from: '',
|
||||
effective_to: '' as string | null,
|
||||
approval_exempt_types: [] as string[],
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
const unitYuan = ref('0.00'),
|
||||
singleLimited = ref(false),
|
||||
loading = ref(false),
|
||||
saving = ref(false),
|
||||
loaded = ref(!props.id),
|
||||
error = ref('')
|
||||
let active = true,
|
||||
version = 0
|
||||
watch(
|
||||
() => [props.id, props.allowed, pollingScope(user)],
|
||||
() => {
|
||||
version++
|
||||
loaded.value = false
|
||||
error.value = ''
|
||||
emit('close')
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
onMounted(async () => {
|
||||
if (!props.id || !props.allowed) return
|
||||
const current = version
|
||||
loading.value = true
|
||||
try {
|
||||
const value = silenceData(await Service.policy(silenceId(props.id)))
|
||||
if (!active || current !== version || !props.allowed) return
|
||||
Object.assign(form, {
|
||||
name: value.name || '',
|
||||
scope_type: value.scope_type || 'all',
|
||||
scope_ids: value.scope_ids || [],
|
||||
free_extension_limit: value.free_extension_limit ?? 0,
|
||||
paid_max_months: value.paid_max_months ?? 0,
|
||||
single_max_months: value.single_max_months ?? 1,
|
||||
effective_from: value.effective_from || '',
|
||||
effective_to: value.effective_to || null,
|
||||
approval_exempt_types: value.approval_exempt_types || [],
|
||||
status: value.status ?? 1,
|
||||
remark: value.remark || ''
|
||||
})
|
||||
unitYuan.value = ((value.unit_price ?? 0) / 100).toFixed(2)
|
||||
singleLimited.value = value.single_max_months != null
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
if (current === version) error.value = gatewayError(e)
|
||||
} finally {
|
||||
if (current === version) loading.value = false
|
||||
}
|
||||
})
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
const current = version,
|
||||
scope = pollingScope(user)
|
||||
try {
|
||||
requireGatewayAccess(props.allowed && loaded.value)
|
||||
const body: SilencePolicyRequest = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
scope_ids:
|
||||
form.scope_type === 'all' ? [] : form.scope_ids.map((value) => silenceId(Number(value))),
|
||||
unit_price: silenceFen(unitYuan.value),
|
||||
single_max_months: singleLimited.value ? form.single_max_months : null,
|
||||
effective_from: silenceDate(form.effective_from),
|
||||
effective_to: form.effective_to ? silenceDate(form.effective_to) : null,
|
||||
approval_exempt_types: [...form.approval_exempt_types]
|
||||
}
|
||||
validateSilencePolicy(body)
|
||||
await ElMessageBox.confirm(
|
||||
'确认保存延期政策?历史订单和已提交审批不受此次修改影响。',
|
||||
'保存确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
requireGatewayAccess(
|
||||
active && current === version && scope === pollingScope(user) && props.allowed
|
||||
)
|
||||
if (props.id) silenceData(await Service.updatePolicy(props.id, { ...body, id: props.id }))
|
||||
else silenceData(await Service.createPolicy(body))
|
||||
if (active && current === version && props.allowed) {
|
||||
emit('saved')
|
||||
emit('close')
|
||||
}
|
||||
} catch (e) {
|
||||
if (active && current === version && e !== 'cancel' && e !== 'close')
|
||||
error.value = gatewayError(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
version++
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.el-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
:deep(.el-form-item) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
justify-content: flex-start;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
flex-basis: 100%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ElFormItem label="沉默期(天)"
|
||||
><ElSwitch
|
||||
v-model="overrideEnabled"
|
||||
active-text="调整本次沉默期"
|
||||
inactive-text="使用默认值"
|
||||
:disabled="disabled" />
|
||||
<ElInputNumber
|
||||
v-if="overrideEnabled"
|
||||
v-model="days"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
placeholder="留空使用默认值"
|
||||
/></ElFormItem>
|
||||
<ElFormItem v-if="overrideEnabled" label="调整原因" :required="days != null"
|
||||
><ElInput v-model="reason" type="textarea" :disabled="disabled"
|
||||
/></ElFormItem>
|
||||
<p class="silence-help"
|
||||
>超过默认沉默期可能需审批,以后台返回结果为准;本次覆盖不受商品默认值 90 天上限限制。</p
|
||||
>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
const days = defineModel<number | null>('days', { required: true })
|
||||
const reason = defineModel<string>('reason', { required: true })
|
||||
const overrideEnabled = computed({
|
||||
get: () => days.value != null,
|
||||
set: (enabled: boolean) => {
|
||||
days.value = enabled ? 1 : null
|
||||
if (!enabled) reason.value = ''
|
||||
}
|
||||
})
|
||||
defineProps<{ disabled?: boolean }>()
|
||||
</script>
|
||||
<style scoped>
|
||||
.silence-help {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
:title="firstPurchase ? '首次购买套餐记录' : '沉默期流水'"
|
||||
width="min(1100px, 96vw)"
|
||||
destroy-on-close
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<ElAlert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<SilenceTable :rows="data || []" :definitions="fields" :loading="loading" @refresh="load" />
|
||||
</ElDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
|
||||
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
|
||||
import { silenceData, silenceId } from '@/utils/business/silenceExtension'
|
||||
import SilenceTable from './SilenceTable.vue'
|
||||
const props = defineProps<{
|
||||
usageId?: number
|
||||
assetId?: number
|
||||
assetType?: 'card' | 'device'
|
||||
allowed?: boolean
|
||||
}>()
|
||||
defineEmits<{ close: [] }>()
|
||||
const firstPurchase = computed(() => !!props.assetType)
|
||||
const { data, error, loading, load } = useSchedulerRequest<object[]>(
|
||||
async () => {
|
||||
if (props.assetType)
|
||||
return (
|
||||
silenceData(await Service.firstPurchase(props.assetType, silenceId(props.assetId))) || []
|
||||
)
|
||||
return silenceData(await Service.records(silenceId(props.usageId))) || []
|
||||
},
|
||||
() => props.allowed !== false
|
||||
)
|
||||
const fields = computed(() =>
|
||||
(firstPurchase.value
|
||||
? [
|
||||
['generation', '世代'],
|
||||
['first_purchase_at', '首次购买套餐时间'],
|
||||
['source_name', '来源'],
|
||||
['order_no', '订单号'],
|
||||
['ended_at', '世代结束时间'],
|
||||
['end_reason', '结束原因'],
|
||||
['created_at', '记录时间']
|
||||
]
|
||||
: [
|
||||
['created_at', '记录时间'],
|
||||
['event_name', '操作'],
|
||||
['before_status', '变更前状态'],
|
||||
['after_status', '变更后状态'],
|
||||
['before_deadline_at', '变更前截止'],
|
||||
['after_deadline_at', '变更后截止'],
|
||||
['change_days', '变化天数'],
|
||||
['operator_id', '操作人'],
|
||||
['operator_type', '操作人类型'],
|
||||
['reason', '原因'],
|
||||
['trigger_source', '触发来源'],
|
||||
['exchange_no', '换货单号']
|
||||
]
|
||||
).map(([prop, label]) => ({ prop, label }))
|
||||
)
|
||||
watch(() => [props.usageId, props.assetId, props.assetType], load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
89
src/components/business/package-silence/SilenceSummary.vue
Normal file
89
src/components/business/package-silence/SilenceSummary.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div v-if="silence" class="silence-summary">
|
||||
<ElTag :type="silence.silence_status === 'expired' ? 'danger' : 'info'">{{
|
||||
silenceStatusLabel(silence)
|
||||
}}</ElTag>
|
||||
<span
|
||||
>截止:{{
|
||||
silence.silence_deadline_at ? formatDateTime(silence.silence_deadline_at) : '-'
|
||||
}}</span
|
||||
>
|
||||
<span v-if="silence.silence_period_days != null"
|
||||
>初始 {{ silence.silence_period_days }} 天</span
|
||||
>
|
||||
<span v-if="silence.silence_extended_days != null"
|
||||
>累计延期 {{ silence.silence_extended_days }} 天</span
|
||||
>
|
||||
<ElTag v-if="silence.silence_adjust_pending" type="warning">调整审批中</ElTag>
|
||||
<span v-if="silence.silence_paid_extended_days != null"
|
||||
>付费延期 {{ silence.silence_paid_extended_days }} 天</span
|
||||
>
|
||||
<span v-if="silence.silence_extension_count != null"
|
||||
>延期 {{ silence.silence_extension_count }} 次</span
|
||||
>
|
||||
<span v-if="silence.silence_free_extension_used != null"
|
||||
>已用免费次数 {{ silence.silence_free_extension_used }}</span
|
||||
>
|
||||
<ElButton v-if="usageId" link type="primary" @click="recordsVisible = true"
|
||||
>沉默期流水</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="usageId && silence.extendable && (hasAuth(P.extend) || hasAuth(P.offline))"
|
||||
link
|
||||
type="primary"
|
||||
@click="extendVisible = true"
|
||||
>延期</ElButton
|
||||
>
|
||||
<SilenceRecordsDialog
|
||||
v-if="recordsVisible && usageId"
|
||||
:usage-id="usageId"
|
||||
@close="recordsVisible = false"
|
||||
/>
|
||||
<SilenceExtendDialog
|
||||
v-if="extendVisible && usageId"
|
||||
:usage-id="usageId"
|
||||
@close="closeExtension"
|
||||
@changed="dirty = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { PACKAGE_SILENCE_PERMISSIONS as P } from '@/config/constants/packageSilence'
|
||||
import SilenceRecordsDialog from './SilenceRecordsDialog.vue'
|
||||
import SilenceExtendDialog from './SilenceExtendDialog.vue'
|
||||
import type { PackageSilenceInfo } from '@/types/api/packageSilence'
|
||||
import { silenceStatusLabel } from '@/utils/business/packageSilence'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
const props = defineProps<{ silence?: PackageSilenceInfo | null; usageId?: number }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const { hasAuth } = useAuth()
|
||||
const recordsVisible = ref(false),
|
||||
extendVisible = ref(false),
|
||||
dirty = ref(false)
|
||||
function closeExtension() {
|
||||
extendVisible.value = false
|
||||
if (dirty.value) {
|
||||
dirty.value = false
|
||||
emit('changed')
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.usageId, !!props.silence],
|
||||
() => {
|
||||
recordsVisible.value = false
|
||||
extendVisible.value = false
|
||||
dirty.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.silence-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
43
src/components/business/package-silence/SilenceTable.vue
Normal file
43
src/components/business/package-silence/SilenceTable.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ArtTableHeader v-model:columns="columnChecks" @refresh="$emit('refresh')"
|
||||
><template #left><slot name="toolbar" /></template
|
||||
></ArtTableHeader>
|
||||
<ArtTable
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:row-key="rowKey || 'id'"
|
||||
:pagination="total != null"
|
||||
:current-page="page || 1"
|
||||
:page-size="size || 20"
|
||||
:total="total || 0"
|
||||
:margin-top="10"
|
||||
@current-change="(value) => $emit('page', value)"
|
||||
@size-change="(value) => $emit('size', value)"
|
||||
>
|
||||
<ElTableColumn
|
||||
v-for="column in columns"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
:min-width="column.width || 170"
|
||||
show-overflow-tooltip
|
||||
><template #default="{ row }">{{ silenceDisplay(row, column.prop) }}</template></ElTableColumn
|
||||
>
|
||||
<slot />
|
||||
</ArtTable>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useGatewayColumns } from '@/composables/useGateway'
|
||||
import { silenceDisplay } from '@/utils/business/silenceExtension'
|
||||
const props = defineProps<{
|
||||
rows: object[]
|
||||
rowKey?: string
|
||||
definitions: { prop: string; label: string; width?: number }[]
|
||||
loading?: boolean
|
||||
page?: number
|
||||
size?: number
|
||||
total?: number
|
||||
}>()
|
||||
defineEmits<{ refresh: []; page: [number]; size: [number] }>()
|
||||
const { columns, columnChecks } = useGatewayColumns(props.definitions)
|
||||
</script>
|
||||
@@ -69,18 +69,21 @@
|
||||
props.kind
|
||||
]
|
||||
)
|
||||
const permission = () => SCHEDULER_PERMISSIONS[props.kind].detailPage
|
||||
const { data, loading, error, load } = useSchedulerRequest<Record<string, any>>(
|
||||
async () => {
|
||||
const id = Number(route.params.id ?? route.params.carrier_id)
|
||||
validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '记录 ID')
|
||||
if (props.kind === 'params') return gatewayData(await Service.getCarrierParam(id))
|
||||
if (props.kind === 'alerts') return gatewayData(await Service.getAlert(id))
|
||||
return gatewayData(await Service.getImport(id))
|
||||
},
|
||||
() =>
|
||||
hasAuth(permission()) && (props.kind !== 'imports' || isPollingPlatform(user.info.user_type))
|
||||
)
|
||||
const canReadDetail = () => {
|
||||
const permissions = SCHEDULER_PERMISSIONS[props.kind]
|
||||
return (
|
||||
hasAuth(permissions.detailPage) &&
|
||||
hasAuth(permissions.detail) &&
|
||||
(props.kind !== 'imports' || isPollingPlatform(user.info.user_type))
|
||||
)
|
||||
}
|
||||
const { data, loading, error, load } = useSchedulerRequest<Record<string, any>>(async () => {
|
||||
const id = Number(route.params.id ?? route.params.carrier_id)
|
||||
validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '记录 ID')
|
||||
if (props.kind === 'params') return gatewayData(await Service.getCarrierParam(id))
|
||||
if (props.kind === 'alerts') return gatewayData(await Service.getAlert(id))
|
||||
return gatewayData(await Service.getImport(id))
|
||||
}, canReadDetail)
|
||||
const maps = {
|
||||
params: [
|
||||
['carrier_name', '运营商账户'],
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
<el-form :model="filter" :label-position="props.labelPosition">
|
||||
<el-row class="search-form-row" :gutter="props.gutter">
|
||||
<el-col
|
||||
v-for="item in useFormItemArr"
|
||||
v-for="(item, index) in props.items"
|
||||
:key="item.prop"
|
||||
v-show="index < visibleItemCount"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="item.elColSpan || props.elColSpan"
|
||||
@@ -73,6 +74,7 @@
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import ArtSearchInput from './widget/art-search-input/index.vue'
|
||||
import ArtSearchSelect from './widget/art-search-select/index.vue'
|
||||
import ArtSearchBusinessSelect from './widget/art-search-business-select/index.vue'
|
||||
import ArtSearchRadio from './widget/art-search-radio/index.vue'
|
||||
import ArtSearchDate from './widget/art-search-date/index.vue'
|
||||
import ArtSearchTreeSelect from './widget/art-search-tree-select/index.vue'
|
||||
@@ -81,7 +83,7 @@
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
type FilterVo = string | number | undefined | null | unknown[]
|
||||
type FilterVo = string | number | boolean | undefined | null | unknown[]
|
||||
|
||||
interface PropsVO {
|
||||
filter: Record<string, FilterVo> // 查询参数
|
||||
@@ -113,15 +115,10 @@
|
||||
|
||||
const isShow = ref(false)
|
||||
|
||||
const useFormItemArr = computed(() => {
|
||||
const isshowLess = !props.isExpand && !isShow.value
|
||||
if (isshowLess) {
|
||||
const num = Math.floor(24 / props.elColSpan) - 1
|
||||
return props.items.slice(0, num)
|
||||
} else {
|
||||
return props.items
|
||||
}
|
||||
})
|
||||
// 收起时保留控件实例,避免远程选中项的名称和候选缓存在重新展开后丢失。
|
||||
const visibleItemCount = computed(() =>
|
||||
!props.isExpand && !isShow.value ? Math.floor(24 / props.elColSpan) - 1 : props.items.length
|
||||
)
|
||||
|
||||
const filter = computed({
|
||||
get: () => props.filter,
|
||||
@@ -132,6 +129,7 @@
|
||||
const componentsMap: Record<string, any> = {
|
||||
input: ArtSearchInput,
|
||||
select: ArtSearchSelect,
|
||||
'business-select': ArtSearchBusinessSelect,
|
||||
'tree-select': ArtSearchTreeSelect,
|
||||
radio: ArtSearchRadio,
|
||||
datetime: ArtSearchDate,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<BusinessEntitySelect
|
||||
v-model="value"
|
||||
v-bind="item.config"
|
||||
:entity="entity"
|
||||
:placeholder="item.placeholder"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BusinessEntitySelect from '@/components/business/BusinessEntitySelect.vue'
|
||||
import type { BusinessOptionKind } from '@/api/modules/businessOptions'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
const props = defineProps<{ value: unknown; item: SearchFormItem }>()
|
||||
const emit = defineEmits<{ 'update:value': [value: unknown] }>()
|
||||
const entity = computed(() => props.item.config?.entity as BusinessOptionKind)
|
||||
const value = computed({
|
||||
get: () => props.value as string | number | (string | number)[] | null | undefined,
|
||||
set: (value) => emit('update:value', value)
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user