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 在源仓同一提交上同样失败,属既有缺陷。
This commit is contained in:
MiFaZhan
2026-10-26 14:01:25 +08:00
commit 2ed5dfa6a1
1013 changed files with 138466 additions and 0 deletions
@@ -0,0 +1,740 @@
<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>