1. 诊所管理

This commit is contained in:
李琦
2026-06-18 15:05:14 +08:00
parent d26923fd73
commit e202a1167d
8 changed files with 328 additions and 51 deletions

View File

@@ -0,0 +1,22 @@
let pending = null
export function requestGalleryPick() {
return new Promise((resolve, reject) => {
pending = { resolve, reject }
uni.$emit('salesperson:open-gallery')
})
}
export function resolveGalleryPick(url) {
if (pending) {
pending.resolve(url)
pending = null
}
}
export function cancelGalleryPick() {
if (pending) {
pending.reject(new Error('cancel'))
pending = null
}
}

View File

@@ -1,45 +1,26 @@
import { prepareImagePath } from '@/common/js/image-compress.js'
import { uploadToOss } from '@/common/js/oss-upload.js'
import { requestGalleryPick } from '@/subPackages/sub_salesperson/common/imageGalleryBridge.js'
/**
* 选择并上传图片(压缩 + OSS 直传)
* @param {{ count?: number }} options count>1 时依次上传多张,返回 url 数组
*/
export function pickAndUploadImage(options = {}) {
const count = Math.min(Math.max(options.count || 1, 1), 9)
function chooseImageFromDevice(count = 1) {
return new Promise((resolve, reject) => {
uni.chooseImage({
count,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
uni.showLoading({ title: '上传中...', mask: true })
try {
const files = res.tempFiles || []
const paths = files.length
? files.map((f) => f.path)
: (res.tempFilePaths || [])
const urls = []
for (const path of paths) {
const prepared = await prepareImagePath({
path,
size: files.find((f) => f.path === path)?.size,
})
if (!prepared) continue
const result = await uploadToOss({ filePath: prepared.path })
urls.push(result.url)
}
uni.hideLoading()
if (!urls.length) {
reject(new Error('上传失败'))
return
}
resolve(count === 1 ? urls[0] : urls)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
reject(e)
}
uni.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (sheet) => {
const sourceType = sheet.tapIndex === 0 ? ['camera'] : ['album']
uni.chooseImage({
count,
sizeType: ['original', 'compressed'],
sourceType,
success: (res) => resolve(res),
fail: (err) => {
if (err && err.errMsg && err.errMsg.indexOf('cancel') !== -1) {
reject(new Error('cancel'))
return
}
reject(err)
},
})
},
fail: (err) => {
if (err && err.errMsg && err.errMsg.indexOf('cancel') !== -1) {
@@ -52,6 +33,50 @@ export function pickAndUploadImage(options = {}) {
})
}
async function uploadChosenImages(res, count = 1) {
uni.showLoading({ title: '上传中...', mask: true })
try {
const files = res.tempFiles || []
const paths = files.length ? files.map((f) => f.path) : (res.tempFilePaths || [])
const urls = []
for (const path of paths) {
const prepared = await prepareImagePath({
path,
size: files.find((f) => f.path === path)?.size,
})
if (!prepared) continue
const fileSize = files.find((f) => f.path === path)?.size || 0
const result = await uploadToOss({ filePath: prepared.path, fileSize })
urls.push(result.url)
}
uni.hideLoading()
if (!urls.length) throw new Error('上传失败')
return count === 1 ? urls[0] : urls
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
throw e
}
}
/**
* 打开图库选择(由页面内 link 单独触发)
*/
export function openGalleryPick() {
return requestGalleryPick()
}
/**
* 选择并上传图片(压缩 + OSS 直传)
* @param {{ count?: number }} options count>1 时依次上传多张,返回 url 数组
*/
export function pickAndUploadImage(options = {}) {
const count = Math.min(Math.max(options.count || 1, 1), 9)
return chooseImageFromDevice(count)
.then((res) => uploadChosenImages(res, count))
.catch((err) => Promise.reject(err))
}
function getUserDataPath() {
if (typeof wx !== 'undefined' && wx.env && wx.env.USER_DATA_PATH) {
return wx.env.USER_DATA_PATH
@@ -177,6 +202,7 @@ export function createDefaultDoctorInputForm() {
card_up: '',
card_down: '',
sign_image: '',
register_price: 0,
store_id: 0,
store_ids: [],
}

View File

@@ -0,0 +1,181 @@
<template>
<u-popup v-model="visible" mode="bottom" border-radius="24" height="70%">
<view class="gallery-panel">
<view class="gallery-header">
<text class="gallery-title">从图库选择</text>
<text class="gallery-close" @click="close">关闭</text>
</view>
<scroll-view scroll-y class="gallery-scroll" @scrolltolower="loadMore">
<view v-if="loading && !items.length" class="gallery-loading">加载中...</view>
<view v-else-if="!items.length" class="gallery-empty">图库暂无图片</view>
<view v-else class="gallery-grid">
<view
v-for="item in items"
:key="item.id"
class="gallery-item"
@click="selectItem(item)"
>
<image :src="item.url" class="gallery-thumb" mode="aspectFill" />
<text class="gallery-size">{{ item.file_size_text || '' }}</text>
<text v-if="item.created_at" class="gallery-time">{{ item.created_at }}</text>
</view>
</view>
<view v-if="loadingMore" class="gallery-loading">加载更多...</view>
</scroll-view>
</view>
</u-popup>
</template>
<script>
import { req } from '@/common/js/index.js'
import { unwrapClinicRes } from '@/api/clinicAdmin.js'
import { cancelGalleryPick, resolveGalleryPick } from '@/subPackages/sub_salesperson/common/imageGalleryBridge.js'
const SALESPERSON_PREFIX = '/newApi/salesperson-file-gallery/'
const PLATFORM_PREFIX = '/newApi/platform-admin-file-gallery/'
function getGalleryListApi(params) {
const mode = uni.getStorageSync('loginMode')
const prefix = mode === 'platform_admin' ? PLATFORM_PREFIX : SALESPERSON_PREFIX
const headerKey = mode === 'platform_admin' ? '_platformAdmin' : '_salesperson'
return req.request({
url: `${prefix}list`,
method: 'GET',
data: params,
header: { [headerKey]: '1' },
})
}
export default {
name: 'ImageGalleryPicker',
data() {
return {
visible: false,
loading: false,
loadingMore: false,
items: [],
page: 1,
pageSize: 24,
total: 0,
}
},
created() {
uni.$on('salesperson:open-gallery', this.open)
},
beforeDestroy() {
uni.$off('salesperson:open-gallery', this.open)
},
methods: {
open() {
this.visible = true
this.page = 1
this.items = []
this.fetchList(true)
},
close() {
this.visible = false
cancelGalleryPick()
},
async fetchList(reset) {
if (reset) {
this.loading = true
} else {
this.loadingMore = true
}
try {
const res = await getGalleryListApi({
page: this.page,
page_size: this.pageSize,
pid: 0,
type: 0,
})
const data = unwrapClinicRes(res) || {}
const list = data.items || []
this.total = data.total || 0
this.items = reset ? list : [...this.items, ...list]
} catch (e) {
uni.showToast({ title: '图库加载失败', icon: 'none' })
} finally {
this.loading = false
this.loadingMore = false
}
},
loadMore() {
if (this.loadingMore || this.items.length >= this.total) return
this.page += 1
this.fetchList(false)
},
selectItem(item) {
if (!item || !item.url) return
this.visible = false
resolveGalleryPick(item.url)
},
},
}
</script>
<style scoped lang="scss">
.gallery-panel {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.gallery-title {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
}
.gallery-close {
font-size: 28rpx;
color: #86909c;
}
.gallery-scroll {
flex: 1;
height: 0;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
padding: 24rpx;
}
.gallery-item {
border-radius: 12rpx;
overflow: hidden;
background: #f5f5f5;
}
.gallery-thumb {
width: 100%;
height: 200rpx;
display: block;
}
.gallery-size {
display: block;
font-size: 22rpx;
color: #86909c;
text-align: center;
padding: 8rpx 0 0;
}
.gallery-time {
display: block;
font-size: 20rpx;
color: #86909c;
text-align: center;
padding: 4rpx 0 12rpx;
}
.gallery-loading,
.gallery-empty {
text-align: center;
padding: 80rpx 0;
color: #86909c;
font-size: 28rpx;
}
</style>

View File

@@ -10,6 +10,7 @@
<view class="clinic-body" :style="bodyStyle">
<slot />
</view>
<image-gallery-picker />
<u-tabbar
v-if="showTabbar"
:value="tabIndex"
@@ -22,10 +23,12 @@
<script>
import businessMixin from '@/subPackages/sub_salesperson/common/businessMixin.js';
import ImageGalleryPicker from '@/subPackages/sub_salesperson/components/ImageGalleryPicker.vue';
export default {
name: 'BusinessPageLayout',
mixins: [businessMixin],
components: { ImageGalleryPicker },
props: {
title: { type: String, default: '' },
showBack: { type: Boolean, default: false },

View File

@@ -28,6 +28,7 @@
<text class="upload-txt">上传头像</text>
</view>
</view>
<text class="gallery-link" @click="pickFromGallery('avatar')">从图库选择</text>
</u-form-item>
<u-form-item label="职称" right-icon="arrow-right">
<u-input :value="selectedTitleLabel" type="select" @click="showTitle = true" placeholder="请选择" />
@@ -96,6 +97,7 @@
<text class="upload-txt">上传签名图</text>
</view>
</view>
<text class="gallery-link" @click="pickFromGallery('sign_image')">从图库选择</text>
</view>
</u-form-item>
<u-form-item label="擅长" :required="true">
@@ -104,10 +106,14 @@
<u-form-item label="简介" :required="true">
<u-input v-model="form.intro" type="textarea" placeholder="请输入简介" />
</u-form-item>
<u-form-item label="挂号费">
<u-input v-model="form.register_price" type="digit" placeholder="默认0自动开启挂号" />
</u-form-item>
</view>
<view v-show="currentStep === 1" class="step-content">
<view class="section-title">证照资料</view>
<view class="upload-grid">
<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">
@@ -117,6 +123,8 @@
<text class="upload-txt">上传</text>
</view>
</view>
<text class="gallery-link" @click="pickFromGallery(item.key)">从图库选择</text>
</view>
</view>
</view>
@@ -128,6 +136,7 @@
<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.register_price != null && form.register_price !== '' ? form.register_price : '0' }}</text></view>
<view class="summary-row"><text class="k">签名</text><text class="v">{{ form.sign_image ? '已填写' : '-' }}</text></view>
</view>
</view>
@@ -164,6 +173,7 @@ import businessMixin from '@/subPackages/sub_salesperson/common/businessMixin.js
import { getInputBusinessContext } from '@/subPackages/sub_salesperson/common/context.js'
import {
pickAndUploadImage,
openGalleryPick,
uploadSignCanvasBase64,
uploadSignCanvasTempPath,
createDefaultDoctorInputForm,
@@ -347,6 +357,9 @@ export default {
uploadField(field) {
pickAndUploadImage().then((url) => { this.form[field] = url }).catch(() => {})
},
pickFromGallery(field) {
openGalleryPick().then((url) => { this.form[field] = url }).catch(() => {})
},
onSignCanvasSave(payload) {
this.showSignPad = false
const base64 = typeof payload === 'string' ? payload : (payload && payload.base64)
@@ -473,6 +486,7 @@ export default {
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,
@@ -544,6 +558,17 @@ export default {
gap: 12rpx;
}
.sign-tip { font-size: 26rpx; color: #6acdbb; }
.upload-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
padding-bottom: 20rpx;
}
.upload-grid .upload-group {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.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; }
@@ -561,6 +586,12 @@ export default {
border: 2rpx dashed #dcdfe6;
}
.upload-txt { font-size: 24rpx; color: #999; margin-top: 12rpx; }
.gallery-link {
display: inline-block;
margin-top: 12rpx;
font-size: 24rpx;
color: #6acdbb;
}
.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; }

View File

@@ -103,6 +103,7 @@
<text class="upload-txt">上传公章</text>
</view>
</view>
<text class="gallery-link" @click="pickFromGallery('see_rate')">从图库选择</text>
</view>
<view class="upload-group">
@@ -113,6 +114,7 @@
<u-icon name="plus" size="40" color="#999"></u-icon>
</view>
</view>
<text class="gallery-link" @click="pickCarouselFromGallery">从图库选择</text>
</view>
<view class="upload-group">
@@ -196,7 +198,7 @@ import RegionAddressPicker from '@/subPackages/sub_salesperson/components/Region
import PromoterPicker from '@/subPackages/sub_salesperson/components/PromoterPicker.vue'
import businessMixin from '@/subPackages/sub_salesperson/common/businessMixin.js'
import { getInputBusinessContext } from '@/subPackages/sub_salesperson/common/context.js'
import { pickAndUploadImage, createDefaultStoreInputForm } from '@/subPackages/sub_salesperson/common/inputFormHelpers.js'
import { pickAndUploadImage, openGalleryPick, createDefaultStoreInputForm } from '@/subPackages/sub_salesperson/common/inputFormHelpers.js'
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'
@@ -360,6 +362,12 @@ export default {
uploadField(field) {
pickAndUploadImage().then(url => { this.form[field] = url }).catch(() => {})
},
pickFromGallery(field) {
openGalleryPick().then(url => { this.form[field] = url }).catch(() => {})
},
pickCarouselFromGallery() {
openGalleryPick().then(url => { this.form.url.push(url) }).catch(() => {})
},
uploadCarousel() {
pickAndUploadImage().then(url => { this.form.url.push(url) }).catch(() => {})
},
@@ -516,21 +524,21 @@ export default {
.time-box { flex: 1; text-align: center; background: #f5f7fa; padding: 12rpx 0; border-radius: 8rpx; font-size: 28rpx; color: #606266; }
.time-sep { margin: 0 20rpx; color: #999; }
.clinic-type-cards { display: flex; gap: 20rpx; width: 100%; }
.clinic-type-cards { display: flex; gap: 12rpx; width: 100%; }
.clinic-type-card {
flex: 1;
padding: 24rpx 16rpx;
padding: 12rpx 10rpx;
text-align: center;
background: #f5f7fa;
border: 2rpx solid #e4e7ed;
border-radius: 12rpx;
border-radius: 8rpx;
box-sizing: border-box;
}
.clinic-type-card.active {
background: rgba(106, 205, 187, 0.08);
border-color: #6acdbb;
}
.clinic-type-label { font-size: 28rpx; color: #303133; }
.clinic-type-label { font-size: 24rpx; color: #303133; }
.clinic-type-card.active .clinic-type-label { color: #6acdbb; font-weight: 600; }
.switch-row {
display: flex;
@@ -550,6 +558,12 @@ export default {
.thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.05); }
.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; }
.gallery-link {
display: inline-block;
margin-top: 12rpx;
font-size: 24rpx;
color: #6acdbb;
}
.contract-btn { width: 100%; height: 100rpx; flex-direction: row; gap: 12rpx; background: rgba(106, 205, 187, 0.05); border-color: #6acdbb; }
/* 底部操作区 */