diff --git a/README.md b/README.md index f3c8598..9b45d40 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ iotLCM 物联网卡全生命周期管理平台的**小程序端 + 用户端服 ## 快速开始 ### 后端 +将 `server/config/config.example.yaml` 复制为 `server/config/config.yaml` 并填写自己的凭据;使用代码生成前同样将 `server/hack/config.example.yaml` 复制为 `server/hack/config.yaml`。 + ```bash cd server @@ -51,7 +53,7 @@ pnpm dev:mp-weixin # 微信小程序,产物用微信开发者工具打开 | 位置 | 说明 | | --- | --- | -| `server/config/config.yaml` | 后端配置(**公开版凭据为占位符**) | +| `server/config/config.example.yaml` | 后端配置(**公开版凭据为占位符**) | | `app/env/.env*` | 前端环境变量(**公开版 AppID 为占位符**) | 后端配置占位: diff --git a/app/Dockerfile b/app/Dockerfile index ada19b1..d69440e 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -11,6 +11,7 @@ FROM ${BASE_REGISTRY}/node:24-alpine AS builder WORKDIR /build/app COPY package.json pnpm-lock.yaml .npmrc ./ +COPY patches ./patches RUN npm config set registry https://registry.npmmirror.com/ && \ npm install -g pnpm@10.10.0 --registry=https://registry.npmmirror.com/ && \ diff --git a/app/manifest.config.ts b/app/manifest.config.ts index 607a04b..b4c4a31 100644 --- a/app/manifest.config.ts +++ b/app/manifest.config.ts @@ -25,8 +25,8 @@ export default defineManifestConfig({ 'name': VITE_APP_TITLE, 'appid': VITE_UNI_APPID, 'description': '', - 'versionName': '1.0.0', - 'versionCode': '100', + 'versionName': '1.0.2', + 'versionCode': '102', 'transformPx': false, 'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans' 'h5': { diff --git a/app/package.json b/app/package.json index 9067152..3351d1b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "iotlcm-app-ui", "type": "module", - "version": "1.0.0", + "version": "1.0.2", "unibest": { "platforms": [ "h5", @@ -25,7 +25,7 @@ "author": { "name": "feige996", "zhName": "菲鸽", - "email": "1020103647@example.com", + "email": "xxx@example.com", "github": "https://github.com/feige996", "gitee": "https://gitee.com/feige996" }, @@ -112,6 +112,7 @@ "upload:changeset": "pnpm changeset && pnpm changeset version", "upload:mp": "node ./scripts/upload-weixin.js", "type-check": "vue-tsc --noEmit", + "test:run": "vitest run", "lint": "eslint", "lint:fix": "eslint --fix", "format": "prettier . --write", @@ -202,11 +203,15 @@ "vite": "5.2.8", "vite-plugin-restart": "^1.0.0", "vite-plugin-vue-devtools": "^8.0.5", + "vitest": "3.2.4", "vue-tsc": "^3.0.6" }, "pnpm": { "overrides": { "unconfig": "7.3.2" + }, + "patchedDependencies": { + "@wot-ui/ui@2.1.0": "patches/@wot-ui__ui@2.1.0.patch" } }, "overrides": { diff --git a/app/patches/@wot-ui__ui@2.1.0.patch b/app/patches/@wot-ui__ui@2.1.0.patch new file mode 100644 index 0000000..a64506c --- /dev/null +++ b/app/patches/@wot-ui__ui@2.1.0.patch @@ -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, +- 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).type || 'dots' ++ swiperIndicator.minShowNum = (indicator as Partial).minShowNum || 2 ++ swiperIndicator.showControls = (indicator as Partial).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 { + + + diff --git a/app/src/components/WorkOrderAttachments.test.ts b/app/src/components/WorkOrderAttachments.test.ts new file mode 100644 index 0000000..afffaf9 --- /dev/null +++ b/app/src/components/WorkOrderAttachments.test.ts @@ -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(/]*>([\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)') + }) +}) diff --git a/app/src/components/WorkOrderAttachments.vue b/app/src/components/WorkOrderAttachments.vue new file mode 100644 index 0000000..5ea52bf --- /dev/null +++ b/app/src/components/WorkOrderAttachments.vue @@ -0,0 +1,162 @@ + + + diff --git a/app/src/constant/RouterPath.ts b/app/src/constant/RouterPath.ts index d632ec8..ca795e3 100644 --- a/app/src/constant/RouterPath.ts +++ b/app/src/constant/RouterPath.ts @@ -6,76 +6,82 @@ * 页面路径常量 */ const RouterPath = { - /** 山东平台物联卡 */ - Home: '/pages/home/index', + /** 示例企业物联卡 */ + 'Home': '/pages/home/index', /** 办理 */ - Business: '/pages/business/index', + 'Business': '/pages/business/index', /** 卡片 */ - Card: '/pages/card/index', - /** 山东平台物联卡 */ - Login: '/pages/login/index', + 'Card': '/pages/card/index', + /** 示例企业物联卡 */ + 'Login': '/pages/login/index', /** 消息 */ - Message: '/pages/message/index', + 'Message': '/pages/message/index', /** 我的 */ - User: '/pages/user/index', + 'User': '/pages/user/index', /** 找回密码 */ 'LoginForgot-password': '/pages/login/forgot-password/index', /** 注册账号 */ - LoginRegister: '/pages/login/register/index', + 'LoginRegister': '/pages/login/register/index', /** 加油包 */ - BusinessViewsAddon: '/pages/business/views/addon/index', + 'BusinessViewsAddon': '/pages/business/views/addon/index', /** 批量卡片划拨 */ - BusinessViewsAllocate: '/pages/business/views/allocate/index', + 'BusinessViewsAllocate': '/pages/business/views/allocate/index', /** 余额充值 */ - BusinessViewsBalanceRecharge: '/pages/business/views/balanceRecharge/index', + 'BusinessViewsBalanceRecharge': '/pages/business/views/balanceRecharge/index', /** 单卡套餐续费 */ - BusinessViewsCardRenew: '/pages/business/views/cardRenew/index', + 'BusinessViewsCardRenew': '/pages/business/views/cardRenew/index', /** 套餐变更 */ - BusinessViewsChange: '/pages/business/views/change/index', + 'BusinessViewsChange': '/pages/business/views/change/index', /** 客户管理 */ - BusinessViewsCustomer: '/pages/business/views/customer/index', + 'BusinessViewsCustomer': '/pages/business/views/customer/index', /** 客户详情 */ - BusinessViewsCustomerDetail: '/pages/business/views/customerDetail/index', + 'BusinessViewsCustomerDetail': '/pages/business/views/customerDetail/index', /** 卡片排查 */ - BusinessViewsDiagnosis: '/pages/business/views/diagnosis/index', + 'BusinessViewsDiagnosis': '/pages/business/views/diagnosis/index', /** 我的订单 */ - BusinessViewsOrder: '/pages/business/views/order/index', + 'BusinessViewsOrder': '/pages/business/views/order/index', /** 批量套餐续费 */ - BusinessViewsRenew: '/pages/business/views/renew/index', + 'BusinessViewsRenew': '/pages/business/views/renew/index', /** 预警配置 */ - BusinessViewsWarning: '/pages/business/views/warning/index', + 'BusinessViewsWarning': '/pages/business/views/warning/index', /** 工单列表 */ - BusinessViewsWorkOrder: '/pages/business/views/workOrder/index', + 'BusinessViewsWorkOrder': '/pages/business/views/workOrder/index', /** 提交工单 */ - BusinessViewsWorkOrderCreate: '/pages/business/views/workOrderCreate/index', + 'BusinessViewsWorkOrderCreate': '/pages/business/views/workOrderCreate/index', /** 工单详情 */ - BusinessViewsWorkOrderDetail: '/pages/business/views/workOrderDetail/index', + 'BusinessViewsWorkOrderDetail': '/pages/business/views/workOrderDetail/index', /** 卡片详情 */ - CardViewsDetail: '/pages/card/views/detail/index', + 'CardViewsDetail': '/pages/card/views/detail/index', /** 全部设备 */ - CardViewsManagement: '/pages/card/views/management/index', + 'CardViewsManagement': '/pages/card/views/management/index', /** 卡片详情 */ - CardViewsPackageDetail: '/pages/card/views/packageDetail/index', + 'CardViewsPackageDetail': '/pages/card/views/packageDetail/index', /** 预警配置 */ - CardViewsWarning: '/pages/card/views/warning/index', + 'CardViewsWarning': '/pages/card/views/warning/index', /** 消息详情 */ - MessageViewsCardDetail: '/pages/message/views/cardDetail/index', + 'MessageViewsCardDetail': '/pages/message/views/cardDetail/index', /** 消息详情 */ - MessageViewsSystemDetail: '/pages/message/views/systemDetail/index', + 'MessageViewsSystemDetail': '/pages/message/views/systemDetail/index', /** 关于我们 */ - UserViewsAbout: '/pages/user/views/about/index', + 'UserViewsAbout': '/pages/user/views/about/index', /** 协议与隐私 */ - UserViewsAgreement: '/pages/user/views/agreement/index', + 'UserViewsAgreement': '/pages/user/views/agreement/index', /** 联系客服 */ - UserViewsContact: '/pages/user/views/contact/index', + 'UserViewsContact': '/pages/user/views/contact/index', + /** 批次执行日志 */ + 'UserViewsExecutionLog': '/pages/user/views/executionLog/index', + /** 执行日志详情 */ + 'UserViewsExecutionLogDetail': '/pages/user/views/executionLogDetail/index', /** 帮助与反馈 */ - UserViewsFeedback: '/pages/user/views/feedback/index', - /** 微信提醒 */ - UserViewsNotification: '/pages/user/views/notification/index', - /** 账号资料 */ - UserViewsProfile: '/pages/user/views/profile/index', + 'UserViewsFeedback': '/pages/user/views/feedback/index', + /** 使用手册 */ + 'UserViewsManual': '/pages/user/views/manual/index', + /** 消息提醒 */ + 'UserViewsNotification': '/pages/user/views/notification/index', + /** 个人资料 */ + 'UserViewsProfile': '/pages/user/views/profile/index', /** 实名信息 */ - UserViewsRealName: '/pages/user/views/realName/index', + 'UserViewsRealName': '/pages/user/views/realName/index', } export default RouterPath diff --git a/app/src/constant/release.ts b/app/src/constant/release.ts new file mode 100644 index 0000000..c6fc6b3 --- /dev/null +++ b/app/src/constant/release.ts @@ -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) +} diff --git a/app/src/http/interceptor.test.ts b/app/src/http/interceptor.test.ts new file mode 100644 index 0000000..0b19569 --- /dev/null +++ b/app/src/http/interceptor.test.ts @@ -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`/) + }) +}) diff --git a/app/src/http/tools/enum.ts b/app/src/http/tools/enum.ts index 8ea91ff..72cfce0 100644 --- a/app/src/http/tools/enum.ts +++ b/app/src/http/tools/enum.ts @@ -28,7 +28,7 @@ const networkErrorPattern = /request:fail|timeout|failed to fetch|networkerror|err_connection|econnrefused|econnreset|econnaborted|enotfound|net::|socket|connect|refused|abort|not in domain list|url not in domain list|ssl|tls|certificate|网络|超时/i const technicalDetailPattern - = /https?:\/\/|\/api\/|接口[::]|errMsg|statusCode|StatusCode|request:|XMLHttpRequest|TypeError:|Error:/i + = /https?:\/\/|\/api\/|接口[::]|errMsg|statusCode|request:|XMLHttpRequest|TypeError:|Error:/i /** * 根据状态码,生成对应的错误信息 diff --git a/app/src/manifest.json b/app/src/manifest.json index 05eeedf..365cb58 100644 --- a/app/src/manifest.json +++ b/app/src/manifest.json @@ -2,8 +2,8 @@ "name": "山东平台物联卡", "appid": "__UNI__YOURAPPID", "description": "", - "versionName": "1.0.0", - "versionCode": "100", + "versionName": "1.0.2", + "versionCode": "102", "transformPx": false, "app-plus": { "usingComponents": true, diff --git a/app/src/pages.json b/app/src/pages.json index 34fd6e3..386f33b 100644 --- a/app/src/pages.json +++ b/app/src/pages.json @@ -21,7 +21,7 @@ "type": "home", "style": { "navigationStyle": "default", - "navigationBarTitleText": "山东平台物联卡", + "navigationBarTitleText": "示例企业物联卡", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black" }, @@ -49,7 +49,7 @@ "type": "page", "name": "Login", "style": { - "navigationBarTitleText": "山东平台物联卡", + "navigationBarTitleText": "示例企业物联卡", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black", "disableScroll": true @@ -180,7 +180,8 @@ "path": "workOrder/index", "type": "page", "style": { - "navigationBarTitleText": "工单列表" + "navigationBarTitleText": "工单列表", + "enablePullDownRefresh": true } }, { @@ -290,7 +291,7 @@ "path": "notification/index", "type": "page", "style": { - "navigationBarTitleText": "微信提醒" + "navigationBarTitleText": "消息提醒" } }, { @@ -298,7 +299,7 @@ "type": "page", "excludeLoginPath": true, "style": { - "navigationBarTitleText": "账号资料" + "navigationBarTitleText": "个人资料" } }, { @@ -307,6 +308,27 @@ "style": { "navigationBarTitleText": "实名信息" } + }, + { + "path": "manual/index", + "type": "page", + "style": { + "navigationBarTitleText": "使用手册" + } + }, + { + "path": "executionLog/index", + "type": "page", + "style": { + "navigationBarTitleText": "批次执行日志" + } + }, + { + "path": "executionLogDetail/index", + "type": "page", + "style": { + "navigationBarTitleText": "执行日志详情" + } } ] } diff --git a/app/src/pages/business/components/BusinessSection.vue b/app/src/pages/business/components/BusinessSection.vue index c74d6a0..0c70d84 100644 --- a/app/src/pages/business/components/BusinessSection.vue +++ b/app/src/pages/business/components/BusinessSection.vue @@ -3,6 +3,7 @@ import type { BusinessServiceItem, BusinessServiceSection, BusinessTone } from ' const props = defineProps<{ section: BusinessServiceSection + busy?: boolean }>() const emit = defineEmits<{ @@ -19,12 +20,13 @@ const toneClass: Record = { } function handleClick(item: BusinessServiceItem) { - emit('action', item) + if (!props.busy) + emit('action', item) } function visibleRoleTag(item: BusinessServiceItem) { if (item.agentOnly) - return '代理商' + return '企业 / 代理' if (item.terminalOnly) return '用户' if (item.terminalHidden) diff --git a/app/src/pages/business/data.test.ts b/app/src/pages/business/data.test.ts index 0cce706..ad4c030 100644 --- a/app/src/pages/business/data.test.ts +++ b/app/src/pages/business/data.test.ts @@ -21,6 +21,15 @@ const expectedSubViews = [ ] describe('getUserBusinessSections', () => { + it('按服务、资源管理和执行记录划分入口且不重复', () => { + const sectionItems = (title: string) => businessSections.find(section => section.title === title)?.items.map(item => item.action) + expect(sectionItems('服务记录')).toEqual(['order', 'workOrder']) + expect(sectionItems('卡片与客户')).toEqual(['cardList', 'customer']) + expect(sectionItems('执行记录')).toEqual(['executionLog']) + const titles = businessSections.flatMap(section => section.items.map(item => item.title)) + expect(new Set(titles).size).toBe(titles.length) + }) + it('只保留用户侧一级业务入口', () => { const sections = getUserBusinessSections(businessSections) const titles = sections.flatMap(section => section.items.map(item => item.title)) diff --git a/app/src/pages/business/data.ts b/app/src/pages/business/data.ts index 482f7b1..c05a290 100644 --- a/app/src/pages/business/data.ts +++ b/app/src/pages/business/data.ts @@ -1,6 +1,6 @@ import type { IUserInfoRes } from '@/api/types/login' import type { AppPermissionAction } from '@/utils/permission' -import { hasUserPermission } from '@/utils/permission' +import { hasAccessCode, hasUserPermission } from '@/utils/permission' import { getAccountWorkspace, isTerminalUserInfo } from '@/utils/userIdentity' export type BusinessTone = 'blue' | 'green' | 'orange' | 'primary' | 'purple' | 'red' @@ -18,11 +18,13 @@ export interface BusinessServiceItem { | 'cardList' | 'change' | 'customer' + | 'executionLog' | 'diagnosis' | 'order' | 'renew' | 'warning' | 'workOrder' + accessCode?: string agentOnly?: boolean terminalOnly?: boolean terminalHidden?: boolean @@ -113,7 +115,7 @@ export const businessSections: BusinessServiceSection[] = [ }, { title: '预警配置', - desc: '流量阈值与微信订阅', + desc: '流量阈值与站内消息提醒', tag: '预警', icon: 'i-carbon-warning-alt', tone: 'orange', @@ -134,7 +136,7 @@ export const businessSections: BusinessServiceSection[] = [ }, { title: '服务记录', - desc: '订单与工单', + desc: '订单查询与问题反馈', type: 'grid', items: [ { @@ -154,6 +156,13 @@ export const businessSections: BusinessServiceSection[] = [ action: 'workOrder', permissionAction: ['workOrder', 'workOrderCreate'], }, + ], + }, + { + title: '卡片与客户', + desc: '卡片信息与客户管理', + type: 'list', + items: [ { title: '我的卡片', desc: '查看卡信息、套餐和流量', @@ -164,7 +173,7 @@ export const businessSections: BusinessServiceSection[] = [ }, { title: '客户管理', - desc: '直属客户与客户详情', + desc: '授权范围内的客户与详情', tag: '客户', icon: 'i-carbon-user-multiple', tone: 'green', @@ -174,6 +183,22 @@ export const businessSections: BusinessServiceSection[] = [ }, ], }, + { + title: '执行记录', + desc: '批次进度与执行结果', + type: 'list', + items: [ + { + title: '批次执行日志', + desc: '查看批次进度与执行结果', + tag: '日志', + icon: 'i-carbon-document', + tone: 'blue', + action: 'executionLog', + accessCode: 'cpm:common:batchExecutionLog:list', + }, + ], + }, ] /** ==================== 方法 ==================== */ @@ -191,6 +216,8 @@ export function getUserBusinessSections( .map(section => ({ ...section, items: section.items.filter((item) => { + if (item.accessCode && !hasAccessCode(userInfo?.accessCodes, item.accessCode)) + return false if (item.agentOnly && !isAgentUser) return false if (item.terminalOnly && !isTerminalUser) diff --git a/app/src/pages/business/index.test.ts b/app/src/pages/business/index.test.ts new file mode 100644 index 0000000..ca424a5 --- /dev/null +++ b/app/src/pages/business/index.test.ts @@ -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) { + 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(/]*>([\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, + fetchUserInfo, + navigateTo, + switchTab, + } +} + +function visibleTitles(userInfo: Partial) { + return createHarness(userInfo).titles() +} + +describe('办理入口发布范围', () => { + it('沿用紧凑办理布局,不展示大块消息提醒入口', () => { + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + const template = source.split('')[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('客户管理') + }) +}) diff --git a/app/src/pages/business/index.vue b/app/src/pages/business/index.vue index 1a0f480..259cdf5 100644 --- a/app/src/pages/business/index.vue +++ b/app/src/pages/business/index.vue @@ -1,10 +1,11 @@