sync: 对齐小程序 v1.0.2 生产代码并脱敏
This commit is contained in:
@@ -30,6 +30,8 @@ iotLCM 物联网卡全生命周期管理平台的**小程序端 + 用户端服
|
|||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
|
将 `server/config/config.example.yaml` 复制为 `server/config/config.yaml` 并填写自己的凭据;使用代码生成前同样将 `server/hack/config.example.yaml` 复制为 `server/hack/config.yaml`。
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
@@ -51,7 +53,7 @@ pnpm dev:mp-weixin # 微信小程序,产物用微信开发者工具打开
|
|||||||
|
|
||||||
| 位置 | 说明 |
|
| 位置 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `server/config/config.yaml` | 后端配置(**公开版凭据为占位符**) |
|
| `server/config/config.example.yaml` | 后端配置(**公开版凭据为占位符**) |
|
||||||
| `app/env/.env*` | 前端环境变量(**公开版 AppID 为占位符**) |
|
| `app/env/.env*` | 前端环境变量(**公开版 AppID 为占位符**) |
|
||||||
|
|
||||||
后端配置占位:
|
后端配置占位:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ FROM ${BASE_REGISTRY}/node:24-alpine AS builder
|
|||||||
WORKDIR /build/app
|
WORKDIR /build/app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||||
|
COPY patches ./patches
|
||||||
|
|
||||||
RUN npm config set registry https://registry.npmmirror.com/ && \
|
RUN npm config set registry https://registry.npmmirror.com/ && \
|
||||||
npm install -g pnpm@10.10.0 --registry=https://registry.npmmirror.com/ && \
|
npm install -g pnpm@10.10.0 --registry=https://registry.npmmirror.com/ && \
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export default defineManifestConfig({
|
|||||||
'name': VITE_APP_TITLE,
|
'name': VITE_APP_TITLE,
|
||||||
'appid': VITE_UNI_APPID,
|
'appid': VITE_UNI_APPID,
|
||||||
'description': '',
|
'description': '',
|
||||||
'versionName': '1.0.0',
|
'versionName': '1.0.2',
|
||||||
'versionCode': '100',
|
'versionCode': '102',
|
||||||
'transformPx': false,
|
'transformPx': false,
|
||||||
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
|
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
|
||||||
'h5': {
|
'h5': {
|
||||||
|
|||||||
+7
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "iotlcm-app-ui",
|
"name": "iotlcm-app-ui",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"unibest": {
|
"unibest": {
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"h5",
|
"h5",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"author": {
|
"author": {
|
||||||
"name": "feige996",
|
"name": "feige996",
|
||||||
"zhName": "菲鸽",
|
"zhName": "菲鸽",
|
||||||
"email": "1020103647@example.com",
|
"email": "xxx@example.com",
|
||||||
"github": "https://github.com/feige996",
|
"github": "https://github.com/feige996",
|
||||||
"gitee": "https://gitee.com/feige996"
|
"gitee": "https://gitee.com/feige996"
|
||||||
},
|
},
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
"upload:changeset": "pnpm changeset && pnpm changeset version",
|
"upload:changeset": "pnpm changeset && pnpm changeset version",
|
||||||
"upload:mp": "node ./scripts/upload-weixin.js",
|
"upload:mp": "node ./scripts/upload-weixin.js",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"test:run": "vitest run",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"lint:fix": "eslint --fix",
|
"lint:fix": "eslint --fix",
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
@@ -202,11 +203,15 @@
|
|||||||
"vite": "5.2.8",
|
"vite": "5.2.8",
|
||||||
"vite-plugin-restart": "^1.0.0",
|
"vite-plugin-restart": "^1.0.0",
|
||||||
"vite-plugin-vue-devtools": "^8.0.5",
|
"vite-plugin-vue-devtools": "^8.0.5",
|
||||||
|
"vitest": "3.2.4",
|
||||||
"vue-tsc": "^3.0.6"
|
"vue-tsc": "^3.0.6"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"unconfig": "7.3.2"
|
"unconfig": "7.3.2"
|
||||||
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@wot-ui/ui@2.1.0": "patches/@wot-ui__ui@2.1.0.patch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
|
}
|
||||||
|
]
|
||||||
Generated
+10
-2
@@ -8,6 +8,11 @@ overrides:
|
|||||||
bin-wrapper: npm:bin-wrapper-china
|
bin-wrapper: npm:bin-wrapper-china
|
||||||
unconfig: 7.3.2
|
unconfig: 7.3.2
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
'@wot-ui/ui@2.1.0':
|
||||||
|
hash: 706185b1a0b469b3fa2e58924fab9db34fc17f6694e0c92792e6105474427700
|
||||||
|
path: patches/@wot-ui__ui@2.1.0.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -74,7 +79,7 @@ importers:
|
|||||||
version: 2.5.0-20230101
|
version: 2.5.0-20230101
|
||||||
'@wot-ui/ui':
|
'@wot-ui/ui':
|
||||||
specifier: latest
|
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:
|
abortcontroller-polyfill:
|
||||||
specifier: ^1.7.8
|
specifier: ^1.7.8
|
||||||
version: 1.7.8
|
version: 1.7.8
|
||||||
@@ -259,6 +264,9 @@ importers:
|
|||||||
vite-plugin-vue-devtools:
|
vite-plugin-vue-devtools:
|
||||||
specifier: ^8.0.5
|
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))
|
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:
|
vue-tsc:
|
||||||
specifier: ^3.0.6
|
specifier: ^3.0.6
|
||||||
version: 3.0.6(typescript@5.8.3)
|
version: 3.0.6(typescript@5.8.3)
|
||||||
@@ -15044,7 +15052,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.20
|
'@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:
|
dependencies:
|
||||||
vue: 3.4.21(typescript@5.8.3)
|
vue: 3.4.21(typescript@5.8.3)
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -11,7 +11,8 @@ onShow(() => {
|
|||||||
// 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
|
// 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
isCurrentPageTabbar.value = true
|
isCurrentPageTabbar.value = true
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
isCurrentPageTabbar.value = isPageTabbar(path)
|
isCurrentPageTabbar.value = isPageTabbar(path)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -28,7 +29,9 @@ defineExpose({
|
|||||||
<template>
|
<template>
|
||||||
<view class="app-root">
|
<view class="app-root">
|
||||||
<!-- 这个先隐藏了,知道这样用就行 -->
|
<!-- 这个先隐藏了,知道这样用就行 -->
|
||||||
<view class="hidden text-center"> {{ helloKuRoot }},这里可以配置全局的东西 </view>
|
<view class="hidden text-center">
|
||||||
|
{{ helloKuRoot }},这里可以配置全局的东西
|
||||||
|
</view>
|
||||||
|
|
||||||
<KuRootView />
|
<KuRootView />
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -4,6 +4,7 @@ import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
|||||||
import { navigateToInterceptor } from '@/router/interceptor'
|
import { navigateToInterceptor } from '@/router/interceptor'
|
||||||
import { tabbarStore } from '@/tabbar/store'
|
import { tabbarStore } from '@/tabbar/store'
|
||||||
import { permission } from '@/router/permission'
|
import { permission } from '@/router/permission'
|
||||||
|
import { useMessageStore } from '@/store/message'
|
||||||
|
|
||||||
const { proxy } = (getCurrentInstance() || {}) as any
|
const { proxy } = (getCurrentInstance() || {}) as any
|
||||||
const router = proxy?.$router
|
const router = proxy?.$router
|
||||||
@@ -14,12 +15,14 @@ onLaunch((options: any) => {
|
|||||||
// console.log('App.vue onLaunch', options)
|
// console.log('App.vue onLaunch', options)
|
||||||
})
|
})
|
||||||
onShow((options: any) => {
|
onShow((options: any) => {
|
||||||
|
void useMessageStore().refreshUnreadCount()
|
||||||
// console.log('App.vue onShow', options)
|
// console.log('App.vue onShow', options)
|
||||||
// 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
|
// 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
|
||||||
// https://github.com/unibest-tech/unibest/issues/192
|
// https://github.com/unibest-tech/unibest/issues/192
|
||||||
if (options?.path) {
|
if (options?.path) {
|
||||||
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
|
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
navigateToInterceptor.invoke({ url: '/' })
|
navigateToInterceptor.invoke({ url: '/' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-6
@@ -10,8 +10,8 @@ import type {
|
|||||||
CardLifecycleStatusParams,
|
CardLifecycleStatusParams,
|
||||||
CardListParams,
|
CardListParams,
|
||||||
CardListRes,
|
CardListRes,
|
||||||
CardRecognitionRes,
|
|
||||||
CardQueryRes,
|
CardQueryRes,
|
||||||
|
CardRecognitionRes,
|
||||||
ChangePrepareParams,
|
ChangePrepareParams,
|
||||||
ChangePrepareRes,
|
ChangePrepareRes,
|
||||||
ChangeSubmitParams,
|
ChangeSubmitParams,
|
||||||
@@ -57,6 +57,20 @@ export function publicQueryCard(data: PublicQueryCardParams) {
|
|||||||
return http.post<CardQueryRes>('/card/public-query', data, undefined, undefined, CARD_REQUEST_OPTIONS)
|
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) {
|
export function recognizeCardImage(filePath: string) {
|
||||||
return new Promise<CardRecognitionRes>((resolve, reject) => {
|
return new Promise<CardRecognitionRes>((resolve, reject) => {
|
||||||
uni.uploadFile({
|
uni.uploadFile({
|
||||||
@@ -65,17 +79,20 @@ export function recognizeCardImage(filePath: string) {
|
|||||||
name: 'file',
|
name: 'file',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
success: (response) => {
|
success: (response) => {
|
||||||
let payload: { code?: number, message?: string, data?: CardRecognitionRes }
|
let payload: CardRecognitionPayload
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(String(response.data || '{}'))
|
payload = parseCardRecognitionPayload(response.data)
|
||||||
}
|
}
|
||||||
catch {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300 || Number(payload.code) !== 0) {
|
if (response.statusCode < 200 || response.statusCode >= 300 || Number(payload.code) !== 0) {
|
||||||
reject({ message: payload.message || '图片识别失败' })
|
reject(new Error(payload.message || '图片识别失败'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolve(payload.data || {
|
resolve(payload.data || {
|
||||||
@@ -88,7 +105,7 @@ export function recognizeCardImage(filePath: string) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
fail: (error) => {
|
fail: (error) => {
|
||||||
reject({ message: error.errMsg || '图片上传失败,请检查网络后重试' })
|
reject(new Error(error.errMsg || '图片上传失败,请检查网络后重试'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function getOpenDiagnosisInfo(data: DiagnosisInfoParams & { tenantId?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDiagnosisFlow(data: DiagnosisFlowParams) {
|
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,
|
hideErrorToast: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ICheckSideCaptchaForm,
|
ICheckSideCaptchaForm,
|
||||||
IForgotPasswordForm,
|
IForgotPasswordForm,
|
||||||
ILoginForm,
|
ILoginForm,
|
||||||
|
IPhoneSmsLoginForm,
|
||||||
IRegisterForm,
|
IRegisterForm,
|
||||||
ISendSmsForm,
|
ISendSmsForm,
|
||||||
ISendSmsRes,
|
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) {
|
export function wechatLogin(data: IWechatLoginForm) {
|
||||||
return http.post<IWechatLoginRes>(
|
return http.post<IWechatLoginRes>(
|
||||||
'/auth/wechat-login',
|
'/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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface CardListParams {
|
|||||||
cardValue?: string
|
cardValue?: string
|
||||||
status?: string
|
status?: string
|
||||||
categoryCode?: string
|
categoryCode?: string
|
||||||
|
allocateOnly?: boolean
|
||||||
includeTotal?: boolean
|
includeTotal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,13 +208,16 @@ export interface AllocateTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AllocateBatchPrepareParams {
|
export interface AllocateBatchPrepareParams {
|
||||||
|
executionMode?: number
|
||||||
cardValues: string[]
|
cardValues: string[]
|
||||||
targetDeptId: number
|
targetDeptId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AllocateBatchSubmitParams {
|
export interface AllocateBatchSubmitParams {
|
||||||
|
requestId?: string
|
||||||
|
executionMode?: number
|
||||||
cardValues?: string[]
|
cardValues?: string[]
|
||||||
batchNo: string
|
batchNo?: string
|
||||||
targetDeptId?: number
|
targetDeptId?: number
|
||||||
remark?: string
|
remark?: string
|
||||||
}
|
}
|
||||||
@@ -277,6 +281,7 @@ export interface AllocateTargetListRes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AllocateBatchRes {
|
export interface AllocateBatchRes {
|
||||||
|
requestId?: string
|
||||||
batchNo: string
|
batchNo: string
|
||||||
sourceBatchNo?: string
|
sourceBatchNo?: string
|
||||||
batchType: number
|
batchType: number
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface AppCustomer {
|
|||||||
balance?: number
|
balance?: number
|
||||||
cardCount: number
|
cardCount: number
|
||||||
normalCardCount: number
|
normalCardCount: number
|
||||||
|
cardStatusCounts?: { status: number, count: number }[]
|
||||||
|
packageStatusCounts?: { status: number, count: number }[]
|
||||||
address?: string
|
address?: string
|
||||||
fullName?: string
|
fullName?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
@@ -41,6 +43,11 @@ export interface AppCustomerDetail extends AppCustomer {
|
|||||||
export interface CustomerListRes {
|
export interface CustomerListRes {
|
||||||
items: AppCustomer[]
|
items: AppCustomer[]
|
||||||
total: number
|
total: number
|
||||||
|
summary: {
|
||||||
|
total: number
|
||||||
|
normalCount: number
|
||||||
|
pendingProfileCount: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerDetailParams {
|
export interface CustomerDetailParams {
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ export interface DiagnosisInfoItem {
|
|||||||
iccid?: string
|
iccid?: string
|
||||||
imsi?: string
|
imsi?: string
|
||||||
msisdn?: string
|
msisdn?: string
|
||||||
|
imei?: string
|
||||||
status?: string
|
status?: string
|
||||||
dataStatus?: string
|
dataStatus?: string
|
||||||
|
dataServiceSource?: 'apn' | 'network_block'
|
||||||
|
netBlockStatus?: string
|
||||||
|
netBlockReason?: string
|
||||||
onlineStatus?: string
|
onlineStatus?: string
|
||||||
operatingStatus?: string
|
operatingStatus?: string
|
||||||
deviceStatus?: string
|
deviceStatus?: string
|
||||||
@@ -88,6 +92,7 @@ export interface DiagnosisInfoRes {
|
|||||||
|
|
||||||
export interface DiagnosisModuleItem {
|
export interface DiagnosisModuleItem {
|
||||||
status: 'success' | 'error' | 'cached' | 'skipped' | string
|
status: 'success' | 'error' | 'cached' | 'skipped' | string
|
||||||
|
reason?: 'not_subscribed' | 'unsupported' | 'no_data' | 'query_error' | string
|
||||||
message?: string
|
message?: string
|
||||||
cached?: boolean
|
cached?: boolean
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
@@ -96,6 +101,7 @@ export interface DiagnosisModuleItem {
|
|||||||
export interface DiagnosisFlowRes {
|
export interface DiagnosisFlowRes {
|
||||||
times: string[]
|
times: string[]
|
||||||
flow: number[]
|
flow: number[]
|
||||||
|
flowAvailable?: boolean[]
|
||||||
unit: string
|
unit: string
|
||||||
granularity?: 'day' | 'month' | string
|
granularity?: 'day' | 'month' | string
|
||||||
hasData?: boolean
|
hasData?: boolean
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ export interface ILoginForm {
|
|||||||
wxCode?: string
|
wxCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPhoneSmsLoginForm {
|
||||||
|
tenantId: string
|
||||||
|
phone: string
|
||||||
|
smsCode: string
|
||||||
|
selectedUserId?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface IWechatLoginForm {
|
export interface IWechatLoginForm {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
wxCode: string
|
wxCode: string
|
||||||
@@ -106,8 +113,7 @@ export interface IWechatBindPhoneForm {
|
|||||||
tenantId: string
|
tenantId: string
|
||||||
bindTicket: string
|
bindTicket: string
|
||||||
phone: string
|
phone: string
|
||||||
captchaKey?: string
|
smsCode: string
|
||||||
captchaPoint?: string
|
|
||||||
selectedUserId?: number
|
selectedUserId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +122,7 @@ export interface IRegisterForm {
|
|||||||
phone: string
|
phone: string
|
||||||
password: string
|
password: string
|
||||||
nickName: string
|
nickName: string
|
||||||
/** 短信验证码:当前注册未接入短信,图形验证通过后可为空 */
|
smsCode: string
|
||||||
smsCode?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IForgotPasswordForm {
|
export interface IForgotPasswordForm {
|
||||||
@@ -128,6 +133,7 @@ export interface IForgotPasswordForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserInfoRes {
|
export interface IUserInfoRes {
|
||||||
|
isTopLevelEnterprise?: boolean
|
||||||
userId?: number
|
userId?: number
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
deptId?: number
|
deptId?: number
|
||||||
@@ -179,7 +185,9 @@ export interface ICheckSideCaptchaForm {
|
|||||||
export interface ISendSmsForm {
|
export interface ISendSmsForm {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
phone: string
|
phone: string
|
||||||
scene: 'register' | 'forgotPassword'
|
scene: 'register' | 'login' | 'forgotPassword' | 'wechatBindPhone'
|
||||||
|
captchaKey: string
|
||||||
|
captchaPoint: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISendSmsRes {
|
export interface ISendSmsRes {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface WarningConfigSaveParams {
|
|||||||
secondWarn?: number
|
secondWarn?: number
|
||||||
thirdWarn?: number
|
thirdWarn?: number
|
||||||
isEnabled: number
|
isEnabled: number
|
||||||
appNotifyEnabled: number
|
appNotifyEnabled?: number
|
||||||
remark?: string
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +74,8 @@ export interface WarningConfigMetaRes {
|
|||||||
wechatBound: boolean
|
wechatBound: boolean
|
||||||
wechatBindText: string
|
wechatBindText: string
|
||||||
appNotifyEnabled: number
|
appNotifyEnabled: number
|
||||||
|
activeConfigCount?: number
|
||||||
|
notifyConfigCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WarningConfigBatchSaveParams {
|
export interface WarningConfigBatchSaveParams {
|
||||||
@@ -87,7 +89,7 @@ export interface WarningConfigBatchItemParams {
|
|||||||
secondWarn?: number
|
secondWarn?: number
|
||||||
thirdWarn?: number
|
thirdWarn?: number
|
||||||
isEnabled: number
|
isEnabled: number
|
||||||
appNotifyEnabled: number
|
appNotifyEnabled?: number
|
||||||
remark?: string
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export interface WorkOrderListParams {
|
export interface WorkOrderListParams {
|
||||||
|
scope?: 'all' | 'submitted' | 'received'
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
keyword?: string
|
keyword?: string
|
||||||
@@ -7,10 +8,12 @@ export interface WorkOrderListParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderDetailParams {
|
export interface WorkOrderDetailParams {
|
||||||
|
beforeId?: number
|
||||||
woNo: string
|
woNo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderCreateParams {
|
export interface WorkOrderCreateParams {
|
||||||
|
creatorPhone: string
|
||||||
woType: number
|
woType: number
|
||||||
relatedId?: number
|
relatedId?: number
|
||||||
title: string
|
title: string
|
||||||
@@ -29,7 +32,37 @@ export interface AppWorkOrderCommunication {
|
|||||||
createdAt?: string
|
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 {
|
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
|
id: number
|
||||||
woNo: string
|
woNo: string
|
||||||
woType: number
|
woType: number
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
WorkOrderActionParams,
|
||||||
WorkOrderCreateParams,
|
WorkOrderCreateParams,
|
||||||
WorkOrderCreateRes,
|
WorkOrderCreateRes,
|
||||||
WorkOrderDetailParams,
|
WorkOrderDetailParams,
|
||||||
@@ -11,6 +12,7 @@ import { http } from '@/http/http'
|
|||||||
export type {
|
export type {
|
||||||
AppWorkOrder,
|
AppWorkOrder,
|
||||||
AppWorkOrderCommunication,
|
AppWorkOrderCommunication,
|
||||||
|
WorkOrderActionParams,
|
||||||
WorkOrderCreateParams,
|
WorkOrderCreateParams,
|
||||||
WorkOrderDetailParams,
|
WorkOrderDetailParams,
|
||||||
WorkOrderListParams,
|
WorkOrderListParams,
|
||||||
@@ -33,3 +35,7 @@ export function createWorkOrder(data: WorkOrderCreateParams) {
|
|||||||
hideErrorToast: true,
|
hideErrorToast: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function actOnWorkOrder(data: WorkOrderActionParams) {
|
||||||
|
return http.post('/work-order/action', data, undefined, undefined, { hideErrorToast: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getCurrentPageI18nKey, isCurrentPageTabbar } from '@/utils'
|
import { getCurrentPageI18nKey, isCurrentPageTabbar } from '@/utils'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
desc?: string
|
desc?: string
|
||||||
}>()
|
}>()
|
||||||
@@ -37,10 +38,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="nav-title-row">
|
<view class="nav-title-row">
|
||||||
<view class="nav-title">{{ title }}</view>
|
<view class="nav-title">
|
||||||
|
{{ title }}
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</wd-navbar>
|
</wd-navbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -109,5 +111,4 @@ onMounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -6,76 +6,82 @@
|
|||||||
* 页面路径常量
|
* 页面路径常量
|
||||||
*/
|
*/
|
||||||
const RouterPath = {
|
const RouterPath = {
|
||||||
/** 山东平台物联卡 */
|
/** 示例企业物联卡 */
|
||||||
Home: '/pages/home/index',
|
'Home': '/pages/home/index',
|
||||||
/** 办理 */
|
/** 办理 */
|
||||||
Business: '/pages/business/index',
|
'Business': '/pages/business/index',
|
||||||
/** 卡片 */
|
/** 卡片 */
|
||||||
Card: '/pages/card/index',
|
'Card': '/pages/card/index',
|
||||||
/** 山东平台物联卡 */
|
/** 示例企业物联卡 */
|
||||||
Login: '/pages/login/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',
|
'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',
|
'UserViewsFeedback': '/pages/user/views/feedback/index',
|
||||||
/** 微信提醒 */
|
/** 使用手册 */
|
||||||
UserViewsNotification: '/pages/user/views/notification/index',
|
'UserViewsManual': '/pages/user/views/manual/index',
|
||||||
/** 账号资料 */
|
/** 消息提醒 */
|
||||||
UserViewsProfile: '/pages/user/views/profile/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
|
export default RouterPath
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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`/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
= /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
|
const technicalDetailPattern
|
||||||
= /https?:\/\/|\/api\/|接口[::]|errMsg|statusCode|StatusCode|request:|XMLHttpRequest|TypeError:|Error:/i
|
= /https?:\/\/|\/api\/|接口[::]|errMsg|statusCode|request:|XMLHttpRequest|TypeError:|Error:/i
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据状态码,生成对应的错误信息
|
* 根据状态码,生成对应的错误信息
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"name": "山东平台物联卡",
|
"name": "山东平台物联卡",
|
||||||
"appid": "__UNI__YOURAPPID",
|
"appid": "__UNI__YOURAPPID",
|
||||||
"description": "",
|
"description": "",
|
||||||
"versionName": "1.0.0",
|
"versionName": "1.0.2",
|
||||||
"versionCode": "100",
|
"versionCode": "102",
|
||||||
"transformPx": false,
|
"transformPx": false,
|
||||||
"app-plus": {
|
"app-plus": {
|
||||||
"usingComponents": true,
|
"usingComponents": true,
|
||||||
|
|||||||
+27
-5
@@ -21,7 +21,7 @@
|
|||||||
"type": "home",
|
"type": "home",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "default",
|
"navigationStyle": "default",
|
||||||
"navigationBarTitleText": "山东平台物联卡",
|
"navigationBarTitleText": "示例企业物联卡",
|
||||||
"navigationBarBackgroundColor": "#ffffff",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
"navigationBarTextStyle": "black"
|
"navigationBarTextStyle": "black"
|
||||||
},
|
},
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"type": "page",
|
"type": "page",
|
||||||
"name": "Login",
|
"name": "Login",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "山东平台物联卡",
|
"navigationBarTitleText": "示例企业物联卡",
|
||||||
"navigationBarBackgroundColor": "#ffffff",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"disableScroll": true
|
"disableScroll": true
|
||||||
@@ -180,7 +180,8 @@
|
|||||||
"path": "workOrder/index",
|
"path": "workOrder/index",
|
||||||
"type": "page",
|
"type": "page",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "工单列表"
|
"navigationBarTitleText": "工单列表",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -290,7 +291,7 @@
|
|||||||
"path": "notification/index",
|
"path": "notification/index",
|
||||||
"type": "page",
|
"type": "page",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "微信提醒"
|
"navigationBarTitleText": "消息提醒"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -298,7 +299,7 @@
|
|||||||
"type": "page",
|
"type": "page",
|
||||||
"excludeLoginPath": true,
|
"excludeLoginPath": true,
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "账号资料"
|
"navigationBarTitleText": "个人资料"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -307,6 +308,27 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "实名信息"
|
"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<{
|
const props = defineProps<{
|
||||||
section: BusinessServiceSection
|
section: BusinessServiceSection
|
||||||
|
busy?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -19,12 +20,13 @@ const toneClass: Record<BusinessTone, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleClick(item: BusinessServiceItem) {
|
function handleClick(item: BusinessServiceItem) {
|
||||||
|
if (!props.busy)
|
||||||
emit('action', item)
|
emit('action', item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleRoleTag(item: BusinessServiceItem) {
|
function visibleRoleTag(item: BusinessServiceItem) {
|
||||||
if (item.agentOnly)
|
if (item.agentOnly)
|
||||||
return '代理商'
|
return '企业 / 代理'
|
||||||
if (item.terminalOnly)
|
if (item.terminalOnly)
|
||||||
return '用户'
|
return '用户'
|
||||||
if (item.terminalHidden)
|
if (item.terminalHidden)
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ const expectedSubViews = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
describe('getUserBusinessSections', () => {
|
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('只保留用户侧一级业务入口', () => {
|
it('只保留用户侧一级业务入口', () => {
|
||||||
const sections = getUserBusinessSections(businessSections)
|
const sections = getUserBusinessSections(businessSections)
|
||||||
const titles = sections.flatMap(section => section.items.map(item => item.title))
|
const titles = sections.flatMap(section => section.items.map(item => item.title))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IUserInfoRes } from '@/api/types/login'
|
import type { IUserInfoRes } from '@/api/types/login'
|
||||||
import type { AppPermissionAction } from '@/utils/permission'
|
import type { AppPermissionAction } from '@/utils/permission'
|
||||||
import { hasUserPermission } from '@/utils/permission'
|
import { hasAccessCode, hasUserPermission } from '@/utils/permission'
|
||||||
import { getAccountWorkspace, isTerminalUserInfo } from '@/utils/userIdentity'
|
import { getAccountWorkspace, isTerminalUserInfo } from '@/utils/userIdentity'
|
||||||
|
|
||||||
export type BusinessTone = 'blue' | 'green' | 'orange' | 'primary' | 'purple' | 'red'
|
export type BusinessTone = 'blue' | 'green' | 'orange' | 'primary' | 'purple' | 'red'
|
||||||
@@ -18,11 +18,13 @@ export interface BusinessServiceItem {
|
|||||||
| 'cardList'
|
| 'cardList'
|
||||||
| 'change'
|
| 'change'
|
||||||
| 'customer'
|
| 'customer'
|
||||||
|
| 'executionLog'
|
||||||
| 'diagnosis'
|
| 'diagnosis'
|
||||||
| 'order'
|
| 'order'
|
||||||
| 'renew'
|
| 'renew'
|
||||||
| 'warning'
|
| 'warning'
|
||||||
| 'workOrder'
|
| 'workOrder'
|
||||||
|
accessCode?: string
|
||||||
agentOnly?: boolean
|
agentOnly?: boolean
|
||||||
terminalOnly?: boolean
|
terminalOnly?: boolean
|
||||||
terminalHidden?: boolean
|
terminalHidden?: boolean
|
||||||
@@ -113,7 +115,7 @@ export const businessSections: BusinessServiceSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '预警配置',
|
title: '预警配置',
|
||||||
desc: '流量阈值与微信订阅',
|
desc: '流量阈值与站内消息提醒',
|
||||||
tag: '预警',
|
tag: '预警',
|
||||||
icon: 'i-carbon-warning-alt',
|
icon: 'i-carbon-warning-alt',
|
||||||
tone: 'orange',
|
tone: 'orange',
|
||||||
@@ -134,7 +136,7 @@ export const businessSections: BusinessServiceSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '服务记录',
|
title: '服务记录',
|
||||||
desc: '订单与工单',
|
desc: '订单查询与问题反馈',
|
||||||
type: 'grid',
|
type: 'grid',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -154,6 +156,13 @@ export const businessSections: BusinessServiceSection[] = [
|
|||||||
action: 'workOrder',
|
action: 'workOrder',
|
||||||
permissionAction: ['workOrder', 'workOrderCreate'],
|
permissionAction: ['workOrder', 'workOrderCreate'],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '卡片与客户',
|
||||||
|
desc: '卡片信息与客户管理',
|
||||||
|
type: 'list',
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
title: '我的卡片',
|
title: '我的卡片',
|
||||||
desc: '查看卡信息、套餐和流量',
|
desc: '查看卡信息、套餐和流量',
|
||||||
@@ -164,7 +173,7 @@ export const businessSections: BusinessServiceSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '客户管理',
|
title: '客户管理',
|
||||||
desc: '直属客户与客户详情',
|
desc: '授权范围内的客户与详情',
|
||||||
tag: '客户',
|
tag: '客户',
|
||||||
icon: 'i-carbon-user-multiple',
|
icon: 'i-carbon-user-multiple',
|
||||||
tone: 'green',
|
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 => ({
|
.map(section => ({
|
||||||
...section,
|
...section,
|
||||||
items: section.items.filter((item) => {
|
items: section.items.filter((item) => {
|
||||||
|
if (item.accessCode && !hasAccessCode(userInfo?.accessCodes, item.accessCode))
|
||||||
|
return false
|
||||||
if (item.agentOnly && !isAgentUser)
|
if (item.agentOnly && !isAgentUser)
|
||||||
return false
|
return false
|
||||||
if (item.terminalOnly && !isTerminalUser)
|
if (item.terminalOnly && !isTerminalUser)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { IUserInfoRes } from '@/api/types/login'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createContext, runInContext } from 'node:vm'
|
||||||
|
import { ModuleKind, transpileModule } from 'typescript'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import RouterPath from '@/constant/RouterPath'
|
||||||
|
import * as release from '@/constant/release'
|
||||||
|
import { customTabbarList } from '@/tabbar/config'
|
||||||
|
import * as permission from '@/utils/permission'
|
||||||
|
import * as data from './data'
|
||||||
|
|
||||||
|
function createHarness(userInfo: Partial<IUserInfoRes>) {
|
||||||
|
const navigateTo = vi.fn((options: { complete?: () => void }) => options.complete?.())
|
||||||
|
const switchTab = vi.fn((options: { complete?: () => void }) => options.complete?.())
|
||||||
|
const fetchUserInfo = vi.fn(async () => userInfo)
|
||||||
|
const tokenStore = { hasLogin: true, updateNowTime: () => tokenStore }
|
||||||
|
const context = createContext({
|
||||||
|
exports: {},
|
||||||
|
require: (name: string) => {
|
||||||
|
if (name === '@/constant/RouterPath')
|
||||||
|
return { default: RouterPath }
|
||||||
|
if (name === '@/constant/release')
|
||||||
|
return release
|
||||||
|
if (name === '@/utils/permission')
|
||||||
|
return permission
|
||||||
|
if (name === '@/store/user')
|
||||||
|
return { useUserStore: () => ({ userInfo, fetchUserInfo }) }
|
||||||
|
if (name === '@/store/token')
|
||||||
|
return { useTokenStore: () => tokenStore }
|
||||||
|
if (name === './data')
|
||||||
|
return data
|
||||||
|
if (name === './components/BusinessSection.vue')
|
||||||
|
return {}
|
||||||
|
throw new Error(`Unexpected import: ${name}`)
|
||||||
|
},
|
||||||
|
ref: (value: unknown) => ({ value }),
|
||||||
|
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
|
||||||
|
definePage: () => undefined,
|
||||||
|
onShow: () => undefined,
|
||||||
|
uni: { navigateTo, switchTab, showToast: vi.fn() },
|
||||||
|
Promise,
|
||||||
|
})
|
||||||
|
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
|
||||||
|
if (!script)
|
||||||
|
throw new Error('Business page script not found')
|
||||||
|
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
|
||||||
|
return {
|
||||||
|
titles: () => Array.from(runInContext('sections.value.flatMap(section => section.items.map(item => item.title))', context)) as string[],
|
||||||
|
action: (title: string) => runInContext(`handleBusinessAction(sections.value.flatMap(section => section.items).find(item => item.title === ${JSON.stringify(title)}))`, context) as Promise<void>,
|
||||||
|
fetchUserInfo,
|
||||||
|
navigateTo,
|
||||||
|
switchTab,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleTitles(userInfo: Partial<IUserInfoRes>) {
|
||||||
|
return createHarness(userInfo).titles()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('办理入口发布范围', () => {
|
||||||
|
it('沿用紧凑办理布局,不展示大块消息提醒入口', () => {
|
||||||
|
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||||
|
const template = source.split('<template>')[1].split('</template>')[0]
|
||||||
|
expect(template).toContain('assist-card')
|
||||||
|
expect(template).toContain(':busy="actionPending"')
|
||||||
|
expect(template).not.toContain('查看业务消息')
|
||||||
|
expect(template).not.toContain('微信提醒设置')
|
||||||
|
expect(template).not.toContain('业务办理')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('身份标签放在业务标题旁,保持单行且不挤占右侧区域', () => {
|
||||||
|
const source = readFileSync(new URL('./components/BusinessSection.vue', import.meta.url), 'utf8')
|
||||||
|
expect(source).toContain('biz-title-row')
|
||||||
|
expect(source).toContain('app-tag app-tag--info biz-role-tag')
|
||||||
|
expect(source).toContain('white-space: nowrap')
|
||||||
|
expect(source).not.toContain('title-width="75%"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同一办理入口连续点击只跳转一次', async () => {
|
||||||
|
const harness = createHarness({ userId: 1, username: 'admin' })
|
||||||
|
await Promise.all([harness.action('我的卡片'), harness.action('我的卡片')])
|
||||||
|
expect(harness.fetchUserInfo).toHaveBeenCalledTimes(1)
|
||||||
|
expect(harness.switchTab).toHaveBeenCalledTimes(1)
|
||||||
|
expect(harness.switchTab.mock.calls[0][0]).toMatchObject({ url: RouterPath.Card })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('权限更新后不再跳转已失效的客户入口', async () => {
|
||||||
|
const user = { userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } }
|
||||||
|
const harness = createHarness(user)
|
||||||
|
harness.fetchUserInfo.mockImplementationOnce(async () => {
|
||||||
|
user.appAccess.features = []
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
await harness.action('客户管理')
|
||||||
|
expect(harness.navigateTo).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('工单入口按列表或提交权限开放,路由可达', () => {
|
||||||
|
for (const feature of ['workorder:list', 'workorder:create'])
|
||||||
|
expect(visibleTitles({ userId: 12, appAccess: { configured: true, features: [feature] } })).toContain('工单服务')
|
||||||
|
expect(visibleTitles({ userId: 12, appAccess: { configured: true, features: [] } })).not.toContain('工单服务')
|
||||||
|
for (const route of [RouterPath.BusinessViewsWorkOrder, RouterPath.BusinessViewsWorkOrderCreate, RouterPath.BusinessViewsWorkOrderDetail])
|
||||||
|
expect(release.isV101HiddenRoute(route)).toBe(false)
|
||||||
|
expect(release.isV101HiddenRoute(RouterPath.BusinessViewsRenew)).toBe(true)
|
||||||
|
})
|
||||||
|
it('批次日志入口只对拥有原有日志权限的账号开放', () => {
|
||||||
|
expect(visibleTitles({ userId: 12, userType: 'sys_user', accessCodes: ['cpm:common:batchExecutionLog:list'] })).toContain('批次执行日志')
|
||||||
|
expect(visibleTitles({ userId: 13, userType: 'sys_user', accessCodes: [] })).not.toContain('批次执行日志')
|
||||||
|
})
|
||||||
|
it('恢复首页、设备、办理、我的顺序', () => {
|
||||||
|
expect(customTabbarList.map(item => item.text)).toEqual(['首页', '设备', '办理', '我的'])
|
||||||
|
expect(release.isV101HiddenRoute('/pages/business/index')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('管理员只能看到已开放业务,不展示仍受发布限制的操作', () => {
|
||||||
|
expect(visibleTitles({ userId: 1, username: 'admin' })).toEqual(['卡片排查', '卡片划拨', '工单服务', '我的卡片', '客户管理'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('客户入口继续受账号身份和权限限制', () => {
|
||||||
|
expect(visibleTitles({ userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } })).toContain('客户管理')
|
||||||
|
expect(visibleTitles({ userId: 13, userType: 'sys_user', appAccess: { configured: true, features: [] } })).not.toContain('客户管理')
|
||||||
|
expect(visibleTitles({ userId: 14, userType: 'app_user' })).not.toContain('客户管理')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
|
import { isV101HiddenRoute } from '@/constant/release'
|
||||||
import type { IUserInfoRes } from '@/api/types/login'
|
import type { IUserInfoRes } from '@/api/types/login'
|
||||||
import { useTokenStore } from '@/store/token'
|
import { useTokenStore } from '@/store/token'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import type { AppPermissionAction } from '@/utils/permission'
|
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 BusinessSection from './components/BusinessSection.vue'
|
||||||
import { getUserBusinessSections } from './data'
|
import { getUserBusinessSections } from './data'
|
||||||
import type { BusinessServiceItem } from './data'
|
import type { BusinessServiceItem } from './data'
|
||||||
@@ -23,6 +24,7 @@ const actionRouteMap: Partial<Record<NonNullable<BusinessServiceItem['action']>,
|
|||||||
balanceRecharge: RouterPath.BusinessViewsBalanceRecharge,
|
balanceRecharge: RouterPath.BusinessViewsBalanceRecharge,
|
||||||
change: RouterPath.BusinessViewsChange,
|
change: RouterPath.BusinessViewsChange,
|
||||||
customer: RouterPath.BusinessViewsCustomer,
|
customer: RouterPath.BusinessViewsCustomer,
|
||||||
|
executionLog: RouterPath.UserViewsExecutionLog,
|
||||||
addon: RouterPath.BusinessViewsAddon,
|
addon: RouterPath.BusinessViewsAddon,
|
||||||
diagnosis: RouterPath.BusinessViewsDiagnosis,
|
diagnosis: RouterPath.BusinessViewsDiagnosis,
|
||||||
order: RouterPath.BusinessViewsOrder,
|
order: RouterPath.BusinessViewsOrder,
|
||||||
@@ -31,10 +33,21 @@ const actionRouteMap: Partial<Record<NonNullable<BusinessServiceItem['action']>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ==================== 数据 ==================== */
|
/** ==================== 数据 ==================== */
|
||||||
|
const actionPending = ref(false)
|
||||||
const tokenStore = useTokenStore()
|
const tokenStore = useTokenStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
let refreshUserPromise: Promise<void | IUserInfoRes> | undefined
|
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() {
|
async function refreshUserInfoIfNeeded() {
|
||||||
@@ -54,31 +67,54 @@ async function refreshUserInfoIfNeeded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleBusinessAction(item: BusinessServiceItem) {
|
async function handleBusinessAction(item: BusinessServiceItem) {
|
||||||
|
if (actionPending.value)
|
||||||
|
return
|
||||||
|
actionPending.value = true
|
||||||
|
try {
|
||||||
await refreshUserInfoIfNeeded()
|
await refreshUserInfoIfNeeded()
|
||||||
if (item.permissionAction && !hasBusinessItemPermission(item.permissionAction)) {
|
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' })
|
uni.showToast({ title: '暂无操作权限', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const url = item.action === 'cardList'
|
||||||
if (item.action === 'cardList') {
|
? RouterPath.Card
|
||||||
uni.switchTab({ url: 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
|
return
|
||||||
}
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
const url = item.action ? actionRouteMap[item.action] : ''
|
const options = { url, complete: () => resolve() }
|
||||||
if (url) {
|
if (item.action === 'cardList')
|
||||||
uni.navigateTo({ url })
|
uni.switchTab(options)
|
||||||
return
|
else
|
||||||
}
|
uni.navigateTo(options)
|
||||||
|
|
||||||
uni.showToast({
|
|
||||||
title: item.title,
|
|
||||||
icon: 'none',
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
actionPending.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goDiagnosis() {
|
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[]) {
|
function hasBusinessItemPermission(action: AppPermissionAction | AppPermissionAction[]) {
|
||||||
@@ -114,6 +150,7 @@ onShow(() => {
|
|||||||
v-for="section in sections"
|
v-for="section in sections"
|
||||||
:key="section.title"
|
:key="section.title"
|
||||||
:section="section"
|
:section="section"
|
||||||
|
:busy="actionPending"
|
||||||
@action="handleBusinessAction"
|
@action="handleBusinessAction"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ definePage({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type AddonOption = {
|
interface AddonOption {
|
||||||
name: string
|
name: string
|
||||||
price: string
|
price: string
|
||||||
desc: string
|
desc: string
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { allocateDetailStatus, allocateResultStatus, allocateSelectionFingerprint, allocateStatusPresentation, allocateTaskSnapshot, mergeAllocateSelection } from './selection'
|
||||||
|
|
||||||
|
describe('划拨选卡', () => {
|
||||||
|
it('搜索和分页不会清除其他页已选卡', () => {
|
||||||
|
expect(mergeAllocateSelection(['A', 'B'], ['B', 'C'], ['C'])).toEqual(['A', 'C'])
|
||||||
|
})
|
||||||
|
it('重复选择只保留一份', () => {
|
||||||
|
expect(mergeAllocateSelection(['A'], ['B'], ['B', 'B'])).toEqual(['A', 'B'])
|
||||||
|
})
|
||||||
|
it('相同卡集合保持相同快照,目标变更使校验失效', () => {
|
||||||
|
expect(allocateSelectionFingerprint(['A', 'B'], 1)).toBe(allocateSelectionFingerprint(['B', 'A'], 1))
|
||||||
|
expect(allocateSelectionFingerprint(['A'], 1)).not.toBe(allocateSelectionFingerprint(['A'], 2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('划拨执行结果', () => {
|
||||||
|
it('等待和处理中不会被误报为成功或失败', () => {
|
||||||
|
expect(allocateResultStatus(0)).toBe('pending')
|
||||||
|
expect(allocateResultStatus(1)).toBe('pending')
|
||||||
|
expect(allocateResultStatus(2)).toBe('processing')
|
||||||
|
expect(allocateResultStatus(999)).toBe('pending')
|
||||||
|
})
|
||||||
|
it('保留异常部分成功状态,不误报为整批失败', () => {
|
||||||
|
expect(allocateResultStatus(3)).toBe('success')
|
||||||
|
expect(allocateResultStatus(4)).toBe('failed')
|
||||||
|
expect(allocateResultStatus(5)).toBe('partial_success')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('划拨恢复快照', () => {
|
||||||
|
const item = { requestId: 'request', batchNo: '', batchType: 3, status: 'pending', quantity: 1, target: { deptId: 2, deptName: '客户' }, cards: [], details: [] }
|
||||||
|
const request = { requestId: 'request', cardValues: ['A'], targetDeptId: 2, executionMode: 2 }
|
||||||
|
it('受理前保留原请求以便恢复重试', () => {
|
||||||
|
const saved = allocateTaskSnapshot(item, request)
|
||||||
|
expect(saved.pendingRequest).toEqual(request)
|
||||||
|
expect(saved.cards).toEqual([])
|
||||||
|
expect(saved.details).toEqual([])
|
||||||
|
})
|
||||||
|
it('受理后和明确拒绝后清理原卡号参数', () => {
|
||||||
|
expect(allocateTaskSnapshot({ ...item, batchNo: 'ALC' }, request).pendingRequest).toBeUndefined()
|
||||||
|
expect(allocateTaskSnapshot({ ...item, status: 'failed' }, request).pendingRequest).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('划拨页面状态展示', () => {
|
||||||
|
it('区分受理不确定和后台待处理', () => {
|
||||||
|
expect(allocateStatusPresentation('pending', false).title).toBe('提交结果待确认')
|
||||||
|
expect(allocateStatusPresentation('pending', true).title).toBe('等待后台处理')
|
||||||
|
})
|
||||||
|
it('区分校验拒绝和执行失败,异常部分成功提示核查', () => {
|
||||||
|
expect(allocateStatusPresentation('failed', false).title).toBe('校验未通过')
|
||||||
|
expect(allocateStatusPresentation('failed', true).title).toBe('划拨失败')
|
||||||
|
expect(allocateStatusPresentation('partial_success').title).toBe('结果待核查')
|
||||||
|
expect(allocateStatusPresentation('success').type).toBe('success')
|
||||||
|
expect(allocateStatusPresentation().title).toBe('待提交')
|
||||||
|
})
|
||||||
|
it('子任务校验通过不能误报为划拨完成', () => {
|
||||||
|
expect(allocateDetailStatus(2, 1)).toBe('校验通过 · 待整批完成')
|
||||||
|
expect(allocateDetailStatus(2, 2)).toBe('校验通过 · 待整批完成')
|
||||||
|
expect(allocateDetailStatus(2, 4)).toBe('校验通过 · 整批未完成')
|
||||||
|
expect(allocateDetailStatus(2, 3)).toBe('已划拨')
|
||||||
|
expect(allocateDetailStatus(3, 4)).toBe('校验失败')
|
||||||
|
expect(allocateDetailStatus(999, 2)).toBe('状态待确认')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('直接异步提交不确定状态', () => {
|
||||||
|
it('响应丢失不保存可重建请求,不声称未受理', () => {
|
||||||
|
const item = { batchNo: '', batchType: 3, status: 'unknown', quantity: 1, target: { deptId: 2, deptName: '客户' }, cards: [], details: [] }
|
||||||
|
expect(allocateTaskSnapshot(item, { cardValues: ['A'], targetDeptId: 2 }).pendingRequest).toBeUndefined()
|
||||||
|
expect(allocateStatusPresentation('unknown', false).description).toContain('后台可能已创建任务')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { AllocateBatchRes, AllocateBatchSubmitParams } from '@/api/types/card'
|
||||||
|
|
||||||
|
export function mergeAllocateSelection(selected: string[], visible: string[], checked: string[]) {
|
||||||
|
const visibleSet = new Set(visible)
|
||||||
|
return Array.from(new Set([...selected.filter(value => !visibleSet.has(value)), ...checked]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allocateSelectionFingerprint(values: string[], targetId?: number) {
|
||||||
|
return JSON.stringify([targetId || 0, Array.from(new Set(values)).sort()])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allocateTaskSnapshot(item: AllocateBatchRes, request?: AllocateBatchSubmitParams) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
cards: [],
|
||||||
|
details: [],
|
||||||
|
target: { deptId: item.target.deptId, deptName: item.target.deptName },
|
||||||
|
pendingRequest: item.batchNo || !['pending', 'processing'].includes(item.status) ? undefined : request,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { allocateDetailStatus, allocateResultStatus, allocateStatusPresentation } from '@/utils/allocateStatus'
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createContext, runInContext } from 'node:vm'
|
||||||
|
import { ModuleKind, transpileModule } from 'typescript'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import * as selection from './selection'
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const submit = vi.fn()
|
||||||
|
const summary = vi.fn()
|
||||||
|
const toast = vi.fn()
|
||||||
|
const removeStorage = vi.fn()
|
||||||
|
const userInfo = { userId: 12, tenantId: 'A', deptId: 10 }
|
||||||
|
const context = createContext({
|
||||||
|
exports: {},
|
||||||
|
require: (name: string) => {
|
||||||
|
if (name === '@/api/card')
|
||||||
|
return { allocateBatchSubmit: submit, getAllocateTargets: vi.fn(), getCardList: vi.fn() }
|
||||||
|
if (name === '@/api/executionLog')
|
||||||
|
return { allocateSummary: summary }
|
||||||
|
if (name === './selection')
|
||||||
|
return selection
|
||||||
|
if (name === '@/pages/card/data')
|
||||||
|
return {}
|
||||||
|
if (name === '@/store/user')
|
||||||
|
return { useUserStore: () => ({ userInfo, fetchUserInfo: vi.fn(async () => userInfo) }) }
|
||||||
|
if (name === '@/utils/permission')
|
||||||
|
return { hasUserPermission: () => true }
|
||||||
|
if (name === '@/utils/userIdentity')
|
||||||
|
return { getAccountWorkspace: () => 'agent' }
|
||||||
|
throw new Error(name)
|
||||||
|
},
|
||||||
|
ref: (value: unknown) => ({ value }),
|
||||||
|
computed: (getter: (() => unknown) | { get: () => unknown }) => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
|
||||||
|
definePage: () => undefined,
|
||||||
|
watch: () => undefined,
|
||||||
|
onShow: () => undefined,
|
||||||
|
onHide: () => undefined,
|
||||||
|
onUnload: () => undefined,
|
||||||
|
onMounted: () => undefined,
|
||||||
|
onLoad: () => undefined,
|
||||||
|
setTimeout: vi.fn(),
|
||||||
|
uni: {
|
||||||
|
showModal: (options: { success: (value: { confirm: boolean }) => void }) => options.success({ confirm: true }),
|
||||||
|
showToast: toast,
|
||||||
|
setStorageSync: vi.fn(),
|
||||||
|
removeStorageSync: removeStorage,
|
||||||
|
getStorageSync: vi.fn(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
|
||||||
|
if (!script)
|
||||||
|
throw new Error('missing script')
|
||||||
|
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context)
|
||||||
|
runInContext('chosenTarget.value = { deptId: 20, deptName: \'目标客户\' }; selectedCardValues.value = [\'CARD\']', context)
|
||||||
|
return { submit, summary, toast, removeStorage, evaluate: (code: string) => runInContext(code, context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('异步划拨提交', () => {
|
||||||
|
it('受理后清空选择和任务状态,不自动查询或轮询结果', async () => {
|
||||||
|
const page = harness()
|
||||||
|
page.submit.mockResolvedValue({ batchNo: 'ALC202610130001', status: 'pending' })
|
||||||
|
await page.evaluate('handleSubmit()')
|
||||||
|
expect(page.submit).toHaveBeenCalledWith({ cardValues: ['CARD'], targetDeptId: 20, executionMode: 2 })
|
||||||
|
expect(page.summary).not.toHaveBeenCalled()
|
||||||
|
expect(page.evaluate('result.value')).toBeUndefined()
|
||||||
|
expect(page.evaluate('selectedCardValues.value.length')).toBe(0)
|
||||||
|
expect(page.removeStorage).toHaveBeenCalledWith('allocate-last-batch:A:12')
|
||||||
|
expect(page.toast).toHaveBeenCalledWith({ title: '已提交,请到批次日志查看', icon: 'none', duration: 3000 })
|
||||||
|
})
|
||||||
|
it('请求结果未知时保留防重复提交保护,不清空选择', async () => {
|
||||||
|
const page = harness()
|
||||||
|
page.submit.mockRejectedValue(new Error('timeout'))
|
||||||
|
await page.evaluate('handleSubmit()')
|
||||||
|
expect(page.evaluate('result.value.status')).toBe('unknown')
|
||||||
|
expect(page.evaluate('canSubmit.value')).toBe(false)
|
||||||
|
expect(page.evaluate('selectedCardValues.value.length')).toBe(1)
|
||||||
|
expect(page.removeStorage).not.toHaveBeenCalled()
|
||||||
|
expect(page.summary).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import type {
|
|
||||||
DiagnosisFlowParams,
|
|
||||||
DiagnosisFlowRes,
|
|
||||||
DiagnosisInfoParams,
|
|
||||||
DiagnosisInfoRes,
|
|
||||||
} from '@/api/types/diagnosis'
|
|
||||||
import { http } from '@/http/http'
|
|
||||||
|
|
||||||
export type {
|
|
||||||
DiagnosisFlowParams,
|
|
||||||
DiagnosisFlowRes,
|
|
||||||
DiagnosisInfoItem,
|
|
||||||
DiagnosisInfoParams,
|
|
||||||
DiagnosisInfoRes,
|
|
||||||
DiagnosisModuleItem,
|
|
||||||
DiagnosisPackageItem,
|
|
||||||
DiagnosisRateLimitApnItem,
|
|
||||||
DiagnosisRateLimitPccItem,
|
|
||||||
DiagnosisSessionItem,
|
|
||||||
} from '@/api/types/diagnosis'
|
|
||||||
|
|
||||||
export function getDiagnosisInfo(data: DiagnosisInfoParams) {
|
|
||||||
return http.post<DiagnosisInfoRes>('/diagnosis/info', data, undefined, undefined, {
|
|
||||||
hideErrorToast: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOpenDiagnosisInfo(data: DiagnosisInfoParams & { tenantId?: string }) {
|
|
||||||
return http.post<DiagnosisInfoRes>('/open/diagnosis/info', data, undefined, undefined, {
|
|
||||||
hideErrorToast: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDiagnosisFlow(data: DiagnosisFlowParams) {
|
|
||||||
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
|
|
||||||
hideErrorToast: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOpenDiagnosisFlow(data: DiagnosisFlowParams & { tenantId?: string }) {
|
|
||||||
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
|
|
||||||
hideErrorToast: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,35 +1 @@
|
|||||||
import type {
|
export * from '@/api/workOrder'
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -447,7 +447,9 @@ onLoad((query) => {
|
|||||||
<view class="biz-card__body">
|
<view class="biz-card__body">
|
||||||
<view class="biz-section-head mb-8rpx">
|
<view class="biz-section-head mb-8rpx">
|
||||||
<text class="biz-section-title">办理结果</text>
|
<text class="biz-section-title">办理结果</text>
|
||||||
<wd-tag type="success" variant="plain">已支付</wd-tag>
|
<wd-tag type="success" variant="plain">
|
||||||
|
已支付
|
||||||
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
<view class="biz-row">
|
<view class="biz-row">
|
||||||
<text class="biz-row__label">订单号</text>
|
<text class="biz-row__label">订单号</text>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { CustomerListRes } from '@/api/types/customer'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createContext, runInContext } from 'node:vm'
|
||||||
|
import { ModuleKind, transpileModule } from 'typescript'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const requests = vi.fn<(params: Record<string, unknown>) => Promise<CustomerListRes>>()
|
||||||
|
const unmountCallbacks: Array<() => void> = []
|
||||||
|
const features = { allowed: true }
|
||||||
|
const context = createContext({
|
||||||
|
exports: {},
|
||||||
|
require: (name: string) => {
|
||||||
|
if (name === '../api/customer')
|
||||||
|
return { getCustomerList: requests }
|
||||||
|
if (name === '@/store/user')
|
||||||
|
return { useUserStore: () => ({ userInfo: {}, fetchUserInfo: async () => undefined }) }
|
||||||
|
if (name === '@/utils/permission')
|
||||||
|
return { hasAnyFeature: () => features.allowed }
|
||||||
|
if (name === '@/utils/userIdentity')
|
||||||
|
return { getAccountWorkspace: () => 'enterprise', getAccountWorkspaceLabel: () => '企业' }
|
||||||
|
if (name === '@/constant/RouterPath')
|
||||||
|
return { default: { BusinessViewsCustomerDetail: '/customer/detail' } }
|
||||||
|
throw new Error(`Unexpected import: ${name}`)
|
||||||
|
},
|
||||||
|
ref: (value: unknown) => ({ value }),
|
||||||
|
computed: (getter: () => unknown) => ({ get value() { return getter() } }),
|
||||||
|
definePage: () => undefined,
|
||||||
|
onMounted: () => undefined,
|
||||||
|
onUnmounted: (callback: () => void) => unmountCallbacks.push(callback),
|
||||||
|
uni: { showToast: vi.fn(), navigateTo: vi.fn() },
|
||||||
|
})
|
||||||
|
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
|
||||||
|
if (!script)
|
||||||
|
throw new Error('Customer script not found')
|
||||||
|
const compiled = transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } })
|
||||||
|
runInContext(compiled.outputText, context)
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
features,
|
||||||
|
evaluate: <Result>(expression: string) => runInContext(expression, context) as Result,
|
||||||
|
unmount: () => unmountCallbacks.forEach(callback => callback()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(customerId: number, total = 2): CustomerListRes {
|
||||||
|
return {
|
||||||
|
items: [{ customerId, customerName: `客户${customerId}`, customerType: 'enterprise', customerTypeName: '企业客户', status: '0', statusText: '正常', realNameStatus: '已录入实名资料', cardCount: 0, normalCardCount: 0 }],
|
||||||
|
total,
|
||||||
|
summary: { total, normalCount: total, pendingProfileCount: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('客户列表请求状态', () => {
|
||||||
|
it('列表不展示机构编号或内部ID替代编号', () => {
|
||||||
|
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||||
|
expect(source).not.toContain('customerLabel')
|
||||||
|
expect(source).not.toContain('customerCode')
|
||||||
|
expect(source).not.toContain('ID ${')
|
||||||
|
expect(source).not.toContain('搜索企业名称 / 编码')
|
||||||
|
})
|
||||||
|
it('加载更多沿用已提交的搜索条件', async () => {
|
||||||
|
const harness = createHarness()
|
||||||
|
harness.requests.mockResolvedValueOnce(result(1)).mockResolvedValueOnce(result(2))
|
||||||
|
harness.evaluate('keyword.value = \'企业甲\'')
|
||||||
|
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||||
|
harness.evaluate('keyword.value = \'企业乙\'')
|
||||||
|
await harness.evaluate<Promise<void>>('loadCustomers(false)')
|
||||||
|
expect(harness.requests.mock.calls[1][0]).toMatchObject({ page: 2, keyword: '企业甲' })
|
||||||
|
expect(harness.requests.mock.calls[1][0]).not.toHaveProperty('status')
|
||||||
|
expect(harness.evaluate<number[]>('customers.value.map(item => item.customerId)')).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('旧搜索晚返回时不覆盖新结果或总数', async () => {
|
||||||
|
const harness = createHarness()
|
||||||
|
let resolveOld!: (value: CustomerListRes) => void
|
||||||
|
harness.requests.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveOld = resolve
|
||||||
|
}))
|
||||||
|
harness.requests.mockResolvedValueOnce(result(2, 3))
|
||||||
|
const oldRequest = harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||||
|
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||||
|
resolveOld(result(1, 10))
|
||||||
|
await oldRequest
|
||||||
|
expect(harness.evaluate<number[]>('customers.value.map(item => item.customerId)')).toEqual([2])
|
||||||
|
expect(harness.evaluate<number>('total.value')).toBe(3)
|
||||||
|
expect(harness.evaluate<boolean>('loading.value')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('页面卸载后不再处理返回结果或发起请求', async () => {
|
||||||
|
const harness = createHarness()
|
||||||
|
let resolveRequest!: (value: CustomerListRes) => void
|
||||||
|
harness.requests.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve
|
||||||
|
}))
|
||||||
|
const request = harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||||
|
harness.unmount()
|
||||||
|
resolveRequest(result(1))
|
||||||
|
await request
|
||||||
|
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||||
|
expect(harness.evaluate<number>('customers.value.length')).toBe(0)
|
||||||
|
expect(harness.requests).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('失去权限后清空旧数据并拒绝加载', async () => {
|
||||||
|
const harness = createHarness()
|
||||||
|
harness.requests.mockResolvedValueOnce(result(1))
|
||||||
|
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||||
|
harness.features.allowed = false
|
||||||
|
await harness.evaluate<Promise<void>>('loadCustomers(true)')
|
||||||
|
expect(harness.evaluate<number>('customers.value.length')).toBe(0)
|
||||||
|
expect(harness.evaluate<number>('total.value')).toBe(0)
|
||||||
|
expect(harness.evaluate<boolean>('noPermission.value')).toBe(true)
|
||||||
|
expect(harness.requests).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,7 +14,7 @@ definePage({
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const customerType = ref<AppCustomerType>('enterprise')
|
const customerType: AppCustomerType = 'enterprise'
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -22,6 +22,11 @@ const customers = ref<AppCustomer[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
const noPermission = 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 hasMore = computed(() => customers.value.length < total.value)
|
||||||
const workspace = computed(() => getAccountWorkspace(userStore.userInfo))
|
const workspace = computed(() => getAccountWorkspace(userStore.userInfo))
|
||||||
@@ -32,32 +37,18 @@ const summaryText = computed(() => {
|
|||||||
return `共 ${total.value} 个企业客户`
|
return `共 ${total.value} 个企业客户`
|
||||||
})
|
})
|
||||||
const workspaceLabel = computed(() => getAccountWorkspaceLabel(workspace.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) {
|
function realNameTagType(item: AppCustomer) {
|
||||||
return item.realNameStatus === '已录入实名资料' ? 'success' : 'warning'
|
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) {
|
function buildQuery(nextPage: number) {
|
||||||
return {
|
return {
|
||||||
page: nextPage,
|
page: nextPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
keyword: keyword.value.trim() || undefined,
|
keyword: submittedKeyword.value || undefined,
|
||||||
customerType: customerType.value,
|
customerType,
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,29 +78,51 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCustomers(reset = true) {
|
async function loadCustomers(reset = true) {
|
||||||
|
if (disposed)
|
||||||
|
return
|
||||||
if (!canUseCustomerList.value) {
|
if (!canUseCustomerList.value) {
|
||||||
|
requestVersion++
|
||||||
|
customers.value = []
|
||||||
|
total.value = 0
|
||||||
|
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
noPermission.value = false
|
||||||
if (reset) {
|
if (reset) {
|
||||||
|
submittedKeyword.value = keyword.value.trim()
|
||||||
|
customers.value = []
|
||||||
|
total.value = 0
|
||||||
|
|
||||||
|
loadingMore.value = false
|
||||||
page.value = 1
|
page.value = 1
|
||||||
loading.value = true
|
loading.value = true
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (!hasMore.value || loadingMore.value)
|
if (loading.value || !hasMore.value || loadingMore.value)
|
||||||
return
|
return
|
||||||
loadingMore.value = true
|
loadingMore.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentRequest = ++requestVersion
|
||||||
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const nextPage = reset ? 1 : page.value + 1
|
const nextPage = reset ? 1 : page.value + 1
|
||||||
const res = await getCustomerList(buildQuery(nextPage))
|
const res = await getCustomerList(buildQuery(nextPage))
|
||||||
|
if (currentRequest !== requestVersion)
|
||||||
|
return
|
||||||
const list = res.items || []
|
const list = res.items || []
|
||||||
customers.value = reset ? list : [...customers.value, ...list]
|
customers.value = reset ? list : [...customers.value, ...list]
|
||||||
total.value = res.total || 0
|
total.value = res.total || 0
|
||||||
|
|
||||||
page.value = nextPage
|
page.value = nextPage
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (currentRequest !== requestVersion)
|
||||||
|
return
|
||||||
|
errorMessage.value = getErrorMessage(error, '客户列表加载失败')
|
||||||
if (reset) {
|
if (reset) {
|
||||||
customers.value = []
|
customers.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
@@ -117,9 +130,11 @@ async function loadCustomers(reset = true) {
|
|||||||
uni.showToast({ title: getErrorMessage(error, '客户列表加载失败'), icon: 'none' })
|
uni.showToast({ title: getErrorMessage(error, '客户列表加载失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (currentRequest === requestVersion) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
loadingMore.value = false
|
loadingMore.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchChange() {
|
function handleSearchChange() {
|
||||||
@@ -127,6 +142,8 @@ function handleSearchChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDetail(item: AppCustomer) {
|
function handleDetail(item: AppCustomer) {
|
||||||
|
if (!canUseCustomerDetail.value)
|
||||||
|
return
|
||||||
if (item.customerType !== 'enterprise') {
|
if (item.customerType !== 'enterprise') {
|
||||||
uni.showToast({ title: '暂不支持查看终端客户', icon: 'none' })
|
uni.showToast({ title: '暂不支持查看终端客户', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -138,6 +155,8 @@ function handleDetail(item: AppCustomer) {
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await userStore.fetchUserInfo().catch(() => undefined)
|
await userStore.fetchUserInfo().catch(() => undefined)
|
||||||
|
if (disposed)
|
||||||
|
return
|
||||||
if (!canUseCustomerList.value) {
|
if (!canUseCustomerList.value) {
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
uni.showToast({ title: '当前账号不能查看客户管理', icon: 'none' })
|
uni.showToast({ title: '当前账号不能查看客户管理', icon: 'none' })
|
||||||
@@ -146,6 +165,11 @@ async function bootstrap() {
|
|||||||
await loadCustomers(true)
|
await loadCustomers(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
requestVersion++
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
})
|
})
|
||||||
@@ -157,16 +181,22 @@ onMounted(() => {
|
|||||||
<wd-card custom-class="!mx-0 !mb-0">
|
<wd-card custom-class="!mx-0 !mb-0">
|
||||||
<view class="flex items-center justify-between gap-20rpx">
|
<view class="flex items-center justify-between gap-20rpx">
|
||||||
<view class="min-w-0">
|
<view class="min-w-0">
|
||||||
<view class="customer-kicker">{{ workspaceLabel }}工作台</view>
|
<view class="block text-20rpx text-#2563eb font-700">
|
||||||
<view class="customer-title">客户管理</view>
|
{{ workspaceLabel }}工作台
|
||||||
<view class="customer-subtitle">
|
</view>
|
||||||
|
<view class="mt-4rpx text-34rpx text-#111827 font-900">
|
||||||
|
客户管理
|
||||||
|
</view>
|
||||||
|
<view class="mt-6rpx block text-23rpx text-#64748b">
|
||||||
{{ noPermission ? '当前账号暂无客户管理权限' : summaryText }}
|
{{ noPermission ? '当前账号暂无客户管理权限' : summaryText }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<wd-button
|
<wd-button
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
variant="text"
|
||||||
|
icon="refresh"
|
||||||
|
custom-class="!h-64rpx !px-16rpx !text-24rpx !font-500"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="noPermission"
|
:disabled="noPermission"
|
||||||
@click="loadCustomers(true)"
|
@click="loadCustomers(true)"
|
||||||
@@ -184,41 +214,24 @@ onMounted(() => {
|
|||||||
hide-cancel
|
hide-cancel
|
||||||
placeholder-left
|
placeholder-left
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder="搜索企业名称 / 编码 / 手机号"
|
placeholder="搜索企业名称 / 手机号"
|
||||||
custom-class="!p-0"
|
custom-class="!p-0"
|
||||||
@search="handleSearchChange"
|
@search="handleSearchChange"
|
||||||
@clear="handleSearchChange"
|
@clear="handleSearchChange"
|
||||||
/>
|
/>
|
||||||
<view class="rounded-8rpx bg-#f8fafc px-18rpx py-14rpx text-24rpx text-#64748b">
|
|
||||||
当前展示本企业直属下级企业客户,终端客户暂不展示。
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</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-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||||
<wd-cell-group v-if="customers.length" :border="false">
|
<wd-cell-group v-if="customers.length" :border="false">
|
||||||
<wd-cell
|
<wd-cell
|
||||||
v-for="item in customers"
|
v-for="item in customers"
|
||||||
:key="`${item.customerType}-${item.customerId}`"
|
:key="`${item.customerType}-${item.customerId}`"
|
||||||
:title="item.customerName"
|
:title="item.customerName"
|
||||||
:label="customerLabel(item)"
|
title-width="42%"
|
||||||
is-link
|
:label="item.customerTypeName || '企业客户'"
|
||||||
clickable
|
:is-link="canUseCustomerDetail"
|
||||||
|
:clickable="canUseCustomerDetail"
|
||||||
center
|
center
|
||||||
custom-class="!min-h-164rpx"
|
custom-class="!min-h-164rpx"
|
||||||
custom-title-class="!text-28rpx !text-#111827 !font-800"
|
custom-title-class="!text-28rpx !text-#111827 !font-800"
|
||||||
@@ -229,11 +242,8 @@ onMounted(() => {
|
|||||||
<view class="text-28rpx text-#111827 font-900">
|
<view class="text-28rpx text-#111827 font-900">
|
||||||
{{ item.cardCount || 0 }} 张卡
|
{{ item.cardCount || 0 }} 张卡
|
||||||
</view>
|
</view>
|
||||||
<view class="flex items-center gap-8rpx">
|
<view class="flex flex-wrap items-center justify-end gap-8rpx">
|
||||||
<wd-tag :type="statusTagType(item)" variant="plain">
|
<wd-tag :type="realNameTagType(item)" variant="light" round custom-class="!shrink-0 !whitespace-nowrap">
|
||||||
{{ item.statusText || '未设置' }}
|
|
||||||
</wd-tag>
|
|
||||||
<wd-tag :type="realNameTagType(item)" variant="plain">
|
|
||||||
{{ item.realNameStatus }}
|
{{ item.realNameStatus }}
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
@@ -242,14 +252,14 @@ onMounted(() => {
|
|||||||
</wd-cell-group>
|
</wd-cell-group>
|
||||||
|
|
||||||
<view v-else class="p-48rpx">
|
<view v-else class="p-48rpx">
|
||||||
<wd-empty :tip="loading ? '客户加载中' : '暂无客户'" />
|
<wd-empty :tip="loading ? '客户加载中' : (errorMessage || '暂无符合条件的客户')" />
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
<wd-button
|
<wd-button
|
||||||
v-if="hasMore"
|
v-if="hasMore"
|
||||||
|
|
||||||
plain block
|
variant="text" block
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="loadingMore"
|
:loading="loadingMore"
|
||||||
@click="loadCustomers(false)"
|
@click="loadCustomers(false)"
|
||||||
@@ -260,70 +270,3 @@ onMounted(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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 { getCustomerDetail } from '../api/customer'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { hasAnyFeature } from '@/utils/permission'
|
import { hasAnyFeature } from '@/utils/permission'
|
||||||
|
import { getCustomerCardStatusItems } from './data'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
@@ -17,7 +18,10 @@ const queryType = ref('')
|
|||||||
const queryId = ref(0)
|
const queryId = ref(0)
|
||||||
const noPermission = ref(false)
|
const noPermission = ref(false)
|
||||||
const userStore = useUserStore()
|
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 titleText = computed(() => customer.value?.customerName || '客户详情')
|
||||||
const canUseCustomerDetail = computed(() => hasAnyFeature(userStore.userInfo, 'customer:detail'))
|
const canUseCustomerDetail = computed(() => hasAnyFeature(userStore.userInfo, 'customer:detail'))
|
||||||
|
|
||||||
@@ -27,13 +31,10 @@ const baseRows = computed(() => {
|
|||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
['客户类型', item.customerTypeName || '-'],
|
['客户类型', item.customerTypeName || '-'],
|
||||||
['客户编码', item.customerCode || (item.terminalId ? `ID ${item.terminalId}` : `ID ${item.customerId}`)],
|
|
||||||
['联系电话', item.phone || '-'],
|
['联系电话', item.phone || '-'],
|
||||||
['邮箱', item.email || '-'],
|
['邮箱', item.email || '-'],
|
||||||
['客户状态', item.statusText || '-'],
|
['实名资料状态', item.realNameStatus || '-'],
|
||||||
['实名状态', item.realNameStatus || '-'],
|
['直接持卡数量', `${item.cardCount || 0} 张`],
|
||||||
['卡片数量', `${item.cardCount || 0} 张`],
|
|
||||||
['正常卡数', `${item.normalCardCount || 0} 张`],
|
|
||||||
['地址', item.address || '-'],
|
['地址', item.address || '-'],
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -44,9 +45,7 @@ const enterpriseRows = computed(() => {
|
|||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
['企业名称', profile.deptName || '-'],
|
['企业名称', profile.deptName || '-'],
|
||||||
['客户编码', profile.deptKey || '-'],
|
|
||||||
['联系电话', profile.phone || '-'],
|
['联系电话', profile.phone || '-'],
|
||||||
['企业状态', profile.statusText || '-'],
|
|
||||||
['统一信用代码', profile.creditCode || '-'],
|
['统一信用代码', profile.creditCode || '-'],
|
||||||
['单位全称', profile.fullName || '-'],
|
['单位全称', profile.fullName || '-'],
|
||||||
['行业', profile.industry || '-'],
|
['行业', 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) {
|
function normalizeQueryValue(value: string | string[] | undefined) {
|
||||||
return Array.isArray(value) ? value[0] : String(value || '')
|
return Array.isArray(value) ? value[0] : String(value || '')
|
||||||
}
|
}
|
||||||
@@ -95,12 +86,14 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCustomerDetail() {
|
async function loadCustomerDetail() {
|
||||||
|
if (disposed || loading.value)
|
||||||
|
return
|
||||||
if (!canUseCustomerDetail.value) {
|
if (!canUseCustomerDetail.value) {
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
errorMessage.value = '当前账号不能查看客户详情'
|
errorMessage.value = '当前账号不能查看客户详情'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!queryType.value || !queryId.value) {
|
if (!queryType.value || !Number.isSafeInteger(queryId.value) || queryId.value <= 0) {
|
||||||
errorMessage.value = '客户参数不完整'
|
errorMessage.value = '客户参数不完整'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,6 +101,8 @@ async function loadCustomerDetail() {
|
|||||||
errorMessage.value = '暂不支持查看终端客户'
|
errorMessage.value = '暂不支持查看终端客户'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
noPermission.value = false
|
||||||
|
const currentRequest = ++requestVersion
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -115,13 +110,18 @@ async function loadCustomerDetail() {
|
|||||||
customerType: queryType.value,
|
customerType: queryType.value,
|
||||||
customerId: queryId.value,
|
customerId: queryId.value,
|
||||||
})
|
})
|
||||||
|
if (currentRequest !== requestVersion)
|
||||||
|
return
|
||||||
customer.value = res.customer
|
customer.value = res.customer
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (currentRequest !== requestVersion)
|
||||||
|
return
|
||||||
customer.value = undefined
|
customer.value = undefined
|
||||||
errorMessage.value = getErrorMessage(error, '客户详情加载失败')
|
errorMessage.value = getErrorMessage(error, '客户详情加载失败')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (currentRequest === requestVersion)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +133,8 @@ onLoad((query) => {
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await userStore.fetchUserInfo().catch(() => undefined)
|
await userStore.fetchUserInfo().catch(() => undefined)
|
||||||
|
if (disposed)
|
||||||
|
return
|
||||||
if (!canUseCustomerDetail.value) {
|
if (!canUseCustomerDetail.value) {
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
uni.showToast({ title: '当前账号不能查看客户详情', icon: 'none' })
|
uni.showToast({ title: '当前账号不能查看客户详情', icon: 'none' })
|
||||||
@@ -141,6 +143,11 @@ async function bootstrap() {
|
|||||||
await loadCustomerDetail()
|
await loadCustomerDetail()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
requestVersion++
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
})
|
})
|
||||||
@@ -159,9 +166,6 @@ onMounted(() => {
|
|||||||
{{ customer?.customerTypeName || '客户资料' }}
|
{{ customer?.customerTypeName || '客户资料' }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<wd-tag :type="statusTagType(customer)" variant="plain">
|
|
||||||
{{ customer?.statusText || '加载中' }}
|
|
||||||
</wd-tag>
|
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
@@ -188,27 +192,30 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template v-else-if="customer">
|
<template v-else-if="customer">
|
||||||
<wd-card custom-class="!mx-0 !mb-0">
|
<wd-card custom-class="!mx-0 !mb-0">
|
||||||
<view class="grid grid-cols-2 gap-12rpx text-center">
|
<view class="flex items-center justify-between rounded-12rpx bg-#f8fafc px-24rpx py-20rpx">
|
||||||
<view class="rounded-8rpx bg-#f8fafc px-12rpx py-18rpx">
|
<view class="flex items-baseline gap-8rpx">
|
||||||
<view class="text-30rpx text-#111827 font-900">
|
<text class="text-38rpx text-#0f172a font-800">{{ customer.cardCount || 0 }}</text>
|
||||||
{{ customer.cardCount || 0 }}
|
<text class="text-23rpx text-#64748b">张卡</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-6rpx text-22rpx text-#64748b">
|
<text class="text-24rpx text-#64748b">持卡总数</text>
|
||||||
卡片
|
</view>
|
||||||
</view>
|
<view class="mt-24rpx border-x-0 border-b-0 border-t-1 border-#e2e8f0 border-solid pt-24rpx">
|
||||||
</view>
|
<view class="mb-18rpx flex items-center gap-12rpx">
|
||||||
<view class="rounded-8rpx bg-#f8fafc px-12rpx py-18rpx">
|
<view class="h-26rpx w-6rpx rounded-full bg-#2563eb" />
|
||||||
<view class="text-30rpx text-#2563eb font-900">
|
<text class="text-26rpx text-#0f172a font-800">运营商卡状态</text>
|
||||||
{{ customer.normalCardCount || 0 }}
|
</view>
|
||||||
</view>
|
<view v-if="cardStatusItems.length" class="grid grid-cols-3 gap-12rpx">
|
||||||
<view class="mt-6rpx text-22rpx text-#64748b">
|
<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>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-else class="text-24rpx text-#64748b">暂无状态统计,请刷新后重试</text>
|
||||||
|
<text class="mt-16rpx block text-21rpx text-#94a3b8 leading-32rpx">仅统计该客户直接持有的卡片,不含下级客户。</text>
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
<wd-card custom-class="!mx-0 !mb-0" custom-title-class="!p-0" custom-content-class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="h-80rpx flex items-center px-24rpx">
|
<view class="h-80rpx flex items-center px-24rpx">
|
||||||
<text class="text-28rpx text-#111827 font-900">基础信息</text>
|
<text class="text-28rpx text-#111827 font-900">基础信息</text>
|
||||||
@@ -228,6 +235,7 @@ onMounted(() => {
|
|||||||
<wd-card
|
<wd-card
|
||||||
v-if="enterpriseRows.length"
|
v-if="enterpriseRows.length"
|
||||||
custom-class="!mx-0 !mb-0"
|
custom-class="!mx-0 !mb-0"
|
||||||
|
custom-title-class="!p-0"
|
||||||
custom-content-class="!p-0"
|
custom-content-class="!p-0"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createContext, runInContext } from 'node:vm'
|
||||||
|
import { transpileModule } from 'typescript'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
async function drawPoints(points: Array<{ x: number, y: number, available: boolean }>) {
|
||||||
|
const methods = ['clearRect', 'setStrokeStyle', 'setLineWidth', 'beginPath', 'moveTo', 'lineTo', 'stroke', 'setLineCap', 'setLineJoin', 'setFillStyle', 'arc', 'fill', 'draw']
|
||||||
|
const canvas = Object.fromEntries(methods.map(method => [method, vi.fn()]))
|
||||||
|
const query = {
|
||||||
|
in: () => query,
|
||||||
|
select: () => query,
|
||||||
|
boundingClientRect: (callback: (rect: { width: number, height: number }) => void) => {
|
||||||
|
callback({ width: 600, height: 190 })
|
||||||
|
return query
|
||||||
|
},
|
||||||
|
exec: vi.fn(),
|
||||||
|
}
|
||||||
|
const context = createContext({
|
||||||
|
defineProps: () => ({ points }),
|
||||||
|
getCurrentInstance: () => ({ proxy: {} }),
|
||||||
|
nextTick: async () => undefined,
|
||||||
|
onMounted: vi.fn(),
|
||||||
|
onUnmounted: vi.fn(),
|
||||||
|
watch: vi.fn(),
|
||||||
|
uni: { createSelectorQuery: () => query, createCanvasContext: () => canvas },
|
||||||
|
})
|
||||||
|
const source = readFileSync(new URL('./DiagnosisFlowChart.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.split('<script setup lang="ts">')[1]?.split('</script>')[0]
|
||||||
|
if (!script)
|
||||||
|
throw new Error('missing chart script')
|
||||||
|
runInContext(transpileModule(script, {}).outputText, context)
|
||||||
|
await runInContext('draw()', context)
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('小程序流量图绘制', () => {
|
||||||
|
it('只有一个采集点也绘制圆点并提交画布', async () => {
|
||||||
|
const ctx = await drawPoints([{ x: 300, y: 80, available: true }])
|
||||||
|
expect(ctx.arc).toHaveBeenCalledWith(300, 80, 3, 0, Math.PI * 2)
|
||||||
|
expect(ctx.draw).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
it('未采集周期不画点且断开折线', async () => {
|
||||||
|
const ctx = await drawPoints([
|
||||||
|
{ x: 20, y: 80, available: true },
|
||||||
|
{ x: 300, y: 156, available: false },
|
||||||
|
{ x: 580, y: 40, available: true },
|
||||||
|
])
|
||||||
|
expect(ctx.arc).toHaveBeenCalledTimes(2)
|
||||||
|
expect(ctx.moveTo).toHaveBeenCalledWith(580, 40)
|
||||||
|
expect(ctx.lineTo).not.toHaveBeenCalledWith(580, 40)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface FlowPoint {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
available: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ points: FlowPoint[] }>()
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
const canvasId = 'diagnosis-flow-chart'
|
||||||
|
let revision = 0
|
||||||
|
|
||||||
|
async function draw() {
|
||||||
|
const current = ++revision
|
||||||
|
await nextTick()
|
||||||
|
if (!instance?.proxy)
|
||||||
|
return
|
||||||
|
const proxy = instance.proxy
|
||||||
|
uni.createSelectorQuery().in(proxy).select('.diagnosis-flow-canvas').boundingClientRect((rect) => {
|
||||||
|
if (current !== revision || !rect || Array.isArray(rect) || !rect.width || !rect.height)
|
||||||
|
return
|
||||||
|
const ctx = uni.createCanvasContext(canvasId, proxy)
|
||||||
|
const scaleX = rect.width / 600
|
||||||
|
const scaleY = rect.height / 190
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||||
|
ctx.setStrokeStyle('#e2eaf4')
|
||||||
|
ctx.setLineWidth(1)
|
||||||
|
for (const y of [18, 87, 178]) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(20 * scaleX, y * scaleY)
|
||||||
|
ctx.lineTo(580 * scaleX, y * scaleY)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
ctx.setStrokeStyle('#2563eb')
|
||||||
|
ctx.setLineWidth(2)
|
||||||
|
ctx.setLineCap('round')
|
||||||
|
ctx.setLineJoin('round')
|
||||||
|
ctx.beginPath()
|
||||||
|
let connected = false
|
||||||
|
for (const point of props.points) {
|
||||||
|
if (!point.available) {
|
||||||
|
connected = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (connected)
|
||||||
|
ctx.lineTo(point.x * scaleX, point.y * scaleY)
|
||||||
|
else
|
||||||
|
ctx.moveTo(point.x * scaleX, point.y * scaleY)
|
||||||
|
connected = true
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setFillStyle('#ffffff')
|
||||||
|
for (const point of props.points.filter(point => point.available)) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(point.x * scaleX, point.y * scaleY, 3, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
ctx.draw()
|
||||||
|
}).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(draw)
|
||||||
|
watch(() => props.points, draw, { deep: true, flush: 'post' })
|
||||||
|
onUnmounted(() => { revision++ })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas :canvas-id="canvasId" :id="canvasId" class="diagnosis-flow-canvas block h-190rpx w-full" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
diagnosisDataServiceDisplay,
|
||||||
|
diagnosisFlowRetryHint,
|
||||||
|
diagnosisOnlineStatusDisplay,
|
||||||
|
diagnosisSessionStatusDisplay,
|
||||||
|
isDiagnosisNoDataText,
|
||||||
|
isDiagnosisUnsupportedModule,
|
||||||
|
pickDiagnosisFlowValue,
|
||||||
|
pickDiagnosisRemainingFlowValue,
|
||||||
|
resolveDiagnosisOnlineStatus,
|
||||||
|
shouldHideDiagnosisModule,
|
||||||
|
shouldHideDiagnosisText,
|
||||||
|
} from './display'
|
||||||
|
|
||||||
|
describe('在线状态展示', () => {
|
||||||
|
it('未知、缺失或非法状态统一显示横线', () => {
|
||||||
|
for (const status of ['2', 'UNKNOWN', '', 'unexpected', 'constructor', 'toString', '__proto__']) {
|
||||||
|
expect(diagnosisOnlineStatusDisplay(status)).toBe('-')
|
||||||
|
}
|
||||||
|
for (const status of ['-2', '02', '2', 'UNKNOWN', '', 'unexpected', 'constructor', 'toString', '__proto__']) {
|
||||||
|
expect(diagnosisSessionStatusDisplay(status)).toBe('-')
|
||||||
|
}
|
||||||
|
expect(diagnosisOnlineStatusDisplay()).toBe('-')
|
||||||
|
expect(diagnosisSessionStatusDisplay()).toBe('-')
|
||||||
|
})
|
||||||
|
it('保留明确的在线、离线和不支持状态', () => {
|
||||||
|
expect(diagnosisOnlineStatusDisplay('1')).toBe('在线')
|
||||||
|
expect(diagnosisOnlineStatusDisplay('0')).toBe('离线')
|
||||||
|
expect(diagnosisSessionStatusDisplay('0')).toBe('在线')
|
||||||
|
expect(diagnosisSessionStatusDisplay('-1')).toBe('离线')
|
||||||
|
expect(diagnosisSessionStatusDisplay('UNSUPPORTED')).toBe('不支持')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('实时会话状态优先', () => {
|
||||||
|
it('明确会话状态覆盖汇总状态,异常状态不回退旧在线值', () => {
|
||||||
|
expect(resolveDiagnosisOnlineStatus('0', '0')).toBe('1')
|
||||||
|
expect(resolveDiagnosisOnlineStatus('1', '-1')).toBe('0')
|
||||||
|
for (const status of ['-2', '02', '2', 'UNKNOWN', 'unexpected', 'UNSUPPORTED']) {
|
||||||
|
expect(resolveDiagnosisOnlineStatus('1', status)).toBe('2')
|
||||||
|
}
|
||||||
|
expect(resolveDiagnosisOnlineStatus('1', undefined)).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('数据服务来源隔离', () => {
|
||||||
|
it('电信展示断网状态,不使用 APN 开关值代替', () => {
|
||||||
|
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '1', dataStatus: '1' })).toBe('已断网')
|
||||||
|
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '0', dataStatus: '0' })).toBe('未断网')
|
||||||
|
})
|
||||||
|
it('电信缺少断网状态时显示横线,不回退 APN 开关', () => {
|
||||||
|
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', dataStatus: '1' })).toBe('-')
|
||||||
|
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '2' })).toBe('-')
|
||||||
|
})
|
||||||
|
it('移动仍使用 APN 开停状态', () => {
|
||||||
|
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'apn', dataStatus: '1' })).toBe('开启')
|
||||||
|
expect(diagnosisDataServiceDisplay({ dataServiceSource: 'apn', dataStatus: '0' })).toBe('关闭')
|
||||||
|
expect(diagnosisDataServiceDisplay({})).toBe('-')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('diagnosis display rules', () => {
|
||||||
|
it('hides unsubscribed and unsupported modules', () => {
|
||||||
|
expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'not_subscribed' })).toBe(true)
|
||||||
|
expect(shouldHideDiagnosisModule({ status: 'skipped', message: '该卡未订购机卡绑定服务' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps no-data and query-error modules visible', () => {
|
||||||
|
expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'no_data' })).toBe(false)
|
||||||
|
expect(shouldHideDiagnosisModule({ status: 'error', reason: 'query_error' })).toBe(false)
|
||||||
|
expect(shouldHideDiagnosisModule({ status: 'skipped', message: '查询暂时未完成,请稍后重试' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows unsupported modules as unsupported', () => {
|
||||||
|
expect(isDiagnosisUnsupportedModule({ status: 'skipped', reason: 'unsupported' })).toBe(true)
|
||||||
|
expect(isDiagnosisUnsupportedModule({ status: 'skipped', message: '运营商暂不支持' })).toBe(true)
|
||||||
|
expect(isDiagnosisUnsupportedModule({ status: 'skipped' })).toBe(true)
|
||||||
|
expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'unsupported' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes empty data to a dash without hiding it', () => {
|
||||||
|
expect(isDiagnosisNoDataText('')).toBe(true)
|
||||||
|
expect(isDiagnosisNoDataText('暂无数据')).toBe(true)
|
||||||
|
expect(shouldHideDiagnosisText('暂无数据')).toBe(false)
|
||||||
|
expect(shouldHideDiagnosisText('运营商暂不支持')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat a failed flow query default zero as real usage', () => {
|
||||||
|
expect(pickDiagnosisFlowValue(
|
||||||
|
{ status: 'error', reason: 'query_error' },
|
||||||
|
[null, undefined],
|
||||||
|
[0],
|
||||||
|
)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps successful decimal flow usage and real zero values', () => {
|
||||||
|
expect(pickDiagnosisFlowValue({ status: 'success' }, [0.03], [0])).toBe(0.03)
|
||||||
|
expect(pickDiagnosisFlowValue({ status: 'cached' }, [null], [0])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show default remaining flow as upstream data', () => {
|
||||||
|
expect(pickDiagnosisRemainingFlowValue({ status: 'error' }, [0])).toBeNull()
|
||||||
|
expect(pickDiagnosisRemainingFlowValue({ status: 'success' }, [null, undefined])).toBeNull()
|
||||||
|
expect(pickDiagnosisRemainingFlowValue({ status: 'success' }, [0])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only shows a retry hint for reachable business failures', () => {
|
||||||
|
expect(diagnosisFlowRetryHint({ status: 'error', message: '服务繁忙,请重试' })).toBe('服务繁忙,请重试')
|
||||||
|
expect(diagnosisFlowRetryHint({ status: 'error' })).toBe('')
|
||||||
|
expect(diagnosisFlowRetryHint({ status: 'success', message: '服务繁忙,请重试' })).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { DiagnosisInfoItem, DiagnosisModuleItem } from '@/api/diagnosis'
|
||||||
|
|
||||||
|
export function diagnosisOnlineStatusDisplay(status?: string): string {
|
||||||
|
const labels: Record<string, string> = { 0: '离线', 1: '在线' }
|
||||||
|
const value = String(status ?? '').trim()
|
||||||
|
return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diagnosisSessionStatusDisplay(status?: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'0': '在线',
|
||||||
|
'01': '在线',
|
||||||
|
'ONLINE': '在线',
|
||||||
|
'-1': '离线',
|
||||||
|
'00': '离线',
|
||||||
|
'OFFLINE': '离线',
|
||||||
|
'UNSUPPORTED': '不支持',
|
||||||
|
}
|
||||||
|
const value = String(status ?? '').trim()
|
||||||
|
return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiagnosisOnlineStatus(
|
||||||
|
infoStatus?: string | number,
|
||||||
|
sessionStatus?: string | number,
|
||||||
|
): string {
|
||||||
|
const status = String(sessionStatus ?? '').trim()
|
||||||
|
switch (status) {
|
||||||
|
case '0':
|
||||||
|
case '01':
|
||||||
|
case 'ONLINE':
|
||||||
|
return '1'
|
||||||
|
case '-1':
|
||||||
|
case '00':
|
||||||
|
case 'OFFLINE':
|
||||||
|
return '0'
|
||||||
|
default:
|
||||||
|
return status ? '2' : String(infoStatus ?? '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diagnosisDataServiceDisplay(info?: DiagnosisInfoItem): string {
|
||||||
|
const networkBlock = info?.dataServiceSource === 'network_block'
|
||||||
|
const labels: Record<string, string> = networkBlock
|
||||||
|
? { 0: '未断网', 1: '已断网' }
|
||||||
|
: { '0': '关闭', '1': '开启', '-1': '未知' }
|
||||||
|
const status = networkBlock ? info?.netBlockStatus : info?.dataStatus
|
||||||
|
const value = String(status ?? '').trim()
|
||||||
|
return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOT_SUBSCRIBED_MESSAGE_RE = /未订购|未开通|未申请|通道未申请/
|
||||||
|
const UNSUPPORTED_MESSAGE_RE = /不支持|暂不支持|暂不提供|暂未提供|未提供独立|无独立.*接口|当前无.*接口/
|
||||||
|
const NO_DATA_MESSAGE_RE = /未返回|未查询到|无数据|暂无数据|暂时未完成/
|
||||||
|
|
||||||
|
export function shouldHideDiagnosisModule(module?: DiagnosisModuleItem) {
|
||||||
|
const reason = String(module?.reason || '').trim()
|
||||||
|
if (reason === 'not_subscribed')
|
||||||
|
return true
|
||||||
|
if (reason === 'unsupported' || reason === 'no_data' || reason === 'query_error')
|
||||||
|
return false
|
||||||
|
|
||||||
|
const status = String(module?.status || '').trim()
|
||||||
|
const message = String(module?.message || '').trim()
|
||||||
|
return NOT_SUBSCRIBED_MESSAGE_RE.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDiagnosisUnsupportedModule(module?: DiagnosisModuleItem) {
|
||||||
|
const reason = String(module?.reason || '').trim()
|
||||||
|
if (reason === 'unsupported')
|
||||||
|
return true
|
||||||
|
if (reason === 'not_subscribed' || reason === 'no_data' || reason === 'query_error')
|
||||||
|
return false
|
||||||
|
const status = String(module?.status || '').trim()
|
||||||
|
const message = String(module?.message || '').trim()
|
||||||
|
return UNSUPPORTED_MESSAGE_RE.test(message) || (status === 'skipped' && !message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDiagnosisNoDataText(value: unknown) {
|
||||||
|
const text = String(value ?? '').trim()
|
||||||
|
return !text
|
||||||
|
|| text === '-'
|
||||||
|
|| text === '<nil>'
|
||||||
|
|| text === 'undefined'
|
||||||
|
|| text === 'null'
|
||||||
|
|| NO_DATA_MESSAGE_RE.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHideDiagnosisText(value: unknown) {
|
||||||
|
return NOT_SUBSCRIBED_MESSAGE_RE.test(String(value ?? '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickDiagnosisFlowValue(
|
||||||
|
module: DiagnosisModuleItem | undefined,
|
||||||
|
primaryValues: unknown[],
|
||||||
|
fallbackValues: unknown[],
|
||||||
|
) {
|
||||||
|
const primary = firstFiniteNumber(primaryValues)
|
||||||
|
if (primary !== null)
|
||||||
|
return primary
|
||||||
|
|
||||||
|
const status = String(module?.status || '').trim()
|
||||||
|
if (module && status !== 'success' && status !== 'cached')
|
||||||
|
return null
|
||||||
|
|
||||||
|
return firstFiniteNumber(fallbackValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diagnosisFlowRetryHint(module?: DiagnosisModuleItem) {
|
||||||
|
if (String(module?.status || '').trim() !== 'error')
|
||||||
|
return ''
|
||||||
|
return String(module?.message || '').trim() === '服务繁忙,请重试'
|
||||||
|
? '服务繁忙,请重试'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickDiagnosisRemainingFlowValue(module: DiagnosisModuleItem | undefined, values: unknown[]) {
|
||||||
|
const status = String(module?.status || '').trim()
|
||||||
|
if (status !== 'success' && status !== 'cached')
|
||||||
|
return null
|
||||||
|
return firstFiniteNumber(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstFiniteNumber(values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined || value === null || value === '')
|
||||||
|
continue
|
||||||
|
const number = Number(value)
|
||||||
|
if (Number.isFinite(number))
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,9 @@
|
|||||||
import { balanceRechargeRefund } from '../api/balance'
|
import { balanceRechargeRefund } from '../api/balance'
|
||||||
import type { AppOrder, AppOrderItem } from '../api/order'
|
import type { AppOrder, AppOrderItem } from '../api/order'
|
||||||
import { getOrderDetail, getOrderList } 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({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
@@ -13,6 +16,7 @@ type OrderTypeFilter = 'all' | 'renew' | 'recharge'
|
|||||||
type OrderStatusFilter = 'all' | 'pending' | 'paid'
|
type OrderStatusFilter = 'all' | 'pending' | 'paid'
|
||||||
|
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
const userStore = useUserStore()
|
||||||
const orderType = ref<OrderTypeFilter>('all')
|
const orderType = ref<OrderTypeFilter>('all')
|
||||||
const orderStatus = ref<OrderStatusFilter>('all')
|
const orderStatus = ref<OrderStatusFilter>('all')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -328,6 +332,13 @@ async function handleDetail(item: AppOrder) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadOrders(true)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -505,6 +516,13 @@ onMounted(() => {
|
|||||||
</wd-cell-group>
|
</wd-cell-group>
|
||||||
</wd-card>
|
</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">
|
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="h-80rpx flex items-center px-24rpx">
|
<view class="h-80rpx flex items-center px-24rpx">
|
||||||
@@ -542,8 +560,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<wd-button
|
<wd-button
|
||||||
v-if="canRequestRefund(selectedOrder)"
|
v-if="canRequestRefund(selectedOrder)"
|
||||||
block
|
|
||||||
plain
|
plain block
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="refunding"
|
:loading="refunding"
|
||||||
@click="handleRefund"
|
@click="handleRefund"
|
||||||
@@ -553,10 +571,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<wd-button
|
<wd-button
|
||||||
v-else-if="isRefundPending(selectedOrder)"
|
v-else-if="isRefundPending(selectedOrder)"
|
||||||
block
|
|
||||||
plain
|
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled
|
plain disabled block
|
||||||
>
|
>
|
||||||
{{ refundStatusText(selectedOrder) }}
|
{{ refundStatusText(selectedOrder) }}
|
||||||
</wd-button>
|
</wd-button>
|
||||||
|
|||||||
@@ -2,19 +2,15 @@
|
|||||||
import type { AppCard } from '@/api/card'
|
import type { AppCard } from '@/api/card'
|
||||||
import type { WarningConfigBatchItemParams } from '../api/warning'
|
import type { WarningConfigBatchItemParams } from '../api/warning'
|
||||||
import { getCardList } from '@/api/card'
|
import { getCardList } from '@/api/card'
|
||||||
import { bindWechat, getWxCode } from '@/api/login'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
import {
|
import {
|
||||||
getWarningConfigMeta,
|
|
||||||
saveWarningConfigBatch,
|
saveWarningConfigBatch,
|
||||||
updateWarningConfigNotifyBatch,
|
|
||||||
} from '../api/warning'
|
} from '../api/warning'
|
||||||
import {
|
import {
|
||||||
displayCardValue,
|
displayCardValue,
|
||||||
displayPackageName,
|
displayPackageName,
|
||||||
formatFlowPair,
|
formatFlowPair,
|
||||||
} from '@/pages/card/data'
|
} from '@/pages/card/data'
|
||||||
import { useUserStore } from '@/store/user'
|
|
||||||
import { guideWechatSubscribeSettings, requestWechatSubscribeMessage, shouldGuideWechatSubscribeSettings, wechatSubscribeSaveMessage } from '@/utils'
|
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
@@ -40,20 +36,12 @@ interface WarningForm {
|
|||||||
|
|
||||||
const MAX_BATCH_CARD_COUNT = 100
|
const MAX_BATCH_CARD_COUNT = 100
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const cards = ref<AppCard[]>([])
|
const cards = ref<AppCard[]>([])
|
||||||
const selectedCardValues = ref<string[]>([])
|
const selectedCardValues = ref<string[]>([])
|
||||||
const loadingCards = ref(false)
|
const loadingCards = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const notificationSubmitting = ref(false)
|
|
||||||
const binding = ref(false)
|
|
||||||
const errorMessage = ref('')
|
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 showAllCards = ref(false)
|
||||||
const cardPreviewLimit = 6
|
const cardPreviewLimit = 6
|
||||||
|
|
||||||
@@ -96,22 +84,6 @@ const hiddenCardCount = computed(() => Math.max(0, visibleCards.value.length - d
|
|||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
selectedCardValues.value.length > 0 && filledConfigCount.value > 0 && !submitting.value,
|
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}`)
|
const selectedText = computed(() => `${selectedCardValues.value.length}/${MAX_BATCH_CARD_COUNT}`)
|
||||||
|
|
||||||
async function loadCards() {
|
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) {
|
function cardKey(item: AppCard) {
|
||||||
return displayCardValue(item)
|
return displayCardValue(item)
|
||||||
}
|
}
|
||||||
@@ -217,7 +175,6 @@ function buildConfigPayload(validate: boolean): WarningConfigBatchItemParams[] {
|
|||||||
secondWarn,
|
secondWarn,
|
||||||
thirdWarn,
|
thirdWarn,
|
||||||
isEnabled: form.enabled ? 1 : 0,
|
isEnabled: form.enabled ? 1 : 0,
|
||||||
appNotifyEnabled: appNotifyEnabled.value ? 1 : 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (validate && !configs.length)
|
if (validate && !configs.length)
|
||||||
@@ -237,84 +194,6 @@ function validateThreshold(form: WarningForm, firstWarn: number, secondWarn: num
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncBatchNotify() {
|
|
||||||
if (!selectedCardValues.value.length) {
|
|
||||||
uni.showToast({ title: '请先选择要同步的卡片', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (notificationSubmitting.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
notificationSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const subscribeResult = appNotifyEnabled.value
|
|
||||||
? await requestWechatSubscribeMessage(templateId.value, templateReady.value)
|
|
||||||
: undefined
|
|
||||||
const res = await updateWarningConfigNotifyBatch({
|
|
||||||
cardValues: selectedCardValues.value,
|
|
||||||
warnUnit: 1,
|
|
||||||
appNotifyEnabled: appNotifyEnabled.value ? 1 : 0,
|
|
||||||
})
|
|
||||||
const lines = [
|
|
||||||
`成功卡数:${res.successCount || 0} 张`,
|
|
||||||
`失败卡数:${res.failCount || 0} 张`,
|
|
||||||
]
|
|
||||||
if (res.failItems?.length) {
|
|
||||||
lines.push('')
|
|
||||||
lines.push(...res.failItems.slice(0, 3).map(item => `${item.cardValue}:${item.reason}`))
|
|
||||||
}
|
|
||||||
const shouldGuideSettings = Boolean(subscribeResult && shouldGuideWechatSubscribeSettings(subscribeResult))
|
|
||||||
const subscribeMessage = !shouldGuideSettings && subscribeResult
|
|
||||||
? wechatSubscribeSaveMessage(subscribeResult)
|
|
||||||
: ''
|
|
||||||
if (subscribeMessage) {
|
|
||||||
lines.push('')
|
|
||||||
lines.push(subscribeMessage)
|
|
||||||
}
|
|
||||||
uni.showModal({
|
|
||||||
title: res.failCount > 0 ? '部分开关已更新' : '通知开关已更新',
|
|
||||||
content: lines.join('\n'),
|
|
||||||
showCancel: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
uni.showToast({ title: getErrorMessage(error, '更新通知开关失败'), icon: 'none' })
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
notificationSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBindWechat() {
|
|
||||||
// #ifndef MP-WEIXIN
|
|
||||||
uni.showToast({ title: '请在微信小程序中绑定', icon: 'none' })
|
|
||||||
return
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
if (binding.value)
|
|
||||||
return
|
|
||||||
binding.value = true
|
|
||||||
try {
|
|
||||||
const wxRes = await getWxCode()
|
|
||||||
if (!wxRes.code)
|
|
||||||
throw new Error('获取微信登录凭证失败')
|
|
||||||
const res = await bindWechat({ wxCode: wxRes.code })
|
|
||||||
if (res.userInfo)
|
|
||||||
userStore.setUserInfo(res.userInfo)
|
|
||||||
wechatBound.value = true
|
|
||||||
wechatBindText.value = '已绑定'
|
|
||||||
uni.showToast({ title: '微信绑定成功', icon: 'success' })
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
uni.showToast({ title: getErrorMessage(error, '微信绑定失败'), icon: 'none' })
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
binding.value = false
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!selectedCardValues.value.length) {
|
if (!selectedCardValues.value.length) {
|
||||||
uni.showToast({ title: '请先选择卡片', icon: 'none' })
|
uni.showToast({ title: '请先选择卡片', icon: 'none' })
|
||||||
@@ -326,24 +205,14 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const subscribeResult = appNotifyEnabled.value
|
|
||||||
? await requestWechatSubscribeMessage(templateId.value, templateReady.value)
|
|
||||||
: undefined
|
|
||||||
const res = await saveWarningConfigBatch({
|
const res = await saveWarningConfigBatch({
|
||||||
cardValues: selectedCardValues.value,
|
cardValues: selectedCardValues.value,
|
||||||
configs,
|
configs,
|
||||||
})
|
})
|
||||||
wechatBound.value = Boolean(res.wechatBound)
|
|
||||||
wechatBindText.value = res.wechatBindText || (res.wechatBound ? '已绑定' : '未绑定')
|
|
||||||
const shouldGuideSettings = Boolean(subscribeResult && shouldGuideWechatSubscribeSettings(subscribeResult))
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: res.failCount > 0 ? '部分配置成功' : '配置成功',
|
title: res.failCount > 0 ? '部分配置成功' : '配置成功',
|
||||||
content: buildResultText(res, subscribeResult),
|
content: buildResultText(res),
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
success: () => {
|
|
||||||
if (shouldGuideSettings)
|
|
||||||
guideWechatSubscribeSettings(subscribeResult!)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -359,7 +228,7 @@ function buildResultText(res: {
|
|||||||
successCount?: number
|
successCount?: number
|
||||||
failCount?: number
|
failCount?: number
|
||||||
failItems?: Array<{ cardValue: string, reason: string }>
|
failItems?: Array<{ cardValue: string, reason: string }>
|
||||||
}, subscribeResult?: Awaited<ReturnType<typeof requestWechatSubscribeMessage>>) {
|
}) {
|
||||||
const lines = [
|
const lines = [
|
||||||
`批次号:${res.batchNo || '-'}`,
|
`批次号:${res.batchNo || '-'}`,
|
||||||
`成功卡数:${res.successCount || 0} 张`,
|
`成功卡数:${res.successCount || 0} 张`,
|
||||||
@@ -370,16 +239,13 @@ function buildResultText(res: {
|
|||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push(...failItems.slice(0, 3).map(item => `${item.cardValue}:${item.reason}`))
|
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')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goMessageCenter() {
|
||||||
|
uni.navigateTo({ url: RouterPath.Message })
|
||||||
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string) {
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
|
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
|
||||||
@@ -404,7 +270,6 @@ watch(selectedCardValues, (values) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadCards()
|
void loadCards()
|
||||||
void loadMeta()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -419,11 +284,11 @@ onMounted(() => {
|
|||||||
</view>
|
</view>
|
||||||
<view class="min-w-0">
|
<view class="min-w-0">
|
||||||
<text class="biz-hero__title">流量预警</text>
|
<text class="biz-hero__title">流量预警</text>
|
||||||
<text class="biz-hero__sub">选择卡片后统一配置微信订阅提醒与流量阈值</text>
|
<text class="biz-hero__sub">选择卡片后统一配置流量预警阈值,预警通知在消息中心查看</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<wd-tag :type="templateReady ? 'primary' : 'warning'" variant="plain">
|
<wd-tag type="primary" variant="plain">
|
||||||
{{ subscribeText }}
|
账号级提醒设置
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -432,70 +297,12 @@ onMounted(() => {
|
|||||||
<text class="biz-stat__label">已选卡片</text>
|
<text class="biz-stat__label">已选卡片</text>
|
||||||
<text class="biz-stat__value">{{ selectedText }} 张</text>
|
<text class="biz-stat__value">{{ selectedText }} 张</text>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
|
|
||||||
<view v-if="appNotifyEnabled && !wechatBound" class="mt-18rpx">
|
<wd-cell-group border>
|
||||||
<wd-button type="primary" plain block :loading="binding" @click="handleBindWechat">
|
<wd-cell title="消息中心" label="预警触发后可查看站内通知" value="查看消息" is-link @click="goMessageCenter" />
|
||||||
绑定当前微信
|
</wd-cell-group>
|
||||||
</wd-button>
|
|
||||||
</view>
|
|
||||||
<wd-button
|
|
||||||
class="mt-16rpx"
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
plain
|
|
||||||
block
|
|
||||||
custom-class="biz-mini-action-btn"
|
|
||||||
:loading="notificationSubmitting"
|
|
||||||
:disabled="!selectedCardValues.length"
|
|
||||||
@click="syncBatchNotify"
|
|
||||||
>
|
|
||||||
<view class="biz-mini-action-btn__inner">
|
|
||||||
<view class="i-carbon-checkmark-outline biz-mini-action-btn__icon biz-mini-action-btn__icon--blue" />
|
|
||||||
<text class="biz-mini-action-btn__text">应用到已选卡片</text>
|
|
||||||
</view>
|
|
||||||
</wd-button>
|
|
||||||
</wd-card>
|
|
||||||
|
|
||||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -689,10 +496,10 @@ onMounted(() => {
|
|||||||
<view class="biz-paybar__inner">
|
<view class="biz-paybar__inner">
|
||||||
<view class="biz-paybar__summary">
|
<view class="biz-paybar__summary">
|
||||||
<text class="biz-paybar__label">已选 {{ selectedCardValues.length }} 张</text>
|
<text class="biz-paybar__label">已选 {{ selectedCardValues.length }} 张</text>
|
||||||
<text class="biz-paybar__value">{{ notificationSummary }}</text>
|
<text class="biz-paybar__value">预警通知在消息中心查看</text>
|
||||||
</view>
|
</view>
|
||||||
<wd-button type="primary" block custom-class="biz-paybar-btn" :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
|
<wd-button type="primary" block custom-class="biz-paybar-btn" :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
|
||||||
{{ submitButtonText }}
|
保存预警配置
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('工单列表筛选布局', () => {
|
||||||
|
const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
it('六个状态使用一行标签而非换行按钮', () => {
|
||||||
|
expect(source).toContain('<wd-tabs v-model="status" :slidable-num="6"')
|
||||||
|
expect(source).toContain('<wd-tab v-for="option in statusOptions"')
|
||||||
|
expect(source).not.toContain('<wd-button v-for="option in statusOptions"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('搜索和筛选在同一区块且切换仍刷新列表', () => {
|
||||||
|
const searchEnd = source.indexOf('@clear="loadWorkOrders()"')
|
||||||
|
const tabsStart = source.indexOf('<wd-tabs v-model="status"')
|
||||||
|
expect(searchEnd).toBeGreaterThan(0)
|
||||||
|
expect(tabsStart).toBeGreaterThan(searchEnd)
|
||||||
|
expect(source.slice(searchEnd, tabsStart)).not.toContain('</wd-card>')
|
||||||
|
expect(source).toContain('watch(status, () => {')
|
||||||
|
expect(source).toContain('currentStatus: status.value || undefined')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||||
import type { AppWorkOrder } from '../api/workOrder'
|
import type { AppWorkOrder } from '../api/workOrder'
|
||||||
import { getWorkOrderList } from '../api/workOrder'
|
import { getWorkOrderList } from '../api/workOrder'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
@@ -8,21 +9,42 @@ import { hasAnyFeature } from '@/utils/permission'
|
|||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '工单列表',
|
navigationBarTitleText: '工单列表',
|
||||||
|
enablePullDownRefresh: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const noPermission = ref(false)
|
const noPermission = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const workOrders = ref<AppWorkOrder[]>([])
|
const workOrders = ref<AppWorkOrder[]>([])
|
||||||
const total = ref(0)
|
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 canViewWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:list'))
|
||||||
const canCreateWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:create'))
|
const canCreateWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:create'))
|
||||||
const canViewWorkOrderDetail = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:detail'))
|
const canViewWorkOrderDetail = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:detail'))
|
||||||
const canEnterWorkOrder = computed(() => canViewWorkOrder.value || canCreateWorkOrder.value)
|
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) {
|
if (!canViewWorkOrder.value) {
|
||||||
noPermission.value = !canCreateWorkOrder.value
|
noPermission.value = !canCreateWorkOrder.value
|
||||||
workOrders.value = []
|
workOrders.value = []
|
||||||
@@ -30,22 +52,30 @@ async function loadWorkOrders() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
noPermission.value = false
|
noPermission.value = false
|
||||||
try {
|
try {
|
||||||
const res = await getWorkOrderList({
|
const res = await getWorkOrderList({
|
||||||
page: 1,
|
scope: scope.value,
|
||||||
pageSize: 50,
|
page: nextPage,
|
||||||
|
pageSize: 20,
|
||||||
|
currentStatus: status.value || undefined,
|
||||||
keyword: keyword.value.trim() || 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
|
total.value = res.total || 0
|
||||||
|
page.value = nextPage
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
workOrders.value = []
|
if (version !== requestVersion)
|
||||||
total.value = 0
|
return
|
||||||
uni.showToast({ title: getErrorMessage(error, '工单加载失败'), icon: 'none' })
|
loadError.value = getErrorMessage(error, '工单加载失败,请重试')
|
||||||
|
uni.showToast({ title: loadError.value, icon: 'none' })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (version === requestVersion)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +133,12 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onShow(async () => {
|
||||||
|
if (initialized) {
|
||||||
|
void loadWorkOrders()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
await userStore.fetchUserInfo().catch(() => undefined)
|
await userStore.fetchUserInfo().catch(() => undefined)
|
||||||
if (!canEnterWorkOrder.value) {
|
if (!canEnterWorkOrder.value) {
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
@@ -112,10 +147,29 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
void loadWorkOrders()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen p-24rpx pb-40rpx">
|
<view class="min-h-screen bg-#f5f7fa p-24rpx pb-48rpx">
|
||||||
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
||||||
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
|
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
|
||||||
<view class="text-26rpx text-#64748b leading-38rpx">
|
<view class="text-26rpx text-#64748b leading-38rpx">
|
||||||
@@ -126,16 +180,16 @@ onMounted(async () => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<wd-card custom-class="!mx-0 !mb-0">
|
<wd-card custom-class="!mx-0 !mb-0">
|
||||||
<view class="flex items-center justify-between gap-20rpx">
|
<view class="flex items-center justify-between gap-20rpx">
|
||||||
<view class="min-w-0">
|
<view class="min-w-0 flex-1">
|
||||||
<view class="text-34rpx text-#111827 font-900">
|
<view class="text-34rpx text-#111827 font-700">
|
||||||
我的工单
|
我的工单
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-8rpx text-24rpx text-#64748b">
|
<view class="mt-8rpx text-24rpx text-#64748b">
|
||||||
{{ canViewWorkOrder ? `共 ${total} 条,按最新提交时间排序` : '当前账号仅可提交新工单' }}
|
{{ canViewWorkOrder ? `共 ${total} 条,按最新提交时间排序` : '当前账号仅可提交新工单' }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<wd-button size="small" type="primary" :disabled="!canCreateWorkOrder" @click="handleCreate">
|
<wd-button v-if="canCreateWorkOrder" size="small" type="primary" custom-class="!shrink-0" @click="handleCreate">
|
||||||
新增
|
提交工单
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
@@ -152,52 +206,77 @@ onMounted(async () => {
|
|||||||
当前角色未配置工单列表权限,提交后可由平台客服继续处理。
|
当前角色未配置工单列表权限,提交后可由平台客服继续处理。
|
||||||
</view>
|
</view>
|
||||||
<wd-button type="primary" block @click="handleCreate">
|
<wd-button type="primary" block @click="handleCreate">
|
||||||
新增工单
|
提交工单
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
<wd-card v-if="canViewWorkOrder" custom-class="!mx-0 !mb-0" custom-content-class="!p-18rpx">
|
<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
|
<wd-search
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
placeholder="搜索工单编号、标题或内容"
|
placeholder="搜索工单编号、标题或内容"
|
||||||
hide-cancel
|
hide-cancel
|
||||||
@search="loadWorkOrders"
|
custom-class="!px-20rpx !pt-20rpx !pb-8rpx"
|
||||||
@clear="loadWorkOrders"
|
@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>
|
||||||
|
|
||||||
<wd-card v-if="canViewWorkOrder && loading" custom-class="!mx-0 !mb-0">
|
<wd-card v-if="canViewWorkOrder && loading && !workOrders.length" custom-class="!mx-0 !mb-0">
|
||||||
<wd-skeleton theme="paragraph" />
|
<wd-skeleton theme="paragraph" />
|
||||||
</wd-card>
|
</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">
|
<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="py-40rpx text-center text-26rpx text-#64748b">
|
||||||
暂无工单
|
暂无工单
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
<wd-card v-else-if="canViewWorkOrder" custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
<view v-else-if="canViewWorkOrder" class="flex flex-col gap-20rpx">
|
||||||
<wd-cell-group :border="false">
|
<wd-card v-for="item in workOrders" :key="item.woNo" custom-class="!mx-0 !mb-0" custom-content-class="!p-24rpx">
|
||||||
<wd-cell
|
<view class="flex items-start justify-between gap-20rpx">
|
||||||
v-for="item in workOrders"
|
<text class="min-w-0 flex-1 break-all text-28rpx text-#0f172a font-600 leading-42rpx">{{ item.title }}</text>
|
||||||
:key="item.woNo"
|
<view class="shrink-0">
|
||||||
:title="item.title"
|
<wd-tag :type="tagType(item.statusType)" variant="light">
|
||||||
:label="`${item.woNo} · ${item.woTypeText || '工单'} · ${workOrderTime(item)}`"
|
|
||||||
:is-link="canViewWorkOrderDetail"
|
|
||||||
:clickable="canViewWorkOrderDetail"
|
|
||||||
center
|
|
||||||
custom-class="!min-h-132rpx"
|
|
||||||
custom-title-class="!text-28rpx !text-#111827 !font-800"
|
|
||||||
custom-label-class="!text-24rpx !text-#64748b"
|
|
||||||
@click="handleDetail(item)"
|
|
||||||
>
|
|
||||||
<wd-tag :type="tagType(item.statusType)" variant="plain">
|
|
||||||
{{ item.currentStatusText || '处理中' }}
|
{{ item.currentStatusText || '处理中' }}
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</wd-cell>
|
</view>
|
||||||
</wd-cell-group>
|
</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>
|
</wd-card>
|
||||||
|
</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>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -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 { createWorkOrder } from '../api/workOrder'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { useTokenStore } from '@/store/token'
|
import WorkOrderAttachments from '@/components/WorkOrderAttachments.vue'
|
||||||
import { hasAnyFeature } from '@/utils/permission'
|
import { hasAnyFeature } from '@/utils/permission'
|
||||||
import { getEnvBaseUrl } from '@/utils'
|
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
@@ -24,14 +23,27 @@ const problemTypes = [
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const noPermission = ref(false)
|
const noPermission = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const attachmentUrls = ref<string[]>([])
|
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 canCreateWorkOrder = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:create'))
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
woType: [0],
|
woType: [0],
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
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(() => {
|
const typeText = computed(() => {
|
||||||
@@ -39,107 +51,27 @@ const typeText = computed(() => {
|
|||||||
return problemTypes.find(item => item.value === value)?.label || '卡片问题'
|
return problemTypes.find(item => item.value === value)?.label || '卡片问题'
|
||||||
})
|
})
|
||||||
|
|
||||||
function chooseAttachment() {
|
function openTypePicker() {
|
||||||
// #ifdef H5
|
if (relatedId.value || submitting.value || submitted.value)
|
||||||
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
|
return
|
||||||
// #endif
|
typePickerOpen.value = true
|
||||||
|
|
||||||
const choose = (uni as any).chooseFile || (uni as any).chooseMessageFile
|
|
||||||
if (typeof choose !== 'function') {
|
|
||||||
uni.showToast({ title: '当前平台暂不支持选择附件', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
choose({
|
|
||||||
count: 1,
|
|
||||||
type: 'all',
|
|
||||||
success: (res: any) => {
|
|
||||||
const file = res.tempFiles?.[0] || res.tempFilePaths?.[0]
|
|
||||||
const filePath = file?.path || file?.tempFilePath || file
|
|
||||||
const fileName = file?.name || String(filePath || '').split('/').pop() || '附件'
|
|
||||||
if (filePath)
|
|
||||||
void uploadAttachment(filePath, fileName)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAttachment(filePath: string | File, fileName: string) {
|
|
||||||
uploading.value = true
|
|
||||||
try {
|
|
||||||
if (typeof filePath !== 'string') {
|
|
||||||
const token = useTokenStore().updateNowTime().validToken
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', filePath, fileName)
|
|
||||||
form.append('fileType', 'file')
|
|
||||||
const response = await fetch(`${getEnvBaseUrl()}/work-order/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
const body = await response.json()
|
|
||||||
if (!response.ok || Number(body.code) !== 0)
|
|
||||||
throw new Error(body.message || body.msg || '附件上传失败')
|
|
||||||
const data = body.data || body
|
|
||||||
const url = data.url || data.path
|
|
||||||
if (!url)
|
|
||||||
throw new Error('上传接口未返回文件地址')
|
|
||||||
attachmentUrls.value.push(url)
|
|
||||||
attachmentNames.value.push(fileName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
uni.uploadFile({
|
|
||||||
url: `${getEnvBaseUrl()}/work-order/upload`,
|
|
||||||
filePath,
|
|
||||||
name: 'file',
|
|
||||||
formData: { fileType: 'file' },
|
|
||||||
success: (res) => {
|
|
||||||
try {
|
|
||||||
const body = JSON.parse(res.data)
|
|
||||||
const data = body.data || body
|
|
||||||
const url = data.url || data.path
|
|
||||||
if (!url)
|
|
||||||
throw new Error('上传接口未返回文件地址')
|
|
||||||
attachmentUrls.value.push(url)
|
|
||||||
attachmentNames.value.push(fileName)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: reject,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
uni.showToast({ title: getErrorMessage(error, '附件上传失败'), icon: 'none' })
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
uploading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function removeAttachment(index: number) {
|
|
||||||
attachmentUrls.value.splice(index, 1)
|
|
||||||
attachmentNames.value.splice(index, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
if (submitting.value || submitted.value || uploading.value)
|
||||||
|
return
|
||||||
if (!canCreateWorkOrder.value) {
|
if (!canCreateWorkOrder.value) {
|
||||||
uni.showToast({ title: '暂无提交工单权限', icon: 'none' })
|
uni.showToast({ title: '暂无提交工单权限', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const title = formData.title.trim()
|
const title = formData.title.trim()
|
||||||
const content = formData.content.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) {
|
if (!title) {
|
||||||
uni.showToast({ title: '请输入工单标题', icon: 'none' })
|
uni.showToast({ title: '请输入工单标题', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -149,24 +81,33 @@ async function handleSubmit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(creatorPhone)) {
|
||||||
|
uni.showToast({ title: creatorPhone ? '请输入正确的手机号' : '请输入创建人手机号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const res = await createWorkOrder({
|
const res = await createWorkOrder({
|
||||||
woType: Number(formData.woType[0] ?? 0),
|
woType,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
|
creatorPhone,
|
||||||
attachmentUrls: attachmentUrls.value,
|
attachmentUrls: attachmentUrls.value,
|
||||||
|
relatedId: relatedId.value,
|
||||||
})
|
})
|
||||||
const woNo = res.workOrder?.woNo || ''
|
const woNo = res.workOrder?.woNo || ''
|
||||||
|
submitted.value = true
|
||||||
uni.showToast({ title: '工单已提交', icon: 'success' })
|
uni.showToast({ title: '工单已提交', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (woNo) {
|
if (woNo && hasAnyFeature(userStore.userInfo, 'workorder:detail')) {
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
url: `${RouterPath.BusinessViewsWorkOrderDetail}?woNo=${encodeURIComponent(woNo)}`,
|
url: `${RouterPath.BusinessViewsWorkOrderDetail}?woNo=${encodeURIComponent(woNo)}`,
|
||||||
|
fail: () => uni.navigateBack({ fail: () => uni.switchTab({ url: RouterPath.Business }) }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
uni.navigateBack()
|
uni.navigateBack({ fail: () => uni.switchTab({ url: RouterPath.Business }) })
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
@@ -195,6 +136,8 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await userStore.fetchUserInfo().catch(() => undefined)
|
await userStore.fetchUserInfo().catch(() => undefined)
|
||||||
|
if (!formData.creatorPhone)
|
||||||
|
formData.creatorPhone = userStore.userInfo?.phone || ''
|
||||||
if (!canCreateWorkOrder.value) {
|
if (!canCreateWorkOrder.value) {
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
uni.showToast({ title: '当前账号不能提交工单', icon: 'none' })
|
uni.showToast({ title: '当前账号不能提交工单', icon: 'none' })
|
||||||
@@ -203,7 +146,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen p-24rpx pb-40rpx">
|
<view class="min-h-screen bg-#f5f7fa p-24rpx pb-48rpx">
|
||||||
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
||||||
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
|
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
|
||||||
<view class="text-26rpx text-#64748b leading-38rpx">
|
<view class="text-26rpx text-#64748b leading-38rpx">
|
||||||
@@ -218,76 +161,92 @@ onMounted(async () => {
|
|||||||
<view class="i-carbon-chat text-36rpx" />
|
<view class="i-carbon-chat text-36rpx" />
|
||||||
</view>
|
</view>
|
||||||
<view class="min-w-0 flex-1">
|
<view class="min-w-0 flex-1">
|
||||||
<text class="block text-34rpx text-#111827 font-900">提交工单</text>
|
<text class="block text-34rpx text-#111827 font-700">提交工单</text>
|
||||||
<text class="mt-8rpx block text-24rpx text-#64748b leading-36rpx">
|
<text class="mt-8rpx block text-24rpx text-#64748b leading-36rpx">
|
||||||
工单会分派到平台服务部门处理
|
提交后由服务部门跟进,可在工单详情查看进度
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</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">
|
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="h-88rpx flex items-center justify-between">
|
<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">
|
<wd-tag type="primary" variant="plain">
|
||||||
{{ typeText }}
|
{{ typeText }}
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<wd-cell-group :border="false">
|
<wd-cell-group :border="false">
|
||||||
<wd-picker
|
<wd-cell
|
||||||
v-model="formData.woType"
|
title="工单类型"
|
||||||
label="问题类型"
|
:value="typeText"
|
||||||
label-width="96px"
|
:label="relatedId ? '已关联业务,类型不可更改' : '请选择本次工单的问题分类'"
|
||||||
:columns="problemTypes"
|
:is-link="!relatedId && !submitting && !submitted"
|
||||||
value-key="value"
|
center
|
||||||
label-key="label"
|
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
|
<wd-input
|
||||||
v-model="formData.title"
|
v-model="formData.title"
|
||||||
label="工单标题"
|
:disabled="submitting || submitted"
|
||||||
label-width="96px"
|
|
||||||
placeholder="请输入简短标题"
|
placeholder="请输入简短标题"
|
||||||
|
:maxlength="100"
|
||||||
|
compact
|
||||||
clearable
|
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-cell-group>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
<wd-card custom-class="!mx-0 !mb-0">
|
<wd-card custom-class="!mx-0 !mb-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="h-88rpx flex items-center">
|
<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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<wd-textarea
|
<wd-textarea
|
||||||
v-model="formData.content"
|
v-model="formData.content"
|
||||||
|
:disabled="submitting || submitted"
|
||||||
placeholder="请描述问题现象、发生时间和已尝试的处理方式"
|
placeholder="请描述问题现象、发生时间和已尝试的处理方式"
|
||||||
:maxlength="500"
|
:maxlength="2000"
|
||||||
show-word-limit
|
show-word-limit
|
||||||
/>
|
/>
|
||||||
<view class="mt-20rpx flex items-center justify-between">
|
<view class="mt-20rpx">
|
||||||
<text class="text-24rpx text-#64748b">附件(可选)</text>
|
<WorkOrderAttachments v-model="attachmentUrls" :disabled="submitting || submitted" @uploading="uploading = $event" />
|
||||||
<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>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
</view>
|
||||||
</wd-form>
|
</wd-form>
|
||||||
|
|
||||||
<wd-button block type="primary" :loading="submitting" :disabled="!canCreateWorkOrder || uploading" @click="handleSubmit">
|
<wd-button block type="primary" :round="false" :loading="submitting" :disabled="!canCreateWorkOrder || submitting || uploading || submitted" @click="handleSubmit">
|
||||||
提交工单
|
提交工单
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
<wd-picker
|
||||||
|
v-model="formData.woType"
|
||||||
|
v-model:visible="typePickerOpen"
|
||||||
|
title="选择工单类型"
|
||||||
|
:columns="[problemTypes]"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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>
|
<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 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 { useUserStore } from '@/store/user'
|
||||||
import { hasAnyFeature } from '@/utils/permission'
|
import { hasAnyFeature } from '@/utils/permission'
|
||||||
import { getEnvBaseUrl } from '@/utils'
|
import { getEnvBaseUrl } from '@/utils'
|
||||||
|
import { isWorkOrderImage, workOrderAttachmentFilename, workOrderAttachmentName } from '@/utils/workOrderAttachment'
|
||||||
|
// #ifdef H5
|
||||||
|
import { downloadWorkOrderAttachment } from '@/utils/workOrderAttachmentDownload'
|
||||||
|
// #endif
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
@@ -14,9 +21,134 @@ definePage({
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const noPermission = ref(false)
|
const noPermission = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
|
const refreshing = ref(false)
|
||||||
const woNo = ref('')
|
const woNo = ref('')
|
||||||
const workOrder = ref<AppWorkOrder>()
|
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 canViewWorkOrderDetail = computed(() => hasAnyFeature(userStore.userInfo, 'workorder:detail'))
|
||||||
|
|
||||||
const infoRows = computed(() => {
|
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) {
|
if (!canViewWorkOrderDetail.value) {
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!woNo.value) {
|
if (!woNo.value) {
|
||||||
|
loading.value = false
|
||||||
uni.showToast({ title: '工单编号不能为空', icon: 'none' })
|
uni.showToast({ title: '工单编号不能为空', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
fetching = true
|
||||||
|
refreshing.value = true
|
||||||
|
loading.value = !workOrder.value
|
||||||
noPermission.value = false
|
noPermission.value = false
|
||||||
try {
|
try {
|
||||||
const res = await getWorkOrderDetail({ woNo: woNo.value })
|
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) {
|
catch (error) {
|
||||||
workOrder.value = undefined
|
if (!silent)
|
||||||
uni.showToast({ title: getErrorMessage(error, '工单详情加载失败'), icon: 'none' })
|
uni.showToast({ title: getErrorMessage(error, '工单详情加载失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
fetching = false
|
||||||
|
refreshing.value = false
|
||||||
loading.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) {
|
function tagType(type?: string) {
|
||||||
if (type === 'success')
|
if (type === 'success')
|
||||||
return 'success'
|
return 'success'
|
||||||
@@ -67,6 +245,10 @@ function tagType(type?: string) {
|
|||||||
return 'warning'
|
return 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentName(url: string) {
|
||||||
|
return workOrderAttachmentName(url, workOrder.value?.attachmentNames?.[url])
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAttachmentUrl(value: string) {
|
function normalizeAttachmentUrl(value: string) {
|
||||||
const url = String(value || '').trim().replaceAll('\\', '/')
|
const url = String(value || '').trim().replaceAll('\\', '/')
|
||||||
if (!url)
|
if (!url)
|
||||||
@@ -77,20 +259,74 @@ function normalizeAttachmentUrl(value: string) {
|
|||||||
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`
|
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) {
|
function openAttachment(value: string) {
|
||||||
const url = normalizeAttachmentUrl(value)
|
const url = normalizeAttachmentUrl(value)
|
||||||
if (!url)
|
if (!url)
|
||||||
return
|
return
|
||||||
|
if (isWorkOrderImage(url, attachmentName(value))) {
|
||||||
|
uni.previewImage({ urls: [url], current: url })
|
||||||
|
return
|
||||||
|
}
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
window.open(url, '_blank')
|
downloadWorkOrderAttachment(url, attachmentName(value)).catch(() => {
|
||||||
|
uni.showToast({ title: '附件下载失败,请重试', icon: 'none' })
|
||||||
|
})
|
||||||
// #endif
|
// #endif
|
||||||
// #ifndef H5
|
// #ifndef H5
|
||||||
uni.downloadFile({
|
uni.downloadFile({
|
||||||
url,
|
url,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.statusCode === 200)
|
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
|
// #endif
|
||||||
}
|
}
|
||||||
@@ -120,19 +356,41 @@ onLoad((query: Record<string, string | undefined>) => {
|
|||||||
woNo.value = query.woNo || query.no || ''
|
woNo.value = query.woNo || query.no || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onShow(async () => {
|
||||||
|
cleanupAttachmentPreviewCopy()
|
||||||
|
visible = true
|
||||||
|
stopPolling()
|
||||||
|
loading.value = !workOrder.value
|
||||||
await userStore.fetchUserInfo().catch(() => undefined)
|
await userStore.fetchUserInfo().catch(() => undefined)
|
||||||
if (!canViewWorkOrderDetail.value) {
|
if (!canViewWorkOrderDetail.value) {
|
||||||
noPermission.value = true
|
noPermission.value = true
|
||||||
|
loading.value = false
|
||||||
|
showConversation.value = false
|
||||||
uni.showToast({ title: '当前账号不能查看工单详情', icon: 'none' })
|
uni.showToast({ title: '当前账号不能查看工单详情', icon: 'none' })
|
||||||
return
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen p-24rpx pb-40rpx">
|
<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">
|
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
||||||
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
|
<wd-card v-if="noPermission" custom-class="!mx-0 !mb-0">
|
||||||
<view class="text-26rpx text-#64748b leading-38rpx">
|
<view class="text-26rpx text-#64748b leading-38rpx">
|
||||||
@@ -147,12 +405,12 @@ onMounted(async () => {
|
|||||||
<template v-else-if="workOrder">
|
<template v-else-if="workOrder">
|
||||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="h-88rpx flex items-center justify-between gap-16rpx">
|
<view class="min-h-88rpx flex items-start justify-between gap-16rpx py-20rpx">
|
||||||
<text class="min-w-0 flex-1 truncate text-30rpx text-#111827 font-900">
|
<text class="min-w-0 flex-1 break-all text-28rpx text-#0f172a font-600">
|
||||||
{{ workOrder.title }}
|
{{ workOrder.title }}
|
||||||
</text>
|
</text>
|
||||||
<wd-tag :type="tagType(workOrder.statusType)" variant="plain">
|
<wd-tag :type="tagType(workOrder.statusType)" variant="plain">
|
||||||
{{ workOrder.currentStatusText || '处理中' }}
|
{{ workOrder.currentStatus === 3 ? '待确认解决' : (workOrder.currentStatusText || '处理中') }}
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -169,54 +427,212 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<wd-card custom-class="!mx-0 !mb-0">
|
<wd-card custom-class="!mx-0 !mb-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="h-88rpx flex items-center">
|
<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>
|
||||||
|
<text v-if="workOrder.attachmentUrls?.length" class="text-22rpx text-#94a3b8">
|
||||||
|
{{ workOrder.attachmentUrls.length }} 个附件
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<view class="text-28rpx text-#334155 leading-44rpx">
|
<view v-if="workOrder.content?.trim()" class="rounded-16rpx bg-#f8fafc px-20rpx py-18rpx">
|
||||||
{{ workOrder.content || '-' }}
|
<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>
|
||||||
|
<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)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="!workOrder.content?.trim() && !workOrder.attachmentUrls?.length" class="text-26rpx text-#94a3b8">
|
||||||
|
暂无问题描述
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
<wd-card custom-class="!mx-0 !mb-0">
|
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||||
<template #title>
|
<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">
|
||||||
<view class="h-88rpx flex items-center justify-between">
|
<text class="whitespace-nowrap text-24rpx text-#2563eb">{{ canReply ? '继续沟通' : '查看记录' }}</text>
|
||||||
<text class="text-30rpx text-#111827 font-900">沟通记录</text>
|
</wd-cell>
|
||||||
<wd-tag type="primary" variant="plain">
|
</wd-card>
|
||||||
{{ workOrder.communications?.length || 0 }} 条
|
<wd-card v-if="workOrder.records?.length" custom-class="!mx-0 !mb-0">
|
||||||
</wd-tag>
|
<view class="mb-20rpx text-28rpx text-#0f172a font-600">
|
||||||
|
处理进度
|
||||||
</view>
|
</view>
|
||||||
</template>
|
<view v-for="record in workOrder.records" :key="record.id" class="mb-16rpx flex items-center justify-between gap-16rpx text-24rpx">
|
||||||
<view v-if="workOrder.communications?.length" class="flex flex-col gap-16rpx">
|
<text class="text-#334155">{{ record.operationText }}</text>
|
||||||
<view
|
<text class="text-#94a3b8">{{ formatTime(record.createdAt) }}</text>
|
||||||
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>
|
||||||
<view class="text-22rpx text-#94a3b8">
|
</wd-card>
|
||||||
{{ formatTime(item.createdAt) }}
|
|
||||||
|
<wd-card v-if="autoCloseText" custom-class="!mx-0 !mb-0">
|
||||||
|
<view class="text-26rpx text-#64748b leading-40rpx">
|
||||||
|
{{ autoCloseText }}
|
||||||
</view>
|
</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>
|
</view>
|
||||||
<view class="mt-8rpx text-28rpx text-#334155 leading-42rpx">
|
<wd-rate v-if="actions.includes('evaluate')" v-model="rating" :disabled="sending" />
|
||||||
{{ item.content }}
|
<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>
|
</view>
|
||||||
|
<wd-rate :model-value="workOrder.satisfactionLevel" readonly />
|
||||||
|
<view class="mt-12rpx text-26rpx text-#64748b">
|
||||||
|
{{ workOrder.evalContent || '未填写评价内容' }}
|
||||||
</view>
|
</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>
|
||||||
<view v-else class="py-24rpx text-center text-26rpx text-#64748b">
|
<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>
|
</view>
|
||||||
</wd-card>
|
</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>
|
</template>
|
||||||
|
|
||||||
<wd-card v-else custom-class="!mx-0 !mb-0">
|
<wd-card v-else custom-class="!mx-0 !mb-0">
|
||||||
<view class="py-40rpx text-center text-26rpx text-#64748b">
|
<view class="py-40rpx text-center text-26rpx text-#64748b">
|
||||||
未查询到工单
|
工单暂时无法加载
|
||||||
</view>
|
</view>
|
||||||
|
<wd-button block type="info" @click="loadDetail()">
|
||||||
|
重试
|
||||||
|
</wd-button>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import type { AppCard } from '@/api/card'
|
|||||||
import type { IUserInfoRes } from '@/api/types/login'
|
import type { IUserInfoRes } from '@/api/types/login'
|
||||||
import { hasUserPermission } from '@/utils/permission'
|
import { hasUserPermission } from '@/utils/permission'
|
||||||
import { getAccountWorkspace, isEnterpriseUserInfo } from '@/utils/userIdentity'
|
import { getAccountWorkspace, isEnterpriseUserInfo } from '@/utils/userIdentity'
|
||||||
|
import { V101_RELEASE } from '@/constant/release'
|
||||||
|
|
||||||
export type CardMoreActionKey = 'restart' | 'stop' | 'start' | 'dataStart' | 'dataStop' | 'change' | 'allocate' | 'warning'
|
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) {
|
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) {
|
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) {
|
export function canChangeCardAction(userInfo: Partial<IUserInfoRes> | null | undefined, card?: AppCard | null) {
|
||||||
@@ -78,6 +79,12 @@ export function buildCardMoreActions(
|
|||||||
userInfo: Partial<IUserInfoRes> | null | undefined,
|
userInfo: Partial<IUserInfoRes> | null | undefined,
|
||||||
card?: AppCard | null,
|
card?: AppCard | null,
|
||||||
) {
|
) {
|
||||||
|
if (!V101_RELEASE.showCardMoreActions) {
|
||||||
|
return canAllocateCardAction(userInfo, card)
|
||||||
|
? [{ key: 'allocate', name: '卡片划拨', description: '将这张卡划拨给直属客户' }] satisfies CardMoreAction[]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
const actions: CardMoreAction[] = []
|
const actions: CardMoreAction[] = []
|
||||||
|
|
||||||
if (card?.bossCardStatus === 4) {
|
if (card?.bossCardStatus === 4) {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AppCard } from '@/api/card'
|
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 RouterPath from '@/constant/RouterPath'
|
||||||
|
import { V101_RELEASE } from '@/constant/release'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
|
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +17,8 @@ import {
|
|||||||
canAllocateCardAction,
|
canAllocateCardAction,
|
||||||
canBusinessCardAction,
|
canBusinessCardAction,
|
||||||
canChangeCardAction,
|
canChangeCardAction,
|
||||||
|
canDataStartCardAction,
|
||||||
|
canDataStopCardAction,
|
||||||
canRenewCardAction,
|
canRenewCardAction,
|
||||||
canRestartCardAction,
|
canRestartCardAction,
|
||||||
canStartCardAction,
|
canStartCardAction,
|
||||||
@@ -62,9 +72,9 @@ const moreSheetActions = computed(() =>
|
|||||||
)
|
)
|
||||||
const showInitialLoading = computed(() => loading.value && cards.value.length === 0)
|
const showInitialLoading = computed(() => loading.value && cards.value.length === 0)
|
||||||
const previewSize = computed(() => Math.max(1, Number(props.previewLimit || 5)))
|
const previewSize = computed(() => Math.max(1, Number(props.previewLimit || 5)))
|
||||||
const pageSize = computed(() => props.compact ? previewSize.value : 20)
|
const pageSize = computed(() => (props.compact ? previewSize.value : 20))
|
||||||
const hasMore = computed(() =>
|
const hasMore = computed(
|
||||||
!props.compact && serverHasMore.value && cards.value.length < MAX_LOADED_CARDS,
|
() => !props.compact && serverHasMore.value && cards.value.length < MAX_LOADED_CARDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleCards = computed(() => {
|
const visibleCards = computed(() => {
|
||||||
@@ -91,15 +101,12 @@ const renderCards = computed(() => {
|
|||||||
return visibleCards.value.slice(0, previewSize.value)
|
return visibleCards.value.slice(0, previewSize.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hiddenPreviewCount = computed(() =>
|
const hiddenPreviewCount = computed(() => Math.max(0, total.value - renderCards.value.length))
|
||||||
Math.max(0, total.value - renderCards.value.length),
|
|
||||||
)
|
|
||||||
const showPreviewMore = computed(() => Boolean(props.compact && hiddenPreviewCount.value > 0))
|
const showPreviewMore = computed(() => Boolean(props.compact && hiddenPreviewCount.value > 0))
|
||||||
|
|
||||||
function mergeCards(current: AppCard[], incoming: AppCard[]) {
|
function mergeCards(current: AppCard[], incoming: AppCard[]) {
|
||||||
const merged = new Map<string, AppCard>()
|
const merged = new Map<string, AppCard>()
|
||||||
for (const item of [...current, ...incoming])
|
for (const item of [...current, ...incoming]) merged.set(displayCardValue(item), item)
|
||||||
merged.set(displayCardValue(item), item)
|
|
||||||
return Array.from(merged.values())
|
return Array.from(merged.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +118,7 @@ async function loadCards(reset = true) {
|
|||||||
const nextPage = reset ? 1 : currentPage.value + 1
|
const nextPage = reset ? 1 : currentPage.value + 1
|
||||||
if (reset)
|
if (reset)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
else
|
else loadingMore.value = true
|
||||||
loadingMore.value = true
|
|
||||||
emit('loadingChange', true)
|
emit('loadingChange', true)
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +137,7 @@ async function loadCards(reset = true) {
|
|||||||
cards.value = cards.value.slice(0, MAX_LOADED_CARDS)
|
cards.value = cards.value.slice(0, MAX_LOADED_CARDS)
|
||||||
if (reset)
|
if (reset)
|
||||||
total.value = Math.max(Number(res.total || 0), cards.value.length)
|
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
|
currentPage.value = nextPage
|
||||||
emit('cardsChange', cards.value, total.value)
|
emit('cardsChange', cards.value, total.value)
|
||||||
}
|
}
|
||||||
@@ -225,6 +231,14 @@ function canRestart(item: AppCard) {
|
|||||||
return canRestartCardAction(userStore.userInfo, item)
|
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) {
|
function canWarning(item?: AppCard | null) {
|
||||||
return canWarningCardAction(userStore.userInfo, item)
|
return canWarningCardAction(userStore.userInfo, item)
|
||||||
}
|
}
|
||||||
@@ -251,26 +265,6 @@ function canLifecycle(item: AppCard, operation: 'stop' | 'start') {
|
|||||||
: canStartCardAction(userStore.userInfo, item)
|
: 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) {
|
async function handleRestart(item: AppCard) {
|
||||||
if (!canRestart(item)) {
|
if (!canRestart(item)) {
|
||||||
uni.showToast({ title: '当前账号不能重启卡片', icon: 'none' })
|
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') {
|
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)
|
const cardValue = displayCardValue(item)
|
||||||
if (cardValue === '-')
|
if (cardValue === '-')
|
||||||
return
|
return
|
||||||
@@ -358,13 +357,18 @@ 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}`
|
const submittingKey = `${operation}:${cardValue}`
|
||||||
if (lifecycleSubmittingCard.value)
|
if (lifecycleSubmittingCard.value)
|
||||||
return
|
return
|
||||||
lifecycleSubmittingCard.value = submittingKey
|
lifecycleSubmittingCard.value = submittingKey
|
||||||
try {
|
try {
|
||||||
const res = operation === 'dataStart'
|
const res
|
||||||
|
= operation === 'dataStart'
|
||||||
? await startDataService({ cardValue })
|
? await startDataService({ cardValue })
|
||||||
: await stopDataService({ cardValue })
|
: await stopDataService({ cardValue })
|
||||||
await showLifecycleBatchProgress(res, actionText)
|
await showLifecycleBatchProgress(res, actionText)
|
||||||
@@ -463,6 +467,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
++cardLoadSeq
|
||||||
if (searchTimer)
|
if (searchTimer)
|
||||||
clearTimeout(searchTimer)
|
clearTimeout(searchTimer)
|
||||||
})
|
})
|
||||||
@@ -511,8 +516,13 @@ defineExpose({
|
|||||||
:class="{ 'preview-card-row': props.compact }"
|
:class="{ 'preview-card-row': props.compact }"
|
||||||
>
|
>
|
||||||
<template v-if="props.browse">
|
<template v-if="props.browse">
|
||||||
<view class="min-h-112rpx min-w-0 flex items-center gap-16rpx py-18rpx" @click="goDetail(item)">
|
<view
|
||||||
<view class="h-64rpx w-64rpx flex flex-shrink-0 items-center justify-center rounded-14rpx bg-#eff6ff text-32rpx text-#2563eb">
|
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 class="i-carbon-sim-card" />
|
||||||
</view>
|
</view>
|
||||||
<view class="min-w-0 flex-1">
|
<view class="min-w-0 flex-1">
|
||||||
@@ -520,13 +530,17 @@ defineExpose({
|
|||||||
{{ displayCardValue(item) }}
|
{{ displayCardValue(item) }}
|
||||||
</text>
|
</text>
|
||||||
<text class="mt-4rpx block truncate text-22rpx text-#64748b leading-30rpx">
|
<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>
|
</text>
|
||||||
<view class="mt-8rpx flex items-center gap-10rpx">
|
<view class="mt-8rpx flex items-center gap-10rpx">
|
||||||
<text class="min-w-0 truncate text-21rpx text-#475569">
|
<text class="min-w-0 truncate text-21rpx text-#475569">
|
||||||
剩余 {{ formatRemainFlow(item) }}
|
剩余 {{ formatRemainFlow(item) }}
|
||||||
</text>
|
</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) }}
|
{{ getBossCardStatusText(item.bossCardStatus) }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -584,7 +598,10 @@ defineExpose({
|
|||||||
<text class="owner-val truncate">{{ ownerDeptText(item) }}</text>
|
<text class="owner-val truncate">{{ ownerDeptText(item) }}</text>
|
||||||
</view>
|
</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" />
|
<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">
|
<text class="min-w-0 flex-1 truncate text-22rpx text-#b91c1c font-700">
|
||||||
{{ getCardAbnormalReasonText(item) }}
|
{{ getCardAbnormalReasonText(item) }}
|
||||||
@@ -619,69 +636,16 @@ defineExpose({
|
|||||||
详情
|
详情
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button
|
<wd-button
|
||||||
|
v-if="buildCardMoreActions(userStore.userInfo, item).length"
|
||||||
size="small"
|
size="small"
|
||||||
plain
|
plain
|
||||||
block
|
block
|
||||||
custom-class="card-primary-btn"
|
custom-class="card-primary-btn"
|
||||||
@click="openMoreActions(item)"
|
@click="openMoreActions(item)"
|
||||||
>
|
>
|
||||||
更多
|
{{ V101_RELEASE.showCardMoreActions ? '更多' : '划拨' }}
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</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>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -694,17 +658,15 @@ defineExpose({
|
|||||||
<wd-loading size="18px" />
|
<wd-loading size="18px" />
|
||||||
<text>正在加载更多...</text>
|
<text>正在加载更多...</text>
|
||||||
</view>
|
</view>
|
||||||
<wd-button
|
<wd-button v-else-if="hasMore" size="small" plain block @click="loadMore">
|
||||||
v-else-if="hasMore"
|
|
||||||
size="small"
|
|
||||||
plain
|
|
||||||
block
|
|
||||||
@click="loadMore"
|
|
||||||
>
|
|
||||||
加载更多(已加载 {{ cards.length }}/{{ total }})
|
加载更多(已加载 {{ cards.length }}/{{ total }})
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<text v-else class="text-22rpx text-muted">
|
<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>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -716,6 +678,7 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<wd-action-sheet
|
<wd-action-sheet
|
||||||
|
v-if="moreSheetActions.length"
|
||||||
v-model="moreSheetVisible"
|
v-model="moreSheetVisible"
|
||||||
title="更多操作"
|
title="更多操作"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@@ -862,86 +825,6 @@ defineExpose({
|
|||||||
gap: 12rpx;
|
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 {
|
.status-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1036,15 +919,13 @@ defineExpose({
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.card-primary-actions .wd-button),
|
:deep(.card-primary-actions .wd-button) {
|
||||||
:deep(.card-quick-row .wd-button) {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.card-primary-actions .wd-button__text),
|
:deep(.card-primary-actions .wd-button__text) {
|
||||||
:deep(.card-quick-row .wd-button__text) {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function handleRefresh() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<view class="flex items-center gap-12rpx">
|
||||||
<wd-search
|
<wd-search
|
||||||
v-model="searchValue"
|
v-model="searchValue"
|
||||||
|
|||||||
+22
-149
@@ -13,6 +13,7 @@ import {
|
|||||||
stopDataService,
|
stopDataService,
|
||||||
} from '@/api/card'
|
} from '@/api/card'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
|
import { V101_RELEASE } from '@/constant/release'
|
||||||
import { useLastQueriedCardStore, useTokenStore } from '@/store'
|
import { useLastQueriedCardStore, useTokenStore } from '@/store'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
|
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
|
||||||
@@ -67,9 +68,12 @@ let cardStatsLoadSeq = 0
|
|||||||
|
|
||||||
function handleListData(_items: AppCard[], total: number) {
|
function handleListData(_items: AppCard[], total: number) {
|
||||||
cardCount.value = total
|
cardCount.value = total
|
||||||
|
void loadCardStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCardStats() {
|
async function loadCardStats() {
|
||||||
|
if (!tokenStore.hasLogin)
|
||||||
|
return
|
||||||
const seq = ++cardStatsLoadSeq
|
const seq = ++cardStatsLoadSeq
|
||||||
try {
|
try {
|
||||||
const [normal, lowFlow, abnormal] = await Promise.all([
|
const [normal, lowFlow, abnormal] = await Promise.all([
|
||||||
@@ -196,13 +200,8 @@ const masterLifecycleDisabled = computed(() =>
|
|||||||
|
|
||||||
function handleRefresh() {
|
function handleRefresh() {
|
||||||
refreshKey.value += 1
|
refreshKey.value += 1
|
||||||
void loadCardStats()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadCardStats()
|
|
||||||
})
|
|
||||||
|
|
||||||
function canMasterRenew() {
|
function canMasterRenew() {
|
||||||
return canRenewCardAction(userStore.userInfo, lastQueriedCard.value)
|
return canRenewCardAction(userStore.userInfo, lastQueriedCard.value)
|
||||||
}
|
}
|
||||||
@@ -465,15 +464,15 @@ function goMasterWarning() {
|
|||||||
<view class="summary-statuses">
|
<view class="summary-statuses">
|
||||||
<view class="summary-status summary-status--normal">
|
<view class="summary-status summary-status--normal">
|
||||||
<text class="summary-status__num">{{ normalCount }}</text>
|
<text class="summary-status__num">{{ normalCount }}</text>
|
||||||
<text>正常</text>
|
<text class="shrink-0 whitespace-nowrap">正常</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="summary-status summary-status--low">
|
<view class="summary-status summary-status--low">
|
||||||
<text class="summary-status__num">{{ lowFlowCount }}</text>
|
<text class="summary-status__num">{{ lowFlowCount }}</text>
|
||||||
<text>低流量</text>
|
<text class="shrink-0 whitespace-nowrap">低流量</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="summary-status summary-status--danger">
|
<view class="summary-status summary-status--danger">
|
||||||
<text class="summary-status__num">{{ abnormalCount }}</text>
|
<text class="summary-status__num">{{ abnormalCount }}</text>
|
||||||
<text>异常</text>
|
<text class="shrink-0 whitespace-nowrap">异常</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -510,6 +509,7 @@ function goMasterWarning() {
|
|||||||
详情
|
详情
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button
|
<wd-button
|
||||||
|
v-if="V101_RELEASE.showCardMoreActions"
|
||||||
size="small"
|
size="small"
|
||||||
plain
|
plain
|
||||||
custom-class="master-action-btn"
|
custom-class="master-action-btn"
|
||||||
@@ -518,62 +518,9 @@ function goMasterWarning() {
|
|||||||
更多
|
更多
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</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>
|
||||||
|
|
||||||
<view class="bind-card" @click="openBindPanel">
|
<view v-if="V101_RELEASE.showBindCard" class="bind-card" @click="openBindPanel">
|
||||||
<view class="bind-card__icon">
|
<view class="bind-card__icon">
|
||||||
<view class="i-carbon-add-alt" />
|
<view class="i-carbon-add-alt" />
|
||||||
</view>
|
</view>
|
||||||
@@ -619,6 +566,7 @@ function goMasterWarning() {
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<wd-action-sheet
|
<wd-action-sheet
|
||||||
|
v-if="V101_RELEASE.showCardMoreActions"
|
||||||
v-model="masterMoreVisible"
|
v-model="masterMoreVisible"
|
||||||
title="更多操作"
|
title="更多操作"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@@ -630,6 +578,7 @@ function goMasterWarning() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<wd-popup
|
<wd-popup
|
||||||
|
v-if="V101_RELEASE.showBindCard"
|
||||||
v-model="showBindPanel"
|
v-model="showBindPanel"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
round
|
round
|
||||||
@@ -749,6 +698,7 @@ function goMasterWarning() {
|
|||||||
|
|
||||||
.card-summary {
|
.card-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 18rpx;
|
gap: 18rpx;
|
||||||
@@ -758,6 +708,7 @@ function goMasterWarning() {
|
|||||||
|
|
||||||
.summary-total {
|
.summary-total {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -785,14 +736,18 @@ function goMasterWarning() {
|
|||||||
|
|
||||||
.summary-statuses {
|
.summary-statuses {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12rpx;
|
justify-content: flex-end;
|
||||||
|
gap: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-status {
|
.summary-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6rpx;
|
justify-content: center;
|
||||||
|
gap: 4rpx;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -801,6 +756,8 @@ function goMasterWarning() {
|
|||||||
|
|
||||||
.summary-status__num {
|
.summary-status__num {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
min-width: 28rpx;
|
min-width: 28rpx;
|
||||||
height: 28rpx;
|
height: 28rpx;
|
||||||
padding: 0 8rpx;
|
padding: 0 8rpx;
|
||||||
@@ -916,25 +873,6 @@ function goMasterWarning() {
|
|||||||
margin-top: 20rpx;
|
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) {
|
:deep(.master-action-btn) {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 80rpx !important;
|
height: 80rpx !important;
|
||||||
@@ -955,68 +893,6 @@ function goMasterWarning() {
|
|||||||
color: #fff !important;
|
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 {
|
.bind-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1382,16 +1258,13 @@ function goMasterWarning() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-statuses {
|
.summary-statuses {
|
||||||
gap: 6rpx;
|
gap: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-status {
|
.summary-status {
|
||||||
padding-right: 8rpx;
|
|
||||||
padding-left: 8rpx;
|
|
||||||
font-size: 19rpx;
|
font-size: 19rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.master-card__quick-grid,
|
|
||||||
.bind-card__actions {
|
.bind-card__actions {
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
stopDataService,
|
stopDataService,
|
||||||
} from '@/api/card'
|
} from '@/api/card'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
|
import { hasAnyFeature } from '@/utils/permission'
|
||||||
|
import { V101_RELEASE } from '@/constant/release'
|
||||||
import { useTokenStore, useUserStore } from '@/store'
|
import { useTokenStore, useUserStore } from '@/store'
|
||||||
import { copyText } from '@/utils/clipboard'
|
import { copyText } from '@/utils/clipboard'
|
||||||
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
|
import { showLifecycleBatchProgress } from '@/utils/lifecycleBatch'
|
||||||
@@ -20,6 +22,8 @@ import {
|
|||||||
canAllocateCardAction,
|
canAllocateCardAction,
|
||||||
canBusinessCardAction,
|
canBusinessCardAction,
|
||||||
canChangeCardAction,
|
canChangeCardAction,
|
||||||
|
canDataStartCardAction,
|
||||||
|
canDataStopCardAction,
|
||||||
canRenewCardAction,
|
canRenewCardAction,
|
||||||
canRestartCardAction,
|
canRestartCardAction,
|
||||||
canStartCardAction,
|
canStartCardAction,
|
||||||
@@ -37,6 +41,7 @@ import {
|
|||||||
getNetModelText,
|
getNetModelText,
|
||||||
getOnlineStatusText,
|
getOnlineStatusText,
|
||||||
getRatText,
|
getRatText,
|
||||||
|
getServiceStatusText,
|
||||||
} from '../../data'
|
} from '../../data'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
@@ -75,6 +80,21 @@ const canAllocateCard = computed(
|
|||||||
const canRestartCard = computed(
|
const canRestartCard = computed(
|
||||||
() => !isPublicLimited.value && canRestartCardAction(userStore.userInfo, card.value),
|
() => !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(
|
const canStopCard = computed(
|
||||||
() => !isPublicLimited.value && canStopCardAction(userStore.userInfo, card.value),
|
() => !isPublicLimited.value && canStopCardAction(userStore.userInfo, card.value),
|
||||||
)
|
)
|
||||||
@@ -95,6 +115,8 @@ const flowRows = computed(() => {
|
|||||||
const item = card.value
|
const item = card.value
|
||||||
if (!item)
|
if (!item)
|
||||||
return []
|
return []
|
||||||
|
if (isPublicLimited.value)
|
||||||
|
return [['已用流量', formatFlow(item.usedFlowMb)]]
|
||||||
const rows = [
|
const rows = [
|
||||||
['总流量', formatFlow(item.packageTotalFlowMb)],
|
['总流量', formatFlow(item.packageTotalFlowMb)],
|
||||||
['已用流量', formatFlow(item.usedFlowMb)],
|
['已用流量', 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(() =>
|
const detailSections = computed(() =>
|
||||||
[
|
[
|
||||||
{ title: '基础信息', rows: baseRows.value },
|
{ title: '基础信息', rows: baseRows.value },
|
||||||
@@ -163,6 +198,8 @@ const extraInfoSections = computed(() => {
|
|||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
{ title: '会话信息', rows: sessionRows.value },
|
{ title: '会话信息', rows: sessionRows.value },
|
||||||
|
// v1.0.1 暂不展示服务状态,保留 serviceRows 便于后续恢复。
|
||||||
|
// { title: '服务状态', rows: serviceRows.value },
|
||||||
].filter(section => section.rows.some(row => isMeaningfulDetailValue(row[1])))
|
].filter(section => section.rows.some(row => isMeaningfulDetailValue(row[1])))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,7 +227,12 @@ async function loadCard() {
|
|||||||
visibleExtraSectionCount.value = 1
|
visibleExtraSectionCount.value = 1
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
errorMessage.value = getErrorMessage(error, '卡片详情加载失败')
|
const message = getErrorMessage(error, '卡片详情加载失败')
|
||||||
|
if (isCardNotFoundMessage(message)) {
|
||||||
|
goDiagnosisForMissingCard(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errorMessage.value = message
|
||||||
card.value = undefined
|
card.value = undefined
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -331,6 +373,12 @@ function isMeaningfulDetailValue(value: unknown) {
|
|||||||
return !!text && text !== '-' && text !== '未知' && text !== '暂无会话信息'
|
return !!text && text !== '-' && text !== '未知' && text !== '暂无会话信息'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatServiceStatus(status?: number) {
|
||||||
|
if (status === undefined || status === null)
|
||||||
|
return '-'
|
||||||
|
return getServiceStatusText(status)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRestart() {
|
async function handleRestart() {
|
||||||
const value = displayCardValue(card.value)
|
const value = displayCardValue(card.value)
|
||||||
if (!hasLogin.value) {
|
if (!hasLogin.value) {
|
||||||
@@ -401,6 +449,11 @@ async function submitLifecycle(cardNo: string, operation: 'stop' | 'start', acti
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDataService(operation: 'dataStart' | 'dataStop') {
|
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)
|
const value = displayCardValue(card.value)
|
||||||
if (value === '-')
|
if (value === '-')
|
||||||
return
|
return
|
||||||
@@ -545,6 +598,21 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
return message || fallback
|
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) => {
|
onLoad((query) => {
|
||||||
const queryValue = query?.cardValue || query?.cardNo || query?.iccid
|
const queryValue = query?.cardValue || query?.cardNo || query?.iccid
|
||||||
cardValue.value = normalizeQueryValue(queryValue)
|
cardValue.value = normalizeQueryValue(queryValue)
|
||||||
@@ -562,6 +630,13 @@ onShow(() => {
|
|||||||
function normalizeQueryValue(value: string | string[] | undefined) {
|
function normalizeQueryValue(value: string | string[] | undefined) {
|
||||||
return Array.isArray(value) ? value[0] : String(value || '')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -626,7 +701,7 @@ function normalizeQueryValue(value: string | string[] | undefined) {
|
|||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
<wd-card
|
<wd-card
|
||||||
v-if="!isPublicLimited"
|
v-if="showQuickActions"
|
||||||
custom-class="!mx-0 !mb-0 overflow-hidden detail-card detail-actions-card"
|
custom-class="!mx-0 !mb-0 overflow-hidden detail-card detail-actions-card"
|
||||||
custom-content-class="!p-0"
|
custom-content-class="!p-0"
|
||||||
>
|
>
|
||||||
@@ -635,20 +710,13 @@ function normalizeQueryValue(value: string | string[] | undefined) {
|
|||||||
<view class="section-bar" />
|
<view class="section-bar" />
|
||||||
<text class="section-title">快捷操作</text>
|
<text class="section-title">快捷操作</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="section-hint">常用功能</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="action-grid">
|
<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
|
<wd-button
|
||||||
|
v-if="canRestartCard"
|
||||||
plain
|
plain
|
||||||
block
|
block
|
||||||
custom-class="detail-action-btn detail-action-btn--restart"
|
custom-class="detail-action-btn detail-action-btn--restart"
|
||||||
:disabled="!canRestartCard"
|
|
||||||
:loading="restarting"
|
:loading="restarting"
|
||||||
@click="handleRestart"
|
@click="handleRestart"
|
||||||
>
|
>
|
||||||
@@ -658,26 +726,41 @@ function normalizeQueryValue(value: string | string[] | undefined) {
|
|||||||
</view>
|
</view>
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button
|
<wd-button
|
||||||
|
v-if="canStartDataService"
|
||||||
plain
|
plain
|
||||||
block
|
block
|
||||||
custom-class="detail-action-btn detail-action-btn--warning"
|
custom-class="detail-action-btn"
|
||||||
:disabled="!canWarningCard"
|
:loading="lifecycleSubmitting"
|
||||||
@click="handleWarning"
|
@click="handleDataService('dataStart')"
|
||||||
>
|
>
|
||||||
<view class="action-button-content">
|
<view class="action-button-content">
|
||||||
<view class="i-carbon-warning-alt action-button-icon action-button-icon--orange" />
|
<view class="i-carbon-play-filled-alt action-button-icon action-button-icon--blue" />
|
||||||
<text>预警</text>
|
<text>开通数据</text>
|
||||||
</view>
|
</view>
|
||||||
</wd-button>
|
</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="action-button-content">
|
||||||
<view class="i-carbon-overflow-menu-horizontal action-button-icon action-button-icon--gray" />
|
<view class="i-carbon-pause-filled action-button-icon action-button-icon--blue" />
|
||||||
<text>更多</text>
|
<text>停用数据</text>
|
||||||
</view>
|
</view>
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
|
<wd-button
|
||||||
|
v-if="card?.id && hasAnyFeature(userStore.userInfo, 'workorder:create')"
|
||||||
|
plain
|
||||||
|
@click="handleCreateWorkOrder"
|
||||||
|
>
|
||||||
|
反馈卡片问题
|
||||||
|
</wd-button>
|
||||||
<wd-card
|
<wd-card
|
||||||
v-for="section in detailSections"
|
v-for="section in detailSections"
|
||||||
:key="section.title"
|
:key="section.title"
|
||||||
@@ -748,6 +831,10 @@ function normalizeQueryValue(value: string | string[] | undefined) {
|
|||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|
||||||
|
<wd-button v-if="moreSheetActions.length" plain block type="primary" size="small" @click="openMoreActions">
|
||||||
|
{{ V101_RELEASE.showCardMoreActions ? '更多操作' : '划拨这张卡' }}
|
||||||
|
</wd-button>
|
||||||
|
|
||||||
<wd-button
|
<wd-button
|
||||||
v-if="hasMoreInfo"
|
v-if="hasMoreInfo"
|
||||||
plain
|
plain
|
||||||
@@ -760,7 +847,7 @@ function normalizeQueryValue(value: string | string[] | undefined) {
|
|||||||
</wd-button>
|
</wd-button>
|
||||||
|
|
||||||
<wd-card
|
<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-class="!mx-0 !mb-0 overflow-hidden detail-card detail-bind-card"
|
||||||
custom-content-class="!p-0"
|
custom-content-class="!p-0"
|
||||||
>
|
>
|
||||||
@@ -786,6 +873,7 @@ function normalizeQueryValue(value: string | string[] | undefined) {
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<wd-action-sheet
|
<wd-action-sheet
|
||||||
|
v-if="moreSheetActions.length"
|
||||||
v-model="moreSheetVisible"
|
v-model="moreSheetVisible"
|
||||||
title="更多操作"
|
title="更多操作"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@@ -978,8 +1066,8 @@ function normalizeQueryValue(value: string | string[] | undefined) {
|
|||||||
|
|
||||||
.action-grid {
|
.action-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10rpx;
|
gap: 12rpx;
|
||||||
padding: 0 24rpx 20rpx;
|
padding: 0 24rpx 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { WarningConfig, WarningConfigSaveParams } from '../api/warning'
|
import type { WarningConfig, WarningConfigSaveParams } from '../api/warning'
|
||||||
import { bindWechat, getWxCode } from '@/api/login'
|
|
||||||
import {
|
import {
|
||||||
getWarningConfigList,
|
getWarningConfigList,
|
||||||
saveWarningConfig,
|
saveWarningConfig,
|
||||||
updateWarningConfigNotify,
|
|
||||||
} from '../api/warning'
|
} from '../api/warning'
|
||||||
|
import RouterPath from '@/constant/RouterPath'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import {
|
import {
|
||||||
displayCardValue,
|
displayCardValue,
|
||||||
formatFlowPair,
|
formatFlowPair,
|
||||||
} from '../../data'
|
} from '../../data'
|
||||||
import { guideWechatSubscribeSettings, requestWechatSubscribeMessage, shouldGuideWechatSubscribeSettings, shouldShowWechatSubscribeNotice } from '@/utils'
|
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
@@ -40,16 +38,8 @@ type WarningPayload = Omit<WarningConfigSaveParams, 'cardValue'>
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const notificationSubmitting = ref(false)
|
|
||||||
const binding = ref(false)
|
|
||||||
const cardValue = ref('')
|
const cardValue = ref('')
|
||||||
const card = ref<any>()
|
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 errorMessage = ref('')
|
||||||
|
|
||||||
const forms = reactive<WarningForm[]>([
|
const forms = reactive<WarningForm[]>([
|
||||||
@@ -75,22 +65,6 @@ const cardSubTitle = computed(() =>
|
|||||||
card.value ? (card.value.msisdn || '卡片预警配置') : '加载卡片信息',
|
card.value ? (card.value.msisdn || '卡片预警配置') : '加载卡片信息',
|
||||||
)
|
)
|
||||||
const flowText = computed(() => (card.value ? formatFlowPair(card.value) : '-'))
|
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 filledConfigCount = computed(() => forms.filter(hasThresholdValue).length)
|
||||||
const canSubmit = computed(() => filledConfigCount.value > 0 && !submitting.value && !loading.value)
|
const canSubmit = computed(() => filledConfigCount.value > 0 && !submitting.value && !loading.value)
|
||||||
|
|
||||||
@@ -104,10 +78,6 @@ async function loadData() {
|
|||||||
try {
|
try {
|
||||||
const res = await getWarningConfigList({ cardValue: cardValue.value })
|
const res = await getWarningConfigList({ cardValue: cardValue.value })
|
||||||
card.value = res.card
|
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 || [])
|
syncForms(res.items || [])
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -123,8 +93,6 @@ function syncForms(items: WarningConfig[]) {
|
|||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
source.set(item.warnUnit, item)
|
source.set(item.warnUnit, item)
|
||||||
})
|
})
|
||||||
savedConfig.value = null
|
|
||||||
appNotifyEnabled.value = false
|
|
||||||
forms.forEach((form) => {
|
forms.forEach((form) => {
|
||||||
const item = source.get(form.warnUnit)
|
const item = source.get(form.warnUnit)
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -134,8 +102,6 @@ function syncForms(items: WarningConfig[]) {
|
|||||||
form.thirdWarn = ''
|
form.thirdWarn = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
savedConfig.value = item
|
|
||||||
appNotifyEnabled.value = item.appNotifyEnabled === 1
|
|
||||||
form.enabled = item.isEnabled !== 0
|
form.enabled = item.isEnabled !== 0
|
||||||
form.firstWarn = formatThreshold(item.firstWarn)
|
form.firstWarn = formatThreshold(item.firstWarn)
|
||||||
form.secondWarn = formatThreshold(item.secondWarn)
|
form.secondWarn = formatThreshold(item.secondWarn)
|
||||||
@@ -184,7 +150,6 @@ function buildConfigPayload(validate: boolean): WarningPayload[] {
|
|||||||
secondWarn,
|
secondWarn,
|
||||||
thirdWarn,
|
thirdWarn,
|
||||||
isEnabled: form.enabled ? 1 : 0,
|
isEnabled: form.enabled ? 1 : 0,
|
||||||
appNotifyEnabled: appNotifyEnabled.value ? 1 : 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,97 +170,6 @@ function validateThreshold(form: WarningForm, firstWarn: number, secondWarn: num
|
|||||||
return ''
|
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() {
|
async function handleSubmit() {
|
||||||
const configs = buildConfigPayload(true)
|
const configs = buildConfigPayload(true)
|
||||||
if (!configs.length || submitting.value)
|
if (!configs.length || submitting.value)
|
||||||
@@ -303,12 +177,7 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const subscribeResult = appNotifyEnabled.value
|
|
||||||
? await requestWechatSubscribeMessage(templateId.value, templateReady.value)
|
|
||||||
: undefined
|
|
||||||
const savedItems: WarningConfig[] = []
|
const savedItems: WarningConfig[] = []
|
||||||
let latestBound = wechatBound.value
|
|
||||||
let latestBindText = wechatBindText.value
|
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const res = await saveWarningConfig({
|
const res = await saveWarningConfig({
|
||||||
cardValue: cardValue.value,
|
cardValue: cardValue.value,
|
||||||
@@ -316,29 +185,9 @@ async function handleSubmit() {
|
|||||||
})
|
})
|
||||||
if (res.config)
|
if (res.config)
|
||||||
savedItems.push(res.config)
|
savedItems.push(res.config)
|
||||||
latestBound = Boolean(res.wechatBound)
|
|
||||||
latestBindText = res.wechatBindText || (res.wechatBound ? '已绑定' : '未绑定')
|
|
||||||
}
|
}
|
||||||
syncForms(savedItems)
|
syncForms(savedItems)
|
||||||
wechatBound.value = latestBound
|
showFeedback('预警配置已保存,触发后的通知可在消息中心查看。', '配置已保存', 'success')
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
showFeedback(getErrorMessage(error, '保存失败'), '保存失败')
|
showFeedback(getErrorMessage(error, '保存失败'), '保存失败')
|
||||||
@@ -348,6 +197,10 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goMessageCenter() {
|
||||||
|
uni.navigateTo({ url: RouterPath.Message })
|
||||||
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string) {
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
|
const data = error as { msg?: unknown, message?: unknown, errMsg?: unknown }
|
||||||
@@ -422,8 +275,8 @@ onShow(() => {
|
|||||||
{{ cardSubTitle }}
|
{{ cardSubTitle }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<wd-tag :type="templateReady ? 'primary' : 'warning'" round>
|
<wd-tag type="primary" round>
|
||||||
{{ subscribeText }}
|
站内消息提醒
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -437,51 +290,15 @@ onShow(() => {
|
|||||||
<view class="rounded-16rpx bg-#f8fafc p-18rpx">
|
<view class="rounded-16rpx bg-#f8fafc p-18rpx">
|
||||||
<text class="block text-22rpx text-muted">提醒方式</text>
|
<text class="block text-22rpx text-muted">提醒方式</text>
|
||||||
<text class="mt-8rpx block truncate text-26rpx text-ink font-800">
|
<text class="mt-8rpx block truncate text-26rpx text-ink font-800">
|
||||||
{{ appNotifyEnabled ? '微信订阅消息' : '暂不提醒' }}
|
全局统一管理
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-24rpx">
|
<wd-cell-group border>
|
||||||
<view class="flex items-start justify-between gap-20rpx">
|
<wd-cell title="消息中心" label="预警触发后可查看站内通知" value="查看消息" is-link @click="goMessageCenter" />
|
||||||
<view class="min-w-0 flex items-start gap-16rpx">
|
</wd-cell-group>
|
||||||
<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-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -577,10 +394,10 @@ onShow(() => {
|
|||||||
<view class="mx-auto max-w-920rpx pb-18rpx">
|
<view class="mx-auto max-w-920rpx pb-18rpx">
|
||||||
<view class="mb-12rpx flex items-center justify-between text-24rpx text-muted">
|
<view class="mb-12rpx flex items-center justify-between text-24rpx text-muted">
|
||||||
<text>已填 {{ filledConfigCount }} 项配置</text>
|
<text>已填 {{ filledConfigCount }} 项配置</text>
|
||||||
<text>{{ notificationSummary }}</text>
|
<text>预警通知在消息中心查看</text>
|
||||||
</view>
|
</view>
|
||||||
<wd-button type="primary" block :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
|
<wd-button type="primary" block :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
|
||||||
{{ submitButtonText }}
|
保存预警配置
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1,34 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AppCard } from '@/api/card'
|
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 RouterPath from '@/constant/RouterPath'
|
||||||
import {
|
import {
|
||||||
formatFlowPair,
|
formatFlow,
|
||||||
getBossCardStatusText,
|
getBossCardStatusText,
|
||||||
getCardStatusType,
|
getCardStatusType,
|
||||||
getOnlineStatusText,
|
getOnlineStatusText,
|
||||||
} from '@/pages/card/data'
|
} from '@/pages/card/data'
|
||||||
import { useLastQueriedCardStore, useTokenStore, useUserStore } from '@/store'
|
import { useLastQueriedCardStore } from '@/store'
|
||||||
import { copyText } from '@/utils/clipboard'
|
import { copyText } from '@/utils/clipboard'
|
||||||
import { cardRecognitionMessage, chooseCardImage } from '@/utils/cardRecognition'
|
import { cardRecognitionMessage, chooseCardImage } from '@/utils/cardRecognition'
|
||||||
import { isTerminalUserInfo } from '@/utils/userIdentity'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
refreshStats: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const searchValue = ref('')
|
const searchValue = ref('')
|
||||||
const queryLoading = ref(false)
|
const queryLoading = ref(false)
|
||||||
const recognizing = ref(false)
|
const recognizing = ref(false)
|
||||||
const binding = ref(false)
|
|
||||||
const queryErrorMessage = ref('')
|
const queryErrorMessage = ref('')
|
||||||
const result = ref<AppCard>()
|
const result = ref<AppCard>()
|
||||||
|
|
||||||
const tokenStore = useTokenStore()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const lastQueriedCardStore = useLastQueriedCardStore()
|
const lastQueriedCardStore = useLastQueriedCardStore()
|
||||||
const hasLogin = computed(() => tokenStore.hasLogin)
|
|
||||||
const isTerminal = computed(() => isTerminalUserInfo(userStore.userInfo))
|
|
||||||
const loadingText = computed(() => recognizing.value ? '图片识别中...' : '卡片查询中...')
|
const loadingText = computed(() => recognizing.value ? '图片识别中...' : '卡片查询中...')
|
||||||
|
|
||||||
const queryRows = computed(() => {
|
const queryRows = computed(() => {
|
||||||
@@ -36,7 +26,7 @@ const queryRows = computed(() => {
|
|||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
{ label: '卡号', value: publicCardTitle(result.value), copyable: true },
|
{ 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 },
|
{ label: '在线状态', value: getOnlineStatusText(result.value.onlineStatus), copyable: false },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -57,7 +47,12 @@ async function handleQuery() {
|
|||||||
lastQueriedCardStore.setCard(res.card)
|
lastQueriedCardStore.setCard(res.card)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
queryErrorMessage.value = getErrorMessage(error, '查询失败,请检查输入后重试')
|
const message = getErrorMessage(error, '查询失败,请检查输入后重试')
|
||||||
|
if (isCardNotFoundMessage(message)) {
|
||||||
|
goDiagnosisForMissingCard(cardValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queryErrorMessage.value = message
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
queryLoading.value = false
|
queryLoading.value = false
|
||||||
@@ -90,7 +85,8 @@ async function handlePhotoRecognize() {
|
|||||||
const recognition = await recognizeCardImage(filePath)
|
const recognition = await recognizeCardImage(filePath)
|
||||||
if (recognition.code === 'CARD_RECOGNIZED' && recognition.cardValue) {
|
if (recognition.code === 'CARD_RECOGNIZED' && recognition.cardValue) {
|
||||||
searchValue.value = recognition.cardValue
|
searchValue.value = recognition.cardValue
|
||||||
uni.showToast({ title: 'ICCID 识别成功', icon: 'success' })
|
recognizing.value = false
|
||||||
|
await handleQuery()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
searchValue.value = recognition.cardValue || ''
|
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) {
|
function publicCardTitle(card: AppCard) {
|
||||||
return card.iccid || card.msisdn || card.imsi || '-'
|
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) {
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
let message = ''
|
let message = ''
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
@@ -238,35 +207,28 @@ function copyQueryRow(row: { label: string, value: string, copyable?: boolean })
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="tool-row">
|
<view class="tool-row">
|
||||||
<wd-button class="tool-button tool-button--scan" size="small" plain @click="handleScan">
|
<wd-button class="tool-button tool-button--scan" size="small" css-icon="i-carbon-grid" plain @click="handleScan">
|
||||||
<view class="tool-button__content">
|
扫码
|
||||||
<view class="i-carbon-grid tool-button__icon" />
|
|
||||||
<text>扫码</text>
|
|
||||||
</view>
|
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button
|
<wd-button
|
||||||
class="tool-button tool-button--photo"
|
class="tool-button tool-button--photo"
|
||||||
size="small"
|
size="small"
|
||||||
|
css-icon="i-carbon-camera"
|
||||||
plain
|
plain
|
||||||
:loading="recognizing"
|
:loading="recognizing"
|
||||||
@click="handlePhotoRecognize"
|
@click="handlePhotoRecognize"
|
||||||
>
|
>
|
||||||
<view class="tool-button__content">
|
拍照
|
||||||
<view class="i-carbon-camera tool-button__icon" />
|
|
||||||
<text>拍照</text>
|
|
||||||
</view>
|
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button
|
<wd-button
|
||||||
class="tool-button tool-button--diagnosis"
|
class="tool-button tool-button--diagnosis"
|
||||||
size="small"
|
size="small"
|
||||||
|
css-icon="i-carbon-stethoscope"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
@click="goDiagnosis"
|
@click="goDiagnosis"
|
||||||
>
|
>
|
||||||
<view class="tool-button__content">
|
卡片排查
|
||||||
<view class="i-my-icons-sparkle tool-button__icon" />
|
|
||||||
<text>卡片排查</text>
|
|
||||||
</view>
|
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -319,25 +281,13 @@ function copyQueryRow(row: { label: string, value: string, copyable?: boolean })
|
|||||||
</view>
|
</view>
|
||||||
</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">
|
<wd-button size="small" type="primary" custom-class="result-action result-action--detail" @click="goDetail">
|
||||||
<view class="result-action__content">
|
<view class="result-action__content">
|
||||||
<view class="i-carbon-document-view result-action__icon" />
|
<view class="i-carbon-document-view result-action__icon" />
|
||||||
<text>查看详情</text>
|
<text>查看详情</text>
|
||||||
</view>
|
</view>
|
||||||
</wd-button>
|
</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -420,21 +370,18 @@ function copyQueryRow(row: { label: string, value: string, copyable?: boolean })
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button__content,
|
|
||||||
.result-action__content {
|
.result-action__content {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10rpx;
|
gap: 10rpx;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button__icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 26rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-button--scan {
|
.tool-button--scan {
|
||||||
border-color: #d8e2ef !important;
|
border-color: #d8e2ef !important;
|
||||||
background: #f8fbff !important;
|
background: #f8fbff !important;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '@/utils/cardRecognition'
|
} from '@/utils/cardRecognition'
|
||||||
import type { AppPermissionAction } from '@/utils/permission'
|
import type { AppPermissionAction } from '@/utils/permission'
|
||||||
import { hasUserPermission } from '@/utils/permission'
|
import { hasUserPermission } from '@/utils/permission'
|
||||||
import { isTerminalUserInfo } from '@/utils/userIdentity'
|
|
||||||
|
|
||||||
interface ShortcutItem {
|
interface ShortcutItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -41,29 +40,6 @@ const shortcuts: ShortcutItem[] = [
|
|||||||
action: 'photoRecognize',
|
action: 'photoRecognize',
|
||||||
sub: '识别卡片 ICCID',
|
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: '卡片排查',
|
label: '卡片排查',
|
||||||
icon: 'i-carbon-stethoscope',
|
icon: 'i-carbon-stethoscope',
|
||||||
@@ -71,21 +47,6 @@ const shortcuts: ShortcutItem[] = [
|
|||||||
path: RouterPath.BusinessViewsDiagnosis,
|
path: RouterPath.BusinessViewsDiagnosis,
|
||||||
sub: '查看状态、会话与流量异常',
|
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> = {
|
const toneClass: Record<ShortcutItem['tone'], string> = {
|
||||||
@@ -102,18 +63,10 @@ const userStore = useUserStore()
|
|||||||
const photoRecognizing = ref(false)
|
const photoRecognizing = ref(false)
|
||||||
let refreshUserPromise: Promise<void | IUserInfoRes> | undefined
|
let refreshUserPromise: Promise<void | IUserInfoRes> | undefined
|
||||||
|
|
||||||
const isTerminalUser = computed(() => isTerminalUserInfo(userStore.userInfo))
|
|
||||||
|
|
||||||
const visibleShortcuts = computed(() =>
|
const visibleShortcuts = computed(() =>
|
||||||
shortcuts.filter((item) => {
|
shortcuts.filter((item) => {
|
||||||
if (props.hideCardEntry && item.label === '我的卡片')
|
if (props.hideCardEntry && item.label === '我的卡片')
|
||||||
return false
|
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))
|
if (item.permissionAction && !hasShortcutPermission(item.permissionAction))
|
||||||
return false
|
return false
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AppCard } from '@/api/card'
|
import type { AppCard } from '@/api/card'
|
||||||
import { getCardList } from '@/api/card'
|
import { getCardList } from '@/api/card'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
|
||||||
import { useTokenStore } from '@/store'
|
import { useTokenStore } from '@/store'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import QuickBusiness from './components/QuickBusiness.vue'
|
import QuickBusiness from './components/QuickBusiness.vue'
|
||||||
@@ -12,7 +11,7 @@ definePage({
|
|||||||
type: 'home',
|
type: 'home',
|
||||||
style: {
|
style: {
|
||||||
navigationStyle: 'default',
|
navigationStyle: 'default',
|
||||||
navigationBarTitleText: '山东平台物联卡',
|
navigationBarTitleText: '示例企业物联卡',
|
||||||
navigationBarBackgroundColor: '#ffffff',
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
navigationBarTextStyle: 'black',
|
navigationBarTextStyle: 'black',
|
||||||
},
|
},
|
||||||
@@ -29,12 +28,6 @@ let cardLoadSeq = 0
|
|||||||
|
|
||||||
const hasLogin = computed(() => tokenStore.hasLogin)
|
const hasLogin = computed(() => tokenStore.hasLogin)
|
||||||
const showCardsLoading = computed(() => cardsLoading.value && cards.value.length === 0)
|
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() {
|
async function loadCards() {
|
||||||
if (!hasLogin.value) {
|
if (!hasLogin.value) {
|
||||||
@@ -84,12 +77,6 @@ function loadHomeData() {
|
|||||||
void loadCards()
|
void loadCards()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goLogin() {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `${RouterPath.Login}?redirect=${encodeURIComponent(RouterPath.Home)}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
loadHomeData()
|
loadHomeData()
|
||||||
})
|
})
|
||||||
@@ -110,7 +97,7 @@ onShow(() => {
|
|||||||
<view class="min-w-0 flex-1">
|
<view class="min-w-0 flex-1">
|
||||||
<text class="diagnosis-tip__title">遇到问题?先查卡状态</text>
|
<text class="diagnosis-tip__title">遇到问题?先查卡状态</text>
|
||||||
<text class="diagnosis-tip__desc">
|
<text class="diagnosis-tip__desc">
|
||||||
根据卡片状态、套餐、流量和会话信息,整理常见异常与处理方向
|
根据卡片状态、在线状态、流量和设备信息,整理常见异常与处理方向
|
||||||
</text>
|
</text>
|
||||||
<text class="mt-8rpx block text-21rpx text-#1d4ed8 leading-31rpx">
|
<text class="mt-8rpx block text-21rpx text-#1d4ed8 leading-31rpx">
|
||||||
排查结果仅供参考,以运营商和业务系统结果为准
|
排查结果仅供参考,以运营商和业务系统结果为准
|
||||||
@@ -118,6 +105,8 @@ onShow(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<QuickBusiness />
|
||||||
|
|
||||||
<OverviewCard
|
<OverviewCard
|
||||||
v-if="hasLogin"
|
v-if="hasLogin"
|
||||||
:cards="cards"
|
:cards="cards"
|
||||||
@@ -126,27 +115,6 @@ onShow(() => {
|
|||||||
:error-message="cardsErrorMessage"
|
:error-message="cardsErrorMessage"
|
||||||
@refresh-stats="loadCards"
|
@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>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ function goBack() {
|
|||||||
<text class="forgot-panel__desc">
|
<text class="forgot-panel__desc">
|
||||||
当前短信验证码服务还没有接入,为避免提交无效请求,找回密码功能暂时关闭。
|
当前短信验证码服务还没有接入,为避免提交无效请求,找回密码功能暂时关闭。
|
||||||
</text>
|
</text>
|
||||||
<wd-button type="primary" block disabled>等待短信服务接入</wd-button>
|
<wd-button type="primary" disabled block>
|
||||||
<wd-button class="forgot-back-button" plain block @click="goBack">返回登录</wd-button>
|
等待短信服务接入
|
||||||
|
</wd-button>
|
||||||
|
<wd-button class="forgot-back-button" plain block @click="goBack">
|
||||||
|
返回登录
|
||||||
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+253
-155
@@ -10,9 +10,10 @@ import type {
|
|||||||
import { onUnmounted, reactive, ref, watch } from 'vue'
|
import { onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
bindWechat,
|
bindWechat,
|
||||||
checkSideCaptcha,
|
|
||||||
getWxCode,
|
getWxCode,
|
||||||
|
phoneSmsLogin,
|
||||||
register,
|
register,
|
||||||
|
sendSms,
|
||||||
wechatBindPhone,
|
wechatBindPhone,
|
||||||
wechatLogin,
|
wechatLogin,
|
||||||
} from '@/api/login'
|
} from '@/api/login'
|
||||||
@@ -20,12 +21,15 @@ import SideCaptcha from '@/components/SideCaptcha.vue'
|
|||||||
import { useTokenStore } from '@/store/token'
|
import { useTokenStore } from '@/store/token'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { isPageTabbar } from '@/tabbar/store'
|
import { isPageTabbar } from '@/tabbar/store'
|
||||||
|
import { isNativeTabbar } from '@/tabbar/config'
|
||||||
import { HOME_PAGE } from '@/utils'
|
import { HOME_PAGE } from '@/utils'
|
||||||
|
import { navigateAfterLogin, phoneLoginDestination } from '@/utils/loginNavigation'
|
||||||
|
import { V101_RELEASE } from '@/constant/release'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '山东平台物联卡',
|
navigationBarTitleText: '示例企业物联卡',
|
||||||
navigationBarBackgroundColor: '#ffffff',
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
navigationBarTextStyle: 'black',
|
navigationBarTextStyle: 'black',
|
||||||
disableScroll: true,
|
disableScroll: true,
|
||||||
@@ -34,13 +38,15 @@ definePage({
|
|||||||
})
|
})
|
||||||
|
|
||||||
type AuthMode = 'login' | 'register'
|
type AuthMode = 'login' | 'register'
|
||||||
type CaptchaScene = 'register' | 'wechatBindPhone'
|
type LoginMethod = 'password' | 'sms'
|
||||||
|
type CaptchaScene = 'register' | 'login' | 'wechatBindPhone'
|
||||||
type AccountSelectScene = 'login' | 'wechatBindPhone'
|
type AccountSelectScene = 'login' | 'wechatBindPhone'
|
||||||
|
|
||||||
const tokenStore = useTokenStore()
|
const tokenStore = useTokenStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const authMode = ref<AuthMode>('login')
|
const authMode = ref<AuthMode>('login')
|
||||||
|
const loginMethod = ref<LoginMethod>('password')
|
||||||
const authPanelVisible = ref(false)
|
const authPanelVisible = ref(false)
|
||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
const wechatLoginLoading = ref(false)
|
const wechatLoginLoading = ref(false)
|
||||||
@@ -61,6 +67,9 @@ const wechatBindTips = ['流量预警', '业务通知', '卡片消息']
|
|||||||
let wechatBindResolve: ((value: boolean) => void) | null = null
|
let wechatBindResolve: ((value: boolean) => void) | null = null
|
||||||
const wechatBindPhoneVisible = ref(false)
|
const wechatBindPhoneVisible = ref(false)
|
||||||
const wechatBindPhoneLoading = 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({
|
const loginForm = reactive({
|
||||||
tenantId: '000000',
|
tenantId: '000000',
|
||||||
@@ -68,27 +77,31 @@ const loginForm = reactive({
|
|||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loginSmsForm = reactive({
|
||||||
|
tenantId: '000000',
|
||||||
|
phone: '',
|
||||||
|
smsCode: '',
|
||||||
|
})
|
||||||
|
|
||||||
const registerForm = reactive({
|
const registerForm = reactive({
|
||||||
tenantId: '000000',
|
tenantId: '000000',
|
||||||
phone: '',
|
phone: '',
|
||||||
nickName: '',
|
nickName: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
|
smsCode: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const wechatBindPhoneForm = reactive({
|
const wechatBindPhoneForm = reactive({
|
||||||
tenantId: '000000',
|
tenantId: '000000',
|
||||||
bindTicket: '',
|
bindTicket: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
|
smsCode: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const wechatBindPhoneCaptcha = reactive({
|
// 短信发送前必须完成滑块验证,验证码由后端统一校验。
|
||||||
captchaKey: '',
|
|
||||||
captchaPoint: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图形验证是否通过(注册前置门槛,暂不接入短信验证码)
|
|
||||||
const captchaPassed = ref(false)
|
const captchaPassed = ref(false)
|
||||||
|
const loginSmsCaptchaPassed = ref(false)
|
||||||
const wechatBindPhoneCaptchaPassed = ref(false)
|
const wechatBindPhoneCaptchaPassed = ref(false)
|
||||||
|
|
||||||
function isLoginSelectResponse(value: unknown): value is ILoginSelectRes {
|
function isLoginSelectResponse(value: unknown): value is ILoginSelectRes {
|
||||||
@@ -114,6 +127,8 @@ function getWechatBindTicket(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchMode(mode: AuthMode) {
|
function switchMode(mode: AuthMode) {
|
||||||
|
if (mode === 'register' && !V101_RELEASE.showRegister)
|
||||||
|
return
|
||||||
authMode.value = mode
|
authMode.value = mode
|
||||||
authPanelVisible.value = true
|
authPanelVisible.value = true
|
||||||
captchaPassed.value = false
|
captchaPassed.value = false
|
||||||
@@ -152,12 +167,12 @@ function normalizeRedirect(value?: string | string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goAfterLogin() {
|
function goAfterLogin() {
|
||||||
const url = redirectUrl.value || HOME_PAGE
|
authPanelVisible.value = false
|
||||||
if (isPageTabbar(url)) {
|
resetAccountSelection()
|
||||||
uni.switchTab({ url })
|
navigateAfterLogin(redirectUrl.value || HOME_PAGE, HOME_PAGE, isPageTabbar, {
|
||||||
return
|
switchTab: options => uni.switchTab(options),
|
||||||
}
|
reLaunch: options => uni.reLaunch(options),
|
||||||
uni.reLaunch({ url })
|
}, isNativeTabbar)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAccountSelection() {
|
function resetAccountSelection() {
|
||||||
@@ -168,14 +183,86 @@ function resetAccountSelection() {
|
|||||||
|
|
||||||
function resetWechatBindPhoneCaptcha() {
|
function resetWechatBindPhoneCaptcha() {
|
||||||
wechatBindPhoneCaptchaPassed.value = false
|
wechatBindPhoneCaptchaPassed.value = false
|
||||||
wechatBindPhoneCaptcha.captchaKey = ''
|
wechatBindPhoneForm.smsCode = ''
|
||||||
wechatBindPhoneCaptcha.captchaPoint = ''
|
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) {
|
function openWechatBindPhonePanel(bindTicket: string) {
|
||||||
wechatBindPhoneForm.tenantId = loginForm.tenantId || '000000'
|
wechatBindPhoneForm.tenantId = loginForm.tenantId || '000000'
|
||||||
wechatBindPhoneForm.bindTicket = bindTicket
|
wechatBindPhoneForm.bindTicket = bindTicket
|
||||||
wechatBindPhoneForm.phone = ''
|
wechatBindPhoneForm.phone = ''
|
||||||
|
wechatBindPhoneForm.smsCode = ''
|
||||||
resetWechatBindPhoneCaptcha()
|
resetWechatBindPhoneCaptcha()
|
||||||
authPanelVisible.value = false
|
authPanelVisible.value = false
|
||||||
wechatBindPhoneVisible.value = true
|
wechatBindPhoneVisible.value = true
|
||||||
@@ -304,8 +391,12 @@ function validateWechatBindPhoneForm(selectedUserId?: number) {
|
|||||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!selectedUserId && !wechatBindPhoneCaptchaPassed.value) {
|
if (!wechatBindPhoneCaptchaPassed.value) {
|
||||||
uni.showToast({ title: '请先完成图形验证', icon: 'none' })
|
uni.showToast({ title: '请先获取短信验证码', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!wechatBindPhoneForm.smsCode) {
|
||||||
|
uni.showToast({ title: '请输入短信验证码', icon: 'none' })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -324,8 +415,7 @@ async function submitWechatBindPhone(selectedUserId?: number) {
|
|||||||
tenantId: wechatBindPhoneForm.tenantId,
|
tenantId: wechatBindPhoneForm.tenantId,
|
||||||
bindTicket: wechatBindPhoneForm.bindTicket,
|
bindTicket: wechatBindPhoneForm.bindTicket,
|
||||||
phone: wechatBindPhoneForm.phone,
|
phone: wechatBindPhoneForm.phone,
|
||||||
captchaKey: selectedUserId ? undefined : wechatBindPhoneCaptcha.captchaKey,
|
smsCode: wechatBindPhoneForm.smsCode,
|
||||||
captchaPoint: selectedUserId ? undefined : wechatBindPhoneCaptcha.captchaPoint,
|
|
||||||
selectedUserId,
|
selectedUserId,
|
||||||
})
|
})
|
||||||
if (isLoginSelectResponse(res)) {
|
if (isLoginSelectResponse(res)) {
|
||||||
@@ -409,6 +499,7 @@ async function submitLogin(selectedUserId?: number) {
|
|||||||
// 登录令牌已经落地,后续绑定提示不能继续占用登录按钮的 loading 状态。
|
// 登录令牌已经落地,后续绑定提示不能继续占用登录按钮的 loading 状态。
|
||||||
loginLoading.value = false
|
loginLoading.value = false
|
||||||
await promptBindWechatIfNeeded()
|
await promptBindWechatIfNeeded()
|
||||||
|
redirectUrl.value = phoneLoginDestination(account, redirectUrl.value, HOME_PAGE)
|
||||||
goAfterLogin()
|
goAfterLogin()
|
||||||
}
|
}
|
||||||
finally {
|
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) {
|
function selectLoginAccount(item: ILoginCandidate) {
|
||||||
accountSelectVisible.value = false
|
accountSelectVisible.value = false
|
||||||
if (accountSelectScene.value === 'wechatBindPhone') {
|
if (accountSelectScene.value === 'wechatBindPhone') {
|
||||||
void submitWechatBindPhone(item.userId)
|
void submitWechatBindPhone(item.userId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void submitLogin(item.userId)
|
void (loginMethod.value === 'sms' ? submitPhoneSmsLogin(item.userId) : submitLogin(item.userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGetCaptcha(scene: CaptchaScene = 'register') {
|
function handleGetCaptcha(scene: CaptchaScene = 'register') {
|
||||||
if (scene === 'register' && captchaPassed.value)
|
if (smsCountdown[scene] > 0 || smsLoading.value)
|
||||||
return
|
return
|
||||||
if (scene === 'wechatBindPhone' && wechatBindPhoneCaptchaPassed.value)
|
if (!ensureAgreementChecked())
|
||||||
return
|
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)) {
|
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||||
uni.showToast({ title: '请先输入正确手机号', icon: 'none' })
|
uni.showToast({ title: '请先输入正确手机号', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -452,25 +579,15 @@ async function onSideCaptchaConfirm({
|
|||||||
return
|
return
|
||||||
captchaLoading.value = true
|
captchaLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (captchaScene.value === 'wechatBindPhone') {
|
await sendSmsAfterCaptcha(captchaScene.value, captchaKey, `${posX},${posY}`)
|
||||||
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' })
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (captchaScene.value === 'register')
|
||||||
captchaPassed.value = false
|
captchaPassed.value = false
|
||||||
|
if (captchaScene.value === 'login')
|
||||||
|
loginSmsCaptchaPassed.value = false
|
||||||
|
if (captchaScene.value === 'wechatBindPhone')
|
||||||
|
wechatBindPhoneCaptchaPassed.value = false
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: getLoginErrorMessage(error, '验证码校验失败,请重试'),
|
title: getLoginErrorMessage(error, '验证码校验失败,请重试'),
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
@@ -500,7 +617,11 @@ function validateRegisterForm() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!captchaPassed.value) {
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -520,6 +641,7 @@ async function submitRegister() {
|
|||||||
phone: registerForm.phone,
|
phone: registerForm.phone,
|
||||||
nickName: registerForm.nickName.trim(),
|
nickName: registerForm.nickName.trim(),
|
||||||
password: registerForm.password,
|
password: registerForm.password,
|
||||||
|
smsCode: registerForm.smsCode,
|
||||||
})
|
})
|
||||||
tokenStore.setTokenInfo(res)
|
tokenStore.setTokenInfo(res)
|
||||||
if (isSingleTokenResponse(res) && res.userInfo)
|
if (isSingleTokenResponse(res) && res.userInfo)
|
||||||
@@ -546,7 +668,14 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => registerForm.phone,
|
() => registerForm.phone,
|
||||||
() => {
|
() => {
|
||||||
captchaPassed.value = false
|
resetRegisterSmsCaptcha()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => loginSmsForm.phone,
|
||||||
|
() => {
|
||||||
|
resetLoginSmsCaptcha()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -559,7 +688,7 @@ watch(
|
|||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
redirectUrl.value = normalizeRedirect(query?.redirect)
|
redirectUrl.value = normalizeRedirect(query?.redirect)
|
||||||
if (query?.mode === 'register') {
|
if (query?.mode === 'register' && V101_RELEASE.showRegister) {
|
||||||
authMode.value = 'register'
|
authMode.value = 'register'
|
||||||
authPanelVisible.value = true
|
authPanelVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -568,6 +697,7 @@ onLoad((query) => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
sideCaptchaRef.value = null
|
sideCaptchaRef.value = null
|
||||||
wechatBindResolve = null
|
wechatBindResolve = null
|
||||||
|
Object.values(smsTimers).forEach(timer => timer && clearInterval(timer))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -578,14 +708,15 @@ onUnmounted(() => {
|
|||||||
<view class="hero-logo" aria-label="物联网智能服务">
|
<view class="hero-logo" aria-label="物联网智能服务">
|
||||||
<view class="i-carbon-user hero-logo__user" />
|
<view class="i-carbon-user hero-logo__user" />
|
||||||
</view>
|
</view>
|
||||||
<text class="hero-title">山东平台物联卡</text>
|
<text class="hero-title">示例企业物联卡</text>
|
||||||
<text class="hero-sub"> 卡片查询 · 状态排查 · 一键绑定 </text>
|
<text class="hero-sub"> 卡片查询 · 状态排查 </text>
|
||||||
<text class="hero-desc"> 让每一张物联卡自己会说话 </text>
|
<text class="hero-desc"> 让每一张物联卡自己会说话 </text>
|
||||||
<text class="hero-desc"> 快速掌握卡片状态,办理业务更清晰 </text>
|
<text class="hero-desc"> 快速掌握卡片状态,查询结果更清晰 </text>
|
||||||
|
|
||||||
<view class="hero-actions">
|
<view class="hero-actions">
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<wd-button
|
<wd-button
|
||||||
|
v-if="V101_RELEASE.showWechatLogin"
|
||||||
block
|
block
|
||||||
custom-class="hero-wechat-btn"
|
custom-class="hero-wechat-btn"
|
||||||
:loading="wechatLoginLoading"
|
:loading="wechatLoginLoading"
|
||||||
@@ -603,31 +734,6 @@ onUnmounted(() => {
|
|||||||
<view class="guest-query" @click="goPublicQuery">
|
<view class="guest-query" @click="goPublicQuery">
|
||||||
未登录也可直接查询卡片(输入 ICCID / 扫码)
|
未登录也可直接查询卡片(输入 ICCID / 扫码)
|
||||||
</view>
|
</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>
|
</view>
|
||||||
|
|
||||||
<wd-popup
|
<wd-popup
|
||||||
@@ -641,12 +747,12 @@ onUnmounted(() => {
|
|||||||
<view class="panel-head">
|
<view class="panel-head">
|
||||||
<view>
|
<view>
|
||||||
<text class="panel-title">
|
<text class="panel-title">
|
||||||
{{ authMode === 'login' ? '账号密码登录' : '注册账号' }}
|
{{ authMode === 'login' ? '账号登录' : '注册账号' }}
|
||||||
</text>
|
</text>
|
||||||
<text class="panel-sub">
|
<text class="panel-sub">
|
||||||
{{
|
{{
|
||||||
authMode === 'login'
|
authMode === 'login'
|
||||||
? '企业、代理商和终端用户均可使用分配账号登录'
|
? '支持账号密码或手机号验证码登录'
|
||||||
: '终端用户可用手机号注册并绑定卡片'
|
: '终端用户可用手机号注册并绑定卡片'
|
||||||
}}
|
}}
|
||||||
</text>
|
</text>
|
||||||
@@ -659,26 +765,33 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</view>
|
</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 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 -->
|
<!-- #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="wechat-login__icon">
|
||||||
<view class="i-carbon-logo-wechat wechat-login__icon-glyph" />
|
<view class="i-carbon-logo-wechat wechat-login__icon-glyph" />
|
||||||
</view>
|
</view>
|
||||||
@@ -690,12 +803,12 @@ onUnmounted(() => {
|
|||||||
<view v-else class="i-carbon-chevron-right wechat-login__arrow" />
|
<view v-else class="i-carbon-chevron-right wechat-login__arrow" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="login-divider">
|
<view v-if="V101_RELEASE.showWechatLogin" class="login-divider">
|
||||||
<text>或使用账号密码</text>
|
<text>或使用账号密码</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
|
||||||
<wd-form :model="loginForm" error-type="toast">
|
<wd-form v-if="loginMethod === 'password'" :model="loginForm" error-type="toast">
|
||||||
<view class="field">
|
<view class="field">
|
||||||
<text class="field-label">账号</text>
|
<text class="field-label">账号</text>
|
||||||
<wd-input
|
<wd-input
|
||||||
@@ -721,18 +834,39 @@ onUnmounted(() => {
|
|||||||
</view>
|
</view>
|
||||||
</wd-form>
|
</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
|
<wd-button
|
||||||
class="mt-32rpx"
|
class="mt-32rpx"
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
:loading="loginLoading"
|
:loading="loginLoading"
|
||||||
@click="submitLogin()"
|
@click="loginMethod === 'sms' ? submitPhoneSmsLogin() : submitLogin()"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</wd-button>
|
</wd-button>
|
||||||
|
|
||||||
<view class="panel-links">
|
<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>
|
<text class="link-muted" @click="goForgotPassword">忘记密码</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -746,7 +880,7 @@ onUnmounted(() => {
|
|||||||
</view>
|
</view>
|
||||||
</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">
|
<wd-form :model="registerForm" error-type="toast">
|
||||||
<view class="field compact">
|
<view class="field compact">
|
||||||
<text class="field-label">手机号</text>
|
<text class="field-label">手机号</text>
|
||||||
@@ -759,6 +893,20 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="field-divider" />
|
<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">
|
<view class="field compact">
|
||||||
<text class="field-label">昵称</text>
|
<text class="field-label">昵称</text>
|
||||||
<wd-input
|
<wd-input
|
||||||
@@ -790,25 +938,6 @@ onUnmounted(() => {
|
|||||||
custom-class="field-input"
|
custom-class="field-input"
|
||||||
/>
|
/>
|
||||||
</view>
|
</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-form>
|
||||||
|
|
||||||
<wd-button
|
<wd-button
|
||||||
@@ -928,21 +1057,16 @@ onUnmounted(() => {
|
|||||||
</view>
|
</view>
|
||||||
<view class="field-divider" />
|
<view class="field-divider" />
|
||||||
<view class="field compact">
|
<view class="field compact">
|
||||||
<text class="field-label">安全验证</text>
|
<text class="field-label">验证码</text>
|
||||||
<view
|
<view class="flex flex-1 items-center gap-12rpx">
|
||||||
class="verify-bar"
|
<wd-input v-model="wechatBindPhoneForm.smsCode" type="number" placeholder="请输入短信验证码" clearable custom-class="field-input flex-1" />
|
||||||
:class="{ 'verify-bar--done': wechatBindPhoneCaptchaPassed }"
|
<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')"
|
@click="handleGetCaptcha('wechatBindPhone')"
|
||||||
>
|
>
|
||||||
<view class="i-carbon-security verify-bar__ico" />
|
{{ smsLoading ? '发送中' : smsCountdown.wechatBindPhone > 0 ? `${smsCountdown.wechatBindPhone}s 后重试` : '获取验证码' }}
|
||||||
<text class="verify-bar__text">
|
|
||||||
{{ wechatBindPhoneCaptchaPassed ? '图形验证已完成' : '请完成图形验证' }}
|
|
||||||
</text>
|
</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>
|
||||||
</view>
|
</view>
|
||||||
</wd-form>
|
</wd-form>
|
||||||
@@ -1242,34 +1366,8 @@ page {
|
|||||||
color: #94a3b8 !important;
|
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 {
|
.panel-body {
|
||||||
margin-top: 18rpx;
|
margin-top: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-login {
|
.wechat-login {
|
||||||
|
|||||||
@@ -37,176 +37,26 @@ function iconClass(item: MessageItem) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="msg-list">
|
<view class="flex flex-col gap-16rpx">
|
||||||
<view class="mlist">
|
<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
|
<view class="h-64rpx w-64rpx flex shrink-0 items-center justify-center rounded-16rpx bg-canvas-soft text-30rpx text-primary">
|
||||||
v-for="item in list"
|
<view :class="iconClass(item)" />
|
||||||
:key="item.messageId"
|
|
||||||
class="mitem"
|
|
||||||
@click="emit('open', item)"
|
|
||||||
>
|
|
||||||
<view class="micon" :class="`micon--${itemTone(item)}`">
|
|
||||||
<view class="micon-glyph" :class="iconClass(item)" />
|
|
||||||
</view>
|
</view>
|
||||||
<view class="mmain">
|
<view class="min-w-0 flex flex-1 flex-col gap-12rpx">
|
||||||
<view class="mt-row">
|
<view class="flex items-start justify-between gap-16rpx">
|
||||||
<text class="mt">{{ item.title }}</text>
|
<text class="min-w-0 flex-1 break-words text-28rpx text-ink font-bold leading-40rpx">{{ item.title }}</text>
|
||||||
<view v-if="item.unread" class="udot" />
|
<wd-tag v-if="item.unread" type="danger" round>
|
||||||
|
未读
|
||||||
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
<text class="ms">{{ item.summary }}</text>
|
<text class="line-clamp-2 break-words text-25rpx text-muted leading-38rpx">{{ item.summary }}</text>
|
||||||
</view>
|
<view class="flex flex-wrap items-center justify-between gap-12rpx">
|
||||||
<view class="mside">
|
<wd-tag :type="itemTone(item)" plain>
|
||||||
<text class="mtime">{{ item.time || '暂无时间' }}</text>
|
{{ item.typeText || group.label }}
|
||||||
|
</wd-tag>
|
||||||
|
<text class="text-22rpx text-disabled">{{ item.time || '暂无时间' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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
|
placeholder: string
|
||||||
modelValue: string
|
modelValue: string
|
||||||
filterKey: MessageFilterKey
|
filterKey: MessageFilterKey
|
||||||
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -23,120 +24,18 @@ const activeFilterKey = computed({
|
|||||||
get: () => props.filterKey,
|
get: () => props.filterKey,
|
||||||
set: value => emit('update:filterKey', value),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="toolbar">
|
<view class="card-base flex flex-col gap-20rpx p-20rpx">
|
||||||
<view class="search-row">
|
<view class="flex items-center gap-16rpx">
|
||||||
<wd-search
|
<wd-search v-model="keyword" hide-cancel placeholder-left variant="filled" :placeholder="props.placeholder" custom-class="min-w-0 flex-1 !p-0" />
|
||||||
v-model="keyword"
|
<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')">
|
||||||
hide-cancel
|
刷新
|
||||||
placeholder-left
|
</wd-button>
|
||||||
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>
|
</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>
|
</view>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ export const messageFilterOptions: MessageFilterOption[] = [
|
|||||||
|
|
||||||
export function normalizeMessage(source: AppMessage): MessageItem {
|
export function normalizeMessage(source: AppMessage): MessageItem {
|
||||||
const messageType = Number(source.messageType || 1)
|
const messageType = Number(source.messageType || 1)
|
||||||
const group =
|
const group
|
||||||
baseMessageGroups.find((item) => item.messageTypes.includes(messageType)) ||
|
= baseMessageGroups.find(item => item.messageTypes.includes(messageType))
|
||||||
baseMessageGroups[0]
|
|| baseMessageGroups[0]
|
||||||
const content = String(source.content || '')
|
const content = String(source.content || '')
|
||||||
const summary = stripRichText(content) || '暂无消息详细内容'
|
const summary = stripRichText(content) || '暂无消息详细内容'
|
||||||
|
|
||||||
@@ -157,16 +157,18 @@ export function normalizeMessage(source: AppMessage): MessageItem {
|
|||||||
|
|
||||||
export function messageMatchesKeyword(item: MessageItem, keyword: string) {
|
export function messageMatchesKeyword(item: MessageItem, keyword: string) {
|
||||||
const value = keyword.trim()
|
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(
|
export function messageMatchesGroup(
|
||||||
item: MessageItem,
|
item: MessageItem,
|
||||||
group: Pick<MessageGroup, 'key' | 'messageTypes'>,
|
group: Pick<MessageGroup, 'key' | 'messageTypes'>,
|
||||||
) {
|
) {
|
||||||
if (group.key === 'all') return true
|
if (group.key === 'all')
|
||||||
|
return true
|
||||||
return group.messageTypes.includes(item.messageType)
|
return group.messageTypes.includes(item.messageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +182,8 @@ const messageModulePatterns: Array<[MessageModuleKey, RegExp]> = [
|
|||||||
export function getMessageModuleKey(item: MessageItem): MessageModuleKey {
|
export function getMessageModuleKey(item: MessageItem): MessageModuleKey {
|
||||||
const text = `${item.title}${item.summary}${item.content}`
|
const text = `${item.title}${item.summary}${item.content}`
|
||||||
const matched = messageModulePatterns.find(([, pattern]) => pattern.test(text))
|
const matched = messageModulePatterns.find(([, pattern]) => pattern.test(text))
|
||||||
if (matched) return matched[0]
|
if (matched)
|
||||||
|
return matched[0]
|
||||||
|
|
||||||
switch (item.messageType) {
|
switch (item.messageType) {
|
||||||
case 3:
|
case 3:
|
||||||
@@ -198,7 +201,7 @@ export function messageMatchesModule(item: MessageItem, module: MessageModule) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function latestModuleMessage(items: 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) {
|
export function messageTimestamp(item: MessageItem) {
|
||||||
@@ -208,42 +211,50 @@ export function messageTimestamp(item: MessageItem) {
|
|||||||
|
|
||||||
export function compareMessageDesc(a: MessageItem, b: MessageItem) {
|
export function compareMessageDesc(a: MessageItem, b: MessageItem) {
|
||||||
const timeDiff = messageTimestamp(b) - messageTimestamp(a)
|
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)
|
return Number(b.messageId || b.id || 0) - Number(a.messageId || a.id || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareMessageAsc(a: MessageItem, b: MessageItem) {
|
export function compareMessageAsc(a: MessageItem, b: MessageItem) {
|
||||||
const timeDiff = messageTimestamp(a) - messageTimestamp(b)
|
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)
|
return Number(a.messageId || a.id || 0) - Number(b.messageId || b.id || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMessageTimestamp(value?: string | number | Date | null) {
|
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) {
|
if (value instanceof Date) {
|
||||||
const time = value.getTime()
|
const time = value.getTime()
|
||||||
return Number.isFinite(time) ? time : 0
|
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()
|
const text = String(value).trim()
|
||||||
if (!text) return 0
|
if (!text)
|
||||||
|
return 0
|
||||||
|
|
||||||
const candidates = [text, text.replace(' ', 'T'), text.replace(/-/g, '/').replace('T', ' ')]
|
const candidates = [text, text.replace(' ', 'T'), text.replace(/-/g, '/').replace('T', ' ')]
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const parsed = Date.parse(candidate)
|
const parsed = Date.parse(candidate)
|
||||||
if (Number.isFinite(parsed)) return parsed
|
if (Number.isFinite(parsed))
|
||||||
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageWithinDays(item: MessageItem, days: number) {
|
export function messageWithinDays(item: MessageItem, days: number) {
|
||||||
if (days <= 0) return true
|
if (days <= 0)
|
||||||
|
return true
|
||||||
|
|
||||||
const timestamp = messageTimestamp(item)
|
const timestamp = messageTimestamp(item)
|
||||||
if (!timestamp) return false
|
if (!timestamp)
|
||||||
|
return false
|
||||||
|
|
||||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
|
||||||
return timestamp >= cutoff
|
return timestamp >= cutoff
|
||||||
@@ -293,7 +304,8 @@ export function stripRichText(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMessageTime(value: string) {
|
function formatMessageTime(value: string) {
|
||||||
if (!value) return ''
|
if (!value)
|
||||||
|
return ''
|
||||||
|
|
||||||
return String(value)
|
return String(value)
|
||||||
.replace('T', ' ')
|
.replace('T', ' ')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+196
-395
@@ -2,9 +2,13 @@
|
|||||||
import type { AppMessage } from '@/api/message'
|
import type { AppMessage } from '@/api/message'
|
||||||
import { getMessageList, readMessages } from '@/api/message'
|
import { getMessageList, readMessages } from '@/api/message'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { hasAnyFeature } from '@/utils/permission'
|
||||||
import { useMessageStore } from '@/store/message'
|
import { useMessageStore } from '@/store/message'
|
||||||
|
import { useTokenStore } from '@/store/token'
|
||||||
import MessageList from './components/MessageList.vue'
|
import MessageList from './components/MessageList.vue'
|
||||||
import MessageToolbar from './components/MessageToolbar.vue'
|
import MessageToolbar from './components/MessageToolbar.vue'
|
||||||
|
import { collectMessagePages } from './pagination'
|
||||||
import {
|
import {
|
||||||
compareMessageAsc,
|
compareMessageAsc,
|
||||||
compareMessageDesc,
|
compareMessageDesc,
|
||||||
@@ -24,8 +28,8 @@ definePage({
|
|||||||
layout: 'default',
|
layout: 'default',
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageSize = 100
|
|
||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
|
const tokenStore = useTokenStore()
|
||||||
|
|
||||||
const activeModuleKey = ref<MessageModuleKey>()
|
const activeModuleKey = ref<MessageModuleKey>()
|
||||||
const activeFilter = ref<MessageFilterKey>('all')
|
const activeFilter = ref<MessageFilterKey>('all')
|
||||||
@@ -37,29 +41,45 @@ const rawMessages = ref<AppMessage[]>([])
|
|||||||
const totalUnread = ref(0)
|
const totalUnread = ref(0)
|
||||||
let messageLoadSeq = 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(() =>
|
const moduleRows = computed(() =>
|
||||||
messageModules.map((module) => {
|
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)
|
const latest = latestModuleMessage(moduleMessages, module)
|
||||||
return {
|
return {
|
||||||
...module,
|
...module,
|
||||||
unreadCount: moduleMessages.filter((item) => item.unread).length,
|
unreadCount: moduleMessages.filter(item => item.unread).length,
|
||||||
latest,
|
latest,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeModule = computed(() =>
|
const activeModule = computed(() =>
|
||||||
messageModules.find((item) => item.key === activeModuleKey.value),
|
messageModules.find(item => item.key === activeModuleKey.value),
|
||||||
)
|
)
|
||||||
const currentUnreadIds = computed(() => {
|
const currentUnreadIds = computed(() => {
|
||||||
const ids = messageItems.value
|
const ids = messageItems.value
|
||||||
.filter(
|
.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)
|
.filter(Boolean)
|
||||||
return Array.from(new Set(ids))
|
return Array.from(new Set(ids))
|
||||||
})
|
})
|
||||||
@@ -77,55 +97,67 @@ const activeModuleMessageGroup = computed(() => ({
|
|||||||
const filteredMessages = computed(() => {
|
const filteredMessages = computed(() => {
|
||||||
const compareMessage = activeFilter.value === 'asc' ? compareMessageAsc : compareMessageDesc
|
const compareMessage = activeFilter.value === 'asc' ? compareMessageAsc : compareMessageDesc
|
||||||
const list = messageItems.value
|
const list = messageItems.value
|
||||||
.filter((item) => (activeModule.value ? messageMatchesModule(item, activeModule.value) : false))
|
.filter(item => (activeModule.value ? messageMatchesModule(item, activeModule.value) : false))
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (activeFilter.value !== 'recent30') return true
|
if (activeFilter.value !== 'recent30')
|
||||||
|
return true
|
||||||
return messageWithinDays(item, 30)
|
return messageWithinDays(item, 30)
|
||||||
})
|
})
|
||||||
.filter((item) => messageMatchesKeyword(item, keyword.value))
|
.filter(item => messageMatchesKeyword(item, keyword.value))
|
||||||
.sort(compareMessage)
|
.sort(compareMessage)
|
||||||
|
|
||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyTip = computed(() => {
|
const emptyTip = computed(() => {
|
||||||
if (keyword.value.trim()) return '暂无匹配消息'
|
if (keyword.value.trim())
|
||||||
if (activeFilter.value === 'recent30') return '最近30天暂无消息'
|
return '暂无匹配消息'
|
||||||
|
if (activeFilter.value === 'recent30')
|
||||||
|
return '最近30天暂无消息'
|
||||||
return activeModule.value?.emptyTip || '请选择一个消息模块'
|
return activeModule.value?.emptyTip || '请选择一个消息模块'
|
||||||
})
|
})
|
||||||
const showInitialLoading = computed(() => loading.value && rawMessages.value.length === 0)
|
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({
|
const res = await getMessageList({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 1,
|
pageSize: 1,
|
||||||
isRead: '0',
|
isRead: '0',
|
||||||
})
|
})
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
totalUnread.value = Number(res.total || 0)
|
totalUnread.value = Number(res.total || 0)
|
||||||
messageStore.setUnreadCount(totalUnread.value)
|
messageStore.setUnreadCount(totalUnread.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages() {
|
async function loadMessages() {
|
||||||
const seq = ++messageLoadSeq
|
const seq = ++messageLoadSeq
|
||||||
|
const token = tokenStore.validToken
|
||||||
|
if (!token)
|
||||||
|
return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await getMessageList({
|
const items = await collectMessagePages(getMessageList, {}, () => isCurrentRequest(seq, token))
|
||||||
page: 1,
|
if (!items || !isCurrentRequest(seq, token))
|
||||||
pageSize,
|
return
|
||||||
})
|
rawMessages.value = items
|
||||||
if (seq !== messageLoadSeq) return
|
await loadUnreadTotal(seq, token)
|
||||||
rawMessages.value = res.items || []
|
}
|
||||||
await loadUnreadTotal()
|
catch (error) {
|
||||||
} catch (error) {
|
if (!isCurrentRequest(seq, token))
|
||||||
if (seq !== messageLoadSeq) return
|
return
|
||||||
if (rawMessages.value.length === 0) {
|
if (rawMessages.value.length === 0) {
|
||||||
rawMessages.value = []
|
rawMessages.value = []
|
||||||
totalUnread.value = 0
|
totalUnread.value = 0
|
||||||
messageStore.setUnreadCount(0)
|
messageStore.setUnreadCount(0)
|
||||||
}
|
}
|
||||||
errorMessage.value = getErrorMessage(error, '消息加载失败,请稍后重试')
|
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) {
|
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) {
|
if (item.unread) {
|
||||||
|
const seq = messageLoadSeq
|
||||||
|
const token = tokenStore.validToken
|
||||||
|
readMessages({ ids: [item.messageId] }).then(() => {
|
||||||
|
if (isCurrentRequest(seq, token))
|
||||||
markLocalRead(item.id)
|
markLocalRead(item.id)
|
||||||
readMessages({ ids: [item.messageId] }).catch(() => {
|
}).catch(() => {
|
||||||
|
if (isCurrentRequest(seq, token))
|
||||||
|
void loadMessages()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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()
|
void loadMessages()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const detailPath =
|
const detailPath
|
||||||
Number(item.messageType) === 1
|
= Number(item.messageType) === 1
|
||||||
? RouterPath.MessageViewsSystemDetail
|
? RouterPath.MessageViewsSystemDetail
|
||||||
: RouterPath.MessageViewsCardDetail
|
: RouterPath.MessageViewsCardDetail
|
||||||
|
|
||||||
@@ -160,37 +227,58 @@ async function handleOpen(item: MessageItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleReadAll() {
|
async function handleReadAll() {
|
||||||
if (readingAll.value) return
|
if (readingAll.value)
|
||||||
|
return
|
||||||
|
|
||||||
const ids = currentUnreadIds.value
|
const ids = currentUnreadIds.value
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
uni.showToast({ title: '当前没有未读消息', icon: 'none' })
|
uni.showToast({ title: '当前没有未读消息', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const seq = messageLoadSeq
|
||||||
|
const token = tokenStore.validToken
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
readingAll.value = true
|
readingAll.value = true
|
||||||
try {
|
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))
|
const idSet = new Set(ids.map(Number))
|
||||||
rawMessages.value = rawMessages.value.map((item) => {
|
rawMessages.value = rawMessages.value.map((item) => {
|
||||||
const id = Number(item.messageId || item.id || 0)
|
const id = Number(item.messageId || item.id || 0)
|
||||||
if (!idSet.has(id)) return item
|
return idSet.has(id) ? { ...item, isRead: 1 } : item
|
||||||
return { ...item, isRead: 1 }
|
|
||||||
})
|
})
|
||||||
totalUnread.value = Math.max(0, totalUnread.value - ids.length)
|
totalUnread.value = Math.max(0, totalUnread.value - ids.length)
|
||||||
messageStore.setUnreadCount(totalUnread.value)
|
messageStore.setUnreadCount(totalUnread.value)
|
||||||
await loadUnreadTotal()
|
await loadUnreadTotal(seq, token)
|
||||||
|
if (isCurrentRequest(seq, token))
|
||||||
uni.showToast({ title: '已标记为已读', icon: 'success' })
|
uni.showToast({ title: '已标记为已读', icon: 'success' })
|
||||||
} catch (error) {
|
}
|
||||||
|
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' })
|
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
|
if (tokenStore.validToken === token)
|
||||||
readingAll.value = false
|
readingAll.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markLocalRead(id: number) {
|
function markLocalRead(id: number) {
|
||||||
rawMessages.value = rawMessages.value.map((item) => {
|
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 }
|
return { ...item, isRead: 1 }
|
||||||
})
|
})
|
||||||
totalUnread.value = Math.max(0, totalUnread.value - 1)
|
totalUnread.value = Math.max(0, totalUnread.value - 1)
|
||||||
@@ -199,390 +287,103 @@ function markLocalRead(id: number) {
|
|||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string) {
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
const data = error as { msg?: unknown; message?: unknown }
|
const data = error as { msg?: unknown, message?: unknown }
|
||||||
if (typeof data.msg === 'string' && data.msg) return data.msg
|
if (typeof data.msg === 'string' && data.msg)
|
||||||
if (typeof data.message === 'string' && data.message) return data.message
|
return data.msg
|
||||||
|
if (typeof data.message === 'string' && data.message)
|
||||||
|
return data.message
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad((query) => {
|
||||||
const moduleKey = String(query?.module || '').trim() as MessageModuleKey
|
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)
|
onShow(loadMessages)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="message-page">
|
<view class="min-h-screen bg-canvas p-20rpx pb-48rpx">
|
||||||
<view class="message-stack">
|
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
||||||
<view class="message-header">
|
<view class="card-base flex flex-col gap-24rpx p-28rpx">
|
||||||
<view class="message-header__nav">
|
<view class="flex items-center gap-20rpx">
|
||||||
<view v-if="activeModule" class="back-button" @click="closeModule">
|
<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">
|
||||||
<view class="i-carbon-arrow-left" />
|
返回
|
||||||
|
</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>
|
||||||
<view class="message-header__copy">
|
<wd-tag :type="totalUnread ? 'danger' : 'default'" plain>
|
||||||
<text class="message-title">{{ activeModule?.label || '消息' }}</text>
|
{{ totalUnread > 99 ? '99+' : totalUnread }} 未读
|
||||||
<text class="message-sub">{{
|
</wd-tag>
|
||||||
activeModule ? '查看这个模块的全部消息' : '按模块查看消息和业务动态'
|
|
||||||
}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="message-header__unread">
|
|
||||||
<text>未读</text>
|
|
||||||
<text class="unread-count">{{ totalUnread > 99 ? '99+' : totalUnread }}</text>
|
|
||||||
</view>
|
|
||||||
</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>
|
|
||||||
</view>
|
</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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-if="!activeModule">
|
<MessageToolbar v-if="activeModule" v-model="keyword" v-model:filter-key="activeFilter" :placeholder="`搜索${activeModule.label}`" :loading="loading" @refresh="loadMessages" />
|
||||||
<view class="module-list">
|
<view v-else class="flex items-center justify-between px-4rpx">
|
||||||
<view
|
<text class="text-28rpx text-ink font-bold">消息分类</text>
|
||||||
v-for="module in moduleRows"
|
<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">
|
||||||
:key="module.key"
|
刷新消息
|
||||||
class="module-row"
|
</wd-button>
|
||||||
@click="openModule(module)"
|
</view>
|
||||||
>
|
|
||||||
<view class="module-icon" :class="`module-icon--${module.tagType}`">
|
<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 :class="module.icon" />
|
||||||
</view>
|
</view>
|
||||||
<view class="module-main">
|
<view class="min-w-0 flex-1">
|
||||||
<view class="module-title-row">
|
<view class="flex flex-wrap items-center gap-12rpx">
|
||||||
<text class="module-title">{{ module.label }}</text>
|
<text class="text-29rpx text-ink font-bold">{{ module.label }}</text>
|
||||||
<view v-if="module.unreadCount" class="module-unread">
|
<wd-tag v-if="module.unreadCount" type="danger" round>
|
||||||
{{ module.unreadCount > 99 ? '99+' : module.unreadCount }}
|
{{ 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>
|
||||||
</view>
|
</view>
|
||||||
<text class="module-summary">{{ module.latest?.summary || module.emptyTip }}</text>
|
<view class="flex items-start gap-12rpx px-8rpx text-23rpx text-muted leading-36rpx">
|
||||||
</view>
|
<view class="i-carbon-information mt-4rpx shrink-0 text-26rpx" />
|
||||||
<view class="module-side">
|
<text>进入详情查看消息和预警通知。</text>
|
||||||
<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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
<MessageList v-else-if="filteredMessages.length" :group="activeModuleMessageGroup" :list="filteredMessages" @open="handleOpen" />
|
||||||
<template v-else>
|
<view v-else class="card-base py-32rpx">
|
||||||
<MessageToolbar
|
<wd-empty :tip="emptyTip">
|
||||||
v-model="keyword"
|
<template #image>
|
||||||
v-model:filter-key="activeFilter"
|
<view class="i-carbon-notification text-72rpx text-disabled" />
|
||||||
: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>
|
</template>
|
||||||
|
</wd-empty>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -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('网络失败')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('消息分页未前进,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import type { AppMessage } from '@/api/message'
|
import type { AppMessage } from '@/api/message'
|
||||||
import { getMessageDetail, readMessages } from '@/api/message'
|
import { getMessageDetail, readMessages } from '@/api/message'
|
||||||
import { stripRichText } from '../../data'
|
import { stripRichText } from '../../data'
|
||||||
|
import { useMessageStore } from '@/store/message'
|
||||||
|
import { useTokenStore } from '@/store/token'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
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 loading = ref(false)
|
||||||
const marking = ref(false)
|
const marking = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const detail = ref<AppMessage | null>(null)
|
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 content = computed(() => detail.value?.content || '')
|
||||||
const contentText = computed(() => stripRichText(content.value) || '暂无消息详细内容')
|
const contentText = computed(() => stripRichText(content.value) || '暂无消息详细内容')
|
||||||
const canRenderRichText = computed(() => /<[^>]+>/.test(content.value))
|
const canRenderRichText = computed(() => /<[^>]+>/.test(content.value))
|
||||||
@@ -29,43 +51,74 @@ async function loadDetail(id: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seq = ++detailRequestSeq
|
||||||
|
const token = tokenStore.validToken
|
||||||
|
if (!token)
|
||||||
|
return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await getMessageDetail({ messageId: id })
|
const res = await getMessageDetail({ messageId: id })
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
|
if (!res.message)
|
||||||
|
throw new Error('消息不存在或已撤回')
|
||||||
detail.value = res.message
|
detail.value = res.message
|
||||||
if (Number(res.message?.isRead) === 0) {
|
if (Number(res.message.isRead) === 0) {
|
||||||
|
try {
|
||||||
|
await readMessages({ ids: [id] })
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
detail.value = { ...res.message, isRead: 1 }
|
detail.value = { ...res.message, isRead: 1 }
|
||||||
readMessages({ ids: [id] }).catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
|
catch {
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
|
uni.showToast({ title: '自动标记失败,可手动标记已读', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void useMessageStore().refreshUnreadCount()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
errorMessage.value = getErrorMessage(error, '消息详情加载失败,请稍后重试')
|
errorMessage.value = getErrorMessage(error, '消息详情加载失败,请稍后重试')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (isCurrentRequest(seq, token))
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMarkRead() {
|
async function handleMarkRead() {
|
||||||
const id = Number(detail.value?.messageId || detail.value?.id || 0)
|
const id = Number(detail.value?.messageId || detail.value?.id || 0)
|
||||||
if (!id)
|
if (!id || marking.value)
|
||||||
return
|
return
|
||||||
if (Number(detail.value?.isRead) === 1) {
|
if (Number(detail.value?.isRead) === 1) {
|
||||||
uni.showToast({ title: '当前消息已读', icon: 'none' })
|
uni.showToast({ title: '当前消息已读', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seq = detailRequestSeq
|
||||||
|
const token = tokenStore.validToken
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
marking.value = true
|
marking.value = true
|
||||||
try {
|
try {
|
||||||
await readMessages({ ids: [id] })
|
await readMessages({ ids: [id] })
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
detail.value = { ...detail.value!, isRead: 1 }
|
detail.value = { ...detail.value!, isRead: 1 }
|
||||||
|
void useMessageStore().refreshUnreadCount()
|
||||||
uni.showToast({ title: '已标记已读', icon: 'success' })
|
uni.showToast({ title: '已标记已读', icon: 'success' })
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
|
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (isCurrentRequest(seq, token))
|
||||||
marking.value = false
|
marking.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +159,7 @@ function formatTime(value: string) {
|
|||||||
|
|
||||||
return String(value)
|
return String(value)
|
||||||
.replace('T', ' ')
|
.replace('T', ' ')
|
||||||
.replace(/\.\d+.*$/, '')
|
.replace(/\.\d.*$/, '')
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,128 +180,71 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLoad((options: Record<string, string | undefined>) => {
|
onLoad((options: Record<string, string | undefined>) => {
|
||||||
void loadDetail(Number(options?.messageId || 0))
|
messageId.value = Number(options?.messageId || 0)
|
||||||
|
void loadDetail(messageId.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
|
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
|
||||||
<view class="mx-auto max-w-920rpx flex flex-col gap-16rpx">
|
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
||||||
<view v-if="loading" class="card-base flex items-center justify-center py-72rpx">
|
<view v-if="loading" class="card-base flex flex-col items-center gap-20rpx py-72rpx">
|
||||||
<wd-loading />
|
<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>
|
||||||
|
|
||||||
<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>
|
</view>
|
||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<view class="card-base overflow-hidden">
|
<view class="card-base flex flex-col gap-20rpx p-28rpx">
|
||||||
<view class="detail-row">
|
<view class="flex flex-wrap items-center gap-12rpx">
|
||||||
<text class="detail-label">标题</text>
|
<wd-tag type="primary" plain>
|
||||||
<text class="detail-value detail-title">{{ detail.title || '未命名消息' }}</text>
|
{{ sourceText }}
|
||||||
</view>
|
</wd-tag>
|
||||||
<view class="detail-row">
|
<wd-tag :type="readText === '已读' ? 'success' : 'warning'" round>
|
||||||
<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 }}
|
{{ readText }}
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</view>
|
</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-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>
|
||||||
<view class="content-body">
|
<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" />
|
<rich-text v-if="canRenderRichText" :nodes="content" />
|
||||||
<text v-else>{{ contentText }}</text>
|
<text v-else selectable class="whitespace-pre-wrap">{{ 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>
|
</view>
|
||||||
|
<wd-cell-group v-if="relatedCard" border>
|
||||||
<view class="grid grid-cols-2 gap-14rpx">
|
<wd-cell title="关联卡号" label="点击复制卡号,方便查询或联系服务人员。" is-link @click="copyCard">
|
||||||
<wd-button type="primary" block :loading="marking" @click="handleMarkRead">
|
<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 }}
|
{{ readButtonText }}
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button type="primary" plain block @click="handleBack">
|
<wd-button type="primary" plain block @click="handleBack">
|
||||||
返回列表
|
返回消息列表
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import type { AppMessage } from '@/api/message'
|
import type { AppMessage } from '@/api/message'
|
||||||
import { getMessageDetail, readMessages } from '@/api/message'
|
import { getMessageDetail, readMessages } from '@/api/message'
|
||||||
import { stripRichText } from '../../data'
|
import { stripRichText } from '../../data'
|
||||||
|
import { useMessageStore } from '@/store/message'
|
||||||
|
import { useTokenStore } from '@/store/token'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
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 loading = ref(false)
|
||||||
const marking = ref(false)
|
const marking = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const detail = ref<AppMessage | null>(null)
|
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 content = computed(() => detail.value?.content || '')
|
||||||
const contentText = computed(() => stripRichText(content.value) || '暂无消息详细内容')
|
const contentText = computed(() => stripRichText(content.value) || '暂无消息详细内容')
|
||||||
const canRenderRichText = computed(() => /<[^>]+>/.test(content.value))
|
const canRenderRichText = computed(() => /<[^>]+>/.test(content.value))
|
||||||
@@ -29,43 +51,74 @@ async function loadDetail(id: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seq = ++detailRequestSeq
|
||||||
|
const token = tokenStore.validToken
|
||||||
|
if (!token)
|
||||||
|
return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await getMessageDetail({ messageId: id })
|
const res = await getMessageDetail({ messageId: id })
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
|
if (!res.message)
|
||||||
|
throw new Error('消息不存在或已撤回')
|
||||||
detail.value = res.message
|
detail.value = res.message
|
||||||
if (Number(res.message?.isRead) === 0) {
|
if (Number(res.message.isRead) === 0) {
|
||||||
|
try {
|
||||||
|
await readMessages({ ids: [id] })
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
detail.value = { ...res.message, isRead: 1 }
|
detail.value = { ...res.message, isRead: 1 }
|
||||||
readMessages({ ids: [id] }).catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
|
catch {
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
|
uni.showToast({ title: '自动标记失败,可手动标记已读', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void useMessageStore().refreshUnreadCount()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
errorMessage.value = getErrorMessage(error, '消息详情加载失败,请稍后重试')
|
errorMessage.value = getErrorMessage(error, '消息详情加载失败,请稍后重试')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (isCurrentRequest(seq, token))
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMarkRead() {
|
async function handleMarkRead() {
|
||||||
const id = Number(detail.value?.messageId || detail.value?.id || 0)
|
const id = Number(detail.value?.messageId || detail.value?.id || 0)
|
||||||
if (!id)
|
if (!id || marking.value)
|
||||||
return
|
return
|
||||||
if (Number(detail.value?.isRead) === 1) {
|
if (Number(detail.value?.isRead) === 1) {
|
||||||
uni.showToast({ title: '当前消息已读', icon: 'none' })
|
uni.showToast({ title: '当前消息已读', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seq = detailRequestSeq
|
||||||
|
const token = tokenStore.validToken
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
marking.value = true
|
marking.value = true
|
||||||
try {
|
try {
|
||||||
await readMessages({ ids: [id] })
|
await readMessages({ ids: [id] })
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
detail.value = { ...detail.value!, isRead: 1 }
|
detail.value = { ...detail.value!, isRead: 1 }
|
||||||
|
void useMessageStore().refreshUnreadCount()
|
||||||
uni.showToast({ title: '已标记已读', icon: 'success' })
|
uni.showToast({ title: '已标记已读', icon: 'success' })
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (!isCurrentRequest(seq, token))
|
||||||
|
return
|
||||||
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
|
uni.showToast({ title: getErrorMessage(error, '标记已读失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (isCurrentRequest(seq, token))
|
||||||
marking.value = false
|
marking.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +159,7 @@ function formatTime(value: string) {
|
|||||||
|
|
||||||
return String(value)
|
return String(value)
|
||||||
.replace('T', ' ')
|
.replace('T', ' ')
|
||||||
.replace(/\.\d+.*$/, '')
|
.replace(/\.\d.*$/, '')
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,128 +180,71 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLoad((options: Record<string, string | undefined>) => {
|
onLoad((options: Record<string, string | undefined>) => {
|
||||||
void loadDetail(Number(options?.messageId || 0))
|
messageId.value = Number(options?.messageId || 0)
|
||||||
|
void loadDetail(messageId.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
|
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
|
||||||
<view class="mx-auto max-w-920rpx flex flex-col gap-16rpx">
|
<view class="mx-auto max-w-920rpx flex flex-col gap-20rpx">
|
||||||
<view v-if="loading" class="card-base flex items-center justify-center py-72rpx">
|
<view v-if="loading" class="card-base flex flex-col items-center gap-20rpx py-72rpx">
|
||||||
<wd-loading />
|
<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>
|
||||||
|
|
||||||
<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>
|
</view>
|
||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<view class="card-base overflow-hidden">
|
<view class="card-base flex flex-col gap-20rpx p-28rpx">
|
||||||
<view class="detail-row">
|
<view class="flex flex-wrap items-center gap-12rpx">
|
||||||
<text class="detail-label">标题</text>
|
<wd-tag type="primary" plain>
|
||||||
<text class="detail-value detail-title">{{ detail.title || '未命名消息' }}</text>
|
{{ sourceText }}
|
||||||
</view>
|
</wd-tag>
|
||||||
<view class="detail-row">
|
<wd-tag :type="readText === '已读' ? 'success' : 'warning'" round>
|
||||||
<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 }}
|
{{ readText }}
|
||||||
</wd-tag>
|
</wd-tag>
|
||||||
</view>
|
</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-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>
|
||||||
<view class="content-body">
|
<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" />
|
<rich-text v-if="canRenderRichText" :nodes="content" />
|
||||||
<text v-else>{{ contentText }}</text>
|
<text v-else selectable class="whitespace-pre-wrap">{{ 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>
|
</view>
|
||||||
|
<wd-cell-group v-if="relatedCard" border>
|
||||||
<view class="grid grid-cols-2 gap-14rpx">
|
<wd-cell title="关联卡号" label="点击复制卡号,方便查询或联系服务人员。" is-link @click="copyCard">
|
||||||
<wd-button type="primary" block :loading="marking" @click="handleMarkRead">
|
<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 }}
|
{{ readButtonText }}
|
||||||
</wd-button>
|
</wd-button>
|
||||||
<wd-button type="primary" plain block @click="handleBack">
|
<wd-button type="primary" plain block @click="handleBack">
|
||||||
返回列表
|
返回消息列表
|
||||||
</wd-button>
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
|
||||||
import { useMessageStore } from '@/store/message'
|
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import {
|
import {
|
||||||
getAccountRoleLabel,
|
getAccountRoleLabel,
|
||||||
@@ -10,7 +8,6 @@ import {
|
|||||||
} from '@/utils/userIdentity'
|
} from '@/utils/userIdentity'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const messageStore = useMessageStore()
|
|
||||||
|
|
||||||
const userInfo = computed(() => userStore.userInfo)
|
const userInfo = computed(() => userStore.userInfo)
|
||||||
const workspace = computed(() => getAccountWorkspace(userInfo.value))
|
const workspace = computed(() => getAccountWorkspace(userInfo.value))
|
||||||
@@ -59,33 +56,22 @@ const avatarUrl = computed(() => {
|
|||||||
const avatar = String(userInfo.value.avatar || '').trim()
|
const avatar = String(userInfo.value.avatar || '').trim()
|
||||||
return avatar && !avatar.includes('default-avatar') ? avatar : ''
|
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(() => {
|
const displayName = computed(() => {
|
||||||
if (workspace.value === 'terminal') {
|
if (workspace.value === 'terminal') {
|
||||||
return (
|
return (
|
||||||
userInfo.value.terminalProfile?.terminalName
|
userInfo.value.nickName
|
||||||
|| userInfo.value.nickName
|
|
||||||
|| userInfo.value.nickname
|
|| userInfo.value.nickname
|
||||||
|| userInfo.value.username
|
|| userInfo.value.username
|
||||||
|| '未登录用户'
|
|| '未登录用户'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
userInfo.value.enterpriseProfile?.deptName
|
userInfo.value.nickName
|
||||||
|| userInfo.value.enterpriseProfile?.fullName
|
|
||||||
|| userInfo.value.nickName
|
|
||||||
|| userInfo.value.nickname
|
|| userInfo.value.nickname
|
||||||
|| userInfo.value.username
|
|| userInfo.value.username
|
||||||
|| '未登录用户'
|
|| '未登录用户'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function goMessage() {
|
|
||||||
uni.navigateTo({ url: RouterPath.Message })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -110,13 +96,6 @@ function goMessage() {
|
|||||||
</view>
|
</view>
|
||||||
<text class="account-sub">{{ workspaceMeta.sub }}</text>
|
<text class="account-sub">{{ workspaceMeta.sub }}</text>
|
||||||
</view>
|
</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>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -222,39 +201,4 @@ function goMessage() {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import UserEntryGroup from './UserEntryGroup.vue'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
|
import { isV101HiddenRoute, V101_RELEASE } from '@/constant/release'
|
||||||
import { useMessageStore } from '@/store/message'
|
import { useMessageStore } from '@/store/message'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { getAccountWorkspace } from '@/utils/userIdentity'
|
import { getAccountWorkspace } from '@/utils/userIdentity'
|
||||||
@@ -36,7 +38,14 @@ const commonGroups = computed<ServiceGroup[]>(() => [
|
|||||||
layout: 'list',
|
layout: 'list',
|
||||||
cells: [
|
cells: [
|
||||||
{
|
{
|
||||||
title: '账号资料',
|
title: '消息中心',
|
||||||
|
desc: unreadCount.value > 0 ? `${String(unreadCount.value)} 条未读消息` : '查看系统通知与预警消息',
|
||||||
|
icon: 'i-carbon-notification',
|
||||||
|
tone: 'blue',
|
||||||
|
path: RouterPath.Message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '个人资料',
|
||||||
desc: '完善个人与企业信息',
|
desc: '完善个人与企业信息',
|
||||||
icon: 'i-carbon-user-profile',
|
icon: 'i-carbon-user-profile',
|
||||||
tone: 'blue',
|
tone: 'blue',
|
||||||
@@ -49,13 +58,6 @@ const commonGroups = computed<ServiceGroup[]>(() => [
|
|||||||
tone: 'green',
|
tone: 'green',
|
||||||
path: RouterPath.UserViewsRealName,
|
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[]>(() =>
|
const visibleGroups = computed<ServiceGroup[]>(() =>
|
||||||
groups.value
|
groups.value
|
||||||
.map(group => ({
|
.map(group => ({
|
||||||
...group,
|
...group,
|
||||||
cells: group.cells.filter(cell =>
|
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),
|
.filter(group => group.cells.length > 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleCellClick(item: ServiceCell) {
|
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) {
|
if (item.path) {
|
||||||
uni.navigateTo({ url: item.path })
|
uni.navigateTo({ url: item.path })
|
||||||
return
|
return
|
||||||
@@ -186,236 +194,13 @@ function handleCellClick(item: ServiceCell) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="service-stack">
|
<view class="flex flex-col gap-12rpx">
|
||||||
<view v-for="group in visibleGroups" :key="group.title" class="service-section">
|
<UserEntryGroup
|
||||||
<view class="section-head">
|
v-for="group in visibleGroups"
|
||||||
<view class="title-bar" />
|
:key="group.title"
|
||||||
<view class="min-w-0 flex-1">
|
:title="group.title"
|
||||||
<text class="section-title">{{ group.title }}</text>
|
:cells="group.cells"
|
||||||
<text v-if="group.desc" class="section-desc">{{ group.desc }}</text>
|
@action="handleCellClick(group.cells[$event])"
|
||||||
</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>
|
</view>
|
||||||
</template>
|
</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 {
|
.stats-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
padding: 28rpx;
|
padding: 28rpx;
|
||||||
border: 1px solid var(--app-line);
|
border: 1px solid var(--app-line);
|
||||||
@@ -121,6 +121,7 @@ function toneClass(tone: StatItem['tone']) {
|
|||||||
|
|
||||||
.stats-main {
|
.stats-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6rpx;
|
gap: 6rpx;
|
||||||
@@ -141,15 +142,16 @@ function toneClass(tone: StatItem['tone']) {
|
|||||||
|
|
||||||
.stats-items {
|
.stats-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
max-width: 62%;
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-pill {
|
.stat-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6rpx;
|
gap: 6rpx;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -158,10 +160,13 @@ function toneClass(tone: StatItem['tone']) {
|
|||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
line-height: 30rpx;
|
line-height: 30rpx;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-pill__value {
|
.stat-pill__value {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
min-width: 30rpx;
|
min-width: 30rpx;
|
||||||
height: 30rpx;
|
height: 30rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -173,6 +178,8 @@ function toneClass(tone: StatItem['tone']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-pill__label {
|
.stat-pill__label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
|
import UserEntryGroup from './UserEntryGroup.vue'
|
||||||
|
|
||||||
interface CellGroup {
|
interface CellGroup {
|
||||||
title: string
|
title: string
|
||||||
@@ -20,7 +21,8 @@ const cellGroups: CellGroup[] = [
|
|||||||
{ title: '帮助与反馈', desc: '问题建议与使用反馈', icon: 'i-carbon-chat', path: RouterPath.UserViewsFeedback },
|
{ title: '帮助与反馈', desc: '问题建议与使用反馈', icon: 'i-carbon-chat', path: RouterPath.UserViewsFeedback },
|
||||||
{ title: '联系客服', desc: '在线客服', icon: 'i-carbon-customer-service', path: RouterPath.UserViewsContact },
|
{ title: '联系客服', desc: '在线客服', icon: 'i-carbon-customer-service', path: RouterPath.UserViewsContact },
|
||||||
{ title: '协议与隐私', desc: '服务协议与隐私政策', icon: 'i-carbon-document', path: RouterPath.UserViewsAgreement },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="support-stack">
|
<view class="flex flex-col gap-12rpx">
|
||||||
<view v-for="group in cellGroups" :key="group.title" class="support-section">
|
<UserEntryGroup
|
||||||
<view class="section-head">
|
v-for="group in cellGroups"
|
||||||
<view class="title-bar" />
|
:key="group.title"
|
||||||
<text class="section-title">{{ group.title }}</text>
|
:title="group.title"
|
||||||
</view>
|
:cells="group.cells"
|
||||||
|
@action="handleCellClick(group.cells[$event])"
|
||||||
<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>
|
</view>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,9 +4,11 @@ import UserServiceCells from './components/UserServiceCells.vue'
|
|||||||
import UserStatsCard from './components/UserStatsCard.vue'
|
import UserStatsCard from './components/UserStatsCard.vue'
|
||||||
import UserSupportCells from './components/UserSupportCells.vue'
|
import UserSupportCells from './components/UserSupportCells.vue'
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
import { useMessageStore } from '@/store/message'
|
|
||||||
import { useTokenStore } from '@/store/token'
|
import { useTokenStore } from '@/store/token'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { useMessageStore } from '@/store/message'
|
||||||
|
import { hasAnyFeature, hasBatchExecutionLogPermission } from '@/utils/permission'
|
||||||
|
import { getAccountWorkspace } from '@/utils/userIdentity'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
excludeLoginPath: true,
|
excludeLoginPath: true,
|
||||||
@@ -20,11 +22,31 @@ definePage({
|
|||||||
|
|
||||||
const tokenStore = useTokenStore()
|
const tokenStore = useTokenStore()
|
||||||
const userStore = useUserStore()
|
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
|
let loginRedirecting = false
|
||||||
|
|
||||||
function goLogin() {
|
function goLogin() {
|
||||||
if (loginRedirecting) return
|
if (loginRedirecting)
|
||||||
|
return
|
||||||
loginRedirecting = true
|
loginRedirecting = true
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `${RouterPath.Login}?redirect=${encodeURIComponent(RouterPath.User)}`,
|
url: `${RouterPath.Login}?redirect=${encodeURIComponent(RouterPath.User)}`,
|
||||||
@@ -42,7 +64,7 @@ onShow(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
void userStore.fetchUserInfo().catch(() => undefined)
|
void userStore.fetchUserInfo().catch(() => undefined)
|
||||||
void messageStore.refreshUnreadCount().catch(() => undefined)
|
void useMessageStore().refreshUnreadCount()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
@@ -57,6 +79,67 @@ async function handleLogout() {
|
|||||||
<UserProfileCard />
|
<UserProfileCard />
|
||||||
<view class="user-content">
|
<view class="user-content">
|
||||||
<UserStatsCard />
|
<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 />
|
<UserServiceCells />
|
||||||
<UserSupportCells />
|
<UserSupportCells />
|
||||||
<view class="logout-wrap">
|
<view class="logout-wrap">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ definePage({
|
|||||||
<wd-card custom-class="!m-0">
|
<wd-card custom-class="!m-0">
|
||||||
<wd-cell-group :border="false">
|
<wd-cell-group :border="false">
|
||||||
<wd-cell title="应用名称" value="IOTLCM" />
|
<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 title="服务定位" label="物联卡用户服务小程序" />
|
||||||
</wd-cell-group>
|
</wd-cell-group>
|
||||||
</wd-card>
|
</wd-card>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const privacySections: TextSection[] = [
|
|||||||
title: '我们如何收集和使用信息',
|
title: '我们如何收集和使用信息',
|
||||||
content: [
|
content: [
|
||||||
'为向你提供物联网卡查询、拍照识别卡号、绑定、套餐办理、预警提醒、卡片排查、工单服务等功能,我们会在必要范围内收集并使用你主动填写、主动上传或业务系统返回的信息。',
|
'为向你提供物联网卡查询、拍照识别卡号、绑定、套餐办理、预警提醒、卡片排查、工单服务等功能,我们会在必要范围内收集并使用你主动填写、主动上传或业务系统返回的信息。',
|
||||||
'未登录查卡、注册登录、绑定微信、订阅提醒、查看实名资料、提交业务办理或工单时,会分别使用对应场景所需的信息。',
|
'未登录查卡、注册登录、绑定微信、查看实名资料、提交业务办理或工单时,会分别使用对应场景所需的信息。',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,7 +62,7 @@ const privacySections: TextSection[] = [
|
|||||||
{
|
{
|
||||||
title: '第三方和系统能力',
|
title: '第三方和系统能力',
|
||||||
content: [
|
content: [
|
||||||
'当你主动绑定微信或申请订阅提醒时,小程序会调用微信提供的登录凭证和订阅消息能力,用于完成微信身份绑定和提醒授权。',
|
'当你主动绑定微信时,小程序会调用微信提供的登录凭证,用于完成微信身份绑定。',
|
||||||
'当你主动上传头像、凭证或反馈材料时,小程序会使用相册或拍摄能力读取你选择的图片或文件。',
|
'当你主动上传头像、凭证或反馈材料时,小程序会使用相册或拍摄能力读取你选择的图片或文件。',
|
||||||
'当你使用拍照识别卡号时,小程序会将你主动选择的物联网卡图片提交到本系统服务端临时识别 ICCID;图片仅用于本次识别和查卡,本系统不保存原图。',
|
'当你使用拍照识别卡号时,小程序会将你主动选择的物联网卡图片提交到本系统服务端临时识别 ICCID;图片仅用于本次识别和查卡,本系统不保存原图。',
|
||||||
],
|
],
|
||||||
@@ -70,8 +70,8 @@ const privacySections: TextSection[] = [
|
|||||||
{
|
{
|
||||||
title: '你的权利',
|
title: '你的权利',
|
||||||
content: [
|
content: [
|
||||||
'你可以在个人中心查看账号资料、实名状态、微信提醒绑定状态和消息提醒状态。',
|
'你可以在个人中心查看账号资料、实名状态和微信绑定状态,在消息中心查看站内提醒。',
|
||||||
'你可以通过关闭微信订阅权限、取消相关业务授权、退出登录等方式撤回相应授权;撤回后可能无法继续使用对应功能,但不影响撤回前基于授权进行处理的合法性。',
|
'你可以通过解除微信绑定、取消相关业务授权、退出登录等方式撤回相应授权;撤回后可能无法继续使用对应功能,但不影响撤回前基于授权进行处理的合法性。',
|
||||||
'如需更正资料、删除信息、注销账号或咨询信息保护问题,可通过“我的-联系客服”与服务人员联系处理。',
|
'如需更正资料、删除信息、注销账号或咨询信息保护问题,可通过“我的-联系客服”与服务人员联系处理。',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -86,7 +86,7 @@ const privacySections: TextSection[] = [
|
|||||||
{
|
{
|
||||||
title: '第三方服务与未成年人',
|
title: '第三方服务与未成年人',
|
||||||
content: [
|
content: [
|
||||||
'微信登录、微信订阅消息、微信支付、相册和拍摄能力由微信相关服务提供;使用这些功能时,相关信息还需遵守微信平台规则和隐私政策。',
|
'微信登录、微信支付、相册和拍摄能力由微信相关服务提供;使用这些功能时,相关信息还需遵守微信平台规则和隐私政策。',
|
||||||
'图片识别请求可能由本系统委托的模型服务商进行临时处理,仅用于识别卡号;实际委托服务商名称、处理范围和联系方式以微信后台隐私保护指引及本页面最新公示为准。',
|
'图片识别请求可能由本系统委托的模型服务商进行临时处理,仅用于识别卡号;实际委托服务商名称、处理范围和联系方式以微信后台隐私保护指引及本页面最新公示为准。',
|
||||||
'未满十四周岁的未成年人应在监护人同意和指导下使用本小程序;监护人如发现未成年人未经同意使用并提交信息,请联系客服处理。',
|
'未满十四周岁的未成年人应在监护人同意和指导下使用本小程序;监护人如发现未成年人未经同意使用并提交信息,请联系客服处理。',
|
||||||
],
|
],
|
||||||
@@ -104,9 +104,9 @@ const collectionItems: CollectionItem[] = [
|
|||||||
{
|
{
|
||||||
title: '微信用户标识',
|
title: '微信用户标识',
|
||||||
icon: 'i-carbon-link',
|
icon: 'i-carbon-link',
|
||||||
fields: '微信登录临时凭证、微信绑定状态、订阅授权结果',
|
fields: '微信登录临时凭证、微信绑定状态',
|
||||||
purpose: '用于绑定当前微信,并在你授权后接收流量预警、订单状态、工单回复等提醒。',
|
purpose: '用于绑定当前微信,以便通过微信登录。',
|
||||||
scene: '绑定当前微信、申请订阅授权、微信提醒设置。',
|
scene: '绑定当前微信、通过微信登录。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '物联网卡与设备信息',
|
title: '物联网卡与设备信息',
|
||||||
@@ -321,7 +321,7 @@ function openWechatPrivacyContract() {
|
|||||||
<view class="flex items-start gap-14rpx text-24rpx text-muted leading-36rpx">
|
<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" />
|
<view class="i-carbon-information mt-4rpx shrink-0 text-28rpx text-#2563eb" />
|
||||||
<text class="min-w-0 flex-1">
|
<text class="min-w-0 flex-1">
|
||||||
若你不同意相关协议或隐私政策,请停止使用需要登录、绑定、订阅或业务办理的功能。
|
若你不同意相关协议或隐私政策,请停止使用需要登录、绑定或业务办理的功能。
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
</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>
|
||||||
@@ -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)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,403 +1,17 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { isMpWeixin } from '@uni-helper/uni-env'
|
|
||||||
import { bindWechat, getWxCode } from '@/api/login'
|
|
||||||
import RouterPath from '@/constant/RouterPath'
|
import RouterPath from '@/constant/RouterPath'
|
||||||
import { useUserStore } from '@/store/user'
|
|
||||||
import { getWarningConfigMeta } from '../api/warning'
|
|
||||||
import { guideWechatSubscribeSettings, requestWechatSubscribeMessage, shouldGuideWechatSubscribeSettings, wechatSubscribeSaveMessage } from '@/utils'
|
|
||||||
|
|
||||||
definePage({
|
definePage({ style: { navigationBarTitleText: '消息提醒' } })
|
||||||
style: {
|
|
||||||
navigationBarTitleText: '微信提醒',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
interface NotifyRow {
|
function goMessageCenter() {
|
||||||
title: string
|
uni.navigateTo({ url: RouterPath.Message })
|
||||||
desc: string
|
|
||||||
enabled: boolean
|
|
||||||
status: string
|
|
||||||
editable: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen bg-canvas p-20rpx pb-56rpx">
|
<view class="min-h-screen bg-canvas p-24rpx">
|
||||||
<view class="mx-auto max-w-920rpx flex flex-col gap-16rpx">
|
<view class="overflow-hidden rounded-16rpx bg-white">
|
||||||
<view v-if="loading" class="card-base flex items-center justify-center py-72rpx">
|
<wd-cell title="站内消息提醒" label="流量预警等通知将在消息中心展示" value="查看消息" is-link @click="goMessageCenter" />
|
||||||
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { updateInfo } from '@/api/login'
|
import { isMpWeixin } from '@uni-helper/uni-env'
|
||||||
|
import { bindWechat, getWxCode, unbindWechat, updateInfo } from '@/api/login'
|
||||||
import { useTokenStore } from '@/store/token'
|
import { useTokenStore } from '@/store/token'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import {
|
import {
|
||||||
getAccountRoleLabel,
|
getAccountRoleLabel,
|
||||||
getAccountWorkspaceLabel,
|
|
||||||
getAccountWorkspace,
|
getAccountWorkspace,
|
||||||
|
getAccountWorkspaceLabel,
|
||||||
} from '@/utils/userIdentity'
|
} from '@/utils/userIdentity'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
excludeLoginPath: true,
|
excludeLoginPath: true,
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '账号资料',
|
navigationBarTitleText: '个人资料',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -27,34 +28,66 @@ interface InfoRow {
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const tokenStore = useTokenStore()
|
const tokenStore = useTokenStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const binding = ref(false)
|
||||||
const pageMode = ref<PageMode>('view')
|
const pageMode = ref<PageMode>('view')
|
||||||
|
|
||||||
const isTerminal = computed(() => Boolean(userStore.userInfo.isTerminal))
|
const isTerminal = computed(() => Boolean(userStore.userInfo.isTerminal))
|
||||||
const canEditProfile = computed(() => isTerminal.value && pageMode.value === 'edit')
|
const canEditProfile = computed(() => isTerminal.value && pageMode.value === 'edit')
|
||||||
const isViewMode = computed(() => !canEditProfile.value)
|
const isViewMode = computed(() => !canEditProfile.value)
|
||||||
const modeLabel = computed(() => (canEditProfile.value ? '编辑' : '仅查看'))
|
const showDetails = ref(false)
|
||||||
|
const loggingOut = ref(false)
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
if (isTerminal.value) {
|
if (isTerminal.value) {
|
||||||
return (
|
return (
|
||||||
userStore.userInfo.terminalProfile?.terminalName ||
|
userStore.userInfo.terminalProfile?.terminalName
|
||||||
userStore.userInfo.nickName ||
|
|| userStore.userInfo.nickName
|
||||||
userStore.userInfo.nickname ||
|
|| userStore.userInfo.nickname
|
||||||
userStore.userInfo.username ||
|
|| userStore.userInfo.username
|
||||||
'未登录用户'
|
|| '未登录用户'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
userStore.userInfo.enterpriseProfile?.deptName ||
|
userStore.userInfo.enterpriseProfile?.deptName
|
||||||
userStore.userInfo.enterpriseProfile?.fullName ||
|
|| userStore.userInfo.enterpriseProfile?.fullName
|
||||||
userStore.userInfo.nickName ||
|
|| userStore.userInfo.nickName
|
||||||
userStore.userInfo.nickname ||
|
|| userStore.userInfo.nickname
|
||||||
userStore.userInfo.username ||
|
|| userStore.userInfo.username
|
||||||
getAccountWorkspaceLabel(getAccountWorkspace(userStore.userInfo))
|
|| getAccountWorkspaceLabel(getAccountWorkspace(userStore.userInfo))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const avatarText = computed(() => displayName.value.slice(0, 1))
|
const avatarText = computed(() => displayName.value.slice(0, 1))
|
||||||
const roleText = computed(() => getAccountRoleLabel(userStore.userInfo))
|
const roleText = computed(() => getAccountRoleLabel(userStore.userInfo))
|
||||||
const balanceText = computed(() => `¥${Number(userStore.userInfo.balance || 0).toFixed(2)}`)
|
const accountText = computed(() => maskPhone(userStore.userInfo.username || '未设置'))
|
||||||
|
const emailText = computed(() => userStore.userInfo.terminalProfile?.email || userStore.userInfo.enterpriseProfile?.email || '未填写')
|
||||||
|
const organizationText = computed(() => {
|
||||||
|
const profile = isTerminal.value ? userStore.userInfo.terminalProfile : userStore.userInfo.enterpriseProfile
|
||||||
|
return profile?.fullName || (isTerminal.value ? userStore.userInfo.terminalProfile?.terminalName : userStore.userInfo.enterpriseProfile?.deptName) || '未填写'
|
||||||
|
})
|
||||||
|
const businessRoleText = computed(() => userStore.userInfo.roleName || roleText.value)
|
||||||
|
|
||||||
|
function maskPhone(value: string) {
|
||||||
|
return /^1\d{10}$/.test(value) ? value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2') : value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
if (loggingOut.value || binding.value || loading.value)
|
||||||
|
return
|
||||||
|
const result = await uni.showModal({ title: '退出登录', content: '确定退出当前账号吗?' })
|
||||||
|
if (!result.confirm)
|
||||||
|
return
|
||||||
|
loggingOut.value = true
|
||||||
|
try {
|
||||||
|
await tokenStore.logout()
|
||||||
|
uni.reLaunch({ url: '/pages/login/index' })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loggingOut.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPassword() {
|
||||||
|
uni.navigateTo({ url: '/pages/login/forgot-password/index' })
|
||||||
|
}
|
||||||
const phoneText = computed(() => userStore.userInfo.phone || userStore.userInfo.username || '-')
|
const phoneText = computed(() => userStore.userInfo.phone || userStore.userInfo.username || '-')
|
||||||
const identityTitle = computed(
|
const identityTitle = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -66,7 +99,8 @@ const identityTitle = computed(
|
|||||||
})[getAccountWorkspace(userStore.userInfo)],
|
})[getAccountWorkspace(userStore.userInfo)],
|
||||||
)
|
)
|
||||||
const realNameText = computed(() => {
|
const realNameText = computed(() => {
|
||||||
if (isTerminal.value) return userStore.userInfo.terminalProfile?.realNameStatus || '资料待完善'
|
if (isTerminal.value)
|
||||||
|
return userStore.userInfo.terminalProfile?.realNameStatus || '资料待完善'
|
||||||
return userStore.userInfo.enterpriseProfile?.realNameStatus || '资料待完善'
|
return userStore.userInfo.enterpriseProfile?.realNameStatus || '资料待完善'
|
||||||
})
|
})
|
||||||
const wechatText = computed(
|
const wechatText = computed(
|
||||||
@@ -149,7 +183,8 @@ const identityRows = computed<InfoRow[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function displayValue(value?: string | number) {
|
function displayValue(value?: string | number) {
|
||||||
if (value === undefined || value === null || String(value).trim() === '') return '-'
|
if (value === undefined || value === null || String(value).trim() === '')
|
||||||
|
return '-'
|
||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +210,48 @@ function syncForm() {
|
|||||||
formData.bankAccount = profile?.bankAccount || ''
|
formData.bankAccount = profile?.bankAccount || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function manageWechat() {
|
||||||
|
if (binding.value || loading.value)
|
||||||
|
return
|
||||||
|
const bound = Boolean(userStore.userInfo.wechatBound)
|
||||||
|
let action = 'bind'
|
||||||
|
if (bound) {
|
||||||
|
const result = await uni.showActionSheet({ itemList: ['更换微信绑定', '解除微信绑定'] }).catch(() => null)
|
||||||
|
if (!result)
|
||||||
|
return
|
||||||
|
action = result.tapIndex === 1 ? 'unbind' : 'bind'
|
||||||
|
}
|
||||||
|
if (action === 'bind' && !isMpWeixin) {
|
||||||
|
uni.showToast({ title: '请在微信小程序中绑定', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.value = true
|
||||||
|
try {
|
||||||
|
const confirmed = await uni.showModal({
|
||||||
|
title: action === 'unbind' ? '解除微信绑定?' : bound ? '更换微信绑定?' : '绑定当前微信?',
|
||||||
|
content: action === 'unbind'
|
||||||
|
? '解绑后将不能通过原绑定微信登录,站内消息不受影响。请确认记得账号密码。'
|
||||||
|
: '将当前微信绑定到账号,用于后续通过微信登录。',
|
||||||
|
confirmText: action === 'unbind' ? '确认解绑' : '确认绑定',
|
||||||
|
})
|
||||||
|
if (!confirmed.confirm)
|
||||||
|
return
|
||||||
|
const res = action === 'unbind'
|
||||||
|
? await unbindWechat()
|
||||||
|
: await bindWechat({ wxCode: (await getWxCode()).code })
|
||||||
|
if (res.userInfo)
|
||||||
|
userStore.setUserInfo(res.userInfo)
|
||||||
|
uni.showToast({ title: action === 'unbind' ? '已解除绑定' : '微信绑定成功', icon: 'success' })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const detail = error as { msg?: string, message?: string }
|
||||||
|
uni.showToast({ title: detail?.msg || detail?.message || '微信绑定操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
binding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canEditProfile.value) {
|
if (!canEditProfile.value) {
|
||||||
uni.showToast({ title: '当前页面仅支持查看资料', icon: 'none' })
|
uni.showToast({ title: '当前页面仅支持查看资料', icon: 'none' })
|
||||||
@@ -217,7 +294,8 @@ async function submit() {
|
|||||||
userStore.setUserInfo(res)
|
userStore.setUserInfo(res)
|
||||||
pageMode.value = 'view'
|
pageMode.value = 'view'
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,60 +314,44 @@ onShow(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="min-h-screen bg-#f5f7fb p-24rpx pb-48rpx">
|
<view class="min-h-screen bg-#f5f7fb p-24rpx pb-48rpx">
|
||||||
<view class="profile-hero">
|
<view class="flex items-center gap-24rpx rounded-24rpx bg-white p-28rpx">
|
||||||
<view class="flex items-center gap-20rpx">
|
<view class="h-96rpx w-92rpx flex shrink-0 items-center justify-center overflow-hidden rounded-24rpx bg-blue-50 text-36rpx text-blue-600 font-semibold">
|
||||||
<view class="avatar">
|
<image v-if="userStore.userInfo.avatar" class="h-full w-full" :src="userStore.userInfo.avatar" mode="aspectFill" />
|
||||||
<image
|
|
||||||
v-if="userStore.userInfo.avatar"
|
|
||||||
class="h-full w-full"
|
|
||||||
:src="userStore.userInfo.avatar"
|
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<text v-else>{{ avatarText }}</text>
|
<text v-else>{{ avatarText }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="min-w-0 flex-1">
|
<view class="min-w-0 flex-1">
|
||||||
<view class="flex items-center gap-12rpx">
|
<view class="truncate text-30rpx text-gray-900 font-semibold">
|
||||||
<text class="truncate text-34rpx text-#111827 font-800">{{ displayName }}</text>
|
{{ displayName }}
|
||||||
<view class="role-pill">
|
</view>
|
||||||
{{ roleText }}
|
<view class="mt-10rpx text-22rpx text-gray-500">
|
||||||
|
账号 {{ accountText }} · {{ roleText }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="mt-8rpx block truncate text-24rpx text-#64748b">{{ phoneText }}</text>
|
<wd-button v-if="isTerminal" type="primary" plain size="small" :disabled="loading" @click="pageMode = canEditProfile ? 'view' : 'edit'">
|
||||||
</view>
|
{{ canEditProfile ? '取消' : '编辑' }}
|
||||||
<wd-tag :type="canEditProfile ? 'primary' : 'warning'" plain size="small">
|
</wd-button>
|
||||||
{{ modeLabel }}
|
|
||||||
</wd-tag>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="summary-grid">
|
<view v-if="isViewMode" class="mt-24rpx overflow-hidden rounded-24rpx bg-white">
|
||||||
<view class="summary-item">
|
<wd-cell-group border>
|
||||||
<text class="summary-label">登录账号</text>
|
<wd-cell title="实名认证" :value="realNameText" center />
|
||||||
<text class="summary-value">{{ userStore.userInfo.username || '-' }}</text>
|
<wd-cell :title="isTerminal ? '客户名称' : '企业名称'" :value="organizationText" center />
|
||||||
</view>
|
<wd-cell title="角色" :value="businessRoleText" center />
|
||||||
<view class="summary-item">
|
<wd-cell title="详细资料" :value="showDetails ? '收起' : '查看'" is-link center @click="showDetails = !showDetails" />
|
||||||
<text class="summary-label">账户余额</text>
|
</wd-cell-group>
|
||||||
<text class="summary-value">{{ balanceText }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="summary-item">
|
|
||||||
<text class="summary-label">资料状态</text>
|
|
||||||
<text class="summary-value">{{ realNameText }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="summary-item">
|
|
||||||
<text class="summary-label">微信提醒</text>
|
|
||||||
<text class="summary-value">{{ wechatText }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<wd-card
|
<wd-card
|
||||||
v-if="isViewMode"
|
v-if="isViewMode && showDetails"
|
||||||
custom-class="!m-0 !mt-24rpx profile-card"
|
custom-class="!m-0 !mt-24rpx !rounded-24rpx !overflow-hidden"
|
||||||
custom-content-class="!p-0"
|
custom-content-class="!p-0"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="flex items-center justify-between gap-16rpx">
|
<view class="flex items-center justify-between gap-16rpx">
|
||||||
<text class="text-30rpx text-#111827 font-900">{{ identityTitle }}</text>
|
<text class="text-30rpx text-#111827 font-900">{{ identityTitle }}</text>
|
||||||
<wd-tag v-if="isViewMode" type="primary" plain> 资料查看 </wd-tag>
|
<wd-tag v-if="isViewMode" type="primary" plain>
|
||||||
|
资料查看
|
||||||
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<wd-cell-group :border="false">
|
<wd-cell-group :border="false">
|
||||||
@@ -308,11 +370,13 @@ onShow(async () => {
|
|||||||
|
|
||||||
<template v-if="canEditProfile">
|
<template v-if="canEditProfile">
|
||||||
<wd-form :model="formData" error-type="toast" class="mt-24rpx flex flex-col gap-16rpx">
|
<wd-form :model="formData" error-type="toast" class="mt-24rpx flex flex-col gap-16rpx">
|
||||||
<wd-card custom-class="!m-0 profile-card" custom-content-class="!p-0">
|
<wd-card custom-class="!m-0 !rounded-24rpx !overflow-hidden" custom-content-class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<view class="flex items-center justify-between gap-16rpx">
|
<view class="flex items-center justify-between gap-16rpx">
|
||||||
<text class="text-30rpx text-#111827 font-900">基础资料</text>
|
<text class="text-30rpx text-#111827 font-900">基础资料</text>
|
||||||
<wd-tag type="primary" plain> 终端用户 </wd-tag>
|
<wd-tag type="primary" plain>
|
||||||
|
终端用户
|
||||||
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<wd-cell-group :border="false">
|
<wd-cell-group :border="false">
|
||||||
@@ -373,7 +437,9 @@ onShow(async () => {
|
|||||||
<template #title>
|
<template #title>
|
||||||
<view class="flex items-center justify-between gap-16rpx">
|
<view class="flex items-center justify-between gap-16rpx">
|
||||||
<text class="text-30rpx text-#111827 font-900">单位资料</text>
|
<text class="text-30rpx text-#111827 font-900">单位资料</text>
|
||||||
<wd-tag type="primary" plain> 可完善 </wd-tag>
|
<wd-tag type="primary" plain>
|
||||||
|
可完善
|
||||||
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<wd-cell-group :border="false">
|
<wd-cell-group :border="false">
|
||||||
@@ -419,7 +485,9 @@ onShow(async () => {
|
|||||||
<template #title>
|
<template #title>
|
||||||
<view class="flex items-center justify-between gap-16rpx">
|
<view class="flex items-center justify-between gap-16rpx">
|
||||||
<text class="text-30rpx text-#111827 font-900">实名与账户</text>
|
<text class="text-30rpx text-#111827 font-900">实名与账户</text>
|
||||||
<wd-tag type="primary" plain> 自主维护 </wd-tag>
|
<wd-tag type="primary" plain>
|
||||||
|
自主维护
|
||||||
|
</wd-tag>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<wd-cell-group :border="false">
|
<wd-cell-group :border="false">
|
||||||
@@ -456,95 +524,32 @@ onShow(async () => {
|
|||||||
</wd-form>
|
</wd-form>
|
||||||
|
|
||||||
<view class="mt-36rpx">
|
<view class="mt-36rpx">
|
||||||
<wd-button type="primary" block :loading="loading" @click="submit"> 保存资料 </wd-button>
|
<wd-button type="primary" block :loading="loading" @click="submit">
|
||||||
|
保存资料
|
||||||
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<wd-card v-else custom-class="!m-0 !mt-24rpx profile-card" custom-content-class="!p-24rpx">
|
<view v-if="isViewMode" class="mt-32rpx">
|
||||||
<view class="flex items-center justify-between gap-16rpx">
|
<view class="mb-16rpx px-8rpx text-24rpx text-gray-500">
|
||||||
<text class="text-30rpx text-#111827 font-900">资料状态</text>
|
账号与安全
|
||||||
<wd-tag type="warning" plain> 只读 </wd-tag>
|
</view>
|
||||||
|
<view class="overflow-hidden rounded-24rpx bg-white">
|
||||||
|
<wd-cell-group border>
|
||||||
|
<wd-cell title="手机号" :value="maskPhone(phoneText)" center />
|
||||||
|
<wd-cell title="邮箱" :value="emailText" center />
|
||||||
|
<wd-cell title="登录密码" value="重置" is-link center @click="openPassword" />
|
||||||
|
<wd-cell title="微信绑定" :value="binding ? '处理中…' : wechatText" :is-link="!binding" center @click="manageWechat" />
|
||||||
|
</wd-cell-group>
|
||||||
|
</view>
|
||||||
|
<view class="mt-16rpx px-8rpx text-22rpx text-gray-400 leading-34rpx">
|
||||||
|
{{ isTerminal ? '联系方式可通过顶部编辑维护。' : '企业资料与联系方式由管理平台维护。' }}预警通知请在消息中心查看。
|
||||||
|
</view>
|
||||||
|
<view class="mt-32rpx">
|
||||||
|
<wd-button type="danger" plain block :loading="loggingOut" :disabled="binding || loading" @click="handleLogout">
|
||||||
|
退出登录
|
||||||
|
</wd-button>
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-14rpx text-24rpx text-#64748b leading-36rpx">
|
|
||||||
{{
|
|
||||||
isTerminal
|
|
||||||
? '从服务模块进入时仅展示资料,需要修改请返回“我的”页面点击顶部编辑。'
|
|
||||||
: '企业账号资料由管理平台维护,小程序端仅支持查看。'
|
|
||||||
}}
|
|
||||||
</view>
|
</view>
|
||||||
</wd-card>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.profile-hero {
|
|
||||||
padding: 24rpx;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 88rpx;
|
|
||||||
height: 88rpx;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
background: #eff6ff;
|
|
||||||
border: 1px solid #dbeafe;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-pill {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 5rpx 14rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
background: #eff6ff;
|
|
||||||
border: 1px solid #bfdbfe;
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-top: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 14rpx 16rpx;
|
|
||||||
border: 1px solid #edf2f7;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 21rpx;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-value {
|
|
||||||
display: block;
|
|
||||||
margin-top: 6rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 25rpx;
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.profile-card) {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user