From dabfa45ac076d7e319cb67946e167b9b292e8a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 23 Jul 2026 09:25:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E3=80=81=E5=9C=A8=E7=BA=BF=E5=A4=8D=E8=AF=8A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E3=80=81=E6=96=B0=E5=A2=9E=E9=85=8D=E9=80=81=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sub_business_shared/common/context.js | 12 +- .../components/UserPatientCell.vue | 2 +- .../components/UserPatientDetail.vue | 2 +- .../user-patient/detail.vue | 2 +- .../user-patient/index.vue | 2 +- .../sub_platform_admin/common/context.js | 4 +- .../sub_salesperson/common/submitGuard.js | 115 ++++++++++++++++++ .../sub_salesperson/doctor-input/form.vue | 41 ++++--- .../sub_salesperson/store-input/form.vue | 44 ++++--- 9 files changed, 176 insertions(+), 48 deletions(-) create mode 100644 subPackages/sub_salesperson/common/submitGuard.js diff --git a/subPackages/sub_business_shared/common/context.js b/subPackages/sub_business_shared/common/context.js index 5ef4e64..b08afd5 100644 --- a/subPackages/sub_business_shared/common/context.js +++ b/subPackages/sub_business_shared/common/context.js @@ -29,8 +29,8 @@ export function getPlatformAdminContext() { canDoctorInputAudit: true, }, tabList: [ - { text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: PLATFORM_HOME }, - { text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: PLATFORM_MY }, + { text: '工作台', iconPath: 'home', selectedIconPath: 'home-fill', pagePath: PLATFORM_HOME }, + { text: '我的', iconPath: 'account', selectedIconPath: 'account-fill', pagePath: PLATFORM_MY }, ], }; } @@ -53,8 +53,8 @@ export function getSalespersonContext() { canDoctorInput: true, }, tabList: [ - { text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: SALESPERSON_HOME }, - { text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: SALESPERSON_MY }, + { text: '工作台', iconPath: 'home', selectedIconPath: 'home-fill', pagePath: SALESPERSON_HOME }, + { text: '我的', iconPath: 'account', selectedIconPath: 'account-fill', pagePath: SALESPERSON_MY }, ], }; } @@ -79,8 +79,8 @@ export function getClinicAdminContext() { warehouseType: 'store', }, tabList: [ - { text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: '/subPackages/sub_clinic_admin/home/index' }, - { text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: '/subPackages/sub_clinic_admin/my/index' }, + { text: '工作台', iconPath: 'home', selectedIconPath: 'home-fill', pagePath: '/subPackages/sub_clinic_admin/home/index' }, + { text: '我的', iconPath: 'account', selectedIconPath: 'account-fill', pagePath: '/subPackages/sub_clinic_admin/my/index' }, ], }; } diff --git a/subPackages/sub_business_shared/components/UserPatientCell.vue b/subPackages/sub_business_shared/components/UserPatientCell.vue index 22dbacd..81cbb22 100644 --- a/subPackages/sub_business_shared/components/UserPatientCell.vue +++ b/subPackages/sub_business_shared/components/UserPatientCell.vue @@ -46,7 +46,7 @@ export default { return this.user.nickname || '—'; }, userAvatar() { - return this.user.avatarurl || '/static/image/default-avatar.png'; + return this.user.avatarurl || 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-client-wx/static/mine/mr_tx.png'; }, patientName() { if (typeof this.patient === 'string') return this.patient || '—'; diff --git a/subPackages/sub_business_shared/components/UserPatientDetail.vue b/subPackages/sub_business_shared/components/UserPatientDetail.vue index 20a96f0..f52b1e3 100644 --- a/subPackages/sub_business_shared/components/UserPatientDetail.vue +++ b/subPackages/sub_business_shared/components/UserPatientDetail.vue @@ -23,7 +23,7 @@ diff --git a/subPackages/sub_business_shared/user-patient/detail.vue b/subPackages/sub_business_shared/user-patient/detail.vue index 9879e7f..2660c90 100644 --- a/subPackages/sub_business_shared/user-patient/detail.vue +++ b/subPackages/sub_business_shared/user-patient/detail.vue @@ -7,7 +7,7 @@ - + {{ profile.user.nickname || '微信用户' }} ID {{ profile.user.id || '—' }} diff --git a/subPackages/sub_business_shared/user-patient/index.vue b/subPackages/sub_business_shared/user-patient/index.vue index 5a29cf4..b94c3f3 100644 --- a/subPackages/sub_business_shared/user-patient/index.vue +++ b/subPackages/sub_business_shared/user-patient/index.vue @@ -68,7 +68,7 @@ export default { methods: { avatarSrc(raw) { const s = String(raw || '').trim(); - return s || '/static/image/default-avatar.png'; + return s || 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-client-wx/static/mine/mr_tx.png'; }, /** 标签文案:姓名 或 姓名 · 脱敏手机 */ patientTagText(p) { diff --git a/subPackages/sub_platform_admin/common/context.js b/subPackages/sub_platform_admin/common/context.js index 556a01c..02c2f34 100644 --- a/subPackages/sub_platform_admin/common/context.js +++ b/subPackages/sub_platform_admin/common/context.js @@ -28,8 +28,8 @@ export function getPlatformAdminContext() { canDoctorInputAudit: true, }, tabList: [ - { text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: PLATFORM_HOME }, - { text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: PLATFORM_MY }, + { text: '工作台', iconPath: 'home', selectedIconPath: 'home-fill', pagePath: PLATFORM_HOME }, + { text: '我的', iconPath: 'account', selectedIconPath: 'account-fill', pagePath: PLATFORM_MY }, ], }; } diff --git a/subPackages/sub_salesperson/common/submitGuard.js b/subPackages/sub_salesperson/common/submitGuard.js new file mode 100644 index 0000000..59612b7 --- /dev/null +++ b/subPackages/sub_salesperson/common/submitGuard.js @@ -0,0 +1,115 @@ +/** + * 表单提交 loading / 防重复点击 / 「未发起请求」超时守卫。 + * 为什么抽成公共模块:门店录入与医生录入提交流程一致,避免两页各自维护一套定时器与异常收尾逻辑。 + */ + +/** 点击后若超过该毫秒仍未真正发起请求,则取消 loading 并提示 */ +export const SUBMIT_PREP_TIMEOUT_MS = 10000 + +/** + * 清理「未发起请求」倒计时,避免页面销毁或重复 begin 时残留定时器。 + * @param {object} ctx 页面/组件实例(需挂 _submitPrepTimer) + */ +function clearPrepTimer(ctx) { + if (ctx && ctx._submitPrepTimer) { + clearTimeout(ctx._submitPrepTimer) + ctx._submitPrepTimer = null + } +} + +/** + * 开始提交:立刻 loading + 防重复 + 启动 10s「未发起请求」倒计时。 + * @param {object} ctx 需有 data.submitting + * @param {{ title?: string }} [options] + * @returns {boolean} false 表示已在提交中,调用方应直接 return + */ +export function beginSubmit(ctx, options = {}) { + if (!ctx || ctx.submitting) return false + ctx.submitting = true + ctx._submitRequestStarted = false + clearPrepTimer(ctx) + uni.showLoading({ title: options.title || '提交中...', mask: true }) + // 10 秒内若仍未 markRequestStarted,说明卡在校验/组 payload,需收起 loading 并友好提示 + ctx._submitPrepTimer = setTimeout(() => { + ctx._submitPrepTimer = null + if (ctx._submitRequestStarted) return + endSubmit(ctx, { toast: '提交超时,请稍后重试' }) + }, SUBMIT_PREP_TIMEOUT_MS) + return true +} + +/** + * 标记已真正发起 API 请求:清除「未发起请求」倒计时,loading 继续等待接口返回。 + * @param {object} ctx + */ +export function markRequestStarted(ctx) { + if (!ctx) return + ctx._submitRequestStarted = true + clearPrepTimer(ctx) +} + +/** + * 结束提交:清定时器、复位 submitting;默认关 loading,可选 toast。 + * 校验失败时 showError 已 showToast(会顶掉 loading),须传 skipHideLoading,否则 hideLoading 会把 toast 清掉。 + * @param {object} ctx + * @param {{ toast?: string, skipHideLoading?: boolean }} [options] + */ +export function endSubmit(ctx, options = {}) { + if (!ctx) return + clearPrepTimer(ctx) + ctx._submitRequestStarted = false + ctx.submitting = false + if (!options.skipHideLoading) { + try { + uni.hideLoading() + } catch (e) { + // hideLoading 在无 loading 时偶发抛错,忽略以免二次异常 + } + } + if (options.toast) { + uni.showToast({ title: options.toast, icon: 'none' }) + } +} + +/** + * 统一跑完「校验 → 发请求 → 成功/失败/异常收尾」。 + * @param {object} ctx + * @param {{ + * validate: () => boolean, + * request: () => Promise<{ ok?: boolean, res?: any, msg?: string, data?: any }>, + * onSuccess: (w: any) => void, + * loadingTitle?: string, + * }} options + */ +export function runSubmit(ctx, options) { + const { validate, request, onSuccess, loadingTitle } = options || {} + if (!beginSubmit(ctx, { title: loadingTitle })) return + try { + if (typeof validate === 'function' && !validate()) { + // 校验失败:showError 已 toast(顶掉 loading),只复位状态,勿再 hideLoading + endSubmit(ctx, { skipHideLoading: true }) + return + } + markRequestStarted(ctx) + const reqPromise = typeof request === 'function' ? request() : null + if (!reqPromise || typeof reqPromise.then !== 'function') { + endSubmit(ctx, { toast: '提交异常,请稍后重试' }) + return + } + reqPromise + .then((w) => { + if (w && w.ok) { + endSubmit(ctx) + if (typeof onSuccess === 'function') onSuccess(w) + return + } + const msg = (w && w.res && w.res.msg) || (w && w.msg) || '提交失败' + endSubmit(ctx, { toast: msg }) + }) + .catch(() => { + endSubmit(ctx, { toast: '提交异常,请稍后重试' }) + }) + } catch (e) { + endSubmit(ctx, { toast: '提交异常,请稍后重试' }) + } +} diff --git a/subPackages/sub_salesperson/doctor-input/form.vue b/subPackages/sub_salesperson/doctor-input/form.vue index bea9984..391f885 100644 --- a/subPackages/sub_salesperson/doctor-input/form.vue +++ b/subPackages/sub_salesperson/doctor-input/form.vue @@ -147,7 +147,7 @@ 存草稿 上一步 下一步 - {{ id ? '确认保存' : '提交预填' }} + {{ id ? '确认保存' : '提交预填' }} @@ -180,6 +180,7 @@ import { } from '@/subPackages/sub_salesperson/common/inputFormHelpers.js' import { getDraft, saveDraft, removeDraft } from '@/subPackages/sub_salesperson/common/inputDraftStorage.js' import { serializeDoctorDraft, applyDoctorDraft } from '@/subPackages/sub_salesperson/common/inputDraftHelpers.js' +import { runSubmit, endSubmit } from '@/subPackages/sub_salesperson/common/submitGuard.js' export default { mixins: [businessMixin], @@ -190,6 +191,8 @@ export default { data() { return { id: 0, + // 提交中:用于按钮 loading/禁用,配合 submitGuard 防重复点击 + submitting: false, currentDraftId: '', showDraftNameModal: false, draftNameInput: '', @@ -302,6 +305,10 @@ export default { } }) }, + // 页面销毁时收起提交 loading,避免定时器残留 + beforeDestroy() { + endSubmit(this) + }, methods: { pickSelectRow(e) { if (Array.isArray(e)) return e[0] || null @@ -483,28 +490,28 @@ export default { return true }, submit() { - if (!this.validate()) return - const payload = { - ...this.form, - register_price: Number(this.form.register_price) || 0, - store_input_ids: this.selectedStoreInputIds, - store_ids: this.selectedStoreIds, - store_id: this.selectedStoreInputIds[0] || this.selectedStoreIds[0] || 0, - } - const req = this.id - ? this.bizApi.updateDoctorInput({ ...payload, id: this.id }) - : this.bizApi.createDoctorInput(payload) - req.then((w) => { - if (w.ok) { + runSubmit(this, { + validate: () => this.validate(), + request: () => { + const payload = { + ...this.form, + register_price: Number(this.form.register_price) || 0, + store_input_ids: this.selectedStoreInputIds, + store_ids: this.selectedStoreIds, + store_id: this.selectedStoreInputIds[0] || this.selectedStoreIds[0] || 0, + } + return this.id + ? this.bizApi.updateDoctorInput({ ...payload, id: this.id }) + : this.bizApi.createDoctorInput(payload) + }, + onSuccess: () => { if (this.currentDraftId) { removeDraft('doctor', this.currentDraftId) this.currentDraftId = '' } uni.showToast({ title: '已保存' }) setTimeout(() => uni.navigateBack(), 500) - return - } - uni.showToast({ title: (w.res && w.res.msg) || w.msg || '提交失败', icon: 'none' }) + }, }) }, }, diff --git a/subPackages/sub_salesperson/store-input/form.vue b/subPackages/sub_salesperson/store-input/form.vue index 49aef64..c44d925 100644 --- a/subPackages/sub_salesperson/store-input/form.vue +++ b/subPackages/sub_salesperson/store-input/form.vue @@ -176,7 +176,7 @@ 存草稿 上一步 下一步 - 确认保存 + 确认保存 @@ -202,6 +202,7 @@ import { pickAndUploadImage, openGalleryPick, createDefaultStoreInputForm } from import { getDraft, saveDraft, removeDraft } from '@/subPackages/sub_salesperson/common/inputDraftStorage.js' import { serializeStoreDraft, applyStoreDraft } from '@/subPackages/sub_salesperson/common/inputDraftHelpers.js' import { goDoctorInputFromStore } from '@/subPackages/sub_salesperson/common/inputNavigation.js' +import { runSubmit, endSubmit } from '@/subPackages/sub_salesperson/common/submitGuard.js' export default { mixins: [businessMixin], @@ -212,6 +213,8 @@ export default { data() { return { id: 0, + // 提交中:用于按钮 loading/禁用,配合 submitGuard 防重复点击 + submitting: false, submitSuccess: false, lastCreatedStoreInputId: 0, currentDraftId: '', @@ -289,6 +292,10 @@ export default { } this.loadPromoterOptions() }, + // 页面销毁时收起提交 loading,避免定时器残留 + beforeDestroy() { + endSubmit(this) + }, methods: { loadPromoterOptions() { this.promoterLoading = true @@ -457,24 +464,23 @@ export default { return true }, - // --- 最终提交 --- + // --- 最终提交(点击即 loading;10s 未发请求或异常则收起并提示) --- submit() { - if (!this.validateOptionalBankFields()) return - - const payload = { - ...this.form, - type: Number(this.form.type) === 1 ? 1 : 0, - address: [Number(this.form.province_id), Number(this.form.city_id)], - } - delete payload.erp_id - delete payload.mes_id - - const req = this.id - ? this.bizApi.updateStoreInput({ ...payload, id: this.id }) - : this.bizApi.createStoreInput(payload) - - req.then(w => { - if (w.ok) { + runSubmit(this, { + validate: () => this.validateOptionalBankFields(), + request: () => { + const payload = { + ...this.form, + type: Number(this.form.type) === 1 ? 1 : 0, + address: [Number(this.form.province_id), Number(this.form.city_id)], + } + delete payload.erp_id + delete payload.mes_id + return this.id + ? this.bizApi.updateStoreInput({ ...payload, id: this.id }) + : this.bizApi.createStoreInput(payload) + }, + onSuccess: (w) => { if (this.currentDraftId) { removeDraft('store', this.currentDraftId) this.currentDraftId = '' @@ -488,7 +494,7 @@ export default { this.lastCreatedStoreInputId = Number(newId) this.submitSuccess = true uni.showToast({ title: '提交成功', icon: 'success' }) - } + }, }) }, goDoctorInput() {