sync: 对齐小程序 v1.0.2 生产代码并脱敏

This commit is contained in:
MiFaZhan
2026-10-29 15:15:12 +08:00
parent a7abd476f0
commit 5fcf74deb0
423 changed files with 15001 additions and 6382 deletions
@@ -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)
+9
View File
@@ -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))
+31 -4
View File
@@ -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)
+125
View File
@@ -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('客户管理')
})
})
+58 -21
View File
@@ -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>
+96 -96
View File
@@ -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
View File
@@ -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>
+198 -196
View File
@@ -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)
})
})
+129 -186
View File
@@ -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
+22 -5
View File
@@ -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>
+15 -208
View File
@@ -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')
})
})
+163 -84
View File
@@ -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>