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:
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user