Files
iotlcm/app/src/pages/business/views/allocate/index.vue
T
MiFaZhan 2ed5dfa6a1 chore: 初始开源发布
iotlcm-app:小程序端 + 用户端服务。

- app/:uni-app 小程序前端(基于 unibest,MIT)
  - 微信 AppID → YOUR_WX_APPID,uni-app AppID → __UNI__YOURAPPID
  - 内网后端地址 → admin.iotlcm.example.com
  - 应用标题与 unibest 模板 README 重写(保留上游致谢与 MIT 声明)
- server/:GoFrame 用户端服务(基于 XiuAdmin,Apache-2.0)
  - module xiuadmin → git.5iot.com/iotlcm-public/iotlcm-app(360 文件)
  - 支付回调地址 → app.iotlcm.example.com,商户名与应用名泛化
  - 真实手机号 → 合法 11 位编造号(保留号段前 3 位 + 后 4 位)
  - 依赖号码尾部的断言同步更新
  - 补 server/LICENSE(Apache-2.0)
- 移除内部资产:CI 触发器
- 补根 README,LF 行尾 + .gitattributes

已知:TestSysDept_RefreshDeptAncestors 在源仓同一提交上同样失败,属既有缺陷。
2026-10-26 14:01:25 +08:00

741 lines
26 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.
<script lang="ts" setup>
import type { AllocateBatchRes, AllocateTarget, AppCard } from '@/api/card'
import {
allocateBatchPrepare,
allocateBatchSubmit,
getAllocateTargets,
getCardList,
} from '@/api/card'
import {
displayCardValue,
displayPackageName,
formatDateTime,
formatFlowPair,
} from '@/pages/card/data'
import { useUserStore } from '@/store/user'
import { hasUserPermission } from '@/utils/permission'
import { getAccountWorkspace } from '@/utils/userIdentity'
definePage({
style: {
navigationBarTitleText: '批量卡片划拨',
},
})
const userStore = useUserStore()
const keyword = ref('')
const targetKeyword = ref('')
const cards = ref<AppCard[]>([])
const targets = ref<AllocateTarget[]>([])
const selectedCardValues = ref<string[]>([])
const selectedTargetId = ref<number>()
const loadingCards = ref(false)
const loadingTargets = ref(false)
const preparing = ref(false)
const submitting = ref(false)
const noPermission = ref(false)
const errorMessage = ref('')
const targetPopupVisible = ref(false)
const prepare = ref<AllocateBatchRes>()
const result = ref<AllocateBatchRes>()
const initialCardValue = ref('')
let targetLoadSeq = 0
const workspace = computed(() => getAccountWorkspace(userStore.userInfo))
const isAgentUser = computed(() => workspace.value === 'enterprise' || workspace.value === 'agent' || workspace.value === 'admin')
const canUseAllocate = computed(() => isAgentUser.value && hasUserPermission(userStore.userInfo, 'allocate'))
const selectedTarget = computed(() =>
targets.value.find(item => Number(item.deptId) === Number(selectedTargetId.value)),
)
const allocatableCards = computed(() => cards.value.filter(item => item.canAllocate !== false))
const visibleCards = computed(() => {
const value = keyword.value.trim()
if (!value)
return allocatableCards.value
return allocatableCards.value.filter((item) => {
return (
allocateCardValue(item).includes(value)
|| (item.msisdn || '').includes(value)
|| (item.imsi || '').includes(value)
|| displayPackageName(item).includes(value)
|| (item.remark || '').includes(value)
|| (item.terminalName || '').includes(value)
|| ownerDeptText(item).includes(value)
)
})
})
const canPrepare = computed(
() => canUseAllocate.value && !!selectedTarget.value && !!selectedCardValues.value.length,
)
const canSubmit = computed(() => {
return !!prepare.value?.batchNo
&& !submitting.value
&& !result.value
&& (prepare.value.failCount || 0) === 0
&& prepare.value.status !== 'failed'
})
const isFixedCardMode = computed(() => !!initialCardValue.value)
async function openTargetPopup() {
if (noPermission.value)
return
targetPopupVisible.value = true
await refreshTargetPopup()
}
async function refreshTargetPopup() {
targetKeyword.value = ''
targets.value = []
await loadTargets('')
}
async function searchTargetPopup() {
await loadTargets(targetKeyword.value)
}
async function bootstrap() {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canUseAllocate.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能使用卡片划拨', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 800)
return
}
await Promise.all([loadTargets(), loadCards()])
}
async function loadTargets(keyword?: string) {
const seq = ++targetLoadSeq
loadingTargets.value = true
try {
const actualKeyword = typeof keyword === 'string' ? keyword : targetKeyword.value
const res = await getAllocateTargets({
page: 1,
pageSize: 1000,
keyword: actualKeyword.trim() || undefined,
})
if (seq !== targetLoadSeq)
return
targets.value = (res.items || []).map(item => ({
...item,
deptId: Number(item.deptId),
parentId: item.parentId == null ? undefined : Number(item.parentId),
})).filter(isSelectableTarget)
if (
selectedTargetId.value
&& !targets.value.some(item => Number(item.deptId) === Number(selectedTargetId.value))
) {
selectedTargetId.value = undefined
}
}
catch (error) {
if (seq !== targetLoadSeq)
return
targets.value = []
uni.showToast({ title: getErrorMessage(error, '目标客户加载失败'), icon: 'none' })
}
finally {
if (seq === targetLoadSeq)
loadingTargets.value = false
}
}
async function loadCards() {
loadingCards.value = true
errorMessage.value = ''
try {
const res = await getCardList({
page: 1,
pageSize: 100,
cardValue: keyword.value.trim() || undefined,
})
cards.value = res.items || []
applyInitialCardSelection()
const nextKeys = new Set(allocatableCards.value.map(allocateCardValue))
selectedCardValues.value = selectedCardValues.value.filter(value => nextKeys.has(value))
}
catch (error) {
cards.value = []
selectedCardValues.value = []
errorMessage.value = getErrorMessage(error, '卡片列表加载失败')
}
finally {
loadingCards.value = false
}
}
function allocateCardValue(item: AppCard) {
return item.iccid || item.msisdn || item.imsi || item.imei || ''
}
function ownerDeptText(item: AppCard) {
return item.deptName || '-'
}
function cardMatchesValue(item: AppCard, value: string) {
return [item.iccid, item.msisdn, item.imsi, item.imei].filter(Boolean).includes(value)
}
function applyInitialCardSelection() {
const value = initialCardValue.value.trim()
if (!value)
return
const item = allocatableCards.value.find(cardItem => cardMatchesValue(cardItem, value))
if (!item)
return
const selectedValue = allocateCardValue(item)
if (selectedValue) {
selectedCardValues.value = [selectedValue]
}
}
function isSelected(item: AppCard) {
return selectedCardValues.value.includes(allocateCardValue(item))
}
function selectVisibleCards() {
const values = visibleCards.value.map(allocateCardValue).filter(Boolean)
selectedCardValues.value = Array.from(new Set([...selectedCardValues.value, ...values]))
}
function clearSelection() {
selectedCardValues.value = []
}
function selectTarget(item: AllocateTarget) {
selectedTargetId.value = Number(item.deptId)
targetPopupVisible.value = false
}
function targetLabel(item?: AllocateTarget) {
if (!item)
return '请选择划拨目标客户'
return item.label || item.deptName || '未命名客户'
}
function targetDescription(item?: AllocateTarget) {
if (!item)
return ''
return item.deptKey ? `客户编码:${item.deptKey}` : targetScopeName(item)
}
function targetScopeName(item?: AllocateTarget) {
return item?.targetScopeName || (item?.targetScope === 'direct_child' ? '直属下级' : '当前机构')
}
function targetScopeTagType(item?: AllocateTarget) {
return item?.targetScope === 'direct_child' ? 'primary' : 'success'
}
function isSelectableTarget(item: AllocateTarget) {
const currentDeptId = Number(userStore.userInfo?.deptId || userStore.userInfo?.enterpriseProfile?.deptId || 0)
if (currentDeptId && Number(item.deptId) === currentDeptId)
return false
if (item.targetScope === 'current')
return false
if (item.targetScope === 'direct_child')
return true
if (currentDeptId && Number(item.parentId) === currentDeptId)
return true
return String(item.targetScopeName || '').includes('下级')
}
function prepareStatusText(item?: AllocateBatchRes) {
if (!item)
return '-'
if (result.value)
return '已划拨'
if (item.status === 'failed' || (item.failCount || 0) > 0)
return '校验失败'
return '已校验'
}
function failedAllocateDetails(item?: AllocateBatchRes) {
return item?.details?.filter(detail => detail.status === 'failed') || []
}
function showPrepareFailedModal(item: AllocateBatchRes) {
const failedDetails = failedAllocateDetails(item)
const failCount = item.failCount || failedDetails.length
const lines = failedDetails.slice(0, 6).map((detail) => {
const cardNo = detail.itemId || `第${detail.rowNo}行`
return `${cardNo}:${detail.errorReason || '校验失败'}`
})
if (failCount > lines.length)
lines.push(`还有 ${failCount - lines.length} 条失败明细,请在页面下方查看。`)
uni.showModal({
title: `校验失败 ${failCount} 张`,
content: lines.join('\n') || item.failReason || '存在校验失败卡,请调整后重新校验。',
confirmText: '知道了',
showCancel: false,
})
}
async function handlePrepare() {
if (!canPrepare.value || preparing.value) {
uni.showToast({ title: '请先选择目标客户和需要划拨的卡', icon: 'none' })
return
}
preparing.value = true
try {
const res = await allocateBatchPrepare({
cardValues: selectedCardValues.value,
targetDeptId: selectedTargetId.value as number,
})
prepare.value = res
result.value = undefined
if ((res.failCount || 0) > 0 || res.status === 'failed') {
showPrepareFailedModal(res)
}
else {
uni.showToast({ title: '校验通过', icon: 'success' })
}
}
catch (error) {
prepare.value = undefined
uni.showToast({ title: getErrorMessage(error, '划拨校验失败'), icon: 'none' })
}
finally {
preparing.value = false
}
}
async function handleSubmit() {
if (!prepare.value?.batchNo || !selectedTarget.value) {
uni.showToast({ title: '请先校验划拨批次', icon: 'none' })
return
}
if (submitting.value)
return
const confirmed = await confirmAllocateSubmit()
if (!confirmed)
return
submitting.value = true
try {
const res = await allocateBatchSubmit({
batchNo: prepare.value.batchNo,
cardValues: selectedCardValues.value,
targetDeptId: selectedTarget.value.deptId,
})
result.value = res
prepare.value = res
uni.showModal({
title: '划拨成功',
content: `划拨批次:${res.batchNo || '-'}\n目标客户:${targetLabel(res.target)}\n划拨卡数:${res.quantity || selectedCardValues.value.length} 张\n当前企业保留查看和其他办理权限,划拨权交由目标客户。`,
showCancel: false,
})
initialCardValue.value = ''
clearSelection()
await loadCards()
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '划拨提交失败'), icon: 'none' })
}
finally {
submitting.value = false
}
}
function confirmAllocateSubmit() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: '确认划拨',
content: `目标客户:${targetLabel(selectedTarget.value)}\n划拨卡数:${prepare.value?.quantity || selectedCardValues.value.length} 张\n确认后更新卡片归属,当前企业保留查看和其他办理权限,划拨权交由目标客户。`,
confirmText: '确认划拨',
cancelText: '再看一下',
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
})
})
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as {
msg?: unknown
message?: unknown
errMsg?: unknown
statusCode?: unknown
data?: { msg?: unknown, message?: unknown }
}
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
if (typeof data.data?.msg === 'string' && data.data.msg)
return data.data.msg
if (typeof data.data?.message === 'string' && data.data.message)
return data.data.message
if (typeof data.statusCode === 'number')
return fallback
if (typeof data.errMsg === 'string' && data.errMsg)
return fallback
}
return fallback
}
watch([selectedCardValues, selectedTargetId], () => {
prepare.value = undefined
result.value = undefined
})
onMounted(() => {
void bootstrap()
})
onLoad((query) => {
const value = normalizeQueryValue(query?.cardValue || query?.cardNo || query?.iccid)
if (!value)
return
initialCardValue.value = value
keyword.value = value
})
function normalizeQueryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : String(value || '')
}
</script>
<template>
<scroll-view scroll-y class="h-screen">
<view class="biz-page">
<view class="biz-stack">
<view v-if="noPermission" class="biz-card">
<view class="biz-card__body text-26rpx text-#64748b leading-38rpx">
当前账号不能使用卡片划拨。
</view>
</view>
<template v-else>
<view class="biz-hero">
<view class="biz-hero__top">
<view class="min-w-0 flex items-center gap-16rpx">
<view class="biz-hero__icon">
<view class="i-carbon-user-multiple" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">卡片划拨</text>
<text class="biz-hero__sub">选择可划拨卡片,校验后转给目标下级客户</text>
</view>
</view>
<view class="app-tag app-tag--info shrink-0 whitespace-nowrap">
代理商
</view>
</view>
<view class="biz-stat-grid">
<view class="biz-stat">
<text class="biz-stat__label">目标客户</text>
<text class="biz-stat__value">{{ targetLabel(selectedTarget) }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">已选卡片</text>
<text class="biz-stat__value">{{ selectedCardValues.length }} 张</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">校验状态</text>
<text class="biz-stat__value">{{ prepareStatusText(prepare) }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">失败卡数</text>
<text class="biz-stat__value">{{ prepare?.failCount || 0 }} 张</text>
</view>
</view>
</view>
<view class="biz-card">
<wd-cell
title="目标客户"
:value="targetLabel(selectedTarget)"
clickable
is-link
center
@click="openTargetPopup"
/>
</view>
<view class="biz-card">
<view class="biz-card__body">
<wd-input
v-model="keyword"
placeholder="搜索卡号、套餐、备注或客户"
:clearable="!isFixedCardMode"
@confirm="loadCards"
/>
<view class="biz-mini-action-grid mt-16rpx">
<wd-button size="small" type="primary" plain block custom-class="biz-mini-action-btn" :loading="loadingCards" @click="loadCards">
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-renew biz-mini-action-btn__icon biz-mini-action-btn__icon--blue" />
<text class="biz-mini-action-btn__text">刷新</text>
</view>
</wd-button>
<wd-button
size="small"
type="primary"
plain
block
custom-class="biz-mini-action-btn"
:disabled="!visibleCards.length"
@click="selectVisibleCards"
>
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-checkmark-outline biz-mini-action-btn__icon biz-mini-action-btn__icon--green" />
<text class="biz-mini-action-btn__text">全选</text>
</view>
</wd-button>
<wd-button
size="small"
type="primary"
plain
block
custom-class="biz-mini-action-btn"
:disabled="!selectedCardValues.length"
@click="clearSelection"
>
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-close biz-mini-action-btn__icon biz-mini-action-btn__icon--gray" />
<text class="biz-mini-action-btn__text">清空</text>
</view>
</wd-button>
</view>
</view>
</view>
<view class="biz-section-head">
<view>
<text class="biz-section-title">当前可划拨卡</text>
<text class="biz-section-sub">划拨前会生成 ALC 批次进行归属和状态校验</text>
</view>
<view class="app-tag app-tag--info shrink-0 whitespace-nowrap">
已选 {{ selectedCardValues.length }}
</view>
</view>
<view v-if="loadingCards" class="biz-card">
<view class="biz-card__body">
<wd-skeleton theme="paragraph" />
</view>
</view>
<view v-else-if="errorMessage" class="biz-card">
<view class="biz-card__body grid gap-16rpx">
<view class="text-26rpx text-#b91c1c leading-38rpx">
{{ errorMessage }}
</view>
<wd-button size="small" type="primary" @click="loadCards">
重试
</wd-button>
</view>
</view>
<view v-else class="biz-card">
<view v-if="visibleCards.length" class="biz-card__body">
<wd-checkbox-group v-model="selectedCardValues" type="square" checked-color="#2563eb">
<view class="grid gap-12rpx">
<view
v-for="item in visibleCards"
:key="allocateCardValue(item)"
class="biz-check-row"
:class="{ 'is-active': isSelected(item) }"
>
<wd-checkbox
:name="allocateCardValue(item)"
custom-label-class="!block !w-full"
>
<view class="min-w-0 pr-16rpx">
<view class="truncate text-30rpx text-#111827 font-900">
{{ displayCardValue(item) }}
</view>
<view class="mt-8rpx truncate text-24rpx text-#64748b">
{{ displayPackageName(item) }}
</view>
<view class="mt-8rpx text-22rpx text-#64748b leading-32rpx">
{{ formatFlowPair(item) }} · 到期 {{ formatDateTime(item.flowPkgEndAt) }}
</view>
<view class="mt-8rpx text-22rpx text-#64748b leading-32rpx">
归属客户:{{ ownerDeptText(item) }}
</view>
<view v-if="item.terminalName" class="mt-8rpx text-22rpx text-#64748b leading-32rpx">
终端绑定:{{ item.terminalName }}
</view>
</view>
</wd-checkbox>
</view>
</view>
</wd-checkbox-group>
</view>
<view v-else class="biz-card__body text-center text-26rpx text-#64748b">
暂无可划拨卡片
</view>
</view>
<view v-if="prepare" class="biz-card">
<view class="biz-card__body">
<view class="biz-section-head mb-8rpx">
<text class="biz-section-title">校验结果</text>
<wd-tag :type="prepare.failCount ? 'warning' : 'success'" variant="plain">
{{ prepareStatusText(prepare) }}
</wd-tag>
</view>
<view class="biz-row">
<text class="biz-row__label">划拨批次</text>
<text class="biz-row__value mono">{{ prepare.batchNo || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">目标客户</text>
<text class="biz-row__value">{{ targetLabel(prepare.target) }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">校验卡数</text>
<text class="biz-row__value">
{{ prepare.validCount ?? prepare.quantity ?? 0 }}/{{ prepare.totalCount ?? selectedCardValues.length }} 张
</text>
</view>
<view class="biz-row">
<text class="biz-row__label">失败卡数</text>
<text class="biz-row__value">{{ prepare.failCount || 0 }} 张</text>
</view>
</view>
<view
v-if="prepare.failCount"
class="border-t border-#eef2f7 border-solid px-24rpx py-20rpx"
>
<view class="mb-14rpx text-26rpx text-#991b1b font-800">
失败明细
</view>
<view class="grid gap-12rpx">
<view
v-for="item in failedAllocateDetails(prepare)"
:key="`${item.rowNo}-${item.itemId}`"
class="rounded-8rpx bg-#fef2f2 px-18rpx py-16rpx"
>
<view class="text-24rpx text-#7f1d1d font-700">
第 {{ item.rowNo }} 行:{{ item.itemId || '-' }}
</view>
<view class="mt-6rpx text-23rpx text-#991b1b leading-32rpx">
{{ item.errorReason || '校验失败' }}
</view>
</view>
</view>
</view>
</view>
</template>
<wd-popup v-model="targetPopupVisible" position="bottom" round safe-area-inset-bottom>
<view class="max-h-84vh flex flex-col p-24rpx">
<view class="text-32rpx text-#111827 font-900">
选择目标客户
</view>
<view class="mt-20rpx">
<wd-input
v-model="targetKeyword"
label="搜索"
label-width="72px"
placeholder="客户名称或编码"
clearable
@confirm="searchTargetPopup"
/>
<view class="mt-16rpx">
<wd-button
size="small"
type="primary"
plain
block
custom-class="biz-mini-action-btn"
:loading="loadingTargets"
@click="refreshTargetPopup"
>
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-renew biz-mini-action-btn__icon biz-mini-action-btn__icon--blue" />
<text class="biz-mini-action-btn__text">刷新目标客户</text>
</view>
</wd-button>
</view>
</view>
<view class="mt-16rpx text-24rpx text-#64748b">
{{ loadingTargets ? '正在加载下级代理...' : `已加载 ${targets.length} 个下级代理` }}
</view>
<scroll-view scroll-y class="mt-20rpx h-620rpx min-h-260rpx">
<view v-if="targets.length" class="grid gap-12rpx">
<view
v-for="item in targets"
:key="item.deptId"
class="border rounded-8rpx border-solid p-20rpx"
:class="
Number(item.deptId) === Number(selectedTargetId)
? 'border-#2563eb bg-#eff6ff'
: 'border-#e5e7eb bg-#f8fafc'
"
@click="selectTarget(item)"
>
<view class="flex items-center justify-between gap-16rpx">
<view class="min-w-0">
<view class="truncate text-28rpx text-#111827 font-900">
{{ item.deptName }}
</view>
<view class="mt-8rpx text-23rpx text-#64748b">
{{ targetDescription(item) }}
</view>
</view>
<view class="flex shrink-0 items-center gap-8rpx">
<wd-tag :type="targetScopeTagType(item)" variant="plain">
{{ targetScopeName(item) }}
</wd-tag>
<wd-tag v-if="Number(item.deptId) === Number(selectedTargetId)" type="primary" variant="plain">
已选择
</wd-tag>
</view>
</view>
</view>
</view>
<view v-else class="p-32rpx text-center text-26rpx text-#64748b">
暂无可划拨下级代理
</view>
</scroll-view>
</view>
</wd-popup>
</view>
</view>
</scroll-view>
<view
v-if="!noPermission && !targetPopupVisible"
class="biz-paybar z-1100"
>
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">已选 {{ selectedCardValues.length }} 张</text>
<text class="biz-paybar__value">
{{ selectedTarget ? `${targetScopeName(selectedTarget)} · ${targetLabel(selectedTarget)}` : '请选择目标客户' }}
</text>
</view>
<view class="biz-paybar-actions">
<wd-button
type="primary"
plain
block
custom-class="biz-paybar-btn"
:loading="preparing"
:disabled="!canPrepare || preparing"
@click="handlePrepare"
>
校验划拨
</wd-button>
<wd-button
type="primary"
block
custom-class="biz-paybar-btn"
:loading="submitting"
:disabled="!canSubmit"
@click="handleSubmit"
>
确认划拨
</wd-button>
</view>
</view>
</view>
</template>