Files
xk-doctor-wx/subPackages/sub_salesperson/doctor-input/form.vue
2026-06-05 12:28:36 +08:00

553 lines
22 KiB
Vue

<template>
<business-page-layout :title="pageTitle" :show-back="true">
<view class="step-header">
<u-steps :list="stepList" :current="currentStep" active-color="#6acdbb"></u-steps>
</view>
<scroll-view scroll-y class="form-scroll">
<u-form :model="form" ref="uForm" label-width="220" label-align="left">
<view v-show="currentStep === 0" class="step-content">
<view class="section-title">基础信息</view>
<u-form-item label="预填门店" :required="true">
<store-input-picker
:value="selectedStoreInputIds"
:options="storeInputOptions"
:loading="storeInputLoading"
:default-store-input-id="presetStoreInputId"
@input="selectedStoreInputIds = $event"
/>
</u-form-item>
<u-form-item label="医生头像" :required="true">
<view class="img-row">
<image v-if="form.avatar" :src="form.avatar" class="thumb" mode="aspectFill" @click="uploadField('avatar')" />
<view v-else class="upload-btn" @click="uploadField('avatar')">
<u-icon name="plus" size="40" color="#999"></u-icon>
<text class="upload-txt">上传头像</text>
</view>
</view>
</u-form-item>
<u-form-item label="职称" right-icon="arrow-right">
<u-input :value="selectedTitleLabel" type="select" @click="showTitle = true" placeholder="请选择" />
<u-select v-model="showTitle" :list="titleSelectList" @confirm="onTitleConfirm"></u-select>
</u-form-item>
<u-form-item label="科室" :required="true">
<depart-picker
:value="form.depart_id"
:options="departOptions"
@input="onDepartPick"
/>
</u-form-item>
<u-form-item label="医生姓名" :required="true">
<u-input v-model="form.name" placeholder="请输入医生姓名" />
</u-form-item>
<u-form-item label="手机号" :required="true">
<u-input v-model="form.mobile" type="number" placeholder="请输入手机号" />
</u-form-item>
<u-form-item label="身份证" :required="true">
<u-input v-model="form.idcard" placeholder="请输入身份证号" />
</u-form-item>
<u-form-item label="身份" :required="true">
<view class="tab-wrap">
<u-subsection
:list="typeTabLabels"
:current="typeTabIndex"
active-color="#6acdbb"
@change="onTypeTabChange"
/>
</view>
</u-form-item>
<u-form-item label="签名类型" :required="true">
<view class="tab-wrap">
<u-subsection
:list="signTypeTabLabels"
:current="signTypeTabIndex"
active-color="#6acdbb"
@change="onSignTypeTabChange"
/>
</view>
</u-form-item>
<u-form-item label="医生签名" :required="true">
<view class="sign-block">
<view v-if="Number(form.sign_type) === 2" class="sign-pad-area" @click="showSignPad = true">
<image
v-if="form.sign_image"
:src="form.sign_image"
class="sign-preview"
mode="aspectFit"
/>
<view v-else class="sign-placeholder">
<u-icon name="edit-pen" size="40" color="#6acdbb"></u-icon>
<text class="sign-tip">点击签名</text>
</view>
</view>
<view v-else class="img-row">
<image
v-if="form.sign_image"
:src="form.sign_image"
class="sign-preview"
mode="aspectFit"
@click="uploadField('sign_image')"
/>
<view v-else class="upload-btn" @click="uploadField('sign_image')">
<u-icon name="plus" size="40" color="#999"></u-icon>
<text class="upload-txt">上传签名图</text>
</view>
</view>
</view>
</u-form-item>
<u-form-item label="擅长" :required="true">
<u-input v-model="form.good_at" type="textarea" placeholder="请输入擅长领域" />
</u-form-item>
<u-form-item label="简介" :required="true">
<u-input v-model="form.intro" type="textarea" placeholder="请输入简介" />
</u-form-item>
</view>
<view v-show="currentStep === 1" class="step-content">
<view class="section-title">证照资料</view>
<view v-for="item in imageFields" :key="item.key" class="upload-group">
<view class="upload-label"><text v-if="item.required" class="required">*</text>{{ item.label }}</view>
<view class="img-row">
<image v-if="form[item.key]" :src="form[item.key]" class="thumb" mode="aspectFill" @click="uploadField(item.key)" />
<view v-else class="upload-btn" @click="uploadField(item.key)">
<u-icon name="plus" size="40" color="#999"></u-icon>
<text class="upload-txt">上传</text>
</view>
</view>
</view>
</view>
<view v-show="currentStep === 2" class="step-content">
<view class="section-title">确认信息</view>
<view class="summary-card">
<view class="summary-row"><text class="k">医生姓名</text><text class="v">{{ form.name || '-' }}</text></view>
<view class="summary-row"><text class="k">手机号</text><text class="v">{{ form.mobile || '-' }}</text></view>
<view class="summary-row"><text class="k">关联门店</text><text class="v">{{ selectedStoreNames || '-' }}</text></view>
<view class="summary-row"><text class="k">科室/职称</text><text class="v">{{ selectedDepartLabel }} / {{ selectedTitleLabel }}</text></view>
<view class="summary-row"><text class="k">身份</text><text class="v">{{ selectedTypeLabel }}</text></view>
<view class="summary-row"><text class="k">签名</text><text class="v">{{ form.sign_image ? '已填写' : '-' }}</text></view>
</view>
</view>
</u-form>
</scroll-view>
<view class="footer-actions">
<u-button v-if="canSaveDraft" class="btn-draft" @click="openSaveDraft">存草稿</u-button>
<u-button v-if="currentStep > 0" class="btn-prev" @click="prevStep">上一步</u-button>
<u-button v-if="currentStep < 2" class="btn-next" type="primary" :custom-style="primaryStyle" @click="nextStep">下一步</u-button>
<u-button v-if="currentStep === 2" class="btn-submit" type="primary" :custom-style="primaryStyle" @click="submit">{{ id ? '确认保存' : '提交预填' }}</u-button>
</view>
<u-popup v-model="showDraftNameModal" mode="center" border-radius="16" width="600rpx">
<view class="draft-name-box">
<text class="draft-name-title">草稿名称</text>
<u-input v-model="draftNameInput" placeholder="请输入草稿名称" />
<view class="draft-name-actions">
<u-button size="medium" @click="showDraftNameModal = false">取消</u-button>
<u-button size="medium" type="primary" :custom-style="primaryStyle" @click="confirmSaveDraft">保存</u-button>
</view>
</view>
</u-popup>
<d-handwritten v-model="showSignPad" :use-page-container="true" @saveCanvas="onSignCanvasSave"></d-handwritten>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_salesperson/components/business-page-layout.vue'
import StoreInputPicker from '@/subPackages/sub_salesperson/components/StoreInputPicker.vue'
import DepartPicker from '@/subPackages/sub_salesperson/components/DepartPicker.vue'
import businessMixin from '@/subPackages/sub_salesperson/common/businessMixin.js'
import { getInputBusinessContext } from '@/subPackages/sub_salesperson/common/context.js'
import {
pickAndUploadImage,
uploadSignCanvasBase64,
uploadSignCanvasTempPath,
createDefaultDoctorInputForm,
} 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'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout, StoreInputPicker, DepartPicker },
provide() {
return { businessContext: getInputBusinessContext() }
},
data() {
return {
id: 0,
currentDraftId: '',
showDraftNameModal: false,
draftNameInput: '',
currentStep: 0,
stepList: [
{ name: '基础信息' },
{ name: '证照资料' },
{ name: '确认提交' },
],
primaryStyle: {
backgroundColor: '#6acdbb',
color: '#ffffff',
borderColor: '#6acdbb',
},
showTitle: false,
showSignPad: false,
storeInputOptions: [],
storeInputLoading: false,
selectedStoreInputIds: [],
presetStoreInputId: 0,
departOptions: [],
titleOptions: [],
titleIndex: 0,
typeOptions: [{ label: '中医医生', value: 1 }, { label: '西医医生', value: 2 }],
signTypeOptions: [{ label: '电子签名', value: 1 }, { label: '手写签名', value: 2 }],
imageFields: [
{ key: 'qualification', label: '资格证书', required: true },
{ key: 'practicing', label: '执业证书', required: true },
{ key: 'title', label: '职称证书', required: false },
{ key: 'card_up', label: '身份证正面', required: true },
{ key: 'card_down', label: '身份证反面', required: true },
],
form: createDefaultDoctorInputForm(),
}
},
computed: {
pageTitle() { return this.id ? '编辑医生预填' : '新建医生预填' },
canSaveDraft() { return !this.id },
typeTabLabels() {
return this.typeOptions.map((t) => t.label)
},
signTypeTabLabels() {
return this.signTypeOptions.map((t) => t.label)
},
typeTabIndex() {
const idx = this.typeOptions.findIndex((t) => t.value === Number(this.form.type))
return idx >= 0 ? idx : 0
},
signTypeTabIndex() {
const idx = this.signTypeOptions.findIndex((t) => t.value === Number(this.form.sign_type))
return idx >= 0 ? idx : 0
},
selectedDepartLabel() {
const d = this.departOptions.find((x) => x.id === this.form.depart_id)
return d ? d.name : '请选择科室'
},
selectedTitleLabel() {
const t = this.titleOptions.find((x) => x.id === this.form.title_id)
return t ? t.name : '请选择职称'
},
selectedTypeLabel() {
const item = this.typeOptions.find((x) => x.value === Number(this.form.type))
return item ? item.label : '请选择'
},
titleSelectList() {
return this.titleOptions.map((t) => ({ label: t.name, value: t.id }))
},
selectedStoreNames() {
return this.storeInputOptions
.filter((s) => this.selectedStoreInputIds.includes(s.id))
.map((s) => s.name)
.join('、')
},
},
onLoad(q) {
this.id = Number(q.id || 0)
const draftId = !this.id ? (q.draftId || '') : ''
if (draftId) this.currentDraftId = draftId
this.presetStoreInputId = !this.id ? Number(q.storeInputId || 0) : 0
this.storeInputLoading = true
Promise.all([
this.bizApi.getStoreInputOption().then((w) => {
this.storeInputLoading = false
this.storeInputOptions = w.data || []
}).catch(() => { this.storeInputLoading = false }),
this.bizApi.getDepartOption().then((w) => { this.departOptions = w.data || [] }),
this.bizApi.getDoctorTitleOption().then((w) => { this.titleOptions = w.data || [] }),
]).then(() => {
if (this.presetStoreInputId > 0 && !this.selectedStoreInputIds.includes(this.presetStoreInputId)) {
this.selectedStoreInputIds = [this.presetStoreInputId]
}
if (this.id) {
this.loadDetail()
} else if (draftId) {
this.loadDraft(draftId)
this.syncPickerFromForm()
} else {
this.applyPickerDefaultsAfterLoad()
}
})
},
methods: {
pickSelectRow(e) {
if (Array.isArray(e)) return e[0] || null
return e || null
},
pickSelectValue(row) {
if (!row) return null
if (row.value !== undefined && row.value !== null) return row.value
if (row.id !== undefined && row.id !== null) return row.id
return null
},
syncPickerFromForm() {
const ti = this.titleOptions.findIndex((t) => t.id === this.form.title_id)
this.titleIndex = ti >= 0 ? ti : -1
},
applyPickerDefaultsAfterLoad() {
if (!this.form.depart_id && this.departOptions.length) {
this.form.depart_id = this.departOptions[0].id
}
if (!this.form.title_id && this.titleOptions.length) {
this.form.title_id = this.titleOptions[0].id
}
if (this.form.type == null || this.form.type === '') {
this.form.type = this.typeOptions[0]?.value ?? 1
}
if (this.form.sign_type == null || this.form.sign_type === '') {
this.form.sign_type = this.signTypeOptions[0]?.value ?? 1
}
this.syncPickerFromForm()
},
onDepartPick(id) {
this.form.depart_id = id
},
onTypeTabChange(index) {
const item = this.typeOptions[index]
if (item) this.form.type = item.value
},
onSignTypeTabChange(index) {
const item = this.signTypeOptions[index]
if (!item) return
this.form.sign_type = item.value
if (Number(item.value) === 2 && this.form.sign_image && !/^https?:\/\//i.test(this.form.sign_image)) {
this.form.sign_image = ''
}
},
onTitleConfirm(e) {
const selected = this.pickSelectRow(e)
const value = this.pickSelectValue(selected)
if (value == null) return
this.form.title_id = value
this.syncPickerFromForm()
},
uploadField(field) {
pickAndUploadImage().then((url) => { this.form[field] = url }).catch(() => {})
},
onSignCanvasSave(payload) {
this.showSignPad = false
const base64 = typeof payload === 'string' ? payload : (payload && payload.base64)
const tempFilePath = payload && payload.tempFilePath
if (!base64 && !tempFilePath) {
uni.showToast({ title: '请先签名', icon: 'none' })
return
}
const raw = base64 ? String(base64).replace(/^data:image\/\w+;base64,/, '') : ''
if (raw) {
this.form.sign_image = `data:image/png;base64,${raw}`
}
const uploadPromise = tempFilePath
? uploadSignCanvasTempPath(tempFilePath)
: uploadSignCanvasBase64(raw)
uploadPromise
.then((url) => {
this.form.sign_image = url
})
.catch((e) => {
uni.showToast({ title: (e && e.message) || '签名上传失败', icon: 'none' })
if (!raw) this.form.sign_image = ''
})
},
loadDraft(draftId) {
const draft = getDraft('doctor', draftId)
if (!draft) {
uni.showToast({ title: '草稿不存在', icon: 'none' })
this.currentDraftId = ''
return
}
applyDoctorDraft(this, draft)
},
openSaveDraft() {
const defaultName = (this.form.name || '').trim() || (this.form.mobile || '').trim() || '未命名草稿'
this.draftNameInput = defaultName
this.showDraftNameModal = true
},
confirmSaveDraft() {
const name = (this.draftNameInput || '').trim()
if (!name) {
uni.showToast({ title: '请输入草稿名称', icon: 'none' })
return
}
try {
const { form, ui } = serializeDoctorDraft(this)
const saved = saveDraft('doctor', {
name,
form,
ui,
draftId: this.currentDraftId || undefined,
})
this.currentDraftId = saved.id
this.showDraftNameModal = false
uni.showToast({ title: '草稿已保存', icon: 'success' })
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
}
},
loadDetail() {
this.bizApi.getDoctorInputDetail(this.id).then((w) => {
if (!w.ok || !w.data) return
Object.assign(this.form, createDefaultDoctorInputForm(), w.data)
this.selectedStoreInputIds = w.data.store_input_ids?.length
? [...w.data.store_input_ids]
: (w.data.store_ids || (w.data.store_id ? [w.data.store_id] : []))
this.syncPickerFromForm()
})
},
nextStep() {
if (this.validateCurrentStep()) this.currentStep += 1
},
prevStep() {
this.currentStep -= 1
},
validateCurrentStep() {
const f = this.form
if (this.currentStep === 0) {
if (!this.selectedStoreInputIds.length) return this.showError('请选择预填门店')
if (!String(f.name || '').trim()) return this.showError('请输入医生姓名')
if (!String(f.mobile || '').trim()) return this.showError('请输入手机号')
if (!String(f.idcard || '').trim()) return this.showError('请输入身份证')
if (!f.avatar) return this.showError('请上传医生头像')
if (!f.depart_id) return this.showError('请选择科室')
if (!f.title_id) return this.showError('请选择职称')
if (!String(f.good_at || '').trim()) return this.showError('请输入擅长')
if (!String(f.intro || '').trim()) return this.showError('请输入简介')
if (!f.sign_image || !/^https?:\/\//i.test(String(f.sign_image))) {
return this.showError(Number(f.sign_type) === 2 ? '请完成手写签名' : '请上传签名图')
}
}
if (this.currentStep === 1) {
const required = ['qualification', 'practicing', 'card_up', 'card_down']
for (const k of required) {
if (!String(f[k] || '').trim()) return this.showError('请上传完整证照资料')
}
}
return true
},
showError(msg) {
uni.showToast({ title: msg, icon: 'none' })
return false
},
validate() {
if (!this.validateCurrentStep()) return false
return true
},
submit() {
if (!this.validate()) return
const payload = {
...this.form,
store_input_ids: this.selectedStoreInputIds,
store_ids: this.selectedStoreInputIds,
store_id: this.selectedStoreInputIds[0] || 0,
}
const req = this.id
? this.bizApi.updateDoctorInput({ ...payload, id: this.id })
: this.bizApi.createDoctorInput(payload)
req.then((w) => {
if (w.ok) {
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' })
})
},
},
}
</script>
<style scoped>
.step-header { background-color: #fff; padding: 30rpx 40rpx; margin-bottom: 20rpx; }
.form-scroll { height: calc(100vh - 480rpx); box-sizing: border-box; }
.step-content { background: #fff; padding: 0 32rpx; border-radius: 16rpx; margin: 0 24rpx; }
.section-title {
font-size: 32rpx;
font-weight: 600;
padding: 40rpx 0 20rpx;
color: #1D2129;
position: relative;
}
.section-title::after {
content: '';
position: absolute;
left: -32rpx;
top: 46rpx;
width: 8rpx;
height: 32rpx;
background: #6acdbb;
border-radius: 0 4rpx 4rpx 0;
}
.tab-wrap { width: 100%; padding: 8rpx 0; }
.sign-block { width: 100%; }
.sign-pad-area {
width: 100%;
min-height: 200rpx;
background: repeating-conic-gradient(#f0f0f0 0% 25%, #fafafa 0% 50%) 50% / 24rpx 24rpx;
border-radius: 12rpx;
border: 2rpx dashed #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
padding: 16rpx;
box-sizing: border-box;
}
.sign-preview {
width: 100%;
max-width: 520rpx;
height: 200rpx;
}
.sign-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.sign-tip { font-size: 26rpx; color: #6acdbb; }
.img-row, .img-list { display: flex; flex-wrap: wrap; gap: 24rpx; }
.thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; }
.upload-group { margin-bottom: 30rpx; padding-bottom: 20rpx; border-bottom: 1rpx solid #f5f5f5; }
.upload-label { font-size: 28rpx; color: #303133; margin-bottom: 20rpx; }
.required { color: #fa3534; margin-right: 8rpx; font-weight: bold; }
.upload-btn {
width: 160rpx;
height: 160rpx;
background: #f4f5f7;
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2rpx dashed #dcdfe6;
}
.upload-txt { font-size: 24rpx; color: #999; margin-top: 12rpx; }
.summary-card { background: #f7f8fa; border-radius: 12rpx; padding: 24rpx; margin-bottom: 40rpx; }
.summary-row { display: flex; padding: 12rpx 0; font-size: 28rpx; }
.summary-row .k { width: 180rpx; color: #86909c; flex-shrink: 0; }
.summary-row .v { flex: 1; color: #1d2129; }
.footer-actions {
display: flex;
flex-wrap: wrap;
padding: 40rpx 24rpx 80rpx;
gap: 24rpx;
background: transparent;
}
.btn-draft { flex: 1; min-width: 160rpx; }
.btn-prev { flex: 1; }
.btn-next, .btn-submit { flex: 2; }
.draft-name-box { padding: 40rpx 32rpx; background: #fff; }
.draft-name-title { display: block; font-size: 32rpx; font-weight: 600; margin-bottom: 24rpx; color: #1d2129; }
.draft-name-actions { display: flex; gap: 24rpx; margin-top: 32rpx; justify-content: flex-end; }
</style>