fix: some
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 54s

This commit is contained in:
luo
2026-10-28 15:18:44 +08:00
parent 06684e1fc0
commit f2614d8efb
99 changed files with 11511 additions and 132 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

View File

@@ -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', '运营商账户'],

View File

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

View File

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