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 -1
View File
@@ -30,6 +30,8 @@ iotLCM 物联网卡全生命周期管理平台的**小程序端 + 用户端服
## 快速开始
### 后端
将 `server/config/config.example.yaml` 复制为 `server/config/config.yaml` 并填写自己的凭据;使用代码生成前同样将 `server/hack/config.example.yaml` 复制为 `server/hack/config.yaml`。
```bash
cd server
@@ -51,7 +53,7 @@ pnpm dev:mp-weixin # 微信小程序,产物用微信开发者工具打开
| 位置 | 说明 |
| --- | --- |
| `server/config/config.yaml` | 后端配置(**公开版凭据为占位符**) |
| `server/config/config.example.yaml` | 后端配置(**公开版凭据为占位符**) |
| `app/env/.env*` | 前端环境变量(**公开版 AppID 为占位符**) |
后端配置占位:
+1
View File
@@ -11,6 +11,7 @@ FROM ${BASE_REGISTRY}/node:24-alpine AS builder
WORKDIR /build/app
COPY package.json pnpm-lock.yaml .npmrc ./
COPY patches ./patches
RUN npm config set registry https://registry.npmmirror.com/ && \
npm install -g pnpm@10.10.0 --registry=https://registry.npmmirror.com/ && \
+2 -2
View File
@@ -25,8 +25,8 @@ export default defineManifestConfig({
'name': VITE_APP_TITLE,
'appid': VITE_UNI_APPID,
'description': '',
'versionName': '1.0.0',
'versionCode': '100',
'versionName': '1.0.2',
'versionCode': '102',
'transformPx': false,
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
'h5': {
+7 -2
View File
@@ -1,7 +1,7 @@
{
"name": "iotlcm-app-ui",
"type": "module",
"version": "1.0.0",
"version": "1.0.2",
"unibest": {
"platforms": [
"h5",
@@ -25,7 +25,7 @@
"author": {
"name": "feige996",
"zhName": "菲鸽",
"email": "1020103647@example.com",
"email": "xxx@example.com",
"github": "https://github.com/feige996",
"gitee": "https://gitee.com/feige996"
},
@@ -112,6 +112,7 @@
"upload:changeset": "pnpm changeset && pnpm changeset version",
"upload:mp": "node ./scripts/upload-weixin.js",
"type-check": "vue-tsc --noEmit",
"test:run": "vitest run",
"lint": "eslint",
"lint:fix": "eslint --fix",
"format": "prettier . --write",
@@ -202,11 +203,15 @@
"vite": "5.2.8",
"vite-plugin-restart": "^1.0.0",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "3.2.4",
"vue-tsc": "^3.0.6"
},
"pnpm": {
"overrides": {
"unconfig": "7.3.2"
},
"patchedDependencies": {
"@wot-ui/ui@2.1.0": "patches/@wot-ui__ui@2.1.0.patch"
}
},
"overrides": {
+87
View File
@@ -0,0 +1,87 @@
diff --git a/components/wd-button/wd-button.vue b/components/wd-button/wd-button.vue
index 8b2610d90c8ebe78e2726179bb83f2b688ec0d96..c25b26965ccfb9470cc3c2ab9e3b2a1576c3220d 100644
--- a/components/wd-button/wd-button.vue
+++ b/components/wd-button/wd-button.vue
@@ -18,7 +18,7 @@
]"
:hover-start-time="hoverStartTime"
:hover-stay-time="hoverStayTime"
- :open-type="openTypeValue"
+ :open-type="openTypeValue as UniHelper.ButtonOpenType"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
diff --git a/components/wd-fab/types.ts b/components/wd-fab/types.ts
index 50ca94f1a95a54c95fe68d9ed3535b45f1e36bcc..57b5ceb1143a0e9385cc280fe8054ed87f255807 100644
--- a/components/wd-fab/types.ts
+++ b/components/wd-fab/types.ts
@@ -87,7 +87,7 @@ export const fabProps = {
*/
gap: {
type: Object as PropType<FabGap>,
- default: () => ({})
+ default: (): FabGap => ({})
},
/**
* 点击时是否展开菜单
diff --git a/components/wd-swiper/wd-swiper.vue b/components/wd-swiper/wd-swiper.vue
index c662e565171dad9c350b7215dddd3e8fc11320ab..54a5260a40ed1d6554a7e1b2158f94d8598b1c10 100644
--- a/components/wd-swiper/wd-swiper.vue
+++ b/components/wd-swiper/wd-swiper.vue
@@ -158,9 +158,9 @@ const swiperIndicator = computed(() => {
indicatorPosition: indicatorPosition || 'bottom'
}
if (isObj(indicator)) {
- swiperIndicator.type = indicator.type || 'dots'
- swiperIndicator.minShowNum = indicator.minShowNum || 2
- swiperIndicator.showControls = indicator.showControls || false
+ swiperIndicator.type = (indicator as Partial<SwiperNavProps>).type || 'dots'
+ swiperIndicator.minShowNum = (indicator as Partial<SwiperNavProps>).minShowNum || 2
+ swiperIndicator.showControls = (indicator as Partial<SwiperNavProps>).showControls || false
}
return swiperIndicator
})
diff --git a/components/wd-tour/wd-tour.vue b/components/wd-tour/wd-tour.vue
index d1c6e895a819a4e15178930c3869a7034d741887..2eacba3bb4b20621d94465d17a94d88ccea6195e 100644
--- a/components/wd-tour/wd-tour.vue
+++ b/components/wd-tour/wd-tour.vue
@@ -69,7 +69,7 @@ export default {
<script lang="ts" setup>
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
import { addUnit, getRect, getSystemInfo, isDef, objToStyle } from '../../common/util'
-import { tourProps, type TourEmits, type TourExpose } from './types'
+import { tourProps, type TourEmits, type TourExpose, type TourStep } from './types'
import { useRaf } from '../../composables/useRaf'
import { useTranslate } from '../../composables/useTranslate'
import wdButton from '../wd-button/wd-button.vue'
@@ -122,7 +122,7 @@ const highlightClass = computed(() => {
})
// 计算属性
-const currentStep = computed(() => {
+const currentStep = computed<Partial<TourStep>>(() => {
return props.steps[currentIndex.value] || {}
})
// 提取公共的默认样式函数
diff --git a/composables/useUpload.ts b/composables/useUpload.ts
index acad05a461562cb197299623100371280d559918..83b02be95ebb380b8d1cd9f03a742a8727ece502 100644
--- a/composables/useUpload.ts
+++ b/composables/useUpload.ts
@@ -174,7 +174,7 @@ export function useUpload(): UseUploadReturn {
path: item.path || '',
name: item.name || '',
size: item.size,
- type: 'image',
+ type: 'image' as const,
thumb: item.path || ''
}))
}
@@ -183,7 +183,7 @@ export function useUpload(): UseUploadReturn {
path: (res.tempFiles as any).path || '',
name: (res.tempFiles as any).name || '',
size: (res.tempFiles as any).size,
- type: 'image',
+ type: 'image' as const,
thumb: (res.tempFiles as any).path || ''
}
]
+10 -2
View File
@@ -8,6 +8,11 @@ overrides:
bin-wrapper: npm:bin-wrapper-china
unconfig: 7.3.2
patchedDependencies:
'@wot-ui/ui@2.1.0':
hash: 706185b1a0b469b3fa2e58924fab9db34fc17f6694e0c92792e6105474427700
path: patches/@wot-ui__ui@2.1.0.patch
importers:
.:
@@ -74,7 +79,7 @@ importers:
version: 2.5.0-20230101
'@wot-ui/ui':
specifier: latest
version: 2.1.0(vue@3.4.21(typescript@5.8.3))
version: 2.1.0(patch_hash=706185b1a0b469b3fa2e58924fab9db34fc17f6694e0c92792e6105474427700)(vue@3.4.21(typescript@5.8.3))
abortcontroller-polyfill:
specifier: ^1.7.8
version: 1.7.8
@@ -259,6 +264,9 @@ importers:
vite-plugin-vue-devtools:
specifier: ^8.0.5
version: 8.0.5(vite@5.2.8(@types/node@20.19.20)(less@4.5.1)(sass@1.77.8)(terser@5.43.1))(vue@3.4.21(typescript@5.8.3))
vitest:
specifier: 3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.20)(jsdom@29.1.1)(less@4.5.1)(sass@1.77.8)(terser@5.43.1)
vue-tsc:
specifier: ^3.0.6
version: 3.0.6(typescript@5.8.3)
@@ -15044,7 +15052,7 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.20
'@wot-ui/ui@2.1.0(vue@3.4.21(typescript@5.8.3))':
'@wot-ui/ui@2.1.0(patch_hash=706185b1a0b469b3fa2e58924fab9db34fc17f6694e0c92792e6105474427700)(vue@3.4.21(typescript@5.8.3))':
dependencies:
vue: 3.4.21(typescript@5.8.3)
+5 -2
View File
@@ -11,7 +11,8 @@ onShow(() => {
// 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
if (path === '/') {
isCurrentPageTabbar.value = true
} else {
}
else {
isCurrentPageTabbar.value = isPageTabbar(path)
}
})
@@ -28,7 +29,9 @@ defineExpose({
<template>
<view class="app-root">
<!-- 这个先隐藏了,知道这样用就行 -->
<view class="hidden text-center"> {{ helloKuRoot }},这里可以配置全局的东西 </view>
<view class="hidden text-center">
{{ helloKuRoot }},这里可以配置全局的东西
</view>
<KuRootView />
+4 -1
View File
@@ -4,6 +4,7 @@ import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
import { navigateToInterceptor } from '@/router/interceptor'
import { tabbarStore } from '@/tabbar/store'
import { permission } from '@/router/permission'
import { useMessageStore } from '@/store/message'
const { proxy } = (getCurrentInstance() || {}) as any
const router = proxy?.$router
@@ -14,12 +15,14 @@ onLaunch((options: any) => {
// console.log('App.vue onLaunch', options)
})
onShow((options: any) => {
void useMessageStore().refreshUnreadCount()
// console.log('App.vue onShow', options)
// 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
// https://github.com/unibest-tech/unibest/issues/192
if (options?.path) {
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
} else {
}
else {
navigateToInterceptor.invoke({ url: '/' })
}
})
+23 -6
View File
@@ -10,8 +10,8 @@ import type {
CardLifecycleStatusParams,
CardListParams,
CardListRes,
CardRecognitionRes,
CardQueryRes,
CardRecognitionRes,
ChangePrepareParams,
ChangePrepareRes,
ChangeSubmitParams,
@@ -57,6 +57,20 @@ export function publicQueryCard(data: PublicQueryCardParams) {
return http.post<CardQueryRes>('/card/public-query', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
interface CardRecognitionPayload {
code?: number
message?: string
data?: CardRecognitionRes
}
function parseCardRecognitionPayload(data: unknown): CardRecognitionPayload {
if (data && typeof data === 'object')
return data as CardRecognitionPayload
const text = String(data ?? '').replace(/^\uFEFF/, '').trim()
return text ? JSON.parse(text) as CardRecognitionPayload : {}
}
export function recognizeCardImage(filePath: string) {
return new Promise<CardRecognitionRes>((resolve, reject) => {
uni.uploadFile({
@@ -65,17 +79,20 @@ export function recognizeCardImage(filePath: string) {
name: 'file',
timeout: 30000,
success: (response) => {
let payload: { code?: number, message?: string, data?: CardRecognitionRes }
let payload: CardRecognitionPayload
try {
payload = JSON.parse(String(response.data || '{}'))
payload = parseCardRecognitionPayload(response.data)
}
catch {
reject({ message: '图片识别响应格式错误' })
const headers = (response as typeof response & { header?: Record<string, string> }).header
const contentType = headers?.['content-type'] || headers?.['Content-Type']
const responseType = contentType ? `,${contentType}` : ''
reject(new Error(`图片识别响应格式错误(HTTP ${response.statusCode}${responseType})`))
return
}
if (response.statusCode < 200 || response.statusCode >= 300 || Number(payload.code) !== 0) {
reject({ message: payload.message || '图片识别失败' })
reject(new Error(payload.message || '图片识别失败'))
return
}
resolve(payload.data || {
@@ -88,7 +105,7 @@ export function recognizeCardImage(filePath: string) {
})
},
fail: (error) => {
reject({ message: error.errMsg || '图片上传失败,请检查网络后重试' })
reject(new Error(error.errMsg || '图片上传失败,请检查网络后重试'))
},
})
})
+1 -1
View File
@@ -32,7 +32,7 @@ export function getOpenDiagnosisInfo(data: DiagnosisInfoParams & { tenantId?: st
}
export function getDiagnosisFlow(data: DiagnosisFlowParams) {
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
return http.post<DiagnosisFlowRes>('/diagnosis/flow', data, undefined, undefined, {
hideErrorToast: true,
})
}
+57
View File
@@ -0,0 +1,57 @@
import { http } from '@/http/http'
export interface AllocationUnits {
sourceDeptId?: number
sourceDeptName?: string
targetDeptId?: number
targetDeptName?: string
targetDeptMode?: 'single' | 'per_row'
}
export function allocationUnitName(name?: string, id?: number) {
return name || (id ? '单位已删除或信息未记录' : '单位信息未记录')
}
export function receivingUnitName(item: AllocationUnits) {
return item.targetDeptMode === 'per_row' ? '按卡片明细接收' : allocationUnitName(item.targetDeptName, item.targetDeptId)
}
export interface ExecutionLog extends AllocationUnits {
batchNo: string
batchType: number
sourceChannel: string
executionMode: number
sourceType: number | null
batchAsyncStatus: number
totalCount: number
runResult: number
failReason?: string
createdAt?: string
startTime?: string
endTime?: string
}
export interface ExecutionDetail extends AllocationUnits {
itemId: string
rowNo: number
detailStatus: number
errorCode?: string
errorReason?: string
}
export const channelLabels: Record<string, string> = { console: '控制台', mini_program: '小程序', api: '开放API', system: '系统任务', legacy: '历史未知' }
export const modeLabels = ['未知', '单卡', '批量']
export const inputLabels = ['选择', '连号', '输入', '文件', '定时任务']
export const statusLabels = ['已创建', '待处理', '处理中', '成功', '失败', '部分成功']
export const detailLabels = ['待处理', '处理中', '成功', '失败']
export function listExecutionLogs(params: Record<string, unknown>) {
return http.get<{ items: ExecutionLog[], total: number }>('/execution-log/list', params, undefined, { hideErrorToast: true })
}
export function executionSummary(batchNo: string) {
return http.get<ExecutionLog>('/execution-log/summary', { batchNo }, undefined, { hideErrorToast: true })
}
export function executionDetails(params: Record<string, unknown>) {
return http.get<{ items: ExecutionDetail[], total: number }>('/execution-log/details', params, undefined, { hideErrorToast: true })
}
export function allocateSummary(params: { batchNo?: string, requestId?: string }) {
return http.get<ExecutionLog>('/card/allocate-result', params, undefined, { hideErrorToast: true })
}
export function allocateDetails(params: Record<string, unknown>) {
return http.get<{ items: ExecutionDetail[], total: number }>('/card/allocate-details', params, undefined, { hideErrorToast: true })
}
+28
View File
@@ -3,6 +3,7 @@ import type {
ICheckSideCaptchaForm,
IForgotPasswordForm,
ILoginForm,
IPhoneSmsLoginForm,
IRegisterForm,
ISendSmsForm,
ISendSmsRes,
@@ -59,6 +60,16 @@ export function login(loginForm: ILoginForm) {
)
}
export function phoneSmsLogin(data: IPhoneSmsLoginForm) {
return http.post<IAuthLoginRes>(
'/auth/phone-login',
data,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function wechatLogin(data: IWechatLoginForm) {
return http.post<IWechatLoginRes>(
'/auth/wechat-login',
@@ -150,3 +161,20 @@ export function getWxCode() {
})
})
}
export interface WarningPreference {
enabled: boolean
wechatBound: boolean
}
export function getWarningPreference() {
return http.get<WarningPreference>('/user/wechat/warning-preference', undefined, undefined, AUTH_REQUEST_OPTIONS)
}
export function saveWarningPreference(enabled: boolean) {
return http.post<WarningPreference>('/user/wechat/warning-preference', { enabled }, undefined, undefined, AUTH_REQUEST_OPTIONS)
}
export function unbindWechat() {
return http.post<IWechatBindRes>('/user/wechat/unbind', {}, undefined, undefined, AUTH_REQUEST_OPTIONS)
}
+12 -7
View File
@@ -64,12 +64,12 @@ export interface PublicQueryCardParams {
export type CardRecognitionCode
= | 'CARD_RECOGNIZED'
| 'CARD_AMBIGUOUS'
| 'CARD_UNREADABLE'
| 'RECOGNITION_TIMEOUT'
| 'RECOGNITION_FAILED'
| 'RECOGNITION_DISABLED'
| 'RECOGNITION_RATE_LIMITED'
| 'CARD_AMBIGUOUS'
| 'CARD_UNREADABLE'
| 'RECOGNITION_TIMEOUT'
| 'RECOGNITION_FAILED'
| 'RECOGNITION_DISABLED'
| 'RECOGNITION_RATE_LIMITED'
export interface CardRecognitionRes {
code: CardRecognitionCode | string
@@ -86,6 +86,7 @@ export interface CardListParams {
cardValue?: string
status?: string
categoryCode?: string
allocateOnly?: boolean
includeTotal?: boolean
}
@@ -207,13 +208,16 @@ export interface AllocateTarget {
}
export interface AllocateBatchPrepareParams {
executionMode?: number
cardValues: string[]
targetDeptId: number
}
export interface AllocateBatchSubmitParams {
requestId?: string
executionMode?: number
cardValues?: string[]
batchNo: string
batchNo?: string
targetDeptId?: number
remark?: string
}
@@ -277,6 +281,7 @@ export interface AllocateTargetListRes {
}
export interface AllocateBatchRes {
requestId?: string
batchNo: string
sourceBatchNo?: string
batchType: number
+7
View File
@@ -28,6 +28,8 @@ export interface AppCustomer {
balance?: number
cardCount: number
normalCardCount: number
cardStatusCounts?: { status: number, count: number }[]
packageStatusCounts?: { status: number, count: number }[]
address?: string
fullName?: string
createdAt?: string
@@ -41,6 +43,11 @@ export interface AppCustomerDetail extends AppCustomer {
export interface CustomerListRes {
items: AppCustomer[]
total: number
summary: {
total: number
normalCount: number
pendingProfileCount: number
}
}
export interface CustomerDetailParams {
+6
View File
@@ -13,8 +13,12 @@ export interface DiagnosisInfoItem {
iccid?: string
imsi?: string
msisdn?: string
imei?: string
status?: string
dataStatus?: string
dataServiceSource?: 'apn' | 'network_block'
netBlockStatus?: string
netBlockReason?: string
onlineStatus?: string
operatingStatus?: string
deviceStatus?: string
@@ -88,6 +92,7 @@ export interface DiagnosisInfoRes {
export interface DiagnosisModuleItem {
status: 'success' | 'error' | 'cached' | 'skipped' | string
reason?: 'not_subscribed' | 'unsupported' | 'no_data' | 'query_error' | string
message?: string
cached?: boolean
updatedAt?: string
@@ -96,6 +101,7 @@ export interface DiagnosisModuleItem {
export interface DiagnosisFlowRes {
times: string[]
flow: number[]
flowAvailable?: boolean[]
unit: string
granularity?: 'day' | 'month' | string
hasData?: boolean
+13 -5
View File
@@ -97,6 +97,13 @@ export interface ILoginForm {
wxCode?: string
}
export interface IPhoneSmsLoginForm {
tenantId: string
phone: string
smsCode: string
selectedUserId?: number
}
export interface IWechatLoginForm {
tenantId: string
wxCode: string
@@ -106,8 +113,7 @@ export interface IWechatBindPhoneForm {
tenantId: string
bindTicket: string
phone: string
captchaKey?: string
captchaPoint?: string
smsCode: string
selectedUserId?: number
}
@@ -116,8 +122,7 @@ export interface IRegisterForm {
phone: string
password: string
nickName: string
/** 短信验证码:当前注册未接入短信,图形验证通过后可为空 */
smsCode?: string
smsCode: string
}
export interface IForgotPasswordForm {
@@ -128,6 +133,7 @@ export interface IForgotPasswordForm {
}
export interface IUserInfoRes {
isTopLevelEnterprise?: boolean
userId?: number
tenantId?: string
deptId?: number
@@ -179,7 +185,9 @@ export interface ICheckSideCaptchaForm {
export interface ISendSmsForm {
tenantId: string
phone: string
scene: 'register' | 'forgotPassword'
scene: 'register' | 'login' | 'forgotPassword' | 'wechatBindPhone'
captchaKey: string
captchaPoint: string
}
export interface ISendSmsRes {
+4 -2
View File
@@ -40,7 +40,7 @@ export interface WarningConfigSaveParams {
secondWarn?: number
thirdWarn?: number
isEnabled: number
appNotifyEnabled: number
appNotifyEnabled?: number
remark?: string
}
@@ -74,6 +74,8 @@ export interface WarningConfigMetaRes {
wechatBound: boolean
wechatBindText: string
appNotifyEnabled: number
activeConfigCount?: number
notifyConfigCount?: number
}
export interface WarningConfigBatchSaveParams {
@@ -87,7 +89,7 @@ export interface WarningConfigBatchItemParams {
secondWarn?: number
thirdWarn?: number
isEnabled: number
appNotifyEnabled: number
appNotifyEnabled?: number
remark?: string
}
+33
View File
@@ -1,4 +1,5 @@
export interface WorkOrderListParams {
scope?: 'all' | 'submitted' | 'received'
page: number
pageSize: number
keyword?: string
@@ -7,10 +8,12 @@ export interface WorkOrderListParams {
}
export interface WorkOrderDetailParams {
beforeId?: number
woNo: string
}
export interface WorkOrderCreateParams {
creatorPhone: string
woType: number
relatedId?: number
title: string
@@ -29,7 +32,37 @@ export interface AppWorkOrderCommunication {
createdAt?: string
}
export type WorkOrderAction = 'reply' | 'reopen' | 'confirm' | 'cancel' | 'evaluate' | 'transfer' | 'resolve' | 'close'
export interface WorkOrderActionParams {
woNo: string
action: WorkOrderAction
content?: string
attachmentUrls?: string[]
satisfactionLevel?: number
}
export interface WorkOrderProgress {
id: number
operationType: number
operationText: string
beforeStatus: number
afterStatus: number
createdAt?: string
}
export interface AppWorkOrder {
attachmentNames?: Record<string, string>
isCreator?: boolean
hasMoreCommunications?: boolean
attachmentUrls?: string[]
actions: WorkOrderAction[]
records?: WorkOrderProgress[]
resolveTime?: string
closeTime?: string
satisfactionLevel: number
evalContent?: string
evalTime?: string
id: number
woNo: string
woType: number
+6
View File
@@ -1,4 +1,5 @@
import type {
WorkOrderActionParams,
WorkOrderCreateParams,
WorkOrderCreateRes,
WorkOrderDetailParams,
@@ -11,6 +12,7 @@ import { http } from '@/http/http'
export type {
AppWorkOrder,
AppWorkOrderCommunication,
WorkOrderActionParams,
WorkOrderCreateParams,
WorkOrderDetailParams,
WorkOrderListParams,
@@ -33,3 +35,7 @@ export function createWorkOrder(data: WorkOrderCreateParams) {
hideErrorToast: true,
})
}
export function actOnWorkOrder(data: WorkOrderActionParams) {
return http.post('/work-order/action', data, undefined, undefined, { hideErrorToast: true })
}
+4 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { getCurrentPageI18nKey, isCurrentPageTabbar } from '@/utils'
defineProps<{
desc?: string
}>()
@@ -37,10 +38,11 @@ onMounted(() => {
<template #title>
<view class="nav-title-row">
<view class="nav-title">{{ title }}</view>
<view class="nav-title">
{{ title }}
</view>
</view>
</template>
</wd-navbar>
</template>
@@ -109,5 +111,4 @@ onMounted(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { isWorkOrderImage } from '@/utils/workOrderAttachment'
const props = defineProps<{ url: string, name: string }>()
const emit = defineEmits<{ open: [] }>()
const failed = ref(false)
const loaded = ref(false)
const isImage = computed(() => isWorkOrderImage(props.url, props.name))
watch(() => props.url, () => {
failed.value = false
loaded.value = false
})
</script>
<template>
<view v-if="isImage" class="w-full overflow-hidden rounded-12rpx bg-#f1f5f9" @click="emit('open')">
<view class="relative h-176rpx w-full">
<view v-if="!loaded || failed" class="absolute inset-0 flex flex-col items-center justify-center gap-8rpx text-#64748b">
<wd-icon name="image" size="28" />
<text class="text-22rpx">{{ failed ? '点击查看原图' : '图片加载中' }}</text>
</view>
<image
v-if="!failed"
:src="url"
mode="aspectFill"
class="relative h-176rpx w-full"
@load="loaded = true"
@error="failed = true"
/>
</view>
<view class="truncate px-12rpx py-10rpx text-22rpx text-#475569">
{{ name }}
</view>
</view>
<view v-else class="w-full overflow-hidden rounded-12rpx bg-#f1f5f9">
<wd-cell :title="name" icon="file" is-link custom-class="!bg-transparent" custom-title-class="!truncate !text-24rpx" @click="emit('open')" />
</view>
</template>
@@ -0,0 +1,33 @@
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 { workOrderAttachmentName } from '@/utils/workOrderAttachment'
const source = readFileSync(new URL('./WorkOrderAttachments.vue', import.meta.url), 'utf8')
describe('工单上传原始文件名', () => {
it('微信上传携带原名,并保留URL字符串协议', async () => {
const emit = vi.fn()
const upload = vi.fn((options) => {
options.success({ statusCode: 200, data: JSON.stringify({ code: 0, data: { url: 'https://files/random.pdf', originalName: '说明.pdf' } }) })
})
const context = createContext({
exports: {},
require: (name: string) => name === '@/utils/workOrderAttachment' ? { workOrderAttachmentName } : name === '@/utils' ? { getEnvBaseUrl: () => 'https://api/api/v1' } : {},
defineProps: () => ({ modelValue: [], disabled: false }),
defineEmits: () => emit,
ref: (value: unknown) => ({ value }),
uni: { uploadFile: upload, showToast: 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)
await runInContext('upload("wxfile://random.pdf", "说明.pdf", 20)', context)
expect(upload).toHaveBeenCalledWith(expect.objectContaining({ url: '/work-order/upload', formData: { fileType: 'file', originalName: '说明.pdf' } }))
expect(emit).toHaveBeenCalledWith('update:modelValue', ['https://files/random.pdf'])
expect(runInContext('nameOf("https://files/random.pdf")', context)).toBe('说明.pdf')
expect(source).toContain('form.append(\'originalName\', name)')
})
})
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { useTokenStore } from '@/store/token'
import { getEnvBaseUrl } from '@/utils'
import { workOrderAttachmentName } from '@/utils/workOrderAttachment'
const props = defineProps<{ modelValue: string[], disabled?: boolean }>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
'uploading': [value: boolean]
}>()
const uploading = ref(false)
const attachmentNames = ref<Record<string, string>>({})
function nameOf(url: string) {
return workOrderAttachmentName(url, attachmentNames.value[url])
}
async function upload(file: string | File, name: string, size?: number) {
if (uploading.value || props.disabled)
return
if ((size || 0) > 10 * 1024 * 1024) {
uni.showToast({ title: '单个附件不能超过10MB', icon: 'none' })
return
}
uploading.value = true
emit('uploading', true)
try {
let body: { code: number, msg?: string, message?: string, data?: { url?: string, path?: string, originalName?: string } }
// #ifdef H5
if (typeof file !== 'string') {
const form = new FormData()
form.append('file', file, name)
form.append('fileType', 'file')
form.append('originalName', name)
const token = useTokenStore().updateNowTime().validToken
const response = await fetch(`${getEnvBaseUrl()}/work-order/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: form,
})
if (!response.ok)
throw new Error('附件上传失败')
body = await response.json()
}
else {
// #endif
body = await new Promise((resolve, reject) => {
uni.uploadFile({
url: '/work-order/upload',
filePath: file as string,
name: 'file',
formData: { fileType: 'file', originalName: name },
success: (res) => {
try {
if (res.statusCode < 200 || res.statusCode >= 300)
throw new Error('附件上传失败')
resolve(JSON.parse(res.data))
}
catch (error) { reject(error) }
},
fail: reject,
})
})
// #ifdef H5
}
// #endif
if (Number(body.code) !== 0)
throw new Error(body.message || body.msg || '附件上传失败')
const url = body.data?.url || body.data?.path
if (!url)
throw new Error('上传接口未返回文件地址')
attachmentNames.value[url] = body.data?.originalName || name
emit('update:modelValue', [...props.modelValue, url])
}
catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : '附件上传失败', icon: 'none' })
}
finally {
uploading.value = false
emit('uploading', false)
}
}
function chooseFile() {
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.onchange = () => {
const file = input.files?.[0]
if (file)
void upload(file, file.name, file.size)
}
input.click()
return
// #endif
// #ifdef MP-WEIXIN
uni.chooseMessageFile({
count: 1,
type: 'all',
success: (res) => {
const file = res.tempFiles[0]
if (file)
void upload(file.path, file.name, file.size)
},
})
return
// #endif
// #ifndef H5
// #ifndef MP-WEIXIN
uni.showToast({ title: '当前平台请使用图片附件', icon: 'none' })
// #endif
// #endif
}
function choose() {
if (uploading.value || props.disabled)
return
if (props.modelValue.length >= 6) {
uni.showToast({ title: '最多上传6个附件', icon: 'none' })
return
}
uni.showActionSheet({
itemList: ['拍照或选择图片', '选择文件'],
success: ({ tapIndex }) => {
if (tapIndex === 1) {
chooseFile()
return
}
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const files = res.tempFiles as UniApp.ChooseImageSuccessCallbackResult['tempFiles']
const file = Array.isArray(files) ? files[0] : undefined
const path = res.tempFilePaths[0]
if (path)
void upload(path, workOrderAttachmentName(path), file?.size)
},
})
},
})
}
</script>
<template>
<view class="flex flex-col gap-12rpx">
<view class="flex items-center justify-between gap-16rpx">
<text class="text-24rpx text-#64748b">附件 {{ modelValue.length }}/6,单个不超过10MB</text>
<wd-button size="small" type="primary" variant="text" icon="add" :disabled="disabled || modelValue.length >= 6" :loading="uploading" @click="choose">
添加附件
</wd-button>
</view>
<view v-for="(url, index) in modelValue" :key="`${url}-${index}`" class="flex items-center justify-between gap-16rpx">
<text class="min-w-0 flex-1 truncate text-24rpx text-#334155">{{ nameOf(url) }}</text>
<wd-button size="small" type="danger" variant="text" :disabled="disabled || uploading" @click="emit('update:modelValue', modelValue.filter((_, i) => i !== index))">
移除
</wd-button>
</view>
</view>
</template>
+44 -38
View File
@@ -6,76 +6,82 @@
* 页面路径常量
*/
const RouterPath = {
/** 山东平台物联卡 */
Home: '/pages/home/index',
/** 示例企业物联卡 */
'Home': '/pages/home/index',
/** 办理 */
Business: '/pages/business/index',
'Business': '/pages/business/index',
/** 卡片 */
Card: '/pages/card/index',
/** 山东平台物联卡 */
Login: '/pages/login/index',
'Card': '/pages/card/index',
/** 示例企业物联卡 */
'Login': '/pages/login/index',
/** 消息 */
Message: '/pages/message/index',
'Message': '/pages/message/index',
/** 我的 */
User: '/pages/user/index',
'User': '/pages/user/index',
/** 找回密码 */
'LoginForgot-password': '/pages/login/forgot-password/index',
/** 注册账号 */
LoginRegister: '/pages/login/register/index',
'LoginRegister': '/pages/login/register/index',
/** 加油包 */
BusinessViewsAddon: '/pages/business/views/addon/index',
'BusinessViewsAddon': '/pages/business/views/addon/index',
/** 批量卡片划拨 */
BusinessViewsAllocate: '/pages/business/views/allocate/index',
'BusinessViewsAllocate': '/pages/business/views/allocate/index',
/** 余额充值 */
BusinessViewsBalanceRecharge: '/pages/business/views/balanceRecharge/index',
'BusinessViewsBalanceRecharge': '/pages/business/views/balanceRecharge/index',
/** 单卡套餐续费 */
BusinessViewsCardRenew: '/pages/business/views/cardRenew/index',
'BusinessViewsCardRenew': '/pages/business/views/cardRenew/index',
/** 套餐变更 */
BusinessViewsChange: '/pages/business/views/change/index',
'BusinessViewsChange': '/pages/business/views/change/index',
/** 客户管理 */
BusinessViewsCustomer: '/pages/business/views/customer/index',
'BusinessViewsCustomer': '/pages/business/views/customer/index',
/** 客户详情 */
BusinessViewsCustomerDetail: '/pages/business/views/customerDetail/index',
'BusinessViewsCustomerDetail': '/pages/business/views/customerDetail/index',
/** 卡片排查 */
BusinessViewsDiagnosis: '/pages/business/views/diagnosis/index',
'BusinessViewsDiagnosis': '/pages/business/views/diagnosis/index',
/** 我的订单 */
BusinessViewsOrder: '/pages/business/views/order/index',
'BusinessViewsOrder': '/pages/business/views/order/index',
/** 批量套餐续费 */
BusinessViewsRenew: '/pages/business/views/renew/index',
'BusinessViewsRenew': '/pages/business/views/renew/index',
/** 预警配置 */
BusinessViewsWarning: '/pages/business/views/warning/index',
'BusinessViewsWarning': '/pages/business/views/warning/index',
/** 工单列表 */
BusinessViewsWorkOrder: '/pages/business/views/workOrder/index',
'BusinessViewsWorkOrder': '/pages/business/views/workOrder/index',
/** 提交工单 */
BusinessViewsWorkOrderCreate: '/pages/business/views/workOrderCreate/index',
'BusinessViewsWorkOrderCreate': '/pages/business/views/workOrderCreate/index',
/** 工单详情 */
BusinessViewsWorkOrderDetail: '/pages/business/views/workOrderDetail/index',
'BusinessViewsWorkOrderDetail': '/pages/business/views/workOrderDetail/index',
/** 卡片详情 */
CardViewsDetail: '/pages/card/views/detail/index',
'CardViewsDetail': '/pages/card/views/detail/index',
/** 全部设备 */
CardViewsManagement: '/pages/card/views/management/index',
'CardViewsManagement': '/pages/card/views/management/index',
/** 卡片详情 */
CardViewsPackageDetail: '/pages/card/views/packageDetail/index',
'CardViewsPackageDetail': '/pages/card/views/packageDetail/index',
/** 预警配置 */
CardViewsWarning: '/pages/card/views/warning/index',
'CardViewsWarning': '/pages/card/views/warning/index',
/** 消息详情 */
MessageViewsCardDetail: '/pages/message/views/cardDetail/index',
'MessageViewsCardDetail': '/pages/message/views/cardDetail/index',
/** 消息详情 */
MessageViewsSystemDetail: '/pages/message/views/systemDetail/index',
'MessageViewsSystemDetail': '/pages/message/views/systemDetail/index',
/** 关于我们 */
UserViewsAbout: '/pages/user/views/about/index',
'UserViewsAbout': '/pages/user/views/about/index',
/** 协议与隐私 */
UserViewsAgreement: '/pages/user/views/agreement/index',
'UserViewsAgreement': '/pages/user/views/agreement/index',
/** 联系客服 */
UserViewsContact: '/pages/user/views/contact/index',
'UserViewsContact': '/pages/user/views/contact/index',
/** 批次执行日志 */
'UserViewsExecutionLog': '/pages/user/views/executionLog/index',
/** 执行日志详情 */
'UserViewsExecutionLogDetail': '/pages/user/views/executionLogDetail/index',
/** 帮助与反馈 */
UserViewsFeedback: '/pages/user/views/feedback/index',
/** 微信提醒 */
UserViewsNotification: '/pages/user/views/notification/index',
/** 账号资料 */
UserViewsProfile: '/pages/user/views/profile/index',
'UserViewsFeedback': '/pages/user/views/feedback/index',
/** 使用手册 */
'UserViewsManual': '/pages/user/views/manual/index',
/** 消息提醒 */
'UserViewsNotification': '/pages/user/views/notification/index',
/** 个人资料 */
'UserViewsProfile': '/pages/user/views/profile/index',
/** 实名信息 */
UserViewsRealName: '/pages/user/views/realName/index',
'UserViewsRealName': '/pages/user/views/realName/index',
}
export default RouterPath
+33
View File
@@ -0,0 +1,33 @@
/**
* v1.0.2 front-end release scope (legacy export names retained).
*
* Business, customer management and authorized allocation are now available.
* Other unreleased lifecycle actions remain hidden. The API layer is unchanged;
* these switches keep every front-end entry point consistent.
*/
export const V101_RELEASE = {
showWechatLogin: false,
showRegister: false,
showBindCard: false,
showCardMoreActions: false,
showBusinessTab: true,
showUserBusinessEntries: false,
showDiagnosisFlowForNonAdmin: false,
} as const
/** Routes that must remain unreachable from direct front-end navigation. */
export const V101_HIDDEN_ROUTE_PATHS = new Set([
'/pages/business/views/addon/index',
'/pages/business/views/balanceRecharge/index',
'/pages/business/views/cardRenew/index',
'/pages/business/views/change/index',
'/pages/business/views/order/index',
'/pages/business/views/renew/index',
'/pages/business/views/warning/index',
'/pages/card/views/packageDetail/index',
'/pages/card/views/warning/index',
])
export function isV101HiddenRoute(path: string) {
return V101_HIDDEN_ROUTE_PATHS.has(path)
}
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
const config = vi.hoisted(() => ({ baseUrl: '/app/api/v1' }))
vi.mock('@/utils', () => ({ getEnvBaseUrl: () => config.baseUrl }))
vi.mock('@/store', () => ({
useTokenStore: () => ({ updateNowTime: () => ({ validToken: 'test-token' }) }),
}))
afterEach(() => {
vi.unstubAllGlobals()
vi.resetModules()
})
describe('工单附件上传地址', () => {
it.each([
'/app/api/v1',
'http://localhost:8010/api/v1',
'https://example.com/app/api/v1',
])('基础地址 %s 只拼接一次,并保留鉴权', async (baseUrl) => {
config.baseUrl = baseUrl
const addInterceptor = vi.fn()
vi.stubGlobal('uni', { addInterceptor })
const { requestInterceptor } = await import('./interceptor')
requestInterceptor.install()
const uploadInterceptor = addInterceptor.mock.calls.find(([name]) => name === 'uploadFile')?.[1]
expect(uploadInterceptor).toBeDefined()
const options = uploadInterceptor.invoke({ url: '/work-order/upload' })
expect(options.url).toBe(`${baseUrl}/work-order/upload`)
expect(options.header.Authorization).toBe('Bearer test-token')
})
it('原生上传交由拦截器拼接,H5 fetch 保留基础地址', () => {
const source = readFileSync(new URL('../components/WorkOrderAttachments.vue', import.meta.url), 'utf8')
expect(source).toMatch(/uni\.uploadFile\(\{\s*url: '\/work-order\/upload'/)
expect(source).toMatch(/fetch\(`\$\{getEnvBaseUrl\(\)\}\/work-order\/upload`/)
})
})
+1 -1
View File
@@ -28,7 +28,7 @@ const networkErrorPattern
= /request:fail|timeout|failed to fetch|networkerror|err_connection|econnrefused|econnreset|econnaborted|enotfound|net::|socket|connect|refused|abort|not in domain list|url not in domain list|ssl|tls|certificate|网络|超时/i
const technicalDetailPattern
= /https?:\/\/|\/api\/|接口[::]|errMsg|statusCode|StatusCode|request:|XMLHttpRequest|TypeError:|Error:/i
= /https?:\/\/|\/api\/|接口[::]|errMsg|statusCode|request:|XMLHttpRequest|TypeError:|Error:/i
/**
* 根据状态码,生成对应的错误信息
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "山东平台物联卡",
"appid": "__UNI__YOURAPPID",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"versionName": "1.0.2",
"versionCode": "102",
"transformPx": false,
"app-plus": {
"usingComponents": true,
+27 -5
View File
@@ -21,7 +21,7 @@
"type": "home",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "山东平台物联卡",
"navigationBarTitleText": "示例企业物联卡",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
},
@@ -49,7 +49,7 @@
"type": "page",
"name": "Login",
"style": {
"navigationBarTitleText": "山东平台物联卡",
"navigationBarTitleText": "示例企业物联卡",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"disableScroll": true
@@ -180,7 +180,8 @@
"path": "workOrder/index",
"type": "page",
"style": {
"navigationBarTitleText": "工单列表"
"navigationBarTitleText": "工单列表",
"enablePullDownRefresh": true
}
},
{
@@ -290,7 +291,7 @@
"path": "notification/index",
"type": "page",
"style": {
"navigationBarTitleText": "微信提醒"
"navigationBarTitleText": "消息提醒"
}
},
{
@@ -298,7 +299,7 @@
"type": "page",
"excludeLoginPath": true,
"style": {
"navigationBarTitleText": "账号资料"
"navigationBarTitleText": "个人资料"
}
},
{
@@ -307,6 +308,27 @@
"style": {
"navigationBarTitleText": "实名信息"
}
},
{
"path": "manual/index",
"type": "page",
"style": {
"navigationBarTitleText": "使用手册"
}
},
{
"path": "executionLog/index",
"type": "page",
"style": {
"navigationBarTitleText": "批次执行日志"
}
},
{
"path": "executionLogDetail/index",
"type": "page",
"style": {
"navigationBarTitleText": "执行日志详情"
}
}
]
}
@@ -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>
+23
View File
@@ -0,0 +1,23 @@
import type { AppCard } from '@/api/card'
import { describe, expect, it } from 'vitest'
import { isV101HiddenRoute, V101_RELEASE } from '@/constant/release'
import { buildCardMoreActions } from './action'
const user = { userId: 12, deptId: 100, userType: 'sys_user', appAccess: { configured: true, features: ['card:allocate'] } }
const card = { ownerType: 'enterprise', canBusiness: true, canAllocate: true } as AppCard
describe('划拨单卡入口发布与权限', () => {
it('只开放授权企业卡的划拨,不暴露其他操作', () => {
expect(V101_RELEASE.showCardMoreActions).toBe(false)
expect(buildCardMoreActions(user, card).map(item => item.key)).toEqual(['allocate'])
expect(isV101HiddenRoute('/pages/business/views/allocate/index')).toBe(false)
expect(isV101HiddenRoute('/pages/business/views/change/index')).toBe(true)
})
it('无权限、终端、公共卡、不可办理和不可划拨卡不显示按钮', () => {
expect(buildCardMoreActions({ ...user, appAccess: { configured: true, features: [] } }, card)).toEqual([])
expect(buildCardMoreActions({ ...user, userType: 'app_user' }, card)).toEqual([])
expect(buildCardMoreActions(user, { ...card, ownerType: 'public' })).toEqual([])
expect(buildCardMoreActions(user, { ...card, canBusiness: false })).toEqual([])
expect(buildCardMoreActions(user, { ...card, canAllocate: false })).toEqual([])
})
})
+9 -2
View File
@@ -2,6 +2,7 @@ import type { AppCard } from '@/api/card'
import type { IUserInfoRes } from '@/api/types/login'
import { hasUserPermission } from '@/utils/permission'
import { getAccountWorkspace, isEnterpriseUserInfo } from '@/utils/userIdentity'
import { V101_RELEASE } from '@/constant/release'
export type CardMoreActionKey = 'restart' | 'stop' | 'start' | 'dataStart' | 'dataStop' | 'change' | 'allocate' | 'warning'
@@ -59,11 +60,11 @@ export function canStartCardAction(userInfo: Partial<IUserInfoRes> | null | unde
}
export function canDataStartCardAction(userInfo: Partial<IUserInfoRes> | null | undefined, card?: AppCard | null) {
return canEnterpriseCardAction(userInfo, card) && hasUserPermission(userInfo, 'dataStart')
return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'dataStart')
}
export function canDataStopCardAction(userInfo: Partial<IUserInfoRes> | null | undefined, card?: AppCard | null) {
return canEnterpriseCardAction(userInfo, card) && hasUserPermission(userInfo, 'dataStop')
return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'dataStop')
}
export function canChangeCardAction(userInfo: Partial<IUserInfoRes> | null | undefined, card?: AppCard | null) {
@@ -78,6 +79,12 @@ export function buildCardMoreActions(
userInfo: Partial<IUserInfoRes> | null | undefined,
card?: AppCard | null,
) {
if (!V101_RELEASE.showCardMoreActions) {
return canAllocateCardAction(userInfo, card)
? [{ key: 'allocate', name: '卡片划拨', description: '将这张卡划拨给直属客户' }] satisfies CardMoreAction[]
: []
}
const actions: CardMoreAction[] = []
if (card?.bossCardStatus === 4) {
+69 -188
View File
@@ -1,7 +1,15 @@
<script setup lang="ts">
import type { AppCard } from '@/api/card'
import { getCardList, restartCard, startCard, startDataService, stopCard, stopDataService } from '@/api/card'
import {
getCardList,
restartCard,
startCard,
startDataService,
stopCard,
stopDataService,
} from '@/api/card'
import RouterPath from '@/constant/RouterPath'
import { V101_RELEASE } from '@/constant/release'
import { useUserStore } from '@/store/user'
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
import {
@@ -9,6 +17,8 @@ import {
canAllocateCardAction,
canBusinessCardAction,
canChangeCardAction,
canDataStartCardAction,
canDataStopCardAction,
canRenewCardAction,
canRestartCardAction,
canStartCardAction,
@@ -62,9 +72,9 @@ const moreSheetActions = computed(() =>
)
const showInitialLoading = computed(() => loading.value && cards.value.length === 0)
const previewSize = computed(() => Math.max(1, Number(props.previewLimit || 5)))
const pageSize = computed(() => props.compact ? previewSize.value : 20)
const hasMore = computed(() =>
!props.compact && serverHasMore.value && cards.value.length < MAX_LOADED_CARDS,
const pageSize = computed(() => (props.compact ? previewSize.value : 20))
const hasMore = computed(
() => !props.compact && serverHasMore.value && cards.value.length < MAX_LOADED_CARDS,
)
const visibleCards = computed(() => {
@@ -91,15 +101,12 @@ const renderCards = computed(() => {
return visibleCards.value.slice(0, previewSize.value)
})
const hiddenPreviewCount = computed(() =>
Math.max(0, total.value - renderCards.value.length),
)
const hiddenPreviewCount = computed(() => Math.max(0, total.value - renderCards.value.length))
const showPreviewMore = computed(() => Boolean(props.compact && hiddenPreviewCount.value > 0))
function mergeCards(current: AppCard[], incoming: AppCard[]) {
const merged = new Map<string, AppCard>()
for (const item of [...current, ...incoming])
merged.set(displayCardValue(item), item)
for (const item of [...current, ...incoming]) merged.set(displayCardValue(item), item)
return Array.from(merged.values())
}
@@ -111,8 +118,7 @@ async function loadCards(reset = true) {
const nextPage = reset ? 1 : currentPage.value + 1
if (reset)
loading.value = true
else
loadingMore.value = true
else loadingMore.value = true
emit('loadingChange', true)
errorMessage.value = ''
try {
@@ -131,7 +137,7 @@ async function loadCards(reset = true) {
cards.value = cards.value.slice(0, MAX_LOADED_CARDS)
if (reset)
total.value = Math.max(Number(res.total || 0), cards.value.length)
serverHasMore.value = Boolean(res.hasMore ?? (cards.value.length < total.value))
serverHasMore.value = Boolean(res.hasMore ?? cards.value.length < total.value)
currentPage.value = nextPage
emit('cardsChange', cards.value, total.value)
}
@@ -225,6 +231,14 @@ function canRestart(item: AppCard) {
return canRestartCardAction(userStore.userInfo, item)
}
function canStartDataService(item: AppCard) {
return Number(item.dataServiceStatus) === 0 && canDataStartCardAction(userStore.userInfo, item)
}
function canStopDataService(item: AppCard) {
return Number(item.dataServiceStatus) === 1 && canDataStopCardAction(userStore.userInfo, item)
}
function canWarning(item?: AppCard | null) {
return canWarningCardAction(userStore.userInfo, item)
}
@@ -251,26 +265,6 @@ function canLifecycle(item: AppCard, operation: 'stop' | 'start') {
: canStartCardAction(userStore.userInfo, item)
}
function primaryOperateText(_item: AppCard) {
return '重启'
}
function primaryOperateIcon(_item: AppCard) {
return 'i-carbon-reset'
}
function primaryOperateDisabled(item: AppCard) {
return !canRestart(item) || !!restartingCard.value
}
function primaryOperateLoading(item: AppCard) {
return restartingCard.value === displayCardValue(item)
}
function handlePrimaryOperate(item: AppCard) {
void handleRestart(item)
}
async function handleRestart(item: AppCard) {
if (!canRestart(item)) {
uni.showToast({ title: '当前账号不能重启卡片', icon: 'none' })
@@ -343,6 +337,11 @@ async function submitLifecycle(cardValue: string, operation: 'stop' | 'start', a
}
function handleDataService(item: AppCard, operation: 'dataStart' | 'dataStop') {
const allowed = operation === 'dataStart' ? canStartDataService(item) : canStopDataService(item)
if (!allowed) {
uni.showToast({ title: '当前账号没有该数据服务操作权限', icon: 'none' })
return
}
const cardValue = displayCardValue(item)
if (cardValue === '-')
return
@@ -358,15 +357,20 @@ function handleDataService(item: AppCard, operation: 'dataStart' | 'dataStop') {
})
}
async function submitDataService(cardValue: string, operation: 'dataStart' | 'dataStop', actionText: string) {
async function submitDataService(
cardValue: string,
operation: 'dataStart' | 'dataStop',
actionText: string,
) {
const submittingKey = `${operation}:${cardValue}`
if (lifecycleSubmittingCard.value)
return
lifecycleSubmittingCard.value = submittingKey
try {
const res = operation === 'dataStart'
? await startDataService({ cardValue })
: await stopDataService({ cardValue })
const res
= operation === 'dataStart'
? await startDataService({ cardValue })
: await stopDataService({ cardValue })
await showLifecycleBatchProgress(res, actionText)
void loadCards()
}
@@ -463,6 +467,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
++cardLoadSeq
if (searchTimer)
clearTimeout(searchTimer)
})
@@ -511,8 +516,13 @@ defineExpose({
:class="{ 'preview-card-row': props.compact }"
>
<template v-if="props.browse">
<view class="min-h-112rpx min-w-0 flex items-center gap-16rpx py-18rpx" @click="goDetail(item)">
<view class="h-64rpx w-64rpx flex flex-shrink-0 items-center justify-center rounded-14rpx bg-#eff6ff text-32rpx text-#2563eb">
<view
class="min-h-112rpx min-w-0 flex items-center gap-16rpx py-18rpx"
@click="goDetail(item)"
>
<view
class="h-64rpx w-64rpx flex flex-shrink-0 items-center justify-center rounded-14rpx bg-#eff6ff text-32rpx text-#2563eb"
>
<view class="i-carbon-sim-card" />
</view>
<view class="min-w-0 flex-1">
@@ -520,13 +530,17 @@ defineExpose({
{{ displayCardValue(item) }}
</text>
<text class="mt-4rpx block truncate text-22rpx text-#64748b leading-30rpx">
{{ item.msisdn || item.terminalName || '物联网卡' }} · {{ item.packageName || '未配置套餐' }}
{{ item.msisdn || item.terminalName || '物联网卡' }} ·
{{ item.packageName || '未配置套餐' }}
</text>
<view class="mt-8rpx flex items-center gap-10rpx">
<text class="min-w-0 truncate text-21rpx text-#475569">
剩余 {{ formatRemainFlow(item) }}
</text>
<view class="app-tag flex-shrink-0 !px-10rpx !py-2rpx !text-19rpx !leading-26rpx" :class="getCardStatusClass(item)">
<view
class="app-tag flex-shrink-0 !px-10rpx !py-2rpx !text-19rpx !leading-26rpx"
:class="getCardStatusClass(item)"
>
{{ getBossCardStatusText(item.bossCardStatus) }}
</view>
</view>
@@ -584,7 +598,10 @@ defineExpose({
<text class="owner-val truncate">{{ ownerDeptText(item) }}</text>
</view>
<view v-if="getCardAbnormalReasonText(item)" class="mt-12rpx flex items-center gap-8rpx border border-#fee2e2 rounded-8rpx bg-#fef2f2 px-14rpx py-10rpx">
<view
v-if="getCardAbnormalReasonText(item)"
class="mt-12rpx flex items-center gap-8rpx border border-#fee2e2 rounded-8rpx bg-#fef2f2 px-14rpx py-10rpx"
>
<view class="i-carbon-warning-alt flex-shrink-0 text-24rpx text-#dc2626" />
<text class="min-w-0 flex-1 truncate text-22rpx text-#b91c1c font-700">
{{ getCardAbnormalReasonText(item) }}
@@ -619,69 +636,16 @@ defineExpose({
详情
</wd-button>
<wd-button
v-if="buildCardMoreActions(userStore.userInfo, item).length"
size="small"
plain
block
custom-class="card-primary-btn"
@click="openMoreActions(item)"
>
更多
{{ V101_RELEASE.showCardMoreActions ? '更多' : '划拨' }}
</wd-button>
</view>
<view class="card-quick-wrap">
<text class="card-quick-label">快捷操作</text>
<view class="card-quick-row">
<wd-button
size="small"
plain
custom-class="card-quick-btn"
:disabled="!canRenew(item)"
@click="goRenew(item)"
>
<view class="card-quick-btn__inner">
<view class="i-carbon-renew card-quick-btn__icon card-quick-btn__icon--green" />
<text class="card-quick-btn__text">续费</text>
</view>
</wd-button>
<wd-button
size="small"
plain
custom-class="card-quick-btn"
:disabled="primaryOperateDisabled(item)"
:loading="primaryOperateLoading(item)"
@click="handlePrimaryOperate(item)"
>
<view class="card-quick-btn__inner">
<view :class="primaryOperateIcon(item)" class="card-quick-btn__icon card-quick-btn__icon--blue" />
<text class="card-quick-btn__text">{{ primaryOperateText(item) }}</text>
</view>
</wd-button>
<wd-button
size="small"
plain
custom-class="card-quick-btn"
:disabled="!canWarning(item)"
@click="goWarning(item)"
>
<view class="card-quick-btn__inner">
<view class="i-carbon-warning-alt card-quick-btn__icon card-quick-btn__icon--orange" />
<text class="card-quick-btn__text">预警</text>
</view>
</wd-button>
<wd-button
size="small"
plain
custom-class="card-quick-btn"
@click="openMoreActions(item)"
>
<view class="card-quick-btn__inner">
<view class="i-carbon-overflow-menu-horizontal card-quick-btn__icon card-quick-btn__icon--gray" />
<text class="card-quick-btn__text">更多</text>
</view>
</wd-button>
</view>
</view>
</template>
</view>
@@ -694,17 +658,15 @@ defineExpose({
<wd-loading size="18px" />
<text>正在加载更多...</text>
</view>
<wd-button
v-else-if="hasMore"
size="small"
plain
block
@click="loadMore"
>
<wd-button v-else-if="hasMore" size="small" plain block @click="loadMore">
加载更多(已加载 {{ cards.length }}/{{ total }})
</wd-button>
<text v-else class="text-22rpx text-muted">
{{ cards.length >= MAX_LOADED_CARDS && cards.length < total ? `已加载前 ${MAX_LOADED_CARDS} 张,请使用搜索缩小范围` : `已加载全部 ${total} 张卡` }}
{{
cards.length >= MAX_LOADED_CARDS && cards.length < total
? `已加载前 ${MAX_LOADED_CARDS} 张,请使用搜索缩小范围`
: `已加载全部 ${total} 张卡`
}}
</text>
</view>
</template>
@@ -716,6 +678,7 @@ defineExpose({
/>
<wd-action-sheet
v-if="moreSheetActions.length"
v-model="moreSheetVisible"
title="更多操作"
cancel-text="取消"
@@ -862,86 +825,6 @@ defineExpose({
gap: 12rpx;
}
.card-quick-wrap {
margin-top: 14rpx;
padding-top: 14rpx;
border-top: 1px dashed #e2e8f0;
}
.card-quick-label {
display: block;
margin-bottom: 10rpx;
color: #64748b;
font-size: 20rpx;
}
.card-quick-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8rpx;
}
:deep(.card-quick-btn) {
width: 100% !important;
min-width: 0 !important;
height: 80rpx !important;
min-height: 80rpx !important;
padding: 0 10rpx !important;
border: 1px solid #dbe3ef !important;
border-color: #dbe3ef !important;
border-radius: 12rpx !important;
background: #fff !important;
box-shadow: none !important;
color: #1f2937 !important;
font-size: 24rpx !important;
font-weight: 700 !important;
}
:deep(.card-quick-btn.wd-button--disabled),
:deep(.card-quick-btn[disabled]) {
border-color: #e2e8f0 !important;
background: #f8fafc !important;
box-shadow: none !important;
opacity: 0.45 !important;
}
.card-quick-btn__inner {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
min-width: 0;
white-space: nowrap;
}
.card-quick-btn__icon {
flex-shrink: 0;
color: #2563eb;
font-size: 28rpx;
}
.card-quick-btn__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.card-quick-btn__icon--blue {
color: #2563eb;
}
.card-quick-btn__icon--green {
color: #2563eb;
}
.card-quick-btn__icon--orange {
color: #2563eb;
}
.card-quick-btn__icon--gray {
color: #2563eb;
}
.status-stack {
display: flex;
flex-direction: column;
@@ -1036,15 +919,13 @@ defineExpose({
font-weight: 700;
}
:deep(.card-primary-actions .wd-button),
:deep(.card-quick-row .wd-button) {
:deep(.card-primary-actions .wd-button) {
box-sizing: border-box;
width: 100%;
min-width: 0;
}
:deep(.card-primary-actions .wd-button__text),
:deep(.card-quick-row .wd-button__text) {
:deep(.card-primary-actions .wd-button__text) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -17,7 +17,7 @@ function handleRefresh() {
</script>
<template>
<view class="box-border w-full overflow-hidden card-base p-20rpx">
<view class="box-border card-base w-full overflow-hidden p-20rpx">
<view class="flex items-center gap-12rpx">
<wd-search
v-model="searchValue"
+22 -149
View File
@@ -13,6 +13,7 @@ import {
stopDataService,
} from '@/api/card'
import RouterPath from '@/constant/RouterPath'
import { V101_RELEASE } from '@/constant/release'
import { useLastQueriedCardStore, useTokenStore } from '@/store'
import { useUserStore } from '@/store/user'
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
@@ -67,9 +68,12 @@ let cardStatsLoadSeq = 0
function handleListData(_items: AppCard[], total: number) {
cardCount.value = total
void loadCardStats()
}
async function loadCardStats() {
if (!tokenStore.hasLogin)
return
const seq = ++cardStatsLoadSeq
try {
const [normal, lowFlow, abnormal] = await Promise.all([
@@ -196,13 +200,8 @@ const masterLifecycleDisabled = computed(() =>
function handleRefresh() {
refreshKey.value += 1
void loadCardStats()
}
onMounted(() => {
void loadCardStats()
})
function canMasterRenew() {
return canRenewCardAction(userStore.userInfo, lastQueriedCard.value)
}
@@ -465,15 +464,15 @@ function goMasterWarning() {
<view class="summary-statuses">
<view class="summary-status summary-status--normal">
<text class="summary-status__num">{{ normalCount }}</text>
<text>正常</text>
<text class="shrink-0 whitespace-nowrap">正常</text>
</view>
<view class="summary-status summary-status--low">
<text class="summary-status__num">{{ lowFlowCount }}</text>
<text>低流量</text>
<text class="shrink-0 whitespace-nowrap">低流量</text>
</view>
<view class="summary-status summary-status--danger">
<text class="summary-status__num">{{ abnormalCount }}</text>
<text>异常</text>
<text class="shrink-0 whitespace-nowrap">异常</text>
</view>
</view>
</view>
@@ -510,6 +509,7 @@ function goMasterWarning() {
详情
</wd-button>
<wd-button
v-if="V101_RELEASE.showCardMoreActions"
size="small"
plain
custom-class="master-action-btn"
@@ -518,62 +518,9 @@ function goMasterWarning() {
更多
</wd-button>
</view>
<view class="master-card__quick">
<text class="master-card__quick-label">快捷操作</text>
<view class="master-card__quick-grid">
<wd-button
size="small"
plain
custom-class="master-quick-btn"
:disabled="!canMasterRenew()"
@click="goMasterRenew"
>
<view class="master-quick-btn__inner">
<view class="i-carbon-renew master-quick-btn__icon master-quick-btn__icon--green" />
<text class="master-quick-btn__text">续费</text>
</view>
</wd-button>
<wd-button
size="small"
plain
custom-class="master-quick-btn"
:disabled="!canMasterRestart() || masterRestarting"
:loading="masterRestarting"
@click="goMasterRestart"
>
<view class="master-quick-btn__inner">
<view class="i-carbon-reset master-quick-btn__icon master-quick-btn__icon--blue" />
<text class="master-quick-btn__text">重启</text>
</view>
</wd-button>
<wd-button
size="small"
plain
custom-class="master-quick-btn"
:disabled="!canMasterWarning()"
@click="goMasterWarning"
>
<view class="master-quick-btn__inner">
<view class="i-carbon-warning-alt master-quick-btn__icon master-quick-btn__icon--orange" />
<text class="master-quick-btn__text">预警</text>
</view>
</wd-button>
<wd-button
size="small"
plain
custom-class="master-quick-btn"
@click="openMasterMoreActions"
>
<view class="master-quick-btn__inner">
<view class="i-carbon-overflow-menu-horizontal master-quick-btn__icon master-quick-btn__icon--gray" />
<text class="master-quick-btn__text">更多</text>
</view>
</wd-button>
</view>
</view>
</view>
<view class="bind-card" @click="openBindPanel">
<view v-if="V101_RELEASE.showBindCard" class="bind-card" @click="openBindPanel">
<view class="bind-card__icon">
<view class="i-carbon-add-alt" />
</view>
@@ -619,6 +566,7 @@ function goMasterWarning() {
</scroll-view>
<wd-action-sheet
v-if="V101_RELEASE.showCardMoreActions"
v-model="masterMoreVisible"
title="更多操作"
cancel-text="取消"
@@ -630,6 +578,7 @@ function goMasterWarning() {
/>
<wd-popup
v-if="V101_RELEASE.showBindCard"
v-model="showBindPanel"
position="bottom"
round
@@ -749,6 +698,7 @@ function goMasterWarning() {
.card-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 18rpx;
@@ -758,6 +708,7 @@ function goMasterWarning() {
.summary-total {
display: flex;
flex-shrink: 0;
min-width: 0;
align-items: baseline;
flex-wrap: wrap;
@@ -785,14 +736,18 @@ function goMasterWarning() {
.summary-statuses {
display: flex;
flex: 1 0 auto;
align-items: center;
gap: 12rpx;
justify-content: flex-end;
gap: 8rpx;
}
.summary-status {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6rpx;
justify-content: center;
gap: 4rpx;
padding: 0;
font-size: 22rpx;
white-space: nowrap;
@@ -801,6 +756,8 @@ function goMasterWarning() {
.summary-status__num {
display: inline-flex;
white-space: nowrap;
flex-shrink: 0;
min-width: 28rpx;
height: 28rpx;
padding: 0 8rpx;
@@ -916,25 +873,6 @@ function goMasterWarning() {
margin-top: 20rpx;
}
.master-card__quick {
margin-top: 20rpx;
padding-top: 18rpx;
border-top: 1px dashed #e2e8f0;
}
.master-card__quick-label {
display: block;
margin-bottom: 12rpx;
color: #64748b;
font-size: 20rpx;
}
.master-card__quick-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10rpx;
}
:deep(.master-action-btn) {
width: 100% !important;
height: 80rpx !important;
@@ -955,68 +893,6 @@ function goMasterWarning() {
color: #fff !important;
}
:deep(.master-quick-btn) {
width: 100% !important;
min-width: 0 !important;
height: 80rpx !important;
min-height: 80rpx !important;
padding: 0 10rpx !important;
border: 1px solid #dbe3ef !important;
border-color: #dbe3ef !important;
border-radius: 12rpx !important;
background: #fff !important;
box-shadow: none !important;
color: #1f2937 !important;
font-size: 24rpx !important;
font-weight: 700 !important;
}
:deep(.master-quick-btn.is-disabled),
:deep(.master-quick-btn.wd-button--disabled),
:deep(.master-quick-btn[disabled]) {
border-color: #e2e8f0 !important;
background: #f8fafc !important;
box-shadow: none !important;
opacity: 0.45 !important;
}
.master-quick-btn__inner {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 8rpx;
white-space: nowrap;
}
.master-quick-btn__icon {
flex-shrink: 0;
color: #2563eb;
font-size: 28rpx;
}
.master-quick-btn__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.master-quick-btn__icon--blue {
color: #2563eb;
}
.master-quick-btn__icon--green {
color: #2563eb;
}
.master-quick-btn__icon--orange {
color: #2563eb;
}
.master-quick-btn__icon--gray {
color: #2563eb;
}
.bind-card {
display: flex;
align-items: center;
@@ -1382,16 +1258,13 @@ function goMasterWarning() {
}
.summary-statuses {
gap: 6rpx;
gap: 2rpx;
}
.summary-status {
padding-right: 8rpx;
padding-left: 8rpx;
font-size: 19rpx;
}
.master-card__quick-grid,
.bind-card__actions {
gap: 8rpx;
}
+109 -21
View File
@@ -11,6 +11,8 @@ import {
stopDataService,
} from '@/api/card'
import RouterPath from '@/constant/RouterPath'
import { hasAnyFeature } from '@/utils/permission'
import { V101_RELEASE } from '@/constant/release'
import { useTokenStore, useUserStore } from '@/store'
import { copyText } from '@/utils/clipboard'
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
@@ -20,6 +22,8 @@ import {
canAllocateCardAction,
canBusinessCardAction,
canChangeCardAction,
canDataStartCardAction,
canDataStopCardAction,
canRenewCardAction,
canRestartCardAction,
canStartCardAction,
@@ -37,6 +41,7 @@ import {
getNetModelText,
getOnlineStatusText,
getRatText,
getServiceStatusText,
} from '../../data'
definePage({
@@ -75,6 +80,21 @@ const canAllocateCard = computed(
const canRestartCard = computed(
() => !isPublicLimited.value && canRestartCardAction(userStore.userInfo, card.value),
)
const canStartDataService = computed(
() =>
!isPublicLimited.value
&& Number(card.value?.dataServiceStatus) === 0
&& canDataStartCardAction(userStore.userInfo, card.value),
)
const canStopDataService = computed(
() =>
!isPublicLimited.value
&& Number(card.value?.dataServiceStatus) === 1
&& canDataStopCardAction(userStore.userInfo, card.value),
)
const showQuickActions = computed(
() => canRestartCard.value || canStartDataService.value || canStopDataService.value,
)
const canStopCard = computed(
() => !isPublicLimited.value && canStopCardAction(userStore.userInfo, card.value),
)
@@ -95,6 +115,8 @@ const flowRows = computed(() => {
const item = card.value
if (!item)
return []
if (isPublicLimited.value)
return [['已用流量', formatFlow(item.usedFlowMb)]]
const rows = [
['总流量', formatFlow(item.packageTotalFlowMb)],
['已用流量', formatFlow(item.usedFlowMb)],
@@ -151,6 +173,19 @@ const sessionRows = computed(() => {
]
})
const serviceRows = computed(() => {
const item = card.value
if (!item)
return []
return [
['数据服务', formatServiceStatus(item.dataServiceStatus)],
['短信服务', formatServiceStatus(item.smsServiceStatus)],
['语音服务', formatServiceStatus(item.voiceServiceStatus)],
['漫游服务', formatServiceStatus(item.roamServiceStatus)],
['状态同步时间', formatDateTime(item.statusSyncTime)],
]
})
const detailSections = computed(() =>
[
{ title: '基础信息', rows: baseRows.value },
@@ -163,6 +198,8 @@ const extraInfoSections = computed(() => {
return []
return [
{ title: '会话信息', rows: sessionRows.value },
// v1.0.1 暂不展示服务状态,保留 serviceRows 便于后续恢复。
// { title: '服务状态', rows: serviceRows.value },
].filter(section => section.rows.some(row => isMeaningfulDetailValue(row[1])))
})
@@ -190,7 +227,12 @@ async function loadCard() {
visibleExtraSectionCount.value = 1
}
catch (error) {
errorMessage.value = getErrorMessage(error, '卡片详情加载失败')
const message = getErrorMessage(error, '卡片详情加载失败')
if (isCardNotFoundMessage(message)) {
goDiagnosisForMissingCard(value)
return
}
errorMessage.value = message
card.value = undefined
}
finally {
@@ -331,6 +373,12 @@ function isMeaningfulDetailValue(value: unknown) {
return !!text && text !== '-' && text !== '未知' && text !== '暂无会话信息'
}
function formatServiceStatus(status?: number) {
if (status === undefined || status === null)
return '-'
return getServiceStatusText(status)
}
async function handleRestart() {
const value = displayCardValue(card.value)
if (!hasLogin.value) {
@@ -401,6 +449,11 @@ async function submitLifecycle(cardNo: string, operation: 'stop' | 'start', acti
}
function handleDataService(operation: 'dataStart' | 'dataStop') {
const allowed = operation === 'dataStart' ? canStartDataService.value : canStopDataService.value
if (!allowed) {
uni.showToast({ title: '当前账号没有该数据服务操作权限', icon: 'none' })
return
}
const value = displayCardValue(card.value)
if (value === '-')
return
@@ -545,6 +598,21 @@ function getErrorMessage(error: unknown, fallback: string) {
return message || fallback
}
function isCardNotFoundMessage(message: string) {
return /卡信息不存在|卡片不存在|该卡暂未纳入服务|未查询到该卡片|卡号不存在/.test(message)
}
function goDiagnosisForMissingCard(value: string) {
const queryCode = value.trim()
if (!queryCode) {
errorMessage.value = '未查询到该卡片,请核对卡号后重试'
return
}
uni.redirectTo({
url: `${RouterPath.BusinessViewsDiagnosis}?queryCode=${encodeURIComponent(queryCode)}&source=card-detail-fallback`,
})
}
onLoad((query) => {
const queryValue = query?.cardValue || query?.cardNo || query?.iccid
cardValue.value = normalizeQueryValue(queryValue)
@@ -562,6 +630,13 @@ onShow(() => {
function normalizeQueryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : String(value || '')
}
function handleCreateWorkOrder() {
if (!card.value?.id || !hasAnyFeature(userStore.userInfo, 'workorder:create')) {
uni.showToast({ title: '当前卡片无法提交工单', icon: 'none' })
return
}
uni.navigateTo({ url: `${RouterPath.BusinessViewsWorkOrderCreate}?woType=0&relatedId=${card.value?.id}&context=${encodeURIComponent(`卡片:${card.value?.iccid || ''}`)}` })
}
</script>
<template>
@@ -626,7 +701,7 @@ function normalizeQueryValue(value: string | string[] | undefined) {
</wd-card>
<wd-card
v-if="!isPublicLimited"
v-if="showQuickActions"
custom-class="!mx-0 !mb-0 overflow-hidden detail-card detail-actions-card"
custom-content-class="!p-0"
>
@@ -635,20 +710,13 @@ function normalizeQueryValue(value: string | string[] | undefined) {
<view class="section-bar" />
<text class="section-title">快捷操作</text>
</view>
<text class="section-hint">常用功能</text>
</view>
<view class="action-grid">
<wd-button plain block custom-class="detail-action-btn detail-action-btn--renew" @click="handleRenew">
<view class="action-button-content">
<view class="i-carbon-renew action-button-icon action-button-icon--green" />
<text>续费</text>
</view>
</wd-button>
<wd-button
v-if="canRestartCard"
plain
block
custom-class="detail-action-btn detail-action-btn--restart"
:disabled="!canRestartCard"
:loading="restarting"
@click="handleRestart"
>
@@ -658,26 +726,41 @@ function normalizeQueryValue(value: string | string[] | undefined) {
</view>
</wd-button>
<wd-button
v-if="canStartDataService"
plain
block
custom-class="detail-action-btn detail-action-btn--warning"
:disabled="!canWarningCard"
@click="handleWarning"
custom-class="detail-action-btn"
:loading="lifecycleSubmitting"
@click="handleDataService('dataStart')"
>
<view class="action-button-content">
<view class="i-carbon-warning-alt action-button-icon action-button-icon--orange" />
<text>预警</text>
<view class="i-carbon-play-filled-alt action-button-icon action-button-icon--blue" />
<text>开通数据</text>
</view>
</wd-button>
<wd-button plain block custom-class="detail-action-btn detail-action-btn--more" @click="openMoreActions">
<wd-button
v-if="canStopDataService"
plain
block
custom-class="detail-action-btn"
:loading="lifecycleSubmitting"
@click="handleDataService('dataStop')"
>
<view class="action-button-content">
<view class="i-carbon-overflow-menu-horizontal action-button-icon action-button-icon--gray" />
<text>更多</text>
<view class="i-carbon-pause-filled action-button-icon action-button-icon--blue" />
<text>停用数据</text>
</view>
</wd-button>
</view>
</wd-card>
<wd-button
v-if="card?.id && hasAnyFeature(userStore.userInfo, 'workorder:create')"
plain
@click="handleCreateWorkOrder"
>
反馈卡片问题
</wd-button>
<wd-card
v-for="section in detailSections"
:key="section.title"
@@ -748,6 +831,10 @@ function normalizeQueryValue(value: string | string[] | undefined) {
</view>
</wd-card>
<wd-button v-if="moreSheetActions.length" plain block type="primary" size="small" @click="openMoreActions">
{{ V101_RELEASE.showCardMoreActions ? '更多操作' : '划拨这张卡' }}
</wd-button>
<wd-button
v-if="hasMoreInfo"
plain
@@ -760,7 +847,7 @@ function normalizeQueryValue(value: string | string[] | undefined) {
</wd-button>
<wd-card
v-if="!isPublicLimited && !card.isBound && isTerminal"
v-if="V101_RELEASE.showBindCard && !isPublicLimited && !card.isBound && isTerminal"
custom-class="!mx-0 !mb-0 overflow-hidden detail-card detail-bind-card"
custom-content-class="!p-0"
>
@@ -786,6 +873,7 @@ function normalizeQueryValue(value: string | string[] | undefined) {
</scroll-view>
<wd-action-sheet
v-if="moreSheetActions.length"
v-model="moreSheetVisible"
title="更多操作"
cancel-text="取消"
@@ -978,8 +1066,8 @@ function normalizeQueryValue(value: string | string[] | undefined) {
.action-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10rpx;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12rpx;
padding: 0 24rpx 20rpx;
}
+14 -197
View File
@@ -1,17 +1,15 @@
<script lang="ts" setup>
import type { WarningConfig, WarningConfigSaveParams } from '../api/warning'
import { bindWechat, getWxCode } from '@/api/login'
import {
getWarningConfigList,
saveWarningConfig,
updateWarningConfigNotify,
} from '../api/warning'
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
import {
displayCardValue,
formatFlowPair,
} from '../../data'
import { guideWechatSubscribeSettings, requestWechatSubscribeMessage, shouldGuideWechatSubscribeSettings, shouldShowWechatSubscribeNotice } from '@/utils'
definePage({
style: {
@@ -40,16 +38,8 @@ type WarningPayload = Omit<WarningConfigSaveParams, 'cardValue'>
const userStore = useUserStore()
const loading = ref(false)
const submitting = ref(false)
const notificationSubmitting = ref(false)
const binding = ref(false)
const cardValue = ref('')
const card = ref<any>()
const templateId = ref('')
const templateReady = ref(false)
const wechatBound = ref(false)
const wechatBindText = ref('未绑定')
const appNotifyEnabled = ref(false)
const savedConfig = ref<WarningConfig | null>(null)
const errorMessage = ref('')
const forms = reactive<WarningForm[]>([
@@ -75,22 +65,6 @@ const cardSubTitle = computed(() =>
card.value ? (card.value.msisdn || '卡片预警配置') : '加载卡片信息',
)
const flowText = computed(() => (card.value ? formatFlowPair(card.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 filledConfigCount = computed(() => forms.filter(hasThresholdValue).length)
const canSubmit = computed(() => filledConfigCount.value > 0 && !submitting.value && !loading.value)
@@ -104,10 +78,6 @@ async function loadData() {
try {
const res = await getWarningConfigList({ cardValue: cardValue.value })
card.value = res.card
templateId.value = res.templateId || ''
templateReady.value = Boolean(res.templateReady)
wechatBound.value = Boolean(res.wechatBound)
wechatBindText.value = res.wechatBindText || (res.wechatBound ? '已绑定' : '未绑定')
syncForms(res.items || [])
}
catch (error) {
@@ -123,8 +93,6 @@ function syncForms(items: WarningConfig[]) {
items.forEach((item) => {
source.set(item.warnUnit, item)
})
savedConfig.value = null
appNotifyEnabled.value = false
forms.forEach((form) => {
const item = source.get(form.warnUnit)
if (!item) {
@@ -134,8 +102,6 @@ function syncForms(items: WarningConfig[]) {
form.thirdWarn = ''
return
}
savedConfig.value = item
appNotifyEnabled.value = item.appNotifyEnabled === 1
form.enabled = item.isEnabled !== 0
form.firstWarn = formatThreshold(item.firstWarn)
form.secondWarn = formatThreshold(item.secondWarn)
@@ -184,7 +150,6 @@ function buildConfigPayload(validate: boolean): WarningPayload[] {
secondWarn,
thirdWarn,
isEnabled: form.enabled ? 1 : 0,
appNotifyEnabled: appNotifyEnabled.value ? 1 : 0,
})
}
@@ -205,97 +170,6 @@ function validateThreshold(form: WarningForm, firstWarn: number, secondWarn: num
return ''
}
async function handleNotifyChange() {
const enabled = appNotifyEnabled.value
const previousValue = savedConfig.value?.appNotifyEnabled === 1
if (!savedConfig.value?.id) {
showFeedback(
enabled ? '保存预警配置后,小程序提醒才会生效' : '关闭设置将在保存配置时生效',
'提醒设置',
)
return
}
if (notificationSubmitting.value)
return
notificationSubmitting.value = true
try {
const subscribeResult = enabled
? await requestWechatSubscribeMessage(templateId.value, templateReady.value)
: undefined
const res = await updateWarningConfigNotify({
cardValue: cardValue.value,
warnUnit: 1,
appNotifyEnabled: enabled ? 1 : 0,
})
if (res.config)
savedConfig.value = res.config
appNotifyEnabled.value = res.appNotifyEnabled === 1
wechatBound.value = Boolean(res.wechatBound)
wechatBindText.value = res.wechatBindText || (res.wechatBound ? '已绑定' : '未绑定')
if (!enabled) {
uni.showToast({ title: '已关闭小程序提醒', icon: 'success' })
return
}
if (subscribeResult && shouldShowWechatSubscribeNotice(subscribeResult)) {
if (shouldGuideWechatSubscribeSettings(subscribeResult)) {
guideWechatSubscribeSettings(subscribeResult)
return
}
uni.showModal({
title: '提醒开关已更新',
content: subscribeResult.message,
showCancel: false,
})
}
else {
uni.showToast({
title: subscribeResult?.status === 'accepted' ? '已开启并订阅' : '已开启小程序提醒',
icon: 'success',
})
}
}
catch (error) {
appNotifyEnabled.value = previousValue
showFeedback(getErrorMessage(error, '更新提醒开关失败'), '提醒设置失败')
}
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) {
showFeedback(getErrorMessage(error, '微信绑定失败'), '微信绑定失败')
}
finally {
binding.value = false
}
// #endif
}
async function handleSubmit() {
const configs = buildConfigPayload(true)
if (!configs.length || submitting.value)
@@ -303,12 +177,7 @@ async function handleSubmit() {
submitting.value = true
try {
const subscribeResult = appNotifyEnabled.value
? await requestWechatSubscribeMessage(templateId.value, templateReady.value)
: undefined
const savedItems: WarningConfig[] = []
let latestBound = wechatBound.value
let latestBindText = wechatBindText.value
for (const config of configs) {
const res = await saveWarningConfig({
cardValue: cardValue.value,
@@ -316,29 +185,9 @@ async function handleSubmit() {
})
if (res.config)
savedItems.push(res.config)
latestBound = Boolean(res.wechatBound)
latestBindText = res.wechatBindText || (res.wechatBound ? '已绑定' : '未绑定')
}
syncForms(savedItems)
wechatBound.value = latestBound
wechatBindText.value = latestBindText
if (subscribeResult && shouldShowWechatSubscribeNotice(subscribeResult)) {
if (shouldGuideWechatSubscribeSettings(subscribeResult)) {
guideWechatSubscribeSettings(subscribeResult)
return
}
uni.showModal({
title: '配置已保存',
content: subscribeResult.message,
showCancel: false,
})
}
else {
const message = !appNotifyEnabled.value
? '配置已保存,未开启小程序提醒。你仍可在消息中心查看预警记录。'
: subscribeResult?.status === 'accepted' ? '配置已保存并订阅微信提醒。' : '配置已保存。'
showFeedback(message, '配置已保存', 'success')
}
showFeedback('预警配置已保存,触发后的通知可在消息中心查看。', '配置已保存', 'success')
}
catch (error) {
showFeedback(getErrorMessage(error, '保存失败'), '保存失败')
@@ -348,6 +197,10 @@ async function handleSubmit() {
}
}
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 }
@@ -422,8 +275,8 @@ onShow(() => {
{{ cardSubTitle }}
</text>
</view>
<wd-tag :type="templateReady ? 'primary' : 'warning'" round>
{{ subscribeText }}
<wd-tag type="primary" round>
站内消息提醒
</wd-tag>
</view>
@@ -437,51 +290,15 @@ onShow(() => {
<view class="rounded-16rpx bg-#f8fafc p-18rpx">
<text class="block text-22rpx text-muted">提醒方式</text>
<text class="mt-8rpx block truncate text-26rpx text-ink font-800">
{{ 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"
@change="handleNotifyChange"
/>
</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-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>
@@ -577,10 +394,10 @@ onShow(() => {
<view class="mx-auto max-w-920rpx pb-18rpx">
<view class="mb-12rpx flex items-center justify-between text-24rpx text-muted">
<text>已填 {{ filledConfigCount }} 项配置</text>
<text>{{ notificationSummary }}</text>
<text>预警通知在消息中心查看</text>
</view>
<wd-button type="primary" block :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
{{ submitButtonText }}
保存预警配置
</wd-button>
</view>
</view>
@@ -1,34 +1,24 @@
<script setup lang="ts">
import type { AppCard } from '@/api/card'
import { bindCard, publicQueryCard, recognizeCardImage } from '@/api/card'
import { publicQueryCard, recognizeCardImage } from '@/api/card'
import RouterPath from '@/constant/RouterPath'
import {
formatFlowPair,
formatFlow,
getBossCardStatusText,
getCardStatusType,
getOnlineStatusText,
} from '@/pages/card/data'
import { useLastQueriedCardStore, useTokenStore, useUserStore } from '@/store'
import { useLastQueriedCardStore } from '@/store'
import { copyText } from '@/utils/clipboard'
import { cardRecognitionMessage, chooseCardImage } from '@/utils/cardRecognition'
import { isTerminalUserInfo } from '@/utils/userIdentity'
const emit = defineEmits<{
refreshStats: []
}>()
const searchValue = ref('')
const queryLoading = ref(false)
const recognizing = ref(false)
const binding = ref(false)
const queryErrorMessage = ref('')
const result = ref<AppCard>()
const tokenStore = useTokenStore()
const userStore = useUserStore()
const lastQueriedCardStore = useLastQueriedCardStore()
const hasLogin = computed(() => tokenStore.hasLogin)
const isTerminal = computed(() => isTerminalUserInfo(userStore.userInfo))
const loadingText = computed(() => recognizing.value ? '图片识别中...' : '卡片查询中...')
const queryRows = computed(() => {
@@ -36,7 +26,7 @@ const queryRows = computed(() => {
return []
return [
{ label: '卡号', value: publicCardTitle(result.value), copyable: true },
{ label: '剩余流量', value: formatFlowPair(result.value), copyable: false },
{ label: '使用流量', value: formatFlow(result.value.usedFlowMb), copyable: false },
{ label: '在线状态', value: getOnlineStatusText(result.value.onlineStatus), copyable: false },
]
})
@@ -57,7 +47,12 @@ async function handleQuery() {
lastQueriedCardStore.setCard(res.card)
}
catch (error) {
queryErrorMessage.value = getErrorMessage(error, '查询失败,请检查输入后重试')
const message = getErrorMessage(error, '查询失败,请检查输入后重试')
if (isCardNotFoundMessage(message)) {
goDiagnosisForMissingCard(cardValue)
return
}
queryErrorMessage.value = message
}
finally {
queryLoading.value = false
@@ -90,7 +85,8 @@ async function handlePhotoRecognize() {
const recognition = await recognizeCardImage(filePath)
if (recognition.code === 'CARD_RECOGNIZED' && recognition.cardValue) {
searchValue.value = recognition.cardValue
uni.showToast({ title: 'ICCID 识别成功', icon: 'success' })
recognizing.value = false
await handleQuery()
return
}
searchValue.value = recognition.cardValue || ''
@@ -116,6 +112,21 @@ function goDiagnosis() {
})
}
function isCardNotFoundMessage(message: string) {
return /卡信息不存在|卡片不存在|该卡暂未纳入服务|未查询到该卡片|卡号不存在/.test(message)
}
function goDiagnosisForMissingCard(value: string) {
const queryCode = value.trim()
if (!queryCode) {
queryErrorMessage.value = '未查询到该卡片,请核对卡号后重试'
return
}
uni.navigateTo({
url: `${RouterPath.BusinessViewsDiagnosis}?queryCode=${encodeURIComponent(queryCode)}&source=public-query-fallback`,
})
}
function publicCardTitle(card: AppCard) {
return card.iccid || card.msisdn || card.imsi || '-'
}
@@ -133,48 +144,6 @@ function goDetail() {
})
}
function goLogin() {
uni.navigateTo({ url: RouterPath.Login })
}
async function handleBind() {
const cardValue = publicCardCopyValue(result.value) || searchValue.value.trim()
if (!hasLogin.value) {
goLogin()
return
}
if (!isTerminal.value) {
uni.showToast({ title: '企业用户无需绑定卡,请到我的卡片查看', icon: 'none' })
return
}
if (!cardValue)
return
if (result.value?.isBound) {
uni.showToast({ title: '已绑定', icon: 'none' })
return
}
binding.value = true
try {
const res = await bindCard({ cardValue })
result.value = res.card
lastQueriedCardStore.setCard(res.card)
emit('refreshStats')
uni.showToast({ title: '绑定成功', icon: 'success' })
}
catch (error) {
const message = getErrorMessage(error, '绑定失败,请稍后重试')
uni.showToast({ title: isAlreadyBoundMessage(message) ? '已绑定' : message, icon: 'none' })
}
finally {
binding.value = false
}
}
function isAlreadyBoundMessage(message: string) {
return message.includes('已绑定')
}
function getErrorMessage(error: unknown, fallback: string) {
let message = ''
if (error && typeof error === 'object') {
@@ -238,35 +207,28 @@ function copyQueryRow(row: { label: string, value: string, copyable?: boolean })
</view>
<view class="tool-row">
<wd-button class="tool-button tool-button--scan" size="small" plain @click="handleScan">
<view class="tool-button__content">
<view class="i-carbon-grid tool-button__icon" />
<text>扫码</text>
</view>
<wd-button class="tool-button tool-button--scan" size="small" css-icon="i-carbon-grid" plain @click="handleScan">
扫码
</wd-button>
<wd-button
class="tool-button tool-button--photo"
size="small"
css-icon="i-carbon-camera"
plain
:loading="recognizing"
@click="handlePhotoRecognize"
>
<view class="tool-button__content">
<view class="i-carbon-camera tool-button__icon" />
<text>拍照</text>
</view>
拍照
</wd-button>
<wd-button
class="tool-button tool-button--diagnosis"
size="small"
css-icon="i-carbon-stethoscope"
type="primary"
plain
@click="goDiagnosis"
>
<view class="tool-button__content">
<view class="i-my-icons-sparkle tool-button__icon" />
<text>卡片排查</text>
</view>
卡片排查
</wd-button>
</view>
@@ -319,25 +281,13 @@ function copyQueryRow(row: { label: string, value: string, copyable?: boolean })
</view>
</view>
<view class="grid grid-cols-2 mt-24rpx gap-20rpx">
<view class="grid grid-cols-1 mt-24rpx gap-20rpx">
<wd-button size="small" type="primary" custom-class="result-action result-action--detail" @click="goDetail">
<view class="result-action__content">
<view class="i-carbon-document-view result-action__icon" />
<text>查看详情</text>
</view>
</wd-button>
<wd-button
size="small"
variant="plain"
custom-class="result-action result-action--bind"
:loading="binding"
@click="handleBind"
>
<view class="result-action__content">
<view class="i-carbon-link result-action__icon" />
<text>{{ hasLogin ? '绑定到我' : '登录后绑定' }}</text>
</view>
</wd-button>
</view>
</view>
</view>
@@ -420,21 +370,18 @@ function copyQueryRow(row: { label: string, value: string, copyable?: boolean })
box-shadow: none !important;
}
.tool-button__content,
.result-action__content {
display: inline-flex;
display: flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 10rpx;
height: 100%;
min-width: 0;
line-height: 1;
white-space: nowrap;
}
.tool-button__icon {
flex-shrink: 0;
font-size: 26rpx;
}
.tool-button--scan {
border-color: #d8e2ef !important;
background: #f8fbff !important;
@@ -11,7 +11,6 @@ import {
} from '@/utils/cardRecognition'
import type { AppPermissionAction } from '@/utils/permission'
import { hasUserPermission } from '@/utils/permission'
import { isTerminalUserInfo } from '@/utils/userIdentity'
interface ShortcutItem {
label: string
@@ -41,29 +40,6 @@ const shortcuts: ShortcutItem[] = [
action: 'photoRecognize',
sub: '识别卡片 ICCID',
},
{
label: '套餐续费',
icon: 'i-carbon-renew',
tone: 'mint',
path: RouterPath.BusinessViewsRenew,
sub: '到期前续展套餐',
permissionAction: 'renew',
},
{
label: '我的订单',
icon: 'i-carbon-receipt',
tone: 'primary-light',
path: RouterPath.BusinessViewsOrder,
sub: '查看续费和业务记录',
},
{
label: '预警配置',
icon: 'i-carbon-warning',
tone: 'warning',
path: RouterPath.BusinessViewsWarning,
sub: '设置流量预警通知',
permissionAction: 'warning',
},
{
label: '卡片排查',
icon: 'i-carbon-stethoscope',
@@ -71,21 +47,6 @@ const shortcuts: ShortcutItem[] = [
path: RouterPath.BusinessViewsDiagnosis,
sub: '查看状态、会话与流量异常',
},
{
label: '我的卡片',
icon: 'i-carbon-sim-card',
tone: 'mint',
tabPath: RouterPath.Card,
sub: '查看当前设备列表',
},
{
label: '工单服务',
icon: 'i-carbon-chat',
tone: 'warning',
path: RouterPath.BusinessViewsWorkOrder,
sub: '反馈问题和提交工单',
permissionAction: ['workOrder', 'workOrderCreate'],
},
]
const toneClass: Record<ShortcutItem['tone'], string> = {
@@ -102,18 +63,10 @@ const userStore = useUserStore()
const photoRecognizing = ref(false)
let refreshUserPromise: Promise<void | IUserInfoRes> | undefined
const isTerminalUser = computed(() => isTerminalUserInfo(userStore.userInfo))
const visibleShortcuts = computed(() =>
shortcuts.filter((item) => {
if (props.hideCardEntry && item.label === '我的卡片')
return false
if (item.agentOnly && isTerminalUser.value)
return false
if (item.terminalOnly && !isTerminalUser.value)
return false
if (item.terminalHidden && isTerminalUser.value)
return false
if (item.permissionAction && !hasShortcutPermission(item.permissionAction))
return false
return true
+4 -36
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { AppCard } from '@/api/card'
import { getCardList } from '@/api/card'
import RouterPath from '@/constant/RouterPath'
import { useTokenStore } from '@/store'
import { useUserStore } from '@/store/user'
import QuickBusiness from './components/QuickBusiness.vue'
@@ -12,7 +11,7 @@ definePage({
type: 'home',
style: {
navigationStyle: 'default',
navigationBarTitleText: '山东平台物联卡',
navigationBarTitleText: '示例企业物联卡',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
},
@@ -29,12 +28,6 @@ let cardLoadSeq = 0
const hasLogin = computed(() => tokenStore.hasLogin)
const showCardsLoading = computed(() => cardsLoading.value && cards.value.length === 0)
const upgradeItems = [
{ label: '绑定我的卡', icon: 'i-carbon-sim-card' },
{ label: '查看排查记录', icon: 'i-carbon-stethoscope' },
{ label: '设置流量预警', icon: 'i-carbon-warning' },
{ label: '专属客服工单', icon: 'i-carbon-chat' },
]
async function loadCards() {
if (!hasLogin.value) {
@@ -84,12 +77,6 @@ function loadHomeData() {
void loadCards()
}
function goLogin() {
uni.navigateTo({
url: `${RouterPath.Login}?redirect=${encodeURIComponent(RouterPath.Home)}`,
})
}
onShow(() => {
loadHomeData()
})
@@ -110,7 +97,7 @@ onShow(() => {
<view class="min-w-0 flex-1">
<text class="diagnosis-tip__title">遇到问题?先查卡状态</text>
<text class="diagnosis-tip__desc">
根据卡片状态、套餐、流量和会话信息,整理常见异常与处理方向
根据卡片状态、在线状态、流量和设备信息,整理常见异常与处理方向
</text>
<text class="mt-8rpx block text-21rpx text-#1d4ed8 leading-31rpx">
排查结果仅供参考,以运营商和业务系统结果为准
@@ -118,6 +105,8 @@ onShow(() => {
</view>
</view>
<QuickBusiness />
<OverviewCard
v-if="hasLogin"
:cards="cards"
@@ -126,27 +115,6 @@ onShow(() => {
:error-message="cardsErrorMessage"
@refresh-stats="loadCards"
/>
<QuickBusiness />
<view v-if="!hasLogin" class="upgrade-card">
<view class="upgrade-head">
<view class="min-w-0">
<text class="upgrade-title">登录后能力升级</text>
<text class="upgrade-sub">绑定卡片后可查看完整状态与办理记录</text>
</view>
</view>
<view class="upgrade-grid">
<view v-for="item in upgradeItems" :key="item.label" class="upgrade-item">
<view class="upgrade-icon" :class="item.icon" />
<text>{{ item.label }}</text>
</view>
</view>
<view class="upgrade-link" @click="goLogin">
<text>去登录,解锁全部能力</text>
<view class="i-carbon-chevron-right text-24rpx" />
</view>
</view>
</view>
</view>
</scroll-view>
@@ -30,8 +30,12 @@ function goBack() {
<text class="forgot-panel__desc">
当前短信验证码服务还没有接入,为避免提交无效请求,找回密码功能暂时关闭。
</text>
<wd-button type="primary" block disabled>等待短信服务接入</wd-button>
<wd-button class="forgot-back-button" plain block @click="goBack">返回登录</wd-button>
<wd-button type="primary" disabled block>
等待短信服务接入
</wd-button>
<wd-button class="forgot-back-button" plain block @click="goBack">
返回登录
</wd-button>
</view>
</view>
</template>
+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from 'node:fs'
import { createContext, runInContext } from 'node:vm'
import { reactive, ref } from 'vue'
import { ModuleKind, transpileModule } from 'typescript'
import { describe, expect, it, vi } from 'vitest'
import { V101_RELEASE } from '@/constant/release'
import { navigateAfterLogin, phoneLoginDestination } from '@/utils/loginNavigation'
const home = '/pages/home/index'
function createHarness(redirect = home) {
const response = { token: 'test-token', expiresIn: 3600, userInfo: { userId: 1, wechatBound: true } }
const login = vi.fn().mockResolvedValue(response)
const phoneSmsLogin = vi.fn().mockResolvedValue(response)
const setTokenInfo = vi.fn()
const setUserInfo = vi.fn()
const navigator = { switchTab: vi.fn(), reLaunch: vi.fn(), showToast: vi.fn() }
const navigate = vi.fn((...args: Parameters<typeof navigateAfterLogin>) => {
expect(args[3]).not.toBe(navigator)
return navigateAfterLogin(...args)
})
const context = createContext({
exports: {},
require: (name: string) => {
if (name === 'vue')
return { reactive, ref, watch: vi.fn(), onUnmounted: vi.fn() }
if (name === '@/api/login')
return { phoneSmsLogin }
if (name === '@/components/SideCaptcha.vue')
return {}
if (name === '@/store/token')
return { useTokenStore: () => ({ login, setTokenInfo }) }
if (name === '@/store/user')
return { useUserStore: () => ({ userInfo: response.userInfo, setUserInfo, fetchAccessCodes: vi.fn().mockResolvedValue([]) }) }
if (name === '@/tabbar/store')
return { isPageTabbar: (path: string) => path === home }
if (name === '@/tabbar/config')
return { isNativeTabbar: false }
if (name === '@/utils')
return { HOME_PAGE: home }
if (name === '@/utils/loginNavigation')
return { navigateAfterLogin: navigate, phoneLoginDestination }
if (name === '@/constant/release')
return { V101_RELEASE }
throw new Error(`Unexpected import: ${name}`)
},
definePage: vi.fn(),
onLoad: (callback: (query: { redirect: string }) => void) => callback({ redirect }),
uni: navigator,
})
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('Login page script not found')
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
runInContext('agreementChecked.value = true; promptBindWechatIfNeeded = async () => undefined', context)
return { context, login, phoneSmsLogin, setTokenInfo, setUserInfo, navigator, navigate }
}
describe('登录页面成功后的导航', () => {
it('手机号密码登录显式绑定导航 API 并回到首页', async () => {
const harness = createHarness('/pages/user/index')
await runInContext('loginForm.account = "13800008000"; loginForm.password = "password"; submitLogin()', harness.context)
expect(harness.login).toHaveBeenCalledOnce()
expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: home })
expect(harness.navigator.switchTab).not.toHaveBeenCalled()
})
it('用户名密码登录保留来源页面', async () => {
const harness = createHarness('/pages/user/index')
await runInContext('loginForm.account = "agent01"; loginForm.password = "password"; submitLogin()', harness.context)
expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: '/pages/user/index' })
})
it('短信登录写入认证信息后回到首页', async () => {
const harness = createHarness('/pages/user/index')
await runInContext('loginSmsForm.phone = "13800008000"; loginSmsForm.smsCode = "123456"; submitPhoneSmsLogin()', harness.context)
expect(harness.phoneSmsLogin).toHaveBeenCalledOnce()
expect(harness.setTokenInfo).toHaveBeenCalledOnce()
expect(harness.setUserInfo).toHaveBeenCalledOnce()
expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: home })
})
it('验证码多账号选择后仅登录选中的身份并回到首页', async () => {
const harness = createHarness('/pages/user/index')
const candidates = [{ userId: 11, nickName: '账号甲' }, { userId: 22, nickName: '账号乙' }]
const selectedResponse = { token: 'selected-token', expiresIn: 3600, userInfo: { userId: 22, nickName: '账号乙', wechatBound: true } }
harness.phoneSmsLogin.mockResolvedValueOnce({ needSelect: true, candidates }).mockResolvedValueOnce(selectedResponse)
await runInContext('loginMethod.value = "sms"; loginSmsForm.phone = "13800008000"; loginSmsForm.smsCode = "123456"; submitPhoneSmsLogin()', harness.context)
expect(runInContext('accountSelectVisible.value', harness.context)).toBe(true)
expect(harness.setTokenInfo).not.toHaveBeenCalled()
expect(harness.navigate).not.toHaveBeenCalled()
runInContext('selectLoginAccount(accountCandidates.value[1])', harness.context)
await vi.waitFor(() => expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: home }))
expect(harness.phoneSmsLogin).toHaveBeenCalledTimes(2)
expect(harness.phoneSmsLogin).toHaveBeenNthCalledWith(2, {
tenantId: '000000', phone: '13800008000', smsCode: '123456', selectedUserId: 22,
})
expect(harness.login).not.toHaveBeenCalled()
expect(harness.setTokenInfo).toHaveBeenCalledExactlyOnceWith(selectedResponse)
expect(harness.setUserInfo).toHaveBeenCalledExactlyOnceWith(selectedResponse.userInfo)
expect(harness.navigator.switchTab).not.toHaveBeenCalled()
expect(runInContext('accountSelectVisible.value', harness.context)).toBe(false)
expect(harness.navigator.showToast).not.toHaveBeenCalledWith(expect.objectContaining({ icon: 'none' }))
})
it('多账号选择请求失败时不设置登录态也不跳转', async () => {
const harness = createHarness()
harness.phoneSmsLogin.mockResolvedValueOnce({ needSelect: true, candidates: [{ userId: 22 }] }).mockRejectedValueOnce(new Error('选择的账号不属于当前手机号'))
await runInContext('loginMethod.value = "sms"; loginSmsForm.phone = "13800008000"; loginSmsForm.smsCode = "123456"; submitPhoneSmsLogin()', harness.context)
runInContext('selectLoginAccount(accountCandidates.value[0])', harness.context)
await vi.waitFor(() => expect(harness.navigator.showToast).toHaveBeenCalledWith({ title: '选择的账号不属于当前手机号', icon: 'none' }))
expect(harness.setTokenInfo).not.toHaveBeenCalled()
expect(harness.navigate).not.toHaveBeenCalled()
})
it('需要选择身份时不提前跳转', async () => {
const harness = createHarness()
harness.login.mockResolvedValue({ needSelect: true, candidates: [{ userId: 1 }] })
await runInContext('loginForm.account = "13800008000"; loginForm.password = "password"; submitLogin()', harness.context)
expect(harness.navigate).not.toHaveBeenCalled()
expect(runInContext('accountSelectVisible.value', harness.context)).toBe(true)
})
})
+256 -158
View File
@@ -10,9 +10,10 @@ import type {
import { onUnmounted, reactive, ref, watch } from 'vue'
import {
bindWechat,
checkSideCaptcha,
getWxCode,
phoneSmsLogin,
register,
sendSms,
wechatBindPhone,
wechatLogin,
} from '@/api/login'
@@ -20,12 +21,15 @@ import SideCaptcha from '@/components/SideCaptcha.vue'
import { useTokenStore } from '@/store/token'
import { useUserStore } from '@/store/user'
import { isPageTabbar } from '@/tabbar/store'
import { isNativeTabbar } from '@/tabbar/config'
import { HOME_PAGE } from '@/utils'
import { navigateAfterLogin, phoneLoginDestination } from '@/utils/loginNavigation'
import { V101_RELEASE } from '@/constant/release'
definePage({
name: 'Login',
style: {
navigationBarTitleText: '山东平台物联卡',
navigationBarTitleText: '示例企业物联卡',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
disableScroll: true,
@@ -34,13 +38,15 @@ definePage({
})
type AuthMode = 'login' | 'register'
type CaptchaScene = 'register' | 'wechatBindPhone'
type LoginMethod = 'password' | 'sms'
type CaptchaScene = 'register' | 'login' | 'wechatBindPhone'
type AccountSelectScene = 'login' | 'wechatBindPhone'
const tokenStore = useTokenStore()
const userStore = useUserStore()
const authMode = ref<AuthMode>('login')
const loginMethod = ref<LoginMethod>('password')
const authPanelVisible = ref(false)
const loginLoading = ref(false)
const wechatLoginLoading = ref(false)
@@ -61,6 +67,9 @@ const wechatBindTips = ['流量预警', '业务通知', '卡片消息']
let wechatBindResolve: ((value: boolean) => void) | null = null
const wechatBindPhoneVisible = ref(false)
const wechatBindPhoneLoading = ref(false)
const smsLoading = ref(false)
const smsCountdown = reactive<Record<CaptchaScene, number>>({ register: 0, login: 0, wechatBindPhone: 0 })
const smsTimers: Partial<Record<CaptchaScene, ReturnType<typeof setInterval>>> = {}
const loginForm = reactive({
tenantId: '000000',
@@ -68,27 +77,31 @@ const loginForm = reactive({
password: '',
})
const loginSmsForm = reactive({
tenantId: '000000',
phone: '',
smsCode: '',
})
const registerForm = reactive({
tenantId: '000000',
phone: '',
nickName: '',
password: '',
confirmPassword: '',
smsCode: '',
})
const wechatBindPhoneForm = reactive({
tenantId: '000000',
bindTicket: '',
phone: '',
smsCode: '',
})
const wechatBindPhoneCaptcha = reactive({
captchaKey: '',
captchaPoint: '',
})
// 图形验证是否通过(注册前置门槛,暂不接入短信验证码)
// 短信发送前必须完成滑块验证,验证码由后端统一校验。
const captchaPassed = ref(false)
const loginSmsCaptchaPassed = ref(false)
const wechatBindPhoneCaptchaPassed = ref(false)
function isLoginSelectResponse(value: unknown): value is ILoginSelectRes {
@@ -114,6 +127,8 @@ function getWechatBindTicket(value: unknown) {
}
function switchMode(mode: AuthMode) {
if (mode === 'register' && !V101_RELEASE.showRegister)
return
authMode.value = mode
authPanelVisible.value = true
captchaPassed.value = false
@@ -152,12 +167,12 @@ function normalizeRedirect(value?: string | string[]) {
}
function goAfterLogin() {
const url = redirectUrl.value || HOME_PAGE
if (isPageTabbar(url)) {
uni.switchTab({ url })
return
}
uni.reLaunch({ url })
authPanelVisible.value = false
resetAccountSelection()
navigateAfterLogin(redirectUrl.value || HOME_PAGE, HOME_PAGE, isPageTabbar, {
switchTab: options => uni.switchTab(options),
reLaunch: options => uni.reLaunch(options),
}, isNativeTabbar)
}
function resetAccountSelection() {
@@ -168,14 +183,86 @@ function resetAccountSelection() {
function resetWechatBindPhoneCaptcha() {
wechatBindPhoneCaptchaPassed.value = false
wechatBindPhoneCaptcha.captchaKey = ''
wechatBindPhoneCaptcha.captchaPoint = ''
wechatBindPhoneForm.smsCode = ''
stopSmsCountdown('wechatBindPhone')
}
function resetLoginSmsCaptcha() {
loginSmsCaptchaPassed.value = false
loginSmsForm.smsCode = ''
stopSmsCountdown('login')
}
function resetRegisterSmsCaptcha() {
captchaPassed.value = false
registerForm.smsCode = ''
stopSmsCountdown('register')
}
function stopSmsCountdown(scene: CaptchaScene) {
if (smsTimers[scene]) {
clearInterval(smsTimers[scene])
delete smsTimers[scene]
}
smsCountdown[scene] = 0
}
function startSmsCountdown(scene: CaptchaScene) {
if (smsTimers[scene])
clearInterval(smsTimers[scene])
smsCountdown[scene] = 60
smsTimers[scene] = setInterval(() => {
smsCountdown[scene] -= 1
if (smsCountdown[scene] <= 0) {
clearInterval(smsTimers[scene])
delete smsTimers[scene]
}
}, 1000)
}
async function sendSmsAfterCaptcha(scene: CaptchaScene, captchaKey: string, captchaPoint: string) {
if (!ensureAgreementChecked()) {
captchaVisible.value = false
return
}
const phone = scene === 'register'
? registerForm.phone
: scene === 'login' ? loginSmsForm.phone : wechatBindPhoneForm.phone
const tenantId = scene === 'register'
? registerForm.tenantId
: scene === 'login' ? loginSmsForm.tenantId : wechatBindPhoneForm.tenantId
smsLoading.value = true
try {
const res = await sendSms({ tenantId, phone, scene, captchaKey, captchaPoint })
if (scene === 'register') {
captchaPassed.value = true
if (res.smsCode)
registerForm.smsCode = res.smsCode
}
else if (scene === 'login') {
loginSmsCaptchaPassed.value = true
if (res.smsCode)
loginSmsForm.smsCode = res.smsCode
}
else {
wechatBindPhoneCaptchaPassed.value = true
if (res.smsCode)
wechatBindPhoneForm.smsCode = res.smsCode
}
startSmsCountdown(scene)
captchaVisible.value = false
uni.showToast({ title: '验证码已发送', icon: 'success' })
}
finally {
smsLoading.value = false
}
}
function openWechatBindPhonePanel(bindTicket: string) {
wechatBindPhoneForm.tenantId = loginForm.tenantId || '000000'
wechatBindPhoneForm.bindTicket = bindTicket
wechatBindPhoneForm.phone = ''
wechatBindPhoneForm.smsCode = ''
resetWechatBindPhoneCaptcha()
authPanelVisible.value = false
wechatBindPhoneVisible.value = true
@@ -304,8 +391,12 @@ function validateWechatBindPhoneForm(selectedUserId?: number) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return false
}
if (!selectedUserId && !wechatBindPhoneCaptchaPassed.value) {
uni.showToast({ title: '请先完成图形验证', icon: 'none' })
if (!wechatBindPhoneCaptchaPassed.value) {
uni.showToast({ title: '请先获取短信验证码', icon: 'none' })
return false
}
if (!wechatBindPhoneForm.smsCode) {
uni.showToast({ title: '请输入短信验证码', icon: 'none' })
return false
}
return true
@@ -324,8 +415,7 @@ async function submitWechatBindPhone(selectedUserId?: number) {
tenantId: wechatBindPhoneForm.tenantId,
bindTicket: wechatBindPhoneForm.bindTicket,
phone: wechatBindPhoneForm.phone,
captchaKey: selectedUserId ? undefined : wechatBindPhoneCaptcha.captchaKey,
captchaPoint: selectedUserId ? undefined : wechatBindPhoneCaptcha.captchaPoint,
smsCode: wechatBindPhoneForm.smsCode,
selectedUserId,
})
if (isLoginSelectResponse(res)) {
@@ -409,6 +499,7 @@ async function submitLogin(selectedUserId?: number) {
// 登录令牌已经落地,后续绑定提示不能继续占用登录按钮的 loading 状态。
loginLoading.value = false
await promptBindWechatIfNeeded()
redirectUrl.value = phoneLoginDestination(account, redirectUrl.value, HOME_PAGE)
goAfterLogin()
}
finally {
@@ -416,21 +507,57 @@ async function submitLogin(selectedUserId?: number) {
}
}
async function submitPhoneSmsLogin(selectedUserId?: number) {
if (loginLoading.value)
return
if (!ensureAgreementChecked())
return
if (!/^1[3-9]\d{9}$/.test(loginSmsForm.phone)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!loginSmsForm.smsCode) {
uni.showToast({ title: '请输入短信验证码', icon: 'none' })
return
}
loginLoading.value = true
try {
const res = await phoneSmsLogin({ ...loginSmsForm, selectedUserId })
redirectUrl.value = HOME_PAGE
if (isLoginSelectResponse(res)) {
accountSelectScene.value = 'login'
accountCandidates.value = res.candidates || []
accountSelectVisible.value = accountCandidates.value.length > 0
return
}
loginLoading.value = false
await applyAuthLoginResult(res, '登录成功')
}
catch (error) {
uni.showToast({ title: getLoginErrorMessage(error, '手机号登录失败'), icon: 'none' })
}
finally {
loginLoading.value = false
}
}
function selectLoginAccount(item: ILoginCandidate) {
accountSelectVisible.value = false
if (accountSelectScene.value === 'wechatBindPhone') {
void submitWechatBindPhone(item.userId)
return
}
void submitLogin(item.userId)
void (loginMethod.value === 'sms' ? submitPhoneSmsLogin(item.userId) : submitLogin(item.userId))
}
function handleGetCaptcha(scene: CaptchaScene = 'register') {
if (scene === 'register' && captchaPassed.value)
if (smsCountdown[scene] > 0 || smsLoading.value)
return
if (scene === 'wechatBindPhone' && wechatBindPhoneCaptchaPassed.value)
if (!ensureAgreementChecked())
return
const phone = scene === 'wechatBindPhone' ? wechatBindPhoneForm.phone : registerForm.phone
const phone = scene === 'wechatBindPhone'
? wechatBindPhoneForm.phone
: scene === 'login' ? loginSmsForm.phone : registerForm.phone
if (!/^1[3-9]\d{9}$/.test(phone)) {
uni.showToast({ title: '请先输入正确手机号', icon: 'none' })
return
@@ -452,25 +579,15 @@ async function onSideCaptchaConfirm({
return
captchaLoading.value = true
try {
if (captchaScene.value === 'wechatBindPhone') {
wechatBindPhoneCaptcha.captchaKey = captchaKey
wechatBindPhoneCaptcha.captchaPoint = `${posX},${posY}`
wechatBindPhoneCaptchaPassed.value = true
captchaVisible.value = false
uni.showToast({ title: '图形验证已完成,请绑定手机号', icon: 'success' })
return
}
await checkSideCaptcha({
captchaKey,
point: `${posX},${posY}`,
})
// 暂不接入短信验证码:图形验证通过即视为可注册
captchaPassed.value = true
captchaVisible.value = false
uni.showToast({ title: '图形验证通过,请完成注册', icon: 'success' })
await sendSmsAfterCaptcha(captchaScene.value, captchaKey, `${posX},${posY}`)
}
catch (error) {
captchaPassed.value = false
if (captchaScene.value === 'register')
captchaPassed.value = false
if (captchaScene.value === 'login')
loginSmsCaptchaPassed.value = false
if (captchaScene.value === 'wechatBindPhone')
wechatBindPhoneCaptchaPassed.value = false
uni.showToast({
title: getLoginErrorMessage(error, '验证码校验失败,请重试'),
icon: 'none',
@@ -500,7 +617,11 @@ function validateRegisterForm() {
return false
}
if (!captchaPassed.value) {
uni.showToast({ title: '请先完成图形验证', icon: 'none' })
uni.showToast({ title: '请先获取短信验证码', icon: 'none' })
return false
}
if (!registerForm.smsCode) {
uni.showToast({ title: '请输入短信验证码', icon: 'none' })
return false
}
return true
@@ -520,6 +641,7 @@ async function submitRegister() {
phone: registerForm.phone,
nickName: registerForm.nickName.trim(),
password: registerForm.password,
smsCode: registerForm.smsCode,
})
tokenStore.setTokenInfo(res)
if (isSingleTokenResponse(res) && res.userInfo)
@@ -546,7 +668,14 @@ watch(
watch(
() => registerForm.phone,
() => {
captchaPassed.value = false
resetRegisterSmsCaptcha()
},
)
watch(
() => loginSmsForm.phone,
() => {
resetLoginSmsCaptcha()
},
)
@@ -559,7 +688,7 @@ watch(
onLoad((query) => {
redirectUrl.value = normalizeRedirect(query?.redirect)
if (query?.mode === 'register') {
if (query?.mode === 'register' && V101_RELEASE.showRegister) {
authMode.value = 'register'
authPanelVisible.value = true
}
@@ -568,6 +697,7 @@ onLoad((query) => {
onUnmounted(() => {
sideCaptchaRef.value = null
wechatBindResolve = null
Object.values(smsTimers).forEach(timer => timer && clearInterval(timer))
})
</script>
@@ -578,14 +708,15 @@ onUnmounted(() => {
<view class="hero-logo" aria-label="物联网智能服务">
<view class="i-carbon-user hero-logo__user" />
</view>
<text class="hero-title">山东平台物联卡</text>
<text class="hero-sub"> 卡片查询 · 状态排查 · 一键绑定 </text>
<text class="hero-title">示例企业物联卡</text>
<text class="hero-sub"> 卡片查询 · 状态排查 </text>
<text class="hero-desc"> 让每一张物联卡自己会说话 </text>
<text class="hero-desc"> 快速掌握卡片状态,办理业务更清晰 </text>
<text class="hero-desc"> 快速掌握卡片状态,查询结果更清晰 </text>
<view class="hero-actions">
<!-- #ifdef MP-WEIXIN -->
<wd-button
v-if="V101_RELEASE.showWechatLogin"
block
custom-class="hero-wechat-btn"
:loading="wechatLoginLoading"
@@ -603,31 +734,6 @@ onUnmounted(() => {
<view class="guest-query" @click="goPublicQuery">
未登录也可直接查询卡片(输入 ICCID / 扫码)
</view>
<view class="upgrade-card">
<view class="upgrade-title">
<view class="i-carbon-star upgrade-title__icon" />
<text>登录后能力升级</text>
</view>
<view class="upgrade-grid">
<view class="upgrade-item">
<view class="i-carbon-link upgrade-item__icon" />
<text>绑定我的卡</text>
</view>
<view class="upgrade-item">
<view class="i-carbon-time upgrade-item__icon" />
<text>查看排查历史</text>
</view>
<view class="upgrade-item">
<view class="i-carbon-notification upgrade-item__icon" />
<text>设置流量预警</text>
</view>
<view class="upgrade-item">
<view class="i-carbon-customer-service upgrade-item__icon" />
<text>专属客服</text>
</view>
</view>
</view>
</view>
<wd-popup
@@ -641,12 +747,12 @@ onUnmounted(() => {
<view class="panel-head">
<view>
<text class="panel-title">
{{ authMode === 'login' ? '账号密码登录' : '注册账号' }}
{{ authMode === 'login' ? '账号登录' : '注册账号' }}
</text>
<text class="panel-sub">
{{
authMode === 'login'
? '企业、代理商和终端用户均可使用分配账号登录'
? '支持账号密码或手机号验证码登录'
: '终端用户可用手机号注册并绑定卡片'
}}
</text>
@@ -659,26 +765,33 @@ onUnmounted(() => {
/>
</view>
<view class="mode-switch">
<view
class="mode-item"
:class="{ active: authMode === 'login' }"
@click="switchMode('login')"
>
登录
</view>
<view
class="mode-item"
:class="{ active: authMode === 'register' }"
@click="switchMode('register')"
>
注册
</view>
</view>
<view v-if="authMode === 'login'" class="panel-body">
<view class="mb-26rpx flex items-center gap-40rpx border-b border-#e5e7eb">
<view
class="relative pb-18rpx text-26rpx text-#64748b font-600"
:class="{ '!text-#2563eb !font-700': loginMethod === 'password' }"
@click="loginMethod = 'password'"
>
密码登录
<view
v-if="loginMethod === 'password'"
class="absolute bottom--1px left-1/2 h-4rpx w-36rpx rounded-full bg-#2563eb -translate-x-1/2"
/>
</view>
<view
class="relative pb-18rpx text-26rpx text-#64748b font-600"
:class="{ '!text-#2563eb !font-700': loginMethod === 'sms' }"
@click="loginMethod = 'sms'"
>
验证码登录
<view
v-if="loginMethod === 'sms'"
class="absolute bottom--1px left-1/2 h-4rpx w-36rpx rounded-full bg-#2563eb -translate-x-1/2"
/>
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<view class="wechat-login" @click="submitWechatLogin">
<view v-if="V101_RELEASE.showWechatLogin" class="wechat-login" @click="submitWechatLogin">
<view class="wechat-login__icon">
<view class="i-carbon-logo-wechat wechat-login__icon-glyph" />
</view>
@@ -690,12 +803,12 @@ onUnmounted(() => {
<view v-else class="i-carbon-chevron-right wechat-login__arrow" />
</view>
<view class="login-divider">
<view v-if="V101_RELEASE.showWechatLogin" class="login-divider">
<text>或使用账号密码</text>
</view>
<!-- #endif -->
<wd-form :model="loginForm" error-type="toast">
<wd-form v-if="loginMethod === 'password'" :model="loginForm" error-type="toast">
<view class="field">
<text class="field-label">账号</text>
<wd-input
@@ -721,18 +834,39 @@ onUnmounted(() => {
</view>
</wd-form>
<wd-form v-else :model="loginSmsForm" error-type="toast">
<view class="field">
<text class="field-label">手机号</text>
<wd-input v-model="loginSmsForm.phone" type="number" placeholder="请输入手机号" clearable custom-class="field-input" />
</view>
<view class="field-divider" />
<view class="field">
<text class="field-label">验证码</text>
<view class="flex flex-1 items-center gap-12rpx">
<wd-input v-model="loginSmsForm.smsCode" type="number" placeholder="请输入短信验证码" clearable custom-class="field-input flex-1" />
<text
class="shrink-0 border-l border-#e5e7eb pl-20rpx text-24rpx text-#2563eb font-600"
:class="{ '!text-#94a3b8': smsCountdown.login > 0 || smsLoading }"
@click="handleGetCaptcha('login')"
>
{{ smsLoading ? '发送中' : smsCountdown.login > 0 ? `${smsCountdown.login}s 后重试` : '获取验证码' }}
</text>
</view>
</view>
</wd-form>
<wd-button
class="mt-32rpx"
type="primary"
block
:loading="loginLoading"
@click="submitLogin()"
@click="loginMethod === 'sms' ? submitPhoneSmsLogin() : submitLogin()"
>
登录
</wd-button>
<view class="panel-links">
<text class="link-primary" @click="switchMode('register')">注册账号</text>
<text v-if="V101_RELEASE.showRegister" class="link-primary" @click="switchMode('register')">注册账号</text>
<text class="link-muted" @click="goForgotPassword">忘记密码</text>
</view>
@@ -746,7 +880,7 @@ onUnmounted(() => {
</view>
</view>
<view v-else class="panel-body">
<view v-else-if="authMode === 'register' && V101_RELEASE.showRegister" class="panel-body">
<wd-form :model="registerForm" error-type="toast">
<view class="field compact">
<text class="field-label">手机号</text>
@@ -759,6 +893,20 @@ onUnmounted(() => {
/>
</view>
<view class="field-divider" />
<view class="field compact">
<text class="field-label">验证码</text>
<view class="flex flex-1 items-center gap-12rpx">
<wd-input v-model="registerForm.smsCode" type="number" placeholder="请输入短信验证码" clearable custom-class="field-input flex-1" />
<text
class="shrink-0 border-l border-#e5e7eb pl-20rpx text-24rpx text-#2563eb font-600"
:class="{ '!text-#94a3b8': smsCountdown.register > 0 || smsLoading }"
@click="handleGetCaptcha('register')"
>
{{ smsLoading ? '发送中' : smsCountdown.register > 0 ? `${smsCountdown.register}s 后重试` : '获取验证码' }}
</text>
</view>
</view>
<view class="field-divider" />
<view class="field compact">
<text class="field-label">昵称</text>
<wd-input
@@ -790,25 +938,6 @@ onUnmounted(() => {
custom-class="field-input"
/>
</view>
<view class="field-divider" />
<view class="field compact">
<text class="field-label">安全验证</text>
<view
class="verify-bar"
:class="{ 'verify-bar--done': captchaPassed }"
@click="handleGetCaptcha"
>
<view class="i-carbon-security verify-bar__ico" />
<text class="verify-bar__text">
{{ captchaPassed ? '图形验证已通过' : '请完成图形验证(滑块/点选)' }}
</text>
<view
v-if="captchaPassed"
class="i-carbon-checkmark-outline verify-bar__ok"
/>
<view v-else class="i-carbon-chevron-right verify-bar__arrow" />
</view>
</view>
</wd-form>
<wd-button
@@ -928,21 +1057,16 @@ onUnmounted(() => {
</view>
<view class="field-divider" />
<view class="field compact">
<text class="field-label">安全验证</text>
<view
class="verify-bar"
:class="{ 'verify-bar--done': wechatBindPhoneCaptchaPassed }"
@click="handleGetCaptcha('wechatBindPhone')"
>
<view class="i-carbon-security verify-bar__ico" />
<text class="verify-bar__text">
{{ wechatBindPhoneCaptchaPassed ? '图形验证已完成' : '请完成图形验证' }}
<text class="field-label">验证码</text>
<view class="flex flex-1 items-center gap-12rpx">
<wd-input v-model="wechatBindPhoneForm.smsCode" type="number" placeholder="请输入短信验证码" clearable custom-class="field-input flex-1" />
<text
class="shrink-0 border-l border-#e5e7eb pl-20rpx text-24rpx text-#2563eb font-600"
:class="{ '!text-#94a3b8': smsCountdown.wechatBindPhone > 0 || smsLoading }"
@click="handleGetCaptcha('wechatBindPhone')"
>
{{ smsLoading ? '发送中' : smsCountdown.wechatBindPhone > 0 ? `${smsCountdown.wechatBindPhone}s 后重试` : '获取验证码' }}
</text>
<view
v-if="wechatBindPhoneCaptchaPassed"
class="i-carbon-checkmark-outline verify-bar__ok"
/>
<view v-else class="i-carbon-chevron-right verify-bar__arrow" />
</view>
</view>
</wd-form>
@@ -1242,34 +1366,8 @@ page {
color: #94a3b8 !important;
}
.mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rpx;
padding: 4rpx;
border-radius: 10rpx;
background: #f1f5f9;
}
.mode-item {
height: 60rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 27rpx;
font-weight: 700;
color: #64748b;
}
.mode-item.active {
color: #fff;
background: #2563eb;
box-shadow: none;
}
.panel-body {
margin-top: 18rpx;
margin-top: 8rpx;
}
.wechat-login {
+17 -167
View File
@@ -37,176 +37,26 @@ function iconClass(item: MessageItem) {
</script>
<template>
<view class="msg-list">
<view class="mlist">
<view
v-for="item in list"
:key="item.messageId"
class="mitem"
@click="emit('open', item)"
>
<view class="micon" :class="`micon--${itemTone(item)}`">
<view class="micon-glyph" :class="iconClass(item)" />
<view class="flex flex-col gap-16rpx">
<view v-for="item in list" :key="item.messageId" class="card-base flex gap-20rpx p-24rpx active:bg-input-bg" @click="emit('open', item)">
<view class="h-64rpx w-64rpx flex shrink-0 items-center justify-center rounded-16rpx bg-canvas-soft text-30rpx text-primary">
<view :class="iconClass(item)" />
</view>
<view class="min-w-0 flex flex-1 flex-col gap-12rpx">
<view class="flex items-start justify-between gap-16rpx">
<text class="min-w-0 flex-1 break-words text-28rpx text-ink font-bold leading-40rpx">{{ item.title }}</text>
<wd-tag v-if="item.unread" type="danger" round>
未读
</wd-tag>
</view>
<view class="mmain">
<view class="mt-row">
<text class="mt">{{ item.title }}</text>
<view v-if="item.unread" class="udot" />
</view>
<text class="ms">{{ item.summary }}</text>
</view>
<view class="mside">
<text class="mtime">{{ item.time || '暂无时间' }}</text>
<text class="line-clamp-2 break-words text-25rpx text-muted leading-38rpx">{{ item.summary }}</text>
<view class="flex flex-wrap items-center justify-between gap-12rpx">
<wd-tag :type="itemTone(item)" plain>
{{ item.typeText || group.label }}
</wd-tag>
<text class="text-22rpx text-disabled">{{ item.time || '暂无时间' }}</text>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.msg-list {
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 16rpx;
background: #fff;
box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06);
}
.mlist {
display: flex;
flex-direction: column;
}
.mitem {
display: flex;
align-items: center;
gap: 18rpx;
min-height: 122rpx;
padding: 22rpx 22rpx;
border-bottom: 1px solid #f1f5f9;
transition: background 0.16s ease;
}
.mitem:last-child {
border-bottom: 0;
}
.mitem:active {
background: #f8fafc;
}
.micon {
display: flex;
width: 72rpx;
height: 72rpx;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 18rpx;
color: #fff;
}
.micon--primary {
background: #2563eb;
}
.micon--success {
background: #16a34a;
}
.micon--warning {
background: #f59e0b;
}
.micon--danger {
background: #ef4444;
}
.micon-glyph {
font-size: 34rpx;
}
.mmain {
flex: 1;
min-width: 0;
}
.mt-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.mt {
flex: 1;
min-width: 0;
font-size: 28rpx;
font-weight: 800;
line-height: 38rpx;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.udot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #dc2626;
flex-shrink: 0;
box-shadow: 0 0 0 4rpx rgba(220, 38, 38, 0.18);
}
.ms {
display: -webkit-box;
margin-top: 6rpx;
font-size: 24rpx;
color: #64748b;
line-height: 34rpx;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.mside {
display: flex;
width: 96rpx;
align-items: flex-end;
flex-direction: column;
flex-shrink: 0;
gap: 8rpx;
}
.mtime {
max-width: 96rpx;
color: #94a3b8;
font-size: 22rpx;
line-height: 30rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media screen and (max-width: 360px) {
.mitem {
gap: 14rpx;
padding: 20rpx 18rpx;
}
.micon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
}
.mside {
width: 96rpx;
}
.mtime {
max-width: 96rpx;
}
}
</style>
@@ -6,6 +6,7 @@ const props = defineProps<{
placeholder: string
modelValue: string
filterKey: MessageFilterKey
loading?: boolean
}>()
const emit = defineEmits<{
@@ -23,120 +24,18 @@ const activeFilterKey = computed({
get: () => props.filterKey,
set: value => emit('update:filterKey', value),
})
const dotColorMap: Record<MessageFilterKey, string> = {
all: '#2563eb',
recent30: '#2563eb',
asc: '#64748b',
}
function dotColor(key: MessageFilterKey) {
return dotColorMap[key]
}
</script>
<template>
<view class="toolbar">
<view class="search-row">
<wd-search
v-model="keyword"
hide-cancel
placeholder-left
variant="filled"
:placeholder="props.placeholder"
custom-class="min-w-0 flex-1 !p-0"
/>
<wd-button
class="refresh-btn"
icon="i-carbon-renew"
type="primary"
plain
round
size="small"
@click="emit('refresh')"
/>
</view>
<view class="tag-row">
<view
v-for="option in messageFilterOptions"
:key="option.key"
class="chip"
:class="{ 'is-active': activeFilterKey === option.key }"
@click="activeFilterKey = option.key"
>
<view class="dot" :style="{ background: dotColor(option.key) }" />
<text class="chip-txt">{{ option.label }}</text>
</view>
<view class="card-base flex flex-col gap-20rpx p-20rpx">
<view class="flex items-center gap-16rpx">
<wd-search v-model="keyword" hide-cancel placeholder-left variant="filled" :placeholder="props.placeholder" custom-class="min-w-0 flex-1 !p-0" />
<wd-button type="primary" variant="text" size="small" css-icon="i-carbon-renew" custom-class="!h-56rpx !min-h-56rpx !px-8rpx !text-24rpx" :loading="props.loading" :disabled="props.loading" @click="emit('refresh')">
刷新
</wd-button>
</view>
<wd-tabs v-model="activeFilterKey" line-theme="text" custom-class="!bg-transparent">
<wd-tab v-for="option in messageFilterOptions" :key="option.key" :name="option.key" :title="option.label" />
</wd-tabs>
</view>
</template>
<style lang="scss" scoped>
.toolbar {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.search-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.refresh-btn {
flex-shrink: 0;
}
.tag-row {
display: flex;
gap: 12rpx;
overflow-x: auto;
padding-bottom: 4rpx;
}
.chip {
display: flex;
align-items: center;
gap: 7rpx;
height: 44rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: #fff;
border: 1px solid #e2e8f0;
flex-shrink: 0;
transition: all 0.18s ease;
}
.chip:active {
transform: scale(0.95);
}
.chip.is-active {
background: #eff6ff;
border-color: #bfdbfe;
}
.chip-txt {
font-size: 22rpx;
font-weight: 700;
color: #334155;
white-space: nowrap;
}
.chip.is-active .chip-txt {
color: #2563eb;
}
.dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
flex-shrink: 0;
}
.chip.is-active .dot {
box-shadow: 0 0 0 4rpx rgba(37, 99, 235, 0.14);
}
</style>
+29 -17
View File
@@ -132,9 +132,9 @@ export const messageFilterOptions: MessageFilterOption[] = [
export function normalizeMessage(source: AppMessage): MessageItem {
const messageType = Number(source.messageType || 1)
const group =
baseMessageGroups.find((item) => item.messageTypes.includes(messageType)) ||
baseMessageGroups[0]
const group
= baseMessageGroups.find(item => item.messageTypes.includes(messageType))
|| baseMessageGroups[0]
const content = String(source.content || '')
const summary = stripRichText(content) || '暂无消息详细内容'
@@ -157,16 +157,18 @@ export function normalizeMessage(source: AppMessage): MessageItem {
export function messageMatchesKeyword(item: MessageItem, keyword: string) {
const value = keyword.trim()
if (!value) return true
if (!value)
return true
return [item.title, item.summary, item.content, item.time].some((text) => text.includes(value))
return [item.title, item.summary, item.content, item.time].some(text => text.includes(value))
}
export function messageMatchesGroup(
item: MessageItem,
group: Pick<MessageGroup, 'key' | 'messageTypes'>,
) {
if (group.key === 'all') return true
if (group.key === 'all')
return true
return group.messageTypes.includes(item.messageType)
}
@@ -180,7 +182,8 @@ const messageModulePatterns: Array<[MessageModuleKey, RegExp]> = [
export function getMessageModuleKey(item: MessageItem): MessageModuleKey {
const text = `${item.title}${item.summary}${item.content}`
const matched = messageModulePatterns.find(([, pattern]) => pattern.test(text))
if (matched) return matched[0]
if (matched)
return matched[0]
switch (item.messageType) {
case 3:
@@ -198,7 +201,7 @@ export function messageMatchesModule(item: MessageItem, module: MessageModule) {
}
export function latestModuleMessage(items: MessageItem[], module: MessageModule) {
return items.filter((item) => messageMatchesModule(item, module)).sort(compareMessageDesc)[0]
return items.filter(item => messageMatchesModule(item, module)).sort(compareMessageDesc)[0]
}
export function messageTimestamp(item: MessageItem) {
@@ -208,42 +211,50 @@ export function messageTimestamp(item: MessageItem) {
export function compareMessageDesc(a: MessageItem, b: MessageItem) {
const timeDiff = messageTimestamp(b) - messageTimestamp(a)
if (timeDiff !== 0) return timeDiff
if (timeDiff !== 0)
return timeDiff
return Number(b.messageId || b.id || 0) - Number(a.messageId || a.id || 0)
}
export function compareMessageAsc(a: MessageItem, b: MessageItem) {
const timeDiff = messageTimestamp(a) - messageTimestamp(b)
if (timeDiff !== 0) return timeDiff
if (timeDiff !== 0)
return timeDiff
return Number(a.messageId || a.id || 0) - Number(b.messageId || b.id || 0)
}
export function parseMessageTimestamp(value?: string | number | Date | null) {
if (value === undefined || value === null || value === '') return 0
if (value === undefined || value === null || value === '')
return 0
if (value instanceof Date) {
const time = value.getTime()
return Number.isFinite(time) ? time : 0
}
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
if (typeof value === 'number')
return Number.isFinite(value) ? value : 0
const text = String(value).trim()
if (!text) return 0
if (!text)
return 0
const candidates = [text, text.replace(' ', 'T'), text.replace(/-/g, '/').replace('T', ' ')]
for (const candidate of candidates) {
const parsed = Date.parse(candidate)
if (Number.isFinite(parsed)) return parsed
if (Number.isFinite(parsed))
return parsed
}
return 0
}
export function messageWithinDays(item: MessageItem, days: number) {
if (days <= 0) return true
if (days <= 0)
return true
const timestamp = messageTimestamp(item)
if (!timestamp) return false
if (!timestamp)
return false
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
return timestamp >= cutoff
@@ -293,7 +304,8 @@ export function stripRichText(content: string) {
}
function formatMessageTime(value: string) {
if (!value) return ''
if (!value)
return ''
return String(value)
.replace('T', ' ')
+82
View File
@@ -0,0 +1,82 @@
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 data from './data'
import * as pagination from './pagination'
function createHarness() {
const tokenStore = { validToken: 'account-a' }
const setUnreadCount = vi.fn()
const getMessageList = vi.fn()
const readMessages = vi.fn(async () => undefined)
let resetAccount: () => void = () => undefined
const context = createContext({
exports: {},
require: (name: string) => {
if (name === '@/api/message')
return { getMessageList, readMessages }
if (name === '@/store/token')
return { useTokenStore: () => tokenStore }
if (name === '@/store/message')
return { useMessageStore: () => ({ setUnreadCount, adjustUnreadCount: vi.fn(), refreshUnreadCount: vi.fn() }) }
if (name === './data')
return data
if (name === './pagination')
return pagination
return { default: {} }
},
ref: (value: unknown) => ({ value }),
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
watch: (_getter: unknown, callback: () => void) => { resetAccount = callback },
definePage: () => undefined,
onLoad: () => undefined,
onShow: () => undefined,
onUnload: () => undefined,
uni: { showToast: vi.fn(), navigateTo: vi.fn() },
})
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
const script = source.slice(source.indexOf('>') + 1, source.indexOf('</script>'))
if (!script)
throw new Error('Message page script not found')
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
return {
getMessageList,
readMessages,
setUnreadCount,
evaluate: (code: string) => runInContext(code, context),
switchAccount: () => {
tokenStore.validToken = 'account-b'
resetAccount()
},
}
}
describe('消息页面请求隔离', () => {
it('切换账号后丢弃旧账号列表响应与未读数量', async () => {
const harness = createHarness()
let resolvePage!: (value: unknown) => void
harness.getMessageList.mockReturnValueOnce(new Promise((resolve) => {
resolvePage = resolve
}))
const pending = harness.evaluate('loadMessages()')
harness.switchAccount()
resolvePage({ items: [{ id: 1, messageId: 1 }], total: 1 })
await pending
expect(harness.evaluate('rawMessages.value.length')).toBe(0)
expect(harness.setUnreadCount).not.toHaveBeenCalled()
expect(harness.getMessageList).toHaveBeenCalledTimes(1)
})
it('批量已读期间切换账号后停止后续批次', async () => {
const harness = createHarness()
harness.evaluate('activeModuleKey.value = \'system\'')
harness.evaluate('rawMessages.value = Array.from({ length: 101 }, (_, index) => ({ id: index + 1, messageId: index + 1, messageType: 1, isRead: 0 }))')
harness.readMessages.mockImplementationOnce(async () => {
harness.switchAccount()
})
await harness.evaluate('handleReadAll()')
expect(harness.readMessages).toHaveBeenCalledTimes(1)
expect(harness.setUnreadCount).not.toHaveBeenCalled()
})
})
+210 -409
View File
@@ -2,9 +2,13 @@
import type { AppMessage } from '@/api/message'
import { getMessageList, readMessages } from '@/api/message'
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
import { hasAnyFeature } from '@/utils/permission'
import { useMessageStore } from '@/store/message'
import { useTokenStore } from '@/store/token'
import MessageList from './components/MessageList.vue'
import MessageToolbar from './components/MessageToolbar.vue'
import { collectMessagePages } from './pagination'
import {
compareMessageAsc,
compareMessageDesc,
@@ -24,8 +28,8 @@ definePage({
layout: 'default',
})
const pageSize = 100
const messageStore = useMessageStore()
const tokenStore = useTokenStore()
const activeModuleKey = ref<MessageModuleKey>()
const activeFilter = ref<MessageFilterKey>('all')
@@ -37,29 +41,45 @@ const rawMessages = ref<AppMessage[]>([])
const totalUnread = ref(0)
let messageLoadSeq = 0
const messageItems = computed(() => rawMessages.value.map((item) => normalizeMessage(item)))
function isCurrentRequest(seq: number, token: string) {
return seq === messageLoadSeq && Boolean(token) && tokenStore.validToken === token
}
watch(() => tokenStore.validToken, () => {
messageLoadSeq++
rawMessages.value = []
totalUnread.value = 0
errorMessage.value = ''
loading.value = false
readingAll.value = false
}, { flush: 'sync' })
onUnload(() => {
messageLoadSeq++
})
const messageItems = computed(() => rawMessages.value.map(item => normalizeMessage(item)))
const moduleRows = computed(() =>
messageModules.map((module) => {
const moduleMessages = messageItems.value.filter((item) => messageMatchesModule(item, module))
const moduleMessages = messageItems.value.filter(item => messageMatchesModule(item, module))
const latest = latestModuleMessage(moduleMessages, module)
return {
...module,
unreadCount: moduleMessages.filter((item) => item.unread).length,
unreadCount: moduleMessages.filter(item => item.unread).length,
latest,
}
}),
)
const activeModule = computed(() =>
messageModules.find((item) => item.key === activeModuleKey.value),
messageModules.find(item => item.key === activeModuleKey.value),
)
const currentUnreadIds = computed(() => {
const ids = messageItems.value
.filter(
(item) => item.unread && activeModule.value && messageMatchesModule(item, activeModule.value),
item => item.unread && activeModule.value && messageMatchesModule(item, activeModule.value),
)
.map((item) => Number(item.messageId || item.id || 0))
.map(item => Number(item.messageId || item.id || 0))
.filter(Boolean)
return Array.from(new Set(ids))
})
@@ -77,55 +97,67 @@ const activeModuleMessageGroup = computed(() => ({
const filteredMessages = computed(() => {
const compareMessage = activeFilter.value === 'asc' ? compareMessageAsc : compareMessageDesc
const list = messageItems.value
.filter((item) => (activeModule.value ? messageMatchesModule(item, activeModule.value) : false))
.filter(item => (activeModule.value ? messageMatchesModule(item, activeModule.value) : false))
.filter((item) => {
if (activeFilter.value !== 'recent30') return true
if (activeFilter.value !== 'recent30')
return true
return messageWithinDays(item, 30)
})
.filter((item) => messageMatchesKeyword(item, keyword.value))
.filter(item => messageMatchesKeyword(item, keyword.value))
.sort(compareMessage)
return list
})
const emptyTip = computed(() => {
if (keyword.value.trim()) return '暂无匹配消息'
if (activeFilter.value === 'recent30') return '最近30天暂无消息'
if (keyword.value.trim())
return '暂无匹配消息'
if (activeFilter.value === 'recent30')
return '最近30天暂无消息'
return activeModule.value?.emptyTip || '请选择一个消息模块'
})
const showInitialLoading = computed(() => loading.value && rawMessages.value.length === 0)
async function loadUnreadTotal() {
async function loadUnreadTotal(seq = messageLoadSeq, token = tokenStore.validToken) {
if (!isCurrentRequest(seq, token))
return
const res = await getMessageList({
page: 1,
pageSize: 1,
isRead: '0',
})
if (!isCurrentRequest(seq, token))
return
totalUnread.value = Number(res.total || 0)
messageStore.setUnreadCount(totalUnread.value)
}
async function loadMessages() {
const seq = ++messageLoadSeq
const token = tokenStore.validToken
if (!token)
return
loading.value = true
errorMessage.value = ''
try {
const res = await getMessageList({
page: 1,
pageSize,
})
if (seq !== messageLoadSeq) return
rawMessages.value = res.items || []
await loadUnreadTotal()
} catch (error) {
if (seq !== messageLoadSeq) return
const items = await collectMessagePages(getMessageList, {}, () => isCurrentRequest(seq, token))
if (!items || !isCurrentRequest(seq, token))
return
rawMessages.value = items
await loadUnreadTotal(seq, token)
}
catch (error) {
if (!isCurrentRequest(seq, token))
return
if (rawMessages.value.length === 0) {
rawMessages.value = []
totalUnread.value = 0
messageStore.setUnreadCount(0)
}
errorMessage.value = getErrorMessage(error, '消息加载失败,请稍后重试')
} finally {
if (seq === messageLoadSeq) loading.value = false
}
finally {
if (isCurrentRequest(seq, token))
loading.value = false
}
}
@@ -142,15 +174,50 @@ function closeModule() {
}
async function handleOpen(item: MessageItem) {
const link = String(item.raw.link || '')
const prefix = `${RouterPath.BusinessViewsWorkOrderDetail}?woNo=`
const workOrderLink = link.startsWith(prefix) && !link.slice(prefix.length).includes('&') && !link.includes('#')
if (workOrderLink) {
if (!hasAnyFeature(useUserStore().userInfo, 'workorder:detail')) {
uni.showToast({ title: '暂无工单查看权限', icon: 'none' })
return
}
let woNo = ''
try {
woNo = decodeURIComponent(link.slice(prefix.length))
}
catch {
uni.showToast({ title: '工单链接无效', icon: 'none' })
return
}
uni.navigateTo({ url: `${RouterPath.BusinessViewsWorkOrderDetail}?woNo=${encodeURIComponent(woNo)}` })
if (item.unread) {
const seq = messageLoadSeq
const token = tokenStore.validToken
readMessages({ ids: [item.messageId] }).then(() => {
if (isCurrentRequest(seq, token))
markLocalRead(item.id)
}).catch(() => {
if (isCurrentRequest(seq, token))
void loadMessages()
})
}
return
}
if (item.unread) {
markLocalRead(item.id)
readMessages({ ids: [item.messageId] }).catch(() => {
void loadMessages()
const seq = messageLoadSeq
const token = tokenStore.validToken
readMessages({ ids: [item.messageId] }).then(() => {
if (isCurrentRequest(seq, token))
markLocalRead(item.id)
}).catch(() => {
if (isCurrentRequest(seq, token))
void loadMessages()
})
}
const detailPath =
Number(item.messageType) === 1
const detailPath
= Number(item.messageType) === 1
? RouterPath.MessageViewsSystemDetail
: RouterPath.MessageViewsCardDetail
@@ -160,37 +227,58 @@ async function handleOpen(item: MessageItem) {
}
async function handleReadAll() {
if (readingAll.value) return
if (readingAll.value)
return
const ids = currentUnreadIds.value
if (!ids.length) {
uni.showToast({ title: '当前没有未读消息', icon: 'none' })
return
}
const seq = messageLoadSeq
const token = tokenStore.validToken
if (!isCurrentRequest(seq, token))
return
readingAll.value = true
try {
await readMessages({ ids })
for (let offset = 0; offset < ids.length; offset += 100) {
if (!isCurrentRequest(seq, token))
return
await readMessages({ ids: ids.slice(offset, offset + 100) })
}
if (!isCurrentRequest(seq, token))
return
const idSet = new Set(ids.map(Number))
rawMessages.value = rawMessages.value.map((item) => {
const id = Number(item.messageId || item.id || 0)
if (!idSet.has(id)) return item
return { ...item, isRead: 1 }
return idSet.has(id) ? { ...item, isRead: 1 } : item
})
totalUnread.value = Math.max(0, totalUnread.value - ids.length)
messageStore.setUnreadCount(totalUnread.value)
await loadUnreadTotal()
uni.showToast({ title: '已标记为已读', icon: 'success' })
} catch (error) {
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
} finally {
readingAll.value = false
await loadUnreadTotal(seq, token)
if (isCurrentRequest(seq, token))
uni.showToast({ title: '已标记为已读', icon: 'success' })
}
catch (error) {
if (!isCurrentRequest(seq, token))
return
await loadMessages()
if (tokenStore.validToken !== token)
return
await messageStore.refreshUnreadCount()
if (tokenStore.validToken === token)
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
}
finally {
if (tokenStore.validToken === token)
readingAll.value = false
}
}
function markLocalRead(id: number) {
rawMessages.value = rawMessages.value.map((item) => {
if (Number(item.id) !== Number(id)) return item
if (Number(item.id) !== Number(id))
return item
return { ...item, isRead: 1 }
})
totalUnread.value = Math.max(0, totalUnread.value - 1)
@@ -199,390 +287,103 @@ function markLocalRead(id: number) {
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as { msg?: unknown; message?: unknown }
if (typeof data.msg === 'string' && data.msg) return data.msg
if (typeof data.message === 'string' && data.message) return data.message
const data = error as { msg?: unknown, message?: unknown }
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
}
return fallback
}
onLoad((query) => {
const moduleKey = String(query?.module || '').trim() as MessageModuleKey
if (messageModules.some((item) => item.key === moduleKey)) activeModuleKey.value = moduleKey
if (messageModules.some(item => item.key === moduleKey))
activeModuleKey.value = moduleKey
})
onShow(loadMessages)
</script>
<template>
<view class="message-page">
<view class="message-stack">
<view class="message-header">
<view class="message-header__nav">
<view v-if="activeModule" class="back-button" @click="closeModule">
<view class="i-carbon-arrow-left" />
</view>
<view class="message-header__copy">
<text class="message-title">{{ activeModule?.label || '消息' }}</text>
<text class="message-sub">{{
activeModule ? '查看这个模块的全部消息' : '按模块查看消息和业务动态'
}}</text>
</view>
<view class="message-header__unread">
<text>未读</text>
<text class="unread-count">{{ totalUnread > 99 ? '99+' : totalUnread }}</text>
</view>
<view class="min-h-screen bg-canvas p-20rpx pb-48rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<view class="card-base flex flex-col gap-24rpx p-28rpx">
<view class="flex items-center gap-20rpx">
<wd-button v-if="activeModule" type="info" variant="text" size="small" css-icon="i-carbon-chevron-left" custom-class="!h-56rpx !min-h-56rpx !px-0 !text-24rpx" @click="closeModule">
返回
</wd-button>
<view class="min-w-0 flex-1">
<text class="block text-36rpx text-ink font-bold">{{ activeModule?.label || '消息中心' }}</text>
<text class="mt-8rpx block text-24rpx text-muted leading-36rpx">{{ activeModule ? '业务动态与提醒,集中查看' : '预警、卡片、订单和系统通知' }}</text>
</view>
<view v-if="activeModule" class="message-header__tools">
<text class="message-header__scope"
>{{ activeModule.label }} · {{ filteredMessages.length }} 条</text
>
<view
class="read-all"
:class="{ 'is-disabled': readingAll || !currentUnreadIds.length }"
@click="handleReadAll"
>
<wd-loading v-if="readingAll" custom-class="!mr-8rpx" />
<text>全部已读</text>
<wd-tag :type="totalUnread ? 'danger' : 'default'" plain>
{{ totalUnread > 99 ? '99+' : totalUnread }} 未读
</wd-tag>
</view>
<view class="flex flex-wrap items-center justify-between gap-16rpx border-0 border-t border-line border-solid pt-20rpx">
<text class="text-24rpx text-muted">{{ activeModule ? `当前模块 · ${filteredMessages.length} 条` : '预警、卡片、订单和系统通知' }}</text>
<wd-button v-if="activeModule" type="primary" variant="text" size="small" custom-class="!h-56rpx !min-h-56rpx !px-8rpx !text-24rpx" :loading="readingAll" :disabled="loading || readingAll || !currentUnreadIds.length" @click="handleReadAll">
全部已读
</wd-button>
</view>
</view>
<MessageToolbar v-if="activeModule" v-model="keyword" v-model:filter-key="activeFilter" :placeholder="`搜索${activeModule.label}`" :loading="loading" @refresh="loadMessages" />
<view v-else class="flex items-center justify-between px-4rpx">
<text class="text-28rpx text-ink font-bold">消息分类</text>
<wd-button type="primary" variant="text" size="small" css-icon="i-carbon-renew" custom-class="!h-56rpx !min-h-56rpx !px-8rpx !text-24rpx" :loading="loading" :disabled="loading" @click="loadMessages">
刷新消息
</wd-button>
</view>
<view v-if="showInitialLoading" class="card-base flex flex-col items-center gap-20rpx py-72rpx">
<wd-loading />
<text class="text-24rpx text-muted">正在加载消息…</text>
</view>
<view v-else-if="errorMessage" class="card-base flex flex-col items-center gap-24rpx p-32rpx">
<wd-empty tip="消息暂时加载失败">
<template #image>
<view class="i-carbon-information text-72rpx text-warning" />
</template>
</wd-empty>
<text class="text-center text-25rpx text-muted leading-38rpx">{{ errorMessage }}</text>
<wd-button type="primary" variant="text" size="small" custom-class="!h-56rpx !min-h-56rpx !px-8rpx !text-24rpx" @click="loadMessages">
重新加载
</wd-button>
</view>
<template v-else-if="!activeModule">
<view class="card-base overflow-hidden">
<view v-for="module in moduleRows" :key="module.key" class="flex items-center gap-20rpx border-0 border-b border-line border-solid p-24rpx last:border-b-0 active:bg-input-bg" @click="openModule(module)">
<view class="h-76rpx w-76rpx flex shrink-0 items-center justify-center rounded-20rpx text-34rpx" :class="module.key === 'warning' ? 'bg-amber-50 text-warning' : 'bg-canvas-soft text-primary'">
<view :class="module.icon" />
</view>
<view class="min-w-0 flex-1">
<view class="flex flex-wrap items-center gap-12rpx">
<text class="text-29rpx text-ink font-bold">{{ module.label }}</text>
<wd-tag v-if="module.unreadCount" type="danger" round>
{{ module.unreadCount > 99 ? '99+' : module.unreadCount }}
</wd-tag>
</view>
<text class="mt-10rpx block truncate text-24rpx text-muted">{{ module.latest?.summary || module.emptyTip }}</text>
<text v-if="module.latest?.time" class="mt-8rpx block text-22rpx text-disabled">{{ module.latest.time }}</text>
</view>
<view class="i-carbon-chevron-right shrink-0 text-28rpx text-disabled" />
</view>
</view>
<template v-if="!activeModule">
<view class="module-list">
<view
v-for="module in moduleRows"
:key="module.key"
class="module-row"
@click="openModule(module)"
>
<view class="module-icon" :class="`module-icon--${module.tagType}`">
<view :class="module.icon" />
</view>
<view class="module-main">
<view class="module-title-row">
<text class="module-title">{{ module.label }}</text>
<view v-if="module.unreadCount" class="module-unread">
{{ module.unreadCount > 99 ? '99+' : module.unreadCount }}
</view>
</view>
<text class="module-summary">{{ module.latest?.summary || module.emptyTip }}</text>
</view>
<view class="module-side">
<text class="module-time">{{ module.latest?.time || '' }}</text>
<view class="i-carbon-chevron-right module-arrow" />
</view>
</view>
</view>
<view class="message-note">
<view class="i-carbon-information message-note__icon" />
<text>点进模块后,可查看该模块下的全部消息;进入消息详情会自动标记为已读。</text>
</view>
</template>
<template v-else>
<MessageToolbar
v-model="keyword"
v-model:filter-key="activeFilter"
:placeholder="activeModule.label + '消息'"
@refresh="loadMessages"
/>
<view v-if="showInitialLoading" class="message-card message-loading">
<wd-loading />
</view>
<view v-else-if="errorMessage" class="message-card message-error">
<text class="text-26rpx text-muted">{{ errorMessage }}</text>
<wd-button size="small" type="primary" plain @click="loadMessages">重试</wd-button>
</view>
<MessageList
v-else-if="filteredMessages.length"
:group="activeModuleMessageGroup"
:list="filteredMessages"
@open="handleOpen"
/>
<wd-empty v-else icon="content" :tip="emptyTip" />
</template>
<view class="flex items-start gap-12rpx px-8rpx text-23rpx text-muted leading-36rpx">
<view class="i-carbon-information mt-4rpx shrink-0 text-26rpx" />
<text>进入详情查看消息和预警通知。</text>
</view>
</template>
<MessageList v-else-if="filteredMessages.length" :group="activeModuleMessageGroup" :list="filteredMessages" @open="handleOpen" />
<view v-else class="card-base py-32rpx">
<wd-empty :tip="emptyTip">
<template #image>
<view class="i-carbon-notification text-72rpx text-disabled" />
</template>
</wd-empty>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.message-page {
min-height: 100%;
box-sizing: border-box;
padding: 0 20rpx 48rpx;
background: #f6f8fb;
}
.message-stack {
display: flex;
max-width: 960rpx;
margin: 0 auto;
flex-direction: column;
gap: 16rpx;
}
.message-header {
padding: 24rpx 4rpx 8rpx;
}
.message-header__nav,
.message-header__tools {
display: flex;
align-items: center;
gap: 14rpx;
}
.message-header__nav {
min-height: 64rpx;
}
.back-button {
display: flex;
width: 56rpx;
height: 56rpx;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 999rpx;
color: #334155;
background: #fff;
font-size: 30rpx;
}
.message-header__copy {
min-width: 0;
flex: 1;
}
.message-title,
.message-sub {
display: block;
}
.message-title {
color: #111827;
font-size: 36rpx;
font-weight: 900;
line-height: 46rpx;
}
.message-sub {
margin-top: 4rpx;
color: #64748b;
font-size: 22rpx;
line-height: 32rpx;
}
.message-header__unread {
display: flex;
align-items: center;
gap: 6rpx;
color: #64748b;
font-size: 22rpx;
flex-shrink: 0;
}
.unread-count {
color: #dc2626;
font-size: 24rpx;
font-weight: 900;
}
.message-header__tools {
justify-content: space-between;
margin-top: 18rpx;
padding-top: 16rpx;
border-top: 1px solid #e2e8f0;
}
.message-header__scope {
color: #64748b;
font-size: 22rpx;
}
.read-all {
display: flex;
align-items: center;
color: #2563eb;
font-size: 23rpx;
font-weight: 800;
}
.read-all.is-disabled {
color: #94a3b8;
}
.module-list,
.message-card {
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 16rpx;
background: #fff;
box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06);
}
.module-row {
display: flex;
min-width: 0;
min-height: 122rpx;
align-items: center;
gap: 18rpx;
padding: 22rpx 20rpx;
border-bottom: 1px solid #f1f5f9;
}
.module-row:last-child {
border-bottom: 0;
}
.module-row:active {
background: #f8fafc;
}
.module-icon {
display: flex;
width: 72rpx;
height: 72rpx;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 18rpx;
color: #fff;
font-size: 34rpx;
}
.module-icon--warning {
background: #f59e0b;
}
.module-icon--primary {
background: #2563eb;
}
.module-icon--success {
background: #16a34a;
}
.module-main {
min-width: 0;
flex: 1;
}
.module-title-row {
display: flex;
min-width: 0;
align-items: center;
gap: 10rpx;
}
.module-title {
min-width: 0;
overflow: hidden;
color: #111827;
font-size: 28rpx;
font-weight: 800;
line-height: 38rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-unread {
min-width: 26rpx;
height: 26rpx;
padding: 0 7rpx;
border-radius: 999rpx;
color: #fff;
background: #dc2626;
font-size: 18rpx;
font-weight: 800;
line-height: 26rpx;
text-align: center;
}
.module-summary {
display: -webkit-box;
margin-top: 6rpx;
overflow: hidden;
color: #64748b;
font-size: 23rpx;
line-height: 34rpx;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.module-side {
display: flex;
width: 96rpx;
align-items: flex-end;
flex-direction: column;
flex-shrink: 0;
gap: 10rpx;
}
.module-time {
max-width: 96rpx;
overflow: hidden;
color: #94a3b8;
font-size: 21rpx;
line-height: 30rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-arrow {
color: #cbd5e1;
font-size: 28rpx;
}
.message-note {
display: flex;
align-items: flex-start;
gap: 10rpx;
padding: 18rpx 14rpx;
color: #64748b;
background: #f1f5f9;
font-size: 21rpx;
line-height: 32rpx;
}
.message-note__icon {
flex-shrink: 0;
color: #0ea5e9;
font-size: 24rpx;
line-height: 32rpx;
}
.message-loading,
.message-error {
display: flex;
min-height: 220rpx;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 18rpx;
}
@media screen and (max-width: 360px) {
.message-page {
padding-right: 16rpx;
padding-left: 16rpx;
}
.module-row {
gap: 14rpx;
padding-right: 16rpx;
padding-left: 16rpx;
}
.module-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
}
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import type { AppMessage } from '@/api/message'
import { describe, expect, it, vi } from 'vitest'
import { collectMessagePages } from './pagination'
function item(id: number): AppMessage {
return { id, messageId: id, messageLogId: id, messageType: 1, title: '通知', content: '', isRead: 0 }
}
describe('消息分页完整性', () => {
it('超过 100 条仍能加载完整列表', async () => {
const fetch = vi.fn().mockResolvedValueOnce({ items: Array.from({ length: 100 }, (_, i) => item(i + 1)), total: 101 }).mockResolvedValueOnce({ items: [item(101)], total: 101 })
expect((await collectMessagePages(fetch))?.length).toBe(101)
expect(fetch).toHaveBeenCalledTimes(2)
})
it('过期请求不返回旧数据', async () => {
expect(await collectMessagePages(vi.fn().mockResolvedValue({ items: [item(1)], total: 1 }), {}, () => false)).toBeNull()
})
it('重复分页不造成无限加载', async () => {
const fetch = vi.fn().mockResolvedValue({ items: Array.from({ length: 100 }, (_, i) => item(i + 1)), total: 300 })
await expect(collectMessagePages(fetch)).rejects.toThrow('分页未前进')
})
it('分页失败向调用者传播,不假装加载完整', async () => {
await expect(collectMessagePages(vi.fn().mockRejectedValue(new Error('网络失败')))).rejects.toThrow('网络失败')
})
})
+22
View File
@@ -0,0 +1,22 @@
import type { AppMessage, AppMessageListParams, AppMessageListRes } from '@/api/message'
export async function collectMessagePages(
fetchPage: (params: AppMessageListParams) => Promise<AppMessageListRes>,
params: Omit<AppMessageListParams, 'page' | 'pageSize'> = {},
isCurrent: () => boolean = () => true,
): Promise<AppMessage[] | null> {
const messages = new Map<number, AppMessage>()
for (let page = 1; ; page++) {
const result = await fetchPage({ ...params, page, pageSize: 100 })
if (!isCurrent())
return null
const items = result.items || []
const previousSize = messages.size
for (const item of items)
messages.set(Number(item.messageId || item.id), item)
if (items.length < 100 || page * 100 >= Number(result.total || 0))
return Array.from(messages.values())
if (messages.size === previousSize)
throw new Error('消息分页未前进,请稍后重试')
}
}
+121 -125
View File
@@ -2,6 +2,8 @@
import type { AppMessage } from '@/api/message'
import { getMessageDetail, readMessages } from '@/api/message'
import { stripRichText } from '../../data'
import { useMessageStore } from '@/store/message'
import { useTokenStore } from '@/store/token'
definePage({
style: {
@@ -9,11 +11,31 @@ definePage({
},
})
const tokenStore = useTokenStore()
let detailRequestSeq = 0
function isCurrentRequest(seq: number, token: string) {
return seq === detailRequestSeq && Boolean(token) && tokenStore.validToken === token
}
const messageId = ref(0)
const loading = ref(false)
const marking = ref(false)
const errorMessage = ref('')
const detail = ref<AppMessage | null>(null)
watch(() => tokenStore.validToken, () => {
detailRequestSeq++
detail.value = null
errorMessage.value = ''
loading.value = false
marking.value = false
}, { flush: 'sync' })
onUnload(() => {
detailRequestSeq++
})
const content = computed(() => detail.value?.content || '')
const contentText = computed(() => stripRichText(content.value) || '暂无消息详细内容')
const canRenderRichText = computed(() => /<[^>]+>/.test(content.value))
@@ -29,44 +51,75 @@ async function loadDetail(id: number) {
return
}
const seq = ++detailRequestSeq
const token = tokenStore.validToken
if (!token)
return
loading.value = true
errorMessage.value = ''
try {
const res = await getMessageDetail({ messageId: id })
if (!isCurrentRequest(seq, token))
return
if (!res.message)
throw new Error('消息不存在或已撤回')
detail.value = res.message
if (Number(res.message?.isRead) === 0) {
detail.value = { ...res.message, isRead: 1 }
readMessages({ ids: [id] }).catch(() => undefined)
if (Number(res.message.isRead) === 0) {
try {
await readMessages({ ids: [id] })
if (!isCurrentRequest(seq, token))
return
detail.value = { ...res.message, isRead: 1 }
}
catch {
if (!isCurrentRequest(seq, token))
return
uni.showToast({ title: '自动标记失败,可手动标记已读', icon: 'none' })
}
}
void useMessageStore().refreshUnreadCount()
}
catch (error) {
if (!isCurrentRequest(seq, token))
return
errorMessage.value = getErrorMessage(error, '消息详情加载失败,请稍后重试')
}
finally {
loading.value = false
if (isCurrentRequest(seq, token))
loading.value = false
}
}
async function handleMarkRead() {
const id = Number(detail.value?.messageId || detail.value?.id || 0)
if (!id)
if (!id || marking.value)
return
if (Number(detail.value?.isRead) === 1) {
uni.showToast({ title: '当前消息已读', icon: 'none' })
return
}
const seq = detailRequestSeq
const token = tokenStore.validToken
if (!isCurrentRequest(seq, token))
return
marking.value = true
try {
await readMessages({ ids: [id] })
if (!isCurrentRequest(seq, token))
return
detail.value = { ...detail.value!, isRead: 1 }
void useMessageStore().refreshUnreadCount()
uni.showToast({ title: '已标记已读', icon: 'success' })
}
catch (error) {
if (!isCurrentRequest(seq, token))
return
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
}
finally {
marking.value = false
if (isCurrentRequest(seq, token))
marking.value = false
}
}
@@ -106,7 +159,7 @@ function formatTime(value: string) {
return String(value)
.replace('T', ' ')
.replace(/\.\d+.*$/, '')
.replace(/\.\d.*$/, '')
.slice(0, 16)
}
@@ -127,128 +180,71 @@ function getErrorMessage(error: unknown, fallback: string) {
}
onLoad((options: Record<string, string | undefined>) => {
void loadDetail(Number(options?.messageId || 0))
messageId.value = Number(options?.messageId || 0)
void loadDetail(messageId.value)
})
</script>
<template>
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-16rpx">
<view v-if="loading" class="card-base flex items-center justify-center py-72rpx">
<wd-loading />
</view>
<view v-else-if="errorMessage" class="card-base flex flex-col items-center gap-20rpx py-48rpx">
<text class="text-26rpx text-muted">{{ errorMessage }}</text>
</view>
<template v-else-if="detail">
<view class="card-base overflow-hidden">
<view class="detail-row">
<text class="detail-label">标题</text>
<text class="detail-value detail-title">{{ detail.title || '未命名消息' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">来源</text>
<text class="detail-value">{{ sourceText }}</text>
</view>
<view class="detail-row">
<text class="detail-label">时间</text>
<text class="detail-value">{{ displayTime }}</text>
</view>
<view class="detail-row">
<text class="detail-label">状态</text>
<wd-tag :type="readText === '已读' ? 'primary' : 'danger'" round>
{{ readText }}
</wd-tag>
</view>
</view>
<view class="card-base p-24rpx">
<view class="mb-18rpx flex items-center gap-12rpx">
<view class="title-bar" />
<text class="text-30rpx text-ink font-900">内容</text>
</view>
<view class="content-body">
<rich-text v-if="canRenderRichText" :nodes="content" />
<text v-else>{{ contentText }}</text>
</view>
<view v-if="relatedCard" class="related-row" @click="copyCard">
<text class="detail-label">关联卡</text>
<text class="mono detail-value">{{ relatedCard }}</text>
</view>
</view>
<view class="grid grid-cols-2 gap-14rpx">
<wd-button type="primary" block :loading="marking" @click="handleMarkRead">
{{ readButtonText }}
</wd-button>
<wd-button type="primary" plain block @click="handleBack">
返回列表
</wd-button>
</view>
</template>
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<view v-if="loading" class="card-base flex flex-col items-center gap-20rpx py-72rpx">
<wd-loading />
<text class="text-24rpx text-muted">正在加载消息详情…</text>
</view>
<view v-else-if="errorMessage" class="card-base flex flex-col items-center gap-24rpx p-32rpx">
<wd-empty tip="暂时无法查看这条消息">
<template #image>
<view class="i-carbon-information text-72rpx text-warning" />
</template>
</wd-empty>
<text class="text-center text-25rpx text-muted leading-38rpx">{{ errorMessage }}</text>
<view class="flex flex-wrap justify-center gap-16rpx">
<wd-button v-if="messageId" type="primary" size="small" @click="loadDetail(messageId)">
重新加载
</wd-button>
<wd-button type="primary" plain size="small" @click="handleBack">
返回列表
</wd-button>
</view>
</view>
<template v-else-if="detail">
<view class="card-base flex flex-col gap-20rpx p-28rpx">
<view class="flex flex-wrap items-center gap-12rpx">
<wd-tag type="primary" plain>
{{ sourceText }}
</wd-tag>
<wd-tag :type="readText === '已读' ? 'success' : 'warning'" round>
{{ readText }}
</wd-tag>
</view>
<text class="break-words text-34rpx text-ink font-bold leading-48rpx">{{ detail.title || '未命名消息' }}</text>
<view class="flex items-center gap-10rpx text-24rpx text-muted">
<view class="i-carbon-time" />
<text>{{ displayTime }}</text>
</view>
</view>
<view class="card-base p-28rpx">
<text class="mb-24rpx block text-28rpx text-ink font-bold">消息内容</text>
<view class="overflow-hidden break-words text-28rpx text-body leading-46rpx">
<rich-text v-if="canRenderRichText" :nodes="content" />
<text v-else selectable class="whitespace-pre-wrap">{{ contentText }}</text>
</view>
</view>
<wd-cell-group v-if="relatedCard" border>
<wd-cell title="关联卡号" label="点击复制卡号,方便查询或联系服务人员。" is-link @click="copyCard">
<text class="break-all text-24rpx font-mono">{{ relatedCard }}</text>
</wd-cell>
</wd-cell-group>
<view class="flex flex-col gap-16rpx">
<wd-button v-if="readText !== '已读'" type="primary" block :loading="marking" :disabled="marking" @click="handleMarkRead">
{{ readButtonText }}
</wd-button>
<wd-button type="primary" plain block @click="handleBack">
返回消息列表
</wd-button>
</view>
</template>
</view>
</view>
</template>
<style lang="scss" scoped>
.detail-row {
display: flex;
align-items: center;
gap: 18rpx;
min-height: 84rpx;
padding: 0 24rpx;
border-bottom: 1px solid #f1f5f9;
}
.detail-row:last-child {
border-bottom: 0;
}
.detail-label {
width: 96rpx;
flex-shrink: 0;
color: #64748b;
font-size: 24rpx;
}
.detail-value {
min-width: 0;
flex: 1;
color: #334155;
font-size: 26rpx;
line-height: 38rpx;
text-align: right;
word-break: break-all;
}
.detail-title {
color: #111827;
font-weight: 900;
}
.title-bar {
width: 6rpx;
height: 28rpx;
flex-shrink: 0;
border-radius: 999rpx;
background: #2563eb;
}
.content-body {
color: #334155;
font-size: 26rpx;
line-height: 46rpx;
word-break: break-all;
}
.related-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 20rpx;
padding-top: 18rpx;
border-top: 1px solid #f1f5f9;
}
</style>
+121 -125
View File
@@ -2,6 +2,8 @@
import type { AppMessage } from '@/api/message'
import { getMessageDetail, readMessages } from '@/api/message'
import { stripRichText } from '../../data'
import { useMessageStore } from '@/store/message'
import { useTokenStore } from '@/store/token'
definePage({
style: {
@@ -9,11 +11,31 @@ definePage({
},
})
const tokenStore = useTokenStore()
let detailRequestSeq = 0
function isCurrentRequest(seq: number, token: string) {
return seq === detailRequestSeq && Boolean(token) && tokenStore.validToken === token
}
const messageId = ref(0)
const loading = ref(false)
const marking = ref(false)
const errorMessage = ref('')
const detail = ref<AppMessage | null>(null)
watch(() => tokenStore.validToken, () => {
detailRequestSeq++
detail.value = null
errorMessage.value = ''
loading.value = false
marking.value = false
}, { flush: 'sync' })
onUnload(() => {
detailRequestSeq++
})
const content = computed(() => detail.value?.content || '')
const contentText = computed(() => stripRichText(content.value) || '暂无消息详细内容')
const canRenderRichText = computed(() => /<[^>]+>/.test(content.value))
@@ -29,44 +51,75 @@ async function loadDetail(id: number) {
return
}
const seq = ++detailRequestSeq
const token = tokenStore.validToken
if (!token)
return
loading.value = true
errorMessage.value = ''
try {
const res = await getMessageDetail({ messageId: id })
if (!isCurrentRequest(seq, token))
return
if (!res.message)
throw new Error('消息不存在或已撤回')
detail.value = res.message
if (Number(res.message?.isRead) === 0) {
detail.value = { ...res.message, isRead: 1 }
readMessages({ ids: [id] }).catch(() => undefined)
if (Number(res.message.isRead) === 0) {
try {
await readMessages({ ids: [id] })
if (!isCurrentRequest(seq, token))
return
detail.value = { ...res.message, isRead: 1 }
}
catch {
if (!isCurrentRequest(seq, token))
return
uni.showToast({ title: '自动标记失败,可手动标记已读', icon: 'none' })
}
}
void useMessageStore().refreshUnreadCount()
}
catch (error) {
if (!isCurrentRequest(seq, token))
return
errorMessage.value = getErrorMessage(error, '消息详情加载失败,请稍后重试')
}
finally {
loading.value = false
if (isCurrentRequest(seq, token))
loading.value = false
}
}
async function handleMarkRead() {
const id = Number(detail.value?.messageId || detail.value?.id || 0)
if (!id)
if (!id || marking.value)
return
if (Number(detail.value?.isRead) === 1) {
uni.showToast({ title: '当前消息已读', icon: 'none' })
return
}
const seq = detailRequestSeq
const token = tokenStore.validToken
if (!isCurrentRequest(seq, token))
return
marking.value = true
try {
await readMessages({ ids: [id] })
if (!isCurrentRequest(seq, token))
return
detail.value = { ...detail.value!, isRead: 1 }
void useMessageStore().refreshUnreadCount()
uni.showToast({ title: '已标记已读', icon: 'success' })
}
catch (error) {
if (!isCurrentRequest(seq, token))
return
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
}
finally {
marking.value = false
if (isCurrentRequest(seq, token))
marking.value = false
}
}
@@ -106,7 +159,7 @@ function formatTime(value: string) {
return String(value)
.replace('T', ' ')
.replace(/\.\d+.*$/, '')
.replace(/\.\d.*$/, '')
.slice(0, 16)
}
@@ -127,128 +180,71 @@ function getErrorMessage(error: unknown, fallback: string) {
}
onLoad((options: Record<string, string | undefined>) => {
void loadDetail(Number(options?.messageId || 0))
messageId.value = Number(options?.messageId || 0)
void loadDetail(messageId.value)
})
</script>
<template>
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-16rpx">
<view v-if="loading" class="card-base flex items-center justify-center py-72rpx">
<wd-loading />
</view>
<view v-else-if="errorMessage" class="card-base flex flex-col items-center gap-20rpx py-48rpx">
<text class="text-26rpx text-muted">{{ errorMessage }}</text>
</view>
<template v-else-if="detail">
<view class="card-base overflow-hidden">
<view class="detail-row">
<text class="detail-label">标题</text>
<text class="detail-value detail-title">{{ detail.title || '未命名消息' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">来源</text>
<text class="detail-value">{{ sourceText }}</text>
</view>
<view class="detail-row">
<text class="detail-label">时间</text>
<text class="detail-value">{{ displayTime }}</text>
</view>
<view class="detail-row">
<text class="detail-label">状态</text>
<wd-tag :type="readText === '已读' ? 'primary' : 'danger'" round>
{{ readText }}
</wd-tag>
</view>
</view>
<view class="card-base p-24rpx">
<view class="mb-18rpx flex items-center gap-12rpx">
<view class="title-bar" />
<text class="text-30rpx text-ink font-900">内容</text>
</view>
<view class="content-body">
<rich-text v-if="canRenderRichText" :nodes="content" />
<text v-else>{{ contentText }}</text>
</view>
<view v-if="relatedCard" class="related-row" @click="copyCard">
<text class="detail-label">关联卡</text>
<text class="mono detail-value">{{ relatedCard }}</text>
</view>
</view>
<view class="grid grid-cols-2 gap-14rpx">
<wd-button type="primary" block :loading="marking" @click="handleMarkRead">
{{ readButtonText }}
</wd-button>
<wd-button type="primary" plain block @click="handleBack">
返回列表
</wd-button>
</view>
</template>
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<view v-if="loading" class="card-base flex flex-col items-center gap-20rpx py-72rpx">
<wd-loading />
<text class="text-24rpx text-muted">正在加载消息详情…</text>
</view>
<view v-else-if="errorMessage" class="card-base flex flex-col items-center gap-24rpx p-32rpx">
<wd-empty tip="暂时无法查看这条消息">
<template #image>
<view class="i-carbon-information text-72rpx text-warning" />
</template>
</wd-empty>
<text class="text-center text-25rpx text-muted leading-38rpx">{{ errorMessage }}</text>
<view class="flex flex-wrap justify-center gap-16rpx">
<wd-button v-if="messageId" type="primary" size="small" @click="loadDetail(messageId)">
重新加载
</wd-button>
<wd-button type="primary" plain size="small" @click="handleBack">
返回列表
</wd-button>
</view>
</view>
<template v-else-if="detail">
<view class="card-base flex flex-col gap-20rpx p-28rpx">
<view class="flex flex-wrap items-center gap-12rpx">
<wd-tag type="primary" plain>
{{ sourceText }}
</wd-tag>
<wd-tag :type="readText === '已读' ? 'success' : 'warning'" round>
{{ readText }}
</wd-tag>
</view>
<text class="break-words text-34rpx text-ink font-bold leading-48rpx">{{ detail.title || '未命名消息' }}</text>
<view class="flex items-center gap-10rpx text-24rpx text-muted">
<view class="i-carbon-time" />
<text>{{ displayTime }}</text>
</view>
</view>
<view class="card-base p-28rpx">
<text class="mb-24rpx block text-28rpx text-ink font-bold">消息内容</text>
<view class="overflow-hidden break-words text-28rpx text-body leading-46rpx">
<rich-text v-if="canRenderRichText" :nodes="content" />
<text v-else selectable class="whitespace-pre-wrap">{{ contentText }}</text>
</view>
</view>
<wd-cell-group v-if="relatedCard" border>
<wd-cell title="关联卡号" label="点击复制卡号,方便查询或联系服务人员。" is-link @click="copyCard">
<text class="break-all text-24rpx font-mono">{{ relatedCard }}</text>
</wd-cell>
</wd-cell-group>
<view class="flex flex-col gap-16rpx">
<wd-button v-if="readText !== '已读'" type="primary" block :loading="marking" :disabled="marking" @click="handleMarkRead">
{{ readButtonText }}
</wd-button>
<wd-button type="primary" plain block @click="handleBack">
返回消息列表
</wd-button>
</view>
</template>
</view>
</view>
</template>
<style lang="scss" scoped>
.detail-row {
display: flex;
align-items: center;
gap: 18rpx;
min-height: 84rpx;
padding: 0 24rpx;
border-bottom: 1px solid #f1f5f9;
}
.detail-row:last-child {
border-bottom: 0;
}
.detail-label {
width: 96rpx;
flex-shrink: 0;
color: #64748b;
font-size: 24rpx;
}
.detail-value {
min-width: 0;
flex: 1;
color: #334155;
font-size: 26rpx;
line-height: 38rpx;
text-align: right;
word-break: break-all;
}
.detail-title {
color: #111827;
font-weight: 900;
}
.title-bar {
width: 6rpx;
height: 28rpx;
flex-shrink: 0;
border-radius: 999rpx;
background: #2563eb;
}
.content-body {
color: #334155;
font-size: 26rpx;
line-height: 46rpx;
word-break: break-all;
}
.related-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 20rpx;
padding-top: 18rpx;
border-top: 1px solid #f1f5f9;
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
title: string
cells: { title: string, desc: string, icon: string }[]
}>()
const emit = defineEmits<{
action: [index: number]
}>()
</script>
<template>
<wd-card custom-class="!mx-0 !mb-0" custom-title-class="!p-0" custom-content-class="!p-0">
<template #title>
<view class="h-88rpx flex items-center gap-14rpx px-28rpx">
<view class="h-30rpx w-6rpx rounded-full bg-#2563eb" />
<text class="text-30rpx text-#0f172a font-800">{{ title }}</text>
</view>
</template>
<wd-cell-group>
<wd-cell
v-for="(cell, index) in cells"
:key="cell.title"
:title="cell.title"
:label="cell.desc"
title-width="90%"
is-link center
custom-class="!min-h-124rpx"
custom-title-class="!text-27rpx !font-800 !text-#0f172a"
custom-label-class="!text-22rpx !text-#475569"
@click="emit('action', index)"
>
<template #prefix>
<view class="mr-18rpx h-68rpx w-68rpx flex shrink-0 items-center justify-center rounded-16rpx bg-#eff6ff text-#2563eb">
<view class="text-34rpx" :class="cell.icon" />
</view>
</template>
</wd-cell>
</wd-cell-group>
</wd-card>
</template>
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import RouterPath from '@/constant/RouterPath'
import { useMessageStore } from '@/store/message'
import { useUserStore } from '@/store/user'
import {
getAccountRoleLabel,
@@ -10,7 +8,6 @@ import {
} from '@/utils/userIdentity'
const userStore = useUserStore()
const messageStore = useMessageStore()
const userInfo = computed(() => userStore.userInfo)
const workspace = computed(() => getAccountWorkspace(userInfo.value))
@@ -59,33 +56,22 @@ const avatarUrl = computed(() => {
const avatar = String(userInfo.value.avatar || '').trim()
return avatar && !avatar.includes('default-avatar') ? avatar : ''
})
const unreadCount = computed(() => Number(messageStore.unreadCount || 0))
const showUnreadBadge = computed(() => unreadCount.value > 0)
const unreadBadgeText = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)))
const displayName = computed(() => {
if (workspace.value === 'terminal') {
return (
userInfo.value.terminalProfile?.terminalName
|| userInfo.value.nickName
userInfo.value.nickName
|| userInfo.value.nickname
|| userInfo.value.username
|| '未登录用户'
)
}
return (
userInfo.value.enterpriseProfile?.deptName
|| userInfo.value.enterpriseProfile?.fullName
|| userInfo.value.nickName
userInfo.value.nickName
|| userInfo.value.nickname
|| userInfo.value.username
|| '未登录用户'
)
})
function goMessage() {
uni.navigateTo({ url: RouterPath.Message })
}
</script>
<template>
@@ -110,13 +96,6 @@ function goMessage() {
</view>
<text class="account-sub">{{ workspaceMeta.sub }}</text>
</view>
<view class="message-btn" @click="goMessage">
<view class="i-carbon-notification message-ico" />
<view v-if="showUnreadBadge" class="message-badge">
{{ unreadBadgeText }}
</view>
</view>
</view>
</view>
</template>
@@ -222,39 +201,4 @@ function goMessage() {
text-overflow: ellipsis;
white-space: nowrap;
}
.message-btn {
position: relative;
display: flex;
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid #dbe3ef;
border-radius: 999rpx;
background: #fff;
}
.message-ico {
color: #334155;
font-size: 30rpx;
}
.message-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 28rpx;
height: 28rpx;
padding: 0 6rpx;
border: 2rpx solid #fff;
border-radius: 999rpx;
color: #fff;
background: #e11d48;
font-size: 18rpx;
font-weight: 800;
line-height: 26rpx;
text-align: center;
}
</style>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import UserEntryGroup from './UserEntryGroup.vue'
import RouterPath from '@/constant/RouterPath'
import { isV101HiddenRoute, V101_RELEASE } from '@/constant/release'
import { useMessageStore } from '@/store/message'
import { useUserStore } from '@/store/user'
import { getAccountWorkspace } from '@/utils/userIdentity'
@@ -36,7 +38,14 @@ const commonGroups = computed<ServiceGroup[]>(() => [
layout: 'list',
cells: [
{
title: '账号资料',
title: '消息中心',
desc: unreadCount.value > 0 ? `${String(unreadCount.value)} 条未读消息` : '查看系统通知与预警消息',
icon: 'i-carbon-notification',
tone: 'blue',
path: RouterPath.Message,
},
{
title: '个人资料',
desc: '完善个人与企业信息',
icon: 'i-carbon-user-profile',
tone: 'blue',
@@ -49,13 +58,6 @@ const commonGroups = computed<ServiceGroup[]>(() => [
tone: 'green',
path: RouterPath.UserViewsRealName,
},
{
title: '通知设置',
desc: unreadCount.value > 0 ? `${unreadCount.value} 条未读消息` : '流量与到期提醒',
icon: 'i-carbon-notification',
tone: 'orange',
path: RouterPath.UserViewsNotification,
},
],
},
])
@@ -162,19 +164,25 @@ const workspaceGroups = computed<ServiceGroup[]>(() => {
]
})
const groups = computed(() => [...workspaceGroups.value, ...commonGroups.value])
const groups = computed(() => [...(V101_RELEASE.showUserBusinessEntries ? workspaceGroups.value : []), ...commonGroups.value])
const visibleGroups = computed<ServiceGroup[]>(() =>
groups.value
.map(group => ({
...group,
cells: group.cells.filter(cell =>
!cell.permissionAction || hasUserPermission(userInfo.value, cell.permissionAction),
(!cell.path || !isV101HiddenRoute(cell.path))
&& (!cell.permissionAction || hasUserPermission(userInfo.value, cell.permissionAction)),
),
}))
.filter(group => group.cells.length > 0),
)
function handleCellClick(item: ServiceCell) {
if ((item.path && isV101HiddenRoute(item.path))
|| (item.permissionAction && !hasUserPermission(userInfo.value, item.permissionAction))) {
uni.showToast({ title: '当前入口暂不可用', icon: 'none' })
return
}
if (item.path) {
uni.navigateTo({ url: item.path })
return
@@ -186,236 +194,13 @@ function handleCellClick(item: ServiceCell) {
</script>
<template>
<view class="service-stack">
<view v-for="group in visibleGroups" :key="group.title" class="service-section">
<view class="section-head">
<view class="title-bar" />
<view class="min-w-0 flex-1">
<text class="section-title">{{ group.title }}</text>
<text v-if="group.desc" class="section-desc">{{ group.desc }}</text>
</view>
</view>
<view class="service-list" :class="{ 'service-list--grid': group.layout === 'grid' }">
<view
v-for="cell in group.cells"
:key="cell.title"
class="service-cell"
@click="handleCellClick(cell)"
>
<view class="service-icon" :class="`service-icon--${cell.tone}`">
<view class="service-icon__glyph" :class="cell.icon" />
</view>
<view class="service-main">
<text class="service-title">{{ cell.title }}</text>
<text class="service-desc">{{ cell.desc }}</text>
</view>
<view class="i-carbon-chevron-right service-arrow" />
</view>
</view>
</view>
<view class="flex flex-col gap-12rpx">
<UserEntryGroup
v-for="group in visibleGroups"
:key="group.title"
:title="group.title"
:cells="group.cells"
@action="handleCellClick(group.cells[$event])"
/>
</view>
</template>
<style lang="scss" scoped>
.service-stack {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.service-section {
box-sizing: border-box;
overflow: hidden;
padding: 24rpx 26rpx 0;
border: 1px solid var(--app-line);
border-radius: 16rpx;
background: #fff;
box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06);
}
.section-head {
display: flex;
align-items: center;
gap: 12rpx;
padding-bottom: 18rpx;
border-bottom: 1px solid var(--app-line);
}
.title-bar {
width: 6rpx;
height: 30rpx;
flex-shrink: 0;
border-radius: 999rpx;
background: linear-gradient(180deg, #2563eb, #0b2bac);
}
.section-title {
color: #0f172a;
font-size: 30rpx;
font-weight: 800;
line-height: 40rpx;
}
.section-desc {
display: block;
margin-top: 2rpx;
color: #94a3b8;
font-size: 21rpx;
line-height: 30rpx;
}
.service-list {
display: flex;
flex-direction: column;
}
.service-list--grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8rpx;
padding: 18rpx 0 20rpx;
}
.service-cell {
display: flex;
align-items: center;
gap: 16rpx;
min-width: 0;
min-height: 98rpx;
border-top: 1px solid #edf2f7;
}
.service-list--grid .service-cell {
min-height: 116rpx;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.service-list:not(.service-list--grid) .service-cell:first-child {
border-top: 0;
}
.service-icon {
display: flex;
width: 68rpx;
height: 68rpx;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 18rpx;
}
.service-icon__glyph {
font-size: 32rpx;
}
.service-icon--blue {
color: #2563eb;
background: #eff6ff;
}
.service-icon--green {
color: #0f766e;
background: #f0fdfa;
}
.service-icon--orange {
color: #b45309;
background: #fffbeb;
}
.service-icon--purple {
color: #7c3aed;
background: #f5f3ff;
}
.service-icon--cyan {
color: #0ea5e9;
background: #e0f2fe;
}
.service-icon--gray {
color: #64748b;
background: #f1f5f9;
}
.service-list:not(.service-list--grid) .service-icon {
color: #2563eb;
background: #eff6ff;
}
.service-main {
min-width: 0;
flex: 1;
}
.service-title,
.service-desc {
display: block;
}
.service-title {
overflow: hidden;
color: #111827;
font-size: 27rpx;
font-weight: 800;
line-height: 36rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-desc {
overflow: hidden;
margin-top: 4rpx;
color: #64748b;
font-size: 22rpx;
line-height: 30rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-arrow {
flex-shrink: 0;
color: #cbd5e1;
font-size: 24rpx;
}
.service-list--grid .service-cell {
flex-direction: column;
justify-content: center;
gap: 10rpx;
}
.service-list--grid .service-icon {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
}
.service-list--grid .service-icon__glyph {
font-size: 36rpx;
}
.service-list--grid .service-main {
width: 100%;
flex: initial;
text-align: center;
}
.service-list--grid .service-title {
font-size: 22rpx;
font-weight: 700;
line-height: 30rpx;
}
.service-list--grid .service-desc {
display: none;
}
.service-list--grid .service-arrow {
display: none;
}
</style>
@@ -109,8 +109,8 @@ function toneClass(tone: StatItem['tone']) {
.stats-card {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
align-items: stretch;
flex-direction: column;
gap: 24rpx;
padding: 28rpx;
border: 1px solid var(--app-line);
@@ -121,6 +121,7 @@ function toneClass(tone: StatItem['tone']) {
.stats-main {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 6rpx;
@@ -141,15 +142,16 @@ function toneClass(tone: StatItem['tone']) {
.stats-items {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
justify-content: flex-end;
justify-content: space-between;
gap: 14rpx;
max-width: 62%;
width: 100%;
overflow-x: auto;
}
.stat-pill {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6rpx;
min-width: 0;
@@ -158,10 +160,13 @@ function toneClass(tone: StatItem['tone']) {
font-size: 22rpx;
line-height: 30rpx;
white-space: nowrap;
word-break: keep-all;
}
.stat-pill__value {
display: inline-flex;
flex-shrink: 0;
white-space: nowrap;
min-width: 30rpx;
height: 30rpx;
align-items: center;
@@ -173,6 +178,8 @@ function toneClass(tone: StatItem['tone']) {
}
.stat-pill__label {
flex-shrink: 0;
white-space: nowrap;
font-size: 20rpx;
opacity: 0.9;
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import RouterPath from '@/constant/RouterPath'
import UserEntryGroup from './UserEntryGroup.vue'
interface CellGroup {
title: string
@@ -20,7 +21,8 @@ const cellGroups: CellGroup[] = [
{ title: '帮助与反馈', desc: '问题建议与使用反馈', icon: 'i-carbon-chat', path: RouterPath.UserViewsFeedback },
{ title: '联系客服', desc: '在线客服', icon: 'i-carbon-customer-service', path: RouterPath.UserViewsContact },
{ title: '协议与隐私', desc: '服务协议与隐私政策', icon: 'i-carbon-document', path: RouterPath.UserViewsAgreement },
{ title: '关于我们', desc: 'v1.0.0', icon: 'i-carbon-information', path: RouterPath.UserViewsAbout },
{ title: '关于我们', desc: 'v1.0.2', icon: 'i-carbon-information', path: RouterPath.UserViewsAbout },
{ title: '使用手册', desc: '查看小程序操作指南', icon: 'i-carbon-book', path: RouterPath.UserViewsManual },
],
},
]
@@ -31,141 +33,13 @@ function handleCellClick(item: CellItem) {
</script>
<template>
<view class="support-stack">
<view v-for="group in cellGroups" :key="group.title" class="support-section">
<view class="section-head">
<view class="title-bar" />
<text class="section-title">{{ group.title }}</text>
</view>
<view class="menu-list">
<view
v-for="item in group.cells"
:key="item.title"
class="menu-row"
@click="handleCellClick(item)"
>
<view class="menu-icon">
<view class="menu-glyph" :class="item.icon" />
</view>
<view class="menu-main">
<text class="menu-title">{{ item.title }}</text>
<text class="menu-desc">{{ item.desc }}</text>
</view>
<view class="i-carbon-chevron-right menu-chev" />
</view>
</view>
</view>
<view class="flex flex-col gap-12rpx">
<UserEntryGroup
v-for="group in cellGroups"
:key="group.title"
:title="group.title"
:cells="group.cells"
@action="handleCellClick(group.cells[$event])"
/>
</view>
</template>
<style lang="scss" scoped>
.support-stack {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.support-section {
box-sizing: border-box;
overflow: hidden;
padding: 28rpx 30rpx 0;
border: 1px solid var(--app-line);
border-radius: 16rpx;
background: #fff;
box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06);
}
.section-head {
display: flex;
align-items: center;
gap: 12rpx;
padding-bottom: 20rpx;
border-bottom: 1px solid var(--app-line);
}
.title-bar {
width: 6rpx;
height: 30rpx;
flex-shrink: 0;
border-radius: 999rpx;
background: linear-gradient(180deg, #2563eb, #0b2bac);
}
.section-title {
color: #0f172a;
font-size: 30rpx;
font-weight: 800;
line-height: 40rpx;
}
.menu-list {
display: flex;
flex-direction: column;
}
.menu-row {
display: flex;
min-width: 0;
align-items: center;
gap: 18rpx;
min-height: 104rpx;
border-top: 1px solid #dbe3ef;
}
.menu-row:first-child {
border-top: 0;
}
.menu-icon {
display: flex;
width: 68rpx;
height: 68rpx;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 16rpx;
color: #2563eb;
background: #eff6ff;
}
.menu-glyph {
font-size: 34rpx;
}
.menu-main {
min-width: 0;
flex: 1;
}
.menu-title,
.menu-desc {
display: block;
}
.menu-title {
overflow: hidden;
color: #0f172a;
font-size: 27rpx;
font-weight: 800;
line-height: 36rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-desc {
overflow: hidden;
margin-top: 4rpx;
color: #475569;
font-size: 22rpx;
line-height: 30rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-chev {
flex-shrink: 0;
color: #94a3b8;
font-size: 24rpx;
}
</style>
+92
View File
@@ -0,0 +1,92 @@
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 { isV101HiddenRoute, V101_RELEASE } from '@/constant/release'
import * as permission from '@/utils/permission'
import * as identity from '@/utils/userIdentity'
function createHarness(userInfo: Partial<IUserInfoRes>) {
const navigateTo = vi.fn()
const context = createContext({
exports: {},
require: (name: string) => {
if (name === '@/constant/RouterPath')
return { default: RouterPath }
if (name === '@/constant/release')
return { V101_RELEASE }
if (name === '@/utils/permission')
return permission
if (name === '@/utils/userIdentity')
return identity
if (name === '@/store/user')
return { useUserStore: () => ({ userInfo }) }
if (name === '@/store/message')
return { useMessageStore: () => ({ refreshUnreadCount: vi.fn() }) }
if (name === '@/store/token')
return { useTokenStore: () => ({}) }
if (name.startsWith('./components/'))
return {}
throw new Error(`Unexpected import: ${name}`)
},
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
definePage: () => undefined,
onShow: () => undefined,
uni: { navigateTo, showToast: 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('User page script not found')
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
return {
visible: () => runInContext('canUseCustomerManagement.value', context),
workOrderVisible: () => runInContext('canUseWorkOrder.value', context),
openWorkOrder: () => runInContext('openWorkOrder()', context),
open: () => runInContext('openCustomerManagement()', context),
navigateTo,
source,
}
}
describe('客户管理验证入口', () => {
it('工单入口按权限选择列表或直接提交', () => {
for (const [feature, url] of [['workorder:list', RouterPath.BusinessViewsWorkOrder], ['workorder:create', RouterPath.BusinessViewsWorkOrderCreate]]) {
const harness = createHarness({ userId: 12, appAccess: { configured: true, features: [feature] } })
expect(harness.workOrderVisible()).toBe(true)
harness.openWorkOrder()
expect(harness.navigateTo).toHaveBeenCalledWith({ url })
}
const denied = createHarness({ userId: 12, appAccess: { configured: true, features: [] } })
expect(denied.workOrderVisible()).toBe(false)
denied.openWorkOrder()
expect(denied.navigateTo).not.toHaveBeenCalled()
})
it('有客户权限的企业账号可从我的进入客户管理', () => {
const harness = createHarness({ userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } })
expect(harness.visible()).toBe(true)
expect(harness.source).toContain('v-if="canUseCustomerManagement"')
expect(harness.source).toContain('title="客户管理"')
harness.open()
expect(harness.navigateTo).toHaveBeenCalledWith({ url: RouterPath.BusinessViewsCustomer })
})
it('无权限企业和终端账号不显示入口', () => {
expect(createHarness({ userId: 12, userType: 'sys_user', appAccess: { configured: true, features: [] } }).visible()).toBe(false)
expect(createHarness({ userId: 13, userType: 'app_user', appAccess: { features: ['customer:list'] } }).visible()).toBe(false)
})
it('客户与划拨已开放,其他未发布业务继续隐藏', () => {
expect(isV101HiddenRoute(RouterPath.BusinessViewsCustomer)).toBe(false)
expect(isV101HiddenRoute(RouterPath.BusinessViewsCustomerDetail)).toBe(false)
expect(isV101HiddenRoute(RouterPath.BusinessViewsAllocate)).toBe(false)
expect(isV101HiddenRoute('/pages/business/index')).toBe(false)
expect(V101_RELEASE.showBusinessTab).toBe(true)
expect(V101_RELEASE.showUserBusinessEntries).toBe(false)
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
expect(source).toContain('<UserServiceCells />')
expect(source).not.toContain('v-if="V101_RELEASE.showUserBusinessEntries"')
})
})
+87 -4
View File
@@ -4,9 +4,11 @@ import UserServiceCells from './components/UserServiceCells.vue'
import UserStatsCard from './components/UserStatsCard.vue'
import UserSupportCells from './components/UserSupportCells.vue'
import RouterPath from '@/constant/RouterPath'
import { useMessageStore } from '@/store/message'
import { useTokenStore } from '@/store/token'
import { useUserStore } from '@/store/user'
import { useMessageStore } from '@/store/message'
import { hasAnyFeature, hasBatchExecutionLogPermission } from '@/utils/permission'
import { getAccountWorkspace } from '@/utils/userIdentity'
definePage({
excludeLoginPath: true,
@@ -20,11 +22,31 @@ definePage({
const tokenStore = useTokenStore()
const userStore = useUserStore()
const messageStore = useMessageStore()
const canUseWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, ['workorder:list', 'workorder:create']))
function openWorkOrder() {
if (!canUseWorkOrder.value) {
uni.showToast({ title: '暂无工单服务权限', icon: 'none' })
return
}
uni.navigateTo({ url: hasAnyFeature(userStore.userInfo, 'workorder:list')
? RouterPath.BusinessViewsWorkOrder
: RouterPath.BusinessViewsWorkOrderCreate })
}
const canUseCustomerManagement = computed(() => getAccountWorkspace(userStore.userInfo) !== 'terminal' && hasAnyFeature(userStore.userInfo, 'customer:list'))
function openCustomerManagement() {
uni.navigateTo({ url: RouterPath.BusinessViewsCustomer })
}
const canUseExecutionLog = computed(() => hasBatchExecutionLogPermission(userStore.userInfo, 'list'))
function openExecutionLog() {
if (!canUseExecutionLog.value)
return
uni.navigateTo({ url: RouterPath.UserViewsExecutionLog })
}
let loginRedirecting = false
function goLogin() {
if (loginRedirecting) return
if (loginRedirecting)
return
loginRedirecting = true
uni.navigateTo({
url: `${RouterPath.Login}?redirect=${encodeURIComponent(RouterPath.User)}`,
@@ -42,7 +64,7 @@ onShow(() => {
return
}
void userStore.fetchUserInfo().catch(() => undefined)
void messageStore.refreshUnreadCount().catch(() => undefined)
void useMessageStore().refreshUnreadCount()
})
async function handleLogout() {
@@ -57,6 +79,67 @@ async function handleLogout() {
<UserProfileCard />
<view class="user-content">
<UserStatsCard />
<wd-card v-if="canUseCustomerManagement || canUseExecutionLog || canUseWorkOrder" custom-class="!mx-0 !mb-0" custom-title-class="!p-0" custom-content-class="!p-0">
<template #title>
<view class="h-88rpx flex items-center gap-14rpx px-28rpx">
<view class="h-30rpx w-6rpx rounded-full bg-#2563eb" />
<text class="text-30rpx text-#0f172a font-800">业务工作台</text>
</view>
</template>
<wd-cell-group>
<wd-cell
v-if="canUseWorkOrder"
title="工单服务"
title-width="90%"
label="反馈问题、查看进度与确认处理结果"
is-link center
custom-class="!min-h-124rpx"
custom-title-class="!text-27rpx !font-800 !text-#0f172a"
custom-label-class="!text-22rpx !text-#475569"
@click="openWorkOrder"
>
<template #prefix>
<view class="mr-18rpx h-68rpx w-68rpx flex shrink-0 items-center justify-center rounded-16rpx bg-#fff7ed text-#ea580c">
<view class="i-carbon-chat text-34rpx" />
</view>
</template>
</wd-cell>
<wd-cell
v-if="canUseCustomerManagement"
title="客户管理"
title-width="90%"
label="查看授权客户与卡片概览"
is-link center
custom-class="!min-h-124rpx"
custom-title-class="!text-27rpx !font-800 !text-#0f172a"
custom-label-class="!text-22rpx !text-#475569"
@click="openCustomerManagement"
>
<template #prefix>
<view class="mr-18rpx h-68rpx w-68rpx flex shrink-0 items-center justify-center rounded-16rpx bg-#eff6ff text-#2563eb">
<view class="i-carbon-user-multiple text-34rpx" />
</view>
</template>
</wd-cell>
<wd-cell
v-if="canUseExecutionLog"
title="批次执行日志"
title-width="90%"
label="查看批次进度与执行结果"
is-link center
custom-class="!min-h-124rpx"
custom-title-class="!text-27rpx !font-800 !text-#0f172a"
custom-label-class="!text-22rpx !text-#475569"
@click="openExecutionLog"
>
<template #prefix>
<view class="mr-18rpx h-68rpx w-68rpx flex shrink-0 items-center justify-center rounded-16rpx bg-#eff6ff text-#2563eb">
<view class="i-carbon-document text-34rpx" />
</view>
</template>
</wd-cell>
</wd-cell-group>
</wd-card>
<UserServiceCells />
<UserSupportCells />
<view class="logout-wrap">
+1 -1
View File
@@ -11,7 +11,7 @@ definePage({
<wd-card custom-class="!m-0">
<wd-cell-group :border="false">
<wd-cell title="应用名称" value="IOTLCM" />
<wd-cell title="当前版本" value="v1.0.0" />
<wd-cell title="当前版本" value="v1.0.2" />
<wd-cell title="服务定位" label="物联卡用户服务小程序" />
</wd-cell-group>
</wd-card>
+9 -9
View File
@@ -41,7 +41,7 @@ const privacySections: TextSection[] = [
title: '我们如何收集和使用信息',
content: [
'为向你提供物联网卡查询、拍照识别卡号、绑定、套餐办理、预警提醒、卡片排查、工单服务等功能,我们会在必要范围内收集并使用你主动填写、主动上传或业务系统返回的信息。',
'未登录查卡、注册登录、绑定微信、订阅提醒、查看实名资料、提交业务办理或工单时,会分别使用对应场景所需的信息。',
'未登录查卡、注册登录、绑定微信、查看实名资料、提交业务办理或工单时,会分别使用对应场景所需的信息。',
],
},
{
@@ -62,7 +62,7 @@ const privacySections: TextSection[] = [
{
title: '第三方和系统能力',
content: [
'当你主动绑定微信或申请订阅提醒时,小程序会调用微信提供的登录凭证和订阅消息能力,用于完成微信身份绑定和提醒授权。',
'当你主动绑定微信时,小程序会调用微信提供的登录凭证,用于完成微信身份绑定。',
'当你主动上传头像、凭证或反馈材料时,小程序会使用相册或拍摄能力读取你选择的图片或文件。',
'当你使用拍照识别卡号时,小程序会将你主动选择的物联网卡图片提交到本系统服务端临时识别 ICCID;图片仅用于本次识别和查卡,本系统不保存原图。',
],
@@ -70,8 +70,8 @@ const privacySections: TextSection[] = [
{
title: '你的权利',
content: [
'你可以在个人中心查看账号资料、实名状态、微信提醒绑定状态和消息提醒状态。',
'你可以通过关闭微信订阅权限、取消相关业务授权、退出登录等方式撤回相应授权;撤回后可能无法继续使用对应功能,但不影响撤回前基于授权进行处理的合法性。',
'你可以在个人中心查看账号资料、实名状态和微信绑定状态,在消息中心查看站内提醒。',
'你可以通过解除微信绑定、取消相关业务授权、退出登录等方式撤回相应授权;撤回后可能无法继续使用对应功能,但不影响撤回前基于授权进行处理的合法性。',
'如需更正资料、删除信息、注销账号或咨询信息保护问题,可通过“我的-联系客服”与服务人员联系处理。',
],
},
@@ -86,7 +86,7 @@ const privacySections: TextSection[] = [
{
title: '第三方服务与未成年人',
content: [
'微信登录、微信订阅消息、微信支付、相册和拍摄能力由微信相关服务提供;使用这些功能时,相关信息还需遵守微信平台规则和隐私政策。',
'微信登录、微信支付、相册和拍摄能力由微信相关服务提供;使用这些功能时,相关信息还需遵守微信平台规则和隐私政策。',
'图片识别请求可能由本系统委托的模型服务商进行临时处理,仅用于识别卡号;实际委托服务商名称、处理范围和联系方式以微信后台隐私保护指引及本页面最新公示为准。',
'未满十四周岁的未成年人应在监护人同意和指导下使用本小程序;监护人如发现未成年人未经同意使用并提交信息,请联系客服处理。',
],
@@ -104,9 +104,9 @@ const collectionItems: CollectionItem[] = [
{
title: '微信用户标识',
icon: 'i-carbon-link',
fields: '微信登录临时凭证、微信绑定状态、订阅授权结果',
purpose: '用于绑定当前微信,并在你授权后接收流量预警、订单状态、工单回复等提醒。',
scene: '绑定当前微信、申请订阅授权、微信提醒设置。',
fields: '微信登录临时凭证、微信绑定状态',
purpose: '用于绑定当前微信,以便通过微信登录。',
scene: '绑定当前微信、通过微信登录。',
},
{
title: '物联网卡与设备信息',
@@ -321,7 +321,7 @@ function openWechatPrivacyContract() {
<view class="flex items-start gap-14rpx text-24rpx text-muted leading-36rpx">
<view class="i-carbon-information mt-4rpx shrink-0 text-28rpx text-#2563eb" />
<text class="min-w-0 flex-1">
若你不同意相关协议或隐私政策,请停止使用需要登录、绑定、订阅或业务办理的功能。
若你不同意相关协议或隐私政策,请停止使用需要登录、绑定或业务办理的功能。
</text>
</view>
</wd-card>
@@ -0,0 +1,32 @@
export function executionBatchTypeLabel(type: number) {
const labels: Record<number, string> = {
3: '卡片划拨',
4: '批次准备',
101: '卡片续费',
102: '套餐变更',
103: '开机',
104: '停机',
105: '重启',
109: '开通数据服务',
110: '停用数据服务',
}
return labels[type] || '其他批次'
}
export function executionStatusType(status: number) {
if (status === 3)
return 'success'
if (status === 4)
return 'danger'
if (status === 5)
return 'warning'
return 'primary'
}
export function executionDetailStatusType(status: number) {
if (status === 2)
return 'success'
if (status === 3)
return 'danger'
return 'primary'
}
@@ -0,0 +1,235 @@
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 helpers from './data'
import * as permission from '@/utils/permission'
import * as allocateHelpers from '@/pages/business/views/allocate/selection'
function harness(detail = false) {
const userInfo = { userId: 12, accessCodes: ['cpm:common:batchExecutionLog:list', 'cpm:common:batchExecutionLog:summary', 'cpm:common:batchExecutionLog:detailList', 'cpm:iot:tSimCardAllocate:targets', 'cpm:iot:tSimCardAllocate:input:prepare', 'cpm:iot:tSimCardAllocate:batch:submit'], appAccess: { configured: true } }
const list = vi.fn()
const summary = vi.fn()
const details = vi.fn()
const allocateSummary = vi.fn()
const allocateDetails = vi.fn()
const hideCallbacks: (() => void)[] = []
const unloadCallbacks: (() => void)[] = []
const context = createContext({
exports: {},
require: (name: string) => {
if (name === '@/utils/permission')
return permission
if (name === '@/store/user')
return { useUserStore: () => ({ userInfo, fetchUserInfo: vi.fn(async () => userInfo) }) }
if (name === '@/utils/allocateStatus')
return allocateHelpers
if (name === '@/api/executionLog')
return { listExecutionLogs: list, executionSummary: summary, executionDetails: details, allocateSummary, allocateDetails, channelLabels: { console: '控制台', api: '开放API' }, modeLabels: ['未知', '单卡', '批量'], inputLabels: ['选择', '连号', '输入', '文件'], statusLabels: ['已创建', '待处理', '处理中', '成功', '失败', '部分成功'] }
if (name === '@/constant/RouterPath')
return { default: { UserViewsExecutionLogDetail: '/log/detail' } }
if (name.endsWith('/data'))
return helpers
throw new Error(`Unexpected import: ${name}`)
},
ref: (value: unknown) => ({ value }),
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
definePage: () => undefined,
onShow: () => undefined,
onLoad: () => undefined,
onHide: (callback: () => void) => hideCallbacks.push(callback),
onUnload: (callback: () => void) => unloadCallbacks.push(callback),
uni: { navigateTo: vi.fn() },
})
const source = readFileSync(new URL(detail ? '../executionLogDetail/index.vue' : './index.vue', import.meta.url), 'utf8')
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
if (!script)
throw new Error('Log page script not found')
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
return {
userInfo,
list,
summary,
details,
allocateSummary,
allocateDetails,
evaluate: <Result>(expression: string) => runInContext(expression, context) as Result,
hide: () => hideCallbacks.forEach(callback => callback()),
unload: () => unloadCallbacks.forEach(callback => callback()),
}
}
describe('批次日志查询', () => {
it('用户分包不直接导入业务分包,避免小程序详情脚本加载失败', () => {
const source = readFileSync(new URL('../executionLogDetail/index.vue', import.meta.url), 'utf8')
expect(source).not.toContain('@/pages/business/')
expect(source).toContain('from \'@/utils/allocateStatus\'')
})
it('概览失败时仍显示已取得的卡片明细', async () => {
const page = harness(true)
page.evaluate('batchNo.value = \'A\'')
page.summary.mockRejectedValue(new Error('summary unavailable'))
page.details.mockResolvedValue({ items: [{ itemId: 'CARD' }], total: 1 })
await page.evaluate<Promise<void>>('load(true)')
expect(page.evaluate('items.value[0].itemId')).toBe('CARD')
expect(page.evaluate('error.value')).toBe('批次概览加载失败,请刷新重试')
})
it('批次类型与后端编码一致并兼容历史类型', () => {
expect(helpers.executionBatchTypeLabel(3)).toBe('卡片划拨')
expect(helpers.executionBatchTypeLabel(101)).toBe('卡片续费')
expect(helpers.executionBatchTypeLabel(105)).toBe('重启')
expect(helpers.executionBatchTypeLabel(109)).toBe('开通数据服务')
expect(helpers.executionBatchTypeLabel(110)).toBe('停用数据服务')
expect(helpers.executionBatchTypeLabel(999)).toBe('其他批次')
})
it('筛选使用紧凑双列,重置移至顶部操作区', () => {
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
expect(source).toContain('grid grid-cols-2 mt-20rpx gap-12rpx')
expect(source.indexOf('@click="resetFilters"')).toBeLessThan(source.indexOf('v-if="filtersOpen"'))
expect(source).toContain('executionBatchTypeLabel(item.batchType)')
})
it('无列表权限不发请求并清空旧记录', async () => {
const page = harness()
page.userInfo.accessCodes = []
await page.evaluate<Promise<void>>('load(true)')
expect(page.list).not.toHaveBeenCalled()
expect(page.evaluate('items.value.length')).toBe(0)
})
it('概览和明细分别遵守控制台权限', async () => {
const page = harness(true)
page.evaluate('batchNo.value = \'A\'')
page.userInfo.accessCodes = ['cpm:common:batchExecutionLog:summary']
page.summary.mockResolvedValue({ batchNo: 'A' })
await page.evaluate<Promise<void>>('load(true)')
expect(page.summary).toHaveBeenCalledTimes(1)
expect(page.details).not.toHaveBeenCalled()
page.userInfo.accessCodes = ['cpm:common:batchExecutionLog:detailList']
page.details.mockResolvedValue({ items: [{ itemId: '1' }], total: 1 })
await page.evaluate<Promise<void>>('load(true)')
expect(page.summary).toHaveBeenCalledTimes(1)
expect(page.details).toHaveBeenCalledTimes(1)
expect(page.evaluate('summary.value')).toBeUndefined()
})
it('明细入口绑定在原生 view 上,不依赖卡片组件事件', () => {
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
expect(source).toContain('<view class="p-28rpx" @click="details(item)">')
expect(source).not.toContain('custom-content-class="!p-28rpx" @click="details(item)"')
})
it('默认收起筛选,分页使用已提交条件', async () => {
const page = harness()
expect(page.evaluate('filtersOpen.value')).toBe(false)
page.list.mockResolvedValueOnce({ items: [{ batchNo: 'A' }], total: 3 }).mockResolvedValueOnce({ items: [{ batchNo: 'B' }], total: 3 })
page.evaluate('keyword.value = \'原始\'; channel.value = [\'console\']')
await page.evaluate<Promise<void>>('load(true)')
page.evaluate('keyword.value = \'未提交\'; channel.value = [\'api\']')
await page.evaluate<Promise<void>>('load()')
expect(page.list.mock.calls[1][0]).toMatchObject({ page: 2, batchNo: '原始', sourceChannel: 'console' })
})
it('分页失败后重试同一页,保留已加载记录', async () => {
const page = harness()
page.list.mockResolvedValueOnce({ items: [{ batchNo: 'A' }], total: 3 }).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ items: [{ batchNo: 'B' }], total: 3 })
await page.evaluate<Promise<void>>('load(true)')
await page.evaluate<Promise<void>>('load()')
expect(page.evaluate('page.value')).toBe(1)
expect(page.evaluate('items.value.length')).toBe(1)
await page.evaluate<Promise<void>>('load()')
expect(page.list.mock.calls[2][0].page).toBe(2)
})
it('旧请求不能覆盖新搜索结果', async () => {
const page = harness()
let finishOld!: (value: unknown) => void
page.list.mockImplementationOnce(() => new Promise((resolve) => {
finishOld = resolve
})).mockResolvedValueOnce({ items: [{ batchNo: 'NEW' }], total: 1 })
const oldRequest = page.evaluate<Promise<void>>('load(true)')
await page.evaluate<Promise<void>>('load(true)')
finishOld({ items: [{ batchNo: 'OLD' }], total: 1 })
await oldRequest
expect(page.evaluate('items.value[0].batchNo')).toBe('NEW')
})
it('隐藏和卸载后忽略未完成请求', async () => {
const page = harness()
let finish!: (value: unknown) => void
page.list.mockImplementationOnce(() => new Promise((resolve) => {
finish = resolve
}))
const request = page.evaluate<Promise<void>>('load(true)')
page.hide()
expect(page.evaluate('loading.value')).toBe(false)
page.unload()
finish({ items: [{ batchNo: 'A' }], total: 1 })
await request
await page.evaluate<Promise<void>>('load(true)')
expect(page.evaluate('items.value.length')).toBe(0)
expect(page.list).toHaveBeenCalledTimes(1)
})
it('详情分页保留已提交的失败筛选', async () => {
const page = harness(true)
page.evaluate('batchNo.value = \'A\'; failures.value = true')
page.summary.mockResolvedValue({ batchNo: 'A' })
page.details.mockResolvedValueOnce({ items: [{ itemId: '1' }], total: 3 }).mockResolvedValueOnce({ items: [{ itemId: '2' }], total: 3 })
await page.evaluate<Promise<void>>('load(true)')
page.evaluate('failures.value = false')
await page.evaluate<Promise<void>>('load()')
expect(page.details.mock.calls[1][0]).toMatchObject({ page: 2, detailStatus: 3 })
expect(page.summary).toHaveBeenCalledTimes(1)
expect(page.evaluate('infoOpen.value')).toBe(false)
})
it('明细请求不等待概览接口完成', async () => {
const page = harness(true)
page.evaluate('batchNo.value = \'A\'')
let resolveSummary!: (value: { batchNo: string }) => void
page.summary.mockReturnValue(new Promise((resolve) => {
resolveSummary = resolve
}))
page.details.mockResolvedValue({ items: [{ itemId: '1' }], total: 1 })
const loading = page.evaluate<Promise<void>>('load(true)')
expect(page.details).toHaveBeenCalledTimes(1)
resolveSummary({ batchNo: 'A' })
await loading
expect(page.evaluate('items.value.length')).toBe(1)
})
it('日志入口识别划拨语义,但不切换日志权限接口', async () => {
const page = harness(true)
page.evaluate('batchNo.value = \'A\'')
page.summary.mockResolvedValue({ batchNo: 'A', batchType: 3, batchAsyncStatus: 2 })
page.details.mockResolvedValue({ items: [{ itemId: '1', detailStatus: 2 }], total: 1 })
await page.evaluate<Promise<void>>('load(true)')
expect(page.evaluate('isAllocateTask.value')).toBe(true)
expect(allocateHelpers.allocateDetailStatus(page.evaluate<number>('items.value[0].detailStatus'), page.evaluate<number>('summary.value.batchAsyncStatus'))).toBe('校验通过 · 待整批完成')
expect(page.allocateSummary).not.toHaveBeenCalled()
expect(page.allocateDetails).not.toHaveBeenCalled()
})
it('划拨任务入口继续使用专用结果接口', async () => {
const page = harness(true)
page.evaluate('batchNo.value = \'A\'; allocateScene.value = true')
page.allocateSummary.mockResolvedValue({ batchNo: 'A', batchType: 3, batchAsyncStatus: 3 })
page.allocateDetails.mockResolvedValue({ items: [{ itemId: '1', detailStatus: 2 }], total: 1 })
await page.evaluate<Promise<void>>('load(true)')
expect(page.allocateSummary).toHaveBeenCalledWith({ batchNo: 'A' })
expect(page.summary).not.toHaveBeenCalled()
expect(page.details).not.toHaveBeenCalled()
expect(allocateHelpers.allocateDetailStatus(page.evaluate<number>('items.value[0].detailStatus'), page.evaluate<number>('summary.value.batchAsyncStatus'))).toBe('已划拨')
})
it('批次和明细状态使用各自的编码', () => {
expect(helpers.executionStatusType(3)).toBe('success')
expect(helpers.executionStatusType(4)).toBe('danger')
expect(helpers.executionStatusType(5)).toBe('warning')
expect(helpers.executionDetailStatusType(2)).toBe('success')
expect(helpers.executionDetailStatusType(3)).toBe('danger')
})
})
@@ -0,0 +1,223 @@
<script setup lang="ts">
import type { ExecutionLog } from '@/api/executionLog'
import { allocationUnitName, channelLabels, inputLabels, listExecutionLogs, modeLabels, receivingUnitName, statusLabels } from '@/api/executionLog'
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
import { hasBatchExecutionLogPermission } from '@/utils/permission'
import { executionBatchTypeLabel, executionStatusType } from './data'
definePage({ style: { navigationBarTitleText: '批次执行日志' } })
const userStore = useUserStore()
const canViewDetails = computed(() => hasBatchExecutionLogPermission(userStore.userInfo, 'summary') || hasBatchExecutionLogPermission(userStore.userInfo, 'detailList'))
const items = ref<ExecutionLog[]>([])
const keyword = ref('')
const channel = ref<string[]>([''])
const mode = ref<number[]>([-1])
const input = ref<number[]>([-1])
const status = ref<number[]>([-1])
const filtersOpen = ref(false)
const channelOpen = ref(false)
const modeOpen = ref(false)
const inputOpen = ref(false)
const statusOpen = ref(false)
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const error = ref('')
let revision = 0
let disposed = false
const channels = [{ label: '全部渠道', value: '' }, ...Object.entries(channelLabels).map(([value, label]) => ({ value, label }))]
const modes = [{ label: '全部操作', value: -1 }, ...modeLabels.map((label, value) => ({ label, value }))]
const inputs = [{ label: '全部方式', value: -1 }, ...inputLabels.map((label, value) => ({ label, value }))]
const statuses = [{ label: '全部状态', value: -1 }, ...statusLabels.map((label, value) => ({ label, value }))]
function buildQuery() {
return {
batchNo: keyword.value.trim() || undefined,
sourceChannel: channel.value[0] || undefined,
executionMode: mode.value[0] < 0 ? undefined : mode.value[0],
sourceType: input.value[0] < 0 ? undefined : input.value[0],
batchAsyncStatus: status.value[0] < 0 ? undefined : status.value[0],
}
}
let submittedQuery = buildQuery()
const filterCount = computed(() => [channel.value[0] !== '', mode.value[0] >= 0, input.value[0] >= 0, status.value[0] >= 0].filter(Boolean).length)
const appliedFilterText = ref('')
function filterText() {
return [channel.value[0] ? channelLabels[channel.value[0]] : '', mode.value[0] >= 0 ? modeLabels[mode.value[0]] : '', input.value[0] >= 0 ? inputLabels[input.value[0]] : '', status.value[0] >= 0 ? statusLabels[status.value[0]] : ''].filter(Boolean).join(' · ')
}
async function load(reset = false) {
if (disposed || (!reset && (loading.value || items.value.length >= total.value)))
return
if (!hasBatchExecutionLogPermission(userStore.userInfo, 'list')) {
items.value = []
total.value = 0
error.value = '暂无批次日志查看权限'
return
}
const request = ++revision
const nextPage = reset ? 1 : page.value + 1
if (reset) {
submittedQuery = buildQuery()
appliedFilterText.value = filterText()
page.value = 1
items.value = []
total.value = 0
}
loading.value = true
error.value = ''
try {
const res = await listExecutionLogs({ ...submittedQuery, page: nextPage, pageSize: 20 })
if (request !== revision)
return
items.value = reset ? res.items : [...items.value, ...res.items]
total.value = res.total
page.value = nextPage
}
catch {
if (request === revision)
error.value = '日志加载失败,请重试'
}
finally {
if (request === revision)
loading.value = false
}
}
function resetFilters() {
channel.value = ['']
mode.value = [-1]
input.value = [-1]
status.value = [-1]
void load(true)
}
function details(item: ExecutionLog) {
if (!canViewDetails.value)
return
uni.navigateTo({ url: `${RouterPath.UserViewsExecutionLogDetail}?batchNo=${encodeURIComponent(item.batchNo)}` })
}
onShow(async () => {
try {
await userStore.fetchUserInfo()
}
catch {
items.value = []
total.value = 0
error.value = '权限获取失败,请重试'
return
}
if (!disposed)
await load(true)
})
onHide(() => {
revision++
loading.value = false
})
onUnload(() => {
disposed = true
revision++
})
</script>
<template>
<view class="min-h-screen bg-#f6f8fb p-24rpx pb-40rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-24rpx">
<wd-search v-model="keyword" placeholder="搜索批次编号" variant="filled" placeholder-left hide-cancel custom-class="!p-0" @search="load(true)" @clear="load(true)" />
<view class="mt-16rpx flex items-center justify-between gap-12rpx">
<text class="text-23rpx text-#64748b">共 {{ total }} 条执行记录</text>
<view class="flex items-center gap-8rpx">
<wd-button v-if="filterCount" variant="text" size="small" custom-class="!px-8rpx !text-24rpx !font-500" @click="resetFilters">
重置
</wd-button>
<wd-button variant="text" size="small" icon="filter" custom-class="!px-12rpx !text-24rpx !font-500" @click="filtersOpen = !filtersOpen">
{{ filtersOpen ? '收起筛选' : '筛选' }}{{ filterCount ? ` (${filterCount})` : '' }}
</wd-button>
<wd-button variant="text" size="small" icon="refresh" custom-class="!px-12rpx !text-24rpx !font-500" :loading="loading" @click="load(true)">
刷新
</wd-button>
</view>
</view>
<text v-if="appliedFilterText" class="mt-8rpx block text-22rpx text-#2563eb">{{ appliedFilterText }}</text>
<view v-if="filtersOpen" class="grid grid-cols-2 mt-20rpx gap-12rpx">
<view class="min-w-0 rounded-12rpx bg-#f6f8fb p-18rpx" @click="channelOpen = true">
<text class="block text-22rpx text-#64748b">发起渠道</text>
<view class="mt-10rpx flex items-center justify-between gap-8rpx text-25rpx text-#334155">
<text class="min-w-0 truncate">{{ channelLabels[channel[0]] || '全部渠道' }}</text>
<wd-icon name="arrow-down" size="14" />
</view>
</view>
<view class="min-w-0 rounded-12rpx bg-#f6f8fb p-18rpx" @click="modeOpen = true">
<text class="block text-22rpx text-#64748b">操作规模</text>
<view class="mt-10rpx flex items-center justify-between gap-8rpx text-25rpx text-#334155">
<text class="min-w-0 truncate">{{ modeLabels[mode[0]] || '全部操作' }}</text>
<wd-icon name="arrow-down" size="14" />
</view>
</view>
<view class="min-w-0 rounded-12rpx bg-#f6f8fb p-18rpx" @click="inputOpen = true">
<text class="block text-22rpx text-#64748b">输入方式</text>
<view class="mt-10rpx flex items-center justify-between gap-8rpx text-25rpx text-#334155">
<text class="min-w-0 truncate">{{ inputLabels[input[0]] || '全部方式' }}</text>
<wd-icon name="arrow-down" size="14" />
</view>
</view>
<view class="min-w-0 rounded-12rpx bg-#f6f8fb p-18rpx" @click="statusOpen = true">
<text class="block text-22rpx text-#64748b">执行状态</text>
<view class="mt-10rpx flex items-center justify-between gap-8rpx text-25rpx text-#334155">
<text class="min-w-0 truncate">{{ statusLabels[status[0]] || '全部状态' }}</text>
<wd-icon name="arrow-down" size="14" />
</view>
</view>
</view>
</wd-card>
<view v-if="error" class="flex items-center justify-between gap-12rpx rounded-16rpx bg-#fff1f2 p-24rpx">
<text class="text-24rpx text-#b91c1c">{{ error }}</text>
<wd-button size="small" variant="text" @click="load(items.length === 0)">
重试
</wd-button>
</view>
<wd-card v-if="loading && !items.length" custom-class="!mx-0 !mb-0">
<wd-skeleton theme="paragraph" />
</wd-card>
<wd-empty v-else-if="!error && !items.length" tip="暂无执行记录" />
<wd-card v-for="item in items" :key="item.batchNo" custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
<view class="p-28rpx" @click="details(item)">
<view class="flex items-center justify-between gap-16rpx">
<text class="min-w-0 truncate text-27rpx text-#0f172a font-800">{{ item.batchNo }}</text>
<wd-tag :type="executionStatusType(item.batchAsyncStatus)" variant="light" round custom-class="!shrink-0 !whitespace-nowrap">
{{ statusLabels[item.batchAsyncStatus] || '未知状态' }}
</wd-tag>
</view>
<view class="mt-18rpx flex items-center justify-between gap-12rpx">
<text class="text-25rpx text-#334155 font-700">{{ executionBatchTypeLabel(item.batchType) }}</text>
</view>
<view class="mt-12rpx flex items-center justify-between gap-12rpx">
<text class="text-24rpx text-#64748b">{{ channelLabels[item.sourceChannel] || '历史未知' }} · {{ modeLabels[item.executionMode] || '未知操作' }}</text>
<text class="shrink-0 text-25rpx text-#0f172a font-700">{{ item.totalCount }} 张卡</text>
</view>
<view v-if="item.batchType === 3" class="mt-14rpx flex flex-wrap items-center gap-8rpx text-23rpx text-#64748b">
<text>{{ allocationUnitName(item.sourceDeptName, item.sourceDeptId) }}</text>
<text>→</text>
<text>{{ receivingUnitName(item) }}</text>
</view>
<text v-if="item.failReason" class="line-clamp-2 mt-16rpx block break-all rounded-10rpx bg-#fff1f2 px-16rpx py-12rpx text-22rpx text-#b91c1c leading-34rpx">{{ item.failReason }}</text>
<view class="mt-22rpx flex items-center justify-between gap-12rpx">
<text class="text-22rpx text-#94a3b8">{{ item.createdAt || '时间未记录' }}</text>
<view v-if="canViewDetails" class="flex items-center gap-6rpx text-23rpx text-#2563eb">
<text>查看详情</text><view class="i-carbon-chevron-right text-22rpx" />
</view>
</view>
</view>
</wd-card>
<view v-if="items.length" class="flex justify-center py-8rpx">
<wd-loading v-if="loading" />
<wd-button v-else-if="items.length < total" variant="text" size="small" @click="load()">
加载更多
</wd-button>
<text v-else class="text-22rpx text-#94a3b8">已展示全部记录</text>
</view>
</view>
<wd-picker v-model="channel" v-model:visible="channelOpen" title="发起渠道" :columns="[channels]" @confirm="load(true)" />
<wd-picker v-model="mode" v-model:visible="modeOpen" title="操作规模" :columns="[modes]" @confirm="load(true)" />
<wd-picker v-model="input" v-model:visible="inputOpen" title="输入方式" :columns="[inputs]" @confirm="load(true)" />
<wd-picker v-model="status" v-model:visible="statusOpen" title="执行状态" :columns="[statuses]" @confirm="load(true)" />
</view>
</template>
@@ -0,0 +1,203 @@
<script setup lang="ts">
import type { ExecutionDetail, ExecutionLog } from '@/api/executionLog'
import { allocateDetailStatus, allocateResultStatus, allocateStatusPresentation } from '@/utils/allocateStatus'
import { allocateDetails, allocateSummary, allocationUnitName, channelLabels, detailLabels, executionDetails, executionSummary, inputLabels, modeLabels, receivingUnitName, statusLabels } from '@/api/executionLog'
import { useUserStore } from '@/store/user'
import { hasBatchExecutionLogPermission, hasUserPermission } from '@/utils/permission'
import { executionBatchTypeLabel, executionDetailStatusType, executionStatusType } from '../executionLog/data'
definePage({ style: { navigationBarTitleText: '执行日志详情' } })
const allocateScene = ref(false)
const userStore = useUserStore()
const canViewSummary = computed(() => allocateScene.value ? hasUserPermission(userStore.userInfo, 'allocate') : hasBatchExecutionLogPermission(userStore.userInfo, 'summary'))
const canViewDetails = computed(() => allocateScene.value ? hasUserPermission(userStore.userInfo, 'allocate') : hasBatchExecutionLogPermission(userStore.userInfo, 'detailList'))
const batchNo = ref('')
function copyBatchNo() {
uni.setClipboardData({ data: batchNo.value })
}
const summary = ref<ExecutionLog>()
const items = ref<ExecutionDetail[]>([])
const isAllocateTask = computed(() => allocateScene.value || summary.value?.batchType === 3)
const allocatePresentation = computed(() => allocateStatusPresentation(summary.value ? allocateResultStatus(summary.value.batchAsyncStatus) : undefined))
const page = ref(1)
const total = ref(0)
const failures = ref(false)
const infoOpen = ref(false)
let submittedFailures = false
let disposed = false
const loading = ref(false)
const error = ref('')
let revision = 0
async function load(reset = false) {
if (disposed || (!reset && (loading.value || items.value.length >= total.value)))
return
if (!batchNo.value) {
error.value = '批次号不能为空'
return
}
if (!canViewSummary.value && !canViewDetails.value) {
summary.value = undefined
items.value = []
total.value = 0
error.value = '暂无批次详情查看权限'
return
}
const request = ++revision
const nextPage = reset ? 1 : page.value + 1
if (reset) {
submittedFailures = failures.value
page.value = 1
items.value = []
total.value = 0
summary.value = undefined
}
loading.value = true
error.value = ''
try {
const summaryRequest = canViewSummary.value && (reset || !summary.value)
? (allocateScene.value ? allocateSummary({ batchNo: batchNo.value }) : executionSummary(batchNo.value)).then((info) => {
if (request === revision)
summary.value = info
}).catch(() => {
if (request === revision)
error.value = '批次概览加载失败,请刷新重试'
})
: Promise.resolve()
const detailsRequest = canViewDetails.value ? (allocateScene.value ? allocateDetails : executionDetails)({ batchNo: batchNo.value, page: nextPage, pageSize: 20, detailStatus: submittedFailures ? 3 : undefined }) : Promise.resolve({ items: [], total: 0 })
const [, result] = await Promise.all([summaryRequest, detailsRequest])
if (request !== revision)
return
items.value = reset ? result.items : [...items.value, ...result.items]
page.value = nextPage
total.value = result.total
}
catch {
if (request === revision) {
error.value = '查询失败或无查看权限,请重试'
}
}
finally {
if (request === revision)
loading.value = false
}
}
onLoad((options) => {
batchNo.value = String(options?.batchNo || '')
allocateScene.value = options?.scene === 'allocate'
if (allocateScene.value)
uni.setNavigationBarTitle({ title: '划拨任务详情' })
})
onShow(async () => {
try {
await userStore.fetchUserInfo()
}
catch {
summary.value = undefined
items.value = []
total.value = 0
error.value = '权限获取失败,请重试'
return
}
if (!disposed)
await load(true)
})
onHide(() => {
loading.value = false
revision++
})
onUnload(() => {
disposed = true
revision++
})
</script>
<template>
<view class="min-h-screen bg-#f6f8fb p-24rpx pb-40rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
<wd-card v-if="canViewSummary" custom-class="!mx-0 !mb-0" custom-content-class="!p-28rpx">
<view class="flex items-center justify-between gap-16rpx">
<text class="text-28rpx text-#0f172a font-800">{{ isAllocateTask ? '划拨任务概览' : '批次概览' }}</text>
<wd-tag v-if="summary" :type="isAllocateTask ? allocatePresentation.type : executionStatusType(summary.batchAsyncStatus)" variant="light" round>
{{ isAllocateTask ? allocatePresentation.title : statusLabels[summary.batchAsyncStatus] || '未知状态' }}
</wd-tag>
</view>
<view v-if="isAllocateTask && summary" class="grid mt-16rpx gap-10rpx text-24rpx text-#64748b">
<text>划拨单位:{{ allocationUnitName(summary.sourceDeptName, summary.sourceDeptId) }}</text>
<text>接收单位:{{ receivingUnitName(summary) }}</text>
</view>
<view class="mt-16rpx flex items-center justify-between gap-12rpx">
<text class="shrink-0 text-23rpx text-#94a3b8">任务编号</text>
<text class="min-w-0 flex-1 truncate text-24rpx text-#64748b">{{ batchNo }}</text>
<wd-button icon="copy" size="small" variant="text" custom-class="!shrink-0 !px-8rpx !text-23rpx" @click="copyBatchNo">
复制
</wd-button>
</view>
<template v-if="summary">
<text v-if="isAllocateTask" class="mt-16rpx block text-24rpx text-#64748b leading-36rpx">{{ summary.batchAsyncStatus === 3 ? '卡片已划拨至目标客户。' : allocatePresentation.description }}</text>
<view class="mt-20rpx rounded-12rpx bg-#f8fafc p-20rpx">
<text class="mb-12rpx block text-26rpx text-#0f172a font-700">{{ executionBatchTypeLabel(summary.batchType) }}</text>
<text class="text-36rpx text-#0f172a font-800">{{ summary.totalCount }}</text>
<text class="ml-8rpx text-23rpx text-#64748b">张卡 · {{ channelLabels[summary.sourceChannel] || '历史未知' }} · {{ modeLabels[summary.executionMode] || '未知操作' }}</text>
</view>
<text v-if="summary.failReason" class="mt-16rpx block break-all rounded-10rpx bg-#fff1f2 p-18rpx text-23rpx text-#b91c1c leading-36rpx">{{ summary.failReason }}</text>
<view class="mt-12rpx flex items-center justify-between gap-12rpx">
<text class="text-22rpx text-#94a3b8">{{ summary.createdAt || '时间未记录' }}</text>
<wd-button variant="text" size="small" custom-class="!px-8rpx !text-23rpx" @click="infoOpen = !infoOpen">
{{ infoOpen ? '收起信息' : '批次信息' }}
</wd-button>
</view>
<wd-cell-group v-if="infoOpen">
<wd-cell title="输入方式" :value="summary.sourceType == null ? '未知方式' : inputLabels[summary.sourceType] || '未知方式'" />
<wd-cell title="创建时间" :value="summary.createdAt || '--'" />
<wd-cell title="开始时间" :value="summary.startTime || '--'" />
<wd-cell title="结束时间" :value="summary.endTime || '--'" />
</wd-cell-group>
</template>
<wd-skeleton v-else-if="loading" theme="paragraph" />
</wd-card>
<view v-if="canViewDetails" class="flex items-center justify-between gap-16rpx px-4rpx">
<view class="flex items-center gap-12rpx">
<text class="text-27rpx text-#0f172a font-800">{{ isAllocateTask ? '卡片明细' : '执行明细' }}</text>
<text class="text-22rpx text-#94a3b8">{{ total }} 条</text>
</view>
<view class="flex items-center gap-16rpx">
<wd-checkbox v-model="failures" @change="load(true)">
仅失败
</wd-checkbox>
<wd-button icon="refresh" size="small" variant="text" custom-class="!px-8rpx !text-24rpx !font-500" :loading="loading" @click="load(true)">
刷新
</wd-button>
</view>
</view>
<view v-if="error" class="flex items-center justify-between gap-12rpx rounded-16rpx bg-#fff1f2 p-24rpx">
<text class="text-24rpx text-#b91c1c">{{ error }}</text>
<wd-button variant="text" size="small" @click="load(items.length === 0)">
重试
</wd-button>
</view>
<wd-empty v-if="canViewDetails && !loading && !error && !items.length" :tip="failures ? '暂无失败明细' : '暂无执行明细'" />
<wd-card v-for="(item, index) in items" :key="`${item.itemId}:${index}`" custom-class="!mx-0 !mb-0" custom-content-class="!p-24rpx">
<view class="flex items-center justify-between gap-16rpx">
<text class="text-22rpx text-#94a3b8">第 {{ item.rowNo || index + 1 }} 行</text>
<wd-tag :type="isAllocateTask && item.detailStatus === 2 && summary?.batchAsyncStatus !== 3 ? 'warning' : executionDetailStatusType(item.detailStatus)" variant="light" round>
{{ isAllocateTask ? allocateDetailStatus(item.detailStatus, summary?.batchAsyncStatus) : detailLabels[item.detailStatus] || '未知状态' }}
</wd-tag>
</view>
<text class="mt-12rpx block break-all text-26rpx text-#0f172a font-700">{{ item.itemId }}</text>
<view v-if="isAllocateTask || item.sourceDeptId" class="grid mt-12rpx gap-8rpx text-23rpx text-#64748b">
<text>划拨单位:{{ allocationUnitName(item.sourceDeptName, item.sourceDeptId) }}</text>
<text>接收单位:{{ receivingUnitName(item) }}</text>
</view>
<text v-if="item.errorReason" class="mt-14rpx block break-all rounded-10rpx bg-#fff1f2 px-16rpx py-12rpx text-22rpx text-#b91c1c leading-34rpx">{{ item.errorReason }}</text>
</wd-card>
<view class="flex justify-center py-8rpx">
<wd-loading v-if="loading" />
<wd-button v-else-if="items.length < total" variant="text" size="small" @click="load()">
加载更多
</wd-button>
<text v-else-if="items.length" class="text-22rpx text-#94a3b8">已展示全部明细</text>
</view>
</view>
</view>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
definePage({
style: {
navigationBarTitleText: '使用手册',
},
})
const manualUrl = 'https://example.com/docs/manual/mini-program/'
const failed = ref(false)
const reloadKey = ref(0)
function reloadManual() {
failed.value = false
reloadKey.value += 1
}
</script>
<template>
<!-- #ifdef H5 -->
<view v-if="!failed" class="manual-page">
<iframe :key="reloadKey" class="manual-frame" :src="manualUrl" title="使用手册" />
</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<web-view v-if="!failed" :key="reloadKey" :src="manualUrl" @error="failed = true" />
<!-- #endif -->
<view v-if="failed" class="fixed inset-0 z-10 flex flex-col items-center justify-center gap-16px bg-white px-24px">
<text class="text-16px text-gray-700">手册暂时无法打开</text>
<wd-button size="small" icon="refresh" @click="reloadManual">
重新加载
</wd-button>
</view>
</template>
<style lang="scss" scoped>
.manual-page {
position: fixed;
top: var(--window-top, 44px);
right: 0;
bottom: var(--window-bottom, 0px);
left: 0;
background: #fff;
}
.manual-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
}
</style>
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('站内预警通知入口', () => {
it('提醒页仅展示站内消息,不请求微信一次性订阅或保存旧偏好', () => {
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
expect(source).toContain('站内消息提醒')
expect(source).toContain('RouterPath.Message')
expect(source).not.toContain('requestWechatSubscribeMessage')
expect(source).not.toContain('saveWarningPreference')
expect(source).not.toContain('wd-switch')
})
it('卡片和批量预警页面均保留配置入口,消息跳到站内中心', () => {
const card = readFileSync(new URL('../../../card/views/warning/index.vue', import.meta.url), 'utf8')
const business = readFileSync(new URL('../../../business/views/warning/index.vue', import.meta.url), 'utf8')
for (const source of [card, business]) {
expect(source).toContain('v-model="form.enabled"')
expect(source).toContain('RouterPath.Message')
expect(source).toContain('title="消息中心"')
expect(source).not.toContain('requestWechatSubscribeMessage')
expect(source).not.toContain('微信提醒设置')
}
})
})
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const notification = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
const profile = readFileSync(new URL('../profile/index.vue', import.meta.url), 'utf8')
describe('站内提醒与微信账号绑定职责', () => {
it('旧提醒路由保留站内消息入口,不再显示微信订阅开关', () => {
expect(notification).toContain('navigationBarTitleText: \'消息提醒\'')
expect(notification).toContain('站内消息提醒')
expect(notification).toContain('uni.navigateTo({ url: RouterPath.Message })')
expect(notification).not.toContain('requestWechatSubscribeMessage')
expect(notification).not.toContain('wd-switch')
})
it('个人资料仍可绑定、更换、解绑微信用于登录', () => {
expect(profile).toContain('账号与安全')
expect(profile).toContain('更换微信绑定')
expect(profile).toContain('解除微信绑定')
expect(profile).toContain('await unbindWechat()')
expect(profile).toContain('userStore.setUserInfo(res.userInfo)')
})
})
+6 -392
View File
@@ -1,403 +1,17 @@
<script lang="ts" setup>
import { isMpWeixin } from '@uni-helper/uni-env'
import { bindWechat, getWxCode } from '@/api/login'
import RouterPath from '@/constant/RouterPath'
import { useUserStore } from '@/store/user'
import { getWarningConfigMeta } from '../api/warning'
import { guideWechatSubscribeSettings, requestWechatSubscribeMessage, shouldGuideWechatSubscribeSettings, wechatSubscribeSaveMessage } from '@/utils'
definePage({
style: {
navigationBarTitleText: '微信提醒',
},
})
definePage({ style: { navigationBarTitleText: '消息提醒' } })
interface NotifyRow {
title: string
desc: string
enabled: boolean
status: string
editable: boolean
function goMessageCenter() {
uni.navigateTo({ url: RouterPath.Message })
}
const userStore = useUserStore()
const loading = ref(false)
const binding = ref(false)
const requesting = ref(false)
const templateId = ref('')
const templateReady = ref(false)
const appNotifyEnabled = ref(false)
const wechatBound = ref(false)
const wechatBindText = ref('未绑定')
const bindButtonText = computed(() => (wechatBound.value ? '更换绑定微信' : '绑定当前微信'))
const statusText = computed(() => {
if (!templateReady.value)
return '模板未配置'
if (!appNotifyEnabled.value)
return '预警关闭'
return wechatBound.value ? '已绑定微信' : '待绑定微信'
})
const statusTagType = computed(() => {
if (!templateReady.value)
return 'warning'
if (!appNotifyEnabled.value)
return 'default'
return wechatBound.value ? 'primary' : 'danger'
})
const rows = computed<NotifyRow[]>(() => [
{
title: '流量预警订阅',
desc: '按卡片预警配置触发,开启后会写入消息中心并尝试发送微信订阅消息。',
enabled: appNotifyEnabled.value,
status: appNotifyEnabled.value ? '已开启' : '已关闭',
editable: true,
},
{
title: '订单支付通知',
desc: '续费、变更、加油包等订单状态变化后生成站内通知。',
enabled: true,
status: '默认接收',
editable: false,
},
{
title: '工单回复提醒',
desc: '工单有回复或状态变化时,在消息中心保留通知。',
enabled: true,
status: '默认接收',
editable: false,
},
{
title: '系统公告',
desc: '平台维护、功能调整等公告消息。',
enabled: true,
status: '默认接收',
editable: false,
},
])
async function loadMeta() {
loading.value = true
try {
const [meta] = await Promise.all([
getWarningConfigMeta(),
userStore.fetchUserInfo().catch(() => undefined),
])
templateId.value = meta.templateId || ''
templateReady.value = Boolean(meta.templateReady)
appNotifyEnabled.value = meta.appNotifyEnabled === 1
wechatBound.value = Boolean(meta.wechatBound || userStore.userInfo.wechatBound)
wechatBindText.value
= meta.wechatBindText
|| userStore.userInfo.wechatBindText
|| (wechatBound.value ? '已绑定' : '未绑定')
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '微信提醒状态加载失败'), icon: 'none' })
}
finally {
loading.value = false
}
}
async function handleBindWechat() {
if (!isMpWeixin) {
uni.showToast({ title: '请在微信小程序中绑定', icon: 'none' })
return
}
if (binding.value)
return
const confirmed = await confirmBindWechat()
if (!confirmed)
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
}
}
async function handleRequestSubscribe() {
if (requesting.value)
return
requesting.value = true
try {
const result = await requestWechatSubscribeMessage(templateId.value, templateReady.value)
if (shouldGuideWechatSubscribeSettings(result)) {
guideWechatSubscribeSettings(result)
return
}
uni.showToast({
title: wechatSubscribeSaveMessage(result) || result.message,
icon: result.status === 'accepted' ? 'success' : 'none',
})
}
finally {
requesting.value = false
}
}
function handleNotifyRow(row: NotifyRow) {
if (!row.editable)
return
uni.showModal({
title: '配置流量预警',
content: '小程序提醒需要按卡片保存预警配置。是否前往预警配置页面开启或关闭?',
confirmText: '去配置',
cancelText: '取消',
success: (res) => {
if (res.confirm)
uni.navigateTo({ url: RouterPath.BusinessViewsWarning })
},
})
}
function goWarningConfig() {
uni.navigateTo({ url: RouterPath.BusinessViewsWarning })
}
function confirmBindWechat() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: wechatBound.value ? '更换微信绑定' : '绑定微信提醒',
content: wechatBound.value
? '确认将预警订阅提醒切换到当前微信用户?'
: '绑定当前微信后,可接收流量预警和卡片消息等订阅提醒。',
confirmText: wechatBound.value ? '确认更换' : '立即绑定',
cancelText: '取消',
success: res => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
if (typeof data.errMsg === 'string' && data.errMsg)
return data.errMsg
}
if (error instanceof Error && error.message)
return error.message
return fallback
}
onShow(() => {
void loadMeta()
})
</script>
<template>
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
<view class="mx-auto max-w-920rpx flex flex-col gap-16rpx">
<view v-if="loading" class="card-base flex items-center justify-center py-72rpx">
<wd-loading />
</view>
<template v-else>
<view class="notify-summary card-base">
<view class="notify-summary__icon">
<view class="i-carbon-notification text-38rpx" />
</view>
<view class="min-w-0 flex-1">
<view class="flex items-center gap-12rpx">
<text class="notify-summary__title">微信提醒</text>
<wd-tag :type="statusTagType" size="small" round>
{{ statusText }}
</wd-tag>
</view>
<text class="notify-summary__sub">{{ wechatBindText }},授权后接收重要提醒</text>
</view>
</view>
<view class="notify-card">
<view class="notify-card__title">
提醒项目
</view>
<view
v-for="row in rows"
:key="row.title"
class="notify-row"
@click="handleNotifyRow(row)"
>
<view class="notify-row__icon">
<view
:class="
row.title === '流量预警订阅'
? 'i-carbon-warning-alt'
: row.title === '订单支付通知'
? 'i-carbon-receipt'
: row.title === '工单回复提醒'
? 'i-carbon-chat'
: 'i-carbon-notification'
"
/>
</view>
<view class="min-w-0 flex-1">
<view class="flex items-center gap-10rpx">
<text class="notify-title">{{ row.title }}</text>
<text class="notify-status">{{ row.status }}</text>
</view>
<text class="notify-desc">{{ row.desc }}</text>
</view>
<wd-switch
:model-value="row.enabled"
:disabled="!row.editable"
@change="handleNotifyRow(row)"
/>
</view>
</view>
<view class="notice-banner">
<view class="i-carbon-information mt-4rpx shrink-0 text-28rpx" />
<text class="min-w-0 flex-1">微信订阅需要主动授权,预警配置仍以卡片设置为准。</text>
</view>
<view class="grid gap-12rpx">
<wd-button type="primary" block :loading="binding" @click="handleBindWechat">
{{ bindButtonText }}
</wd-button>
<wd-button
type="primary"
plain
block
:loading="requesting"
@click="handleRequestSubscribe"
>
申请订阅授权
</wd-button>
<wd-button type="primary" plain block @click="goWarningConfig">
管理预警配置
</wd-button>
</view>
</template>
<view class="min-h-screen bg-canvas p-24rpx">
<view class="overflow-hidden rounded-16rpx bg-white">
<wd-cell title="站内消息提醒" label="流量预警等通知将在消息中心展示" value="查看消息" is-link @click="goMessageCenter" />
</view>
</view>
</template>
<style lang="scss" scoped>
.notify-summary {
display: flex;
align-items: center;
gap: 18rpx;
padding: 24rpx;
}
.notify-summary__icon,
.notify-row__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #2563eb;
background: #eff6ff;
}
.notify-summary__icon {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
}
.notify-summary__title {
color: #111827;
font-size: 32rpx;
font-weight: 900;
}
.notify-summary__sub {
display: block;
margin-top: 8rpx;
overflow: hidden;
color: #64748b;
font-size: 23rpx;
line-height: 32rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.notify-card {
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.notify-card__title {
padding: 20rpx 24rpx 12rpx;
color: #111827;
font-size: 27rpx;
font-weight: 900;
}
.notify-row {
display: flex;
align-items: center;
gap: 16rpx;
min-height: 116rpx;
padding: 18rpx 24rpx;
border-top: 1px solid #f1f5f9;
}
.notify-row__icon {
width: 52rpx;
height: 52rpx;
border-radius: 14rpx;
font-size: 28rpx;
}
.notify-title {
color: #111827;
font-size: 26rpx;
font-weight: 800;
line-height: 36rpx;
}
.notify-status {
flex-shrink: 0;
border-radius: 999rpx;
background: #f8fafc;
color: #64748b;
font-size: 20rpx;
font-weight: 700;
line-height: 28rpx;
padding: 3rpx 12rpx;
}
.notify-desc {
display: block;
margin-top: 5rpx;
color: #64748b;
font-size: 21rpx;
line-height: 30rpx;
}
.notice-banner {
display: flex;
gap: 12rpx;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
font-size: 22rpx;
line-height: 32rpx;
padding: 16rpx 18rpx;
}
</style>

Some files were not shown because too many files have changed in this diff Show More