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,39 @@
<script setup lang="ts">
/** ==================== 常量 ==================== */
const emit = defineEmits<{
action: [action: 'addon' | 'renew']
}>()
/** ==================== 数据 ==================== */
/** ==================== 方法 ==================== */
function handleAction(action: 'addon' | 'renew') {
emit('action', action)
}
/** ==================== 组件 ==================== */
/** ==================== 周期 ==================== */
</script>
<template>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-32rpx">
<view class="flex flex-col gap-24rpx">
<view class="flex flex-col gap-10rpx">
<text class="text-40rpx text-#111827 font-900 leading-48rpx">
常用业务
</text>
<text class="text-26rpx text-#64748b leading-38rpx">
办理套餐续费、加油包订购和卡片自助服务。
</text>
</view>
<view class="grid grid-cols-2 gap-16rpx">
<wd-button type="primary" block @click="handleAction('renew')">
套餐续费
</wd-button>
<wd-button type="primary" plain block @click="handleAction('addon')">
加油包
</wd-button>
</view>
</view>
</wd-card>
</template>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import type { BusinessServiceItem, BusinessServiceSection, BusinessTone } from '../data'
const props = defineProps<{
section: BusinessServiceSection
}>()
const emit = defineEmits<{
action: [item: BusinessServiceItem]
}>()
const toneClass: Record<BusinessTone, string> = {
primary: 'tone-primary',
blue: 'tone-blue',
green: 'tone-green',
orange: 'tone-orange',
red: 'tone-red',
purple: 'tone-purple',
}
function handleClick(item: BusinessServiceItem) {
emit('action', item)
}
function visibleRoleTag(item: BusinessServiceItem) {
if (item.agentOnly)
return '代理商'
if (item.terminalOnly)
return '用户'
if (item.terminalHidden)
return '企业'
return ''
}
</script>
<template>
<view class="biz-section">
<text class="biz-group-title">{{ props.section.title }}</text>
<view class="biz-list">
<view
v-for="item in props.section.items"
:key="item.title"
class="biz-item"
@click="handleClick(item)"
>
<view class="biz-ico" :class="toneClass[item.tone]">
<view :class="[item.icon]" />
</view>
<view class="biz-mid">
<view class="biz-title-row">
<text class="biz-title">{{ item.title }}</text>
<view v-if="visibleRoleTag(item)" class="app-tag app-tag--info biz-role-tag">
{{ visibleRoleTag(item) }}
</view>
</view>
<text class="biz-desc">{{ item.desc }}</text>
</view>
<view class="i-carbon-chevron-right biz-chev" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.biz-section {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.biz-group-title {
padding: 0 4rpx;
color: #64748b;
font-size: 23rpx;
font-weight: 700;
line-height: 32rpx;
}
.biz-list {
box-sizing: border-box;
overflow: hidden;
padding: 6rpx 22rpx;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(17, 24, 39, 0.04);
}
.biz-item {
display: flex;
min-width: 0;
align-items: center;
gap: 18rpx;
padding: 20rpx 0;
border-bottom: 1px solid #e2e8f0;
}
.biz-item:last-child {
border-bottom: 0;
}
.biz-ico {
display: flex;
width: 76rpx;
height: 76rpx;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 18rpx;
}
.biz-ico > view {
font-size: 38rpx;
}
.biz-mid {
min-width: 0;
flex: 1;
}
.biz-title-row {
display: flex;
min-width: 0;
align-items: center;
gap: 10rpx;
}
.biz-title {
min-width: 0;
flex-shrink: 1;
overflow: hidden;
font-size: 26rpx;
font-weight: 700;
color: #111827;
line-height: 34rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.biz-role-tag {
flex-shrink: 0;
max-width: 118rpx;
padding: 2rpx 12rpx;
overflow: hidden;
font-size: 20rpx;
line-height: 28rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.biz-desc {
display: block;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 21rpx;
color: #64748b;
line-height: 30rpx;
}
.biz-chev {
color: #cbd5e1;
font-size: 28rpx;
flex-shrink: 0;
}
.tone-primary {
background: #eff6ff;
}
.tone-primary > view {
color: #2563eb;
}
.tone-blue {
background: #e0f2fe;
}
.tone-blue > view {
color: #0369a1;
}
.tone-green {
background: #dcfce7;
}
.tone-green > view {
color: #16a34a;
}
.tone-orange {
background: #fef3c7;
}
.tone-orange > view {
color: #d97706;
}
.tone-red {
background: #fee2e2;
}
.tone-red > view {
color: #dc2626;
}
.tone-purple {
background: #e0e7ff;
}
.tone-purple > view {
color: #4f46e5;
}
</style>
+200
View File
@@ -0,0 +1,200 @@
import fs from 'node:fs'
import { fileURLToPath, URL } from 'node:url'
import { describe, expect, it } from 'vitest'
import { businessSections, getUserBusinessSections } from './data'
const businessDir = fileURLToPath(new URL('.', import.meta.url))
const expectedSubViews = [
'diagnosis',
'renew',
'allocate',
'change',
'addon',
'customer',
'customerDetail',
'order',
'warning',
'workOrder',
'workOrderCreate',
'workOrderDetail',
]
describe('getUserBusinessSections', () => {
it('只保留用户侧一级业务入口', () => {
const sections = getUserBusinessSections(businessSections)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('套餐续费')
expect(titles).not.toContain('余额充值')
expect(titles).toContain('重启卡')
expect(titles).toContain('停机 / 复机')
expect(titles).toContain('加油包')
expect(titles).toContain('预警配置')
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的卡片')
expect(titles).toContain('我的订单')
expect(titles).toContain('工单服务')
expect(titles).not.toContain('进入商城')
expect(titles).not.toContain('订单记录')
expect(titles).not.toContain('流量池预警(代理商独有)')
})
it('可办理业务都有明确动作', () => {
const sections = getUserBusinessSections(businessSections)
const actionableItems = sections
.flatMap(section => section.items)
.filter(item => item.action)
expect(actionableItems.map(item => [item.title, item.action])).toEqual([
['套餐续费', 'renew'],
['加油包', 'addon'],
['停机 / 复机', 'cardList'],
['重启卡', 'cardList'],
['卡片排查', 'diagnosis'],
['预警配置', 'warning'],
['我的订单', 'order'],
['工单服务', 'workOrder'],
['我的卡片', 'cardList'],
])
})
it('卡片排查完全开放,不受业务权限过滤', () => {
const sections = getUserBusinessSections({
userId: 18,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: [],
features: [],
routes: [],
menus: [],
},
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的订单')
})
it('企业用户显示卡片划拨、套餐变更和客户管理入口', () => {
const enterpriseUserTypes = ['sys_user', '']
enterpriseUserTypes.forEach((userType, index) => {
const sections = getUserBusinessSections({
userId: 10 + index,
deptId: 100,
userType,
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('卡片划拨')
expect(titles).toContain('套餐变更')
expect(titles).toContain('客户管理')
expect(titles).toContain('停机 / 复机')
expect(titles).toContain('重启卡')
})
})
it('终端用户不显示卡片划拨、套餐变更和客户管理入口', () => {
const sections = getUserBusinessSections({
userId: 11,
deptId: 147,
userType: 'app_user',
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).not.toContain('卡片划拨')
expect(titles).not.toContain('套餐变更')
expect(titles).not.toContain('客户管理')
expect(titles).toContain('停机 / 复机')
expect(titles).toContain('重启卡')
expect(titles).toContain('加油包')
expect(titles).toContain('预警配置')
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的卡片')
expect(titles).toContain('工单服务')
})
it('配置权限时只展示已授权业务入口', () => {
const sections = getUserBusinessSections({
userId: 12,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: [],
features: ['card:list', 'customer:list'],
routes: [],
menus: [],
},
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('我的卡片')
expect(titles).toContain('客户管理')
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的订单')
expect(titles).not.toContain('套餐续费')
expect(titles).not.toContain('卡片划拨')
expect(titles).not.toContain('套餐变更')
})
it('控制台权限标识直接决定可见业务入口', () => {
const sections = getUserBusinessSections({
userId: 13,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: ['cpm:iot:tSimCardInfo:list', 'cpm:agent:tAgentEnterprise:list'],
features: [],
routes: [],
menus: [],
},
} as any)
const actions = sections.flatMap(section => section.items.map(item => item.action))
expect(actions).toContain('cardList')
expect(actions).toContain('customer')
expect(actions).not.toContain('renew')
expect(actions).not.toContain('allocate')
expect(actions).not.toContain('change')
})
it('有工单列表或新增权限都展示工单服务入口', () => {
const listSections = getUserBusinessSections({
userId: 14,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: ['cpm:gen:tWoMain:list'],
features: [],
routes: [],
menus: [],
},
} as any)
const createSections = getUserBusinessSections({
userId: 15,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: ['cpm:gen:tWoMain:edit'],
features: [],
routes: [],
menus: [],
},
} as any)
expect(listSections.flatMap(section => section.items.map(item => item.title))).toContain('工单服务')
expect(createSections.flatMap(section => section.items.map(item => item.title))).toContain('工单服务')
})
it('业务子界面容器文件都存在', () => {
expectedSubViews.forEach((viewName) => {
expect(fs.existsSync(`${businessDir}views/${viewName}/index.vue`)).toBe(true)
})
})
})
+214
View File
@@ -0,0 +1,214 @@
import type { IUserInfoRes } from '@/api/types/login'
import type { AppPermissionAction } from '@/utils/permission'
import { hasUserPermission } from '@/utils/permission'
import { getAccountWorkspace, isTerminalUserInfo } from '@/utils/userIdentity'
export type BusinessTone = 'blue' | 'green' | 'orange' | 'primary' | 'purple' | 'red'
export interface BusinessServiceItem {
title: string
desc: string
tag: string
icon: string
tone: BusinessTone
action?:
| 'addon'
| 'allocate'
| 'balanceRecharge'
| 'cardList'
| 'change'
| 'customer'
| 'diagnosis'
| 'order'
| 'renew'
| 'warning'
| 'workOrder'
agentOnly?: boolean
terminalOnly?: boolean
terminalHidden?: boolean
permissionAction?: AppPermissionAction | AppPermissionAction[]
}
export interface BusinessServiceSection {
title: string
desc: string
type: 'grid' | 'list'
items: BusinessServiceItem[]
}
/** ==================== 常量 ==================== */
export const businessSections: BusinessServiceSection[] = [
{
title: '套餐与流量',
desc: '续费、变更与加油包',
type: 'grid',
items: [
{
title: '套餐续费',
desc: '续约当前套餐,避免停机',
tag: '续费',
icon: 'i-carbon-renew',
tone: 'green',
action: 'renew',
permissionAction: 'renew',
},
{
title: '加油包',
desc: '流量加餐,按需购买',
tag: '流量',
icon: 'i-carbon-package',
tone: 'blue',
action: 'addon',
permissionAction: 'addon',
},
{
title: '套餐变更',
desc: '更换套餐档位',
tag: '变更',
icon: 'i-carbon-change-catalog',
tone: 'primary',
action: 'change',
agentOnly: true,
permissionAction: 'change',
},
],
},
{
title: '生命周期操作',
desc: '停复机与联网操作',
type: 'grid',
items: [
{
title: '停机 / 复机',
desc: '进入卡片选择暂停或恢复服务',
tag: '生命周期',
icon: 'i-carbon-pause-outline',
tone: 'orange',
action: 'cardList',
permissionAction: ['stop', 'start'],
},
{
title: '重启卡',
desc: '重新建立网络会话',
tag: '重启',
icon: 'i-carbon-restart',
tone: 'primary',
action: 'cardList',
permissionAction: 'restart',
},
],
},
{
title: '管理与预警',
desc: '状态排查、预警与分配',
type: 'grid',
items: [
{
title: '卡片排查',
desc: '状态、会话和流量异常',
tag: '排查',
icon: 'i-carbon-stethoscope',
tone: 'green',
action: 'diagnosis',
},
{
title: '预警配置',
desc: '流量阈值与微信订阅',
tag: '预警',
icon: 'i-carbon-warning-alt',
tone: 'orange',
action: 'warning',
permissionAction: 'warning',
},
{
title: '卡片划拨',
desc: '分配给下游客户/子账号',
tag: '划拨',
icon: 'i-carbon-share-knowledge',
tone: 'blue',
action: 'allocate',
agentOnly: true,
permissionAction: 'allocate',
},
],
},
{
title: '服务记录',
desc: '订单与工单',
type: 'grid',
items: [
{
title: '我的订单',
desc: '续费/加油包办理记录',
tag: '订单',
icon: 'i-carbon-receipt',
tone: 'blue',
action: 'order',
},
{
title: '工单服务',
desc: '反馈问题和查看进度',
tag: '工单',
icon: 'i-carbon-chat',
tone: 'orange',
action: 'workOrder',
permissionAction: ['workOrder', 'workOrderCreate'],
},
{
title: '我的卡片',
desc: '查看卡信息、套餐和流量',
tag: '进入',
icon: 'i-carbon-sim-card',
tone: 'primary',
action: 'cardList',
},
{
title: '客户管理',
desc: '直属客户与客户详情',
tag: '客户',
icon: 'i-carbon-user-multiple',
tone: 'green',
action: 'customer',
agentOnly: true,
permissionAction: 'customer',
},
],
},
]
/** ==================== 方法 ==================== */
export function getUserBusinessSections(
userInfoOrSections?: IUserInfoRes | BusinessServiceSection[],
sections: BusinessServiceSection[] = businessSections,
) {
const userInfo = Array.isArray(userInfoOrSections) ? undefined : userInfoOrSections
const sourceSections = Array.isArray(userInfoOrSections) ? userInfoOrSections : sections
const workspace = getAccountWorkspace(userInfo)
const isAgentUser = Boolean(userInfo) && workspace !== 'terminal'
const isTerminalUser = !userInfo || isTerminalUserInfo(userInfo)
return sourceSections
.map(section => ({
...section,
items: section.items.filter((item) => {
if (item.agentOnly && !isAgentUser)
return false
if (item.terminalOnly && !isTerminalUser)
return false
if (item.terminalHidden && isTerminalUser)
return false
if (userInfo && item.permissionAction && !hasBusinessItemPermission(userInfo, item.permissionAction))
return false
return true
}),
}))
.filter(section => section.items.length > 0)
}
function hasBusinessItemPermission(
userInfo: IUserInfoRes,
action: AppPermissionAction | AppPermissionAction[],
) {
const actions = Array.isArray(action) ? action : [action]
return actions.some(item => hasUserPermission(userInfo, item))
}
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
import RouterPath from '@/constant/RouterPath'
import type { IUserInfoRes } from '@/api/types/login'
import { useTokenStore } from '@/store/token'
import { useUserStore } from '@/store/user'
import type { AppPermissionAction } from '@/utils/permission'
import { hasUserPermission } from '@/utils/permission'
import BusinessSection from './components/BusinessSection.vue'
import { getUserBusinessSections } from './data'
import type { BusinessServiceItem } from './data'
definePage({
style: {
navigationBarTitleText: '办理',
},
layout: 'default',
})
/** ==================== 常量 ==================== */
const actionRouteMap: Partial<Record<NonNullable<BusinessServiceItem['action']>, string>> = {
renew: RouterPath.BusinessViewsRenew,
allocate: RouterPath.BusinessViewsAllocate,
balanceRecharge: RouterPath.BusinessViewsBalanceRecharge,
change: RouterPath.BusinessViewsChange,
customer: RouterPath.BusinessViewsCustomer,
addon: RouterPath.BusinessViewsAddon,
diagnosis: RouterPath.BusinessViewsDiagnosis,
order: RouterPath.BusinessViewsOrder,
warning: RouterPath.BusinessViewsWarning,
workOrder: RouterPath.BusinessViewsWorkOrder,
}
/** ==================== 数据 ==================== */
const tokenStore = useTokenStore()
const userStore = useUserStore()
let refreshUserPromise: Promise<void | IUserInfoRes> | undefined
const sections = computed(() => getUserBusinessSections(userStore.userInfo))
/** ==================== 方法 ==================== */
async function refreshUserInfoIfNeeded() {
if (!tokenStore.updateNowTime().hasLogin)
return
if (!refreshUserPromise) {
refreshUserPromise = userStore
.fetchUserInfo()
.catch(() => {
// 保留本地已缓存的用户信息,避免网络抖动时业务入口反复隐藏。
})
.finally(() => {
refreshUserPromise = undefined
})
}
await refreshUserPromise
}
async function handleBusinessAction(item: BusinessServiceItem) {
await refreshUserInfoIfNeeded()
if (item.permissionAction && !hasBusinessItemPermission(item.permissionAction)) {
uni.showToast({ title: '暂无操作权限', icon: 'none' })
return
}
if (item.action === 'cardList') {
uni.switchTab({ url: RouterPath.Card })
return
}
const url = item.action ? actionRouteMap[item.action] : ''
if (url) {
uni.navigateTo({ url })
return
}
uni.showToast({
title: item.title,
icon: 'none',
})
}
function goDiagnosis() {
uni.navigateTo({ url: RouterPath.BusinessViewsDiagnosis })
}
function hasBusinessItemPermission(action: AppPermissionAction | AppPermissionAction[]) {
const actions = Array.isArray(action) ? action : [action]
return actions.some(item => hasUserPermission(userStore.userInfo, item))
}
/** ==================== 组件 ==================== */
/** ==================== 周期 ==================== */
onShow(() => {
void refreshUserInfoIfNeeded()
})
</script>
<template>
<view class="business-page">
<view class="business-stack">
<view class="assist-card" @click="goDiagnosis">
<view class="assist-icon">
<view class="i-carbon-stethoscope text-32rpx" />
</view>
<view class="min-w-0 flex-1">
<text class="assist-title">不确定办哪项?先查卡状态</text>
<text class="assist-sub">按卡片状态、套餐、流量和会话信息整理处理方向</text>
<text class="mt-4rpx block text-20rpx text-#2563eb leading-30rpx">
排查建议仅供辅助判断,以实际业务处理结果为准
</text>
</view>
<view class="i-carbon-chevron-right assist-chev" />
</view>
<BusinessSection
v-for="section in sections"
:key="section.title"
:section="section"
@action="handleBusinessAction"
/>
</view>
</view>
</template>
<style lang="scss">
page {
background: #f6f8fb;
}
</style>
<style lang="scss" scoped>
.business-page {
box-sizing: border-box;
min-height: 100vh;
padding: 20rpx 28rpx calc(128rpx + env(safe-area-inset-bottom));
background: #f6f8fb;
}
.business-stack {
display: flex;
max-width: 920rpx;
flex-direction: column;
gap: 18rpx;
margin: 0 auto;
}
.assist-card {
display: flex;
align-items: flex-start;
gap: 18rpx;
padding: 22rpx 24rpx;
border: 1px solid #dbeafe;
border-radius: 12px;
background: linear-gradient(120deg, #eef5ff 0%, #f3f9ff 100%);
}
.assist-icon {
display: flex;
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 16rpx;
background: #2563eb;
color: #fff;
}
.assist-title {
display: block;
font-size: 25rpx;
font-weight: 700;
color: #111827;
line-height: 32rpx;
}
.assist-sub {
display: block;
margin-top: 5rpx;
font-size: 21rpx;
color: #64748b;
line-height: 32rpx;
}
.assist-chev {
margin-top: 12rpx;
flex-shrink: 0;
color: #93c5fd;
font-size: 30rpx;
}
</style>
@@ -0,0 +1,219 @@
<script lang="ts" setup>
import { useLastQueriedCardStore, useUserStore } from '@/store'
import { hasAnyFeature } from '@/utils/permission'
import { displayCardValue, displayPackageName, formatDateTime, formatFlowPair, getBossCardStatusText } from '@/pages/card/data'
definePage({
style: {
navigationBarTitleText: '加油包',
},
})
type AddonOption = {
name: string
price: string
desc: string
flowText: string
badge?: string
}
const addonOptions: AddonOption[] = [
{ name: '1GB 加油包', price: '¥8.00', desc: '适合短时补量', flowText: '1GB', badge: '推荐' },
{ name: '3GB 加油包', price: '¥18.00', desc: '日常轻量补充', flowText: '3GB' },
{ name: '5GB 加油包', price: '¥28.00', desc: '中等用量设备', flowText: '5GB' },
{ name: '10GB 加油包', price: '¥48.00', desc: '持续在线设备', flowText: '10GB' },
]
const userStore = useUserStore()
const lastCardStore = useLastQueriedCardStore()
const noPermission = ref(false)
const selectedIndex = ref(0)
const canUseAddon = computed(() => hasAnyFeature(userStore.userInfo, 'card:addon'))
const currentCard = computed(() => lastCardStore.card)
const selectedOption = computed(() => addonOptions[selectedIndex.value] || addonOptions[0])
const selectedAmountText = computed(() => selectedOption.value?.price || '¥0.00')
const cardValueText = computed(() => {
const card = currentCard.value
return card ? displayCardValue(card) : '请选择卡片'
})
const packageText = computed(() => {
const card = currentCard.value
return card ? displayPackageName(card) : '当前套餐'
})
const flowText = computed(() => {
const card = currentCard.value
return card ? formatFlowPair(card) : '暂无'
})
const expireText = computed(() => {
const card = currentCard.value
return card ? formatDateTime(card.flowPkgEndAt) : '-'
})
const statusText = computed(() => {
const card = currentCard.value
return card ? getBossCardStatusText(card.bossCardStatus) : '未选择'
})
function selectOption(index: number) {
selectedIndex.value = index
}
function handlePay() {
if (!canUseAddon.value) {
uni.showToast({
title: '暂无加油包订购权限',
icon: 'none',
})
return
}
uni.showModal({
title: '确认订购',
content: `卡号:${cardValueText.value}\n加油包:${selectedOption.value.name}\n应付金额:${selectedAmountText.value}\n订购后将立即生效。`,
confirmText: '确认',
cancelText: '再看看',
success: (res) => {
if (!res.confirm)
return
uni.showToast({
title: '已选择加油包',
icon: 'success',
})
},
})
}
onMounted(async () => {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canUseAddon.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能订购加油包', icon: 'none' })
}
})
</script>
<template>
<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-flash" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">加油包</text>
<text class="biz-hero__sub">按需补充流量,选定后直接进入支付确认</text>
</view>
</view>
<wd-tag type="primary" variant="plain">
订购
</wd-tag>
</view>
<view class="biz-stat-grid">
<view class="biz-stat">
<text class="biz-stat__label">当前卡片</text>
<text class="biz-stat__value">{{ cardValueText }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">当前套餐</text>
<text class="biz-stat__value">{{ packageText }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">剩余流量</text>
<text class="biz-stat__value">{{ flowText }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">卡片状态</text>
<text class="biz-stat__value">{{ statusText }}</text>
</view>
</view>
</view>
<view class="biz-card">
<view class="biz-card__body">
<view class="biz-section-head">
<view>
<text class="biz-section-title">选择加油包</text>
<text class="biz-section-sub">按流量档位单选,价格与生效时间由后端统一控制</text>
</view>
<wd-tag type="success" variant="plain">
{{ selectedOption.name }}
</wd-tag>
</view>
<view class="grid gap-12rpx">
<view
v-for="(item, index) in addonOptions"
:key="item.name"
class="biz-option"
:class="{ 'is-active': index === selectedIndex }"
@click="selectOption(index)"
>
<view class="biz-option__main">
<view class="flex items-center gap-10rpx">
<text class="biz-option__title">{{ item.name }}</text>
<wd-tag
v-if="item.badge"
type="warning"
size="small"
variant="plain"
>
{{ item.badge }}
</wd-tag>
</view>
<text class="biz-option__meta">
{{ item.flowText }} · {{ item.desc }}
</text>
</view>
<text class="biz-option__price">{{ item.price }}</text>
<view class="biz-radio" />
</view>
</view>
</view>
</view>
<view class="biz-card">
<view class="biz-card__body">
<view class="biz-row">
<text class="biz-row__label">已选加油包</text>
<text class="biz-row__value">{{ selectedOption.name }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">付款方式</text>
<text class="biz-row__value">微信支付</text>
</view>
<view class="biz-row">
<text class="biz-row__label">生效时间</text>
<text class="biz-row__value">订购成功后立即生效</text>
</view>
<view class="biz-row">
<text class="biz-row__label">到期时间</text>
<text class="biz-row__value">{{ expireText }}</text>
</view>
</view>
</view>
</template>
</view>
</view>
<view v-if="!noPermission" class="biz-paybar">
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">{{ cardValueText }}</text>
<text class="biz-paybar__value">应付 {{ selectedAmountText }}</text>
</view>
<wd-button block type="primary" @click="handlePay">
立即订购 {{ selectedAmountText }}
</wd-button>
</view>
</view>
</template>
@@ -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>
@@ -0,0 +1,61 @@
import type {
BalanceRechargeMockPayParams,
BalanceRechargeRefundParams,
BalanceRechargeStatusParams,
BalanceRechargeStatusRes,
BalanceRechargeSubmitParams,
BalanceRechargeSubmitRes,
} from '@/api/types/balance'
import type { AppOrder } from '@/api/types/order'
import { http } from '@/http/http'
export type {
BalanceRechargeMockPayParams,
BalanceRechargeRefundParams,
BalanceRechargeStatusParams,
BalanceRechargeStatusRes,
BalanceRechargeSubmitParams,
BalanceRechargeSubmitRes,
} from '@/api/types/balance'
export function balanceRechargeSubmit(data: BalanceRechargeSubmitParams) {
return http.post<BalanceRechargeSubmitRes>(
'/balance/recharge-submit',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function balanceRechargeStatus(data: BalanceRechargeStatusParams) {
return http.get<BalanceRechargeStatusRes>('/balance/recharge-status', data, undefined, {
hideErrorToast: true,
})
}
export function balanceRechargeMockPay(data: BalanceRechargeMockPayParams) {
return http.post<BalanceRechargeStatusRes>(
'/balance/recharge-mock-pay',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function balanceRechargeRefund(data: BalanceRechargeRefundParams) {
return http.post<AppOrder>(
'/balance/recharge-refund',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
@@ -0,0 +1,27 @@
import type {
CustomerDetailParams,
CustomerDetailRes,
CustomerListParams,
CustomerListRes,
} from '@/api/types/customer'
import { http } from '@/http/http'
export type {
AppCustomer,
AppCustomerDetail,
AppCustomerType,
CustomerDetailParams,
CustomerListParams,
} from '@/api/types/customer'
export function getCustomerList(params: CustomerListParams) {
return http.get<CustomerListRes>('/customer/list', params, undefined, {
hideErrorToast: true,
})
}
export function getCustomerDetail(params: CustomerDetailParams) {
return http.get<CustomerDetailRes>('/customer/detail', params, undefined, {
hideErrorToast: true,
})
}
@@ -0,0 +1,44 @@
import type {
DiagnosisFlowParams,
DiagnosisFlowRes,
DiagnosisInfoParams,
DiagnosisInfoRes,
} from '@/api/types/diagnosis'
import { http } from '@/http/http'
export type {
DiagnosisFlowParams,
DiagnosisFlowRes,
DiagnosisInfoItem,
DiagnosisInfoParams,
DiagnosisInfoRes,
DiagnosisModuleItem,
DiagnosisPackageItem,
DiagnosisRateLimitApnItem,
DiagnosisRateLimitPccItem,
DiagnosisSessionItem,
} from '@/api/types/diagnosis'
export function getDiagnosisInfo(data: DiagnosisInfoParams) {
return http.post<DiagnosisInfoRes>('/diagnosis/info', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getOpenDiagnosisInfo(data: DiagnosisInfoParams & { tenantId?: string }) {
return http.post<DiagnosisInfoRes>('/open/diagnosis/info', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getDiagnosisFlow(data: DiagnosisFlowParams) {
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getOpenDiagnosisFlow(data: DiagnosisFlowParams & { tenantId?: string }) {
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
hideErrorToast: true,
})
}
+21
View File
@@ -0,0 +1,21 @@
import type {
OrderDetailParams,
OrderDetailRes,
OrderListParams,
OrderListRes,
} from '@/api/types/order'
import { http } from '@/http/http'
export type { AppOrder, AppOrderItem, OrderDetailParams, OrderListParams } from '@/api/types/order'
export function getOrderList(params: OrderListParams) {
return http.get<OrderListRes>('/order/list', params, undefined, {
hideErrorToast: true,
})
}
export function getOrderDetail(params: OrderDetailParams) {
return http.get<OrderDetailRes>('/order/detail', params, undefined, {
hideErrorToast: true,
})
}
@@ -0,0 +1,81 @@
import type {
WarningConfigBatchNotifyParams,
WarningConfigBatchNotifyRes,
WarningConfigBatchSaveParams,
WarningConfigBatchSaveRes,
WarningConfigListParams,
WarningConfigListRes,
WarningConfigMetaRes,
WarningConfigNotifyParams,
WarningConfigNotifyRes,
WarningConfigSaveParams,
WarningConfigSaveRes,
} from '@/api/types/warning'
import { http } from '@/http/http'
export type {
WarningConfig,
WarningConfigBatchItemParams,
WarningConfigBatchNotifyParams,
WarningConfigBatchNotifyRes,
WarningConfigBatchSaveParams,
WarningConfigBatchSaveRes,
WarningConfigListRes,
WarningConfigNotifyParams,
WarningConfigNotifyRes,
WarningConfigSaveParams,
} from '@/api/types/warning'
export function getWarningConfigList(params: WarningConfigListParams) {
return http.get<WarningConfigListRes>('/warning/config/list', params, undefined, {
hideErrorToast: true,
})
}
export function saveWarningConfig(data: WarningConfigSaveParams) {
return http.post<WarningConfigSaveRes>('/warning/config/save', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function updateWarningConfigNotify(data: WarningConfigNotifyParams) {
return http.post<WarningConfigNotifyRes>(
'/warning/config/notify',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function getWarningConfigMeta() {
return http.get<WarningConfigMetaRes>('/warning/config/meta', undefined, undefined, {
hideErrorToast: true,
})
}
export function saveWarningConfigBatch(data: WarningConfigBatchSaveParams) {
return http.post<WarningConfigBatchSaveRes>(
'/warning/config/batch-save',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function updateWarningConfigNotifyBatch(data: WarningConfigBatchNotifyParams) {
return http.post<WarningConfigBatchNotifyRes>(
'/warning/config/batch-notify',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
@@ -0,0 +1,35 @@
import type {
WorkOrderCreateParams,
WorkOrderCreateRes,
WorkOrderDetailParams,
WorkOrderDetailRes,
WorkOrderListParams,
WorkOrderListRes,
} from '@/api/types/workOrder'
import { http } from '@/http/http'
export type {
AppWorkOrder,
AppWorkOrderCommunication,
WorkOrderCreateParams,
WorkOrderDetailParams,
WorkOrderListParams,
} from '@/api/types/workOrder'
export function getWorkOrderList(params: WorkOrderListParams) {
return http.get<WorkOrderListRes>('/work-order/list', params, undefined, {
hideErrorToast: true,
})
}
export function getWorkOrderDetail(params: WorkOrderDetailParams) {
return http.get<WorkOrderDetailRes>('/work-order/detail', params, undefined, {
hideErrorToast: true,
})
}
export function createWorkOrder(data: WorkOrderCreateParams) {
return http.post<WorkOrderCreateRes>('/work-order/create', data, undefined, undefined, {
hideErrorToast: true,
})
}
@@ -0,0 +1,171 @@
<script lang="ts" setup>
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
definePage({
style: {
navigationBarTitleText: '余额充值',
},
})
const userStore = useUserStore()
const balanceText = computed(() => `¥${Number(userStore.userInfo.balance || 0).toFixed(2)}`)
function goOrder() {
uni.navigateTo({ url: RouterPath.BusinessViewsOrder })
}
function goRenew() {
uni.navigateTo({ url: RouterPath.BusinessViewsRenew })
}
function goBusiness() {
uni.switchTab({ url: RouterPath.Business })
}
onMounted(() => {
void userStore.fetchUserInfo().catch(() => undefined)
})
</script>
<template>
<view class="min-h-screen p-14rpx pb-24rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-14rpx">
<view class="balance-hero">
<view class="balance-hero__top">
<view>
<text class="balance-hero__eyebrow">账户资金</text>
<text class="balance-hero__title">当前余额</text>
</view>
<view class="balance-hero__icon i-carbon-wallet" />
</view>
<text class="balance-hero__value">{{ balanceText }}</text>
<view class="balance-hero__foot">
<text>{{ userStore.userInfo.phone || userStore.userInfo.username || '当前账户' }}</text>
<text class="balance-hero__status">余额可用于业务扣款</text>
</view>
</view>
<view class="status-card">
<view class="status-card__icon i-carbon-information" />
<view class="min-w-0 flex-1">
<text class="status-card__title">充值入口暂未开放</text>
<text class="status-card__sub">历史充值订单仍可在我的订单中查看,套餐续费等业务继续使用余额扣款。</text>
</view>
</view>
<wd-card custom-class="!mx-0 !mb-0">
<view class="text-28rpx text-#111827 font-800">
可继续办理
</view>
<view class="mt-10rpx text-24rpx text-#64748b leading-36rpx">
套餐续费、套餐变更等业务仍会使用账户余额扣款;余额不足时请联系平台处理。
</view>
<view class="grid grid-cols-2 mt-22rpx gap-16rpx">
<wd-button type="primary" plain block @click="goOrder">
我的订单
</wd-button>
<wd-button type="primary" block @click="goRenew">
套餐续费
</wd-button>
</view>
</wd-card>
<wd-button type="primary" plain block @click="goBusiness">
返回业务
</wd-button>
</view>
</view>
</template>
<style lang="scss" scoped>
.balance-hero {
padding: 20rpx;
border-radius: 8px;
color: #fff;
background: #2563eb;
box-shadow: 0 4px 12px rgba(11, 43, 172, 0.14);
}
.balance-hero__top,
.balance-hero__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.balance-hero__eyebrow,
.balance-hero__title,
.balance-hero__value {
display: block;
}
.balance-hero__eyebrow {
color: rgba(255, 255, 255, 0.68);
font-size: 20rpx;
}
.balance-hero__title {
margin-top: 5rpx;
font-size: 28rpx;
font-weight: 700;
}
.balance-hero__icon {
font-size: 42rpx;
color: rgba(255, 255, 255, 0.86);
}
.balance-hero__value {
margin-top: 16rpx;
font-family: 'Roboto Mono', monospace;
font-size: 48rpx;
font-weight: 800;
line-height: 1;
}
.balance-hero__foot {
margin-top: 18rpx;
color: rgba(255, 255, 255, 0.74);
font-size: 20rpx;
}
.balance-hero__status {
color: #dbeafe;
}
.status-card {
display: flex;
align-items: flex-start;
gap: 14rpx;
padding: 16rpx;
border: 1px solid #fde68a;
border-radius: 8px;
background: #fffbeb;
}
.status-card__icon {
flex-shrink: 0;
color: #b45309;
font-size: 28rpx;
}
.status-card__title,
.status-card__sub {
display: block;
}
.status-card__title {
color: #92400e;
font-size: 25rpx;
font-weight: 800;
}
.status-card__sub {
margin-top: 5rpx;
color: #a16207;
font-size: 21rpx;
line-height: 32rpx;
}
</style>
@@ -0,0 +1,487 @@
<script lang="ts" setup>
import type { AppRenewOption, RenewPackageOption, RenewPrepareRes, RenewSubmitRes } from '@/api/card'
import { renewPrepare, renewSubmit } from '@/api/card'
import { formatDateTime, formatFlowPair } from '@/pages/card/data'
import { useUserStore } from '@/store/user'
import { canSubmitRenewPermission, canUseRenewPermission } from '@/utils/permission'
definePage({
style: {
navigationBarTitleText: '单卡套餐续费',
},
})
const cardValue = ref('')
const userStore = useUserStore()
const packageCode = ref('')
const subscriptionNo = ref('')
const loading = ref(false)
const submitting = ref(false)
const errorMessage = ref('')
const prepare = ref<RenewPrepareRes>()
const order = ref<RenewSubmitRes>()
const selectedPackageIndex = ref(0)
const selectedIndex = ref(0)
const fixedCardMode = ref(false)
const renewablePackages = computed(() => prepare.value?.renewablePackages || [])
const selectedPackage = computed(() => renewablePackages.value[selectedPackageIndex.value])
const selectedOptions = computed(() => {
const packageOptions = selectedPackage.value?.options || []
return packageOptions.length ? packageOptions : (prepare.value?.options || [])
})
const selectedOption = computed(() => selectedOptions.value[selectedIndex.value])
const amountText = computed(
() => selectedOption.value?.priceText || formatMoney(selectedOption.value?.totalAmount),
)
function queryValue(value: unknown) {
return Array.isArray(value) ? String(value[0] || '') : String(value || '')
}
function ensureRenewPermission(submit = false) {
const allowed = submit
? canSubmitRenewPermission(userStore.userInfo)
: canUseRenewPermission(userStore.userInfo)
if (!allowed) {
uni.showToast({ title: '当前账号没有套餐续费权限', icon: 'none' })
errorMessage.value = '当前账号没有套餐续费权限'
}
return allowed
}
async function loadPrepare() {
if (!ensureRenewPermission())
return
if (!cardValue.value.trim()) {
errorMessage.value = '请输入要续费的卡号'
prepare.value = undefined
return
}
loading.value = true
errorMessage.value = ''
try {
const res = await renewPrepare({
cardValue: cardValue.value.trim(),
packageCode: packageCode.value || undefined,
subscriptionNo: subscriptionNo.value || undefined,
})
prepare.value = res
order.value = undefined
selectedPackageIndex.value = validPackageIndex(res)
selectedIndex.value = validRecommendIndex(selectedOptions.value, res.recommendIndex)
}
catch (error) {
prepare.value = undefined
errorMessage.value = getErrorMessage(error, '续费档位加载失败')
}
finally {
loading.value = false
}
}
function validPackageIndex(res: RenewPrepareRes) {
const packages = res.renewablePackages || []
if (!packages.length)
return 0
const selectedCode = res.package?.packageCode || packageCode.value
const index = packages.findIndex(item => renewPackageCode(item) === selectedCode)
return index >= 0 ? index : 0
}
function validRecommendIndex(options: AppRenewOption[], recommendIndex?: number) {
if (!options.length)
return 0
if (typeof recommendIndex === 'number' && recommendIndex >= 0 && recommendIndex < options.length)
return recommendIndex
const index = options.findIndex(item => item.recommended)
return index >= 0 ? index : 0
}
function selectPackage(index: number) {
selectedPackageIndex.value = index
selectedIndex.value = validRecommendIndex(selectedOptions.value)
}
function selectOption(index: number) {
selectedIndex.value = index
}
function packageName() {
const pkg = selectedPackage.value?.package || prepare.value?.package
return selectedPackage.value?.packageName || pkg?.packageName || '续费套餐'
}
function currentPackageName() {
const pkg = prepare.value?.currentPackage
return pkg?.packageName || prepare.value?.card?.packageName || '当前套餐'
}
function selectedTargetPackageCode() {
return renewPackageCode(selectedPackage.value) || prepare.value?.package?.packageCode || ''
}
function renewPackageCode(item?: RenewPackageOption) {
return item?.packageCode || item?.package?.packageCode || ''
}
function displayCard() {
const card = prepare.value?.card
return card?.iccid || card?.msisdn || cardValue.value || '-'
}
function formatMoney(value?: number) {
const amount = Number(value || 0)
return `¥${amount.toFixed(2)}`
}
function optionTag(item: AppRenewOption, index: number) {
if (item.recommended || index === prepare.value?.recommendIndex)
return '推荐'
return item.label || `${item.durationYears || Math.max(1, Math.floor(item.durationMonths / 12))} 年`
}
async function handleSubmit() {
if (!ensureRenewPermission(true))
return
if (!selectedOption.value) {
uni.showToast({ title: '请选择续费年限', icon: 'none' })
return
}
if (submitting.value)
return
const confirmed = await confirmRenewSubmit()
if (!confirmed)
return
submitting.value = true
try {
const res = await renewSubmit({
cardValue: cardValue.value.trim(),
packageCode: selectedTargetPackageCode() || undefined,
subscriptionNo: subscriptionNo.value || undefined,
durationMonths: selectedOption.value.durationMonths,
durationYears: selectedOption.value.durationYears,
})
order.value = res
void userStore.fetchUserInfo().catch(() => undefined)
uni.showModal({
title: '续费成功',
content: `订单号:${res.orderSn}\n续费批次:${res.batchNo || '-'}\n余额扣款:${res.priceText || formatMoney(res.totalAmount)}\n支付状态:已支付`,
showCancel: false,
})
}
catch (error) {
const message = getErrorMessage(error, '续费提交失败')
if (isBalanceInsufficient(message)) {
showRechargeModal(selectedOption.value?.totalAmount)
return
}
uni.showToast({
title: message,
icon: 'none',
})
}
finally {
submitting.value = false
}
}
function confirmRenewSubmit() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: '确认续费',
content: `卡号:${displayCard()}\n当前套餐:${currentPackageName()}\n续费套餐:${packageName()}\n时长:${selectedOption.value?.label || '-'}\n扣款金额:${amountText.value}\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 `续费请求异常(${data.statusCode})`
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
return fallback
}
function isBalanceInsufficient(message: string) {
return message.includes('余额不足') || message.includes('账户余额不足')
}
function showRechargeModal(amount?: number) {
const amountText = Number.isFinite(Number(amount || 0)) && Number(amount || 0) > 0
? `\n需补足金额约:${formatMoney(amount)}`
: ''
uni.showModal({
title: '余额不足',
content: `当前余额不足,充值功能暂未开放,暂不能继续提交续费。${amountText}`,
confirmText: '知道了',
showCancel: false,
})
}
onLoad((query) => {
cardValue.value = queryValue(query?.cardValue || query?.cardNo || query?.iccid)
packageCode.value = queryValue(query?.packageCode)
subscriptionNo.value = queryValue(query?.subscriptionNo)
fixedCardMode.value = !!cardValue.value
if (cardValue.value) {
void (async () => {
await userStore.fetchUserInfo().catch(() => undefined)
await loadPrepare()
})()
}
})
</script>
<template>
<view class="biz-page">
<view class="biz-stack">
<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-renew" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">套餐续费</text>
<text class="biz-hero__sub">
{{ prepare ? `${displayCard()} · ${packageName()}` : '选择可续费套餐,到期时间按后端规则顺延' }}
</text>
</view>
</view>
<wd-tag type="success" variant="plain">
单卡办理
</wd-tag>
</view>
<view v-if="prepare" class="biz-stat-grid">
<view class="biz-stat">
<text class="biz-stat__label">当前到期</text>
<text class="biz-stat__value">{{ formatDateTime(prepare.card.flowPkgEndAt) }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">剩余流量</text>
<text class="biz-stat__value">{{ formatFlowPair(prepare.card) }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">当前套餐</text>
<text class="biz-stat__value">{{ currentPackageName() }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">续费单年价</text>
<text class="biz-stat__value">{{ formatMoney(prepare.unitPrice) }}</text>
</view>
</view>
</view>
<view v-if="!fixedCardMode" class="biz-card">
<view class="biz-card__body">
<wd-input
v-model="cardValue"
placeholder="请输入 ICCID、MSISDN、IMSI 或 IMEI"
clearable
/>
<view class="mt-20rpx">
<wd-button block type="primary" custom-class="biz-paybar-btn" :loading="loading" @click="loadPrepare">
查询续费档位
</wd-button>
</view>
</view>
</view>
<view v-if="loading" 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="loadPrepare">
重试
</wd-button>
</view>
</view>
<template v-else-if="prepare">
<view v-if="renewablePackages.length" class="app-section">
<view class="app-section__head">
<text class="biz-step">1</text>
<view class="app-title-bar" />
<text class="app-section__title">选择续费套餐</text>
</view>
<view class="biz-option-list">
<view
v-for="(item, index) in renewablePackages"
:key="renewPackageCode(item) || index"
class="biz-option"
:class="{ 'is-active': index === selectedPackageIndex }"
@click="selectPackage(index)"
>
<view class="biz-option__main">
<view class="flex items-center gap-10rpx">
<text class="biz-option__title">{{ item.packageName || item.package?.packageName || renewPackageCode(item) || '-' }}</text>
<wd-tag
v-if="index === selectedPackageIndex"
type="primary"
size="small"
variant="plain"
>
已选
</wd-tag>
</view>
<text class="biz-option__meta">
{{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }} · {{ item.options?.length || item.renewableDurationOptions?.length || 0 }} 个可续费年限
</text>
</view>
<text class="biz-option__price">{{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }}</text>
<view class="biz-radio" />
</view>
</view>
</view>
<view v-else class="biz-card">
<view class="biz-card__body text-center text-26rpx text-#64748b">
当前套餐暂无可续费套餐
</view>
</view>
<view v-if="selectedOptions.length" class="app-section">
<view class="app-section__head">
<text class="biz-step">2</text>
<view class="app-title-bar" />
<text class="app-section__title">选择续费年限</text>
</view>
<view class="biz-option-list">
<view
v-for="(item, index) in selectedOptions"
:key="`${selectedTargetPackageCode()}_${item.durationMonths}`"
class="biz-option"
:class="{ 'is-active': index === selectedIndex }"
@click="selectOption(index)"
>
<view class="biz-option__main">
<view class="flex items-center gap-10rpx">
<text class="biz-option__title">{{ item.label }}</text>
<wd-tag
v-if="item.recommended || index === prepare.recommendIndex"
type="primary"
size="small"
variant="plain"
>
推荐
</wd-tag>
</view>
<text class="biz-option__meta">
{{ optionTag(item, index) }} · 到期 {{ formatDateTime(item.expireAfter) }}
</text>
</view>
<text class="biz-option__price">{{ item.priceText || formatMoney(item.totalAmount) }}</text>
<view class="biz-radio" />
</view>
</view>
</view>
<view v-else class="biz-card">
<view class="biz-card__body text-center text-26rpx text-#64748b">
当前续费套餐暂无可续费年限
</view>
</view>
<view class="biz-card">
<view class="biz-card__body">
<view class="biz-row">
<text class="biz-row__label">确认 ICCID</text>
<text class="biz-row__value mono">{{ prepare.card.iccid || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">当前套餐</text>
<text class="biz-row__value">{{ currentPackageName() }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">续费套餐</text>
<text class="biz-row__value">{{ packageName() }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">续费年限</text>
<text class="biz-row__value">{{ selectedOption?.label || '-' }}</text>
</view>
<view
class="biz-row"
>
<text class="biz-row__label">续费后到期</text>
<text class="biz-row__value">{{ formatDateTime(selectedOption?.expireAfter) }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">应付金额</text>
<text class="biz-row__value text-#2563eb">{{ amountText }}</text>
</view>
</view>
</view>
<view v-if="order" class="biz-card">
<view class="biz-card__body">
<view class="biz-section-head mb-8rpx">
<text class="biz-section-title">办理结果</text>
<wd-tag type="success" variant="plain">已支付</wd-tag>
</view>
<view class="biz-row">
<text class="biz-row__label">订单号</text>
<text class="biz-row__value mono">{{ order.orderSn || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">续费批次</text>
<text class="biz-row__value mono">{{ order.batchNo || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">订单金额</text>
<text class="biz-row__value">{{ order.priceText || formatMoney(order.totalAmount) }}</text>
</view>
</view>
</view>
</template>
</view>
</view>
<view v-if="prepare" class="biz-paybar">
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">{{ displayCard() }}</text>
<text class="biz-paybar__value">应付 {{ amountText }}</text>
</view>
<wd-button
block
type="primary"
custom-class="biz-paybar-btn"
:loading="submitting"
:disabled="submitting"
@click="handleSubmit"
>
确认续费 {{ amountText }}
</wd-button>
</view>
</view>
</template>
@@ -0,0 +1,645 @@
<script lang="ts" setup>
import type { AppCard, AppRenewOption, ChangePackageOption, ChangePrepareRes, ChangeSubmitRes } from '@/api/card'
import { changePrepare, changeSubmit, getCardList } from '@/api/card'
import {
displayCardValue,
displayPackageName,
formatDateTime,
formatFlowPair,
} from '@/pages/card/data'
import { useUserStore } from '@/store/user'
import { hasUserPermission } from '@/utils/permission'
import { isEnterpriseUserInfo } from '@/utils/userIdentity'
definePage({
style: {
navigationBarTitleText: '套餐变更',
},
})
const userStore = useUserStore()
const keyword = ref('')
const cards = ref<AppCard[]>([])
const selectedCardValues = ref<string[]>([])
const loadingCards = ref(false)
const preparing = ref(false)
const submitting = ref(false)
const noPermission = ref(false)
const errorMessage = ref('')
const packagePopupVisible = ref(false)
const prepare = ref<ChangePrepareRes>()
const result = ref<ChangeSubmitRes>()
const selectedPackageIndex = ref(0)
const selectedOptionIndex = ref(0)
const initialCardValue = ref('')
const isAgentUser = computed(() => isEnterpriseUserInfo(userStore.userInfo))
const canUseChange = computed(() => hasUserPermission(userStore.userInfo, 'change'))
const canSubmitChange = computed(() => hasUserPermission(userStore.userInfo, 'changeSubmit'))
const visibleCards = computed(() => {
const value = keyword.value.trim()
if (!value)
return cards.value
return cards.value.filter((item) => {
return (
changeCardValue(item).includes(value)
|| (item.msisdn || '').includes(value)
|| (item.imsi || '').includes(value)
|| displayPackageName(item).includes(value)
|| (item.remark || '').includes(value)
|| (item.terminalName || '').includes(value)
)
})
})
const selectedTarget = computed(() => prepare.value?.targetPackages?.[selectedPackageIndex.value])
const selectedOptions = computed(() => selectedTarget.value?.options || [])
const selectedOption = computed(() => selectedOptions.value[selectedOptionIndex.value])
const amountText = computed(
() => selectedOption.value?.priceText || formatMoney(selectedOption.value?.totalAmount),
)
const canPrepare = computed(() => isAgentUser.value && canUseChange.value && selectedCardValues.value.length > 0)
const canSubmit = computed(
() => canSubmitChange.value && !!prepare.value?.batchNo && !!selectedTarget.value?.package?.packageCode && !!selectedOption.value && !result.value,
)
const isFixedCardMode = computed(() => !!initialCardValue.value)
async function bootstrap() {
await userStore.fetchUserInfo().catch(() => undefined)
if (!isAgentUser.value || !canUseChange.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能使用套餐变更', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 800)
return
}
await loadCards()
}
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(cards.value.map(changeCardValue))
selectedCardValues.value = selectedCardValues.value.filter(value => nextKeys.has(value))
}
catch (error) {
cards.value = []
selectedCardValues.value = []
errorMessage.value = getErrorMessage(error, '卡片列表加载失败')
}
finally {
loadingCards.value = false
}
}
function changeCardValue(item: AppCard) {
return item.iccid || item.msisdn || item.imsi || item.imei || ''
}
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 = cards.value.find(cardItem => cardMatchesValue(cardItem, value))
if (!item)
return
const selectedValue = changeCardValue(item)
if (selectedValue)
selectedCardValues.value = [selectedValue]
}
function isSelected(item: AppCard) {
return selectedCardValues.value.includes(changeCardValue(item))
}
function selectVisibleCards() {
const values = visibleCards.value.map(changeCardValue).filter(Boolean)
selectedCardValues.value = Array.from(new Set([...selectedCardValues.value, ...values]))
}
function clearSelection() {
selectedCardValues.value = []
}
async function handlePrepare() {
if (!canPrepare.value) {
uni.showToast({ title: '请先选择需要变更套餐的卡', icon: 'none' })
return
}
if (preparing.value)
return
preparing.value = true
try {
const res = await changePrepare({
cardValues: selectedCardValues.value,
})
prepare.value = res
result.value = undefined
selectedPackageIndex.value = 0
selectedOptionIndex.value = validRecommendIndex(res.targetPackages?.[0])
packagePopupVisible.value = true
}
catch (error) {
prepare.value = undefined
uni.showToast({ title: getErrorMessage(error, '套餐变更校验失败'), icon: 'none' })
}
finally {
preparing.value = false
}
}
function validRecommendIndex(target?: ChangePackageOption) {
const options = target?.options || []
if (!options.length)
return 0
const index = options.findIndex(item => item.recommended)
return index >= 0 ? index : options.length - 1
}
function selectPackage(index: number) {
selectedPackageIndex.value = index
selectedOptionIndex.value = validRecommendIndex(prepare.value?.targetPackages?.[index])
}
function selectOption(index: number) {
selectedOptionIndex.value = index
}
async function handleSubmit() {
if (!canSubmitChange.value) {
uni.showToast({ title: '当前账号没有套餐变更确认权限', icon: 'none' })
return
}
if (!prepare.value?.batchNo || !selectedTarget.value?.package?.packageCode || !selectedOption.value) {
uni.showToast({ title: '请先校验并选择目标套餐', icon: 'none' })
return
}
if (submitting.value)
return
const confirmed = await confirmChangeSubmit()
if (!confirmed)
return
submitting.value = true
try {
const res = await changeSubmit({
batchNo: prepare.value.batchNo,
cardValues: selectedCardValues.value,
newPackageCode: selectedTarget.value.package.packageCode || '',
durationMonths: selectedOption.value.durationMonths,
})
result.value = res
packagePopupVisible.value = false
void userStore.fetchUserInfo().catch(() => undefined)
uni.showModal({
title: '套餐变更成功',
content: `订单号:${res.orderSn}\n变更批次:${res.batchNo || '-'}\n变更卡数:${res.quantity || selectedCardValues.value.length} 张\n余额扣款:${res.priceText || formatMoney(res.totalAmount)}\n支付状态:已支付`,
showCancel: false,
})
initialCardValue.value = ''
clearSelection()
await loadCards()
}
catch (error) {
const message = getErrorMessage(error, '套餐变更提交失败')
if (isBalanceInsufficient(message)) {
showRechargeModal(selectedOption.value?.totalAmount)
return
}
uni.showToast({ title: message, icon: 'none' })
}
finally {
submitting.value = false
}
}
function confirmChangeSubmit() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: '确认套餐变更',
content: `当前套餐:${prepare.value?.currentPackage?.packageName || '-'}\n目标套餐:${selectedTarget.value?.package?.packageName || '-'}\n卡片数量:${prepare.value?.quantity || selectedCardValues.value.length} 张\n套餐时长:${selectedOption.value?.label || '-'}\n扣款金额:${amountText.value}\n确认后将从账户余额扣款并立即变更套餐。`,
confirmText: '确认变更',
cancelText: '再看看',
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
})
})
}
function packageOptionTag(item: AppRenewOption, index: number) {
if (item.recommended || index === selectedOptionIndex.value)
return '推荐'
return `${item.durationMonths} 个月`
}
function formatMoney(value?: number) {
const amount = Number(value || 0)
return `¥${amount.toFixed(2)}`
}
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 `套餐变更请求异常(${data.statusCode})`
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
return fallback
}
function isBalanceInsufficient(message: string) {
return message.includes('余额不足') || message.includes('账户余额不足')
}
function showRechargeModal(amount?: number) {
const amountText = Number.isFinite(Number(amount || 0)) && Number(amount || 0) > 0
? `\n需补足金额约:${formatMoney(amount)}`
: ''
uni.showModal({
title: '余额不足',
content: `当前余额不足,充值功能暂未开放,暂不能继续提交套餐变更。${amountText}`,
confirmText: '知道了',
showCancel: false,
})
}
watch(selectedCardValues, () => {
prepare.value = undefined
result.value = undefined
selectedPackageIndex.value = 0
selectedOptionIndex.value = 0
})
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-arrows-horizontal" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">套餐变更</text>
<text class="biz-hero__sub">选择同一当前套餐的卡,校验后统一变更目标套餐</text>
</view>
</view>
<wd-tag type="primary" variant="plain">
批量
</wd-tag>
</view>
<view class="biz-stat-grid">
<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">{{ prepare?.currentPackage?.packageName || '待校验' }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">目标套餐</text>
<text class="biz-stat__value">{{ selectedTarget?.package?.packageName || '待选择' }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">应付金额</text>
<text class="biz-stat__value">{{ selectedOption ? amountText : '待计算' }}</text>
</view>
</view>
</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">同一批次必须是同客户、同当前套餐的卡</text>
</view>
<wd-tag type="primary" variant="plain">
已选 {{ selectedCardValues.length }}
</wd-tag>
</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="changeCardValue(item)"
class="biz-check-row"
:class="{ 'is-active': isSelected(item) }"
>
<wd-checkbox :name="changeCardValue(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>
</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="success" variant="plain">
已校验
</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">{{ prepare.currentPackage?.packageName || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">校验卡数</text>
<text class="biz-row__value">{{ prepare.quantity || 0 }} 张</text>
</view>
<view class="biz-row">
<text class="biz-row__label">生效规则</text>
<text class="biz-row__value">{{ prepare.effectiveRule || '-' }}</text>
</view>
<view v-if="selectedTarget" class="biz-row">
<text class="biz-row__label">目标套餐</text>
<text class="biz-row__value">{{ selectedTarget.package?.packageName || '-' }}</text>
</view>
<view v-if="selectedOption" class="biz-row">
<text class="biz-row__label">套餐时长</text>
<text class="biz-row__value">{{ selectedOption.label || '-' }}</text>
</view>
</view>
</view>
<view v-if="result" class="biz-card">
<view class="biz-card__body">
<view class="biz-section-head mb-8rpx">
<text class="biz-section-title">办理结果</text>
<wd-tag type="success" variant="plain">
已支付
</wd-tag>
</view>
<view class="biz-row">
<text class="biz-row__label">订单号</text>
<text class="biz-row__value mono">{{ result.orderSn || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">变更批次</text>
<text class="biz-row__value mono">{{ result.batchNo || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">订单金额</text>
<text class="biz-row__value">{{ result.priceText || formatMoney(result.totalAmount) }}</text>
</view>
</view>
</view>
</template>
<wd-popup v-model="packagePopupVisible" position="bottom" round safe-area-inset-bottom>
<view class="h-84vh max-h-84vh flex flex-col p-24rpx">
<view class="shrink-0">
<view class="text-32rpx text-#111827 font-900">
选择目标套餐
</view>
<view class="mt-8rpx text-24rpx text-#64748b">
{{ prepare?.quantity || selectedCardValues.length }} 张卡统一变更
</view>
</view>
<scroll-view scroll-y class="mt-20rpx min-h-0 flex-1">
<view v-if="prepare?.targetPackages?.length" class="grid gap-16rpx">
<view
v-for="(target, targetIndex) in prepare.targetPackages"
:key="target.package?.packageCode || targetIndex"
class="biz-option"
:class="{ 'is-active': targetIndex === selectedPackageIndex }"
@click="selectPackage(targetIndex)"
>
<view class="biz-option__main">
<text class="biz-option__title">
{{ target.package?.packageName || target.package?.packageCode || '-' }}
</text>
<text class="biz-option__meta">
{{ target.package?.flowText || '流量按套餐配置' }} · {{ target.priceText }}
</text>
</view>
<view class="biz-radio" />
<view v-if="targetIndex === selectedPackageIndex" class="grid grid-cols-2 mt-18rpx gap-12rpx">
<view
v-for="(option, optionIndex) in selectedOptions"
:key="option.durationMonths"
class="border rounded-8rpx border-solid p-16rpx"
:class="
optionIndex === selectedOptionIndex
? 'border-#2563eb bg-white'
: 'border-#e5e7eb bg-white/70'
"
@click.stop="selectOption(optionIndex)"
>
<view class="text-28rpx text-#111827 font-900">
{{ option.label }}
</view>
<view class="mt-10rpx text-28rpx text-#2563eb font-900">
{{ option.priceText || formatMoney(option.totalAmount) }}
</view>
<view class="mt-8rpx text-22rpx text-#64748b">
{{ packageOptionTag(option, optionIndex) }}
</view>
</view>
</view>
</view>
</view>
<view v-else class="p-32rpx text-center text-26rpx text-#64748b">
当前套餐暂无可变更目标套餐
</view>
</scroll-view>
<view class="biz-paybar-actions mt-24rpx shrink-0">
<wd-button type="primary" plain block custom-class="biz-paybar-btn" @click="packagePopupVisible = false">
取消
</wd-button>
<wd-button
type="primary"
block
custom-class="biz-paybar-btn"
:loading="submitting"
:disabled="!canSubmit"
@click="handleSubmit"
>
确认变更
</wd-button>
</view>
</view>
</wd-popup>
</view>
</view>
</scroll-view>
<view
v-if="!noPermission"
class="biz-paybar"
>
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">已选 {{ selectedCardValues.length }} 张</text>
<text v-if="selectedOption" class="biz-paybar__value">应付 {{ amountText }}</text>
<text v-else class="biz-paybar__value">先校验套餐</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"
>
确认变更 {{ selectedOption ? amountText : '' }}
</wd-button>
</view>
</view>
</view>
</template>
@@ -0,0 +1,329 @@
<script lang="ts" setup>
import type { AppCustomer, AppCustomerType } from '../api/customer'
import { getCustomerList } from '../api/customer'
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
import { hasAnyFeature } from '@/utils/permission'
import { getAccountWorkspace, getAccountWorkspaceLabel } from '@/utils/userIdentity'
definePage({
style: {
navigationBarTitleText: '客户管理',
},
})
const userStore = useUserStore()
const keyword = ref('')
const customerType = ref<AppCustomerType>('enterprise')
const page = ref(1)
const pageSize = 20
const total = ref(0)
const customers = ref<AppCustomer[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const noPermission = ref(false)
const hasMore = computed(() => customers.value.length < total.value)
const workspace = computed(() => getAccountWorkspace(userStore.userInfo))
const canUseCustomerList = computed(() => workspace.value !== 'terminal' && hasAnyFeature(userStore.userInfo, 'customer:list'))
const summaryText = computed(() => {
if (loading.value)
return '正在加载客户数据'
return `共 ${total.value} 个企业客户`
})
const workspaceLabel = computed(() => getAccountWorkspaceLabel(workspace.value))
const activeCustomerCount = computed(() => customers.value.filter(item => item.status === '0').length)
const pendingRealNameCount = computed(() => customers.value.filter(item => item.realNameStatus !== '已录入实名资料').length)
function statusTagType(item: AppCustomer) {
if (item.status === '0')
return 'success'
if (item.status === '1')
return 'warning'
return 'primary'
}
function realNameTagType(item: AppCustomer) {
return item.realNameStatus === '已录入实名资料' ? 'success' : 'warning'
}
function customerLabel(item: AppCustomer) {
const code = item.customerCode || (item.terminalId ? `ID ${item.terminalId}` : `ID ${item.customerId}`)
return `${item.customerTypeName || '客户'} · ${code}`
}
function buildQuery(nextPage: number) {
return {
page: nextPage,
pageSize,
keyword: keyword.value.trim() || undefined,
customerType: customerType.value,
}
}
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
}
async function loadCustomers(reset = true) {
if (!canUseCustomerList.value) {
noPermission.value = true
return
}
if (reset) {
page.value = 1
loading.value = true
}
else {
if (!hasMore.value || loadingMore.value)
return
loadingMore.value = true
}
try {
const nextPage = reset ? 1 : page.value + 1
const res = await getCustomerList(buildQuery(nextPage))
const list = res.items || []
customers.value = reset ? list : [...customers.value, ...list]
total.value = res.total || 0
page.value = nextPage
}
catch (error) {
if (reset) {
customers.value = []
total.value = 0
}
uni.showToast({ title: getErrorMessage(error, '客户列表加载失败'), icon: 'none' })
}
finally {
loading.value = false
loadingMore.value = false
}
}
function handleSearchChange() {
void loadCustomers(true)
}
function handleDetail(item: AppCustomer) {
if (item.customerType !== 'enterprise') {
uni.showToast({ title: '暂不支持查看终端客户', icon: 'none' })
return
}
uni.navigateTo({
url: `${RouterPath.BusinessViewsCustomerDetail}?customerType=enterprise&customerId=${item.customerId}`,
})
}
async function bootstrap() {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canUseCustomerList.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能查看客户管理', icon: 'none' })
return
}
await loadCustomers(true)
}
onMounted(() => {
void bootstrap()
})
</script>
<template>
<view class="min-h-screen p-14rpx pb-24rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-14rpx">
<wd-card custom-class="!mx-0 !mb-0">
<view class="flex items-center justify-between gap-20rpx">
<view class="min-w-0">
<view class="customer-kicker">{{ workspaceLabel }}工作台</view>
<view class="customer-title">客户管理</view>
<view class="customer-subtitle">
{{ noPermission ? '当前账号暂无客户管理权限' : summaryText }}
</view>
</view>
<wd-button
size="small"
type="primary"
plain
:loading="loading"
:disabled="noPermission"
@click="loadCustomers(true)"
>
刷新
</wd-button>
</view>
</wd-card>
<template v-if="!noPermission">
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<view class="grid gap-20rpx p-24rpx">
<wd-search
v-model="keyword"
hide-cancel
placeholder-left
variant="filled"
placeholder="搜索企业名称 / 编码 / 手机号"
custom-class="!p-0"
@search="handleSearchChange"
@clear="handleSearchChange"
/>
<view class="rounded-8rpx bg-#f8fafc px-18rpx py-14rpx text-24rpx text-#64748b">
当前展示本企业直属下级企业客户,终端客户暂不展示。
</view>
</view>
</wd-card>
<view class="customer-metrics">
<view class="customer-metric">
<text class="customer-metric__value">{{ total }}</text>
<text class="customer-metric__label">客户总数</text>
</view>
<view class="customer-metric customer-metric--blue">
<text class="customer-metric__value">{{ activeCustomerCount }}</text>
<text class="customer-metric__label">正常客户</text>
</view>
<view class="customer-metric customer-metric--amber">
<text class="customer-metric__value">{{ pendingRealNameCount }}</text>
<text class="customer-metric__label">待实名</text>
</view>
</view>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<wd-cell-group v-if="customers.length" :border="false">
<wd-cell
v-for="item in customers"
:key="`${item.customerType}-${item.customerId}`"
:title="item.customerName"
:label="customerLabel(item)"
is-link
clickable
center
custom-class="!min-h-164rpx"
custom-title-class="!text-28rpx !text-#111827 !font-800"
custom-label-class="!text-24rpx !text-#64748b"
@click="handleDetail(item)"
>
<view class="flex flex-col items-end gap-8rpx">
<view class="text-28rpx text-#111827 font-900">
{{ item.cardCount || 0 }} 张卡
</view>
<view class="flex items-center gap-8rpx">
<wd-tag :type="statusTagType(item)" variant="plain">
{{ item.statusText || '未设置' }}
</wd-tag>
<wd-tag :type="realNameTagType(item)" variant="plain">
{{ item.realNameStatus }}
</wd-tag>
</view>
</view>
</wd-cell>
</wd-cell-group>
<view v-else class="p-48rpx">
<wd-empty :tip="loading ? '客户加载中' : '暂无客户'" />
</view>
</wd-card>
<wd-button
v-if="hasMore"
plain block
type="primary"
:loading="loadingMore"
@click="loadCustomers(false)"
>
加载更多
</wd-button>
</template>
</view>
</view>
</template>
<style lang="scss" scoped>
.customer-kicker,
.customer-subtitle {
display: block;
}
.customer-kicker {
color: #2563eb;
font-size: 20rpx;
font-weight: 700;
}
.customer-title {
margin-top: 4rpx;
color: #111827;
font-size: 34rpx;
font-weight: 900;
}
.customer-subtitle {
margin-top: 6rpx;
color: #64748b;
font-size: 23rpx;
}
.customer-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
.customer-metric {
box-sizing: border-box;
padding: 16rpx;
border: 1px solid #e2e8f0;
border-radius: 10rpx;
background: #fff;
}
.customer-metric--blue {
border-color: #bfdbfe;
background: #eff6ff;
}
.customer-metric--amber {
border-color: #fde68a;
background: #fffbeb;
}
.customer-metric__value,
.customer-metric__label {
display: block;
}
.customer-metric__value {
color: #111827;
font-size: 30rpx;
font-weight: 900;
}
.customer-metric__label {
margin-top: 4rpx;
color: #64748b;
font-size: 20rpx;
}
</style>
@@ -0,0 +1,251 @@
<script lang="ts" setup>
import type { AppCustomerDetail } from '../api/customer'
import { getCustomerDetail } from '../api/customer'
import { useUserStore } from '@/store/user'
import { hasAnyFeature } from '@/utils/permission'
definePage({
style: {
navigationBarTitleText: '客户详情',
},
})
const loading = ref(false)
const errorMessage = ref('')
const customer = ref<AppCustomerDetail>()
const queryType = ref('')
const queryId = ref(0)
const noPermission = ref(false)
const userStore = useUserStore()
const titleText = computed(() => customer.value?.customerName || '客户详情')
const canUseCustomerDetail = computed(() => hasAnyFeature(userStore.userInfo, 'customer:detail'))
const baseRows = computed(() => {
const item = customer.value
if (!item)
return []
return [
['客户类型', item.customerTypeName || '-'],
['客户编码', item.customerCode || (item.terminalId ? `ID ${item.terminalId}` : `ID ${item.customerId}`)],
['联系电话', item.phone || '-'],
['邮箱', item.email || '-'],
['客户状态', item.statusText || '-'],
['实名状态', item.realNameStatus || '-'],
['卡片数量', `${item.cardCount || 0} 张`],
['正常卡数', `${item.normalCardCount || 0} 张`],
['地址', item.address || '-'],
]
})
const enterpriseRows = computed(() => {
const profile = customer.value?.enterpriseProfile
if (!profile)
return []
return [
['企业名称', profile.deptName || '-'],
['客户编码', profile.deptKey || '-'],
['联系电话', profile.phone || '-'],
['企业状态', profile.statusText || '-'],
['统一信用代码', profile.creditCode || '-'],
['单位全称', profile.fullName || '-'],
['行业', profile.industry || '-'],
['单位类型', profile.companyType || '-'],
['成立时间', profile.establishTime || '-'],
['所在地区', profile.areaCode || '-'],
['联系地址', profile.address || '-'],
]
})
function statusTagType(item?: AppCustomerDetail) {
if (item?.status === '0')
return 'success'
if (item?.status === '1')
return 'warning'
return 'primary'
}
function normalizeQueryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : String(value || '')
}
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
}
async function loadCustomerDetail() {
if (!canUseCustomerDetail.value) {
noPermission.value = true
errorMessage.value = '当前账号不能查看客户详情'
return
}
if (!queryType.value || !queryId.value) {
errorMessage.value = '客户参数不完整'
return
}
if (queryType.value !== 'enterprise') {
errorMessage.value = '暂不支持查看终端客户'
return
}
loading.value = true
errorMessage.value = ''
try {
const res = await getCustomerDetail({
customerType: queryType.value,
customerId: queryId.value,
})
customer.value = res.customer
}
catch (error) {
customer.value = undefined
errorMessage.value = getErrorMessage(error, '客户详情加载失败')
}
finally {
loading.value = false
}
}
onLoad((query) => {
queryType.value = normalizeQueryValue(query?.customerType)
queryId.value = Number(normalizeQueryValue(query?.customerId))
})
async function bootstrap() {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canUseCustomerDetail.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能查看客户详情', icon: 'none' })
return
}
await loadCustomerDetail()
}
onMounted(() => {
void bootstrap()
})
</script>
<template>
<view class="min-h-screen p-14rpx pb-24rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-14rpx">
<wd-card custom-class="!mx-0 !mb-0">
<view class="flex items-center justify-between gap-20rpx">
<view class="min-w-0">
<view class="truncate text-34rpx text-#111827 font-900">
{{ titleText }}
</view>
<view class="mt-8rpx text-24rpx text-#64748b">
{{ customer?.customerTypeName || '客户资料' }}
</view>
</view>
<wd-tag :type="statusTagType(customer)" variant="plain">
{{ customer?.statusText || '加载中' }}
</wd-tag>
</view>
</wd-card>
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
<view class="text-26rpx text-#64748b leading-38rpx">
当前账号不能查看客户详情。
</view>
</wd-card>
<wd-card v-else-if="loading" custom-class="!mx-0 !mb-0">
<wd-skeleton theme="paragraph" />
</wd-card>
<wd-card v-else-if="errorMessage" custom-class="!mx-0 !mb-0">
<view class="grid gap-16rpx">
<view class="text-26rpx text-#b91c1c leading-38rpx">
{{ errorMessage }}
</view>
<wd-button size="small" type="primary" @click="loadCustomerDetail">
重试
</wd-button>
</view>
</wd-card>
<template v-else-if="customer">
<wd-card custom-class="!mx-0 !mb-0">
<view class="grid grid-cols-2 gap-12rpx text-center">
<view class="rounded-8rpx bg-#f8fafc px-12rpx py-18rpx">
<view class="text-30rpx text-#111827 font-900">
{{ customer.cardCount || 0 }}
</view>
<view class="mt-6rpx text-22rpx text-#64748b">
卡片
</view>
</view>
<view class="rounded-8rpx bg-#f8fafc px-12rpx py-18rpx">
<view class="text-30rpx text-#2563eb font-900">
{{ customer.normalCardCount || 0 }}
</view>
<view class="mt-6rpx text-22rpx text-#64748b">
正常
</view>
</view>
</view>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<template #title>
<view class="h-80rpx flex items-center px-24rpx">
<text class="text-28rpx text-#111827 font-900">基础信息</text>
</view>
</template>
<wd-cell-group :border="false">
<wd-cell
v-for="row in baseRows"
:key="row[0]"
:title="row[0]"
:value="row[1]"
center
/>
</wd-cell-group>
</wd-card>
<wd-card
v-if="enterpriseRows.length"
custom-class="!mx-0 !mb-0"
custom-content-class="!p-0"
>
<template #title>
<view class="h-80rpx flex items-center px-24rpx">
<text class="text-28rpx text-#111827 font-900">企业实名资料</text>
</view>
</template>
<wd-cell-group :border="false">
<wd-cell
v-for="row in enterpriseRows"
:key="row[0]"
:title="row[0]"
:value="row[1]"
center
/>
</wd-cell-group>
</wd-card>
</template>
</view>
</view>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
<script lang="ts" setup>
import { balanceRechargeRefund } from '../api/balance'
import type { AppOrder, AppOrderItem } from '../api/order'
import { getOrderDetail, getOrderList } from '../api/order'
definePage({
style: {
navigationBarTitleText: '我的订单',
},
})
type OrderTypeFilter = 'all' | 'renew' | 'recharge'
type OrderStatusFilter = 'all' | 'pending' | 'paid'
const keyword = ref('')
const orderType = ref<OrderTypeFilter>('all')
const orderStatus = ref<OrderStatusFilter>('all')
const page = ref(1)
const pageSize = 10
const total = ref(0)
const orders = ref<AppOrder[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const detailLoading = ref(false)
const popupVisible = ref(false)
const selectedOrder = ref<AppOrder>()
const refunding = ref(false)
let orderLoadSeq = 0
const orderTypeOptions = [
{ value: 'all', payload: { label: '全部类型' } },
{ value: 'renew', payload: { label: '套餐续费' } },
{ value: 'recharge', payload: { label: '余额充值' } },
]
const orderStatusOptions = [
{ value: 'all', payload: { label: '全部状态' } },
{ value: 'pending', payload: { label: '待支付' } },
{ value: 'paid', payload: { label: '已完成' } },
]
const hasMore = computed(() => orders.value.length < total.value)
const isEmpty = computed(() => !loading.value && orders.value.length === 0)
const showInitialLoading = computed(() => loading.value && orders.value.length === 0)
const summaryText = computed(() => {
if (loading.value)
return '正在加载订单'
return `共 ${total.value} 笔订单`
})
function orderTypeParam() {
if (orderType.value === 'renew')
return 1
if (orderType.value === 'recharge')
return 2
return undefined
}
function payStatusParam() {
if (orderStatus.value === 'pending')
return 0
if (orderStatus.value === 'paid')
return 2
return undefined
}
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 `订单请求异常(${data.statusCode})`
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
return fallback
}
function formatDate(value?: string) {
if (!value)
return '-'
return value.replace('T', ' ').slice(0, 19)
}
function statusTagType(item: AppOrder) {
if (item.returnStatus === 3)
return 'danger'
if (item.returnStatus === 4)
return 'success'
if ([1, 2].includes(item.returnStatus))
return 'warning'
if (item.payStatus === 2)
return 'success'
if (item.paymentExpired)
return 'danger'
if (item.payStatus === 0)
return 'warning'
return 'primary'
}
function displayStatus(item?: AppOrder) {
if (!item)
return '-'
if (item.returnStatus > 0)
return item.returnStatusText || '退款处理中'
if (item.paymentExpired)
return '已过期'
if (item.payStatus === 0)
return '待支付'
if (item.payStatus === 2)
return '已完成'
return '处理中'
}
function canContinuePay(item?: AppOrder) {
return Boolean(item && item.orderType === 2 && item.payStatus === 0 && !item.paymentExpired)
}
function isWechatRechargeOrder(item?: AppOrder) {
return Boolean(item && item.orderType === 2 && item.payStatus === 2 && item.payType === 1 && item.payOrderSn)
}
function isRefundPending(item?: AppOrder) {
return Boolean(item && [1, 2].includes(item.returnStatus || 0))
}
function canRequestRefund(item?: AppOrder) {
return Boolean(isWechatRechargeOrder(item) && Number(item?.returnStatus || 0) === 0 && !refunding.value)
}
function refundStatusText(item?: AppOrder) {
if (!item)
return '-'
return item.returnStatusText || '未退款'
}
function amountTitle(item?: AppOrder) {
if (item?.orderType === 2)
return '充值金额'
if (item?.orderType === 1)
return '续费金额'
return '订单金额'
}
function payMethodText(item?: AppOrder) {
if (!item || item.payStatus !== 2)
return '-'
if (item.payType === 0)
return '余额'
if (item.orderType === 2)
return '微信支付'
return item.payTypeText || '-'
}
function finishTimeTitle(item?: AppOrder) {
if (item?.orderType === 2)
return '到账时间'
if (item?.orderType === 1)
return '续费时间'
return '完成时间'
}
function itemLabel(item: AppOrderItem) {
const parts = [`数量 ${item.quantity}`]
if (item.serviceBeginTime || item.serviceEndTime) {
parts.push(`${formatDate(item.serviceBeginTime)} 至 ${formatDate(item.serviceEndTime)}`)
}
return parts.join(' · ')
}
function handleContinuePay() {
if (!selectedOrder.value?.orderSn)
return
uni.showModal({
title: '充值功能暂未开放',
content: selectedOrder.value.paymentExpired
? '该充值订单已过期,当前暂不能重新发起充值。'
: '当前暂不能继续支付充值订单,请等待充值功能开放后再操作。',
showCancel: false,
})
}
function confirmRefund() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: '确认申请退款',
content: `订单号:${selectedOrder.value?.orderSn || '-'}\n退款金额:${selectedOrder.value?.totalAmountText || '-'}\n退款后会从账户余额中扣回同等金额。`,
confirmText: '申请退款',
cancelText: '取消',
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
})
})
}
async function handleRefund() {
const order = selectedOrder.value
if (!canRequestRefund(order)) {
uni.showToast({ title: '当前订单不支持发起退款', icon: 'none' })
return
}
const confirmed = await confirmRefund()
if (!confirmed)
return
refunding.value = true
try {
const res = await balanceRechargeRefund({
orderSn: order!.orderSn,
refundReason: '用户发起余额充值退款',
})
selectedOrder.value = res || selectedOrder.value
await loadOrders(true)
uni.showToast({
title: res?.returnStatus === 4 ? '退款已完成' : '退款申请已提交',
icon: 'success',
})
}
catch (error) {
uni.showToast({
title: getErrorMessage(error, '发起退款失败'),
icon: 'none',
})
}
finally {
refunding.value = false
}
}
function buildQuery(nextPage: number) {
return {
page: nextPage,
pageSize,
keyword: keyword.value.trim() || undefined,
orderType: orderTypeParam(),
payStatus: payStatusParam(),
}
}
async function loadOrders(reset = true) {
if (reset) {
page.value = 1
loading.value = true
}
else {
if (!hasMore.value || loadingMore.value)
return
loadingMore.value = true
}
const seq = ++orderLoadSeq
try {
const nextPage = reset ? 1 : page.value + 1
const res = await getOrderList(buildQuery(nextPage))
if (seq !== orderLoadSeq)
return
const list = res.items || []
orders.value = reset ? list : [...orders.value, ...list]
total.value = res.total || 0
page.value = nextPage
}
catch (error) {
if (seq !== orderLoadSeq)
return
if (reset && orders.value.length === 0) {
orders.value = []
total.value = 0
}
uni.showToast({
title: getErrorMessage(error, '订单列表加载失败'),
icon: 'none',
})
}
finally {
if (seq === orderLoadSeq) {
loading.value = false
loadingMore.value = false
}
}
}
function handleSearchChange() {
void loadOrders(true)
}
function handleOrderTypeChange(value: OrderTypeFilter) {
orderType.value = value
void loadOrders(true)
}
function handleOrderStatusChange(value: OrderStatusFilter) {
orderStatus.value = value
void loadOrders(true)
}
async function handleDetail(item: AppOrder) {
selectedOrder.value = item
popupVisible.value = true
detailLoading.value = true
try {
const res = await getOrderDetail({ orderSn: item.orderSn })
selectedOrder.value = res.order || item
}
catch (error) {
uni.showToast({
title: getErrorMessage(error, '订单详情加载失败'),
icon: 'none',
})
}
finally {
detailLoading.value = false
}
}
onMounted(() => {
void loadOrders(true)
})
</script>
<template>
<scroll-view scroll-y class="h-screen">
<view class="min-h-screen p-14rpx pb-24rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-14rpx">
<wd-card custom-class="!mx-0 !mb-0">
<view class="flex items-center justify-between gap-20rpx">
<view class="min-w-0">
<view class="text-34rpx text-#111827 font-900">
我的订单
</view>
<view class="mt-8rpx text-24rpx text-#64748b">
{{ summaryText }}
</view>
</view>
<wd-button
size="small"
type="primary"
plain
:loading="loading"
@click="loadOrders(true)"
>
刷新
</wd-button>
</view>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<view class="grid gap-20rpx p-24rpx">
<wd-search
v-model="keyword"
hide-cancel
placeholder-left
variant="filled"
placeholder="搜索订单号 / 业务名称"
custom-class="!p-0"
@search="handleSearchChange"
@clear="handleSearchChange"
/>
<wd-segmented
:value="orderType"
:options="orderTypeOptions"
theme="card"
@change="handleOrderTypeChange($event.value as OrderTypeFilter)"
>
<template #label="{ option }">
{{ option.payload?.label || option.value }}
</template>
</wd-segmented>
<wd-segmented
:value="orderStatus"
:options="orderStatusOptions"
theme="card"
@change="handleOrderStatusChange($event.value as OrderStatusFilter)"
>
<template #label="{ option }">
{{ option.payload?.label || option.value }}
</template>
</wd-segmented>
</view>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<wd-cell-group v-if="orders.length" :border="false">
<wd-cell
v-for="item in orders"
:key="item.orderSn"
:title="item.itemSummary || item.orderTypeText"
:label="`${item.orderSn} · ${formatDate(item.createdAt)}`"
is-link
clickable
center
custom-class="!min-h-152rpx"
custom-title-class="!text-28rpx !text-#111827 !font-800"
custom-label-class="!text-24rpx !text-#64748b"
@click="handleDetail(item)"
>
<view class="flex flex-col items-end gap-8rpx">
<view class="text-30rpx text-#111827 font-900">
{{ item.totalAmountText }}
</view>
<view class="flex items-center gap-8rpx">
<wd-tag :type="statusTagType(item)" variant="plain">
{{ displayStatus(item) }}
</wd-tag>
</view>
</view>
</wd-cell>
</wd-cell-group>
<view v-else class="p-48rpx">
<wd-empty :tip="showInitialLoading ? '订单加载中' : (isEmpty ? '暂无订单' : '订单加载中')" />
</view>
</wd-card>
<wd-button
v-if="hasMore"
plain block
type="primary"
:loading="loadingMore"
@click="loadOrders(false)"
>
加载更多
</wd-button>
</view>
<wd-popup v-model="popupVisible" position="bottom" round safe-area-inset-bottom>
<view class="max-h-75vh flex flex-col bg-#f8fafc">
<view
class="flex items-center justify-between border-0 border-b border-#e5e7eb border-solid p-24rpx"
>
<view class="min-w-0">
<view class="text-30rpx text-#111827 font-900">
订单详情
</view>
<view class="mt-6rpx truncate text-24rpx text-#64748b">
{{ selectedOrder?.orderSn || '-' }}
</view>
</view>
<wd-button size="small" plain @click="popupVisible = false">
关闭
</wd-button>
</view>
<scroll-view scroll-y class="min-h-0 flex-1">
<view class="grid gap-20rpx p-24rpx">
<wd-card v-if="selectedOrder" custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<wd-cell-group :border="false">
<wd-cell title="状态" :value="displayStatus(selectedOrder)" center />
<wd-cell title="业务类型" :value="selectedOrder.orderTypeText" center />
<wd-cell
:title="amountTitle(selectedOrder)"
:value="selectedOrder.totalAmountText"
center
/>
<wd-cell
v-if="selectedOrder.payStatus === 2"
title="支付方式"
:value="payMethodText(selectedOrder)"
center
/>
<wd-cell
v-if="isWechatRechargeOrder(selectedOrder) && selectedOrder.returnStatus > 0"
title="退款状态"
:value="refundStatusText(selectedOrder)"
center
/>
<wd-cell
v-if="selectedOrder.payOrderSn"
title="支付凭证"
:value="selectedOrder.payOrderSn"
center
/>
<wd-cell title="提交时间" :value="formatDate(selectedOrder.createdAt)" center />
<wd-cell
v-if="selectedOrder.payStatus === 2 && selectedOrder.payTime"
:title="finishTimeTitle(selectedOrder)"
:value="formatDate(selectedOrder.payTime)"
center
/>
<wd-cell
v-if="selectedOrder.paymentExpireAt && selectedOrder.payStatus === 0"
title="支付截止"
:value="formatDate(selectedOrder.paymentExpireAt)"
center
/>
<wd-cell
v-if="selectedOrder.buyerNote"
title="备注"
:value="selectedOrder.buyerNote"
center
/>
</wd-cell-group>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<template #title>
<view class="h-80rpx flex items-center px-24rpx">
<text class="text-28rpx text-#111827 font-900">业务内容</text>
</view>
</template>
<wd-cell-group v-if="selectedOrder?.items?.length" :border="false">
<wd-cell
v-for="item in selectedOrder.items"
:key="item.itemId"
:title="item.productName || '订单商品'"
:label="itemLabel(item)"
center
custom-title-class="!text-28rpx !text-#111827 !font-800"
custom-label-class="!text-24rpx !text-#64748b"
>
<view class="text-28rpx text-#111827 font-900">
{{ item.priceText }}
</view>
</wd-cell>
</wd-cell-group>
<view v-else class="p-40rpx">
<wd-empty :tip="detailLoading ? '订单商品加载中' : '暂无订单商品'" />
</view>
</wd-card>
<wd-button
v-if="canContinuePay(selectedOrder)"
block
type="primary"
@click="handleContinuePay"
>
继续支付
</wd-button>
<wd-button
v-if="canRequestRefund(selectedOrder)"
block
plain
type="primary"
:loading="refunding"
@click="handleRefund"
>
申请退款
</wd-button>
<wd-button
v-else-if="isRefundPending(selectedOrder)"
block
plain
type="primary"
disabled
>
{{ refundStatusText(selectedOrder) }}
</wd-button>
</view>
</scroll-view>
</view>
</wd-popup>
</view>
</scroll-view>
</template>
@@ -0,0 +1,729 @@
<script lang="ts" setup>
import type { AppCard, AppRenewOption, RenewPackageOption, RenewPrepareRes, RenewSubmitRes } from '@/api/card'
import { getCardList, renewBatchPrepare, renewBatchSubmit } from '@/api/card'
import {
displayCardValue,
displayPackageName,
formatDateTime,
formatFlowPair,
} from '@/pages/card/data'
import { useUserStore } from '@/store/user'
import { canSubmitRenewPermission, canUseRenewPermission } from '@/utils/permission'
definePage({
style: {
navigationBarTitleText: '批量套餐续费',
},
})
const keyword = ref('')
const userStore = useUserStore()
const cards = ref<AppCard[]>([])
const selectedCardValues = ref<string[]>([])
const loadingCards = ref(false)
const preparing = ref(false)
const submitting = ref(false)
const errorMessage = ref('')
const optionPopupVisible = ref(false)
const prepare = ref<RenewPrepareRes>()
const order = ref<RenewSubmitRes>()
const selectedPackageIndex = ref(0)
const selectedIndex = ref(0)
const showAllCards = ref(false)
const cardPreviewLimit = 6
const visibleCards = computed(() => {
const value = keyword.value.trim()
if (!value)
return cards.value
return cards.value.filter((item) => {
return (
cardKey(item).includes(value)
|| (item.msisdn || '').includes(value)
|| displayPackageName(item).includes(value)
|| (item.remark || '').includes(value)
|| (item.terminalName || '').includes(value)
)
})
})
const selectedCards = computed(() => {
const selected = new Set(selectedCardValues.value)
return cards.value.filter(item => selected.has(cardKey(item)))
})
const displayCards = computed(() => showAllCards.value ? visibleCards.value : visibleCards.value.slice(0, cardPreviewLimit))
const hiddenCardCount = computed(() => Math.max(0, visibleCards.value.length - displayCards.value.length))
const renewablePackages = computed(() => prepare.value?.renewablePackages || [])
const selectedPackage = computed(() => renewablePackages.value[selectedPackageIndex.value])
const selectedOptions = computed(() => {
const packageOptions = selectedPackage.value?.options || []
return packageOptions.length ? packageOptions : (prepare.value?.options || [])
})
const selectedOption = computed(() => selectedOptions.value[selectedIndex.value])
const amountText = computed(
() => selectedOption.value?.priceText || formatMoney(selectedOption.value?.totalAmount),
)
const canSubmit = computed(
() => !!prepare.value?.batchNo && !!selectedOption.value && !submitting.value && !order.value,
)
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 || []
const nextKeys = new Set(cards.value.map(cardKey))
selectedCardValues.value = selectedCardValues.value.filter(value => nextKeys.has(value))
}
catch (error) {
cards.value = []
selectedCardValues.value = []
errorMessage.value = getErrorMessage(error, '卡片列表加载失败')
}
finally {
loadingCards.value = false
}
}
function ensureRenewPermission(submit = false) {
const allowed = submit
? canSubmitRenewPermission(userStore.userInfo)
: canUseRenewPermission(userStore.userInfo)
if (!allowed) {
uni.showToast({ title: '当前账号没有套餐续费权限', icon: 'none' })
}
return allowed
}
function cardKey(item: AppCard) {
return displayCardValue(item)
}
function isSelected(item: AppCard) {
return selectedCardValues.value.includes(cardKey(item))
}
function clearSelection() {
selectedCardValues.value = []
}
function selectVisibleCards() {
const values = visibleCards.value.map(cardKey).filter(value => value && value !== '-')
selectedCardValues.value = Array.from(new Set([...selectedCardValues.value, ...values]))
}
function toggleAllCards() {
showAllCards.value = !showAllCards.value
}
async function handlePrepare() {
if (!ensureRenewPermission())
return
if (!selectedCardValues.value.length) {
uni.showToast({ title: '请先选择要续费的卡', icon: 'none' })
return
}
if (preparing.value)
return
preparing.value = true
try {
const res = await renewBatchPrepare({
cardValues: selectedCardValues.value,
})
prepare.value = res
order.value = undefined
selectedPackageIndex.value = validPackageIndex(res)
selectedIndex.value = validRecommendIndex(selectedOptions.value, res.recommendIndex)
optionPopupVisible.value = true
}
catch (error) {
prepare.value = undefined
uni.showToast({
title: getErrorMessage(error, '续费批量校验失败'),
icon: 'none',
})
}
finally {
preparing.value = false
}
}
function validPackageIndex(res: RenewPrepareRes) {
const packages = res.renewablePackages || []
if (!packages.length)
return 0
const selectedCode = res.package?.packageCode
const index = packages.findIndex(item => renewPackageCode(item) === selectedCode)
return index >= 0 ? index : 0
}
function validRecommendIndex(options: AppRenewOption[], recommendIndex?: number) {
if (!options.length)
return 0
if (typeof recommendIndex === 'number' && recommendIndex >= 0 && recommendIndex < options.length)
return recommendIndex
const index = options.findIndex(item => item.recommended)
return index >= 0 ? index : 0
}
function selectPackage(index: number) {
selectedPackageIndex.value = index
selectedIndex.value = validRecommendIndex(selectedOptions.value)
}
function selectOption(index: number) {
selectedIndex.value = index
}
async function handleSubmit() {
if (!ensureRenewPermission(true))
return
if (!prepare.value?.batchNo || !selectedOption.value) {
uni.showToast({ title: '请先校验并选择续费档位', icon: 'none' })
return
}
if (submitting.value)
return
const confirmed = await confirmRenewSubmit()
if (!confirmed)
return
submitting.value = true
try {
const res = await renewBatchSubmit({
batchNo: prepare.value.batchNo,
cardValues: selectedCardValues.value,
packageCode: selectedTargetPackageCode() || undefined,
durationMonths: selectedOption.value.durationMonths,
durationYears: selectedOption.value.durationYears,
})
order.value = res
optionPopupVisible.value = false
void userStore.fetchUserInfo().catch(() => undefined)
uni.showModal({
title: '续费成功',
content: `订单号:${res.orderSn}\n续费批次:${res.batchNo || '-'}\n续费卡数:${res.quantity || selectedCardValues.value.length} 张\n余额扣款:${res.priceText || formatMoney(res.totalAmount)}\n支付状态:已支付`,
showCancel: false,
})
}
catch (error) {
const message = getErrorMessage(error, '续费提交失败')
if (isBalanceInsufficient(message)) {
showRechargeModal(selectedOption.value?.totalAmount)
return
}
uni.showToast({
title: message,
icon: 'none',
})
}
finally {
submitting.value = false
}
}
function confirmRenewSubmit() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: '确认续费',
content: `当前套餐:${currentPackageName()}\n续费套餐:${packageName()}\n卡片数量:${prepare.value?.quantity || selectedCardValues.value.length} 张\n续费年限:${selectedOption.value?.label || '-'}\n扣款金额:${amountText.value}\n确认后将从账户余额扣款。`,
confirmText: '确认续费',
cancelText: '再看看',
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
})
})
}
function packageName() {
const selectedName = selectedPackage.value?.packageName || selectedPackage.value?.package?.packageName
if (selectedName)
return selectedName
const preparedName = prepare.value?.package?.packageName
if (preparedName)
return preparedName
const firstCard = selectedCards.value[0]
return firstCard ? displayPackageName(firstCard) : '续费套餐'
}
function currentPackageName() {
const preparedName = prepare.value?.currentPackage?.packageName
if (preparedName)
return preparedName
const firstCard = selectedCards.value[0]
return firstCard ? displayPackageName(firstCard) : '当前套餐'
}
function selectedTargetPackageCode() {
return renewPackageCode(selectedPackage.value) || prepare.value?.package?.packageCode || ''
}
function renewPackageCode(item?: RenewPackageOption) {
return item?.packageCode || item?.package?.packageCode || ''
}
function formatMoney(value?: number) {
const amount = Number(value || 0)
return `¥${amount.toFixed(2)}`
}
function optionTag(item: AppRenewOption, index: number) {
if (item.recommended || index === prepare.value?.recommendIndex)
return '推荐'
return item.label || `${item.durationYears || Math.max(1, Math.floor(item.durationMonths / 12))} 年`
}
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 `续费请求异常(${data.statusCode})`
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
return fallback
}
function isBalanceInsufficient(message: string) {
return message.includes('余额不足') || message.includes('账户余额不足')
}
function showRechargeModal(amount?: number) {
const amountText = Number.isFinite(Number(amount || 0)) && Number(amount || 0) > 0
? `\n需补足金额约:${formatMoney(amount)}`
: ''
uni.showModal({
title: '余额不足',
content: `当前余额不足,充值功能暂未开放,暂不能继续提交续费。${amountText}`,
confirmText: '知道了',
showCancel: false,
})
}
watch(selectedCardValues, () => {
prepare.value = undefined
order.value = undefined
selectedPackageIndex.value = 0
selectedIndex.value = 0
})
onMounted(() => {
void (async () => {
await userStore.fetchUserInfo().catch(() => undefined)
if (!ensureRenewPermission())
return
await loadCards()
})()
})
</script>
<template>
<scroll-view scroll-y class="h-screen">
<view class="biz-page">
<view class="biz-stack">
<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-renew" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">批量套餐续费</text>
<text class="biz-hero__sub">选择同套餐卡片,校验后统一续费并顺延到期时间</text>
</view>
</view>
<wd-tag type="primary" variant="plain">
批量
</wd-tag>
</view>
<view class="biz-stat-grid">
<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">{{ currentPackageName() }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">续费套餐</text>
<text class="biz-stat__value">{{ packageName() }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">校验状态</text>
<text class="biz-stat__value">{{ prepare ? '已校验' : '待校验' }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">应付金额</text>
<text class="biz-stat__value">{{ selectedOption ? amountText : '待计算' }}</text>
</view>
</view>
</view>
<view class="biz-card">
<view class="biz-card__body">
<wd-input
v-model="keyword"
placeholder="搜索卡号、套餐、备注"
clearable
@confirm="loadCards"
/>
<view class="biz-mini-action-grid mt-16rpx">
<wd-button size="small" type="primary" plain block custom-class="biz-mini-action-btn" :disabled="loadingCards" @click="loadCards">
<view class="biz-mini-action-btn__inner">
<view
:class="loadingCards ? 'i-carbon-renew animate-spin' : 'i-carbon-renew'"
class="biz-mini-action-btn__icon biz-mini-action-btn__icon--blue"
/>
<text class="biz-mini-action-btn__text">
{{ loadingCards ? '刷新中' : '刷新' }}
</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>
</view>
<wd-tag type="primary" variant="plain">
已选 {{ selectedCardValues.length }}
</wd-tag>
</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 displayCards"
:key="cardKey(item)"
class="biz-check-row"
:class="{ 'is-active': isSelected(item) }"
>
<wd-checkbox :name="cardKey(item)" custom-label-class="!block !w-full">
<view class="min-w-0 pr-16rpx">
<view class="truncate text-30rpx text-#111827 font-900">
{{ cardKey(item) }}
</view>
<view class="mt-8rpx truncate text-24rpx text-#64748b">
{{ displayPackageName(item) }}
</view>
<view class="mt-8rpx text-23rpx text-#64748b">
{{ formatFlowPair(item) }} · 到期 {{ formatDateTime(item.flowPkgEndAt) }}
</view>
</view>
</wd-checkbox>
</view>
</view>
</wd-checkbox-group>
<view v-if="hiddenCardCount" class="mt-18rpx">
<wd-button type="primary" plain block size="small" custom-class="biz-mini-action-btn" @click="toggleAllCards">
展开全部 {{ visibleCards.length }} 张卡
</wd-button>
</view>
<view v-else-if="showAllCards && visibleCards.length > cardPreviewLimit" class="mt-18rpx">
<wd-button type="primary" plain block size="small" custom-class="biz-mini-action-btn" @click="toggleAllCards">
收起列表
</wd-button>
</view>
</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="success" variant="plain">
已生成批次
</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">{{ packageName() }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">校验卡数</text>
<text class="biz-row__value">{{ prepare.quantity || selectedCardValues.length }} 张</text>
</view>
<view class="biz-row">
<text class="biz-row__label">单年价格</text>
<text class="biz-row__value">{{ formatMoney(prepare.unitPrice) }}</text>
</view>
</view>
</view>
<view v-if="order" class="biz-card">
<view class="biz-card__body">
<view class="biz-section-head mb-8rpx">
<text class="biz-section-title">办理结果</text>
<wd-tag type="success" variant="plain">
已支付
</wd-tag>
</view>
<view class="biz-row">
<text class="biz-row__label">订单号</text>
<text class="biz-row__value mono">{{ order.orderSn || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">续费批次</text>
<text class="biz-row__value mono">{{ order.batchNo || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">订单金额</text>
<text class="biz-row__value">{{ order.priceText || formatMoney(order.totalAmount) }}</text>
</view>
</view>
</view>
<wd-popup v-model="optionPopupVisible" position="bottom" round safe-area-inset-bottom>
<view class="h-84vh max-h-84vh flex flex-col p-24rpx">
<view class="shrink-0">
<view class="text-32rpx text-#111827 font-900">
选择续费档位
</view>
<view class="mt-8rpx text-24rpx text-#64748b">
{{ prepare?.quantity || selectedCardValues.length }} 张卡统一续费
</view>
</view>
<scroll-view scroll-y class="mt-20rpx min-h-0 flex-1">
<view class="grid gap-16rpx">
<view>
<view v-if="renewablePackages.length" class="app-section">
<view class="app-section__head">
<text class="biz-step">1</text>
<view class="app-title-bar" />
<text class="app-section__title">选择续费套餐</text>
</view>
<view class="biz-option-list">
<view
v-for="(item, index) in renewablePackages"
:key="renewPackageCode(item) || index"
class="biz-option"
:class="{ 'is-active': index === selectedPackageIndex }"
@click="selectPackage(index)"
>
<view class="biz-option__main">
<view class="flex items-center gap-10rpx">
<text class="biz-option__title">
{{ item.packageName || item.package?.packageName || renewPackageCode(item) || '-' }}
</text>
<wd-tag
v-if="index === selectedPackageIndex"
type="primary"
size="small"
variant="plain"
>
已选
</wd-tag>
</view>
<text class="biz-option__meta">
{{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }} · {{ item.options?.length || item.renewableDurationOptions?.length || 0 }} 个可续费年限
</text>
</view>
<text class="biz-option__price">{{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }}</text>
<view class="biz-radio" />
</view>
</view>
</view>
<view v-else class="p-32rpx text-center text-26rpx text-#64748b">
当前套餐暂无可续费套餐
</view>
</view>
<view>
<view v-if="selectedOptions.length" class="app-section">
<view class="app-section__head">
<text class="biz-step">2</text>
<view class="app-title-bar" />
<text class="app-section__title">选择续费年限</text>
</view>
<view class="biz-option-list">
<view
v-for="(item, index) in selectedOptions"
:key="`${selectedTargetPackageCode()}_${item.durationMonths}`"
class="biz-option"
:class="{ 'is-active': index === selectedIndex }"
@click="selectOption(index)"
>
<view class="biz-option__main">
<view class="flex items-center gap-10rpx">
<text class="biz-option__title">{{ item.label }}</text>
<wd-tag
v-if="item.recommended || index === prepare.recommendIndex"
type="primary"
size="small"
variant="plain"
>
推荐
</wd-tag>
</view>
<text class="biz-option__meta">
{{ optionTag(item, index) }} · 到期 {{ formatDateTime(item.expireAfter) }}
</text>
</view>
<text class="biz-option__price">{{ item.priceText || formatMoney(item.totalAmount) }}</text>
<view class="biz-radio" />
</view>
</view>
</view>
<view v-else class="p-32rpx text-center text-26rpx text-#64748b">
当前续费套餐暂无可续费年限
</view>
</view>
</view>
</scroll-view>
<view v-if="prepare && selectedOption" class="biz-card mt-20rpx">
<view class="biz-card__body">
<view class="biz-row">
<text class="biz-row__label">当前套餐</text>
<text class="biz-row__value">{{ currentPackageName() }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">续费套餐</text>
<text class="biz-row__value">{{ packageName() }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">续费卡数</text>
<text class="biz-row__value">{{ prepare.quantity || selectedCardValues.length }} 张</text>
</view>
<view class="biz-row">
<text class="biz-row__label">生效规则</text>
<text class="biz-row__value">{{ prepare.effectiveRule }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">应付金额</text>
<text class="biz-row__value">{{ amountText }}</text>
</view>
</view>
</view>
<view class="biz-paybar-actions mt-24rpx shrink-0">
<wd-button type="primary" plain block custom-class="biz-paybar-btn" @click="optionPopupVisible = false">
取消
</wd-button>
<wd-button
type="primary"
block
custom-class="biz-paybar-btn"
:loading="submitting"
:disabled="!canSubmit"
@click="handleSubmit"
>
确认续费
</wd-button>
</view>
</view>
</wd-popup>
</view>
</view>
</scroll-view>
<view v-if="!optionPopupVisible" class="biz-paybar">
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">已选 {{ selectedCardValues.length }} 张</text>
<text class="biz-paybar__value">{{ selectedOption ? `应付 ${amountText}` : '先校验可续费档位' }}</text>
</view>
<view class="biz-paybar-actions">
<wd-button
type="primary"
plain
block
custom-class="biz-paybar-btn"
:loading="preparing"
:disabled="!selectedCardValues.length || preparing"
@click="handlePrepare"
>
校验档位
</wd-button>
<wd-button
block
type="primary"
custom-class="biz-paybar-btn"
:loading="submitting"
:disabled="!canSubmit"
@click="handleSubmit"
>
确认续费 {{ selectedOption ? amountText : '' }}
</wd-button>
</view>
</view>
</view>
</template>
@@ -0,0 +1,699 @@
<script lang="ts" setup>
import type { AppCard } from '@/api/card'
import type { WarningConfigBatchItemParams } from '../api/warning'
import { getCardList } from '@/api/card'
import { bindWechat, getWxCode } from '@/api/login'
import {
getWarningConfigMeta,
saveWarningConfigBatch,
updateWarningConfigNotifyBatch,
} from '../api/warning'
import {
displayCardValue,
displayPackageName,
formatFlowPair,
} from '@/pages/card/data'
import { useUserStore } from '@/store/user'
import { guideWechatSubscribeSettings, requestWechatSubscribeMessage, shouldGuideWechatSubscribeSettings, wechatSubscribeSaveMessage } from '@/utils'
definePage({
style: {
navigationBarTitleText: '预警配置',
},
})
interface WarningForm {
warnUnit: number
title: string
desc: string
unitText: string
icon: string
iconClass: string
firstPlaceholder: string
secondPlaceholder: string
thirdPlaceholder: string
enabled: boolean
firstWarn: string | number
secondWarn: string | number
thirdWarn: string | number
}
const MAX_BATCH_CARD_COUNT = 100
const userStore = useUserStore()
const keyword = ref('')
const cards = ref<AppCard[]>([])
const selectedCardValues = ref<string[]>([])
const loadingCards = ref(false)
const submitting = ref(false)
const notificationSubmitting = ref(false)
const binding = ref(false)
const errorMessage = ref('')
const templateId = ref('')
const templateReady = ref(false)
const wechatBound = ref(false)
const wechatBindText = ref('未绑定')
const appNotifyEnabled = ref(false)
const showAllCards = ref(false)
const cardPreviewLimit = 6
const forms = reactive<WarningForm[]>([
{
warnUnit: 1,
title: '流量使用预警',
desc: '按已用流量 MB 提醒,适合控制超量风险。',
unitText: 'MB',
icon: 'i-carbon-warning-alt',
iconClass: 'bg-#eff6ff text-#2563eb',
firstPlaceholder: '已用流量达到多少 MB 时第一次提醒',
secondPlaceholder: '已用流量达到多少 MB 时第二次提醒',
thirdPlaceholder: '已用流量达到多少 MB 时第三次提醒',
enabled: true,
firstWarn: '',
secondWarn: '',
thirdWarn: '',
},
])
const visibleCards = computed(() => {
const value = keyword.value.trim()
if (!value)
return cards.value
return cards.value.filter((item) => {
return (
cardKey(item).includes(value)
|| (item.msisdn || '').includes(value)
|| displayPackageName(item).includes(value)
|| (item.remark || '').includes(value)
|| (item.terminalName || '').includes(value)
)
})
})
const filledConfigCount = computed(() => forms.filter(hasThresholdValue).length)
const displayCards = computed(() => showAllCards.value ? visibleCards.value : visibleCards.value.slice(0, cardPreviewLimit))
const hiddenCardCount = computed(() => Math.max(0, visibleCards.value.length - displayCards.value.length))
const canSubmit = computed(() =>
selectedCardValues.value.length > 0 && filledConfigCount.value > 0 && !submitting.value,
)
const subscribeText = computed(() => {
if (!appNotifyEnabled.value)
return '提醒已关闭'
if (!templateReady.value)
return '模板未配置'
return wechatBound.value ? '已绑定微信' : '待绑定微信'
})
const notificationDescription = computed(() =>
appNotifyEnabled.value
? '本次保存会为选中卡片开启小程序预警提醒。'
: '本次保存会关闭小程序预警提醒,不影响其他通知渠道。',
)
const notificationSummary = computed(() =>
appNotifyEnabled.value ? (wechatBound.value ? wechatBindText.value : '未绑定微信') : '小程序提醒已关闭',
)
const submitButtonText = computed(() => (appNotifyEnabled.value ? '保存并订阅提醒' : '保存预警配置'))
const selectedText = computed(() => `${selectedCardValues.value.length}/${MAX_BATCH_CARD_COUNT}`)
async function loadCards() {
loadingCards.value = true
errorMessage.value = ''
try {
const res = await getCardList({
page: 1,
pageSize: MAX_BATCH_CARD_COUNT,
cardValue: keyword.value.trim() || undefined,
})
cards.value = res.items || []
const nextKeys = new Set(cards.value.map(cardKey))
selectedCardValues.value = selectedCardValues.value.filter(value => nextKeys.has(value))
}
catch (error) {
cards.value = []
selectedCardValues.value = []
errorMessage.value = getErrorMessage(error, '卡片列表加载失败')
}
finally {
loadingCards.value = false
}
}
async function loadMeta() {
try {
const res = await getWarningConfigMeta()
templateId.value = res.templateId || ''
templateReady.value = Boolean(res.templateReady)
wechatBound.value = Boolean(res.wechatBound)
wechatBindText.value = res.wechatBindText || (res.wechatBound ? '已绑定' : '未绑定')
appNotifyEnabled.value = res.appNotifyEnabled === 1
}
catch {
templateReady.value = false
}
}
function cardKey(item: AppCard) {
return displayCardValue(item)
}
function isSelected(item: AppCard) {
return selectedCardValues.value.includes(cardKey(item))
}
function clearSelection() {
selectedCardValues.value = []
}
function selectVisibleCards() {
const selected = new Set(selectedCardValues.value)
for (const item of visibleCards.value) {
const value = cardKey(item)
if (!value || value === '-')
continue
if (selected.size >= MAX_BATCH_CARD_COUNT)
break
selected.add(value)
}
selectedCardValues.value = Array.from(selected)
if (visibleCards.value.length > selected.size)
uni.showToast({ title: `单次最多选择 ${MAX_BATCH_CARD_COUNT} 张卡`, icon: 'none' })
}
function toggleAllCards() {
showAllCards.value = !showAllCards.value
}
function thresholdText(value: unknown) {
return String(value ?? '').trim()
}
function thresholdNumber(value: unknown) {
const text = thresholdText(value)
return text ? Number(text) : 0
}
function hasThresholdValue(form: WarningForm) {
return Boolean(thresholdText(form.firstWarn) || thresholdText(form.secondWarn) || thresholdText(form.thirdWarn))
}
function buildConfigPayload(validate: boolean): WarningConfigBatchItemParams[] {
const configs: WarningConfigBatchItemParams[] = []
for (const form of forms) {
if (!hasThresholdValue(form))
continue
const firstWarn = thresholdNumber(form.firstWarn)
const secondWarn = thresholdNumber(form.secondWarn)
const thirdWarn = thresholdNumber(form.thirdWarn)
const message = validateThreshold(form, firstWarn, secondWarn, thirdWarn)
if (message) {
if (validate)
uni.showToast({ title: message, icon: 'none' })
return []
}
configs.push({
warnUnit: form.warnUnit,
firstWarn,
secondWarn,
thirdWarn,
isEnabled: form.enabled ? 1 : 0,
appNotifyEnabled: appNotifyEnabled.value ? 1 : 0,
})
}
if (validate && !configs.length)
uni.showToast({ title: '请至少填写一项预警配置', icon: 'none' })
return configs
}
function validateThreshold(form: WarningForm, firstWarn: number, secondWarn: number, thirdWarn: number) {
if (!Number.isFinite(firstWarn) || firstWarn <= 0)
return `${form.title}请填写第一次提醒值`
if (!Number.isFinite(secondWarn) || !Number.isFinite(thirdWarn) || secondWarn < 0 || thirdWarn < 0)
return `${form.title}阈值格式不正确`
if (secondWarn > 0 && secondWarn <= firstWarn)
return '流量第二次提醒值必须大于第一次提醒值'
if (thirdWarn > 0 && (!secondWarn || thirdWarn <= secondWarn))
return '流量第三次提醒值必须大于第二次提醒值'
return ''
}
async function syncBatchNotify() {
if (!selectedCardValues.value.length) {
uni.showToast({ title: '请先选择要同步的卡片', icon: 'none' })
return
}
if (notificationSubmitting.value)
return
notificationSubmitting.value = true
try {
const subscribeResult = appNotifyEnabled.value
? await requestWechatSubscribeMessage(templateId.value, templateReady.value)
: undefined
const res = await updateWarningConfigNotifyBatch({
cardValues: selectedCardValues.value,
warnUnit: 1,
appNotifyEnabled: appNotifyEnabled.value ? 1 : 0,
})
const lines = [
`成功卡数:${res.successCount || 0} 张`,
`失败卡数:${res.failCount || 0} 张`,
]
if (res.failItems?.length) {
lines.push('')
lines.push(...res.failItems.slice(0, 3).map(item => `${item.cardValue}:${item.reason}`))
}
const shouldGuideSettings = Boolean(subscribeResult && shouldGuideWechatSubscribeSettings(subscribeResult))
const subscribeMessage = !shouldGuideSettings && subscribeResult
? wechatSubscribeSaveMessage(subscribeResult)
: ''
if (subscribeMessage) {
lines.push('')
lines.push(subscribeMessage)
}
uni.showModal({
title: res.failCount > 0 ? '部分开关已更新' : '通知开关已更新',
content: lines.join('\n'),
showCancel: false,
})
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '更新通知开关失败'), icon: 'none' })
}
finally {
notificationSubmitting.value = false
}
}
async function handleBindWechat() {
// #ifndef MP-WEIXIN
uni.showToast({ title: '请在微信小程序中绑定', icon: 'none' })
return
// #endif
// #ifdef MP-WEIXIN
if (binding.value)
return
binding.value = true
try {
const wxRes = await getWxCode()
if (!wxRes.code)
throw new Error('获取微信登录凭证失败')
const res = await bindWechat({ wxCode: wxRes.code })
if (res.userInfo)
userStore.setUserInfo(res.userInfo)
wechatBound.value = true
wechatBindText.value = '已绑定'
uni.showToast({ title: '微信绑定成功', icon: 'success' })
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '微信绑定失败'), icon: 'none' })
}
finally {
binding.value = false
}
// #endif
}
async function handleSubmit() {
if (!selectedCardValues.value.length) {
uni.showToast({ title: '请先选择卡片', icon: 'none' })
return
}
const configs = buildConfigPayload(true)
if (!configs.length || submitting.value)
return
submitting.value = true
try {
const subscribeResult = appNotifyEnabled.value
? await requestWechatSubscribeMessage(templateId.value, templateReady.value)
: undefined
const res = await saveWarningConfigBatch({
cardValues: selectedCardValues.value,
configs,
})
wechatBound.value = Boolean(res.wechatBound)
wechatBindText.value = res.wechatBindText || (res.wechatBound ? '已绑定' : '未绑定')
const shouldGuideSettings = Boolean(subscribeResult && shouldGuideWechatSubscribeSettings(subscribeResult))
uni.showModal({
title: res.failCount > 0 ? '部分配置成功' : '配置成功',
content: buildResultText(res, subscribeResult),
showCancel: false,
success: () => {
if (shouldGuideSettings)
guideWechatSubscribeSettings(subscribeResult!)
},
})
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '批量保存失败'), icon: 'none' })
}
finally {
submitting.value = false
}
}
function buildResultText(res: {
batchNo?: string
successCount?: number
failCount?: number
failItems?: Array<{ cardValue: string, reason: string }>
}, subscribeResult?: Awaited<ReturnType<typeof requestWechatSubscribeMessage>>) {
const lines = [
`批次号:${res.batchNo || '-'}`,
`成功卡数:${res.successCount || 0} 张`,
`失败卡数:${res.failCount || 0} 张`,
]
const failItems = res.failItems || []
if (failItems.length) {
lines.push('')
lines.push(...failItems.slice(0, 3).map(item => `${item.cardValue}:${item.reason}`))
}
const subscribeMessage = subscribeResult && !shouldGuideWechatSubscribeSettings(subscribeResult)
? wechatSubscribeSaveMessage(subscribeResult)
: ''
if (subscribeMessage) {
lines.push('')
lines.push(subscribeMessage)
}
return lines.join('\n')
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
if (error instanceof Error && error.message)
return error.message
return fallback
}
watch(selectedCardValues, (values) => {
if (values.length <= MAX_BATCH_CARD_COUNT)
return
selectedCardValues.value = values.slice(0, MAX_BATCH_CARD_COUNT)
uni.showToast({ title: `单次最多选择 ${MAX_BATCH_CARD_COUNT} 张卡`, icon: 'none' })
})
onMounted(() => {
void loadCards()
void loadMeta()
})
</script>
<template>
<view class="biz-page">
<view class="biz-stack">
<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-warning-alt" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">流量预警</text>
<text class="biz-hero__sub">选择卡片后统一配置微信订阅提醒与流量阈值</text>
</view>
</view>
<wd-tag :type="templateReady ? 'primary' : 'warning'" variant="plain">
{{ subscribeText }}
</wd-tag>
</view>
<view class="biz-stat-grid">
<view class="biz-stat">
<text class="biz-stat__label">已选卡片</text>
<text class="biz-stat__value">{{ selectedText }} 张</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">提醒方式</text>
<text class="biz-stat__value">{{ appNotifyEnabled ? '微信订阅消息' : '暂不提醒' }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">微信绑定</text>
<text class="biz-stat__value">{{ wechatBindText }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">订阅状态</text>
<text class="biz-stat__value">{{ appNotifyEnabled ? '开启' : '关闭' }}</text>
</view>
</view>
</view>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-24rpx">
<view class="flex items-start justify-between gap-20rpx">
<view class="min-w-0 flex items-start gap-16rpx">
<view class="h-56rpx w-56rpx center shrink-0 rounded-18rpx bg-#eff6ff text-#2563eb">
<view class="i-carbon-notification text-32rpx" />
</view>
<view class="min-w-0">
<text class="block text-29rpx text-ink font-800">小程序消息订阅</text>
<text class="mt-6rpx block text-23rpx text-muted leading-34rpx">
{{ notificationDescription }}
</text>
</view>
</view>
<wd-switch v-model="appNotifyEnabled" :disabled="notificationSubmitting" />
</view>
<view class="grid grid-cols-2 mt-20rpx gap-12rpx">
<view class="rounded-16rpx bg-#f8fafc p-16rpx">
<text class="block text-22rpx text-muted">通知范围</text>
<text class="mt-6rpx block text-24rpx text-ink font-700">消息中心 + 微信提醒</text>
</view>
<view class="rounded-16rpx bg-#f8fafc p-16rpx">
<text class="block text-22rpx text-muted">其他通知</text>
<text class="mt-6rpx block text-24rpx text-ink font-700">不受影响</text>
</view>
</view>
<view v-if="appNotifyEnabled && !wechatBound" class="mt-18rpx">
<wd-button type="primary" plain block :loading="binding" @click="handleBindWechat">
绑定当前微信
</wd-button>
</view>
<wd-button
class="mt-16rpx"
size="small"
type="primary"
plain
block
custom-class="biz-mini-action-btn"
:loading="notificationSubmitting"
:disabled="!selectedCardValues.length"
@click="syncBatchNotify"
>
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-checkmark-outline biz-mini-action-btn__icon biz-mini-action-btn__icon--blue" />
<text class="biz-mini-action-btn__text">应用到已选卡片</text>
</view>
</wd-button>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<template #title>
<view class="flex items-center justify-between gap-20rpx py-4rpx">
<view class="min-w-0">
<text class="block text-30rpx text-ink font-800">预警阈值</text>
<text class="mt-4rpx block text-23rpx text-muted">
第一次提醒必填,第二次和第三次可按需填写
</text>
</view>
<wd-tag type="primary" variant="plain">
单卡批量
</wd-tag>
</view>
</template>
<view
v-for="(form, index) in forms"
:key="form.warnUnit"
class="px-24rpx py-24rpx"
:class="index ? 'border-t border-line border-solid' : ''"
>
<view class="flex items-start gap-18rpx">
<view
class="h-56rpx w-56rpx center shrink-0 rounded-18rpx"
:class="form.iconClass"
>
<view class="text-32rpx" :class="form.icon" />
</view>
<view class="min-w-0 flex-1">
<view class="flex items-center justify-between gap-16rpx">
<text class="text-29rpx text-ink font-800">{{ form.title }}</text>
<wd-switch v-model="form.enabled" />
</view>
<text class="mt-6rpx block text-23rpx text-muted leading-32rpx">
{{ form.desc }}
</text>
</view>
</view>
<view class="mt-18rpx overflow-hidden rounded-20rpx bg-#f8fafc">
<wd-cell-group :border="false">
<wd-input
v-model="form.firstWarn"
label="第一次提醒"
label-width="90px"
type="digit"
:disabled="!form.enabled"
:placeholder="form.firstPlaceholder"
clearable
>
<template #suffix>
<text class="text-24rpx text-muted">{{ form.unitText }}</text>
</template>
</wd-input>
<wd-input
v-model="form.secondWarn"
label="第二次提醒"
label-width="90px"
type="digit"
:disabled="!form.enabled"
:placeholder="form.secondPlaceholder"
clearable
>
<template #suffix>
<text class="text-24rpx text-muted">{{ form.unitText }}</text>
</template>
</wd-input>
<wd-input
v-model="form.thirdWarn"
label="第三次提醒"
label-width="90px"
type="digit"
:disabled="!form.enabled"
:placeholder="form.thirdPlaceholder"
clearable
>
<template #suffix>
<text class="text-24rpx text-muted">{{ form.unitText }}</text>
</template>
</wd-input>
</wd-cell-group>
</view>
</view>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<view class="p-18rpx">
<wd-input
v-model="keyword"
placeholder="搜索卡号、套餐、备注"
clearable
@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>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-20rpx">
<view class="flex items-center justify-between gap-18rpx">
<view class="min-w-0">
<text class="block text-30rpx text-ink font-900">选择卡片</text>
<text class="mt-4rpx block text-23rpx text-muted">
最多选择 100 张统一配置
</text>
</view>
</view>
</wd-card>
<wd-card v-if="loadingCards" custom-class="!mx-0 !mb-0">
<wd-skeleton theme="paragraph" />
</wd-card>
<wd-card v-else-if="errorMessage" custom-class="!mx-0 !mb-0">
<view class="grid gap-16rpx">
<text class="text-26rpx text-#b91c1c leading-38rpx">{{ errorMessage }}</text>
<wd-button size="small" type="primary" @click="loadCards">
重试
</wd-button>
</view>
</wd-card>
<wd-card v-else custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<view v-if="visibleCards.length" class="p-16rpx">
<wd-checkbox-group v-model="selectedCardValues" type="square" checked-color="#2563eb">
<view class="grid gap-12rpx">
<view
v-for="item in displayCards"
:key="cardKey(item)"
class="border rounded-8rpx border-solid p-16rpx"
:class="isSelected(item) ? 'border-#2563eb bg-#eff6ff' : 'border-#e5e7eb bg-#f8fafc'"
>
<wd-checkbox :name="cardKey(item)" custom-label-class="!block !w-full">
<view class="min-w-0 pr-16rpx">
<text class="block truncate text-30rpx text-ink font-900">{{ cardKey(item) }}</text>
<text class="mt-8rpx block truncate text-24rpx text-muted">{{ displayPackageName(item) }}</text>
<text class="mt-8rpx block text-23rpx text-muted">
{{ formatFlowPair(item) }}
</text>
</view>
</wd-checkbox>
</view>
</view>
</wd-checkbox-group>
<view v-if="hiddenCardCount" class="mt-18rpx">
<wd-button type="primary" plain block size="small" custom-class="biz-mini-action-btn" @click="toggleAllCards">
展开全部 {{ visibleCards.length }} 张卡
</wd-button>
</view>
<view v-else-if="showAllCards && visibleCards.length > cardPreviewLimit" class="mt-18rpx">
<wd-button type="primary" plain block size="small" custom-class="biz-mini-action-btn" @click="toggleAllCards">
收起列表
</wd-button>
</view>
</view>
<view v-else class="p-32rpx text-center text-26rpx text-muted">
暂无可配置卡片
</view>
</wd-card>
</view>
</view>
<view class="biz-paybar">
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">已选 {{ selectedCardValues.length }} 张</text>
<text class="biz-paybar__value">{{ notificationSummary }}</text>
</view>
<wd-button type="primary" block custom-class="biz-paybar-btn" :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
{{ submitButtonText }}
</wd-button>
</view>
</view>
</template>
@@ -0,0 +1,204 @@
<script lang="ts" setup>
import type { AppWorkOrder } from '../api/workOrder'
import { getWorkOrderList } from '../api/workOrder'
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
import { hasAnyFeature } from '@/utils/permission'
definePage({
style: {
navigationBarTitleText: '工单列表',
},
})
const userStore = useUserStore()
const loading = ref(false)
const noPermission = ref(false)
const keyword = ref('')
const workOrders = ref<AppWorkOrder[]>([])
const total = ref(0)
const canViewWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:list'))
const canCreateWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:create'))
const canViewWorkOrderDetail = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:detail'))
const canEnterWorkOrder = computed(() => canViewWorkOrder.value || canCreateWorkOrder.value)
async function loadWorkOrders() {
if (!canViewWorkOrder.value) {
noPermission.value = !canCreateWorkOrder.value
workOrders.value = []
total.value = 0
return
}
loading.value = true
noPermission.value = false
try {
const res = await getWorkOrderList({
page: 1,
pageSize: 50,
keyword: keyword.value.trim() || undefined,
})
workOrders.value = res.items || []
total.value = res.total || 0
}
catch (error) {
workOrders.value = []
total.value = 0
uni.showToast({ title: getErrorMessage(error, '工单加载失败'), icon: 'none' })
}
finally {
loading.value = false
}
}
function handleCreate() {
if (!canCreateWorkOrder.value) {
uni.showToast({ title: '暂无新增工单权限', icon: 'none' })
return
}
uni.navigateTo({ url: RouterPath.BusinessViewsWorkOrderCreate })
}
function handleDetail(item: AppWorkOrder) {
if (!canViewWorkOrderDetail.value) {
uni.showToast({ title: '暂无工单详情权限', icon: 'none' })
return
}
uni.navigateTo({
url: `${RouterPath.BusinessViewsWorkOrderDetail}?woNo=${encodeURIComponent(item.woNo)}`,
})
}
function workOrderTime(item: AppWorkOrder) {
return formatTime(item.createdAt) || '-'
}
function tagType(type?: string) {
if (type === 'success')
return 'success'
if (type === 'danger')
return 'danger'
if (type === 'primary')
return 'primary'
return 'warning'
}
function formatTime(value?: string) {
if (!value)
return ''
return String(value).replace('T', ' ').slice(0, 16)
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
if (error instanceof Error && error.message)
return error.message
return fallback
}
onMounted(async () => {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canEnterWorkOrder.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能使用工单', icon: 'none' })
return
}
void loadWorkOrders()
})
</script>
<template>
<view class="min-h-screen p-24rpx pb-40rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
<view class="text-26rpx text-#64748b leading-38rpx">
当前账号不能使用工单。
</view>
</wd-card>
<template v-else>
<wd-card custom-class="!mx-0 !mb-0">
<view class="flex items-center justify-between gap-20rpx">
<view class="min-w-0">
<view class="text-34rpx text-#111827 font-900">
我的工单
</view>
<view class="mt-8rpx text-24rpx text-#64748b">
{{ canViewWorkOrder ? `共 ${total} 条,按最新提交时间排序` : '当前账号仅可提交新工单' }}
</view>
</view>
<wd-button size="small" type="primary" :disabled="!canCreateWorkOrder" @click="handleCreate">
新增
</wd-button>
</view>
</wd-card>
<wd-card v-if="!canViewWorkOrder && canCreateWorkOrder" custom-class="!mx-0 !mb-0">
<view class="grid gap-18rpx text-center">
<view class="mx-auto h-72rpx w-72rpx center rounded-24rpx bg-#eff6ff text-#2563eb">
<view class="i-carbon-chat text-42rpx" />
</view>
<view class="text-28rpx text-#111827 font-800">
可以提交新工单
</view>
<view class="text-24rpx text-#64748b leading-36rpx">
当前角色未配置工单列表权限,提交后可由平台客服继续处理。
</view>
<wd-button type="primary" block @click="handleCreate">
新增工单
</wd-button>
</view>
</wd-card>
<wd-card v-if="canViewWorkOrder" custom-class="!mx-0 !mb-0" custom-content-class="!p-18rpx">
<wd-search
v-model="keyword"
placeholder="搜索工单编号、标题或内容"
hide-cancel
@search="loadWorkOrders"
@clear="loadWorkOrders"
/>
</wd-card>
<wd-card v-if="canViewWorkOrder && loading" custom-class="!mx-0 !mb-0">
<wd-skeleton theme="paragraph" />
</wd-card>
<wd-card v-else-if="canViewWorkOrder && !workOrders.length" custom-class="!mx-0 !mb-0">
<view class="py-40rpx text-center text-26rpx text-#64748b">
暂无工单
</view>
</wd-card>
<wd-card v-else-if="canViewWorkOrder" custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<wd-cell-group :border="false">
<wd-cell
v-for="item in workOrders"
:key="item.woNo"
:title="item.title"
:label="`${item.woNo} · ${item.woTypeText || '工单'} · ${workOrderTime(item)}`"
:is-link="canViewWorkOrderDetail"
:clickable="canViewWorkOrderDetail"
center
custom-class="!min-h-132rpx"
custom-title-class="!text-28rpx !text-#111827 !font-800"
custom-label-class="!text-24rpx !text-#64748b"
@click="handleDetail(item)"
>
<wd-tag :type="tagType(item.statusType)" variant="plain">
{{ item.currentStatusText || '处理中' }}
</wd-tag>
</wd-cell>
</wd-cell-group>
</wd-card>
</template>
</view>
</view>
</template>
@@ -0,0 +1,293 @@
<script lang="ts" setup>
import { createWorkOrder } from '../api/workOrder'
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
import { useTokenStore } from '@/store/token'
import { hasAnyFeature } from '@/utils/permission'
import { getEnvBaseUrl } from '@/utils'
definePage({
style: {
navigationBarTitleText: '提交工单',
},
})
const problemTypes = [
{ label: '卡片问题', value: 0 },
{ label: '模组问题', value: 1 },
{ label: '设备问题', value: 2 },
{ label: '订单问题', value: 3 },
{ label: '余额问题', value: 4 },
{ label: '提现问题', value: 5 },
]
const userStore = useUserStore()
const noPermission = ref(false)
const submitting = ref(false)
const uploading = ref(false)
const attachmentUrls = ref<string[]>([])
const attachmentNames = ref<string[]>([])
const canCreateWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:create'))
const formData = reactive({
woType: [0],
title: '',
content: '',
})
const typeText = computed(() => {
const value = Number(formData.woType[0] ?? 0)
return problemTypes.find(item => item.value === value)?.label || '卡片问题'
})
function chooseAttachment() {
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (file)
void uploadAttachment(file, file.name)
}
input.click()
return
// #endif
const choose = (uni as any).chooseFile || (uni as any).chooseMessageFile
if (typeof choose !== 'function') {
uni.showToast({ title: '当前平台暂不支持选择附件', icon: 'none' })
return
}
choose({
count: 1,
type: 'all',
success: (res: any) => {
const file = res.tempFiles?.[0] || res.tempFilePaths?.[0]
const filePath = file?.path || file?.tempFilePath || file
const fileName = file?.name || String(filePath || '').split('/').pop() || '附件'
if (filePath)
void uploadAttachment(filePath, fileName)
},
})
}
async function uploadAttachment(filePath: string | File, fileName: string) {
uploading.value = true
try {
if (typeof filePath !== 'string') {
const token = useTokenStore().updateNowTime().validToken
const form = new FormData()
form.append('file', filePath, fileName)
form.append('fileType', 'file')
const response = await fetch(`${getEnvBaseUrl()}/work-order/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: form,
})
const body = await response.json()
if (!response.ok || Number(body.code) !== 0)
throw new Error(body.message || body.msg || '附件上传失败')
const data = body.data || body
const url = data.url || data.path
if (!url)
throw new Error('上传接口未返回文件地址')
attachmentUrls.value.push(url)
attachmentNames.value.push(fileName)
return
}
await new Promise<void>((resolve, reject) => {
uni.uploadFile({
url: `${getEnvBaseUrl()}/work-order/upload`,
filePath,
name: 'file',
formData: { fileType: 'file' },
success: (res) => {
try {
const body = JSON.parse(res.data)
const data = body.data || body
const url = data.url || data.path
if (!url)
throw new Error('上传接口未返回文件地址')
attachmentUrls.value.push(url)
attachmentNames.value.push(fileName)
resolve()
}
catch (error) {
reject(error)
}
},
fail: reject,
})
})
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '附件上传失败'), icon: 'none' })
}
finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachmentUrls.value.splice(index, 1)
attachmentNames.value.splice(index, 1)
}
async function handleSubmit() {
if (!canCreateWorkOrder.value) {
uni.showToast({ title: '暂无提交工单权限', icon: 'none' })
return
}
const title = formData.title.trim()
const content = formData.content.trim()
if (!title) {
uni.showToast({ title: '请输入工单标题', icon: 'none' })
return
}
if (!content) {
uni.showToast({ title: '请输入问题描述', icon: 'none' })
return
}
submitting.value = true
try {
const res = await createWorkOrder({
woType: Number(formData.woType[0] ?? 0),
title,
content,
attachmentUrls: attachmentUrls.value,
})
const woNo = res.workOrder?.woNo || ''
uni.showToast({ title: '工单已提交', icon: 'success' })
setTimeout(() => {
if (woNo) {
uni.redirectTo({
url: `${RouterPath.BusinessViewsWorkOrderDetail}?woNo=${encodeURIComponent(woNo)}`,
})
}
else {
uni.navigateBack()
}
}, 500)
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '工单提交失败'), icon: 'none' })
}
finally {
submitting.value = false
}
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
if (error instanceof Error && error.message)
return error.message
return fallback
}
onMounted(async () => {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canCreateWorkOrder.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能提交工单', icon: 'none' })
}
})
</script>
<template>
<view class="min-h-screen p-24rpx pb-40rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
<view class="text-26rpx text-#64748b leading-38rpx">
当前账号不能提交工单。
</view>
</wd-card>
<template v-else>
<wd-card custom-class="!mx-0 !mb-0">
<view class="flex items-start gap-16rpx">
<view class="h-64rpx w-64rpx center shrink-0 rounded-20rpx bg-#eff6ff text-#2563eb">
<view class="i-carbon-chat text-36rpx" />
</view>
<view class="min-w-0 flex-1">
<text class="block text-34rpx text-#111827 font-900">提交工单</text>
<text class="mt-8rpx block text-24rpx text-#64748b leading-36rpx">
工单会分派到平台服务部门处理
</text>
</view>
</view>
</wd-card>
<wd-form :model="formData" error-type="toast">
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<template #title>
<view class="h-88rpx flex items-center justify-between">
<text class="text-30rpx text-#111827 font-900">基础信息</text>
<wd-tag type="primary" variant="plain">
{{ typeText }}
</wd-tag>
</view>
</template>
<wd-cell-group :border="false">
<wd-picker
v-model="formData.woType"
label="问题类型"
label-width="96px"
:columns="problemTypes"
value-key="value"
label-key="label"
/>
<wd-input
v-model="formData.title"
label="工单标题"
label-width="96px"
placeholder="请输入简短标题"
clearable
/>
</wd-cell-group>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0">
<template #title>
<view class="h-88rpx flex items-center">
<text class="text-30rpx text-#111827 font-900">问题描述</text>
</view>
</template>
<wd-textarea
v-model="formData.content"
placeholder="请描述问题现象、发生时间和已尝试的处理方式"
:maxlength="500"
show-word-limit
/>
<view class="mt-20rpx flex items-center justify-between">
<text class="text-24rpx text-#64748b">附件(可选)</text>
<wd-button size="small" type="info" :loading="uploading" @click="chooseAttachment">选择附件</wd-button>
</view>
<view v-if="attachmentNames.length" class="mt-16rpx flex flex-col gap-12rpx">
<view
v-for="(name, index) in attachmentNames"
:key="name + index"
class="flex items-center justify-between rounded-12rpx bg-#f8fafc px-16rpx py-12rpx"
>
<text class="min-w-0 flex-1 truncate text-24rpx text-#334155">{{ name }}</text>
<wd-button size="small" type="info" plain @click="removeAttachment(index)">移除</wd-button>
</view>
</view>
</wd-card>
</wd-form>
<wd-button block type="primary" :loading="submitting" :disabled="!canCreateWorkOrder || uploading" @click="handleSubmit">
提交工单
</wd-button>
</template>
</view>
</view>
</template>
@@ -0,0 +1,222 @@
<script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app'
import type { AppWorkOrder } from '../api/workOrder'
import { getWorkOrderDetail } from '../api/workOrder'
import { useUserStore } from '@/store/user'
import { hasAnyFeature } from '@/utils/permission'
import { getEnvBaseUrl } from '@/utils'
definePage({
style: {
navigationBarTitleText: '工单详情',
},
})
const userStore = useUserStore()
const noPermission = ref(false)
const loading = ref(false)
const woNo = ref('')
const workOrder = ref<AppWorkOrder>()
const canViewWorkOrderDetail = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:detail'))
const infoRows = computed(() => {
const item = workOrder.value
if (!item)
return []
return [
['工单编号', item.woNo],
['问题类型', item.woTypeText || '-'],
['当前状态', item.currentStatusText || '-'],
['提交人', item.creatorName || '-'],
['联系电话', item.creatorPhone || '-'],
['提交时间', formatTime(item.createdAt) || '-'],
]
})
async function loadDetail() {
if (!canViewWorkOrderDetail.value) {
noPermission.value = true
return
}
if (!woNo.value) {
uni.showToast({ title: '工单编号不能为空', icon: 'none' })
return
}
loading.value = true
noPermission.value = false
try {
const res = await getWorkOrderDetail({ woNo: woNo.value })
workOrder.value = res.workOrder
}
catch (error) {
workOrder.value = undefined
uni.showToast({ title: getErrorMessage(error, '工单详情加载失败'), icon: 'none' })
}
finally {
loading.value = false
}
}
function tagType(type?: string) {
if (type === 'success')
return 'success'
if (type === 'danger')
return 'danger'
if (type === 'primary')
return 'primary'
return 'warning'
}
function normalizeAttachmentUrl(value: string) {
const url = String(value || '').trim().replaceAll('\\', '/')
if (!url)
return ''
if (/^https?:\/\//i.test(url))
return url
const base = getEnvBaseUrl().replace(/\/api\/v1\/?$/, '')
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`
}
function openAttachment(value: string) {
const url = normalizeAttachmentUrl(value)
if (!url)
return
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifndef H5
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode === 200)
uni.openDocument({ filePath: res.tempFilePath })
},
})
// #endif
}
function formatTime(value?: string) {
if (!value)
return ''
return String(value).replace('T', ' ').slice(0, 16)
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
if (error instanceof Error && error.message)
return error.message
return fallback
}
onLoad((query: Record<string, string | undefined>) => {
woNo.value = query.woNo || query.no || ''
})
onMounted(async () => {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canViewWorkOrderDetail.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能查看工单详情', icon: 'none' })
return
}
void loadDetail()
})
</script>
<template>
<view class="min-h-screen p-24rpx pb-40rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
<view class="text-26rpx text-#64748b leading-38rpx">
当前账号不能查看工单详情。
</view>
</wd-card>
<wd-card v-else-if="loading" custom-class="!mx-0 !mb-0">
<wd-skeleton theme="paragraph" />
</wd-card>
<template v-else-if="workOrder">
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<template #title>
<view class="h-88rpx flex items-center justify-between gap-16rpx">
<text class="min-w-0 flex-1 truncate text-30rpx text-#111827 font-900">
{{ workOrder.title }}
</text>
<wd-tag :type="tagType(workOrder.statusType)" variant="plain">
{{ workOrder.currentStatusText || '处理中' }}
</wd-tag>
</view>
</template>
<wd-cell-group :border="false">
<wd-cell
v-for="row in infoRows"
:key="row[0]"
:title="row[0]"
:value="row[1]"
center
/>
</wd-cell-group>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0">
<template #title>
<view class="h-88rpx flex items-center">
<text class="text-30rpx text-#111827 font-900">问题描述</text>
</view>
</template>
<view class="text-28rpx text-#334155 leading-44rpx">
{{ workOrder.content || '-' }}
</view>
</wd-card>
<wd-card custom-class="!mx-0 !mb-0">
<template #title>
<view class="h-88rpx flex items-center justify-between">
<text class="text-30rpx text-#111827 font-900">沟通记录</text>
<wd-tag type="primary" variant="plain">
{{ workOrder.communications?.length || 0 }} 条
</wd-tag>
</view>
</template>
<view v-if="workOrder.communications?.length" class="flex flex-col gap-16rpx">
<view
v-for="item in workOrder.communications"
:key="item.id"
class="rounded-12rpx bg-#f8fafc p-20rpx"
>
<view class="flex items-center justify-between gap-16rpx">
<view class="text-24rpx text-#2563eb font-800">
{{ item.senderName || item.commTypeText || '用户' }}
</view>
<view class="text-22rpx text-#94a3b8">
{{ formatTime(item.createdAt) }}
</view>
</view>
<view class="mt-8rpx text-28rpx text-#334155 leading-42rpx">
{{ item.content }}
</view>
</view>
</view>
<view v-else class="py-24rpx text-center text-26rpx text-#64748b">
暂无沟通记录
</view>
</wd-card>
</template>
<wd-card v-else custom-class="!mx-0 !mb-0">
<view class="py-40rpx text-center text-26rpx text-#64748b">
未查询到工单
</view>
</wd-card>
</view>
</view>
</template>