sync: 对齐小程序 v1.0.2 生产代码并脱敏
This commit is contained in:
@@ -3,6 +3,7 @@ import type { BusinessServiceItem, BusinessServiceSection, BusinessTone } from '
|
||||
|
||||
const props = defineProps<{
|
||||
section: BusinessServiceSection
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,12 +20,13 @@ const toneClass: Record<BusinessTone, string> = {
|
||||
}
|
||||
|
||||
function handleClick(item: BusinessServiceItem) {
|
||||
emit('action', item)
|
||||
if (!props.busy)
|
||||
emit('action', item)
|
||||
}
|
||||
|
||||
function visibleRoleTag(item: BusinessServiceItem) {
|
||||
if (item.agentOnly)
|
||||
return '代理商'
|
||||
return '企业 / 代理'
|
||||
if (item.terminalOnly)
|
||||
return '用户'
|
||||
if (item.terminalHidden)
|
||||
|
||||
@@ -21,6 +21,15 @@ const expectedSubViews = [
|
||||
]
|
||||
|
||||
describe('getUserBusinessSections', () => {
|
||||
it('按服务、资源管理和执行记录划分入口且不重复', () => {
|
||||
const sectionItems = (title: string) => businessSections.find(section => section.title === title)?.items.map(item => item.action)
|
||||
expect(sectionItems('服务记录')).toEqual(['order', 'workOrder'])
|
||||
expect(sectionItems('卡片与客户')).toEqual(['cardList', 'customer'])
|
||||
expect(sectionItems('执行记录')).toEqual(['executionLog'])
|
||||
const titles = businessSections.flatMap(section => section.items.map(item => item.title))
|
||||
expect(new Set(titles).size).toBe(titles.length)
|
||||
})
|
||||
|
||||
it('只保留用户侧一级业务入口', () => {
|
||||
const sections = getUserBusinessSections(businessSections)
|
||||
const titles = sections.flatMap(section => section.items.map(item => item.title))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IUserInfoRes } from '@/api/types/login'
|
||||
import type { AppPermissionAction } from '@/utils/permission'
|
||||
import { hasUserPermission } from '@/utils/permission'
|
||||
import { hasAccessCode, hasUserPermission } from '@/utils/permission'
|
||||
import { getAccountWorkspace, isTerminalUserInfo } from '@/utils/userIdentity'
|
||||
|
||||
export type BusinessTone = 'blue' | 'green' | 'orange' | 'primary' | 'purple' | 'red'
|
||||
@@ -18,11 +18,13 @@ export interface BusinessServiceItem {
|
||||
| 'cardList'
|
||||
| 'change'
|
||||
| 'customer'
|
||||
| 'executionLog'
|
||||
| 'diagnosis'
|
||||
| 'order'
|
||||
| 'renew'
|
||||
| 'warning'
|
||||
| 'workOrder'
|
||||
accessCode?: string
|
||||
agentOnly?: boolean
|
||||
terminalOnly?: boolean
|
||||
terminalHidden?: boolean
|
||||
@@ -113,7 +115,7 @@ export const businessSections: BusinessServiceSection[] = [
|
||||
},
|
||||
{
|
||||
title: '预警配置',
|
||||
desc: '流量阈值与微信订阅',
|
||||
desc: '流量阈值与站内消息提醒',
|
||||
tag: '预警',
|
||||
icon: 'i-carbon-warning-alt',
|
||||
tone: 'orange',
|
||||
@@ -134,7 +136,7 @@ export const businessSections: BusinessServiceSection[] = [
|
||||
},
|
||||
{
|
||||
title: '服务记录',
|
||||
desc: '订单与工单',
|
||||
desc: '订单查询与问题反馈',
|
||||
type: 'grid',
|
||||
items: [
|
||||
{
|
||||
@@ -154,6 +156,13 @@ export const businessSections: BusinessServiceSection[] = [
|
||||
action: 'workOrder',
|
||||
permissionAction: ['workOrder', 'workOrderCreate'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '卡片与客户',
|
||||
desc: '卡片信息与客户管理',
|
||||
type: 'list',
|
||||
items: [
|
||||
{
|
||||
title: '我的卡片',
|
||||
desc: '查看卡信息、套餐和流量',
|
||||
@@ -164,7 +173,7 @@ export const businessSections: BusinessServiceSection[] = [
|
||||
},
|
||||
{
|
||||
title: '客户管理',
|
||||
desc: '直属客户与客户详情',
|
||||
desc: '授权范围内的客户与详情',
|
||||
tag: '客户',
|
||||
icon: 'i-carbon-user-multiple',
|
||||
tone: 'green',
|
||||
@@ -174,6 +183,22 @@ export const businessSections: BusinessServiceSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '执行记录',
|
||||
desc: '批次进度与执行结果',
|
||||
type: 'list',
|
||||
items: [
|
||||
{
|
||||
title: '批次执行日志',
|
||||
desc: '查看批次进度与执行结果',
|
||||
tag: '日志',
|
||||
icon: 'i-carbon-document',
|
||||
tone: 'blue',
|
||||
action: 'executionLog',
|
||||
accessCode: 'cpm:common:batchExecutionLog:list',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** ==================== 方法 ==================== */
|
||||
@@ -191,6 +216,8 @@ export function getUserBusinessSections(
|
||||
.map(section => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => {
|
||||
if (item.accessCode && !hasAccessCode(userInfo?.accessCodes, item.accessCode))
|
||||
return false
|
||||
if (item.agentOnly && !isAgentUser)
|
||||
return false
|
||||
if (item.terminalOnly && !isTerminalUser)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { IUserInfoRes } from '@/api/types/login'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { ModuleKind, transpileModule } from 'typescript'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RouterPath from '@/constant/RouterPath'
|
||||
import * as release from '@/constant/release'
|
||||
import { customTabbarList } from '@/tabbar/config'
|
||||
import * as permission from '@/utils/permission'
|
||||
import * as data from './data'
|
||||
|
||||
function createHarness(userInfo: Partial<IUserInfoRes>) {
|
||||
const navigateTo = vi.fn((options: { complete?: () => void }) => options.complete?.())
|
||||
const switchTab = vi.fn((options: { complete?: () => void }) => options.complete?.())
|
||||
const fetchUserInfo = vi.fn(async () => userInfo)
|
||||
const tokenStore = { hasLogin: true, updateNowTime: () => tokenStore }
|
||||
const context = createContext({
|
||||
exports: {},
|
||||
require: (name: string) => {
|
||||
if (name === '@/constant/RouterPath')
|
||||
return { default: RouterPath }
|
||||
if (name === '@/constant/release')
|
||||
return release
|
||||
if (name === '@/utils/permission')
|
||||
return permission
|
||||
if (name === '@/store/user')
|
||||
return { useUserStore: () => ({ userInfo, fetchUserInfo }) }
|
||||
if (name === '@/store/token')
|
||||
return { useTokenStore: () => tokenStore }
|
||||
if (name === './data')
|
||||
return data
|
||||
if (name === './components/BusinessSection.vue')
|
||||
return {}
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
},
|
||||
ref: (value: unknown) => ({ value }),
|
||||
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
|
||||
definePage: () => undefined,
|
||||
onShow: () => undefined,
|
||||
uni: { navigateTo, switchTab, showToast: vi.fn() },
|
||||
Promise,
|
||||
})
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
|
||||
if (!script)
|
||||
throw new Error('Business page script not found')
|
||||
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
|
||||
return {
|
||||
titles: () => Array.from(runInContext('sections.value.flatMap(section => section.items.map(item => item.title))', context)) as string[],
|
||||
action: (title: string) => runInContext(`handleBusinessAction(sections.value.flatMap(section => section.items).find(item => item.title === ${JSON.stringify(title)}))`, context) as Promise<void>,
|
||||
fetchUserInfo,
|
||||
navigateTo,
|
||||
switchTab,
|
||||
}
|
||||
}
|
||||
|
||||
function visibleTitles(userInfo: Partial<IUserInfoRes>) {
|
||||
return createHarness(userInfo).titles()
|
||||
}
|
||||
|
||||
describe('办理入口发布范围', () => {
|
||||
it('沿用紧凑办理布局,不展示大块消息提醒入口', () => {
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
const template = source.split('<template>')[1].split('</template>')[0]
|
||||
expect(template).toContain('assist-card')
|
||||
expect(template).toContain(':busy="actionPending"')
|
||||
expect(template).not.toContain('查看业务消息')
|
||||
expect(template).not.toContain('微信提醒设置')
|
||||
expect(template).not.toContain('业务办理')
|
||||
})
|
||||
|
||||
it('身份标签放在业务标题旁,保持单行且不挤占右侧区域', () => {
|
||||
const source = readFileSync(new URL('./components/BusinessSection.vue', import.meta.url), 'utf8')
|
||||
expect(source).toContain('biz-title-row')
|
||||
expect(source).toContain('app-tag app-tag--info biz-role-tag')
|
||||
expect(source).toContain('white-space: nowrap')
|
||||
expect(source).not.toContain('title-width="75%"')
|
||||
})
|
||||
|
||||
it('同一办理入口连续点击只跳转一次', async () => {
|
||||
const harness = createHarness({ userId: 1, username: 'admin' })
|
||||
await Promise.all([harness.action('我的卡片'), harness.action('我的卡片')])
|
||||
expect(harness.fetchUserInfo).toHaveBeenCalledTimes(1)
|
||||
expect(harness.switchTab).toHaveBeenCalledTimes(1)
|
||||
expect(harness.switchTab.mock.calls[0][0]).toMatchObject({ url: RouterPath.Card })
|
||||
})
|
||||
|
||||
it('权限更新后不再跳转已失效的客户入口', async () => {
|
||||
const user = { userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } }
|
||||
const harness = createHarness(user)
|
||||
harness.fetchUserInfo.mockImplementationOnce(async () => {
|
||||
user.appAccess.features = []
|
||||
return user
|
||||
})
|
||||
await harness.action('客户管理')
|
||||
expect(harness.navigateTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('工单入口按列表或提交权限开放,路由可达', () => {
|
||||
for (const feature of ['workorder:list', 'workorder:create'])
|
||||
expect(visibleTitles({ userId: 12, appAccess: { configured: true, features: [feature] } })).toContain('工单服务')
|
||||
expect(visibleTitles({ userId: 12, appAccess: { configured: true, features: [] } })).not.toContain('工单服务')
|
||||
for (const route of [RouterPath.BusinessViewsWorkOrder, RouterPath.BusinessViewsWorkOrderCreate, RouterPath.BusinessViewsWorkOrderDetail])
|
||||
expect(release.isV101HiddenRoute(route)).toBe(false)
|
||||
expect(release.isV101HiddenRoute(RouterPath.BusinessViewsRenew)).toBe(true)
|
||||
})
|
||||
it('批次日志入口只对拥有原有日志权限的账号开放', () => {
|
||||
expect(visibleTitles({ userId: 12, userType: 'sys_user', accessCodes: ['cpm:common:batchExecutionLog:list'] })).toContain('批次执行日志')
|
||||
expect(visibleTitles({ userId: 13, userType: 'sys_user', accessCodes: [] })).not.toContain('批次执行日志')
|
||||
})
|
||||
it('恢复首页、设备、办理、我的顺序', () => {
|
||||
expect(customTabbarList.map(item => item.text)).toEqual(['首页', '设备', '办理', '我的'])
|
||||
expect(release.isV101HiddenRoute('/pages/business/index')).toBe(false)
|
||||
})
|
||||
|
||||
it('管理员只能看到已开放业务,不展示仍受发布限制的操作', () => {
|
||||
expect(visibleTitles({ userId: 1, username: 'admin' })).toEqual(['卡片排查', '卡片划拨', '工单服务', '我的卡片', '客户管理'])
|
||||
})
|
||||
|
||||
it('客户入口继续受账号身份和权限限制', () => {
|
||||
expect(visibleTitles({ userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } })).toContain('客户管理')
|
||||
expect(visibleTitles({ userId: 13, userType: 'sys_user', appAccess: { configured: true, features: [] } })).not.toContain('客户管理')
|
||||
expect(visibleTitles({ userId: 14, userType: 'app_user' })).not.toContain('客户管理')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import RouterPath from '@/constant/RouterPath'
|
||||
import { isV101HiddenRoute } from '@/constant/release'
|
||||
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 { hasAccessCode, hasAnyFeature, hasUserPermission } from '@/utils/permission'
|
||||
import BusinessSection from './components/BusinessSection.vue'
|
||||
import { getUserBusinessSections } from './data'
|
||||
import type { BusinessServiceItem } from './data'
|
||||
@@ -23,6 +24,7 @@ const actionRouteMap: Partial<Record<NonNullable<BusinessServiceItem['action']>,
|
||||
balanceRecharge: RouterPath.BusinessViewsBalanceRecharge,
|
||||
change: RouterPath.BusinessViewsChange,
|
||||
customer: RouterPath.BusinessViewsCustomer,
|
||||
executionLog: RouterPath.UserViewsExecutionLog,
|
||||
addon: RouterPath.BusinessViewsAddon,
|
||||
diagnosis: RouterPath.BusinessViewsDiagnosis,
|
||||
order: RouterPath.BusinessViewsOrder,
|
||||
@@ -31,10 +33,21 @@ const actionRouteMap: Partial<Record<NonNullable<BusinessServiceItem['action']>,
|
||||
}
|
||||
|
||||
/** ==================== 数据 ==================== */
|
||||
const actionPending = ref(false)
|
||||
const tokenStore = useTokenStore()
|
||||
const userStore = useUserStore()
|
||||
let refreshUserPromise: Promise<void | IUserInfoRes> | undefined
|
||||
const sections = computed(() => getUserBusinessSections(userStore.userInfo))
|
||||
const sections = computed(() => getUserBusinessSections(userStore.userInfo)
|
||||
.map(section => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => {
|
||||
if (item.action === 'cardList')
|
||||
return !item.permissionAction
|
||||
const route = item.action ? actionRouteMap[item.action] : undefined
|
||||
return Boolean(route && !isV101HiddenRoute(route))
|
||||
}),
|
||||
}))
|
||||
.filter(section => section.items.length > 0))
|
||||
|
||||
/** ==================== 方法 ==================== */
|
||||
async function refreshUserInfoIfNeeded() {
|
||||
@@ -54,31 +67,54 @@ async function refreshUserInfoIfNeeded() {
|
||||
}
|
||||
|
||||
async function handleBusinessAction(item: BusinessServiceItem) {
|
||||
await refreshUserInfoIfNeeded()
|
||||
if (item.permissionAction && !hasBusinessItemPermission(item.permissionAction)) {
|
||||
uni.showToast({ title: '暂无操作权限', icon: 'none' })
|
||||
if (actionPending.value)
|
||||
return
|
||||
actionPending.value = true
|
||||
try {
|
||||
await refreshUserInfoIfNeeded()
|
||||
if (!tokenStore.updateNowTime().hasLogin) {
|
||||
uni.navigateTo({ url: RouterPath.Login })
|
||||
return
|
||||
}
|
||||
if (!sections.value.some(section => section.items.some(current => current.action === item.action && current.title === item.title))) {
|
||||
uni.showToast({ title: '当前入口暂不可用,请刷新后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if ((item.accessCode && !hasAccessCode(userStore.userInfo.accessCodes, item.accessCode))
|
||||
|| (item.permissionAction && !hasBusinessItemPermission(item.permissionAction))) {
|
||||
uni.showToast({ title: '暂无操作权限', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const url = item.action === 'cardList'
|
||||
? RouterPath.Card
|
||||
: item.action === 'workOrder' && !hasAnyFeature(userStore.userInfo, 'workorder:list')
|
||||
? RouterPath.BusinessViewsWorkOrderCreate
|
||||
: item.action ? actionRouteMap[item.action] : ''
|
||||
if (!url || isV101HiddenRoute(url)) {
|
||||
uni.showToast({ title: '当前版本暂未开放该入口', icon: 'none' })
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const options = { url, complete: () => resolve() }
|
||||
if (item.action === 'cardList')
|
||||
uni.switchTab(options)
|
||||
else
|
||||
uni.navigateTo(options)
|
||||
})
|
||||
}
|
||||
|
||||
if (item.action === 'cardList') {
|
||||
uni.switchTab({ url: RouterPath.Card })
|
||||
return
|
||||
finally {
|
||||
actionPending.value = false
|
||||
}
|
||||
|
||||
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 })
|
||||
if (actionPending.value)
|
||||
return
|
||||
const item = sections.value.flatMap(section => section.items).find(current => current.action === 'diagnosis')
|
||||
if (item)
|
||||
void handleBusinessAction(item)
|
||||
else
|
||||
uni.showToast({ title: '当前账号暂无排查权限', icon: 'none' })
|
||||
}
|
||||
|
||||
function hasBusinessItemPermission(action: AppPermissionAction | AppPermissionAction[]) {
|
||||
@@ -114,6 +150,7 @@ onShow(() => {
|
||||
v-for="section in sections"
|
||||
:key="section.title"
|
||||
:section="section"
|
||||
:busy="actionPending"
|
||||
@action="handleBusinessAction"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -9,7 +9,7 @@ definePage({
|
||||
},
|
||||
})
|
||||
|
||||
type AddonOption = {
|
||||
interface AddonOption {
|
||||
name: string
|
||||
price: string
|
||||
desc: string
|
||||
@@ -93,117 +93,117 @@ onMounted(async () => {
|
||||
</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 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>
|
||||
|
||||
<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 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="primary" variant="plain">
|
||||
订购
|
||||
<wd-tag type="success" variant="plain">
|
||||
{{ selectedOption.name }}
|
||||
</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 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__price">{{ item.price }}</text>
|
||||
<view class="biz-radio" />
|
||||
<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 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>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!noPermission" class="biz-paybar">
|
||||
<view class="biz-paybar__inner">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { allocateDetailStatus, allocateResultStatus, allocateSelectionFingerprint, allocateStatusPresentation, allocateTaskSnapshot, mergeAllocateSelection } from './selection'
|
||||
|
||||
describe('划拨选卡', () => {
|
||||
it('搜索和分页不会清除其他页已选卡', () => {
|
||||
expect(mergeAllocateSelection(['A', 'B'], ['B', 'C'], ['C'])).toEqual(['A', 'C'])
|
||||
})
|
||||
it('重复选择只保留一份', () => {
|
||||
expect(mergeAllocateSelection(['A'], ['B'], ['B', 'B'])).toEqual(['A', 'B'])
|
||||
})
|
||||
it('相同卡集合保持相同快照,目标变更使校验失效', () => {
|
||||
expect(allocateSelectionFingerprint(['A', 'B'], 1)).toBe(allocateSelectionFingerprint(['B', 'A'], 1))
|
||||
expect(allocateSelectionFingerprint(['A'], 1)).not.toBe(allocateSelectionFingerprint(['A'], 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('划拨执行结果', () => {
|
||||
it('等待和处理中不会被误报为成功或失败', () => {
|
||||
expect(allocateResultStatus(0)).toBe('pending')
|
||||
expect(allocateResultStatus(1)).toBe('pending')
|
||||
expect(allocateResultStatus(2)).toBe('processing')
|
||||
expect(allocateResultStatus(999)).toBe('pending')
|
||||
})
|
||||
it('保留异常部分成功状态,不误报为整批失败', () => {
|
||||
expect(allocateResultStatus(3)).toBe('success')
|
||||
expect(allocateResultStatus(4)).toBe('failed')
|
||||
expect(allocateResultStatus(5)).toBe('partial_success')
|
||||
})
|
||||
})
|
||||
|
||||
describe('划拨恢复快照', () => {
|
||||
const item = { requestId: 'request', batchNo: '', batchType: 3, status: 'pending', quantity: 1, target: { deptId: 2, deptName: '客户' }, cards: [], details: [] }
|
||||
const request = { requestId: 'request', cardValues: ['A'], targetDeptId: 2, executionMode: 2 }
|
||||
it('受理前保留原请求以便恢复重试', () => {
|
||||
const saved = allocateTaskSnapshot(item, request)
|
||||
expect(saved.pendingRequest).toEqual(request)
|
||||
expect(saved.cards).toEqual([])
|
||||
expect(saved.details).toEqual([])
|
||||
})
|
||||
it('受理后和明确拒绝后清理原卡号参数', () => {
|
||||
expect(allocateTaskSnapshot({ ...item, batchNo: 'ALC' }, request).pendingRequest).toBeUndefined()
|
||||
expect(allocateTaskSnapshot({ ...item, status: 'failed' }, request).pendingRequest).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('划拨页面状态展示', () => {
|
||||
it('区分受理不确定和后台待处理', () => {
|
||||
expect(allocateStatusPresentation('pending', false).title).toBe('提交结果待确认')
|
||||
expect(allocateStatusPresentation('pending', true).title).toBe('等待后台处理')
|
||||
})
|
||||
it('区分校验拒绝和执行失败,异常部分成功提示核查', () => {
|
||||
expect(allocateStatusPresentation('failed', false).title).toBe('校验未通过')
|
||||
expect(allocateStatusPresentation('failed', true).title).toBe('划拨失败')
|
||||
expect(allocateStatusPresentation('partial_success').title).toBe('结果待核查')
|
||||
expect(allocateStatusPresentation('success').type).toBe('success')
|
||||
expect(allocateStatusPresentation().title).toBe('待提交')
|
||||
})
|
||||
it('子任务校验通过不能误报为划拨完成', () => {
|
||||
expect(allocateDetailStatus(2, 1)).toBe('校验通过 · 待整批完成')
|
||||
expect(allocateDetailStatus(2, 2)).toBe('校验通过 · 待整批完成')
|
||||
expect(allocateDetailStatus(2, 4)).toBe('校验通过 · 整批未完成')
|
||||
expect(allocateDetailStatus(2, 3)).toBe('已划拨')
|
||||
expect(allocateDetailStatus(3, 4)).toBe('校验失败')
|
||||
expect(allocateDetailStatus(999, 2)).toBe('状态待确认')
|
||||
})
|
||||
})
|
||||
|
||||
describe('直接异步提交不确定状态', () => {
|
||||
it('响应丢失不保存可重建请求,不声称未受理', () => {
|
||||
const item = { batchNo: '', batchType: 3, status: 'unknown', quantity: 1, target: { deptId: 2, deptName: '客户' }, cards: [], details: [] }
|
||||
expect(allocateTaskSnapshot(item, { cardValues: ['A'], targetDeptId: 2 }).pendingRequest).toBeUndefined()
|
||||
expect(allocateStatusPresentation('unknown', false).description).toContain('后台可能已创建任务')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AllocateBatchRes, AllocateBatchSubmitParams } from '@/api/types/card'
|
||||
|
||||
export function mergeAllocateSelection(selected: string[], visible: string[], checked: string[]) {
|
||||
const visibleSet = new Set(visible)
|
||||
return Array.from(new Set([...selected.filter(value => !visibleSet.has(value)), ...checked]))
|
||||
}
|
||||
|
||||
export function allocateSelectionFingerprint(values: string[], targetId?: number) {
|
||||
return JSON.stringify([targetId || 0, Array.from(new Set(values)).sort()])
|
||||
}
|
||||
|
||||
export function allocateTaskSnapshot(item: AllocateBatchRes, request?: AllocateBatchSubmitParams) {
|
||||
return {
|
||||
...item,
|
||||
cards: [],
|
||||
details: [],
|
||||
target: { deptId: item.target.deptId, deptName: item.target.deptName },
|
||||
pendingRequest: item.batchNo || !['pending', 'processing'].includes(item.status) ? undefined : request,
|
||||
}
|
||||
}
|
||||
|
||||
export { allocateDetailStatus, allocateResultStatus, allocateStatusPresentation } from '@/utils/allocateStatus'
|
||||
@@ -0,0 +1,81 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { ModuleKind, transpileModule } from 'typescript'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as selection from './selection'
|
||||
|
||||
function harness() {
|
||||
const submit = vi.fn()
|
||||
const summary = vi.fn()
|
||||
const toast = vi.fn()
|
||||
const removeStorage = vi.fn()
|
||||
const userInfo = { userId: 12, tenantId: 'A', deptId: 10 }
|
||||
const context = createContext({
|
||||
exports: {},
|
||||
require: (name: string) => {
|
||||
if (name === '@/api/card')
|
||||
return { allocateBatchSubmit: submit, getAllocateTargets: vi.fn(), getCardList: vi.fn() }
|
||||
if (name === '@/api/executionLog')
|
||||
return { allocateSummary: summary }
|
||||
if (name === './selection')
|
||||
return selection
|
||||
if (name === '@/pages/card/data')
|
||||
return {}
|
||||
if (name === '@/store/user')
|
||||
return { useUserStore: () => ({ userInfo, fetchUserInfo: vi.fn(async () => userInfo) }) }
|
||||
if (name === '@/utils/permission')
|
||||
return { hasUserPermission: () => true }
|
||||
if (name === '@/utils/userIdentity')
|
||||
return { getAccountWorkspace: () => 'agent' }
|
||||
throw new Error(name)
|
||||
},
|
||||
ref: (value: unknown) => ({ value }),
|
||||
computed: (getter: (() => unknown) | { get: () => unknown }) => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
|
||||
definePage: () => undefined,
|
||||
watch: () => undefined,
|
||||
onShow: () => undefined,
|
||||
onHide: () => undefined,
|
||||
onUnload: () => undefined,
|
||||
onMounted: () => undefined,
|
||||
onLoad: () => undefined,
|
||||
setTimeout: vi.fn(),
|
||||
uni: {
|
||||
showModal: (options: { success: (value: { confirm: boolean }) => void }) => options.success({ confirm: true }),
|
||||
showToast: toast,
|
||||
setStorageSync: vi.fn(),
|
||||
removeStorageSync: removeStorage,
|
||||
getStorageSync: vi.fn(),
|
||||
},
|
||||
})
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
|
||||
if (!script)
|
||||
throw new Error('missing script')
|
||||
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
|
||||
runInContext('chosenTarget.value = { deptId: 20, deptName: \'目标客户\' }; selectedCardValues.value = [\'CARD\']', context)
|
||||
return { submit, summary, toast, removeStorage, evaluate: (code: string) => runInContext(code, context) }
|
||||
}
|
||||
|
||||
describe('异步划拨提交', () => {
|
||||
it('受理后清空选择和任务状态,不自动查询或轮询结果', async () => {
|
||||
const page = harness()
|
||||
page.submit.mockResolvedValue({ batchNo: 'ALC202610130001', status: 'pending' })
|
||||
await page.evaluate('handleSubmit()')
|
||||
expect(page.submit).toHaveBeenCalledWith({ cardValues: ['CARD'], targetDeptId: 20, executionMode: 2 })
|
||||
expect(page.summary).not.toHaveBeenCalled()
|
||||
expect(page.evaluate('result.value')).toBeUndefined()
|
||||
expect(page.evaluate('selectedCardValues.value.length')).toBe(0)
|
||||
expect(page.removeStorage).toHaveBeenCalledWith('allocate-last-batch:A:12')
|
||||
expect(page.toast).toHaveBeenCalledWith({ title: '已提交,请到批次日志查看', icon: 'none', duration: 3000 })
|
||||
})
|
||||
it('请求结果未知时保留防重复提交保护,不清空选择', async () => {
|
||||
const page = harness()
|
||||
page.submit.mockRejectedValue(new Error('timeout'))
|
||||
await page.evaluate('handleSubmit()')
|
||||
expect(page.evaluate('result.value.status')).toBe('unknown')
|
||||
expect(page.evaluate('canSubmit.value')).toBe(false)
|
||||
expect(page.evaluate('selectedCardValues.value.length')).toBe(1)
|
||||
expect(page.removeStorage).not.toHaveBeenCalled()
|
||||
expect(page.summary).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,35 +1 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
export * from '@/api/workOrder'
|
||||
|
||||
@@ -29,53 +29,53 @@ onMounted(() => {
|
||||
</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 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
|
||||
@@ -257,215 +257,217 @@ onLoad((query) => {
|
||||
</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 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>
|
||||
<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 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="!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 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="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">
|
||||
重试
|
||||
<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>
|
||||
|
||||
<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 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">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { CustomerListRes } from '@/api/types/customer'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { ModuleKind, transpileModule } from 'typescript'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function createHarness() {
|
||||
const requests = vi.fn<(params: Record<string, unknown>) => Promise<CustomerListRes>>()
|
||||
const unmountCallbacks: Array<() => void> = []
|
||||
const features = { allowed: true }
|
||||
const context = createContext({
|
||||
exports: {},
|
||||
require: (name: string) => {
|
||||
if (name === '../api/customer')
|
||||
return { getCustomerList: requests }
|
||||
if (name === '@/store/user')
|
||||
return { useUserStore: () => ({ userInfo: {}, fetchUserInfo: async () => undefined }) }
|
||||
if (name === '@/utils/permission')
|
||||
return { hasAnyFeature: () => features.allowed }
|
||||
if (name === '@/utils/userIdentity')
|
||||
return { getAccountWorkspace: () => 'enterprise', getAccountWorkspaceLabel: () => '企业' }
|
||||
if (name === '@/constant/RouterPath')
|
||||
return { default: { BusinessViewsCustomerDetail: '/customer/detail' } }
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
},
|
||||
ref: (value: unknown) => ({ value }),
|
||||
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
|
||||
definePage: () => undefined,
|
||||
onMounted: () => undefined,
|
||||
onUnmounted: (callback: () => void) => unmountCallbacks.push(callback),
|
||||
uni: { showToast: vi.fn(), navigateTo: vi.fn() },
|
||||
})
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
|
||||
if (!script)
|
||||
throw new Error('Customer script not found')
|
||||
const compiled = transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } })
|
||||
runInContext(compiled.outputText, context)
|
||||
return {
|
||||
requests,
|
||||
features,
|
||||
evaluate: <Result>(expression: string) => runInContext(expression, context) as Result,
|
||||
unmount: () => unmountCallbacks.forEach(callback => callback()),
|
||||
}
|
||||
}
|
||||
|
||||
function result(customerId: number, total = 2): CustomerListRes {
|
||||
return {
|
||||
items: [{ customerId, customerName: `客户${customerId}`, customerType: 'enterprise', customerTypeName: '企业客户', status: '0', statusText: '正常', realNameStatus: '已录入实名资料', cardCount: 0, normalCardCount: 0 }],
|
||||
total,
|
||||
summary: { total, normalCount: total, pendingProfileCount: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
describe('客户列表请求状态', () => {
|
||||
it('列表不展示机构编号或内部ID替代编号', () => {
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
expect(source).not.toContain('customerLabel')
|
||||
expect(source).not.toContain('customerCode')
|
||||
expect(source).not.toContain('ID ${')
|
||||
expect(source).not.toContain('搜索企业名称 / 编码')
|
||||
})
|
||||
it('加载更多沿用已提交的搜索条件', async () => {
|
||||
const harness = createHarness()
|
||||
harness.requests.mockResolvedValueOnce(result(1)).mockResolvedValueOnce(result(2))
|
||||
harness.evaluate('keyword.value = \'企业甲\'')
|
||||
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||
harness.evaluate('keyword.value = \'企业乙\'')
|
||||
await harness.evaluate<Promise<void>>('loadCustomers(false)')
|
||||
expect(harness.requests.mock.calls[1][0]).toMatchObject({ page: 2, keyword: '企业甲' })
|
||||
expect(harness.requests.mock.calls[1][0]).not.toHaveProperty('status')
|
||||
expect(harness.evaluate<number[]>('customers.value.map(item => item.customerId)')).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('旧搜索晚返回时不覆盖新结果或总数', async () => {
|
||||
const harness = createHarness()
|
||||
let resolveOld!: (value: CustomerListRes) => void
|
||||
harness.requests.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveOld = resolve
|
||||
}))
|
||||
harness.requests.mockResolvedValueOnce(result(2, 3))
|
||||
const oldRequest = harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||
resolveOld(result(1, 10))
|
||||
await oldRequest
|
||||
expect(harness.evaluate<number[]>('customers.value.map(item => item.customerId)')).toEqual([2])
|
||||
expect(harness.evaluate<number>('total.value')).toBe(3)
|
||||
expect(harness.evaluate<boolean>('loading.value')).toBe(false)
|
||||
})
|
||||
|
||||
it('页面卸载后不再处理返回结果或发起请求', async () => {
|
||||
const harness = createHarness()
|
||||
let resolveRequest!: (value: CustomerListRes) => void
|
||||
harness.requests.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveRequest = resolve
|
||||
}))
|
||||
const request = harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||
harness.unmount()
|
||||
resolveRequest(result(1))
|
||||
await request
|
||||
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||
expect(harness.evaluate<number>('customers.value.length')).toBe(0)
|
||||
expect(harness.requests).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('失去权限后清空旧数据并拒绝加载', async () => {
|
||||
const harness = createHarness()
|
||||
harness.requests.mockResolvedValueOnce(result(1))
|
||||
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||
harness.features.allowed = false
|
||||
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||
expect(harness.evaluate<number>('customers.value.length')).toBe(0)
|
||||
expect(harness.evaluate<number>('total.value')).toBe(0)
|
||||
expect(harness.evaluate<boolean>('noPermission.value')).toBe(true)
|
||||
expect(harness.requests).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ definePage({
|
||||
|
||||
const userStore = useUserStore()
|
||||
const keyword = ref('')
|
||||
const customerType = ref<AppCustomerType>('enterprise')
|
||||
const customerType: AppCustomerType = 'enterprise'
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
@@ -22,6 +22,11 @@ const customers = ref<AppCustomer[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const noPermission = ref(false)
|
||||
const submittedKeyword = ref('')
|
||||
const errorMessage = ref('')
|
||||
let requestVersion = 0
|
||||
let disposed = false
|
||||
const canUseCustomerDetail = computed(() => hasAnyFeature(userStore.userInfo, 'customer:detail'))
|
||||
|
||||
const hasMore = computed(() => customers.value.length < total.value)
|
||||
const workspace = computed(() => getAccountWorkspace(userStore.userInfo))
|
||||
@@ -32,32 +37,18 @@ const summaryText = computed(() => {
|
||||
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,
|
||||
keyword: submittedKeyword.value || undefined,
|
||||
customerType,
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,29 +78,51 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
}
|
||||
|
||||
async function loadCustomers(reset = true) {
|
||||
if (disposed)
|
||||
return
|
||||
if (!canUseCustomerList.value) {
|
||||
requestVersion++
|
||||
customers.value = []
|
||||
total.value = 0
|
||||
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
noPermission.value = true
|
||||
return
|
||||
}
|
||||
noPermission.value = false
|
||||
if (reset) {
|
||||
submittedKeyword.value = keyword.value.trim()
|
||||
customers.value = []
|
||||
total.value = 0
|
||||
|
||||
loadingMore.value = false
|
||||
page.value = 1
|
||||
loading.value = true
|
||||
}
|
||||
else {
|
||||
if (!hasMore.value || loadingMore.value)
|
||||
if (loading.value || !hasMore.value || loadingMore.value)
|
||||
return
|
||||
loadingMore.value = true
|
||||
}
|
||||
|
||||
const currentRequest = ++requestVersion
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const nextPage = reset ? 1 : page.value + 1
|
||||
const res = await getCustomerList(buildQuery(nextPage))
|
||||
if (currentRequest !== requestVersion)
|
||||
return
|
||||
const list = res.items || []
|
||||
customers.value = reset ? list : [...customers.value, ...list]
|
||||
total.value = res.total || 0
|
||||
|
||||
page.value = nextPage
|
||||
}
|
||||
catch (error) {
|
||||
if (currentRequest !== requestVersion)
|
||||
return
|
||||
errorMessage.value = getErrorMessage(error, '客户列表加载失败')
|
||||
if (reset) {
|
||||
customers.value = []
|
||||
total.value = 0
|
||||
@@ -117,8 +130,10 @@ async function loadCustomers(reset = true) {
|
||||
uni.showToast({ title: getErrorMessage(error, '客户列表加载失败'), icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
if (currentRequest === requestVersion) {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +142,8 @@ function handleSearchChange() {
|
||||
}
|
||||
|
||||
function handleDetail(item: AppCustomer) {
|
||||
if (!canUseCustomerDetail.value)
|
||||
return
|
||||
if (item.customerType !== 'enterprise') {
|
||||
uni.showToast({ title: '暂不支持查看终端客户', icon: 'none' })
|
||||
return
|
||||
@@ -138,6 +155,8 @@ function handleDetail(item: AppCustomer) {
|
||||
|
||||
async function bootstrap() {
|
||||
await userStore.fetchUserInfo().catch(() => undefined)
|
||||
if (disposed)
|
||||
return
|
||||
if (!canUseCustomerList.value) {
|
||||
noPermission.value = true
|
||||
uni.showToast({ title: '当前账号不能查看客户管理', icon: 'none' })
|
||||
@@ -146,184 +165,108 @@ async function bootstrap() {
|
||||
await loadCustomers(true)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
requestVersion++
|
||||
})
|
||||
|
||||
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 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="block text-20rpx text-#2563eb font-700">
|
||||
{{ workspaceLabel }}工作台
|
||||
</view>
|
||||
<wd-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="loading"
|
||||
:disabled="noPermission"
|
||||
@click="loadCustomers(true)"
|
||||
>
|
||||
刷新
|
||||
</wd-button>
|
||||
<view class="mt-4rpx text-34rpx text-#111827 font-900">
|
||||
客户管理
|
||||
</view>
|
||||
<view class="mt-6rpx block text-23rpx text-#64748b">
|
||||
{{ noPermission ? '当前账号暂无客户管理权限' : summaryText }}
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
size="small"
|
||||
type="primary"
|
||||
variant="text"
|
||||
icon="refresh"
|
||||
custom-class="!h-64rpx !px-16rpx !text-24rpx !font-500"
|
||||
: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>
|
||||
</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>
|
||||
<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"
|
||||
title-width="42%"
|
||||
:label="item.customerTypeName || '企业客户'"
|
||||
:is-link="canUseCustomerDetail"
|
||||
:clickable="canUseCustomerDetail"
|
||||
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>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
<view class="flex flex-wrap items-center justify-end gap-8rpx">
|
||||
<wd-tag :type="realNameTagType(item)" variant="light" round custom-class="!shrink-0 !whitespace-nowrap">
|
||||
{{ item.realNameStatus }}
|
||||
</wd-tag>
|
||||
</view>
|
||||
</view>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
|
||||
<view v-else class="p-48rpx">
|
||||
<wd-empty :tip="loading ? '客户加载中' : '暂无客户'" />
|
||||
</view>
|
||||
</wd-card>
|
||||
<view v-else class="p-48rpx">
|
||||
<wd-empty :tip="loading ? '客户加载中' : (errorMessage || '暂无符合条件的客户')" />
|
||||
</view>
|
||||
</wd-card>
|
||||
|
||||
<wd-button
|
||||
v-if="hasMore"
|
||||
<wd-button
|
||||
v-if="hasMore"
|
||||
|
||||
plain block
|
||||
type="primary"
|
||||
:loading="loadingMore"
|
||||
@click="loadCustomers(false)"
|
||||
>
|
||||
加载更多
|
||||
</wd-button>
|
||||
</template>
|
||||
</view>
|
||||
variant="text" 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,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { getCustomerCardStatusItems } from './data'
|
||||
|
||||
describe('客户全部卡片状态', () => {
|
||||
it('客户详情不展示编号、套餐或正常使用数量', () => {
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
expect(source).not.toContain('客户编码')
|
||||
expect(source).not.toContain('customerCode')
|
||||
expect(source).not.toContain('profile.deptKey')
|
||||
expect(source).not.toContain('normalCardCount')
|
||||
expect(source).not.toContain('套餐')
|
||||
expect(source).not.toContain('packageStatus')
|
||||
expect(source).not.toContain('正常使用')
|
||||
})
|
||||
|
||||
it('全部八张已激活卡均展示,不按套餐过滤', () => {
|
||||
const items = getCustomerCardStatusItems([{ status: 2, count: 8 }])
|
||||
expect(items.find(item => item.status === 2)?.count).toBe(8)
|
||||
expect(items.find(item => item.status === 4)?.count).toBe(0)
|
||||
expect(items.reduce((total, item) => total + item.count, 0)).toBe(8)
|
||||
})
|
||||
|
||||
it('展示全部标准状态,不把非正常卡遗漏', () => {
|
||||
const items = getCustomerCardStatusItems([{ status: 2, count: 3 }, { status: 4, count: 5 }, { status: 9, count: 2 }])
|
||||
expect(items.map(item => item.label)).toEqual(['未知', '待激活', '已激活', '停机', '可测试', '库存', '预销户', '已销户'])
|
||||
expect(items.reduce((total, item) => total + item.count, 0)).toBe(10)
|
||||
expect(items.find(item => item.status === 4)?.count).toBe(5)
|
||||
})
|
||||
|
||||
it('保留非标准状态,合并同状态数据', () => {
|
||||
const items = getCustomerCardStatusItems([{ status: 88, count: 2 }, { status: 88, count: 3 }])
|
||||
expect(items.find(item => item.status === 88)).toEqual({ status: 88, label: '未知状态 88', count: 5 })
|
||||
})
|
||||
|
||||
it('区分未返回统计和零持卡', () => {
|
||||
expect(getCustomerCardStatusItems()).toEqual([])
|
||||
expect(getCustomerCardStatusItems([])).toHaveLength(8)
|
||||
expect(getCustomerCardStatusItems([]).every(item => item.count === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AppCustomer } from '@/api/types/customer'
|
||||
import { bossCardStatusTextMap } from '@/pages/card/data'
|
||||
|
||||
export function getCustomerCardStatusItems(counts?: AppCustomer['cardStatusCounts']) {
|
||||
if (!counts)
|
||||
return []
|
||||
const totals = new Map<number, number>()
|
||||
for (const item of counts)
|
||||
totals.set(item.status, (totals.get(item.status) || 0) + item.count)
|
||||
const statuses = new Set([...Object.keys(bossCardStatusTextMap).map(Number), ...totals.keys()])
|
||||
return [...statuses].sort((first, second) => first - second).map(status => ({
|
||||
status,
|
||||
label: bossCardStatusTextMap[status] || `未知状态 ${status}`,
|
||||
count: totals.get(status) || 0,
|
||||
}))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { AppCustomerDetail } from '../api/customer'
|
||||
import { getCustomerDetail } from '../api/customer'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { hasAnyFeature } from '@/utils/permission'
|
||||
import { getCustomerCardStatusItems } from './data'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
@@ -17,7 +18,10 @@ const queryType = ref('')
|
||||
const queryId = ref(0)
|
||||
const noPermission = ref(false)
|
||||
const userStore = useUserStore()
|
||||
let requestVersion = 0
|
||||
let disposed = false
|
||||
|
||||
const cardStatusItems = computed(() => getCustomerCardStatusItems(customer.value?.cardStatusCounts || (customer.value?.cardCount === 0 ? [] : undefined)))
|
||||
const titleText = computed(() => customer.value?.customerName || '客户详情')
|
||||
const canUseCustomerDetail = computed(() => hasAnyFeature(userStore.userInfo, 'customer:detail'))
|
||||
|
||||
@@ -27,13 +31,10 @@ const baseRows = computed(() => {
|
||||
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.realNameStatus || '-'],
|
||||
['直接持卡数量', `${item.cardCount || 0} 张`],
|
||||
['地址', item.address || '-'],
|
||||
]
|
||||
})
|
||||
@@ -44,9 +45,7 @@ const enterpriseRows = computed(() => {
|
||||
return []
|
||||
return [
|
||||
['企业名称', profile.deptName || '-'],
|
||||
['客户编码', profile.deptKey || '-'],
|
||||
['联系电话', profile.phone || '-'],
|
||||
['企业状态', profile.statusText || '-'],
|
||||
['统一信用代码', profile.creditCode || '-'],
|
||||
['单位全称', profile.fullName || '-'],
|
||||
['行业', profile.industry || '-'],
|
||||
@@ -57,14 +56,6 @@ const enterpriseRows = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
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 || '')
|
||||
}
|
||||
@@ -95,12 +86,14 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
}
|
||||
|
||||
async function loadCustomerDetail() {
|
||||
if (disposed || loading.value)
|
||||
return
|
||||
if (!canUseCustomerDetail.value) {
|
||||
noPermission.value = true
|
||||
errorMessage.value = '当前账号不能查看客户详情'
|
||||
return
|
||||
}
|
||||
if (!queryType.value || !queryId.value) {
|
||||
if (!queryType.value || !Number.isSafeInteger(queryId.value) || queryId.value <= 0) {
|
||||
errorMessage.value = '客户参数不完整'
|
||||
return
|
||||
}
|
||||
@@ -108,6 +101,8 @@ async function loadCustomerDetail() {
|
||||
errorMessage.value = '暂不支持查看终端客户'
|
||||
return
|
||||
}
|
||||
noPermission.value = false
|
||||
const currentRequest = ++requestVersion
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
@@ -115,14 +110,19 @@ async function loadCustomerDetail() {
|
||||
customerType: queryType.value,
|
||||
customerId: queryId.value,
|
||||
})
|
||||
if (currentRequest !== requestVersion)
|
||||
return
|
||||
customer.value = res.customer
|
||||
}
|
||||
catch (error) {
|
||||
if (currentRequest !== requestVersion)
|
||||
return
|
||||
customer.value = undefined
|
||||
errorMessage.value = getErrorMessage(error, '客户详情加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
if (currentRequest === requestVersion)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ onLoad((query) => {
|
||||
|
||||
async function bootstrap() {
|
||||
await userStore.fetchUserInfo().catch(() => undefined)
|
||||
if (disposed)
|
||||
return
|
||||
if (!canUseCustomerDetail.value) {
|
||||
noPermission.value = true
|
||||
uni.showToast({ title: '当前账号不能查看客户详情', icon: 'none' })
|
||||
@@ -141,111 +143,117 @@ async function bootstrap() {
|
||||
await loadCustomerDetail()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
requestVersion++
|
||||
})
|
||||
|
||||
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">
|
||||
<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>
|
||||
</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="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 class="flex items-center justify-between rounded-12rpx bg-#f8fafc px-24rpx py-20rpx">
|
||||
<view class="flex items-baseline gap-8rpx">
|
||||
<text class="text-38rpx text-#0f172a font-800">{{ customer.cardCount || 0 }}</text>
|
||||
<text class="text-23rpx text-#64748b">张卡</text>
|
||||
</view>
|
||||
<text class="text-24rpx text-#64748b">持卡总数</text>
|
||||
</view>
|
||||
<view class="mt-24rpx border-x-0 border-b-0 border-t-1 border-#e2e8f0 border-solid pt-24rpx">
|
||||
<view class="mb-18rpx flex items-center gap-12rpx">
|
||||
<view class="h-26rpx w-6rpx rounded-full bg-#2563eb" />
|
||||
<text class="text-26rpx text-#0f172a font-800">运营商卡状态</text>
|
||||
</view>
|
||||
<view v-if="cardStatusItems.length" class="grid grid-cols-3 gap-12rpx">
|
||||
<view v-for="item in cardStatusItems" :key="item.status" class="rounded-12rpx bg-#f8fafc px-12rpx py-18rpx text-center">
|
||||
<text class="block text-28rpx text-#0f172a font-800">{{ item.count }}</text>
|
||||
<text class="mt-6rpx block text-22rpx text-#64748b">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-tag :type="statusTagType(customer)" variant="plain">
|
||||
{{ customer?.statusText || '加载中' }}
|
||||
</wd-tag>
|
||||
<text v-else class="text-24rpx text-#64748b">暂无状态统计,请刷新后重试</text>
|
||||
<text class="mt-16rpx block text-21rpx text-#94a3b8 leading-32rpx">仅统计该客户直接持有的卡片,不含下级客户。</text>
|
||||
</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 }}
|
||||
<wd-card custom-class="!mx-0 !mb-0" custom-title-class="!p-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>
|
||||
<wd-button size="small" type="primary" @click="loadCustomerDetail">
|
||||
重试
|
||||
</wd-button>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<wd-card
|
||||
v-if="enterpriseRows.length"
|
||||
custom-class="!mx-0 !mb-0"
|
||||
custom-title-class="!p-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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { transpileModule } from 'typescript'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function drawPoints(points: Array<{ x: number, y: number, available: boolean }>) {
|
||||
const methods = ['clearRect', 'setStrokeStyle', 'setLineWidth', 'beginPath', 'moveTo', 'lineTo', 'stroke', 'setLineCap', 'setLineJoin', 'setFillStyle', 'arc', 'fill', 'draw']
|
||||
const canvas = Object.fromEntries(methods.map(method => [method, vi.fn()]))
|
||||
const query = {
|
||||
in: () => query,
|
||||
select: () => query,
|
||||
boundingClientRect: (callback: (rect: { width: number, height: number }) => void) => {
|
||||
callback({ width: 600, height: 190 })
|
||||
return query
|
||||
},
|
||||
exec: vi.fn(),
|
||||
}
|
||||
const context = createContext({
|
||||
defineProps: () => ({ points }),
|
||||
getCurrentInstance: () => ({ proxy: {} }),
|
||||
nextTick: async () => undefined,
|
||||
onMounted: vi.fn(),
|
||||
onUnmounted: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
uni: { createSelectorQuery: () => query, createCanvasContext: () => canvas },
|
||||
})
|
||||
const source = readFileSync(new URL('./DiagnosisFlowChart.vue', import.meta.url), 'utf8')
|
||||
const script = source.split('<script setup lang="ts">')[1]?.split('</script>')[0]
|
||||
if (!script)
|
||||
throw new Error('missing chart script')
|
||||
runInContext(transpileModule(script, {}).outputText, context)
|
||||
await runInContext('draw()', context)
|
||||
return canvas
|
||||
}
|
||||
|
||||
describe('小程序流量图绘制', () => {
|
||||
it('只有一个采集点也绘制圆点并提交画布', async () => {
|
||||
const ctx = await drawPoints([{ x: 300, y: 80, available: true }])
|
||||
expect(ctx.arc).toHaveBeenCalledWith(300, 80, 3, 0, Math.PI * 2)
|
||||
expect(ctx.draw).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('未采集周期不画点且断开折线', async () => {
|
||||
const ctx = await drawPoints([
|
||||
{ x: 20, y: 80, available: true },
|
||||
{ x: 300, y: 156, available: false },
|
||||
{ x: 580, y: 40, available: true },
|
||||
])
|
||||
expect(ctx.arc).toHaveBeenCalledTimes(2)
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(580, 40)
|
||||
expect(ctx.lineTo).not.toHaveBeenCalledWith(580, 40)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
interface FlowPoint {
|
||||
x: number
|
||||
y: number
|
||||
available: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ points: FlowPoint[] }>()
|
||||
const instance = getCurrentInstance()
|
||||
const canvasId = 'diagnosis-flow-chart'
|
||||
let revision = 0
|
||||
|
||||
async function draw() {
|
||||
const current = ++revision
|
||||
await nextTick()
|
||||
if (!instance?.proxy)
|
||||
return
|
||||
const proxy = instance.proxy
|
||||
uni.createSelectorQuery().in(proxy).select('.diagnosis-flow-canvas').boundingClientRect((rect) => {
|
||||
if (current !== revision || !rect || Array.isArray(rect) || !rect.width || !rect.height)
|
||||
return
|
||||
const ctx = uni.createCanvasContext(canvasId, proxy)
|
||||
const scaleX = rect.width / 600
|
||||
const scaleY = rect.height / 190
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.setStrokeStyle('#e2eaf4')
|
||||
ctx.setLineWidth(1)
|
||||
for (const y of [18, 87, 178]) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(20 * scaleX, y * scaleY)
|
||||
ctx.lineTo(580 * scaleX, y * scaleY)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.setStrokeStyle('#2563eb')
|
||||
ctx.setLineWidth(2)
|
||||
ctx.setLineCap('round')
|
||||
ctx.setLineJoin('round')
|
||||
ctx.beginPath()
|
||||
let connected = false
|
||||
for (const point of props.points) {
|
||||
if (!point.available) {
|
||||
connected = false
|
||||
continue
|
||||
}
|
||||
if (connected)
|
||||
ctx.lineTo(point.x * scaleX, point.y * scaleY)
|
||||
else
|
||||
ctx.moveTo(point.x * scaleX, point.y * scaleY)
|
||||
connected = true
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.setFillStyle('#ffffff')
|
||||
for (const point of props.points.filter(point => point.available)) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(point.x * scaleX, point.y * scaleY, 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.draw()
|
||||
}).exec()
|
||||
}
|
||||
|
||||
onMounted(draw)
|
||||
watch(() => props.points, draw, { deep: true, flush: 'post' })
|
||||
onUnmounted(() => { revision++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas :canvas-id="canvasId" :id="canvasId" class="diagnosis-flow-canvas block h-190rpx w-full" />
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
diagnosisDataServiceDisplay,
|
||||
diagnosisFlowRetryHint,
|
||||
diagnosisOnlineStatusDisplay,
|
||||
diagnosisSessionStatusDisplay,
|
||||
isDiagnosisNoDataText,
|
||||
isDiagnosisUnsupportedModule,
|
||||
pickDiagnosisFlowValue,
|
||||
pickDiagnosisRemainingFlowValue,
|
||||
resolveDiagnosisOnlineStatus,
|
||||
shouldHideDiagnosisModule,
|
||||
shouldHideDiagnosisText,
|
||||
} from './display'
|
||||
|
||||
describe('在线状态展示', () => {
|
||||
it('未知、缺失或非法状态统一显示横线', () => {
|
||||
for (const status of ['2', 'UNKNOWN', '', 'unexpected', 'constructor', 'toString', '__proto__']) {
|
||||
expect(diagnosisOnlineStatusDisplay(status)).toBe('-')
|
||||
}
|
||||
for (const status of ['-2', '02', '2', 'UNKNOWN', '', 'unexpected', 'constructor', 'toString', '__proto__']) {
|
||||
expect(diagnosisSessionStatusDisplay(status)).toBe('-')
|
||||
}
|
||||
expect(diagnosisOnlineStatusDisplay()).toBe('-')
|
||||
expect(diagnosisSessionStatusDisplay()).toBe('-')
|
||||
})
|
||||
it('保留明确的在线、离线和不支持状态', () => {
|
||||
expect(diagnosisOnlineStatusDisplay('1')).toBe('在线')
|
||||
expect(diagnosisOnlineStatusDisplay('0')).toBe('离线')
|
||||
expect(diagnosisSessionStatusDisplay('0')).toBe('在线')
|
||||
expect(diagnosisSessionStatusDisplay('-1')).toBe('离线')
|
||||
expect(diagnosisSessionStatusDisplay('UNSUPPORTED')).toBe('不支持')
|
||||
})
|
||||
})
|
||||
|
||||
describe('实时会话状态优先', () => {
|
||||
it('明确会话状态覆盖汇总状态,异常状态不回退旧在线值', () => {
|
||||
expect(resolveDiagnosisOnlineStatus('0', '0')).toBe('1')
|
||||
expect(resolveDiagnosisOnlineStatus('1', '-1')).toBe('0')
|
||||
for (const status of ['-2', '02', '2', 'UNKNOWN', 'unexpected', 'UNSUPPORTED']) {
|
||||
expect(resolveDiagnosisOnlineStatus('1', status)).toBe('2')
|
||||
}
|
||||
expect(resolveDiagnosisOnlineStatus('1', undefined)).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('数据服务来源隔离', () => {
|
||||
it('电信展示断网状态,不使用 APN 开关值代替', () => {
|
||||
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '1', dataStatus: '1' })).toBe('已断网')
|
||||
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '0', dataStatus: '0' })).toBe('未断网')
|
||||
})
|
||||
it('电信缺少断网状态时显示横线,不回退 APN 开关', () => {
|
||||
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', dataStatus: '1' })).toBe('-')
|
||||
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '2' })).toBe('-')
|
||||
})
|
||||
it('移动仍使用 APN 开停状态', () => {
|
||||
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'apn', dataStatus: '1' })).toBe('开启')
|
||||
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'apn', dataStatus: '0' })).toBe('关闭')
|
||||
expect(diagnosisDataServiceDisplay({})).toBe('-')
|
||||
})
|
||||
})
|
||||
|
||||
describe('diagnosis display rules', () => {
|
||||
it('hides unsubscribed and unsupported modules', () => {
|
||||
expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'not_subscribed' })).toBe(true)
|
||||
expect(shouldHideDiagnosisModule({ status: 'skipped', message: '该卡未订购机卡绑定服务' })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps no-data and query-error modules visible', () => {
|
||||
expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'no_data' })).toBe(false)
|
||||
expect(shouldHideDiagnosisModule({ status: 'error', reason: 'query_error' })).toBe(false)
|
||||
expect(shouldHideDiagnosisModule({ status: 'skipped', message: '查询暂时未完成,请稍后重试' })).toBe(false)
|
||||
})
|
||||
|
||||
it('shows unsupported modules as unsupported', () => {
|
||||
expect(isDiagnosisUnsupportedModule({ status: 'skipped', reason: 'unsupported' })).toBe(true)
|
||||
expect(isDiagnosisUnsupportedModule({ status: 'skipped', message: '运营商暂不支持' })).toBe(true)
|
||||
expect(isDiagnosisUnsupportedModule({ status: 'skipped' })).toBe(true)
|
||||
expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'unsupported' })).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes empty data to a dash without hiding it', () => {
|
||||
expect(isDiagnosisNoDataText('')).toBe(true)
|
||||
expect(isDiagnosisNoDataText('暂无数据')).toBe(true)
|
||||
expect(shouldHideDiagnosisText('暂无数据')).toBe(false)
|
||||
expect(shouldHideDiagnosisText('运营商暂不支持')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat a failed flow query default zero as real usage', () => {
|
||||
expect(pickDiagnosisFlowValue(
|
||||
{ status: 'error', reason: 'query_error' },
|
||||
[null, undefined],
|
||||
[0],
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps successful decimal flow usage and real zero values', () => {
|
||||
expect(pickDiagnosisFlowValue({ status: 'success' }, [0.03], [0])).toBe(0.03)
|
||||
expect(pickDiagnosisFlowValue({ status: 'cached' }, [null], [0])).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show default remaining flow as upstream data', () => {
|
||||
expect(pickDiagnosisRemainingFlowValue({ status: 'error' }, [0])).toBeNull()
|
||||
expect(pickDiagnosisRemainingFlowValue({ status: 'success' }, [null, undefined])).toBeNull()
|
||||
expect(pickDiagnosisRemainingFlowValue({ status: 'success' }, [0])).toBe(0)
|
||||
})
|
||||
|
||||
it('only shows a retry hint for reachable business failures', () => {
|
||||
expect(diagnosisFlowRetryHint({ status: 'error', message: '服务繁忙,请重试' })).toBe('服务繁忙,请重试')
|
||||
expect(diagnosisFlowRetryHint({ status: 'error' })).toBe('')
|
||||
expect(diagnosisFlowRetryHint({ status: 'success', message: '服务繁忙,请重试' })).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { DiagnosisInfoItem, DiagnosisModuleItem } from '@/api/diagnosis'
|
||||
|
||||
export function diagnosisOnlineStatusDisplay(status?: string): string {
|
||||
const labels: Record<string, string> = { 0: '离线', 1: '在线' }
|
||||
const value = String(status ?? '').trim()
|
||||
return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
|
||||
}
|
||||
|
||||
export function diagnosisSessionStatusDisplay(status?: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'0': '在线',
|
||||
'01': '在线',
|
||||
'ONLINE': '在线',
|
||||
'-1': '离线',
|
||||
'00': '离线',
|
||||
'OFFLINE': '离线',
|
||||
'UNSUPPORTED': '不支持',
|
||||
}
|
||||
const value = String(status ?? '').trim()
|
||||
return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
|
||||
}
|
||||
|
||||
export function resolveDiagnosisOnlineStatus(
|
||||
infoStatus?: string | number,
|
||||
sessionStatus?: string | number,
|
||||
): string {
|
||||
const status = String(sessionStatus ?? '').trim()
|
||||
switch (status) {
|
||||
case '0':
|
||||
case '01':
|
||||
case 'ONLINE':
|
||||
return '1'
|
||||
case '-1':
|
||||
case '00':
|
||||
case 'OFFLINE':
|
||||
return '0'
|
||||
default:
|
||||
return status ? '2' : String(infoStatus ?? '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function diagnosisDataServiceDisplay(info?: DiagnosisInfoItem): string {
|
||||
const networkBlock = info?.dataServiceSource === 'network_block'
|
||||
const labels: Record<string, string> = networkBlock
|
||||
? { 0: '未断网', 1: '已断网' }
|
||||
: { '0': '关闭', '1': '开启', '-1': '未知' }
|
||||
const status = networkBlock ? info?.netBlockStatus : info?.dataStatus
|
||||
const value = String(status ?? '').trim()
|
||||
return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
|
||||
}
|
||||
|
||||
const NOT_SUBSCRIBED_MESSAGE_RE = /未订购|未开通|未申请|通道未申请/
|
||||
const UNSUPPORTED_MESSAGE_RE = /不支持|暂不支持|暂不提供|暂未提供|未提供独立|无独立.*接口|当前无.*接口/
|
||||
const NO_DATA_MESSAGE_RE = /未返回|未查询到|无数据|暂无数据|暂时未完成/
|
||||
|
||||
export function shouldHideDiagnosisModule(module?: DiagnosisModuleItem) {
|
||||
const reason = String(module?.reason || '').trim()
|
||||
if (reason === 'not_subscribed')
|
||||
return true
|
||||
if (reason === 'unsupported' || reason === 'no_data' || reason === 'query_error')
|
||||
return false
|
||||
|
||||
const status = String(module?.status || '').trim()
|
||||
const message = String(module?.message || '').trim()
|
||||
return NOT_SUBSCRIBED_MESSAGE_RE.test(message)
|
||||
}
|
||||
|
||||
export function isDiagnosisUnsupportedModule(module?: DiagnosisModuleItem) {
|
||||
const reason = String(module?.reason || '').trim()
|
||||
if (reason === 'unsupported')
|
||||
return true
|
||||
if (reason === 'not_subscribed' || reason === 'no_data' || reason === 'query_error')
|
||||
return false
|
||||
const status = String(module?.status || '').trim()
|
||||
const message = String(module?.message || '').trim()
|
||||
return UNSUPPORTED_MESSAGE_RE.test(message) || (status === 'skipped' && !message)
|
||||
}
|
||||
|
||||
export function isDiagnosisNoDataText(value: unknown) {
|
||||
const text = String(value ?? '').trim()
|
||||
return !text
|
||||
|| text === '-'
|
||||
|| text === '<nil>'
|
||||
|| text === 'undefined'
|
||||
|| text === 'null'
|
||||
|| NO_DATA_MESSAGE_RE.test(text)
|
||||
}
|
||||
|
||||
export function shouldHideDiagnosisText(value: unknown) {
|
||||
return NOT_SUBSCRIBED_MESSAGE_RE.test(String(value ?? '').trim())
|
||||
}
|
||||
|
||||
export function pickDiagnosisFlowValue(
|
||||
module: DiagnosisModuleItem | undefined,
|
||||
primaryValues: unknown[],
|
||||
fallbackValues: unknown[],
|
||||
) {
|
||||
const primary = firstFiniteNumber(primaryValues)
|
||||
if (primary !== null)
|
||||
return primary
|
||||
|
||||
const status = String(module?.status || '').trim()
|
||||
if (module && status !== 'success' && status !== 'cached')
|
||||
return null
|
||||
|
||||
return firstFiniteNumber(fallbackValues)
|
||||
}
|
||||
|
||||
export function diagnosisFlowRetryHint(module?: DiagnosisModuleItem) {
|
||||
if (String(module?.status || '').trim() !== 'error')
|
||||
return ''
|
||||
return String(module?.message || '').trim() === '服务繁忙,请重试'
|
||||
? '服务繁忙,请重试'
|
||||
: ''
|
||||
}
|
||||
|
||||
export function pickDiagnosisRemainingFlowValue(module: DiagnosisModuleItem | undefined, values: unknown[]) {
|
||||
const status = String(module?.status || '').trim()
|
||||
if (status !== 'success' && status !== 'cached')
|
||||
return null
|
||||
return firstFiniteNumber(values)
|
||||
}
|
||||
|
||||
function firstFiniteNumber(values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null || value === '')
|
||||
continue
|
||||
const number = Number(value)
|
||||
if (Number.isFinite(number))
|
||||
return number
|
||||
}
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,9 @@
|
||||
import { balanceRechargeRefund } from '../api/balance'
|
||||
import type { AppOrder, AppOrderItem } from '../api/order'
|
||||
import { getOrderDetail, getOrderList } from '../api/order'
|
||||
import RouterPath from '@/constant/RouterPath'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { hasAnyFeature } from '@/utils/permission'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
@@ -13,6 +16,7 @@ type OrderTypeFilter = 'all' | 'renew' | 'recharge'
|
||||
type OrderStatusFilter = 'all' | 'pending' | 'paid'
|
||||
|
||||
const keyword = ref('')
|
||||
const userStore = useUserStore()
|
||||
const orderType = ref<OrderTypeFilter>('all')
|
||||
const orderStatus = ref<OrderStatusFilter>('all')
|
||||
const page = ref(1)
|
||||
@@ -328,6 +332,13 @@ async function handleDetail(item: AppOrder) {
|
||||
onMounted(() => {
|
||||
void loadOrders(true)
|
||||
})
|
||||
function handleCreateWorkOrder() {
|
||||
if (!selectedOrder.value?.orderId || !hasAnyFeature(userStore.userInfo, 'workorder:create')) {
|
||||
uni.showToast({ title: '当前订单无法提交工单', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `${RouterPath.BusinessViewsWorkOrderCreate}?woType=3&relatedId=${selectedOrder.value?.orderId}&context=${encodeURIComponent(`订单:${selectedOrder.value?.orderSn || ''}`)}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -505,6 +516,13 @@ onMounted(() => {
|
||||
</wd-cell-group>
|
||||
</wd-card>
|
||||
|
||||
<wd-button
|
||||
v-if="selectedOrder?.orderId && hasAnyFeature(userStore.userInfo, 'workorder:create')"
|
||||
plain
|
||||
@click="handleCreateWorkOrder"
|
||||
>
|
||||
反馈订单问题
|
||||
</wd-button>
|
||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||
<template #title>
|
||||
<view class="h-80rpx flex items-center px-24rpx">
|
||||
@@ -542,8 +560,8 @@ onMounted(() => {
|
||||
|
||||
<wd-button
|
||||
v-if="canRequestRefund(selectedOrder)"
|
||||
block
|
||||
plain
|
||||
|
||||
plain block
|
||||
type="primary"
|
||||
:loading="refunding"
|
||||
@click="handleRefund"
|
||||
@@ -553,10 +571,9 @@ onMounted(() => {
|
||||
|
||||
<wd-button
|
||||
v-else-if="isRefundPending(selectedOrder)"
|
||||
block
|
||||
plain
|
||||
|
||||
type="primary"
|
||||
disabled
|
||||
plain disabled block
|
||||
>
|
||||
{{ refundStatusText(selectedOrder) }}
|
||||
</wd-button>
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
import type { AppCard } from '@/api/card'
|
||||
import type { WarningConfigBatchItemParams } from '../api/warning'
|
||||
import { getCardList } from '@/api/card'
|
||||
import { bindWechat, getWxCode } from '@/api/login'
|
||||
import RouterPath from '@/constant/RouterPath'
|
||||
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: {
|
||||
@@ -40,20 +36,12 @@ interface WarningForm {
|
||||
|
||||
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
|
||||
|
||||
@@ -96,22 +84,6 @@ const hiddenCardCount = computed(() => Math.max(0, visibleCards.value.length - d
|
||||
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() {
|
||||
@@ -137,20 +109,6 @@ async function loadCards() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -217,7 +175,6 @@ function buildConfigPayload(validate: boolean): WarningConfigBatchItemParams[] {
|
||||
secondWarn,
|
||||
thirdWarn,
|
||||
isEnabled: form.enabled ? 1 : 0,
|
||||
appNotifyEnabled: appNotifyEnabled.value ? 1 : 0,
|
||||
})
|
||||
}
|
||||
if (validate && !configs.length)
|
||||
@@ -237,84 +194,6 @@ function validateThreshold(form: WarningForm, firstWarn: number, secondWarn: num
|
||||
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' })
|
||||
@@ -326,24 +205,14 @@ async function handleSubmit() {
|
||||
|
||||
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),
|
||||
content: buildResultText(res),
|
||||
showCancel: false,
|
||||
success: () => {
|
||||
if (shouldGuideSettings)
|
||||
guideWechatSubscribeSettings(subscribeResult!)
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
@@ -359,7 +228,7 @@ function buildResultText(res: {
|
||||
successCount?: number
|
||||
failCount?: number
|
||||
failItems?: Array<{ cardValue: string, reason: string }>
|
||||
}, subscribeResult?: Awaited<ReturnType<typeof requestWechatSubscribeMessage>>) {
|
||||
}) {
|
||||
const lines = [
|
||||
`批次号:${res.batchNo || '-'}`,
|
||||
`成功卡数:${res.successCount || 0} 张`,
|
||||
@@ -370,16 +239,13 @@ function buildResultText(res: {
|
||||
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 goMessageCenter() {
|
||||
uni.navigateTo({ url: RouterPath.Message })
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === 'object') {
|
||||
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
|
||||
@@ -404,7 +270,6 @@ watch(selectedCardValues, (values) => {
|
||||
|
||||
onMounted(() => {
|
||||
void loadCards()
|
||||
void loadMeta()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -419,11 +284,11 @@ onMounted(() => {
|
||||
</view>
|
||||
<view class="min-w-0">
|
||||
<text class="biz-hero__title">流量预警</text>
|
||||
<text class="biz-hero__sub">选择卡片后统一配置微信订阅提醒与流量阈值</text>
|
||||
<text class="biz-hero__sub">选择卡片后统一配置流量预警阈值,预警通知在消息中心查看</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-tag :type="templateReady ? 'primary' : 'warning'" variant="plain">
|
||||
{{ subscribeText }}
|
||||
<wd-tag type="primary" variant="plain">
|
||||
账号级提醒设置
|
||||
</wd-tag>
|
||||
</view>
|
||||
|
||||
@@ -432,70 +297,12 @@ onMounted(() => {
|
||||
<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-cell-group border>
|
||||
<wd-cell title="消息中心" label="预警触发后可查看站内通知" value="查看消息" is-link @click="goMessageCenter" />
|
||||
</wd-cell-group>
|
||||
|
||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||
<template #title>
|
||||
@@ -689,10 +496,10 @@ onMounted(() => {
|
||||
<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>
|
||||
<text class="biz-paybar__value">预警通知在消息中心查看</text>
|
||||
</view>
|
||||
<wd-button type="primary" block custom-class="biz-paybar-btn" :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
|
||||
{{ submitButtonText }}
|
||||
保存预警配置
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('工单列表筛选布局', () => {
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
|
||||
it('六个状态使用一行标签而非换行按钮', () => {
|
||||
expect(source).toContain('<wd-tabs v-model="status" :slidable-num="6"')
|
||||
expect(source).toContain('<wd-tab v-for="option in statusOptions"')
|
||||
expect(source).not.toContain('<wd-button v-for="option in statusOptions"')
|
||||
})
|
||||
|
||||
it('搜索和筛选在同一区块且切换仍刷新列表', () => {
|
||||
const searchEnd = source.indexOf('@clear="loadWorkOrders()"')
|
||||
const tabsStart = source.indexOf('<wd-tabs v-model="status"')
|
||||
expect(searchEnd).toBeGreaterThan(0)
|
||||
expect(tabsStart).toBeGreaterThan(searchEnd)
|
||||
expect(source.slice(searchEnd, tabsStart)).not.toContain('</wd-card>')
|
||||
expect(source).toContain('watch(status, () => {')
|
||||
expect(source).toContain('currentStatus: status.value || undefined')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import type { AppWorkOrder } from '../api/workOrder'
|
||||
import { getWorkOrderList } from '../api/workOrder'
|
||||
import RouterPath from '@/constant/RouterPath'
|
||||
@@ -8,21 +9,42 @@ import { hasAnyFeature } from '@/utils/permission'
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '工单列表',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const noPermission = ref(false)
|
||||
const loadError = ref('')
|
||||
const keyword = ref('')
|
||||
const workOrders = ref<AppWorkOrder[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const status = ref(0)
|
||||
const scope = ref<'all' | 'submitted' | 'received'>('all')
|
||||
const canReceive = computed(() => ['workorder:reply', 'workorder:resolve', 'workorder:transfer', 'workorder:close'].some(feature => hasAnyFeature(userStore.userInfo, feature)))
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: 0 },
|
||||
{ label: '已分派', value: 1 },
|
||||
{ label: '处理中', value: 2 },
|
||||
{ label: '待确认', value: 3 },
|
||||
{ label: '已完结', value: 4 },
|
||||
{ label: '已取消', value: 5 },
|
||||
]
|
||||
let requestVersion = 0
|
||||
let initialized = false
|
||||
const hasMore = computed(() => workOrders.value.length < total.value)
|
||||
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() {
|
||||
async function loadWorkOrders(append = false) {
|
||||
if (append && (loading.value || !hasMore.value))
|
||||
return
|
||||
const version = ++requestVersion
|
||||
const nextPage = append ? page.value + 1 : 1
|
||||
if (!canViewWorkOrder.value) {
|
||||
noPermission.value = !canCreateWorkOrder.value
|
||||
workOrders.value = []
|
||||
@@ -30,23 +52,31 @@ async function loadWorkOrders() {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
noPermission.value = false
|
||||
try {
|
||||
const res = await getWorkOrderList({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
scope: scope.value,
|
||||
page: nextPage,
|
||||
pageSize: 20,
|
||||
currentStatus: status.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
})
|
||||
workOrders.value = res.items || []
|
||||
if (version !== requestVersion)
|
||||
return
|
||||
workOrders.value = append ? [...workOrders.value, ...(res.items || [])] : (res.items || [])
|
||||
total.value = res.total || 0
|
||||
page.value = nextPage
|
||||
}
|
||||
catch (error) {
|
||||
workOrders.value = []
|
||||
total.value = 0
|
||||
uni.showToast({ title: getErrorMessage(error, '工单加载失败'), icon: 'none' })
|
||||
if (version !== requestVersion)
|
||||
return
|
||||
loadError.value = getErrorMessage(error, '工单加载失败,请重试')
|
||||
uni.showToast({ title: loadError.value, icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
if (version === requestVersion)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +133,12 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onShow(async () => {
|
||||
if (initialized) {
|
||||
void loadWorkOrders()
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
await userStore.fetchUserInfo().catch(() => undefined)
|
||||
if (!canEnterWorkOrder.value) {
|
||||
noPermission.value = true
|
||||
@@ -112,93 +147,137 @@ onMounted(async () => {
|
||||
}
|
||||
void loadWorkOrders()
|
||||
})
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadWorkOrders()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
onReachBottom(() => {
|
||||
void loadWorkOrders(true)
|
||||
})
|
||||
watch(scope, () => {
|
||||
workOrders.value = []
|
||||
total.value = 0
|
||||
void loadWorkOrders()
|
||||
})
|
||||
watch(status, () => {
|
||||
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 class="min-h-screen bg-#f5f7fa p-24rpx pb-48rpx">
|
||||
<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 flex-1">
|
||||
<view class="text-34rpx text-#111827 font-700">
|
||||
我的工单
|
||||
</view>
|
||||
<view class="mt-8rpx text-24rpx text-#64748b">
|
||||
{{ canViewWorkOrder ? `共 ${total} 条,按最新提交时间排序` : '当前账号仅可提交新工单' }}
|
||||
</view>
|
||||
</view>
|
||||
<wd-button v-if="canCreateWorkOrder" size="small" type="primary" custom-class="!shrink-0" @click="handleCreate">
|
||||
提交工单
|
||||
</wd-button>
|
||||
</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>
|
||||
<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>
|
||||
</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 class="text-28rpx text-#111827 font-800">
|
||||
可以提交新工单
|
||||
</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 class="text-24rpx text-#64748b leading-36rpx">
|
||||
当前角色未配置工单列表权限,提交后可由平台客服继续处理。
|
||||
</view>
|
||||
</wd-card>
|
||||
<wd-button type="primary" block @click="handleCreate">
|
||||
提交工单
|
||||
</wd-button>
|
||||
</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">
|
||||
<wd-tabs v-if="canViewWorkOrder && canReceive" v-model="scope">
|
||||
<wd-tab name="all" title="全部相关" />
|
||||
<wd-tab name="submitted" title="我提交的" />
|
||||
<wd-tab name="received" title="我收到的" />
|
||||
</wd-tabs>
|
||||
|
||||
<wd-card v-if="canViewWorkOrder" custom-class="!mx-0 !mb-0 !overflow-hidden" custom-content-class="!p-0">
|
||||
<wd-search
|
||||
v-model="keyword"
|
||||
placeholder="搜索工单编号、标题或内容"
|
||||
hide-cancel
|
||||
custom-class="!px-20rpx !pt-20rpx !pb-8rpx"
|
||||
@search="loadWorkOrders()"
|
||||
@clear="loadWorkOrders()"
|
||||
/>
|
||||
<wd-tabs v-model="status" :slidable-num="6" line-theme="normal" :line-width="16" :line-height="2" inactive-color="#64748b" custom-class="!bg-transparent [--wot-tabs-nav-item-padding:24rpx_0] [--wot-tabs-nav-item-font-size:24rpx]">
|
||||
<wd-tab v-for="option in statusOptions" :key="option.value" :name="option.value" :title="option.label" />
|
||||
</wd-tabs>
|
||||
</wd-card>
|
||||
|
||||
<wd-card v-if="canViewWorkOrder && loading && !workOrders.length" custom-class="!mx-0 !mb-0">
|
||||
<wd-skeleton theme="paragraph" />
|
||||
</wd-card>
|
||||
|
||||
<wd-card v-else-if="canViewWorkOrder && loadError && !workOrders.length" custom-class="!mx-0 !mb-0">
|
||||
<view class="py-24rpx text-center text-26rpx text-#64748b">
|
||||
{{ loadError }}
|
||||
</view>
|
||||
<wd-button block type="primary" variant="plain" @click="loadWorkOrders()">
|
||||
重新加载
|
||||
</wd-button>
|
||||
</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>
|
||||
|
||||
<view v-else-if="canViewWorkOrder" class="flex flex-col gap-20rpx">
|
||||
<wd-card v-for="item in workOrders" :key="item.woNo" custom-class="!mx-0 !mb-0" custom-content-class="!p-24rpx">
|
||||
<view class="flex items-start justify-between gap-20rpx">
|
||||
<text class="min-w-0 flex-1 break-all text-28rpx text-#0f172a font-600 leading-42rpx">{{ item.title }}</text>
|
||||
<view class="shrink-0">
|
||||
<wd-tag :type="tagType(item.statusType)" variant="light">
|
||||
{{ item.currentStatusText || '处理中' }}
|
||||
</wd-tag>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mt-12rpx text-24rpx text-#64748b">
|
||||
{{ item.woTypeText || '工单' }} · {{ workOrderTime(item) }}
|
||||
</view>
|
||||
<view class="mt-24rpx flex items-center justify-between gap-16rpx border-t border-#f1f5f9 pt-16rpx">
|
||||
<text class="min-w-0 flex-1 break-all text-22rpx text-#94a3b8">{{ item.woNo }}</text>
|
||||
<wd-button v-if="canViewWorkOrderDetail" type="primary" variant="text" size="small" custom-class="!shrink-0" @click="handleDetail(item)">
|
||||
{{ [4, 5].includes(item.currentStatus) ? '查看详情' : '沟通跟进' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</wd-card>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button v-if="canViewWorkOrder && workOrders.length && hasMore" block type="info" :loading="loading" @click="loadWorkOrders(true)">
|
||||
加载更多
|
||||
</wd-button>
|
||||
<view v-else-if="canViewWorkOrder && workOrders.length" class="py-16rpx text-center text-24rpx text-#94a3b8">
|
||||
已显示全部工单
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
|
||||
function harness(create = vi.fn().mockResolvedValue({ workOrder: { woNo: 'WO-1' } })) {
|
||||
const context = createContext({
|
||||
exports: {},
|
||||
require: (name: string) => {
|
||||
if (name === '@/store/user')
|
||||
return { useUserStore: () => ({ userInfo: { phone: '13800008000' } }) }
|
||||
if (name === '../api/workOrder')
|
||||
return { createWorkOrder: create }
|
||||
if (name === '@/utils/permission')
|
||||
return { hasAnyFeature: () => true }
|
||||
return {}
|
||||
},
|
||||
uni: { showToast: vi.fn() },
|
||||
setTimeout: vi.fn(),
|
||||
ref: (value: unknown) => ({ value }),
|
||||
reactive: (value: unknown) => value,
|
||||
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
|
||||
definePage: vi.fn(),
|
||||
onLoad: vi.fn(),
|
||||
onMounted: vi.fn(),
|
||||
})
|
||||
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
|
||||
if (!script)
|
||||
throw new Error('未找到新建工单脚本')
|
||||
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2020 } }).outputText, context)
|
||||
return <Result>(expression: string) => runInContext(expression, context) as Result
|
||||
}
|
||||
|
||||
describe('工单类型选择入口', () => {
|
||||
it('使用可见单元格打开单列选择器', () => {
|
||||
expect(source).toContain('title="工单类型"')
|
||||
expect(source).toContain('@click="openTypePicker"')
|
||||
expect(source).toContain('v-model:visible="typePickerOpen"')
|
||||
expect(source).toContain(':columns="[problemTypes]"')
|
||||
const evaluate = harness()
|
||||
evaluate('openTypePicker()')
|
||||
expect(evaluate('typePickerOpen.value')).toBe(true)
|
||||
evaluate('formData.woType = [3]')
|
||||
expect(evaluate('typeText.value')).toBe('订单问题')
|
||||
})
|
||||
|
||||
it('关联业务及提交期间不允许更改类型', () => {
|
||||
for (const guard of ['relatedId.value = 1', 'submitting.value = true', 'submitted.value = true']) {
|
||||
const evaluate = harness()
|
||||
evaluate(guard)
|
||||
evaluate('openTypePicker()')
|
||||
expect(evaluate('typePickerOpen.value')).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('新建工单表单与提交', () => {
|
||||
it('默认填入账号手机号,允许修改后随工单提交', async () => {
|
||||
const create = vi.fn().mockResolvedValue({ workOrder: {} })
|
||||
const evaluate = harness(create)
|
||||
expect(source).toContain('<wd-form-item title="创建人手机号"')
|
||||
expect(evaluate('formData.creatorPhone')).toBe('13800008000')
|
||||
evaluate('formData.title = "标题"; formData.content = "描述"; formData.creatorPhone = " 13900009000 "')
|
||||
await evaluate<Promise<void>>('handleSubmit()')
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ creatorPhone: '13900009000' }))
|
||||
expect(evaluate('userStore.userInfo.phone')).toBe('13800008000')
|
||||
})
|
||||
|
||||
it.each(['', ' ', '123', '12800138000', '138001380000', '1380013800a'])('阻止无效手机号:%s', async (phone) => {
|
||||
const create = vi.fn()
|
||||
const evaluate = harness(create)
|
||||
evaluate(`formData.title = "标题"; formData.content = "描述"; formData.creatorPhone = ${JSON.stringify(phone)}`)
|
||||
await evaluate<Promise<void>>('handleSubmit()')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(evaluate('submitting.value')).toBe(false)
|
||||
})
|
||||
|
||||
it('标题通过表单项显示,提交成功后禁止修改附件', () => {
|
||||
expect(source).toContain('<wd-form-item title="工单标题"')
|
||||
expect(source).not.toContain('label-width=')
|
||||
expect(source).toContain('<WorkOrderAttachments v-model="attachmentUrls" :disabled="submitting || submitted"')
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3, 4, 5])('正确提交工单类型 %s、文字与附件', async (type) => {
|
||||
const create = vi.fn().mockResolvedValue({ workOrder: { woNo: 'WO-1' } })
|
||||
const evaluate = harness(create)
|
||||
evaluate(`formData.woType = [${type}]; formData.title = ' 标题 '; formData.content = ' 描述 '; attachmentUrls.value = ['file.pdf']`)
|
||||
await evaluate<Promise<void>>('handleSubmit()')
|
||||
expect(create).toHaveBeenCalledWith({ woType: type, title: '标题', content: '描述', creatorPhone: '13800008000', attachmentUrls: ['file.pdf'], relatedId: undefined })
|
||||
await evaluate<Promise<void>>('handleSubmit()')
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['formData.woType = []', 'formData.woType = [99]', 'formData.title = " "', 'formData.content = " "', 'uploading.value = true'])('阻止无效提交:%s', async (condition) => {
|
||||
const create = vi.fn()
|
||||
const evaluate = harness(create)
|
||||
evaluate('formData.title = "标题"; formData.content = "描述"')
|
||||
evaluate(condition)
|
||||
await evaluate<Promise<void>>('handleSubmit()')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('提交失败保留表单并可重试', async () => {
|
||||
const create = vi.fn().mockRejectedValueOnce(new Error('network')).mockResolvedValue({ workOrder: {} })
|
||||
const evaluate = harness(create)
|
||||
evaluate('formData.title = "标题"; formData.content = "描述"; attachmentUrls.value = ["file.pdf"]')
|
||||
await evaluate<Promise<void>>('handleSubmit()')
|
||||
expect(evaluate('formData.title')).toBe('标题')
|
||||
expect(evaluate('attachmentUrls.value.length')).toBe(1)
|
||||
expect(evaluate('submitting.value')).toBe(false)
|
||||
expect(evaluate('submitted.value')).toBe(false)
|
||||
await evaluate<Promise<void>>('handleSubmit()')
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,8 @@
|
||||
import { createWorkOrder } from '../api/workOrder'
|
||||
import RouterPath from '@/constant/RouterPath'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useTokenStore } from '@/store/token'
|
||||
import WorkOrderAttachments from '@/components/WorkOrderAttachments.vue'
|
||||
import { hasAnyFeature } from '@/utils/permission'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
@@ -24,14 +23,27 @@ const problemTypes = [
|
||||
const userStore = useUserStore()
|
||||
const noPermission = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
const uploading = ref(false)
|
||||
const attachmentUrls = ref<string[]>([])
|
||||
const attachmentNames = ref<string[]>([])
|
||||
const relatedId = ref<number>()
|
||||
const typePickerOpen = ref(false)
|
||||
const canCreateWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:create'))
|
||||
const formData = reactive({
|
||||
woType: [0],
|
||||
title: '',
|
||||
content: '',
|
||||
creatorPhone: userStore.userInfo?.phone || '',
|
||||
})
|
||||
|
||||
onLoad((query) => {
|
||||
const type = Number(query?.woType)
|
||||
const id = Number(query?.relatedId)
|
||||
if ([0, 3].includes(type) && Number.isSafeInteger(id) && id > 0) {
|
||||
formData.woType = [type]
|
||||
relatedId.value = id
|
||||
formData.content = String(query?.context || '').slice(0, 500)
|
||||
}
|
||||
})
|
||||
|
||||
const typeText = computed(() => {
|
||||
@@ -39,107 +51,27 @@ const typeText = computed(() => {
|
||||
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' })
|
||||
function openTypePicker() {
|
||||
if (relatedId.value || submitting.value || submitted.value)
|
||||
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)
|
||||
typePickerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (submitting.value || submitted.value || uploading.value)
|
||||
return
|
||||
if (!canCreateWorkOrder.value) {
|
||||
uni.showToast({ title: '暂无提交工单权限', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const title = formData.title.trim()
|
||||
const content = formData.content.trim()
|
||||
const creatorPhone = formData.creatorPhone.trim()
|
||||
const woType = Number(formData.woType[0])
|
||||
if (!problemTypes.some(item => item.value === woType)) {
|
||||
uni.showToast({ title: '请选择有效的工单类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!title) {
|
||||
uni.showToast({ title: '请输入工单标题', icon: 'none' })
|
||||
return
|
||||
@@ -149,24 +81,33 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(creatorPhone)) {
|
||||
uni.showToast({ title: creatorPhone ? '请输入正确的手机号' : '请输入创建人手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await createWorkOrder({
|
||||
woType: Number(formData.woType[0] ?? 0),
|
||||
woType,
|
||||
title,
|
||||
content,
|
||||
creatorPhone,
|
||||
attachmentUrls: attachmentUrls.value,
|
||||
relatedId: relatedId.value,
|
||||
})
|
||||
const woNo = res.workOrder?.woNo || ''
|
||||
submitted.value = true
|
||||
uni.showToast({ title: '工单已提交', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
if (woNo) {
|
||||
if (woNo && hasAnyFeature(userStore.userInfo, 'workorder:detail')) {
|
||||
uni.redirectTo({
|
||||
url: `${RouterPath.BusinessViewsWorkOrderDetail}?woNo=${encodeURIComponent(woNo)}`,
|
||||
fail: () => uni.navigateBack({ fail: () => uni.switchTab({ url: RouterPath.Business }) }),
|
||||
})
|
||||
}
|
||||
else {
|
||||
uni.navigateBack()
|
||||
uni.navigateBack({ fail: () => uni.switchTab({ url: RouterPath.Business }) })
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
@@ -195,6 +136,8 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.fetchUserInfo().catch(() => undefined)
|
||||
if (!formData.creatorPhone)
|
||||
formData.creatorPhone = userStore.userInfo?.phone || ''
|
||||
if (!canCreateWorkOrder.value) {
|
||||
noPermission.value = true
|
||||
uni.showToast({ title: '当前账号不能提交工单', icon: 'none' })
|
||||
@@ -203,91 +146,107 @@ onMounted(async () => {
|
||||
</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 class="min-h-screen bg-#f5f7fa p-24rpx pb-48rpx">
|
||||
<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-700">提交工单</text>
|
||||
<text class="mt-8rpx block text-24rpx text-#64748b leading-36rpx">
|
||||
提交后由服务部门跟进,可在工单详情查看进度
|
||||
</text>
|
||||
</view>
|
||||
</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-form :model="formData" error-type="toast">
|
||||
<view class="flex flex-col gap-20rpx">
|
||||
<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>
|
||||
<text class="text-28rpx text-#0f172a font-600">基础信息</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
|
||||
title="工单类型"
|
||||
:value="typeText"
|
||||
:label="relatedId ? '已关联业务,类型不可更改' : '请选择本次工单的问题分类'"
|
||||
:is-link="!relatedId && !submitting && !submitted"
|
||||
center
|
||||
custom-title-class="!text-28rpx !text-#334155"
|
||||
custom-value-class="!text-26rpx !text-#2563eb"
|
||||
@click="openTypePicker"
|
||||
/>
|
||||
<wd-form-item title="工单标题" prop="title" required title-width="96px" custom-title-class="!text-28rpx !text-#334155">
|
||||
<wd-input
|
||||
v-model="formData.title"
|
||||
:disabled="submitting || submitted"
|
||||
placeholder="请输入简短标题"
|
||||
:maxlength="100"
|
||||
compact
|
||||
clearable
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item title="创建人手机号" prop="creatorPhone" required title-width="96px" custom-title-class="!text-28rpx !text-#334155">
|
||||
<wd-input
|
||||
v-model="formData.creatorPhone"
|
||||
:disabled="submitting || submitted"
|
||||
type="number"
|
||||
placeholder="请输入创建人手机号"
|
||||
:maxlength="11"
|
||||
compact
|
||||
clearable
|
||||
/>
|
||||
</wd-form-item>
|
||||
</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>
|
||||
<text class="text-28rpx text-#0f172a font-600">问题描述</text>
|
||||
</view>
|
||||
</template>
|
||||
<wd-textarea
|
||||
v-model="formData.content"
|
||||
:disabled="submitting || submitted"
|
||||
placeholder="请描述问题现象、发生时间和已尝试的处理方式"
|
||||
:maxlength="500"
|
||||
:maxlength="2000"
|
||||
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 class="mt-20rpx">
|
||||
<WorkOrderAttachments v-model="attachmentUrls" :disabled="submitting || submitted" @uploading="uploading = $event" />
|
||||
</view>
|
||||
</wd-card>
|
||||
</wd-form>
|
||||
</view>
|
||||
</wd-form>
|
||||
|
||||
<wd-button block type="primary" :loading="submitting" :disabled="!canCreateWorkOrder || uploading" @click="handleSubmit">
|
||||
提交工单
|
||||
</wd-button>
|
||||
</template>
|
||||
</view>
|
||||
<wd-button block type="primary" :round="false" :loading="submitting" :disabled="!canCreateWorkOrder || submitting || uploading || submitted" @click="handleSubmit">
|
||||
提交工单
|
||||
</wd-button>
|
||||
</template>
|
||||
</view>
|
||||
<wd-picker
|
||||
v-model="formData.woType"
|
||||
v-model:visible="typePickerOpen"
|
||||
title="选择工单类型"
|
||||
:columns="[problemTypes]"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isWorkOrderImage, workOrderAttachmentFilename, workOrderAttachmentName } from '@/utils/workOrderAttachment'
|
||||
|
||||
function harness() {
|
||||
const fetchUserInfo = vi.fn().mockResolvedValue(undefined)
|
||||
const act = vi.fn().mockResolvedValue({})
|
||||
const detail = vi.fn().mockResolvedValue({ workOrder: { actions: ['reply'], communications: [] } })
|
||||
const lifecycle: Record<string, () => unknown> = {}
|
||||
const setInterval = vi.fn().mockReturnValue(1)
|
||||
const clearInterval = vi.fn()
|
||||
const downloadFile = vi.fn()
|
||||
const openDocument = vi.fn()
|
||||
const fileManager = {
|
||||
mkdir: vi.fn((options: { success: () => void }) => options.success()),
|
||||
copyFile: vi.fn((options: { srcPath: string, destPath: string, success: () => void }) => options.success()),
|
||||
unlink: vi.fn((options: { complete: () => void }) => options.complete()),
|
||||
rmdir: vi.fn(),
|
||||
}
|
||||
const context = createContext({
|
||||
setInterval,
|
||||
clearInterval,
|
||||
exports: {},
|
||||
require: (name: string) => {
|
||||
if (name === '@dcloudio/uni-app') {
|
||||
return {
|
||||
onLoad: (callback: () => unknown) => { lifecycle.load = callback },
|
||||
onShow: (callback: () => unknown) => { lifecycle.show = callback },
|
||||
onHide: (callback: () => unknown) => { lifecycle.hide = callback },
|
||||
onUnload: (callback: () => unknown) => { lifecycle.unload = callback },
|
||||
}
|
||||
}
|
||||
if (name === '../api/workOrder')
|
||||
return { actOnWorkOrder: act, getWorkOrderDetail: detail }
|
||||
if (name === '@/store/user')
|
||||
return { useUserStore: () => ({ userInfo: {}, fetchUserInfo }) }
|
||||
if (name === '@/utils/workOrderAttachment')
|
||||
return { isWorkOrderImage, workOrderAttachmentName, workOrderAttachmentFilename }
|
||||
if (name === '@/utils')
|
||||
return { getEnvBaseUrl: () => 'https://api.example.com/api/v1' }
|
||||
if (name === '@/utils/permission')
|
||||
return { hasAnyFeature: (user: { revoked?: boolean }) => !user.revoked }
|
||||
return {}
|
||||
},
|
||||
ref: (value: unknown) => ({ value }),
|
||||
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
|
||||
nextTick: () => Promise.resolve(),
|
||||
definePage: vi.fn(),
|
||||
uni: { showToast: vi.fn(), previewImage: vi.fn(), downloadFile, openDocument },
|
||||
wx: { env: { USER_DATA_PATH: '/user-data' }, getFileSystemManager: () => fileManager },
|
||||
})
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]?.replace(/\/\/ #ifdef H5[\s\S]*?\/\/ #endif/g, '').replace(/\/\/ #ifndef MP-WEIXIN[\s\S]*?\/\/ #endif/g, '')
|
||||
if (!script)
|
||||
throw new Error('未找到工单详情脚本')
|
||||
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2020 } }).outputText, context)
|
||||
runInContext('woNo.value = \'WO-1\'; workOrder.value = { actions: [\'reply\'], communications: [] }', context)
|
||||
return { act, detail, fetchUserInfo, lifecycle, setInterval, clearInterval, fileManager, downloadFile, openDocument, evaluate: <Result>(expression: string) => runInContext(expression, context) as Result }
|
||||
}
|
||||
|
||||
describe('工单首次加载状态', () => {
|
||||
it('权限和详情请求完成前持续展示骨架屏', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value = undefined')
|
||||
expect(page.evaluate('loading.value')).toBe(true)
|
||||
let finishUser!: () => void
|
||||
let finishDetail!: (value: unknown) => void
|
||||
page.fetchUserInfo.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finishUser = resolve
|
||||
}))
|
||||
page.detail.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
finishDetail = resolve
|
||||
}))
|
||||
const showing = page.lifecycle.show()
|
||||
expect(page.evaluate('loading.value')).toBe(true)
|
||||
expect(page.detail).not.toHaveBeenCalled()
|
||||
finishUser()
|
||||
await vi.waitFor(() => expect(page.detail).toHaveBeenCalledOnce())
|
||||
expect(page.evaluate('loading.value')).toBe(true)
|
||||
finishDetail({ workOrder: { actions: ['reply'], communications: [] } })
|
||||
await showing
|
||||
expect(page.evaluate('loading.value')).toBe(false)
|
||||
expect(page.evaluate('workOrder.value')).toBeDefined()
|
||||
})
|
||||
|
||||
it('详情请求真正失败后仍可重试', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value = undefined')
|
||||
page.detail.mockRejectedValueOnce(new Error('network'))
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('loading.value')).toBe(false)
|
||||
expect(page.evaluate('workOrder.value')).toBeUndefined()
|
||||
await page.evaluate<Promise<void>>('loadDetail()')
|
||||
expect(page.evaluate('workOrder.value')).toBeDefined()
|
||||
expect(page.evaluate('loading.value')).toBe(false)
|
||||
})
|
||||
|
||||
it('权限不足时结束加载并显示无权限提示', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value = undefined; userStore.userInfo.revoked = true')
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('noPermission.value')).toBe(true)
|
||||
expect(page.evaluate('loading.value')).toBe(false)
|
||||
expect(page.detail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('通知缺少工单编号时结束加载', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value = undefined; woNo.value = ""')
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('loading.value')).toBe(false)
|
||||
expect(page.detail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('刷新权限时保留已有工单内容', async () => {
|
||||
const page = harness()
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('loading.value')).toBe(false)
|
||||
expect(page.evaluate('workOrder.value')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('工单对话回复', () => {
|
||||
it('发送后保持窗口打开,清空草稿并允许继续发送', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('showConversation.value = true; replyContent.value = \'第一条\'')
|
||||
await page.evaluate<Promise<void>>('performAction(\'reply\')')
|
||||
expect(page.evaluate('showConversation.value')).toBe(true)
|
||||
expect(page.evaluate('replyContent.value')).toBe('')
|
||||
expect(page.evaluate('conversationTarget.value')).toBe('conversation-bottom')
|
||||
page.evaluate('replyContent.value = \'第二条\'')
|
||||
await page.evaluate<Promise<void>>('performAction(\'reply\')')
|
||||
expect(page.act).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('发送失败保留文字和附件草稿', async () => {
|
||||
const page = harness()
|
||||
page.act.mockRejectedValueOnce(new Error('network'))
|
||||
page.evaluate('replyContent.value = \'待发送\'; replyAttachments.value = [\'file.pdf\']')
|
||||
await page.evaluate<Promise<void>>('performAction(\'reply\')')
|
||||
expect(page.evaluate('replyContent.value')).toBe('待发送')
|
||||
expect(page.evaluate('replyAttachments.value.length')).toBe(1)
|
||||
expect(page.evaluate('sending.value')).toBe(false)
|
||||
})
|
||||
|
||||
it('禁止空消息、上传期间发送和无权限回复', async () => {
|
||||
const page = harness()
|
||||
await page.evaluate<Promise<void>>('performAction(\'reply\')')
|
||||
page.evaluate('replyContent.value = \'消息\'; uploading.value = true')
|
||||
await page.evaluate<Promise<void>>('performAction(\'reply\')')
|
||||
page.evaluate('uploading.value = false; workOrder.value.actions = []')
|
||||
await page.evaluate<Promise<void>>('performAction(\'reply\')')
|
||||
expect(page.act).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('工单评价与刷新', () => {
|
||||
it('评价使用独立草稿,不把未发送的沟通内容当成评价', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value.actions = ["evaluate"]; replyContent.value = "未发出的沟通"; evaluationContent.value = "服务很好"')
|
||||
await page.evaluate<Promise<void>>('performAction("evaluate")')
|
||||
expect(page.act).toHaveBeenCalledWith(expect.objectContaining({ action: 'evaluate', content: '服务很好', satisfactionLevel: 5 }))
|
||||
expect(page.evaluate('replyContent.value')).toBe('未发出的沟通')
|
||||
expect(page.evaluate('evaluationContent.value')).toBe('')
|
||||
})
|
||||
|
||||
it('评价失败保留评价草稿,无效评分不提交', async () => {
|
||||
const page = harness()
|
||||
page.act.mockRejectedValueOnce(new Error('network'))
|
||||
page.evaluate('workOrder.value.actions = ["evaluate"]; evaluationContent.value = "评价草稿"')
|
||||
await page.evaluate<Promise<void>>('performAction("evaluate")')
|
||||
expect(page.evaluate('evaluationContent.value')).toBe('评价草稿')
|
||||
for (const rating of [0, 6, 4.5]) {
|
||||
page.evaluate(`workOrder.value.actions = ["evaluate"]; rating.value = ${rating}`)
|
||||
await page.evaluate<Promise<void>>('performAction("evaluate")')
|
||||
}
|
||||
expect(page.act).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('操作成功后等待旧轮询结束,再获取最新详情', async () => {
|
||||
const page = harness()
|
||||
let finish!: (value: unknown) => void
|
||||
page.detail.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
finish = resolve
|
||||
}))
|
||||
page.detail.mockResolvedValueOnce({ workOrder: { actions: ['reply'], currentStatus: 2, communications: [{ id: 2, content: '新回复' }] } })
|
||||
const polling = page.evaluate<Promise<void>>('loadDetail(true)')
|
||||
page.evaluate('replyContent.value = "新回复"')
|
||||
const sending = page.evaluate<Promise<void>>('performAction("reply")')
|
||||
await vi.waitFor(() => expect(page.act).toHaveBeenCalledTimes(1))
|
||||
expect(page.detail).toHaveBeenCalledTimes(1)
|
||||
finish({ workOrder: { actions: ['reply'], currentStatus: 1, communications: [] } })
|
||||
await Promise.all([polling, sending])
|
||||
expect(page.detail).toHaveBeenCalledTimes(2)
|
||||
expect(page.evaluate('workOrder.value.currentStatus')).toBe(2)
|
||||
expect(page.evaluate('workOrder.value.communications[0].content')).toBe('新回复')
|
||||
})
|
||||
})
|
||||
|
||||
describe('工单附件原名展示', () => {
|
||||
it('正文和消息附件都显示名称,不再使用附件序号', () => {
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
expect(source.match(/attachmentName\(url\)/g)?.length).toBe(2)
|
||||
expect(source).not.toContain('附件 {{ index + 1 }}')
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value.attachmentNames = { "random.pdf": "排查说明.pdf" }')
|
||||
expect(page.evaluate('attachmentName("random.pdf")')).toBe('排查说明.pdf')
|
||||
expect(page.evaluate('attachmentName("old.xlsx")')).toBe('old.xlsx')
|
||||
})
|
||||
|
||||
it('加载历史和轮询刷新后仍保留历史附件原名', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value.communications = [{ id: 20 }]; hasMoreHistory.value = true')
|
||||
page.detail.mockResolvedValueOnce({ workOrder: { communications: [{ id: 10 }], attachmentNames: { 'old.pdf': '历史资料.pdf' } } })
|
||||
await page.evaluate<Promise<void>>('loadHistory()')
|
||||
page.detail.mockResolvedValueOnce({ workOrder: { actions: ['reply'], communications: [{ id: 21 }], attachmentNames: { 'new.xlsx': '新资料.xlsx' } } })
|
||||
await page.evaluate<Promise<void>>('loadDetail(true)')
|
||||
expect(page.evaluate('attachmentName("old.pdf")')).toBe('历史资料.pdf')
|
||||
expect(page.evaluate('attachmentName("new.xlsx")')).toBe('新资料.xlsx')
|
||||
expect(page.evaluate('workOrder.value.communications.length')).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('工单问题描述布局', () => {
|
||||
it('分开展示文字说明与附件,并在内容为空时显示提示', () => {
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
const start = source.indexOf('问题描述')
|
||||
const end = source.indexOf('</wd-card>', start)
|
||||
const description = source.slice(start, end)
|
||||
expect(description).toContain('文字说明')
|
||||
expect(description).toContain('相关附件')
|
||||
expect(description).toContain('break-words')
|
||||
expect(description).not.toContain('break-all')
|
||||
expect(description).toContain('暂无问题描述')
|
||||
expect(description).toContain('{{ workOrder.attachmentUrls.length }} 个附件')
|
||||
})
|
||||
})
|
||||
|
||||
describe('工单附件文件名预览', () => {
|
||||
it('微信打开的文件使用工单展示的原始名称,返回后清理本地副本', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value.attachmentNames = { "https://files/random": "现场照片.pdf" }')
|
||||
page.evaluate('openAttachment("https://files/random")')
|
||||
page.evaluate('uni.downloadFile.mock.calls[0][0].success({ statusCode: 200, tempFilePath: "/tmp/random.pdf" })')
|
||||
expect(page.fileManager.copyFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
srcPath: '/tmp/random.pdf',
|
||||
destPath: expect.stringMatching(/\/现场照片\.pdf$/),
|
||||
}))
|
||||
expect(page.openDocument).toHaveBeenCalledWith(expect.objectContaining({ filePath: expect.stringMatching(/\/现场照片\.pdf$/) }))
|
||||
page.lifecycle.hide()
|
||||
await page.lifecycle.show()
|
||||
expect(page.fileManager.unlink).toHaveBeenCalledWith(expect.objectContaining({ filePath: expect.stringMatching(/\/现场照片\.pdf$/) }))
|
||||
expect(page.fileManager.rmdir).toHaveBeenCalledWith(expect.objectContaining({ recursive: true }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('工单照片预览', () => {
|
||||
it('正文和沟通附件复用缩略图,原名为照片的随机地址也使用图片预览', () => {
|
||||
const page = harness()
|
||||
page.evaluate('workOrder.value.attachmentNames = { "/upload/random": "现场照片.JPG" }')
|
||||
page.evaluate('openAttachment("/upload/random")')
|
||||
expect(page.evaluate('uni.previewImage.mock.calls[0][0]')).toEqual({
|
||||
urls: ['https://api.example.com/upload/random'],
|
||||
current: 'https://api.example.com/upload/random',
|
||||
})
|
||||
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||
expect(source.match(/<WorkOrderAttachmentPreview\s/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('预览照片后保留工单沟通', () => {
|
||||
it('预览引起隐藏和重新显示时,保留窗口、草稿及阅读位置并恢复轮询', async () => {
|
||||
const page = harness()
|
||||
await page.lifecycle.show()
|
||||
page.evaluate('showConversation.value = true; replyContent.value = "未发送的内容"; replyAttachments.value = ["photo.jpg"]; conversationTarget.value = "history-message"')
|
||||
page.evaluate('openAttachment("photo.jpg")')
|
||||
page.lifecycle.hide()
|
||||
expect(page.clearInterval).toHaveBeenCalledWith(1)
|
||||
expect(page.evaluate('showConversation.value')).toBe(true)
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('showConversation.value')).toBe(true)
|
||||
expect(page.evaluate('replyContent.value')).toBe('未发送的内容')
|
||||
expect(page.evaluate('replyAttachments.value')).toEqual(['photo.jpg'])
|
||||
expect(page.evaluate('conversationTarget.value')).toBe('history-message')
|
||||
expect(page.setInterval).toHaveBeenCalledTimes(2)
|
||||
page.lifecycle.unload()
|
||||
expect(page.evaluate('visible')).toBe(false)
|
||||
expect(page.evaluate('refreshTimer')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('从正文预览返回或手动关闭窗口后,不会自动打开沟通', async () => {
|
||||
const page = harness()
|
||||
page.evaluate('openAttachment("photo.jpg")')
|
||||
page.lifecycle.hide()
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('showConversation.value')).toBe(false)
|
||||
page.evaluate('showConversation.value = true; showConversation.value = false')
|
||||
page.lifecycle.hide()
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('showConversation.value')).toBe(false)
|
||||
})
|
||||
|
||||
it('重新显示时若失去查看权限,关闭沟通窗口并停止轮询', async () => {
|
||||
const page = harness()
|
||||
await page.lifecycle.show()
|
||||
page.evaluate('showConversation.value = true')
|
||||
page.lifecycle.hide()
|
||||
page.evaluate('userStore.userInfo.revoked = true')
|
||||
await page.lifecycle.show()
|
||||
expect(page.evaluate('showConversation.value')).toBe(false)
|
||||
expect(page.evaluate('noPermission.value')).toBe(true)
|
||||
expect(page.evaluate('refreshTimer')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import type { AppWorkOrder } from '../api/workOrder'
|
||||
import { getWorkOrderDetail } from '../api/workOrder'
|
||||
import { actOnWorkOrder, getWorkOrderDetail } from '../api/workOrder'
|
||||
import type { WorkOrderAction } from '@/api/types/workOrder'
|
||||
import WorkOrderAttachments from '@/components/WorkOrderAttachments.vue'
|
||||
import WorkOrderAttachmentPreview from '@/components/WorkOrderAttachmentPreview.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { hasAnyFeature } from '@/utils/permission'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
import { isWorkOrderImage, workOrderAttachmentFilename, workOrderAttachmentName } from '@/utils/workOrderAttachment'
|
||||
// #ifdef H5
|
||||
import { downloadWorkOrderAttachment } from '@/utils/workOrderAttachmentDownload'
|
||||
// #endif
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
@@ -14,9 +21,134 @@ definePage({
|
||||
|
||||
const userStore = useUserStore()
|
||||
const noPermission = ref(false)
|
||||
const loading = ref(false)
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const woNo = ref('')
|
||||
const workOrder = ref<AppWorkOrder>()
|
||||
function attachmentGroups(urls: string[]) {
|
||||
const images: string[] = []
|
||||
const files: string[] = []
|
||||
for (const attachment of urls) {
|
||||
const target = isWorkOrderImage(attachment, attachmentName(attachment)) ? images : files
|
||||
target.push(attachment)
|
||||
}
|
||||
return [
|
||||
{ type: 'images', urls: images },
|
||||
{ type: 'files', urls: files },
|
||||
].filter(group => group.urls.length > 0)
|
||||
}
|
||||
const sending = ref(false)
|
||||
const uploading = ref(false)
|
||||
const replyContent = ref('')
|
||||
const evaluationContent = ref('')
|
||||
const replyAttachments = ref<string[]>([])
|
||||
const showConversation = ref(false)
|
||||
const conversationTarget = ref('')
|
||||
const rating = ref(5)
|
||||
const loadingHistory = ref(false)
|
||||
const hasMoreHistory = ref(false)
|
||||
const actions = computed(() => workOrder.value?.actions || [])
|
||||
const canReply = computed(() => actions.value.includes('reply') || actions.value.includes('reopen'))
|
||||
const canSubmitFeedback = computed(() => {
|
||||
if (actions.value.includes('evaluate'))
|
||||
return Number.isInteger(rating.value) && rating.value >= 1 && rating.value <= 5
|
||||
if (actions.value.includes('reopen'))
|
||||
return !!replyContent.value.trim()
|
||||
return !!replyContent.value.trim() || replyAttachments.value.length > 0
|
||||
})
|
||||
const autoCloseText = computed(() => {
|
||||
if (workOrder.value?.currentStatus !== 3 || !workOrder.value.resolveTime)
|
||||
return ''
|
||||
const value = workOrder.value.resolveTime.replace('T', ' ').replace(/-/g, '/')
|
||||
const deadline = new Date(new Date(value).getTime() + 72 * 60 * 60 * 1000)
|
||||
if (Number.isNaN(deadline.getTime()))
|
||||
return '解决后72小时内未反馈,工单将自动完结。'
|
||||
return `请确认问题是否解决,预计 ${deadline.getFullYear()}/${deadline.getMonth() + 1}/${deadline.getDate()} ${String(deadline.getHours()).padStart(2, '0')}:${String(deadline.getMinutes()).padStart(2, '0')} 自动完结。`
|
||||
})
|
||||
let refreshTimer: ReturnType<typeof setInterval> | undefined
|
||||
let fetching = false
|
||||
let detailRequest: Promise<void> | undefined
|
||||
let visible = false
|
||||
let activePreviewCopy: { directory: string, filePath: string } | undefined
|
||||
|
||||
function stopPolling() {
|
||||
if (refreshTimer)
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = undefined
|
||||
}
|
||||
|
||||
async function scrollToLatest() {
|
||||
conversationTarget.value = ''
|
||||
await nextTick()
|
||||
conversationTarget.value = 'conversation-bottom'
|
||||
}
|
||||
|
||||
async function openConversation() {
|
||||
showConversation.value = true
|
||||
await nextTick()
|
||||
await scrollToLatest()
|
||||
void loadDetail(true)
|
||||
}
|
||||
|
||||
async function performAction(action: WorkOrderAction) {
|
||||
if (sending.value || uploading.value || !actions.value.includes(action))
|
||||
return
|
||||
if ((action === 'reply' || action === 'reopen') && !replyContent.value.trim() && !replyAttachments.value.length) {
|
||||
uni.showToast({ title: '请输入内容或添加附件', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (action === 'reopen' && !replyContent.value.trim()) {
|
||||
uni.showToast({ title: '请说明问题尚未解决的原因', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (action === 'evaluate' && (!Number.isInteger(rating.value) || rating.value < 1 || rating.value > 5)) {
|
||||
uni.showToast({ title: '请选择1至5星的服务评分', icon: 'none' })
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
if (['confirm', 'cancel', 'transfer', 'resolve', 'close'].includes(action)) {
|
||||
const prompts: Partial<Record<WorkOrderAction, { title: string, content: string }>> = {
|
||||
confirm: { title: '确认问题已解决?', content: '确认后工单将完结,您可以评价本次服务。' },
|
||||
cancel: { title: '确认取消工单?', content: '取消后将停止处理,沟通记录仍可查看。' },
|
||||
transfer: { title: '确认转单?', content: '工单将转交同租户顶级部门及其负责人,当前部门可能不再具有办理权限。' },
|
||||
resolve: { title: '确认问题已解决?', content: '将通知提交人确认,未反馈的工单将在72小时后自动完结。' },
|
||||
close: { title: '确认关单?', content: '关闭后不能继续回复,沟通记录仍可查看。' },
|
||||
}
|
||||
const result = await uni.showModal(prompts[action]!)
|
||||
if (!result.confirm)
|
||||
return
|
||||
}
|
||||
await actOnWorkOrder({
|
||||
woNo: woNo.value,
|
||||
action,
|
||||
content: action === 'evaluate' ? evaluationContent.value.trim() : ['reply', 'reopen'].includes(action) ? replyContent.value.trim() : undefined,
|
||||
attachmentUrls: ['reply', 'reopen'].includes(action) ? replyAttachments.value : undefined,
|
||||
satisfactionLevel: action === 'evaluate' ? rating.value : undefined,
|
||||
})
|
||||
if (action === 'evaluate') {
|
||||
evaluationContent.value = ''
|
||||
}
|
||||
else if (action === 'reply' || action === 'reopen') {
|
||||
replyContent.value = ''
|
||||
replyAttachments.value = []
|
||||
}
|
||||
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||
if (action === 'transfer') {
|
||||
showConversation.value = false
|
||||
uni.navigateBack({ fail: () => uni.redirectTo({ url: '/pages/business/views/workOrder/index' }) })
|
||||
return
|
||||
}
|
||||
await loadDetail(true, true)
|
||||
if (action === 'reply' || action === 'reopen')
|
||||
await scrollToLatest()
|
||||
}
|
||||
catch (error) {
|
||||
uni.showToast({ title: getErrorMessage(error, '操作失败,请重试'), icon: 'none' })
|
||||
await loadDetail(true, true)
|
||||
}
|
||||
finally { sending.value = false }
|
||||
}
|
||||
const canViewWorkOrderDetail = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:detail'))
|
||||
|
||||
const infoRows = computed(() => {
|
||||
@@ -33,30 +165,76 @@ const infoRows = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
function loadDetail(silent = false, force = false): Promise<void> {
|
||||
if (detailRequest)
|
||||
return force ? detailRequest.then(() => loadDetail(silent)) : detailRequest
|
||||
detailRequest = fetchDetail(silent).finally(() => {
|
||||
detailRequest = undefined
|
||||
})
|
||||
return detailRequest
|
||||
}
|
||||
|
||||
async function fetchDetail(silent = false) {
|
||||
if (!canViewWorkOrderDetail.value) {
|
||||
noPermission.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
if (!woNo.value) {
|
||||
loading.value = false
|
||||
uni.showToast({ title: '工单编号不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
fetching = true
|
||||
refreshing.value = true
|
||||
loading.value = !workOrder.value
|
||||
noPermission.value = false
|
||||
try {
|
||||
const res = await getWorkOrderDetail({ woNo: woNo.value })
|
||||
workOrder.value = res.workOrder
|
||||
const previous = workOrder.value?.communications || []
|
||||
const latest = res.workOrder.communications || []
|
||||
const merged = new Map(previous.map(item => [item.id, item]))
|
||||
latest.forEach(item => merged.set(item.id, item))
|
||||
if (!previous.length)
|
||||
hasMoreHistory.value = !!res.workOrder.hasMoreCommunications
|
||||
workOrder.value = { ...res.workOrder, attachmentNames: { ...workOrder.value?.attachmentNames, ...res.workOrder.attachmentNames }, communications: [...merged.values()].sort((left, right) => left.id - right.id) }
|
||||
}
|
||||
catch (error) {
|
||||
workOrder.value = undefined
|
||||
uni.showToast({ title: getErrorMessage(error, '工单详情加载失败'), icon: 'none' })
|
||||
if (!silent)
|
||||
uni.showToast({ title: getErrorMessage(error, '工单详情加载失败'), icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
fetching = false
|
||||
refreshing.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (loadingHistory.value || fetching || !canViewWorkOrderDetail.value || !hasMoreHistory.value)
|
||||
return
|
||||
const beforeId = workOrder.value?.communications?.[0]?.id
|
||||
if (!beforeId)
|
||||
return
|
||||
loadingHistory.value = true
|
||||
try {
|
||||
const res = await getWorkOrderDetail({ woNo: woNo.value, beforeId })
|
||||
const merged = new Map((workOrder.value?.communications || []).map(item => [item.id, item]))
|
||||
res.workOrder.communications?.forEach(item => merged.set(item.id, item))
|
||||
if (workOrder.value) {
|
||||
workOrder.value.communications = [...merged.values()].sort((left, right) => left.id - right.id)
|
||||
workOrder.value.attachmentNames = { ...workOrder.value.attachmentNames, ...res.workOrder.attachmentNames }
|
||||
}
|
||||
hasMoreHistory.value = !!res.workOrder.hasMoreCommunications
|
||||
}
|
||||
catch (error) {
|
||||
uni.showToast({ title: getErrorMessage(error, '历史记录加载失败'), icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loadingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function tagType(type?: string) {
|
||||
if (type === 'success')
|
||||
return 'success'
|
||||
@@ -67,6 +245,10 @@ function tagType(type?: string) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function attachmentName(url: string) {
|
||||
return workOrderAttachmentName(url, workOrder.value?.attachmentNames?.[url])
|
||||
}
|
||||
|
||||
function normalizeAttachmentUrl(value: string) {
|
||||
const url = String(value || '').trim().replaceAll('\\', '/')
|
||||
if (!url)
|
||||
@@ -77,20 +259,74 @@ function normalizeAttachmentUrl(value: string) {
|
||||
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`
|
||||
}
|
||||
|
||||
function cleanupAttachmentPreviewCopy() {
|
||||
const previewCopy = activePreviewCopy
|
||||
activePreviewCopy = undefined
|
||||
if (!previewCopy)
|
||||
return
|
||||
// #ifdef MP-WEIXIN
|
||||
const fileManager = wx.getFileSystemManager()
|
||||
fileManager.unlink({
|
||||
filePath: previewCopy.filePath,
|
||||
complete: () => fileManager.rmdir({ dirPath: previewCopy.directory, recursive: true }),
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
function openDownloadedAttachment(filePath: string, name: string) {
|
||||
const openDocument = (path: string) => uni.openDocument({
|
||||
filePath: path,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: '当前附件暂不支持预览', icon: 'none' }),
|
||||
})
|
||||
// #ifdef MP-WEIXIN
|
||||
const filename = workOrderAttachmentFilename(name)
|
||||
const previewDirectory = `${wx.env.USER_DATA_PATH}/work-order-preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const previewFilePath = `${previewDirectory}/${filename}`
|
||||
const fileManager = wx.getFileSystemManager()
|
||||
fileManager.mkdir({
|
||||
dirPath: previewDirectory,
|
||||
recursive: true,
|
||||
success: () => fileManager.copyFile({
|
||||
srcPath: filePath,
|
||||
destPath: previewFilePath,
|
||||
success: () => {
|
||||
activePreviewCopy = { directory: previewDirectory, filePath: previewFilePath }
|
||||
openDocument(previewFilePath)
|
||||
},
|
||||
fail: () => openDocument(filePath),
|
||||
}),
|
||||
fail: () => openDocument(filePath),
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
openDocument(filePath)
|
||||
// #endif
|
||||
}
|
||||
|
||||
function openAttachment(value: string) {
|
||||
const url = normalizeAttachmentUrl(value)
|
||||
if (!url)
|
||||
return
|
||||
if (isWorkOrderImage(url, attachmentName(value))) {
|
||||
uni.previewImage({ urls: [url], current: url })
|
||||
return
|
||||
}
|
||||
// #ifdef H5
|
||||
window.open(url, '_blank')
|
||||
downloadWorkOrderAttachment(url, attachmentName(value)).catch(() => {
|
||||
uni.showToast({ title: '附件下载失败,请重试', icon: 'none' })
|
||||
})
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.downloadFile({
|
||||
url,
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200)
|
||||
uni.openDocument({ filePath: res.tempFilePath })
|
||||
openDownloadedAttachment(res.tempFilePath, attachmentName(value))
|
||||
else
|
||||
uni.showToast({ title: '附件下载失败', icon: 'none' })
|
||||
},
|
||||
fail: () => uni.showToast({ title: '附件下载失败', icon: 'none' }),
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
@@ -120,103 +356,283 @@ onLoad((query: Record<string, string | undefined>) => {
|
||||
woNo.value = query.woNo || query.no || ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
onShow(async () => {
|
||||
cleanupAttachmentPreviewCopy()
|
||||
visible = true
|
||||
stopPolling()
|
||||
loading.value = !workOrder.value
|
||||
await userStore.fetchUserInfo().catch(() => undefined)
|
||||
if (!canViewWorkOrderDetail.value) {
|
||||
noPermission.value = true
|
||||
loading.value = false
|
||||
showConversation.value = false
|
||||
uni.showToast({ title: '当前账号不能查看工单详情', icon: 'none' })
|
||||
return
|
||||
}
|
||||
void loadDetail()
|
||||
await loadDetail()
|
||||
if (visible) {
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!sending.value && !uploading.value)
|
||||
void loadDetail(true)
|
||||
}, 15000)
|
||||
}
|
||||
})
|
||||
onHide(() => {
|
||||
visible = false
|
||||
// 图片预览和选择附件也会隐藏页面,保留沟通窗口及草稿。
|
||||
stopPolling()
|
||||
})
|
||||
onUnload(() => {
|
||||
cleanupAttachmentPreviewCopy()
|
||||
visible = false
|
||||
stopPolling()
|
||||
})
|
||||
</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 class="min-h-screen bg-#f5f7fa p-24rpx pb-48rpx font-sans">
|
||||
<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="min-h-88rpx flex items-start justify-between gap-16rpx py-20rpx">
|
||||
<text class="min-w-0 flex-1 break-all text-28rpx text-#0f172a font-600">
|
||||
{{ workOrder.title }}
|
||||
</text>
|
||||
<wd-tag :type="tagType(workOrder.statusType)" variant="plain">
|
||||
{{ workOrder.currentStatus === 3 ? '待确认解决' : (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 justify-between">
|
||||
<text class="text-28rpx text-#0f172a font-600">问题描述</text>
|
||||
<text v-if="workOrder.attachmentUrls?.length" class="text-22rpx text-#94a3b8">
|
||||
{{ workOrder.attachmentUrls.length }} 个附件
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
<view v-if="workOrder.content?.trim()" class="rounded-16rpx bg-#f8fafc px-20rpx py-18rpx">
|
||||
<view class="mb-8rpx text-22rpx text-#94a3b8">
|
||||
文字说明
|
||||
</view>
|
||||
<view class="whitespace-pre-wrap break-words text-26rpx text-#334155 leading-42rpx">
|
||||
{{ workOrder.content }}
|
||||
</view>
|
||||
</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
|
||||
<view v-if="workOrder.attachmentUrls?.length" class="mt-20rpx border-t border-#f1f5f9 pt-20rpx">
|
||||
<view class="mb-12rpx text-22rpx text-#94a3b8">
|
||||
相关附件
|
||||
</view>
|
||||
<view v-for="group in attachmentGroups(workOrder.attachmentUrls)" :key="group.type" class="mb-12rpx gap-12rpx" :class="group.type === 'images' ? 'grid grid-cols-3' : 'flex flex-col'">
|
||||
<WorkOrderAttachmentPreview
|
||||
v-for="url in group.urls"
|
||||
:key="url"
|
||||
:url="normalizeAttachmentUrl(url)"
|
||||
:name="attachmentName(url)"
|
||||
@open="openAttachment(url)"
|
||||
/>
|
||||
</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>
|
||||
<view v-if="!workOrder.content?.trim() && !workOrder.attachmentUrls?.length" class="text-26rpx text-#94a3b8">
|
||||
暂无问题描述
|
||||
</view>
|
||||
</wd-card>
|
||||
</view>
|
||||
|
||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||
<wd-cell title="工单沟通" label="查看回复,补充问题信息" is-link center custom-title-class="!text-28rpx !font-600 !text-#0f172a" custom-label-class="!text-24rpx !text-#94a3b8" @click="openConversation">
|
||||
<text class="whitespace-nowrap text-24rpx text-#2563eb">{{ canReply ? '继续沟通' : '查看记录' }}</text>
|
||||
</wd-cell>
|
||||
</wd-card>
|
||||
<wd-card v-if="workOrder.records?.length" custom-class="!mx-0 !mb-0">
|
||||
<view class="mb-20rpx text-28rpx text-#0f172a font-600">
|
||||
处理进度
|
||||
</view>
|
||||
<view v-for="record in workOrder.records" :key="record.id" class="mb-16rpx flex items-center justify-between gap-16rpx text-24rpx">
|
||||
<text class="text-#334155">{{ record.operationText }}</text>
|
||||
<text class="text-#94a3b8">{{ formatTime(record.createdAt) }}</text>
|
||||
</view>
|
||||
</wd-card>
|
||||
|
||||
<wd-card v-if="autoCloseText" custom-class="!mx-0 !mb-0">
|
||||
<view class="text-26rpx text-#64748b leading-40rpx">
|
||||
{{ autoCloseText }}
|
||||
</view>
|
||||
</wd-card>
|
||||
|
||||
<wd-card v-if="actions.includes('evaluate')" custom-class="!mx-0 !mb-0">
|
||||
<view class="mb-20rpx text-28rpx text-#0f172a font-600">
|
||||
评价本次服务
|
||||
</view>
|
||||
<wd-rate v-if="actions.includes('evaluate')" v-model="rating" :disabled="sending" />
|
||||
<wd-textarea v-model="evaluationContent" :disabled="sending" :maxlength="2000" show-word-limit placeholder="评价内容(选填)" />
|
||||
<wd-button class="mt-20rpx" type="primary" :round="false" block :loading="sending" :disabled="sending || uploading || !canSubmitFeedback" @click="performAction('evaluate')">
|
||||
提交评价
|
||||
</wd-button>
|
||||
</wd-card>
|
||||
|
||||
<wd-card v-if="workOrder.satisfactionLevel" custom-class="!mx-0 !mb-0">
|
||||
<view class="mb-16rpx text-28rpx text-#0f172a font-600">
|
||||
我的评价
|
||||
</view>
|
||||
<wd-rate :model-value="workOrder.satisfactionLevel" readonly />
|
||||
<view class="mt-12rpx text-26rpx text-#64748b">
|
||||
{{ workOrder.evalContent || '未填写评价内容' }}
|
||||
</view>
|
||||
</wd-card>
|
||||
|
||||
<wd-card v-if="actions.includes('resolve') || actions.includes('transfer') || actions.includes('close')" custom-class="!mx-0 !mb-0">
|
||||
<view class="mb-20rpx text-28rpx text-#0f172a font-600">
|
||||
工单办理
|
||||
</view>
|
||||
<view class="flex items-center gap-12rpx rounded-16rpx bg-#f8fafc p-8rpx">
|
||||
<wd-button v-if="actions.includes('resolve')" size="small" type="primary" variant="text" custom-class="!h-64rpx !min-h-64rpx !rounded-12rpx !font-500 !text-26rpx !px-20rpx" :loading="sending" :disabled="sending || uploading" @click="performAction('resolve')">
|
||||
标记解决
|
||||
</wd-button>
|
||||
<wd-button v-if="actions.includes('transfer')" size="small" type="info" variant="text" custom-class="!h-64rpx !min-h-64rpx !rounded-12rpx !font-500 !text-26rpx !px-20rpx !text-#64748b" :disabled="sending || uploading" @click="performAction('transfer')">
|
||||
转单
|
||||
</wd-button>
|
||||
<wd-button v-if="actions.includes('close')" size="small" type="info" variant="text" custom-class="!h-64rpx !min-h-64rpx !rounded-12rpx !font-500 !text-26rpx !px-20rpx !text-#64748b" :disabled="sending || uploading" @click="performAction('close')">
|
||||
关单
|
||||
</wd-button>
|
||||
</view>
|
||||
</wd-card>
|
||||
|
||||
<wd-button v-if="actions.includes('confirm')" type="primary" :round="false" block :loading="sending" :disabled="sending || uploading" @click="performAction('confirm')">
|
||||
确认解决并完结
|
||||
</wd-button>
|
||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||
<wd-cell-group>
|
||||
<wd-cell title="刷新进度" label="获取最新处理状态与沟通记录" center custom-title-class="!text-27rpx !font-800 !text-#0f172a" custom-label-class="!text-22rpx !text-#475569">
|
||||
<wd-button type="primary" variant="text" size="small" :loading="refreshing" :disabled="sending || uploading || loadingHistory" @click="loadDetail()">
|
||||
刷新
|
||||
</wd-button>
|
||||
</wd-cell>
|
||||
<wd-cell v-if="actions.includes('cancel')" title="取消工单" label="取消后停止处理,沟通记录保留" center custom-title-class="!text-27rpx !font-800 !text-#0f172a" custom-label-class="!text-22rpx !text-#475569">
|
||||
<wd-button type="danger" variant="text" size="small" :loading="sending" :disabled="sending || uploading" @click="performAction('cancel')">
|
||||
取消
|
||||
</wd-button>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
</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-button block type="info" @click="loadDetail()">
|
||||
重试
|
||||
</wd-button>
|
||||
</wd-card>
|
||||
</view>
|
||||
<wd-popup v-model="showConversation" position="bottom" round safe-area-inset-bottom :close-on-click-modal="false" @after-enter="scrollToLatest">
|
||||
<view v-if="workOrder && !noPermission" class="h-85vh flex flex-col bg-#f5f7fa">
|
||||
<view class="flex shrink-0 items-center justify-between gap-16rpx bg-white px-28rpx py-24rpx">
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="text-28rpx text-#0f172a font-600">
|
||||
工单沟通
|
||||
</view>
|
||||
<view class="mt-8rpx truncate text-22rpx text-#64748b">
|
||||
{{ workOrder.title }}
|
||||
</view>
|
||||
</view>
|
||||
<wd-button type="info" variant="text" size="small" custom-class="!min-h-56rpx !h-56rpx !px-12rpx !font-400 !text-24rpx !text-#64748b" @click="showConversation = false">
|
||||
收起
|
||||
</wd-button>
|
||||
</view>
|
||||
<view v-if="actions.includes('resolve') || actions.includes('transfer') || actions.includes('close')" class="flex shrink-0 items-center gap-12rpx border-t border-#f1f5f9 border-solid bg-white px-24rpx py-12rpx">
|
||||
<wd-button v-if="actions.includes('resolve')" size="small" type="primary" variant="text" custom-class="!h-64rpx !min-h-64rpx !rounded-12rpx !font-500 !text-26rpx !px-20rpx" :loading="sending" :disabled="sending || uploading" @click="performAction('resolve')">
|
||||
标记解决
|
||||
</wd-button>
|
||||
<wd-button v-if="actions.includes('transfer')" size="small" type="info" variant="text" custom-class="!h-64rpx !min-h-64rpx !rounded-12rpx !font-500 !text-26rpx !px-20rpx !text-#64748b" :disabled="sending || uploading" @click="performAction('transfer')">
|
||||
转单
|
||||
</wd-button>
|
||||
<wd-button v-if="actions.includes('close')" size="small" type="info" variant="text" custom-class="!h-64rpx !min-h-64rpx !rounded-12rpx !font-500 !text-26rpx !px-20rpx !text-#64748b" :disabled="sending || uploading" @click="performAction('close')">
|
||||
关单
|
||||
</wd-button>
|
||||
</view>
|
||||
<scroll-view scroll-y :scroll-into-view="conversationTarget" class="h-0 min-h-0 flex-1">
|
||||
<view class="px-24rpx py-28rpx">
|
||||
<view class="mb-24rpx text-center text-22rpx text-#94a3b8">
|
||||
{{ workOrder.currentStatusText }} · 每15秒更新沟通记录
|
||||
</view>
|
||||
<view v-if="hasMoreHistory" class="mb-24rpx flex justify-center">
|
||||
<wd-button size="small" type="info" variant="text" :loading="loadingHistory" @click="loadHistory">
|
||||
查看更早的消息
|
||||
</wd-button>
|
||||
</view>
|
||||
<view v-for="item in workOrder.communications" :key="item.id" class="mb-28rpx flex flex-col" :class="(workOrder.isCreator !== false ? item.commType === 1 : item.commType !== 1) ? 'items-end' : 'items-start'">
|
||||
<view class="mb-10rpx text-22rpx text-#94a3b8">
|
||||
{{ item.senderName || (item.commType === 1 ? '提交人' : '处理人员') }} · {{ formatTime(item.createdAt) }}
|
||||
</view>
|
||||
<view class="max-w-85% rounded-20rpx px-24rpx py-18rpx" :class="(workOrder.isCreator !== false ? item.commType === 1 : item.commType !== 1) ? 'bg-#e3edff text-#1e3a5f' : 'bg-white text-#334155'">
|
||||
<text v-if="item.content" class="whitespace-pre-wrap break-all text-28rpx leading-44rpx">{{ item.content }}</text>
|
||||
<view v-if="item.attachmentUrls?.length" class="mt-12rpx">
|
||||
<view v-for="group in attachmentGroups(item.attachmentUrls)" :key="group.type" class="mb-12rpx gap-12rpx" :class="group.type === 'images' ? 'grid grid-cols-2' : 'flex flex-col'">
|
||||
<WorkOrderAttachmentPreview
|
||||
v-for="url in group.urls"
|
||||
:key="url"
|
||||
:url="normalizeAttachmentUrl(url)"
|
||||
:name="attachmentName(url)"
|
||||
@open="openAttachment(url)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!workOrder.communications?.length" class="py-80rpx text-center text-26rpx text-#94a3b8">
|
||||
还没有消息,可以在下方补充问题
|
||||
</view>
|
||||
<view id="conversation-bottom" class="h-1px" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="flex shrink-0 justify-center py-8rpx">
|
||||
<wd-button size="small" type="info" variant="text" custom-class="!h-48rpx !min-h-48rpx !px-16rpx !font-400 !text-22rpx !text-#94a3b8" @click="scrollToLatest">
|
||||
回到最新消息
|
||||
</wd-button>
|
||||
</view>
|
||||
<view v-if="canReply" class="shrink-0 bg-white px-24rpx py-20rpx">
|
||||
<view v-if="actions.includes('reopen')" class="mb-12rpx text-24rpx text-#64748b">
|
||||
问题仍未解决?发送原因后将重新进入处理。
|
||||
</view>
|
||||
<wd-textarea v-model="replyContent" :disabled="sending" :maxlength="2000" :auto-height="false" custom-class="!rounded-16rpx !bg-#f8fafc !px-20rpx !py-12rpx" custom-textarea-class="!h-96rpx !min-h-96rpx !text-26rpx !leading-40rpx" show-word-limit :placeholder="actions.includes('reopen') ? '请说明尚未解决的原因…' : '输入消息,继续沟通…'" />
|
||||
<view class="mt-12rpx max-h-180rpx overflow-y-auto">
|
||||
<WorkOrderAttachments v-model="replyAttachments" :disabled="sending" @uploading="uploading = $event" />
|
||||
</view>
|
||||
<view class="mt-16rpx flex items-center justify-between gap-16rpx">
|
||||
<text class="text-22rpx text-#94a3b8">支持文字、图片和文件</text>
|
||||
<wd-button type="primary" size="small" custom-class="!h-64rpx !min-h-64rpx !min-w-120rpx !rounded-12rpx !px-24rpx !font-500 !text-26rpx" :loading="sending" :disabled="sending || uploading || !canSubmitFeedback" @click="performAction(actions.includes('reopen') ? 'reopen' : 'reply')">
|
||||
{{ actions.includes('reopen') ? '反馈未解决' : '发送' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="shrink-0 bg-white px-24rpx py-28rpx text-center text-24rpx text-#94a3b8">
|
||||
当前工单不可回复,历史消息仍可查看
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user