Files
one-pipe-system/src/components/business/package-silence/SilenceManagement.vue
邹进 0d094a0537
Some checks failed
构建并部署前端到测试环境 / deploy (push) Has been cancelled
feat: 线下登记延期先传凭证自动识别交易单号,弹窗显示套餐名称
- 登记线下付款时收款凭证放到最前,上传后自动识别交易单号预填(复用凭证识别,需人工核对,不覆盖手填)
- 试算后自动带入实收金额
- 延期弹窗显示套餐名称(及资产),不再显示套餐记录 ID
2026-11-07 22:26:38 +09:00

641 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="openExtension(row)"
>延期</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"
:package-name="extendRow?.package_name"
:asset-identifier="extendRow?.asset_identifier"
: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>(),
extendRow = ref<Record<string, any>>(),
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)
}
}
function openExtension(row: Record<string, any>) {
extendRow.value = row
extendId.value = row.package_usage_id
}
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>