|
|
@@ -8,12 +8,13 @@
|
|
|
<view class="card-content">
|
|
|
<view class="info-row">
|
|
|
<text class="row-label">医药代表资质备案号</text>
|
|
|
- <input
|
|
|
+ <wd-input
|
|
|
v-model="model.qualificationRecordNumber"
|
|
|
class="row-input"
|
|
|
type="text"
|
|
|
placeholder="请输入备案号"
|
|
|
- placeholder-class="input-placeholder"
|
|
|
+ align-right
|
|
|
+ clearable
|
|
|
/>
|
|
|
</view>
|
|
|
|
|
|
@@ -21,17 +22,26 @@
|
|
|
<text class="section-label">资质证书</text>
|
|
|
|
|
|
<view class="upload-wrapper">
|
|
|
- <view v-if="certificateUrl" class="image-box" @click="previewCertificate">
|
|
|
- <image class="certificate-image" :src="certificateUrl" mode="aspectFill" />
|
|
|
-
|
|
|
- <view class="delete-btn" @click.stop="removeCertificate">
|
|
|
+ <view
|
|
|
+ v-for="(url, index) in certificateList"
|
|
|
+ :key="`${url}-${index}`"
|
|
|
+ class="image-box"
|
|
|
+ @click="previewCertificate(index)"
|
|
|
+ >
|
|
|
+ <image class="certificate-image" :src="toFullUrl(url)" mode="aspectFill" />
|
|
|
+
|
|
|
+ <view class="delete-btn" @click.stop="removeCertificate(index)">
|
|
|
<text class="delete-text">×</text>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
- <view v-else class="upload-box" @click="chooseCertificate">
|
|
|
+ <view
|
|
|
+ v-if="certificateList.length < maxCount"
|
|
|
+ class="upload-box"
|
|
|
+ @click="chooseCertificate"
|
|
|
+ >
|
|
|
<text class="upload-plus">+</text>
|
|
|
- <text class="upload-text">选择图片</text>
|
|
|
+ <text class="upload-text">{{ uploading ? '上传中' : '选择图片' }}</text>
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
@@ -44,31 +54,53 @@ import { computed, ref } from 'vue'
|
|
|
|
|
|
import type { PersonalBusinessCardResponse } from '@/services/modules/minePage/personalCard/type'
|
|
|
|
|
|
+import { useUserStore } from '@/stores/modules/user'
|
|
|
+
|
|
|
+interface UploadResponse {
|
|
|
+ code: number
|
|
|
+ success: boolean
|
|
|
+ msg: string | null
|
|
|
+ data: {
|
|
|
+ url: string
|
|
|
+ } | null
|
|
|
+}
|
|
|
+
|
|
|
interface ProfessionalSkillFields {
|
|
|
- qualificationRecordNumber?: string
|
|
|
- qualificationsUrl?: string
|
|
|
+ qualificationRecordNumber?: string | null
|
|
|
+ qualificationsUrl?: string[] | null
|
|
|
}
|
|
|
|
|
|
-type ProfessionalSkillModel = PersonalBusinessCardResponse & ProfessionalSkillFields
|
|
|
+type Model = PersonalBusinessCardResponse & ProfessionalSkillFields
|
|
|
|
|
|
const props = withDefaults(
|
|
|
defineProps<{
|
|
|
title?: string
|
|
|
- uploadApi?: (filePath: string) => Promise<string>
|
|
|
+ maxCount?: number
|
|
|
}>(),
|
|
|
{
|
|
|
title: '专业技能',
|
|
|
- uploadApi: undefined,
|
|
|
+ maxCount: 9,
|
|
|
}
|
|
|
)
|
|
|
|
|
|
-const model = defineModel<ProfessionalSkillModel>({
|
|
|
- required: true,
|
|
|
-})
|
|
|
+const model = defineModel<Model>({ required: true })
|
|
|
|
|
|
+const userStore = useUserStore()
|
|
|
const uploading = ref(false)
|
|
|
+const baseUrl = (import.meta.env.VITE_BASE_API as string).replace(/\/$/, '')
|
|
|
|
|
|
-const certificateUrl = computed(() => model.value.qualificationsUrl ?? '')
|
|
|
+const certificateList = computed<string[]>({
|
|
|
+ get: () => model.value.qualificationsUrl ?? [],
|
|
|
+ set: (value) => {
|
|
|
+ model.value.qualificationsUrl = value
|
|
|
+ },
|
|
|
+})
|
|
|
+
|
|
|
+function toFullUrl(url: string) {
|
|
|
+ if (!url) return ''
|
|
|
+ if (/^(https?:|wxfile:|blob:|data:)/.test(url)) return url
|
|
|
+ return `${baseUrl}${url.startsWith('/') ? url : `/${url}`}`
|
|
|
+}
|
|
|
|
|
|
async function chooseCertificate() {
|
|
|
if (uploading.value) return
|
|
|
@@ -77,26 +109,17 @@ async function chooseCertificate() {
|
|
|
const filePath = await chooseImage()
|
|
|
|
|
|
uploading.value = true
|
|
|
- uni.showLoading({
|
|
|
- title: '上传中',
|
|
|
- mask: true,
|
|
|
- })
|
|
|
-
|
|
|
- const url = props.uploadApi ? await props.uploadApi(filePath) : filePath
|
|
|
+ uni.showLoading({ title: '上传中', mask: true })
|
|
|
|
|
|
- model.value.qualificationsUrl = url
|
|
|
+ const url = await upload(filePath)
|
|
|
+ certificateList.value = [...certificateList.value, url]
|
|
|
|
|
|
- uni.showToast({
|
|
|
- title: '上传成功',
|
|
|
- icon: 'success',
|
|
|
- })
|
|
|
+ uni.showToast({ title: '上传成功', icon: 'success' })
|
|
|
} catch (error) {
|
|
|
- if (error instanceof Error && error.message === 'choose image canceled') return
|
|
|
+ if (error instanceof Error && error.message === 'cancel') return
|
|
|
|
|
|
- uni.showToast({
|
|
|
- title: '上传失败',
|
|
|
- icon: 'none',
|
|
|
- })
|
|
|
+ console.error('[ProfessionalSkillCard] upload failed:', error)
|
|
|
+ uni.showToast({ title: '上传失败', icon: 'none' })
|
|
|
} finally {
|
|
|
uploading.value = false
|
|
|
uni.hideLoading()
|
|
|
@@ -109,74 +132,73 @@ function chooseImage(): Promise<string> {
|
|
|
count: 1,
|
|
|
sizeType: ['compressed'],
|
|
|
sourceType: ['album', 'camera'],
|
|
|
- success(res) {
|
|
|
- const filePath = res.tempFilePaths[0]
|
|
|
-
|
|
|
- if (!filePath) {
|
|
|
- reject(new Error('empty file path'))
|
|
|
- return
|
|
|
- }
|
|
|
+ success: (res) => {
|
|
|
+ const filePath = Array.isArray(res.tempFilePaths) ? res.tempFilePaths[0] : res.tempFilePaths
|
|
|
+ filePath ? resolve(filePath) : reject(new Error('empty file path'))
|
|
|
+ },
|
|
|
+ fail: () => reject(new Error('cancel')),
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
|
|
|
- resolve(filePath)
|
|
|
+function upload(filePath: string): Promise<string> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ uni.uploadFile({
|
|
|
+ url: `${baseUrl}/admin/api/file/upload/mobile`,
|
|
|
+ filePath,
|
|
|
+ name: 'file',
|
|
|
+ header: {
|
|
|
+ Authorization: `Bearer ${userStore.access_token}`,
|
|
|
},
|
|
|
- fail() {
|
|
|
- reject(new Error('choose image canceled'))
|
|
|
+ success: (res) => {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(res.data) as UploadResponse
|
|
|
+
|
|
|
+ if (
|
|
|
+ res.statusCode < 200 ||
|
|
|
+ res.statusCode >= 300 ||
|
|
|
+ !parsed.success ||
|
|
|
+ parsed.code !== 0 ||
|
|
|
+ !parsed.data?.url
|
|
|
+ ) {
|
|
|
+ reject(new Error(parsed.msg || 'upload failed'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ resolve(parsed.data.url)
|
|
|
+ } catch {
|
|
|
+ reject(new Error('parse upload response failed'))
|
|
|
+ }
|
|
|
},
|
|
|
+ fail: reject,
|
|
|
})
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-function previewCertificate() {
|
|
|
- if (!certificateUrl.value) return
|
|
|
+function previewCertificate(index: number) {
|
|
|
+ const urls = certificateList.value.map(toFullUrl)
|
|
|
|
|
|
uni.previewImage({
|
|
|
- urls: [certificateUrl.value],
|
|
|
- current: certificateUrl.value,
|
|
|
+ urls,
|
|
|
+ current: urls[index],
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-function removeCertificate() {
|
|
|
- model.value.qualificationsUrl = ''
|
|
|
+function removeCertificate(index: number) {
|
|
|
+ certificateList.value = certificateList.value.filter((_, i) => i !== index)
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
.professional-skill-card {
|
|
|
- margin-top: 28rpx;
|
|
|
- position: relative;
|
|
|
- margin-bottom: 28rpx;
|
|
|
- padding: 30rpx 26rpx 30rpx;
|
|
|
- box-sizing: border-box;
|
|
|
- overflow: hidden;
|
|
|
+ margin: 28rpx 0;
|
|
|
+ padding: 30rpx 26rpx;
|
|
|
border-radius: 30rpx;
|
|
|
- background: #ffffff;
|
|
|
+ background: #fff;
|
|
|
box-shadow: 0 18rpx 48rpx rgba(22, 38, 67, 0.1);
|
|
|
-
|
|
|
- &::before {
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 28rpx;
|
|
|
- right: 28rpx;
|
|
|
- height: 1rpx;
|
|
|
- content: '';
|
|
|
- background: rgba(255, 255, 255, 0.9);
|
|
|
- }
|
|
|
-
|
|
|
- &::after {
|
|
|
- position: absolute;
|
|
|
- top: -96rpx;
|
|
|
- right: -78rpx;
|
|
|
- width: 260rpx;
|
|
|
- height: 260rpx;
|
|
|
- content: '';
|
|
|
- border-radius: 50%;
|
|
|
- background: linear-gradient(135deg, rgba(47, 132, 255, 0.18) 0%, rgba(47, 132, 255, 0.08) 100%);
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
.card-title {
|
|
|
- position: relative;
|
|
|
- z-index: 1;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
margin-bottom: 18rpx;
|
|
|
@@ -187,86 +209,73 @@ function removeCertificate() {
|
|
|
height: 34rpx;
|
|
|
margin-right: 14rpx;
|
|
|
border-radius: 999rpx;
|
|
|
- background: linear-gradient(180deg, #2f84ff 0%, #57a3ff 100%);
|
|
|
+ background: linear-gradient(180deg, #2f84ff, #57a3ff);
|
|
|
}
|
|
|
|
|
|
.title-text {
|
|
|
font-size: 34rpx;
|
|
|
font-weight: 700;
|
|
|
- line-height: 48rpx;
|
|
|
color: #1f2a44;
|
|
|
}
|
|
|
|
|
|
.card-content {
|
|
|
- position: relative;
|
|
|
- z-index: 1;
|
|
|
overflow: hidden;
|
|
|
border-radius: 22rpx;
|
|
|
- background: #ffffff;
|
|
|
- box-shadow:
|
|
|
- 0 18rpx 44rpx rgba(30, 64, 120, 0.08),
|
|
|
- 0 6rpx 18rpx rgba(30, 64, 120, 0.04);
|
|
|
+ background: #fff;
|
|
|
}
|
|
|
|
|
|
.info-row {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- min-height: 88rpx;
|
|
|
+ height: 88rpx;
|
|
|
padding: 0 24rpx;
|
|
|
+ border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
|
|
|
box-sizing: border-box;
|
|
|
- border-bottom: 1rpx solid rgba(31, 63, 120, 0.08);
|
|
|
}
|
|
|
|
|
|
.row-label {
|
|
|
flex-shrink: 0;
|
|
|
font-size: 30rpx;
|
|
|
- font-weight: 400;
|
|
|
- line-height: 42rpx;
|
|
|
color: #2f3545;
|
|
|
}
|
|
|
|
|
|
.row-input {
|
|
|
flex: 1;
|
|
|
min-width: 0;
|
|
|
- height: 88rpx;
|
|
|
margin-left: 24rpx;
|
|
|
font-size: 30rpx;
|
|
|
- font-weight: 500;
|
|
|
- line-height: 88rpx;
|
|
|
- color: #2f3545;
|
|
|
text-align: right;
|
|
|
}
|
|
|
|
|
|
-.input-placeholder {
|
|
|
- color: #b3bdcc;
|
|
|
-}
|
|
|
-
|
|
|
.certificate-section {
|
|
|
padding: 22rpx 24rpx 28rpx;
|
|
|
- box-sizing: border-box;
|
|
|
}
|
|
|
|
|
|
.section-label {
|
|
|
display: block;
|
|
|
margin-bottom: 24rpx;
|
|
|
font-size: 30rpx;
|
|
|
- font-weight: 400;
|
|
|
- line-height: 42rpx;
|
|
|
color: #2f3545;
|
|
|
}
|
|
|
|
|
|
.upload-wrapper {
|
|
|
display: flex;
|
|
|
- align-items: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 18rpx;
|
|
|
}
|
|
|
|
|
|
-.upload-box,
|
|
|
-.image-box {
|
|
|
+.image-box,
|
|
|
+.upload-box {
|
|
|
position: relative;
|
|
|
width: 220rpx;
|
|
|
height: 220rpx;
|
|
|
- overflow: hidden;
|
|
|
border-radius: 16rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.certificate-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
}
|
|
|
|
|
|
.upload-box {
|
|
|
@@ -274,33 +283,26 @@ function removeCertificate() {
|
|
|
flex-direction: column;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
- background: #f3f4f6;
|
|
|
+ background-color: #f3f4f6;
|
|
|
}
|
|
|
|
|
|
.upload-plus {
|
|
|
- height: 64rpx;
|
|
|
font-size: 64rpx;
|
|
|
- font-weight: 300;
|
|
|
line-height: 64rpx;
|
|
|
color: #60656f;
|
|
|
}
|
|
|
|
|
|
.upload-text {
|
|
|
- margin-top: 24rpx;
|
|
|
+ margin-top: 20rpx;
|
|
|
font-size: 28rpx;
|
|
|
- line-height: 40rpx;
|
|
|
color: #60656f;
|
|
|
}
|
|
|
|
|
|
-.certificate-image {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
-}
|
|
|
-
|
|
|
.delete-btn {
|
|
|
position: absolute;
|
|
|
top: 8rpx;
|
|
|
right: 8rpx;
|
|
|
+ z-index: 2;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
@@ -313,6 +315,6 @@ function removeCertificate() {
|
|
|
.delete-text {
|
|
|
font-size: 34rpx;
|
|
|
line-height: 34rpx;
|
|
|
- color: #ffffff;
|
|
|
+ color: #fff;
|
|
|
}
|
|
|
</style>
|