| @@ -0,0 +1,28 @@ | |||
| # 依赖 | |||
| node_modules/ | |||
| package-lock.json | |||
| # 构建产物 | |||
| dist/ | |||
| unpackage/ | |||
| # 本地环境 | |||
| .env.local | |||
| .env.*.local | |||
| # 日志 | |||
| *.log | |||
| npm-debug.log* | |||
| # 编辑器 | |||
| .idea/ | |||
| .vscode/ | |||
| *.suo | |||
| *.ntvs* | |||
| *.njsproj | |||
| *.sln | |||
| *.sw? | |||
| # 系统文件 | |||
| .DS_Store | |||
| Thumbs.db | |||
| @@ -0,0 +1,25 @@ | |||
| <script> | |||
| export default { | |||
| onLaunch: function() { | |||
| console.log('App Launch') | |||
| }, | |||
| onShow: function() { | |||
| console.log('App Show') | |||
| }, | |||
| onHide: function() { | |||
| console.log('App Hide') | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss"> | |||
| /* uview-plus 基础样式 */ | |||
| @import 'uview-plus/index.scss'; | |||
| /* 全局 box-sizing */ | |||
| page, view, text, image, input, textarea, button, form, scroll-view { | |||
| box-sizing: border-box; | |||
| } | |||
| /* 每个页面公共css */ | |||
| </style> | |||
| @@ -0,0 +1,16 @@ | |||
| const envConf = { | |||
| // 开发版-本地环境 | |||
| develop: { | |||
| BASE_URL: 'http://192.168.3.66:8361', | |||
| }, | |||
| // 体验版-测试环境 | |||
| trial: { | |||
| BASE_URL: 'http://localhost:8361', | |||
| }, | |||
| // 正式版-正式环境 | |||
| release: { | |||
| BASE_URL: 'https://api.pap.com', | |||
| } | |||
| } | |||
| export default envConf['develop']; | |||
| @@ -0,0 +1,20 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh-CN"> | |||
| <head> | |||
| <meta charset="UTF-8" /> | |||
| <script> | |||
| var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || | |||
| CSS.supports('top: constant(a)')) | |||
| document.write( | |||
| '<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + | |||
| (coverSupport ? ', viewport-fit=cover' : '') + '" />') | |||
| </script> | |||
| <title></title> | |||
| <!--preload-links--> | |||
| <!--app-context--> | |||
| </head> | |||
| <body> | |||
| <div id="app"><!--app-html--></div> | |||
| <script type="module" src="/main.js"></script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,39 @@ | |||
| import App from './App' | |||
| // #ifndef VUE3 | |||
| import Vue from 'vue' | |||
| import './uni.promisify.adaptor' | |||
| Vue.config.productionTip = false | |||
| App.mpType = 'app' | |||
| const app = new Vue({ | |||
| ...App | |||
| }) | |||
| app.$mount() | |||
| // #endif | |||
| // #ifdef VUE3 | |||
| import { createSSRApp } from 'vue' | |||
| import * as Pinia from 'pinia' | |||
| import uviewPlus from 'uview-plus' | |||
| import { initRequest } from './utils/request.js' | |||
| export function createApp() { | |||
| const app = createSSRApp(App) | |||
| app.use(uviewPlus, () => { | |||
| return { | |||
| httpIns: initRequest, | |||
| options: { | |||
| config: { | |||
| unit: 'rpx' | |||
| } | |||
| } | |||
| } | |||
| }) | |||
| return { | |||
| app, | |||
| Pinia | |||
| } | |||
| } | |||
| // #endif | |||
| @@ -0,0 +1,64 @@ | |||
| { | |||
| "name" : "pap_mini_pharmacy", | |||
| "appid" : "__UNI__PHARMA01", | |||
| "description" : "药房端小程序", | |||
| "versionName" : "1.0.0", | |||
| "versionCode" : "100", | |||
| "transformPx" : false, | |||
| "app-plus" : { | |||
| "usingComponents" : true, | |||
| "nvueStyleCompiler" : "uni-app", | |||
| "compilerVersion" : 3, | |||
| "splashscreen" : { | |||
| "alwaysShowBeforeRender" : true, | |||
| "waiting" : true, | |||
| "autoclose" : true, | |||
| "delay" : 0 | |||
| }, | |||
| "modules" : {}, | |||
| "distribute" : { | |||
| "android" : { | |||
| "permissions" : [ | |||
| "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>", | |||
| "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>", | |||
| "<uses-permission android:name=\"android.permission.VIBRATE\"/>", | |||
| "<uses-permission android:name=\"android.permission.READ_LOGS\"/>", | |||
| "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>", | |||
| "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>", | |||
| "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>", | |||
| "<uses-permission android:name=\"android.permission.CAMERA\"/>", | |||
| "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>", | |||
| "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>", | |||
| "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>", | |||
| "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>", | |||
| "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>", | |||
| "<uses-feature android:name=\"android.hardware.camera\"/>", | |||
| "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>" | |||
| ] | |||
| }, | |||
| "ios" : {}, | |||
| "sdkConfigs" : {} | |||
| } | |||
| }, | |||
| "quickapp" : {}, | |||
| "mp-weixin" : { | |||
| "appid" : "wx9325685c64a2a831", | |||
| "setting" : { | |||
| "urlCheck" : false | |||
| }, | |||
| "usingComponents" : true | |||
| }, | |||
| "mp-alipay" : { | |||
| "usingComponents" : true | |||
| }, | |||
| "mp-baidu" : { | |||
| "usingComponents" : true | |||
| }, | |||
| "mp-toutiao" : { | |||
| "usingComponents" : true | |||
| }, | |||
| "uniStatistics" : { | |||
| "enable" : false | |||
| }, | |||
| "vueVersion" : "3" | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| { | |||
| "name": "pap_mini_pharmacy", | |||
| "version": "1.0.0", | |||
| "description": "药房端", | |||
| "main": "main.js", | |||
| "scripts": { | |||
| "test": "echo \"Error: no test specified\" && exit 1" | |||
| }, | |||
| "keywords": [], | |||
| "author": "", | |||
| "license": "ISC", | |||
| "type": "commonjs", | |||
| "dependencies": { | |||
| "clipboard": "^2.0.11", | |||
| "dayjs": "^1.11.10", | |||
| "uview-plus": "^3.7.0" | |||
| } | |||
| } | |||
| @@ -0,0 +1,109 @@ | |||
| { | |||
| "easycom": { | |||
| "autoscan": true, | |||
| "custom": { | |||
| "^u--(.*)": "uview-plus/components/u-$1/u-$1.vue", | |||
| "^up-(.*)": "uview-plus/components/u-$1/u-$1.vue", | |||
| "^u-([^-].*)": "uview-plus/components/u-$1/u-$1.vue" | |||
| } | |||
| }, | |||
| "pages": [ | |||
| { | |||
| "path": "pages/index/index", | |||
| "style": { | |||
| "navigationBarTitleText": "" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/profile/profile", | |||
| "style": { | |||
| "navigationBarTitleText": "" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/content/content", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/verify/verify", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "实名认证" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/change-phone/change-phone", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "修改手机号" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/myinfo/myinfo", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "我的资料" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/sign/sign", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "签署协议" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/message/message", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "消息中心", | |||
| "enablePullDownRefresh": true | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/message/detail", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "消息详情" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/login/index", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "登录" | |||
| } | |||
| } | |||
| ], | |||
| "globalStyle": { | |||
| "navigationStyle": "custom", | |||
| "navigationBarTextStyle": "black", | |||
| "navigationBarTitleText": "", | |||
| "navigationBarBackgroundColor": "#FFFFFF", | |||
| "backgroundColor": "#F5F5F5" | |||
| }, | |||
| "tabBar": { | |||
| "color": "#787879", | |||
| "selectedColor": "#0F78E9", | |||
| "borderStyle": "black", | |||
| "backgroundColor": "#ffffff", | |||
| "list": [ | |||
| { | |||
| "pagePath": "pages/index/index", | |||
| "text": "项目", | |||
| "iconPath": "static/icon/home.png", | |||
| "selectedIconPath": "static/icon/home-active.png" | |||
| }, | |||
| { | |||
| "pagePath": "pages/profile/profile", | |||
| "text": "我的", | |||
| "iconPath": "static/icon/mine.png", | |||
| "selectedIconPath": "static/icon/mine-active.png" | |||
| } | |||
| ] | |||
| }, | |||
| "uniIdRouter": {} | |||
| } | |||
| @@ -0,0 +1,230 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <!-- 当前手机号 --> | |||
| <view class="current-phone"> | |||
| <view class="label">当前绑定手机号</view> | |||
| <view class="phone">{{ currentPhone ? maskedPhone : '未绑定' }}</view> | |||
| </view> | |||
| <!-- 绑定新手机号 --> | |||
| <view class="step-card"> | |||
| <view class="card-title">绑定新手机号</view> | |||
| <view class="form-group"> | |||
| <text class="form-label"><text class="required">*</text> 新手机号</text> | |||
| <u-input v-model="form.mobile" type="number" placeholder="请输入新手机号" maxlength="11" | |||
| border="surround" /> | |||
| </view> | |||
| <view class="form-group"> | |||
| <text class="form-label"><text class="required">*</text> 验证码</text> | |||
| <view class="code-row"> | |||
| <view class="code-input"> | |||
| <u-input v-model="form.code" type="number" placeholder="请输入验证码" maxlength="6" | |||
| border="surround" /> | |||
| </view> | |||
| <u-button :disabled="!!countdown" :text="countdownText" | |||
| size="normal" @click="handleSendCode" | |||
| color="#0E63E3" | |||
| :customStyle="{ width: '240rpx', flexShrink: 0 }" /> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <view class="btn-wrap"> | |||
| <u-button text="确认修改" :loading="submitting" @click="handleSubmit" | |||
| color="#0E63E3" size="large" /> | |||
| </view> | |||
| <!-- 温馨提示 --> | |||
| <view class="tips"> | |||
| <view class="tips-title">温馨提示</view> | |||
| <view class="tips-list"> | |||
| <view class="tips-item">修改手机号后,后续相关短信通知将发送到新手机号</view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref, computed } from 'vue' | |||
| import { onShow } from '@dcloudio/uni-app' | |||
| import { get, post } from '@/utils/request.js' | |||
| import { getUserInfo, setUserInfo } from '@/utils/cache.js' | |||
| const currentPhone = ref('') | |||
| const form = ref({ mobile: '', code: '' }) | |||
| const countdown = ref(0) | |||
| const submitting = ref(false) | |||
| let timer = null | |||
| const maskedPhone = computed(() => { | |||
| if (!currentPhone.value || currentPhone.value.length !== 11) return currentPhone.value || '' | |||
| return currentPhone.value.slice(0, 3) + ' **** ' + currentPhone.value.slice(-4) | |||
| }) | |||
| const countdownText = computed(() => { | |||
| return countdown.value > 0 ? `${countdown.value}s后重新获取` : '获取验证码' | |||
| }) | |||
| onShow(() => { | |||
| const info = getUserInfo() | |||
| currentPhone.value = info?.patient?.phone || '' | |||
| }) | |||
| const handleSendCode = async () => { | |||
| if (countdown.value > 0) return | |||
| if (!form.value.mobile || !/^1[3-9]\d{9}$/.test(form.value.mobile)) { | |||
| return uni.showToast({ title: '请输入正确的手机号', icon: 'none' }) | |||
| } | |||
| if (form.value.mobile === currentPhone.value) { | |||
| return uni.showToast({ title: '新手机号不能与当前手机号相同', icon: 'none' }) | |||
| } | |||
| try { | |||
| await post('/api/mp/sendSmsCode', { mobile: form.value.mobile, bizType: 'change_phone' }) | |||
| uni.showToast({ title: '验证码已发送', icon: 'success' }) | |||
| countdown.value = 60 | |||
| timer = setInterval(() => { | |||
| countdown.value-- | |||
| if (countdown.value <= 0) { | |||
| clearInterval(timer) | |||
| timer = null | |||
| } | |||
| }, 1000) | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } | |||
| } | |||
| const handleSubmit = async () => { | |||
| if (!form.value.mobile || !/^1[3-9]\d{9}$/.test(form.value.mobile)) { | |||
| return uni.showToast({ title: '请输入正确的手机号', icon: 'none' }) | |||
| } | |||
| if (!form.value.code || !/^\d{6}$/.test(form.value.code)) { | |||
| return uni.showToast({ title: '请输入6位验证码', icon: 'none' }) | |||
| } | |||
| submitting.value = true | |||
| try { | |||
| await post('/api/mp/changePhone', { mobile: form.value.mobile, code: form.value.code }) | |||
| // 刷新用户信息 | |||
| const res = await get('/api/mp/userinfo') | |||
| setUserInfo(res.data) | |||
| uni.showToast({ title: '修改成功', icon: 'success' }) | |||
| setTimeout(() => uni.navigateBack(), 1500) | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } finally { | |||
| submitting.value = false | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #f4f4f5; | |||
| padding: 32rpx; | |||
| padding-bottom: 60rpx; | |||
| } | |||
| .current-phone { | |||
| background: #fff; | |||
| border-radius: 8rpx; | |||
| padding: 40rpx; | |||
| text-align: center; | |||
| border: 1rpx solid #ebeef5; | |||
| margin-bottom: 32rpx; | |||
| .label { | |||
| font-size: 26rpx; | |||
| color: #909399; | |||
| margin-bottom: 16rpx; | |||
| } | |||
| .phone { | |||
| font-size: 48rpx; | |||
| font-weight: 600; | |||
| color: #303133; | |||
| letter-spacing: 4rpx; | |||
| } | |||
| } | |||
| .step-card { | |||
| background: #fff; | |||
| border-radius: 8rpx; | |||
| padding: 40rpx 32rpx; | |||
| border: 1rpx solid #ebeef5; | |||
| } | |||
| .card-title { | |||
| font-size: 32rpx; | |||
| font-weight: 600; | |||
| color: #303133; | |||
| margin-bottom: 32rpx; | |||
| } | |||
| .form-group { | |||
| margin-bottom: 32rpx; | |||
| &:last-child { | |||
| margin-bottom: 0; | |||
| } | |||
| } | |||
| .form-label { | |||
| font-size: 28rpx; | |||
| color: #606266; | |||
| margin-bottom: 16rpx; | |||
| display: block; | |||
| } | |||
| .required { | |||
| color: #3a85f0; | |||
| } | |||
| .code-row { | |||
| display: flex; | |||
| gap: 20rpx; | |||
| align-items: stretch; | |||
| .code-input { | |||
| flex: 1; | |||
| min-width: 0; | |||
| } | |||
| } | |||
| .btn-wrap { | |||
| padding-top: 48rpx; | |||
| } | |||
| .tips { | |||
| margin-top: 32rpx; | |||
| padding: 32rpx; | |||
| background: #fff; | |||
| border-radius: 8rpx; | |||
| border: 1rpx solid #ebeef5; | |||
| .tips-title { | |||
| font-size: 28rpx; | |||
| font-weight: 600; | |||
| color: #303133; | |||
| margin-bottom: 20rpx; | |||
| } | |||
| .tips-list { | |||
| padding-left: 8rpx; | |||
| } | |||
| .tips-item { | |||
| font-size: 26rpx; | |||
| color: #909399; | |||
| line-height: 1.8; | |||
| padding-left: 20rpx; | |||
| position: relative; | |||
| &::before { | |||
| content: '·'; | |||
| position: absolute; | |||
| left: 0; | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,58 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <view class="content-body" v-if="loaded"> | |||
| <mp-html :content="content" /> | |||
| </view> | |||
| <view v-else class="loading"> | |||
| <u-loading-icon /> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { onLoad } from '@dcloudio/uni-app' | |||
| import { get } from '@/utils/request.js' | |||
| import mpHtml from '@/uni_modules/mp-html/components/mp-html/mp-html.vue' | |||
| const content = ref('') | |||
| const loaded = ref(false) | |||
| onLoad((options) => { | |||
| const key = options?.key || '' | |||
| if (key) { | |||
| loadContent(key) | |||
| } | |||
| }) | |||
| const loadContent = async (key) => { | |||
| try { | |||
| const res = await get('/api/content', { key }) | |||
| if (res.data) { | |||
| content.value = res.data.content || '' | |||
| uni.setNavigationBarTitle({ title: res.data.title || '' }) | |||
| } | |||
| } catch (e) { | |||
| console.error('获取内容失败', e) | |||
| } finally { | |||
| loaded.value = true | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #fff; | |||
| } | |||
| .content-body { | |||
| padding: 32rpx; | |||
| } | |||
| .loading { | |||
| display: flex; | |||
| justify-content: center; | |||
| padding: 100rpx 0; | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,199 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <!-- 顶部横幅 --> | |||
| <image class="banner" src="/static/img/index-bg.png" mode="widthFix" /> | |||
| <!-- 正文内容区域 --> | |||
| <view class="content-area"> | |||
| <view v-if="content" class="letter"> | |||
| <view class="body"> | |||
| <mp-html :content="content" /> | |||
| </view> | |||
| </view> | |||
| <view v-else-if="!loaded" class="loading-text">加载中...</view> | |||
| <button class="join-btn" :class="btnClass" @tap="handleJoin">{{ btnText }}</button> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref, computed } from 'vue' | |||
| import { onShow } from '@dcloudio/uni-app' | |||
| import { get } from '@/utils/request.js' | |||
| import { getToken, getUserInfo, setUserInfo } from '@/utils/cache.js' | |||
| import mpHtml from '@/uni_modules/mp-html/components/mp-html/mp-html.vue' | |||
| const CONTENT_CACHE_KEY = 'cytx-index-content' | |||
| const content = ref('') | |||
| const loaded = ref(false) | |||
| const userInfo = ref(null) | |||
| const isLoggedIn = ref(false) | |||
| // 患者状态 | |||
| const patient = computed(() => userInfo.value?.patient || null) | |||
| const isAuthed = computed(() => patient.value?.auth_status === 1) | |||
| const patientStatus = computed(() => patient.value?.status ?? null) | |||
| // 按钮文案和样式 | |||
| const btnText = computed(() => { | |||
| if (!isLoggedIn.value) return '加入项目' | |||
| if (!isAuthed.value) return '加入项目' | |||
| if (patientStatus.value === null || patientStatus.value === undefined) return '加入项目' | |||
| if (patientStatus.value === -1) return '加入项目' | |||
| if (patientStatus.value === 1) return '已加入' | |||
| if (patientStatus.value === 0) return '已申请,审核中' | |||
| if (patientStatus.value === 2) return '已拒绝,重新申请' | |||
| return '加入项目' | |||
| }) | |||
| const btnClass = computed(() => { | |||
| if (!isLoggedIn.value || !isAuthed.value) return '' | |||
| if (patientStatus.value === 1) return 'joined' | |||
| if (patientStatus.value === 0) return 'pending' | |||
| if (patientStatus.value === 2) return 'rejected' | |||
| return '' | |||
| }) | |||
| const loadContent = async () => { | |||
| // 先读缓存 | |||
| try { | |||
| const cached = uni.getStorageSync(CONTENT_CACHE_KEY) | |||
| if (cached) content.value = cached | |||
| } catch (e) {} | |||
| // 再请求最新 | |||
| try { | |||
| const res = await get('/api/content', { key: 'index_content' }) | |||
| if (res.data && res.data.content) { | |||
| content.value = res.data.content | |||
| uni.setStorageSync(CONTENT_CACHE_KEY, res.data.content) | |||
| } | |||
| } catch (e) { | |||
| console.error('获取首页内容失败', e) | |||
| } finally { | |||
| loaded.value = true | |||
| } | |||
| } | |||
| const refreshUserState = async () => { | |||
| isLoggedIn.value = !!getToken() | |||
| userInfo.value = getUserInfo() | |||
| if (isLoggedIn.value) { | |||
| try { | |||
| const res = await get('/api/mp/userinfo') | |||
| userInfo.value = res.data | |||
| setUserInfo(res.data) | |||
| } catch (e) {} | |||
| } | |||
| } | |||
| const handleJoin = () => { | |||
| // 未登录 -> 去登录 | |||
| if (!isLoggedIn.value) { | |||
| uni.navigateTo({ url: '/pages/login/index' }) | |||
| return | |||
| } | |||
| // 未实名 -> 提示去认证 | |||
| if (!isAuthed.value) { | |||
| uni.showModal({ | |||
| title: '提示', | |||
| content: '请先完成实名认证', | |||
| confirmText: '去认证', | |||
| success: (res) => { | |||
| if (res.confirm) uni.navigateTo({ url: '/pages/verify/verify' }) | |||
| } | |||
| }) | |||
| return | |||
| } | |||
| // 审核通过 -> 不可点击 | |||
| if (patientStatus.value === 1) return | |||
| // 待审核 -> 不可点击 | |||
| if (patientStatus.value === 0) return | |||
| // 已拒绝 -> 跳转资料页重新提交 | |||
| if (patientStatus.value === 2) { | |||
| uni.navigateTo({ url: '/pages/myinfo/myinfo' }) | |||
| return | |||
| } | |||
| // 已实名但未提交资料(status=-1)-> 去提交资料 | |||
| uni.navigateTo({ url: '/pages/myinfo/myinfo' }) | |||
| } | |||
| onShow(() => { | |||
| if (!loaded.value) loadContent() | |||
| refreshUserState() | |||
| }) | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #f0f0f0; | |||
| padding-bottom: 20rpx; | |||
| } | |||
| .banner { | |||
| width: 100%; | |||
| display: block; | |||
| } | |||
| .content-area { | |||
| background: #fff; | |||
| border-radius: 24rpx; | |||
| margin: -30% 24rpx 0; | |||
| position: relative; | |||
| z-index: 1; | |||
| padding: 56rpx 40rpx 32rpx; | |||
| } | |||
| .letter { | |||
| .body { | |||
| font-size: 28rpx; | |||
| color: #606266; | |||
| line-height: 2; | |||
| text-align: justify; | |||
| } | |||
| } | |||
| .loading-text { | |||
| text-align: center; | |||
| color: #909399; | |||
| padding: 40rpx 0; | |||
| } | |||
| .join-btn { | |||
| display: block; | |||
| width: 100%; | |||
| margin: 56rpx 0 0; | |||
| height: 88rpx; | |||
| line-height: 88rpx; | |||
| background: linear-gradient(135deg, #1890ff, #096dd9); | |||
| color: #fff; | |||
| border: none; | |||
| border-radius: 44rpx; | |||
| font-size: 32rpx; | |||
| font-weight: 500; | |||
| letter-spacing: 4rpx; | |||
| padding: 0; | |||
| &::after { | |||
| border: none; | |||
| } | |||
| &.joined { | |||
| background: #d9d9d9; | |||
| color: #999; | |||
| letter-spacing: 2rpx; | |||
| } | |||
| &.pending { | |||
| background: linear-gradient(135deg, #faad14, #d48806); | |||
| letter-spacing: 2rpx; | |||
| } | |||
| &.rejected { | |||
| background: linear-gradient(135deg, #ff4d4f, #cf1322); | |||
| letter-spacing: 2rpx; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,136 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <view class="logo-area"> | |||
| <image class="logo" src="/static/logo.png" mode="aspectFit" /> | |||
| <text class="title">肠愈同行</text> | |||
| <text class="subtitle">患者关爱</text> | |||
| </view> | |||
| <view class="btn-area"> | |||
| <button class="login-btn" @tap="handleLogin" :loading="loading"> | |||
| 微信一键登录 | |||
| </button> | |||
| <view class="tip">登录即表示同意 | |||
| <text class="link" @tap="goPrivacy">《隐私协议》</text> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { post } from '@/utils/request.js' | |||
| import { setToken, setUserInfo } from '@/utils/cache.js' | |||
| const loading = ref(false) | |||
| const handleLogin = async () => { | |||
| if (loading.value) return | |||
| loading.value = true | |||
| try { | |||
| const code = await new Promise((resolve, reject) => { | |||
| uni.login({ | |||
| provider: 'weixin', | |||
| success: (res) => resolve(res.code), | |||
| fail: () => reject({ msg: '微信登录失败' }) | |||
| }) | |||
| }) | |||
| const res = await post('/api/mp/login', { code }) | |||
| setToken(res.data.token) | |||
| setUserInfo(res.data.userInfo) | |||
| uni.showToast({ title: '登录成功', icon: 'success' }) | |||
| setTimeout(() => { | |||
| const pages = getCurrentPages() | |||
| if (pages.length > 1) { | |||
| uni.navigateBack() | |||
| } else { | |||
| uni.switchTab({ url: '/pages/profile/profile' }) | |||
| } | |||
| }, 500) | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } finally { | |||
| loading.value = false | |||
| } | |||
| } | |||
| const goPrivacy = () => { | |||
| uni.navigateTo({ url: '/pages/content/content?key=privacy_policy' }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #fff; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| padding-top: 25vh; | |||
| } | |||
| .logo-area { | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| margin-bottom: 80rpx; | |||
| .logo { | |||
| width: 180rpx; | |||
| height: 180rpx; | |||
| border-radius: 24rpx; | |||
| margin-bottom: 32rpx; | |||
| } | |||
| .title { | |||
| font-size: 44rpx; | |||
| font-weight: 600; | |||
| color: #303133; | |||
| margin-bottom: 8rpx; | |||
| } | |||
| .subtitle { | |||
| font-size: 26rpx; | |||
| color: #b0b3b8; | |||
| letter-spacing: 2rpx; | |||
| } | |||
| } | |||
| .btn-area { | |||
| width: 100%; | |||
| padding: 0 64rpx; | |||
| box-sizing: border-box; | |||
| .login-btn { | |||
| width: 100%; | |||
| height: 88rpx; | |||
| line-height: 88rpx; | |||
| background: #0F78E9; | |||
| color: #fff; | |||
| font-size: 32rpx; | |||
| border-radius: 44rpx; | |||
| border: none; | |||
| &::after { | |||
| border: none; | |||
| } | |||
| &:active { | |||
| opacity: 0.85; | |||
| } | |||
| } | |||
| .tip { | |||
| text-align: center; | |||
| font-size: 24rpx; | |||
| color: #909399; | |||
| margin-top: 32rpx; | |||
| .link { | |||
| color: #0F78E9; | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,172 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <view v-if="msg" class="card"> | |||
| <!-- 顶部:图标 + 标题 + 标签 --> | |||
| <view class="header-row"> | |||
| <text class="header-icon">📢</text> | |||
| <text class="header-title">审核通知</text> | |||
| <view class="tag" :class="msg.type === 1 ? 'tag-success' : 'tag-fail'"> | |||
| {{ msg.type === 1 ? '已通过' : '已驳回' }} | |||
| </view> | |||
| </view> | |||
| <!-- 大标题 --> | |||
| <view class="main-title">{{ msg.type === 1 ? '您提交的资料已通过审核' : '您提交的资料未通过审核' }}</view> | |||
| <view class="time">{{ msg.create_time }}</view> | |||
| <!-- 正文 --> | |||
| <view class="body"> | |||
| <text class="greeting">尊敬的{{ msg.patient_name || '' }}:</text> | |||
| <text class="body-text" v-if="msg.type === 1">您提交的个人资料经审核已通过。</text> | |||
| <text class="body-text" v-else>您于 {{ formatDate(msg.create_time) }} 提交的个人资料经审核未通过。</text> | |||
| <text class="body-text" v-if="msg.type === 2">请根据以下原因修改后重新提交。</text> | |||
| </view> | |||
| <!-- 驳回原因 --> | |||
| <view v-if="msg.type === 2 && msg.reason" class="reason-box"> | |||
| <text class="reason-label">驳回原因:</text> | |||
| <text class="reason-text">{{ msg.reason }}</text> | |||
| </view> | |||
| <!-- 操作按钮 --> | |||
| <view v-if="msg.type === 2" class="btn-area"> | |||
| <u-button text="重新提交资料" @click="goMyInfo" color="#0E63E3" size="large" /> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { onLoad } from '@dcloudio/uni-app' | |||
| import { get } from '@/utils/request.js' | |||
| const msg = ref(null) | |||
| onLoad(async (options) => { | |||
| if (!options.id) return | |||
| try { | |||
| const res = await get('/api/mp/messageDetail', { id: options.id }) | |||
| msg.value = res.data | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } | |||
| }) | |||
| const formatDate = (dateStr) => { | |||
| if (!dateStr) return '' | |||
| const d = new Date(dateStr) | |||
| return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日` | |||
| } | |||
| const goMyInfo = () => { | |||
| uni.navigateTo({ url: '/pages/myinfo/myinfo' }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #f4f4f5; | |||
| padding: 24rpx; | |||
| } | |||
| .card { | |||
| background: #fff; | |||
| border-radius: 10rpx; | |||
| padding: 36rpx 32rpx; | |||
| border: 1rpx solid #ebeef5; | |||
| } | |||
| .header-row { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 12rpx; | |||
| margin-bottom: 28rpx; | |||
| } | |||
| .header-icon { | |||
| font-size: 32rpx; | |||
| } | |||
| .header-title { | |||
| font-size: 30rpx; | |||
| font-weight: 600; | |||
| color: #333; | |||
| } | |||
| .tag { | |||
| font-size: 22rpx; | |||
| padding: 4rpx 16rpx; | |||
| border-radius: 6rpx; | |||
| &.tag-success { | |||
| background: #f6ffed; | |||
| color: #52c41a; | |||
| border: 1rpx solid #b7eb8f; | |||
| } | |||
| &.tag-fail { | |||
| background: #fff2f0; | |||
| color: #f5222d; | |||
| border: 1rpx solid #ffa39e; | |||
| } | |||
| } | |||
| .main-title { | |||
| font-size: 36rpx; | |||
| font-weight: 700; | |||
| color: #222; | |||
| line-height: 1.4; | |||
| } | |||
| .time { | |||
| font-size: 26rpx; | |||
| color: #999; | |||
| margin-top: 12rpx; | |||
| margin-bottom: 36rpx; | |||
| } | |||
| .body { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 20rpx; | |||
| } | |||
| .greeting { | |||
| font-size: 30rpx; | |||
| color: #333; | |||
| } | |||
| .body-text { | |||
| font-size: 30rpx; | |||
| color: #333; | |||
| line-height: 1.7; | |||
| } | |||
| .reason-box { | |||
| margin-top: 36rpx; | |||
| background: #f0f5ff; | |||
| border-left: 6rpx solid #0e63e3; | |||
| border-radius: 8rpx; | |||
| padding: 28rpx 24rpx; | |||
| } | |||
| .reason-label { | |||
| font-size: 28rpx; | |||
| font-weight: 600; | |||
| color: #f5222d; | |||
| display: block; | |||
| margin-bottom: 12rpx; | |||
| } | |||
| .reason-text { | |||
| font-size: 28rpx; | |||
| color: #555; | |||
| line-height: 1.7; | |||
| } | |||
| .btn-area { | |||
| margin-top: 48rpx; | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,195 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <view v-if="list.length" class="msg-list"> | |||
| <view class="msg-item" v-for="item in list" :key="item.id" @tap="goDetail(item.id)"> | |||
| <view class="msg-icon" :class="item.type === 1 ? 'success' : 'fail'"> | |||
| <u-icon :name="item.type === 1 ? 'checkmark-circle-fill' : 'close-circle-fill'" size="22" | |||
| :color="item.type === 1 ? '#52c41a' : '#f5222d'" /> | |||
| </view> | |||
| <view class="msg-body"> | |||
| <view class="msg-title-row"> | |||
| <text class="msg-title">{{ item.title }}</text> | |||
| <view v-if="!item.is_read" class="unread-dot"></view> | |||
| </view> | |||
| <text class="msg-desc">{{ item.content }}</text> | |||
| <text class="msg-time">{{ item.create_time }}</text> | |||
| </view> | |||
| <text class="arrow">›</text> | |||
| </view> | |||
| </view> | |||
| <view v-if="!loading && !list.length" class="empty"> | |||
| <u-icon name="bell" size="60" color="#ccc" /> | |||
| <text class="empty-text">暂无消息</text> | |||
| </view> | |||
| <view v-if="loading" class="loading-tip"> | |||
| <u-loading-icon size="24" /> | |||
| </view> | |||
| <view v-if="!loading && finished && list.length" class="no-more">没有更多了</view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' | |||
| import { get } from '@/utils/request.js' | |||
| const list = ref([]) | |||
| const page = ref(1) | |||
| const loading = ref(false) | |||
| const finished = ref(false) | |||
| onLoad(() => { | |||
| loadMessages() | |||
| }) | |||
| onPullDownRefresh(() => { | |||
| page.value = 1 | |||
| finished.value = false | |||
| list.value = [] | |||
| loadMessages().then(() => uni.stopPullDownRefresh()) | |||
| }) | |||
| onReachBottom(() => { | |||
| if (!finished.value && !loading.value) loadMessages() | |||
| }) | |||
| const loadMessages = async () => { | |||
| if (loading.value) return | |||
| loading.value = true | |||
| try { | |||
| const res = await get('/api/mp/messages', { page: page.value, pageSize: 20 }) | |||
| const data = res.data || {} | |||
| const items = data.data || [] | |||
| if (page.value === 1) { | |||
| list.value = items | |||
| } else { | |||
| list.value.push(...items) | |||
| } | |||
| if (page.value >= (data.totalPages || 1)) { | |||
| finished.value = true | |||
| } else { | |||
| page.value++ | |||
| } | |||
| } catch (e) {} | |||
| loading.value = false | |||
| } | |||
| const goDetail = (id) => { | |||
| const item = list.value.find(m => m.id === id) | |||
| if (item) item.is_read = 1 | |||
| uni.navigateTo({ url: `/pages/message/detail?id=${id}` }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #f4f4f5; | |||
| padding: 24rpx; | |||
| } | |||
| .msg-item { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| background: #fff; | |||
| border-radius: 10rpx; | |||
| padding: 28rpx 24rpx; | |||
| margin-bottom: 20rpx; | |||
| border: 1rpx solid #ebeef5; | |||
| } | |||
| .msg-icon { | |||
| width: 56rpx; | |||
| height: 56rpx; | |||
| border-radius: 50%; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| flex-shrink: 0; | |||
| margin-right: 20rpx; | |||
| margin-top: 4rpx; | |||
| &.success { | |||
| background: #f6ffed; | |||
| } | |||
| &.fail { | |||
| background: #fff2f0; | |||
| } | |||
| } | |||
| .msg-body { | |||
| flex: 1; | |||
| min-width: 0; | |||
| } | |||
| .msg-title-row { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 12rpx; | |||
| } | |||
| .msg-title { | |||
| font-size: 30rpx; | |||
| font-weight: 600; | |||
| color: #333; | |||
| } | |||
| .unread-dot { | |||
| width: 14rpx; | |||
| height: 14rpx; | |||
| border-radius: 50%; | |||
| background: #f5222d; | |||
| flex-shrink: 0; | |||
| } | |||
| .msg-desc { | |||
| font-size: 26rpx; | |||
| color: #888; | |||
| margin-top: 8rpx; | |||
| display: -webkit-box; | |||
| -webkit-line-clamp: 1; | |||
| -webkit-box-orient: vertical; | |||
| overflow: hidden; | |||
| } | |||
| .msg-time { | |||
| font-size: 24rpx; | |||
| color: #bbb; | |||
| margin-top: 8rpx; | |||
| } | |||
| .arrow { | |||
| color: #c0c4cc; | |||
| font-size: 28rpx; | |||
| flex-shrink: 0; | |||
| margin-left: 12rpx; | |||
| margin-top: 8rpx; | |||
| } | |||
| .empty { | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| padding-top: 200rpx; | |||
| } | |||
| .empty-text { | |||
| font-size: 28rpx; | |||
| color: #ccc; | |||
| margin-top: 20rpx; | |||
| } | |||
| .loading-tip { | |||
| display: flex; | |||
| justify-content: center; | |||
| padding: 40rpx 0; | |||
| } | |||
| .no-more { | |||
| text-align: center; | |||
| font-size: 24rpx; | |||
| color: #ccc; | |||
| padding: 30rpx 0; | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,723 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <!-- 驳回原因提示 --> | |||
| <view v-if="info.status === 2 && info.reject_reason" class="reject-tip"> | |||
| <u-icon name="warning-fill" size="20" color="#fa8c16" /> | |||
| <text class="reject-text">驳回原因:{{ info.reject_reason }}</text> | |||
| </view> | |||
| <!-- 基本信息 --> | |||
| <view class="section"> | |||
| <view class="section-title"> | |||
| <u-icon name="account-fill" size="18" color="#0e63e3" /> | |||
| <text>基本信息</text> | |||
| </view> | |||
| <view class="form-group"> | |||
| <text class="form-label">姓名</text> | |||
| <view class="readonly-input">{{ info.name }}</view> | |||
| </view> | |||
| <view class="form-group"> | |||
| <text class="form-label">身份证号</text> | |||
| <view class="readonly-input">{{ maskedIdCard }}</view> | |||
| </view> | |||
| <view class="form-group"> | |||
| <text class="form-label">手机号</text> | |||
| <view class="readonly-input">{{ maskedPhone }}</view> | |||
| </view> | |||
| <view class="form-group" v-if="info.gender"> | |||
| <text class="form-label">性别</text> | |||
| <view class="readonly-input">{{ info.gender }}</view> | |||
| </view> | |||
| <view class="form-group" v-else> | |||
| <text class="form-label">性别</text> | |||
| <view class="gender-row"> | |||
| <view class="gender-item" :class="{ active: form.gender === '男' }" @tap="form.gender = '男'">男</view> | |||
| <view class="gender-item" :class="{ active: form.gender === '女' }" @tap="form.gender = '女'">女</view> | |||
| </view> | |||
| </view> | |||
| <view class="form-group"> | |||
| <text class="form-label">联系地址</text> | |||
| <view class="region-row" @tap="showRegionPicker = true"> | |||
| <text :class="['region-text', regionText ? '' : 'placeholder']">{{ regionText || '请选择省/市/区' }}</text> | |||
| <text class="arrow">›</text> | |||
| </view> | |||
| <u-input v-model="form.address" placeholder="详细街道地址" border="surround" | |||
| :customStyle="{ marginTop: '16rpx' }" /> | |||
| </view> | |||
| <view class="form-group"> | |||
| <text class="form-label">紧急联系人(选填)</text> | |||
| <view class="contact-row"> | |||
| <view class="contact-input"> | |||
| <u-input v-model="form.emergency_contact" placeholder="联系人姓名" border="surround" /> | |||
| </view> | |||
| <view class="contact-input"> | |||
| <u-input v-model="form.emergency_phone" type="number" placeholder="联系人电话" border="surround" maxlength="11" /> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <!-- 资料上传 --> | |||
| <view class="section"> | |||
| <view class="section-title"> | |||
| <u-icon name="attach" size="18" color="#fa8c16" /> | |||
| <text>资料上传</text> | |||
| </view> | |||
| <view class="upload-tip">请上传您的检查报告单或出院诊断证明书,上传图片请尽量平整清晰。可上传多张。</view> | |||
| <view class="upload-row"> | |||
| <view class="upload-item" v-for="(doc, idx) in form.documents" :key="idx"> | |||
| <image class="upload-img" :src="doc" mode="aspectFill" @tap="previewImage(idx)" /> | |||
| <view class="upload-del" @tap="form.documents.splice(idx, 1)">×</view> | |||
| </view> | |||
| <view class="upload-box" @tap="chooseDocument"> | |||
| <text class="upload-icon">+</text> | |||
| <text class="upload-text">上传图片</text> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <!-- 授权签名 --> | |||
| <view class="section"> | |||
| <view class="section-title"> | |||
| <u-icon name="edit-pen-fill" size="18" color="#52c41a" /> | |||
| <text>授权签名</text> | |||
| </view> | |||
| <view class="sign-item"> | |||
| <view class="sign-left"> | |||
| <text class="sign-name">个人可支配收入声明</text> | |||
| <text :class="['sign-status', signedIncome ? 'signed' : '']">{{ signedIncome ? '已签署' : '未签署' }}</text> | |||
| </view> | |||
| <view class="sign-btns" v-if="signedIncome"> | |||
| <view class="sign-btn view" @tap="previewSign('income')">查看</view> | |||
| <view class="sign-btn resign" @tap="goSign('income')">重签</view> | |||
| </view> | |||
| <view class="sign-btn primary" v-else @tap="goSign('income')">去签署</view> | |||
| </view> | |||
| <view class="sign-item"> | |||
| <view class="sign-left"> | |||
| <text class="sign-name">个人信息处理同意书</text> | |||
| <text :class="['sign-status', signedPrivacy ? 'signed' : '']">{{ signedPrivacy ? '已签署' : '未签署' }}</text> | |||
| </view> | |||
| <view class="sign-btns" v-if="signedPrivacy"> | |||
| <view class="sign-btn view" @tap="previewSign('privacy')">查看</view> | |||
| <view class="sign-btn resign" @tap="goSign('privacy')">重签</view> | |||
| </view> | |||
| <view class="sign-btn primary" v-else @tap="goSign('privacy')">去签署</view> | |||
| </view> | |||
| <view class="sign-item"> | |||
| <view class="sign-left"> | |||
| <text class="sign-name">声明与承诺</text> | |||
| <text :class="['sign-status', signedPromise ? 'signed' : '']">{{ signedPromise ? '已签署' : '未签署' }}</text> | |||
| </view> | |||
| <view class="sign-btns" v-if="signedPromise"> | |||
| <view class="sign-btn view" @tap="previewSign('promise')">查看</view> | |||
| <view class="sign-btn resign" @tap="goSign('promise')">重签</view> | |||
| </view> | |||
| <view class="sign-btn primary" v-else @tap="goSign('promise')">去签署</view> | |||
| </view> | |||
| </view> | |||
| <!-- 提交按钮 --> | |||
| <view class="btn-wrap"> | |||
| <u-button text="提交审核" :loading="submitting" @click="handleSubmit" color="#0E63E3" size="large" /> | |||
| </view> | |||
| <!-- 地区选择器 --> | |||
| <u-picker v-if="regionColumns[0].length" :show="showRegionPicker" :columns="regionColumns" @confirm="onRegionConfirm" | |||
| @cancel="showRegionPicker = false" @change="onRegionChange" :defaultIndex="regionDefaultIndex" /> | |||
| <!-- 已通过重新提交确认弹窗 --> | |||
| <u-popup :show="showConfirmPopup" mode="center" round="12" :safeAreaInsetBottom="false" @close="showConfirmPopup = false"> | |||
| <view class="confirm-popup"> | |||
| <view class="confirm-title">提示</view> | |||
| <view class="confirm-content">您的资料审核已通过,如果重新提交审核会变为待审核,需要平台重新审核,是否确认提交?</view> | |||
| <view class="confirm-btns"> | |||
| <u-button text="取消" size="normal" :plain="true" shape="circle" @click="showConfirmPopup = false" /> | |||
| <u-button text="确认提交" size="normal" color="#0E63E3" shape="circle" @click="doSubmit" /> | |||
| </view> | |||
| </view> | |||
| </u-popup> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref, reactive, computed, onBeforeUnmount } from 'vue' | |||
| import { onLoad } from '@dcloudio/uni-app' | |||
| import { get, post, upload } from '@/utils/request.js' | |||
| const info = ref({}) | |||
| const form = reactive({ | |||
| gender: '', | |||
| province_code: '', | |||
| city_code: '', | |||
| district_code: '', | |||
| address: '', | |||
| emergency_contact: '', | |||
| emergency_phone: '', | |||
| tag: '', | |||
| documents: [], | |||
| sign_income: '', | |||
| sign_privacy: '', | |||
| sign_promise: '', | |||
| income_amount: '' | |||
| }) | |||
| const submitting = ref(false) | |||
| const showRegionPicker = ref(false) | |||
| const showConfirmPopup = ref(false) | |||
| const subscribeTmplId = ref('') | |||
| // 加载订阅消息模板配置 | |||
| const loadSubscribeConfig = async () => { | |||
| try { | |||
| const res = await get('/api/mp/subscribeConfig') | |||
| if (res.data && res.data.audit_result) { | |||
| subscribeTmplId.value = res.data.audit_result | |||
| } | |||
| } catch (e) {} | |||
| } | |||
| // 请求订阅消息授权 | |||
| const requestSubscribe = () => { | |||
| return new Promise((resolve) => { | |||
| if (!subscribeTmplId.value) return resolve(false) | |||
| // #ifdef MP-WEIXIN | |||
| wx.requestSubscribeMessage({ | |||
| tmplIds: [subscribeTmplId.value], | |||
| success: () => resolve(true), | |||
| fail: () => resolve(false) | |||
| }) | |||
| // #endif | |||
| // #ifndef MP-WEIXIN | |||
| resolve(false) | |||
| // #endif | |||
| }) | |||
| } | |||
| // 地区数据 | |||
| const allRegions = ref([]) | |||
| const regionColumns = ref([[], [], []]) | |||
| const regionDefaultIndex = ref([0, 0, 0]) | |||
| const maskedIdCard = computed(() => { | |||
| const v = info.value.id_card || '' | |||
| if (v.length === 18) return v.slice(0, 3) + '***********' + v.slice(-4) | |||
| return v | |||
| }) | |||
| const maskedPhone = computed(() => { | |||
| const v = info.value.phone || '' | |||
| if (v.length === 11) return v.slice(0, 3) + '****' + v.slice(-4) | |||
| return v | |||
| }) | |||
| // 签署状态:form 中有新签的 URL 或 info 中有已保存的 URL | |||
| const signedIncome = computed(() => form.sign_income || info.value.sign_income) | |||
| const signedPrivacy = computed(() => form.sign_privacy || info.value.sign_privacy) | |||
| const signedPromise = computed(() => form.sign_promise || info.value.sign_promise) | |||
| const regionText = computed(() => { | |||
| const parts = [] | |||
| if (form.province_code) { | |||
| const p = allRegions.value.find(r => r.code === form.province_code) | |||
| if (p) parts.push(p.name) | |||
| } | |||
| if (form.city_code) { | |||
| const prov = allRegions.value.find(r => r.code === form.province_code) | |||
| if (prov && prov.children) { | |||
| const c = prov.children.find(r => r.code === form.city_code) | |||
| if (c) parts.push(c.name) | |||
| } | |||
| } | |||
| if (form.district_code) { | |||
| const prov = allRegions.value.find(r => r.code === form.province_code) | |||
| if (prov && prov.children) { | |||
| const city = prov.children.find(r => r.code === form.city_code) | |||
| if (city && city.children) { | |||
| const d = city.children.find(r => r.code === form.district_code) | |||
| if (d) parts.push(d.name) | |||
| } | |||
| } | |||
| } | |||
| return parts.join(' ') | |||
| }) | |||
| // 签署结果事件监听 | |||
| const onSignResult = (data) => { | |||
| if (data.type === 'income') { | |||
| form.sign_income = data.url | |||
| if (data.amount) form.income_amount = data.amount | |||
| } else if (data.type === 'privacy') { | |||
| form.sign_privacy = data.url | |||
| } else if (data.type === 'promise') { | |||
| form.sign_promise = data.url | |||
| } | |||
| } | |||
| onLoad(async () => { | |||
| uni.$on('signResult', onSignResult) | |||
| await loadRegions() | |||
| await loadInfo() | |||
| loadSubscribeConfig() | |||
| }) | |||
| onBeforeUnmount(() => { | |||
| uni.$off('signResult', onSignResult) | |||
| }) | |||
| const goSign = (type) => { | |||
| uni.navigateTo({ url: `/pages/sign/sign?type=${type}` }) | |||
| } | |||
| const previewSign = (type) => { | |||
| const urlMap = { | |||
| income: form.sign_income || info.value.sign_income, | |||
| privacy: form.sign_privacy || info.value.sign_privacy, | |||
| promise: form.sign_promise || info.value.sign_promise | |||
| } | |||
| const url = urlMap[type] | |||
| if (url) uni.previewImage({ urls: [url], current: 0 }) | |||
| } | |||
| const loadRegions = async () => { | |||
| try { | |||
| const res = await get('/common/regions') | |||
| allRegions.value = res.data || [] | |||
| buildRegionColumns() | |||
| } catch (e) {} | |||
| } | |||
| const buildRegionColumns = (pIdx = 0, cIdx = 0) => { | |||
| const provinces = allRegions.value | |||
| const col0 = provinces.map(p => p.name) | |||
| const cities = (provinces[pIdx] && provinces[pIdx].children) || [] | |||
| const col1 = cities.map(c => c.name) | |||
| const districts = (cities[cIdx] && cities[cIdx].children) || [] | |||
| const col2 = districts.map(d => d.name) | |||
| regionColumns.value = [col0, col1, col2] | |||
| } | |||
| const onRegionChange = (e) => { | |||
| const { columnIndex, index } = e | |||
| if (columnIndex === 0) { | |||
| buildRegionColumns(index, 0) | |||
| regionDefaultIndex.value = [index, 0, 0] | |||
| } else if (columnIndex === 1) { | |||
| const pIdx = regionDefaultIndex.value[0] | |||
| buildRegionColumns(pIdx, index) | |||
| regionDefaultIndex.value = [pIdx, index, 0] | |||
| } | |||
| } | |||
| const onRegionConfirm = (e) => { | |||
| const idxs = e.indexs || e.index || [0, 0, 0] | |||
| const provinces = allRegions.value | |||
| const prov = provinces[idxs[0]] | |||
| const city = prov && prov.children ? prov.children[idxs[1]] : null | |||
| const dist = city && city.children ? city.children[idxs[2]] : null | |||
| form.province_code = prov ? prov.code : '' | |||
| form.city_code = city ? city.code : '' | |||
| form.district_code = dist ? dist.code : '' | |||
| showRegionPicker.value = false | |||
| } | |||
| const loadInfo = async () => { | |||
| try { | |||
| const res = await get('/api/mp/myInfo') | |||
| if (!res.data) return | |||
| info.value = res.data | |||
| // 填充表单 | |||
| form.gender = res.data.gender || '' | |||
| form.province_code = res.data.province_code || '' | |||
| form.city_code = res.data.city_code || '' | |||
| form.district_code = res.data.district_code || '' | |||
| form.address = res.data.address || '' | |||
| form.emergency_contact = res.data.emergency_contact || '' | |||
| form.emergency_phone = res.data.emergency_phone || '' | |||
| form.tag = res.data.tag || '' | |||
| form.documents = res.data.documents || [] | |||
| form.sign_income = res.data.sign_income || '' | |||
| form.sign_privacy = res.data.sign_privacy || '' | |||
| form.sign_promise = res.data.sign_promise || '' | |||
| form.income_amount = res.data.income_amount || '' | |||
| // 设置地区选择器默认索引 | |||
| if (form.province_code && allRegions.value.length) { | |||
| const pIdx = allRegions.value.findIndex(r => r.code === form.province_code) | |||
| if (pIdx >= 0) { | |||
| const cities = allRegions.value[pIdx].children || [] | |||
| const cIdx = cities.findIndex(r => r.code === form.city_code) | |||
| const ci = cIdx >= 0 ? cIdx : 0 | |||
| const districts = (cities[ci] && cities[ci].children) || [] | |||
| const dIdx = districts.findIndex(r => r.code === form.district_code) | |||
| buildRegionColumns(pIdx, ci) | |||
| regionDefaultIndex.value = [pIdx, ci, dIdx >= 0 ? dIdx : 0] | |||
| } | |||
| } | |||
| } catch (e) {} | |||
| } | |||
| const chooseDocument = () => { | |||
| uni.chooseImage({ | |||
| count: 9 - form.documents.length, | |||
| sizeType: ['compressed'], | |||
| sourceType: ['album', 'camera'], | |||
| success: async (res) => { | |||
| for (const filePath of res.tempFilePaths) { | |||
| try { | |||
| const uploadRes = await upload('/api/mp/upload', { filePath, name: 'file' }) | |||
| if (uploadRes.data && uploadRes.data.url) { | |||
| form.documents.push(uploadRes.data.url) | |||
| } | |||
| } catch (e) {} | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| const previewImage = (idx) => { | |||
| uni.previewImage({ urls: form.documents, current: idx }) | |||
| } | |||
| const handleSubmit = async () => { | |||
| if (!info.value.gender && !form.gender) { | |||
| return uni.showToast({ title: '请选择性别', icon: 'none' }) | |||
| } | |||
| if (!form.province_code || !form.city_code || !form.district_code) { | |||
| return uni.showToast({ title: '请选择省市区', icon: 'none' }) | |||
| } | |||
| if (!form.address.trim()) { | |||
| return uni.showToast({ title: '请填写详细地址', icon: 'none' }) | |||
| } | |||
| // 已通过状态需要二次确认 | |||
| if (info.value.status === 1) { | |||
| showConfirmPopup.value = true | |||
| return | |||
| } | |||
| await doSubmit() | |||
| } | |||
| const doSubmit = async () => { | |||
| showConfirmPopup.value = false | |||
| // 先请求订阅消息授权(用户拒绝也继续提交) | |||
| await requestSubscribe() | |||
| submitting.value = true | |||
| try { | |||
| const params = { | |||
| gender: info.value.gender || form.gender, | |||
| province_code: form.province_code, | |||
| city_code: form.city_code, | |||
| district_code: form.district_code, | |||
| address: form.address.trim(), | |||
| emergency_contact: form.emergency_contact, | |||
| emergency_phone: form.emergency_phone, | |||
| tag: form.tag, | |||
| documents: form.documents, | |||
| sign_income: form.sign_income, | |||
| sign_privacy: form.sign_privacy, | |||
| sign_promise: form.sign_promise, | |||
| income_amount: form.income_amount || null, | |||
| // #ifdef MP-WEIXIN | |||
| mp_env_version: uni.getAccountInfoSync().miniProgram.envVersion || 'release' | |||
| // #endif | |||
| } | |||
| await post('/api/mp/saveMyInfo', params) | |||
| uni.showToast({ title: '提交成功', icon: 'success' }) | |||
| setTimeout(() => uni.navigateBack(), 1500) | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } finally { | |||
| submitting.value = false | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #f4f4f5; | |||
| padding: 24rpx; | |||
| padding-bottom: 240rpx; | |||
| } | |||
| .section { | |||
| background: #fff; | |||
| border-radius: 10rpx; | |||
| padding: 32rpx; | |||
| margin-bottom: 24rpx; | |||
| border: 1rpx solid #ebeef5; | |||
| } | |||
| .reject-tip { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| padding: 24rpx 28rpx; | |||
| background: #fff2f0; | |||
| border: 1rpx solid #ffccc7; | |||
| border-radius: 10rpx; | |||
| margin-bottom: 24rpx; | |||
| .reject-text { | |||
| flex: 1; | |||
| font-size: 26rpx; | |||
| color: #f5222d; | |||
| margin-left: 16rpx; | |||
| line-height: 1.5; | |||
| } | |||
| } | |||
| .section-title { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 12rpx; | |||
| font-size: 32rpx; | |||
| font-weight: 600; | |||
| color: #333; | |||
| margin-bottom: 28rpx; | |||
| } | |||
| .form-group { | |||
| padding: 20rpx 0; | |||
| border-bottom: 1rpx solid #f0f0f0; | |||
| &:last-child { | |||
| border-bottom: none; | |||
| } | |||
| } | |||
| .form-label { | |||
| font-size: 28rpx; | |||
| color: #555; | |||
| margin-bottom: 16rpx; | |||
| display: block; | |||
| } | |||
| .readonly-input { | |||
| padding: 20rpx 24rpx; | |||
| background: #f5f5f5; | |||
| border: 1rpx solid #ddd; | |||
| border-radius: 12rpx; | |||
| font-size: 28rpx; | |||
| color: #333; | |||
| } | |||
| .gender-row { | |||
| display: flex; | |||
| gap: 20rpx; | |||
| } | |||
| .gender-item { | |||
| flex: 1; | |||
| text-align: center; | |||
| padding: 16rpx 0; | |||
| border: 1rpx solid #ddd; | |||
| border-radius: 8rpx; | |||
| font-size: 28rpx; | |||
| color: #333; | |||
| &.active { | |||
| border-color: #0e63e3; | |||
| color: #0e63e3; | |||
| background: rgba(14, 99, 227, 0.05); | |||
| } | |||
| } | |||
| .region-row { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| padding: 20rpx 24rpx; | |||
| border: 1rpx solid #ddd; | |||
| border-radius: 8rpx; | |||
| } | |||
| .region-text { | |||
| font-size: 28rpx; | |||
| color: #333; | |||
| &.placeholder { | |||
| color: #c0c4cc; | |||
| } | |||
| } | |||
| .arrow { | |||
| font-size: 28rpx; | |||
| color: #c0c4cc; | |||
| } | |||
| .contact-row { | |||
| display: flex; | |||
| gap: 16rpx; | |||
| } | |||
| .contact-input { | |||
| flex: 1; | |||
| } | |||
| .upload-tip { | |||
| font-size: 26rpx; | |||
| color: #888; | |||
| line-height: 1.6; | |||
| margin-bottom: 20rpx; | |||
| } | |||
| .upload-row { | |||
| display: flex; | |||
| gap: 16rpx; | |||
| flex-wrap: wrap; | |||
| } | |||
| .upload-item { | |||
| position: relative; | |||
| width: 180rpx; | |||
| height: 180rpx; | |||
| } | |||
| .upload-img { | |||
| width: 180rpx; | |||
| height: 180rpx; | |||
| border-radius: 8rpx; | |||
| border: 1rpx solid #eee; | |||
| } | |||
| .upload-del { | |||
| position: absolute; | |||
| top: -10rpx; | |||
| right: -10rpx; | |||
| width: 40rpx; | |||
| height: 40rpx; | |||
| background: rgba(0, 0, 0, 0.5); | |||
| color: #fff; | |||
| border-radius: 50%; | |||
| font-size: 24rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .upload-box { | |||
| width: 180rpx; | |||
| height: 180rpx; | |||
| border: 2rpx dashed #ccc; | |||
| border-radius: 8rpx; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| background: #fafafa; | |||
| } | |||
| .upload-icon { | |||
| font-size: 56rpx; | |||
| color: #ccc; | |||
| } | |||
| .upload-text { | |||
| font-size: 22rpx; | |||
| color: #999; | |||
| margin-top: 4rpx; | |||
| } | |||
| .sign-item { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| padding: 28rpx 0; | |||
| border-bottom: 1rpx solid #f0f0f0; | |||
| &:last-child { | |||
| border-bottom: none; | |||
| } | |||
| } | |||
| .sign-left { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 8rpx; | |||
| } | |||
| .sign-name { | |||
| font-size: 28rpx; | |||
| color: #333; | |||
| } | |||
| .sign-status { | |||
| font-size: 24rpx; | |||
| color: #f5222d; | |||
| &.signed { | |||
| color: #52c41a; | |||
| } | |||
| } | |||
| .sign-btns { | |||
| display: flex; | |||
| gap: 12rpx; | |||
| flex-shrink: 0; | |||
| } | |||
| .sign-btn { | |||
| padding: 10rpx 28rpx; | |||
| border-radius: 28rpx; | |||
| font-size: 24rpx; | |||
| text-align: center; | |||
| flex-shrink: 0; | |||
| &.primary { | |||
| background: #0e63e3; | |||
| color: #fff; | |||
| } | |||
| &.view { | |||
| background: #f0f5ff; | |||
| color: #0e63e3; | |||
| border: 1rpx solid #d0e0ff; | |||
| } | |||
| &.resign { | |||
| background: #fff7e6; | |||
| color: #fa8c16; | |||
| border: 1rpx solid #ffe7ba; | |||
| } | |||
| &:active { | |||
| opacity: 0.7; | |||
| } | |||
| } | |||
| .btn-wrap { | |||
| position: fixed; | |||
| bottom: 0; | |||
| left: 0; | |||
| right: 0; | |||
| background: #fff; | |||
| padding: 20rpx 32rpx; | |||
| padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); | |||
| box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06); | |||
| z-index: 100; | |||
| } | |||
| .confirm-popup { | |||
| padding: 48rpx 40rpx 40rpx; | |||
| width: 560rpx; | |||
| } | |||
| .confirm-title { | |||
| font-size: 34rpx; | |||
| font-weight: 600; | |||
| color: #333; | |||
| text-align: center; | |||
| margin-bottom: 28rpx; | |||
| } | |||
| .confirm-content { | |||
| font-size: 28rpx; | |||
| color: #666; | |||
| line-height: 1.7; | |||
| text-align: center; | |||
| margin-bottom: 40rpx; | |||
| } | |||
| .confirm-btns { | |||
| display: flex; | |||
| gap: 24rpx; | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,441 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <!-- 头部 --> | |||
| <view class="profile-header"> | |||
| <image class="header-bg" src="/static/img/profile-bg.jpg" mode="aspectFill" /> | |||
| <view class="user-row"> | |||
| <view class="avatar-wrap" @tap="isLoggedIn && changeAvatar()"> | |||
| <image class="avatar" :src="userInfo?.avatar || '/static/img/default-avatar.jpg'" | |||
| mode="aspectFill" /> | |||
| <view v-if="isLoggedIn" class="camera-icon"> | |||
| <u-icon name="camera-fill" size="20" color="#666" /> | |||
| </view> | |||
| </view> | |||
| <view class="info" @tap="!isLoggedIn && goLogin()"> | |||
| <view class="name">{{ displayName }}</view> | |||
| <view v-if="isLoggedIn && userInfo?.patient?.patient_no" class="patient-no">{{ userInfo.patient.patient_no }}</view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <!-- 驳回提示 --> | |||
| <view v-if="isRejected" class="reject-tip" @tap="goRejectDetail"> | |||
| <u-icon name="warning-fill" size="20" color="#fa8c16" /> | |||
| <text class="reject-text">您提交的资料未通过审核,请点击查看详情并重新提交。</text> | |||
| <u-icon name="arrow-right" size="16" color="#f5222d" /> | |||
| </view> | |||
| <!-- 菜单区域 --> | |||
| <view class="menu-section" :class="{ 'no-overlap': isRejected }"> | |||
| <view class="menu-item" @tap="goMyInfo"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="file-text-fill" size="20" color="#0e63e3" /> | |||
| </view> | |||
| <text class="text">我的资料</text> | |||
| <text v-if="patientStatus === -1" class="extra draft">待提交</text> | |||
| <text v-else-if="patientStatus === 0" class="extra pending">待审核</text> | |||
| <text v-else-if="patientStatus === 1" class="extra authed">已通过</text> | |||
| <text v-else-if="patientStatus === 2" class="extra rejected">已驳回</text> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| <view class="menu-item" @tap="goTo('/pages/message/message')"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="chat-fill" size="20" color="#fa8c16" /> | |||
| </view> | |||
| <text class="text">消息中心</text> | |||
| <u-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" type="error" /> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| <view class="menu-item" @tap="goTo('/pages/verify/verify')"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="account-fill" size="20" color="#52c41a" /> | |||
| </view> | |||
| <text class="text">实名认证</text> | |||
| <text v-if="isAuthed" class="extra authed">已认证</text> | |||
| <text v-else class="extra link">去认证</text> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| </view> | |||
| <view class="menu-section"> | |||
| <view class="menu-item" @tap="goChangePhone"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="phone-fill" size="20" color="#0e63e3" /> | |||
| </view> | |||
| <text class="text">修改手机号</text> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| <view class="menu-item" @tap="goTo('/pages/content/content?key=privacy_policy')"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="lock-fill" size="20" color="#909399" /> | |||
| </view> | |||
| <text class="text">隐私协议</text> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| <view class="menu-item" @tap="goTo('/pages/content/content?key=about_us')"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="info-circle-fill" size="20" color="#909399" /> | |||
| </view> | |||
| <text class="text">关于我们</text> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| </view> | |||
| <button v-if="isLoggedIn" class="logout-btn" @tap="handleLogout">退出登录</button> | |||
| <button v-else class="login-btn" @tap="goLogin">去登录</button> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref, computed } from 'vue' | |||
| import { onShow } from '@dcloudio/uni-app' | |||
| import { get, post, upload } from '@/utils/request.js' | |||
| import { getToken, getUserInfo, setUserInfo, clearAll } from '@/utils/cache.js' | |||
| const userInfo = ref(getUserInfo()) | |||
| const isLoggedIn = ref(!!getToken()) | |||
| const unreadCount = ref(0) | |||
| const isAuthed = computed(() => userInfo.value?.patient?.auth_status === 1) | |||
| const patientStatus = computed(() => { | |||
| const p = userInfo.value?.patient | |||
| if (!p) return null | |||
| return p.status | |||
| }) | |||
| const isRejected = computed(() => patientStatus.value === 2) | |||
| const displayName = computed(() => { | |||
| if (!isLoggedIn.value) return '点击登录' | |||
| if (isAuthed.value && userInfo.value?.patient?.name) return userInfo.value.patient.name | |||
| return userInfo.value?.nickname || '微信用户' | |||
| }) | |||
| onShow(() => { | |||
| isLoggedIn.value = !!getToken() | |||
| userInfo.value = getUserInfo() | |||
| if (isLoggedIn.value) { | |||
| fetchUserInfo() | |||
| fetchUnreadCount() | |||
| } else { | |||
| unreadCount.value = 0 | |||
| } | |||
| }) | |||
| const fetchUserInfo = async () => { | |||
| try { | |||
| const res = await get('/api/mp/userinfo') | |||
| userInfo.value = res.data | |||
| setUserInfo(res.data) | |||
| } catch (e) { | |||
| if (e?.code === 1009) { | |||
| clearAll() | |||
| userInfo.value = null | |||
| } | |||
| } | |||
| } | |||
| const fetchUnreadCount = async () => { | |||
| try { | |||
| const res = await get('/api/mp/unreadCount') | |||
| unreadCount.value = res.data?.count || 0 | |||
| } catch (e) {} | |||
| } | |||
| const goTo = (url) => { | |||
| uni.navigateTo({ url }) | |||
| } | |||
| const goMyInfo = () => { | |||
| if (!isAuthed.value) { | |||
| uni.showToast({ title: '请先完成实名认证', icon: 'none' }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/myinfo/myinfo' }) | |||
| } | |||
| const goChangePhone = () => { | |||
| if (!isAuthed.value) { | |||
| uni.showToast({ title: '请先完成实名认证', icon: 'none' }) | |||
| return | |||
| } | |||
| uni.navigateTo({ url: '/pages/change-phone/change-phone' }) | |||
| } | |||
| const goLogin = () => { | |||
| uni.navigateTo({ url: '/pages/login/index' }) | |||
| } | |||
| const goRejectDetail = () => { | |||
| uni.navigateTo({ url: '/pages/myinfo/myinfo' }) | |||
| } | |||
| const changeAvatar = () => { | |||
| uni.chooseImage({ | |||
| count: 1, | |||
| sizeType: ['compressed'], | |||
| sourceType: ['album', 'camera'], | |||
| success: async (chooseRes) => { | |||
| let filePath = chooseRes.tempFilePaths[0] | |||
| // 小程序端使用 cropImage 裁剪 | |||
| // #ifdef MP-WEIXIN | |||
| try { | |||
| const cropRes = await new Promise((resolve, reject) => { | |||
| wx.cropImage({ | |||
| src: filePath, | |||
| cropScale: '1:1', | |||
| success: resolve, | |||
| fail: reject | |||
| }) | |||
| }) | |||
| filePath = cropRes.tempFilePath | |||
| } catch (e) { | |||
| // 用户取消裁剪 | |||
| return | |||
| } | |||
| // #endif | |||
| try { | |||
| uni.showLoading({ title: '上传中...' }) | |||
| const uploadRes = await upload('/api/mp/upload', { filePath, name: 'file' }) | |||
| if (!uploadRes.data?.url) throw { msg: '上传失败' } | |||
| const avatarUrl = uploadRes.data.url | |||
| await post('/api/mp/updateAvatar', { avatar: avatarUrl }) | |||
| if (userInfo.value) { | |||
| userInfo.value.avatar = avatarUrl | |||
| setUserInfo(userInfo.value) | |||
| } | |||
| uni.showToast({ title: '头像已更新', icon: 'success' }) | |||
| } catch (e) { | |||
| if (e?.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } finally { | |||
| uni.hideLoading() | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| const handleLogout = () => { | |||
| uni.showModal({ | |||
| title: '提示', | |||
| content: '确定退出登录吗?', | |||
| success: (res) => { | |||
| if (res.confirm) { | |||
| clearAll() | |||
| userInfo.value = null | |||
| isLoggedIn.value = false | |||
| uni.showToast({ title: '已退出', icon: 'success' }) | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #f4f4f5; | |||
| } | |||
| .profile-header { | |||
| position: relative; | |||
| padding: 220rpx 40rpx 60rpx; | |||
| overflow: hidden; | |||
| } | |||
| .header-bg { | |||
| position: absolute; | |||
| top: 0; | |||
| left: 0; | |||
| width: 100%; | |||
| height: 100%; | |||
| z-index: 0; | |||
| } | |||
| .user-row { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 28rpx; | |||
| position: relative; | |||
| z-index: 1; | |||
| } | |||
| .avatar-wrap { | |||
| position: relative; | |||
| width: 120rpx; | |||
| height: 120rpx; | |||
| flex-shrink: 0; | |||
| } | |||
| .avatar { | |||
| width: 120rpx; | |||
| height: 120rpx; | |||
| border-radius: 50%; | |||
| background: rgba(255, 255, 255, 0.3); | |||
| } | |||
| .camera-icon { | |||
| position: absolute; | |||
| right: -4rpx; | |||
| bottom: -4rpx; | |||
| width: 40rpx; | |||
| height: 40rpx; | |||
| background: #fff; | |||
| border-radius: 50%; | |||
| font-size: 22rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15); | |||
| } | |||
| .info { | |||
| .name { | |||
| font-size: 40rpx; | |||
| font-weight: 600; | |||
| color: #fff; | |||
| } | |||
| .patient-no { | |||
| font-size: 24rpx; | |||
| color: rgba(255, 255, 255, 0.6); | |||
| margin-top: 8rpx; | |||
| } | |||
| } | |||
| .menu-section { | |||
| margin: 24rpx 32rpx; | |||
| background: #fff; | |||
| border-radius: 8rpx; | |||
| overflow: hidden; | |||
| border: 1rpx solid #ebeef5; | |||
| &:first-of-type { | |||
| margin-top: -32rpx; | |||
| position: relative; | |||
| z-index: 1; | |||
| } | |||
| } | |||
| .reject-tip { | |||
| display: flex; | |||
| align-items: center; | |||
| margin: 0 32rpx; | |||
| margin-top: 24rpx; | |||
| margin-bottom: 24rpx; | |||
| padding: 24rpx 28rpx; | |||
| background: #fff2f0; | |||
| border: 1rpx solid #ffccc7; | |||
| border-radius: 8rpx; | |||
| position: relative; | |||
| z-index: 1; | |||
| .reject-text { | |||
| flex: 1; | |||
| font-size: 26rpx; | |||
| color: #f5222d; | |||
| margin-left: 16rpx; | |||
| line-height: 1.5; | |||
| } | |||
| } | |||
| .reject-tip + .menu-section { | |||
| margin-top: 0; | |||
| } | |||
| .menu-section.no-overlap { | |||
| margin-top: 0 !important; | |||
| } | |||
| .menu-item { | |||
| display: flex; | |||
| align-items: center; | |||
| padding: 28rpx 32rpx; | |||
| border-bottom: 1rpx solid #ebeef5; | |||
| &:last-child { | |||
| border-bottom: none; | |||
| } | |||
| &:active { | |||
| background: #f5f7fa; | |||
| } | |||
| .menu-icon { | |||
| width: 40rpx; | |||
| height: 40rpx; | |||
| margin-right: 24rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .text { | |||
| flex: 1; | |||
| font-size: 30rpx; | |||
| color: #303133; | |||
| } | |||
| .extra { | |||
| font-size: 26rpx; | |||
| color: #909399; | |||
| margin-right: 16rpx; | |||
| &.link { | |||
| color: #0e63e3; | |||
| } | |||
| &.authed { | |||
| color: #67c23a; | |||
| } | |||
| &.pending { | |||
| color: #fa8c16; | |||
| } | |||
| &.rejected { | |||
| color: #f5222d; | |||
| } | |||
| &.draft { | |||
| color: #909399; | |||
| } | |||
| } | |||
| } | |||
| .login-btn { | |||
| margin: 48rpx 32rpx; | |||
| height: 80rpx; | |||
| line-height: 80rpx; | |||
| background: #fff; | |||
| border: 2rpx solid #0e63e3; | |||
| color: #0e63e3; | |||
| border-radius: 8rpx; | |||
| font-size: 30rpx; | |||
| &::after { | |||
| border: none; | |||
| } | |||
| &:active { | |||
| background: #e8f0fe; | |||
| } | |||
| } | |||
| .logout-btn { | |||
| margin: 48rpx 32rpx; | |||
| height: 80rpx; | |||
| line-height: 80rpx; | |||
| background: #fff; | |||
| border: 2rpx solid #e6e6e6; | |||
| color: #909399; | |||
| border-radius: 8rpx; | |||
| font-size: 30rpx; | |||
| &::after { | |||
| border: none; | |||
| } | |||
| &:active { | |||
| background: #f5f5f5; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,279 @@ | |||
| <template> | |||
| <scroll-view class="page" scroll-y :scroll-with-animation="false" :enable-flex="true" | |||
| :scroll-enabled="!isSigning"> | |||
| <!-- 协议内容 --> | |||
| <view class="section"> | |||
| <view class="doc-title">{{ docTitle }}</view> | |||
| <rich-text class="doc-body" :nodes="docContent" /> | |||
| </view> | |||
| <!-- 收入金额(仅income类型) --> | |||
| <view class="section" v-if="signType === 'income'"> | |||
| <view class="form-label">请填写您的个人月可支配收入(元)</view> | |||
| <view class="amount-wrap"> | |||
| <text class="amount-prefix">¥</text> | |||
| <u-input v-model="incomeAmount" type="number" placeholder="请输入金额" border="none" /> | |||
| </view> | |||
| </view> | |||
| <!-- 签名区域 --> | |||
| <view class="section"> | |||
| <view class="sign-label">请在下方签名确认</view> | |||
| <view class="canvas-wrap"> | |||
| <canvas canvas-id="signCanvas" class="sign-canvas" | |||
| @touchstart="onTouchStart" @touchmove.stop.prevent="onTouchMove" @touchend="onTouchEnd" /> | |||
| <text v-if="!hasSigned" class="canvas-placeholder">请在此处签名</text> | |||
| </view> | |||
| <view class="sign-actions"> | |||
| <view class="clear-btn" @tap="clearSign">清除重签</view> | |||
| </view> | |||
| </view> | |||
| <!-- 底部按钮 --> | |||
| <view class="btn-wrap"> | |||
| <u-button text="确认签署" :loading="submitting" @click="confirmSign" color="#0E63E3" size="large" /> | |||
| </view> | |||
| </scroll-view> | |||
| </template> | |||
| <script setup> | |||
| import { ref } from 'vue' | |||
| import { onLoad, onReady } from '@dcloudio/uni-app' | |||
| import { get, post, upload } from '@/utils/request.js' | |||
| const signType = ref('') | |||
| const docTitle = ref('') | |||
| const docContent = ref('') | |||
| const incomeAmount = ref('') | |||
| const hasSigned = ref(false) | |||
| const submitting = ref(false) | |||
| const isSigning = ref(false) | |||
| let ctx = null | |||
| let points = [] | |||
| const titleMap = { | |||
| income: '个人可支配收入声明', | |||
| privacy: '个人信息处理同意书', | |||
| promise: '声明与承诺' | |||
| } | |||
| onLoad((options) => { | |||
| signType.value = options.type || 'privacy' | |||
| uni.setNavigationBarTitle({ title: titleMap[signType.value] || '签署协议' }) | |||
| loadContent() | |||
| }) | |||
| onReady(() => { | |||
| ctx = uni.createCanvasContext('signCanvas') | |||
| ctx.setStrokeStyle('#333') | |||
| ctx.setLineWidth(3) | |||
| ctx.setLineCap('round') | |||
| ctx.setLineJoin('round') | |||
| }) | |||
| const loadContent = async () => { | |||
| try { | |||
| const key = 'sign_' + signType.value | |||
| const res = await get('/api/content', { key }) | |||
| docTitle.value = res.data.title || '' | |||
| docContent.value = res.data.content || '' | |||
| } catch (e) {} | |||
| } | |||
| const onTouchStart = (e) => { | |||
| isSigning.value = true | |||
| const touch = e.touches[0] | |||
| points = [{ x: touch.x, y: touch.y }] | |||
| ctx.beginPath() | |||
| ctx.moveTo(touch.x, touch.y) | |||
| } | |||
| const onTouchMove = (e) => { | |||
| const touch = e.touches[0] | |||
| points.push({ x: touch.x, y: touch.y }) | |||
| ctx.lineTo(touch.x, touch.y) | |||
| ctx.stroke() | |||
| ctx.draw(true) | |||
| ctx.beginPath() | |||
| ctx.moveTo(touch.x, touch.y) | |||
| if (!hasSigned.value) hasSigned.value = true | |||
| } | |||
| const onTouchEnd = () => { | |||
| points = [] | |||
| isSigning.value = false | |||
| } | |||
| const clearSign = () => { | |||
| ctx.clearRect(0, 0, 9999, 9999) | |||
| ctx.draw() | |||
| hasSigned.value = false | |||
| } | |||
| const confirmSign = async () => { | |||
| if (!hasSigned.value) { | |||
| return uni.showToast({ title: '请先签名', icon: 'none' }) | |||
| } | |||
| if (signType.value === 'income' && (!incomeAmount.value || Number(incomeAmount.value) <= 0)) { | |||
| return uni.showToast({ title: '请填写有效的收入金额', icon: 'none' }) | |||
| } | |||
| submitting.value = true | |||
| try { | |||
| // 1. 导出 canvas 为图片 | |||
| const tempPath = await new Promise((resolve, reject) => { | |||
| uni.canvasToTempFilePath({ | |||
| canvasId: 'signCanvas', | |||
| fileType: 'png', | |||
| success: (res) => resolve(res.tempFilePath), | |||
| fail: reject | |||
| }) | |||
| }) | |||
| // 2. 上传签名图到 COS | |||
| const uploadRes = await upload('/api/mp/upload', { filePath: tempPath, name: 'file' }) | |||
| if (!uploadRes.data || !uploadRes.data.url) { | |||
| throw { msg: '签名图上传失败' } | |||
| } | |||
| const signImage = uploadRes.data.url | |||
| // 3. 调后端合成接口 | |||
| const params = { | |||
| type: signType.value, | |||
| signImage, | |||
| amount: signType.value === 'income' ? incomeAmount.value : undefined | |||
| } | |||
| const res = await post('/api/mp/sign', params) | |||
| // 4. 通过事件把结果传回 myinfo 页面 | |||
| uni.$emit('signResult', { | |||
| type: signType.value, | |||
| url: res.data.url, | |||
| amount: signType.value === 'income' ? incomeAmount.value : undefined | |||
| }) | |||
| uni.showToast({ title: '签署成功', icon: 'success' }) | |||
| setTimeout(() => uni.navigateBack(), 1000) | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } finally { | |||
| submitting.value = false | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| height: 100vh; | |||
| background: #f4f4f5; | |||
| padding: 24rpx; | |||
| padding-bottom: 140rpx; | |||
| } | |||
| .section { | |||
| background: #fff; | |||
| margin-bottom: 24rpx; | |||
| border-radius: 10rpx; | |||
| padding: 32rpx; | |||
| border: 1rpx solid #ebeef5; | |||
| } | |||
| .doc-title { | |||
| font-size: 32rpx; | |||
| font-weight: 600; | |||
| text-align: center; | |||
| margin-bottom: 32rpx; | |||
| color: #222; | |||
| } | |||
| .doc-body { | |||
| font-size: 28rpx; | |||
| color: #555; | |||
| line-height: 1.8; | |||
| } | |||
| .form-label { | |||
| font-size: 28rpx; | |||
| color: #333; | |||
| font-weight: 600; | |||
| margin-bottom: 16rpx; | |||
| } | |||
| .amount-wrap { | |||
| display: flex; | |||
| align-items: center; | |||
| border: 1rpx solid #ddd; | |||
| border-radius: 12rpx; | |||
| overflow: hidden; | |||
| gap: 12rpx; | |||
| } | |||
| .amount-prefix { | |||
| padding: 20rpx 24rpx; | |||
| font-size: 28rpx; | |||
| color: #999; | |||
| background: #f8f8f8; | |||
| } | |||
| .sign-label { | |||
| font-size: 28rpx; | |||
| color: #333; | |||
| font-weight: 600; | |||
| margin-bottom: 16rpx; | |||
| } | |||
| .canvas-wrap { | |||
| position: relative; | |||
| border: 1rpx solid #ddd; | |||
| border-radius: 12rpx; | |||
| overflow: hidden; | |||
| margin-bottom: 16rpx; | |||
| } | |||
| .sign-canvas { | |||
| width: 100%; | |||
| height: 300rpx; | |||
| background: #fff; | |||
| } | |||
| .canvas-placeholder { | |||
| position: absolute; | |||
| top: 50%; | |||
| left: 50%; | |||
| transform: translate(-50%, -50%); | |||
| color: #ccc; | |||
| font-size: 32rpx; | |||
| pointer-events: none; | |||
| } | |||
| .sign-actions { | |||
| display: flex; | |||
| justify-content: flex-end; | |||
| } | |||
| .clear-btn { | |||
| padding: 12rpx 32rpx; | |||
| border: 1rpx solid #ccc; | |||
| border-radius: 12rpx; | |||
| font-size: 26rpx; | |||
| color: #666; | |||
| &:active { | |||
| border-color: #0e63e3; | |||
| color: #0e63e3; | |||
| } | |||
| } | |||
| .btn-wrap { | |||
| position: fixed; | |||
| bottom: 0; | |||
| left: 0; | |||
| right: 0; | |||
| background: #fff; | |||
| padding: 20rpx 32rpx; | |||
| padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); | |||
| box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06); | |||
| z-index: 100; | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,614 @@ | |||
| <template> | |||
| <view class="page"> | |||
| <view class="page-content"> | |||
| <view class="page-title">请确认申请人信息</view> | |||
| <!-- 第1步:证件类型选择 + 上传 --> | |||
| <view class="section"> | |||
| <view class="section-header"> | |||
| <view class="step-num">1</view> | |||
| <text class="label">为谁申请</text> | |||
| <text class="tag warn">请用患者本人信息进行实名申请</text> | |||
| </view> | |||
| <view class="section-desc">请选择用来实名证件的类型</view> | |||
| <view class="cert-tabs"> | |||
| <view class="cert-tab" :class="{ active: certType === 'idcard' }" @tap="certType = 'idcard'"> | |||
| <text>身份证证件</text> | |||
| </view> | |||
| <view class="cert-tab" :class="{ active: certType === 'child' }" @tap="certType = 'child'"> | |||
| <text>无证件儿童</text> | |||
| </view> | |||
| <view class="cert-tab" :class="{ active: certType === 'temp' }" @tap="certType = 'temp'"> | |||
| <text>临时身份证</text> | |||
| </view> | |||
| </view> | |||
| <!-- 身份证 / 临时身份证 --> | |||
| <view v-if="certType === 'idcard' || certType === 'temp'" class="upload-area"> | |||
| <view class="upload-item" @tap="chooseImage('front')"> | |||
| <image :src="form.frontImage || placeholderFront" mode="aspectFill" class="upload-img" /> | |||
| <text class="upload-label">请上传证件人像面</text> | |||
| </view> | |||
| <view class="upload-item" @tap="chooseImage('back')"> | |||
| <image :src="form.backImage || placeholderBack" mode="aspectFill" class="upload-img" /> | |||
| <text class="upload-label">请上传证件国徽面</text> | |||
| </view> | |||
| </view> | |||
| <!-- 无证件儿童 --> | |||
| <view v-if="certType === 'child'" class="child-upload"> | |||
| <view class="child-tip">请上传近期免冠照片 <text class="tip-highlight">用于无证件儿童身份核验</text></view> | |||
| <view class="child-desc">为了避免增加审核时长,如已办理身份证,请走身份证件通道</view> | |||
| <view class="child-photo-box" @tap="chooseImage('child')"> | |||
| <image v-if="form.childImage" :src="form.childImage" mode="aspectFill" class="child-photo-img" /> | |||
| <view v-else class="child-photo-placeholder"> | |||
| <u-icon name="camera" size="40" color="#ccc" /> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <!-- 第2步:信息核验 --> | |||
| <view class="section"> | |||
| <view class="section-header"> | |||
| <view class="step-num">2</view> | |||
| <text class="label">信息核验</text> | |||
| <text class="tag info">如姓名有误,请进行修改再提交</text> | |||
| </view> | |||
| <view class="section-desc">请核对证件信息是否有误</view> | |||
| <view class="form-row"> | |||
| <text class="form-label">证件姓名</text> | |||
| <input class="form-input" v-model="form.name" placeholder="上传身份证后自动识别" /> | |||
| </view> | |||
| <view class="form-row"> | |||
| <text class="form-label">证件号码</text> | |||
| <input class="form-input" v-model="form.idNo" placeholder="上传身份证后自动识别" /> | |||
| </view> | |||
| <view class="form-row"> | |||
| <text class="form-label">发证机关</text> | |||
| <input class="form-input" v-model="form.authority" placeholder="上传身份证后自动识别" /> | |||
| </view> | |||
| <view class="form-row border-none"> | |||
| <text class="form-label">有效期限</text> | |||
| <input class="form-input" v-model="form.validity" placeholder="上传身份证后自动识别" /> | |||
| </view> | |||
| </view> | |||
| <!-- 第3步:手机号码绑定 --> | |||
| <view class="section"> | |||
| <view class="section-header"> | |||
| <view class="step-num">3</view> | |||
| <text class="label">手机号码绑定</text> | |||
| </view> | |||
| <view class="section-desc">请输入未进行过平台认证的号码进行绑定</view> | |||
| <view class="phone-row"> | |||
| <input class="phone-input" type="number" v-model="form.phone" placeholder="请输入手机号码" maxlength="11" /> | |||
| </view> | |||
| <view class="sms-row"> | |||
| <input class="sms-input" type="number" v-model="form.smsCode" placeholder="请输入验证码" maxlength="6" /> | |||
| <button class="sms-btn" :disabled="countdown > 0" @tap="sendSms"> | |||
| {{ countdown > 0 ? countdown + 's后重新获取' : '获取验证码' }} | |||
| </button> | |||
| </view> | |||
| </view> | |||
| </view> | |||
| <!-- 底部提交按钮 --> | |||
| <view class="submit-bar"> | |||
| <button class="submit-btn" @tap="handleSubmit">提交认证</button> | |||
| </view> | |||
| </view> | |||
| </template> | |||
| <script setup> | |||
| import { ref, reactive, onUnmounted } from 'vue' | |||
| import { onShow } from '@dcloudio/uni-app' | |||
| import { get, post, upload } from '@/utils/request.js' | |||
| import { getUserInfo, setUserInfo } from '@/utils/cache.js' | |||
| const CDN_BASE = 'https://cdn.csybhelp.com' | |||
| const placeholderFront = CDN_BASE + '/images/patient/user/idcard-front.webp?v=1' | |||
| const placeholderBack = CDN_BASE + '/images/patient/user/idcard-back.webp?v=1' | |||
| const certType = ref('idcard') | |||
| const countdown = ref(0) | |||
| const submitting = ref(false) | |||
| let timer = null | |||
| // 证件类型映射: certType -> idCardType (后端字段) | |||
| const certTypeMap = { idcard: 1, child: 2, temp: 3 } | |||
| const form = reactive({ | |||
| frontImage: '', // 上传后的CDN URL | |||
| backImage: '', | |||
| childImage: '', | |||
| name: '', | |||
| idNo: '', | |||
| authority: '', | |||
| validity: '', | |||
| gender: '', | |||
| birthday: '', | |||
| phone: '', | |||
| smsCode: '' | |||
| }) | |||
| // 页面显示时加载已有认证信息 | |||
| onShow(() => { | |||
| loadAuthInfo() | |||
| }) | |||
| onUnmounted(() => { | |||
| if (timer) { clearInterval(timer); timer = null } | |||
| }) | |||
| const loadAuthInfo = async () => { | |||
| try { | |||
| const res = await get('/api/mp/authInfo') | |||
| if (res.data.authStatus === 1) { | |||
| const d = res.data | |||
| // 反向映射 idCardType -> certType | |||
| const typeMap = { 1: 'idcard', 2: 'child', 3: 'temp' } | |||
| certType.value = typeMap[d.idCardType] || 'idcard' | |||
| form.frontImage = d.idCardFront || '' | |||
| form.backImage = d.idCardBack || '' | |||
| form.childImage = d.photo || '' | |||
| form.name = d.realName || '' | |||
| form.idNo = d.idCard || '' | |||
| form.authority = d.issuingAuthority || '' | |||
| form.validity = d.validPeriod || '' | |||
| form.gender = d.gender || '' | |||
| form.birthday = d.birthday || '' | |||
| form.phone = d.phone || '' | |||
| } | |||
| } catch (e) { | |||
| // 未认证,忽略 | |||
| } | |||
| } | |||
| // 上传图片到COS,返回CDN URL | |||
| const uploadImage = (tempFilePath) => { | |||
| return new Promise((resolve, reject) => { | |||
| uni.showLoading({ title: '上传中...' }) | |||
| upload('/api/mp/upload', { filePath: tempFilePath, name: 'file' }) | |||
| .then(res => { | |||
| uni.hideLoading() | |||
| resolve(res.data.url) | |||
| }) | |||
| .catch(err => { | |||
| uni.hideLoading() | |||
| uni.showToast({ title: '上传失败', icon: 'none' }) | |||
| reject(err) | |||
| }) | |||
| }) | |||
| } | |||
| // OCR识别身份证正面 | |||
| const doOcrFront = async (imageUrl) => { | |||
| uni.showLoading({ title: '识别中...' }) | |||
| try { | |||
| const res = await post('/common/ocr/idcard', { imageUrl, cardSide: 'FRONT' }) | |||
| const d = res.data | |||
| form.name = d.name || '' | |||
| form.idNo = d.idNum || '' | |||
| // 从身份证号解析性别和生日 | |||
| if (d.idNum && d.idNum.length === 18) { | |||
| const genderNum = parseInt(d.idNum.charAt(16)) | |||
| form.gender = genderNum % 2 === 1 ? '男' : '女' | |||
| form.birthday = `${d.idNum.substring(6, 10)}-${d.idNum.substring(10, 12)}-${d.idNum.substring(12, 14)}` | |||
| } | |||
| uni.hideLoading() | |||
| } catch (e) { | |||
| uni.hideLoading() | |||
| uni.showToast({ title: '识别失败,请手动填写', icon: 'none' }) | |||
| } | |||
| } | |||
| // OCR识别身份证反面 | |||
| const doOcrBack = async (imageUrl) => { | |||
| uni.showLoading({ title: '识别中...' }) | |||
| try { | |||
| const res = await post('/common/ocr/idcard', { imageUrl, cardSide: 'BACK' }) | |||
| form.authority = res.data.authority || '' | |||
| form.validity = res.data.validDate || '' | |||
| uni.hideLoading() | |||
| } catch (e) { | |||
| uni.hideLoading() | |||
| } | |||
| } | |||
| const chooseImage = (type) => { | |||
| uni.chooseImage({ | |||
| count: 1, | |||
| sizeType: ['compressed'], | |||
| sourceType: ['album', 'camera'], | |||
| success: async (res) => { | |||
| const tempPath = res.tempFilePaths[0] | |||
| try { | |||
| const url = await uploadImage(tempPath) | |||
| if (type === 'front') { | |||
| form.frontImage = url | |||
| await doOcrFront(url) | |||
| } else if (type === 'back') { | |||
| form.backImage = url | |||
| await doOcrBack(url) | |||
| } else if (type === 'child') { | |||
| form.childImage = url | |||
| } | |||
| } catch (e) { | |||
| // 上传失败已提示 | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| const sendSms = async () => { | |||
| if (countdown.value > 0) return | |||
| if (!form.phone || form.phone.length !== 11) { | |||
| uni.showToast({ title: '请输入正确的手机号码', icon: 'none' }) | |||
| return | |||
| } | |||
| try { | |||
| const res = await post('/api/mp/sendSmsCode', { mobile: form.phone, bizType: 'real_name_auth' }) | |||
| // 开发环境可能返回验证码 | |||
| if (res.data?.code) { | |||
| uni.showToast({ title: `验证码: ${res.data.code}`, icon: 'none', duration: 3000 }) | |||
| } else { | |||
| uni.showToast({ title: '验证码已发送', icon: 'none' }) | |||
| } | |||
| countdown.value = 60 | |||
| timer = setInterval(() => { | |||
| countdown.value-- | |||
| if (countdown.value <= 0) { clearInterval(timer); timer = null } | |||
| }, 1000) | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } | |||
| } | |||
| const handleSubmit = async () => { | |||
| if (submitting.value) return | |||
| // 前端校验 | |||
| const idCardType = certTypeMap[certType.value] | |||
| if (idCardType === 2) { | |||
| if (!form.childImage) return uni.showToast({ title: '请上传免冠照片', icon: 'none' }) | |||
| } else { | |||
| if (!form.frontImage) return uni.showToast({ title: '请上传证件人像面', icon: 'none' }) | |||
| if (!form.backImage) return uni.showToast({ title: '请上传证件国徽面', icon: 'none' }) | |||
| } | |||
| if (!form.name) return uni.showToast({ title: '请输入证件姓名', icon: 'none' }) | |||
| if (!form.idNo) return uni.showToast({ title: '请输入证件号码', icon: 'none' }) | |||
| if (!form.phone || form.phone.length !== 11) return uni.showToast({ title: '请输入正确的手机号', icon: 'none' }) | |||
| if (!form.smsCode || form.smsCode.length !== 6) return uni.showToast({ title: '请输入6位验证码', icon: 'none' }) | |||
| submitting.value = true | |||
| try { | |||
| await post('/api/mp/authSubmit', { | |||
| idCardType, | |||
| idCardFront: form.frontImage, | |||
| idCardBack: form.backImage, | |||
| photo: form.childImage, | |||
| realName: form.name, | |||
| idCard: form.idNo, | |||
| gender: form.gender, | |||
| birthday: form.birthday, | |||
| issuingAuthority: form.authority, | |||
| validPeriod: form.validity, | |||
| mobile: form.phone, | |||
| code: form.smsCode | |||
| }) | |||
| // 刷新用户信息缓存 | |||
| try { | |||
| const userRes = await get('/api/mp/userinfo') | |||
| setUserInfo(userRes.data) | |||
| } catch (e) {} | |||
| uni.showToast({ title: '认证成功', icon: 'success' }) | |||
| setTimeout(() => { uni.navigateBack() }, 1500) | |||
| } catch (e) { | |||
| if (e && e.msg) uni.showToast({ title: e.msg, icon: 'none' }) | |||
| } finally { | |||
| submitting.value = false | |||
| } | |||
| } | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| min-height: 100vh; | |||
| background: #f4f4f5; | |||
| padding-bottom: 200rpx; | |||
| } | |||
| .page-content { | |||
| padding: 32rpx; | |||
| } | |||
| .page-title { | |||
| font-size: 36rpx; | |||
| font-weight: 600; | |||
| color: #303133; | |||
| margin-bottom: 32rpx; | |||
| } | |||
| .section { | |||
| background: #fff; | |||
| border-radius: 8rpx; | |||
| padding: 40rpx 32rpx; | |||
| margin-bottom: 24rpx; | |||
| border: 1rpx solid #ebeef5; | |||
| } | |||
| .section-header { | |||
| display: flex; | |||
| align-items: center; | |||
| flex-wrap: wrap; | |||
| gap: 12rpx; | |||
| margin-bottom: 8rpx; | |||
| } | |||
| .step-num { | |||
| width: 44rpx; | |||
| height: 44rpx; | |||
| border-radius: 50%; | |||
| background: #0e63e3; | |||
| color: #fff; | |||
| font-size: 24rpx; | |||
| font-weight: 600; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| flex-shrink: 0; | |||
| } | |||
| .label { | |||
| font-size: 32rpx; | |||
| font-weight: 600; | |||
| color: #303133; | |||
| } | |||
| .tag { | |||
| font-size: 22rpx; | |||
| border-radius: 8rpx; | |||
| padding: 4rpx 16rpx; | |||
| &.warn { | |||
| color: #e6a23c; | |||
| background: #fdf6ec; | |||
| border: 1rpx solid #faecd8; | |||
| } | |||
| &.info { | |||
| color: #3a85f0; | |||
| background: #e8f0fe; | |||
| border: 1rpx solid #c6d9f7; | |||
| } | |||
| } | |||
| .section-desc { | |||
| font-size: 26rpx; | |||
| color: #909399; | |||
| margin: 12rpx 0 28rpx 56rpx; | |||
| } | |||
| .cert-tabs { | |||
| display: flex; | |||
| gap: 0; | |||
| margin-bottom: 32rpx; | |||
| } | |||
| .cert-tab { | |||
| flex: 1; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| padding: 16rpx 0; | |||
| border: 1rpx solid #dcdfe6; | |||
| font-size: 26rpx; | |||
| color: #606266; | |||
| background: #fff; | |||
| white-space: nowrap; | |||
| margin-left: -1rpx; | |||
| &:first-child { | |||
| border-radius: 8rpx 0 0 8rpx; | |||
| margin-left: 0; | |||
| } | |||
| &:last-child { | |||
| border-radius: 0 8rpx 8rpx 0; | |||
| } | |||
| &.active { | |||
| color: #0e63e3; | |||
| border-color: #0e63e3; | |||
| background: #e8f0fe; | |||
| position: relative; | |||
| z-index: 1; | |||
| } | |||
| } | |||
| .upload-area { | |||
| display: flex; | |||
| gap: 24rpx; | |||
| } | |||
| .upload-item { | |||
| flex: 1; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| } | |||
| .upload-img { | |||
| width: 100%; | |||
| height: 200rpx; | |||
| border-radius: 12rpx; | |||
| } | |||
| .upload-label { | |||
| font-size: 24rpx; | |||
| color: #606266; | |||
| margin-top: 12rpx; | |||
| } | |||
| .child-upload { | |||
| margin-top: 24rpx; | |||
| } | |||
| .child-tip { | |||
| font-size: 26rpx; | |||
| color: #303133; | |||
| margin-bottom: 8rpx; | |||
| .tip-highlight { | |||
| color: #0e63e3; | |||
| margin-left: 10rpx; | |||
| } | |||
| } | |||
| .child-desc { | |||
| font-size: 22rpx; | |||
| color: #909399; | |||
| margin-bottom: 20rpx; | |||
| } | |||
| .child-photo-box { | |||
| width: 200rpx; | |||
| height: 200rpx; | |||
| border-radius: 12rpx; | |||
| overflow: hidden; | |||
| } | |||
| .child-photo-img { | |||
| width: 100%; | |||
| height: 100%; | |||
| } | |||
| .child-photo-placeholder { | |||
| width: 100%; | |||
| height: 100%; | |||
| background: #f5f5f5; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| } | |||
| .form-row { | |||
| display: flex; | |||
| align-items: center; | |||
| padding: 28rpx 0; | |||
| border-bottom: 1rpx solid #f2f6fc; | |||
| &.border-none { | |||
| border-bottom: none; | |||
| } | |||
| } | |||
| .form-label { | |||
| width: 160rpx; | |||
| font-size: 28rpx; | |||
| color: #303133; | |||
| flex-shrink: 0; | |||
| } | |||
| .form-input { | |||
| flex: 1; | |||
| font-size: 28rpx; | |||
| color: #303133; | |||
| } | |||
| .phone-row { | |||
| margin-top: 20rpx; | |||
| } | |||
| .phone-input { | |||
| width: 100%; | |||
| height: 80rpx; | |||
| border: 1rpx solid #dcdfe6; | |||
| border-radius: 8rpx; | |||
| padding: 0 24rpx; | |||
| font-size: 28rpx; | |||
| box-sizing: border-box; | |||
| } | |||
| .sms-row { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 20rpx; | |||
| margin-top: 20rpx; | |||
| } | |||
| .sms-input { | |||
| flex: 1; | |||
| height: 80rpx; | |||
| border: 1rpx solid #dcdfe6; | |||
| border-radius: 8rpx; | |||
| padding: 0 24rpx; | |||
| font-size: 28rpx; | |||
| box-sizing: border-box; | |||
| } | |||
| .sms-btn { | |||
| height: 80rpx; | |||
| line-height: 80rpx; | |||
| padding: 0 28rpx; | |||
| background: #0e63e3; | |||
| color: #fff; | |||
| border: none; | |||
| border-radius: 8rpx; | |||
| font-size: 26rpx; | |||
| white-space: nowrap; | |||
| &::after { | |||
| border: none; | |||
| } | |||
| &:active { | |||
| opacity: 0.85; | |||
| } | |||
| &[disabled] { | |||
| background: #a0cfff; | |||
| color: #fff; | |||
| } | |||
| } | |||
| .submit-bar { | |||
| position: fixed; | |||
| bottom: 0; | |||
| left: 0; | |||
| right: 0; | |||
| padding: 24rpx 32rpx; | |||
| padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); | |||
| background: #fff; | |||
| border-top: 1rpx solid #ebeef5; | |||
| z-index: 10; | |||
| } | |||
| .submit-btn { | |||
| width: 100%; | |||
| height: 88rpx; | |||
| line-height: 88rpx; | |||
| background: #0e63e3; | |||
| color: #fff; | |||
| border: none; | |||
| border-radius: 44rpx; | |||
| font-size: 32rpx; | |||
| font-weight: 500; | |||
| box-shadow: 0 8rpx 24rpx rgba(14, 99, 227, 0.3); | |||
| &::after { | |||
| border: none; | |||
| } | |||
| &:active { | |||
| opacity: 0.85; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,65 @@ | |||
| lockfileVersion: '9.0' | |||
| settings: | |||
| autoInstallPeers: true | |||
| excludeLinksFromLockfile: false | |||
| importers: | |||
| .: | |||
| dependencies: | |||
| clipboard: | |||
| specifier: ^2.0.11 | |||
| version: 2.0.11 | |||
| dayjs: | |||
| specifier: ^1.11.10 | |||
| version: 1.11.20 | |||
| uview-plus: | |||
| specifier: ^3.7.0 | |||
| version: 3.7.13 | |||
| packages: | |||
| clipboard@2.0.11: | |||
| resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} | |||
| dayjs@1.11.20: | |||
| resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} | |||
| delegate@3.2.0: | |||
| resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} | |||
| good-listener@1.2.2: | |||
| resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} | |||
| select@1.1.2: | |||
| resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} | |||
| tiny-emitter@2.1.0: | |||
| resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} | |||
| uview-plus@3.7.13: | |||
| resolution: {integrity: sha512-vHByf0kxKReYxam6BuU6wn/80giCkMaMUHEblhkf4kAjP852b86V3ctkjfGtV17MEIORFo3Vkve+HFnHNXpwNg==} | |||
| engines: {HBuilderX: ^3.1.0, uni-app: ^4.66, uni-app-x: ''} | |||
| snapshots: | |||
| clipboard@2.0.11: | |||
| dependencies: | |||
| good-listener: 1.2.2 | |||
| select: 1.1.2 | |||
| tiny-emitter: 2.1.0 | |||
| dayjs@1.11.20: {} | |||
| delegate@3.2.0: {} | |||
| good-listener@1.2.2: | |||
| dependencies: | |||
| delegate: 3.2.0 | |||
| select@1.1.2: {} | |||
| tiny-emitter@2.1.0: {} | |||
| uview-plus@3.7.13: {} | |||
| @@ -0,0 +1,13 @@ | |||
| uni.addInterceptor({ | |||
| returnValue (res) { | |||
| if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) { | |||
| return res; | |||
| } | |||
| return new Promise((resolve, reject) => { | |||
| res.then((res) => { | |||
| if (!res) return resolve(res) | |||
| return res[0] ? reject(res[0]) : resolve(res[1]) | |||
| }); | |||
| }); | |||
| }, | |||
| }); | |||
| @@ -0,0 +1,58 @@ | |||
| /** | |||
| * uni-app 内置样式变量 | |||
| */ | |||
| /* uview-plus 主题样式 */ | |||
| @import 'uview-plus/theme.scss'; | |||
| /* 覆盖 uview-plus 主题色 */ | |||
| $u-primary: #0E63E3; | |||
| $u-primary-light: #3A85F0; | |||
| $u-primary-dark: #0B4FB5; | |||
| $u-primary-disabled: #A0C4F5; | |||
| /* 颜色变量 */ | |||
| $uni-color-primary: #0E63E3; | |||
| $uni-color-success: #4cd964; | |||
| $uni-color-warning: #f0ad4e; | |||
| $uni-color-error: #dd524d; | |||
| /* 文字基本颜色 */ | |||
| $uni-text-color:#333; | |||
| $uni-text-color-inverse:#fff; | |||
| $uni-text-color-grey:#999; | |||
| $uni-text-color-placeholder: #808080; | |||
| $uni-text-color-disable:#c0c0c0; | |||
| /* 背景颜色 */ | |||
| $uni-bg-color:#ffffff; | |||
| $uni-bg-color-grey:#f8f8f8; | |||
| $uni-bg-color-hover:#f1f1f1; | |||
| $uni-bg-color-mask:rgba(0, 0, 0, 0.4); | |||
| /* 边框颜色 */ | |||
| $uni-border-color:#c8c7cc; | |||
| /* 尺寸变量 */ | |||
| $uni-font-size-sm:12px; | |||
| $uni-font-size-base:14px; | |||
| $uni-font-size-lg:16px; | |||
| $uni-img-size-sm:20px; | |||
| $uni-img-size-base:26px; | |||
| $uni-img-size-lg:40px; | |||
| $uni-border-radius-sm: 2px; | |||
| $uni-border-radius-base: 3px; | |||
| $uni-border-radius-lg: 6px; | |||
| $uni-border-radius-circle: 50%; | |||
| $uni-spacing-row-sm: 5px; | |||
| $uni-spacing-row-base: 10px; | |||
| $uni-spacing-row-lg: 15px; | |||
| $uni-spacing-col-sm: 4px; | |||
| $uni-spacing-col-base: 8px; | |||
| $uni-spacing-col-lg: 12px; | |||
| $uni-opacity-disabled: 0.3; | |||
| @@ -0,0 +1,192 @@ | |||
| ## 为减小组件包的大小,默认组件包中不包含编辑、latex 公式等扩展功能,需要使用扩展功能的请参考下方的 插件扩展 栏的说明 | |||
| ## 功能介绍 | |||
| - 全端支持(含 `v3、NVUE`) | |||
| - 支持丰富的标签(包括 `table`、`video`、`svg` 等) | |||
| - 支持丰富的事件效果(自动预览图片、链接处理等) | |||
| - 支持设置占位图(加载中、出错时、预览时) | |||
| - 支持锚点跳转、长按复制等丰富功能 | |||
| - 支持大部分 *html* 实体 | |||
| - 丰富的插件(关键词搜索、内容编辑、`latex` 公式等) | |||
| - 效率高、容错性强且轻量化 | |||
| 查看 [功能介绍](https://jin-yufeng.github.io/mp-html/#/overview/feature) 了解更多 | |||
| ## 使用方法 | |||
| - `uni_modules` 方式 | |||
| 1. 点击右上角的 `使用 HBuilder X 导入插件` 按钮直接导入项目或点击 `下载插件 ZIP` 按钮下载插件包并解压到项目的 `uni_modules/mp-html` 目录下 | |||
| 2. 在需要使用页面的 `(n)vue` 文件中添加 | |||
| ```html | |||
| <!-- 不需要引入,可直接使用 --> | |||
| <mp-html :content="html" /> | |||
| ``` | |||
| ```javascript | |||
| export default { | |||
| data() { | |||
| return { | |||
| html: '<div>Hello World!</div>' | |||
| } | |||
| } | |||
| } | |||
| ``` | |||
| 3. 需要更新版本时在 `HBuilder X` 中右键 `uni_modules/mp-html` 目录选择 `从插件市场更新` 即可 | |||
| - 源码方式 | |||
| 1. 从 [github](https://github.com/jin-yufeng/mp-html/tree/master/dist/uni-app) 或 [gitee](https://gitee.com/jin-yufeng/mp-html/tree/master/dist/uni-app) 下载源码 | |||
| 插件市场的 **非 uni_modules 版本** 无法更新,不建议从插件市场获取 | |||
| 2. 在需要使用页面的 `(n)vue` 文件中添加 | |||
| ```html | |||
| <mp-html :content="html" /> | |||
| ``` | |||
| ```javascript | |||
| import mpHtml from '@/components/mp-html/mp-html' | |||
| export default { | |||
| // HBuilderX 2.5.5+ 可以通过 easycom 自动引入 | |||
| components: { | |||
| mpHtml | |||
| }, | |||
| data() { | |||
| return { | |||
| html: '<div>Hello World!</div>' | |||
| } | |||
| } | |||
| } | |||
| ``` | |||
| - npm 方式 | |||
| 1. 在项目根目录下执行 | |||
| ```bash | |||
| npm install mp-html | |||
| ``` | |||
| 2. 在需要使用页面的 `(n)vue` 文件中添加 | |||
| ```html | |||
| <mp-html :content="html" /> | |||
| ``` | |||
| ```javascript | |||
| import mpHtml from 'mp-html/dist/uni-app/components/mp-html/mp-html' | |||
| export default { | |||
| // 不可省略 | |||
| components: { | |||
| mpHtml | |||
| }, | |||
| data() { | |||
| return { | |||
| html: '<div>Hello World!</div>' | |||
| } | |||
| } | |||
| } | |||
| ``` | |||
| 3. 需要更新版本时执行以下命令即可 | |||
| ```bash | |||
| npm update mp-html | |||
| ``` | |||
| 使用 *cli* 方式运行的项目,通过 *npm* 方式引入时,需要在 *vue.config.js* 中配置 *transpileDependencies*,详情可见 [#330](https://github.com/jin-yufeng/mp-html/issues/330#issuecomment-913617687) | |||
| 如果在 **nvue** 中使用还要将 `dist/uni-app/static` 目录下的内容拷贝到项目的 `static` 目录下,否则无法运行 | |||
| 查看 [快速开始](https://jin-yufeng.github.io/mp-html/#/overview/quickstart) 了解更多 | |||
| ## 组件属性 | |||
| | 属性 | 类型 | 默认值 | 说明 | | |||
| |:---:|:---:|:---:|---| | |||
| | container-style | String | | 容器的样式([2.1.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v210)) | | |||
| | content | String | | 用于渲染的 html 字符串 | | |||
| | copy-link | Boolean | true | 是否允许外部链接被点击时自动复制 | | |||
| | domain | String | | 主域名(用于链接拼接) | | |||
| | error-img | String | | 图片出错时的占位图链接 | | |||
| | lazy-load | Boolean | false | 是否开启图片懒加载 | | |||
| | loading-img | String | | 图片加载过程中的占位图链接 | | |||
| | pause-video | Boolean | true | 是否在播放一个视频时自动暂停其他视频 | | |||
| | preview-img | Boolean | true | 是否允许图片被点击时自动预览 | | |||
| | scroll-table | Boolean | false | 是否给每个表格添加一个滚动层使其能单独横向滚动 | | |||
| | selectable | Boolean | false | 是否开启文本长按复制 | | |||
| | set-title | Boolean | true | 是否将 title 标签的内容设置到页面标题 | | |||
| | show-img-menu | Boolean | true | 是否允许图片被长按时显示菜单 | | |||
| | tag-style | Object | | 设置标签的默认样式 | | |||
| | use-anchor | Boolean | false | 是否使用锚点链接 | | |||
| 查看 [属性](https://jin-yufeng.github.io/mp-html/#/basic/prop) 了解更多 | |||
| ## 组件事件 | |||
| | 名称 | 触发时机 | | |||
| |:---:|---| | |||
| | load | dom 树加载完毕时 | | |||
| | ready | 图片加载完毕时 | | |||
| | error | 发生渲染错误时 | | |||
| | imgtap | 图片被点击时 | | |||
| | linktap | 链接被点击时 | | |||
| | play | 音视频播放时 | | |||
| 查看 [事件](https://jin-yufeng.github.io/mp-html/#/basic/event) 了解更多 | |||
| ## api | |||
| 组件实例上提供了一些 `api` 方法可供调用 | |||
| | 名称 | 作用 | | |||
| |:---:|---| | |||
| | in | 将锚点跳转的范围限定在一个 scroll-view 内 | | |||
| | navigateTo | 锚点跳转 | | |||
| | getText | 获取文本内容 | | |||
| | getRect | 获取富文本内容的位置和大小 | | |||
| | setContent | 设置富文本内容 | | |||
| | imgList | 获取所有图片的数组 | | |||
| | pauseMedia | 暂停播放音视频([2.2.2+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v222)) | | |||
| | setPlaybackRate | 设置音视频播放速率([2.4.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v240)) | | |||
| 查看 [api](https://jin-yufeng.github.io/mp-html/#/advanced/api) 了解更多 | |||
| ## 插件扩展 | |||
| 除基本功能外,本组件还提供了丰富的扩展,可按照需要选用 | |||
| | 名称 | 作用 | | |||
| |:---:|---| | |||
| | audio | 音乐播放器 | | |||
| | editable | 富文本 **编辑**([示例项目](https://mp-html.oss-cn-hangzhou.aliyuncs.com/editable.zip)) | | |||
| | emoji | 解析 emoji | | |||
| | highlight | 代码块高亮显示 | | |||
| | markdown | 渲染 markdown | | |||
| | search | 关键词搜索 | | |||
| | style | 匹配 style 标签中的样式 | | |||
| | txv-video | 使用腾讯视频 | | |||
| | img-cache | 图片缓存 by [@PentaTea](https://github.com/PentaTea) | | |||
| | latex | 渲染 latex 公式 by [@Zeng-J](https://github.com/Zeng-J) | | |||
| 从插件市场导入的包中 **不含有** 扩展插件,使用插件需通过微信小程序 `富文本插件` 获取或参考以下方法进行打包: | |||
| 1. 获取完整组件包 | |||
| ```bash | |||
| npm install mp-html | |||
| ``` | |||
| 2. 编辑 `tools/config.js` 中的 `plugins` 项,选择需要的插件 | |||
| 3. 生成新的组件包 | |||
| 在 `node_modules/mp-html` 目录下执行 | |||
| ```bash | |||
| npm install | |||
| npm run build:uni-app | |||
| ``` | |||
| 4. 拷贝 `dist/uni-app` 中的内容到项目根目录 | |||
| 查看 [插件](https://jin-yufeng.github.io/mp-html/#/advanced/plugin) 了解更多 | |||
| ## 关于 nvue | |||
| `nvue` 使用原生渲染,不支持部分 `css` 样式,为实现和 `html` 相同的效果,组件内部通过 `web-view` 进行渲染,性能上差于原生,根据 `weex` 官方建议,`web` 标签仅应用在非常规的降级场景。因此,如果通过原生的方式(如 `richtext`)能够满足需要,则不建议使用本组件,如果有较多的富文本内容,则可以直接使用 `vue` 页面 | |||
| 由于渲染方式与其他端不同,有以下限制: | |||
| 1. 不支持 `lazy-load` 属性 | |||
| 2. 视频不支持全屏播放 | |||
| 3. 如果在 `flex-direction: row` 的容器中使用,需要给组件设置宽度或设置 `flex: 1` 占满剩余宽度 | |||
| 纯 `nvue` 模式下,[此问题](https://ask.dcloud.net.cn/question/119678) 修复前,不支持通过 `uni_modules` 引入,需要本地引入(将 [dist/uni-app](https://github.com/jin-yufeng/mp-html/tree/master/dist/uni-app) 中的内容拷贝到项目根目录下) | |||
| ## 问题反馈 | |||
| 遇到问题时,请先查阅 [常见问题](https://jin-yufeng.github.io/mp-html/#/question/faq) 和 [issue](https://github.com/jin-yufeng/mp-html/issues) 中是否已有相同的问题 | |||
| 可通过 [issue](https://github.com/jin-yufeng/mp-html/issues/new/choose) 、插件问答或发送邮件到 [mp_html@126.com](mailto:mp_html@126.com) 提问,不建议在评论区提问(不方便回复) | |||
| 提问请严格按照 [issue 模板](https://github.com/jin-yufeng/mp-html/issues/new/choose) ,描述清楚使用环境、`html` 内容或可复现的 `demo` 项目以及复现方式,对于 **描述不清**、**无法复现** 或重复的问题将不予回复 | |||
| 欢迎加入 `QQ` 交流群: | |||
| 群1(已满):`699734691` | |||
| 群2(已满):`778239129` | |||
| 群3:`960265313` | |||
| 查看 [问题反馈](https://jin-yufeng.github.io/mp-html/#/question/feedback) 了解更多 | |||
| @@ -0,0 +1,163 @@ | |||
| ## v2.5.2(2025-12-14) | |||
| 1. `A` 增加了音视频暂停 [pause](https://jin-yufeng.github.io/mp-html/#/basic/event?id=pause) 和视频全屏 [fullscreenchange](https://jin-yufeng.github.io/mp-html/#/basic/event?id=fullscreenchange) 事件 [#495](https://github.com/jin-yufeng/mp-html/issues/495) [#595](https://github.com/jin-yufeng/mp-html/issues/595) | |||
| 2. `U` 优化了 [流式输出](https://jin-yufeng.github.io/mp-html/#/overview/feature?id=stream) 效果,通过差量更新解决闪烁问题 [详细](https://github.com/jin-yufeng/mp-html/issues/657) | |||
| 3. `U` `latex` 插件更新字体文件 [详细](https://github.com/jin-yufeng/mp-html/pull/647) by [@JiuyeXD](https://github.com/JiuyeXD) | |||
| 4. `U` 更新 `markdown` 插件中 `marked.js` 版本 [详细](https://github.com/jin-yufeng/mp-html/issues/672) | |||
| 5. `U` 微信小程序替换遗漏的废弃 `api` `getSystemInfoSync` [详细](https://github.com/jin-yufeng/mp-html/pull/653) by [@zcSkr](https://github.com/zcSkr) | |||
| 6. `F` 修复了 `markdown` 插件加粗文本遇到中文符号无效的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/664) by [@qp666](https://github.com/qp666) | |||
| ## v2.5.1(2025-04-20) | |||
| 1. `U` 适配鸿蒙 `APP` [详细](https://github.com/jin-yufeng/mp-html/issues/615) | |||
| 2. `U` 微信小程序替换废弃 `api` `getSystemInfoSync` [详细](https://github.com/jin-yufeng/mp-html/issues/613) | |||
| 3. `F` 修复了 `app` 端播放视频可能报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/617) | |||
| 4. `F` 修复了 `latex` 插件可能出现 `xxx can be used only in display mode` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/632) | |||
| 5. `F` 修复了 `uni-app` 包 `latex` 公式可能不显示的问题 [#599](https://github.com/jin-yufeng/mp-html/issues/599)、[#627](https://github.com/jin-yufeng/mp-html/issues/627) | |||
| ## v2.5.0(2024-04-22) | |||
| 1. `U` `play` 事件增加返回 `src` 等信息 [详细](https://github.com/jin-yufeng/mp-html/issues/526) | |||
| 2. `U` `preview-img` 属性支持设置为 `all` 开启 `base64` 图片预览 [详细](https://github.com/jin-yufeng/mp-html/issues/536) | |||
| 3. `U` `editable` 插件增加简易模式(点击文字直接编辑) | |||
| 4. `U` `latex` 插件支持块级公式 [详细](https://github.com/jin-yufeng/mp-html/issues/582) | |||
| 5. `F` 修复了表格部分情况下背景丢失的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/587) | |||
| 6. `F` 修复了部分 `svg` 无法显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/591) | |||
| 7. `F` 修复了 `h5` 和 `app` 端部分情况下样式无法识别的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/518) | |||
| 8. `F` 修复了 `latex` 插件部分情况下显示不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/580) | |||
| 9. `F` 修复了 `editable` 插件表格无法删除的问题 | |||
| 10. `F` 修复了 `editable` 插件 `vue3` `h5` 端点击图片报错的问题 | |||
| 11. `F` 修复了 `editable` 插件点击表格没有菜单栏的问题 | |||
| ## v2.4.3(2024-01-21) | |||
| 1. `A` 增加 [card](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#card) 插件 [详细](https://github.com/jin-yufeng/mp-html/pull/533) by [@whoooami](https://github.com/whoooami) | |||
| 2. `F` 修复了 `svg` 中包含 `foreignobject` 可能不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/523) | |||
| 3. `F` 修复了合并单元格的表格部分情况下显示不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/561) | |||
| 4. `F` 修复了 `img` 标签设置 `object-fit` 无效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/567) | |||
| 5. `F` 修复了 `latex` 插件公式会换行的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/540) | |||
| 6. `F` 修复了 `editable` 和 `audio` 插件共用时点击 `audio` 无法编辑的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/529) by [@whoooami](https://github.com/whoooami) | |||
| 7. `F` 修复了微信小程序部分情况下图片会报错 `replace of undefined` 的问题 | |||
| 8. `F` 修复了快手小程序图片不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/571) | |||
| ## v2.4.2(2023-05-14) | |||
| 1. `A` `editable` 插件支持修改文字颜色 [详细](https://github.com/jin-yufeng/mp-html/issues/254) | |||
| 2. `F` 修复了 `svg` 中有 `style` 不生效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/505) | |||
| 3. `F` 修复了使用旧版编译器可能报错 `Bad attr nodes` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/472) | |||
| 4. `F` 修复了 `app` 端可能出现无法读取 `lazyLoad` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/513) | |||
| 5. `F` 修复了 `editable` 插件在点击换图时未拼接 `domain` 的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/497) by [@TwoKe945](https://github.com/TwoKe945) | |||
| 6. `F` 修复了 `latex` 插件部分情况下不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/515) | |||
| 7. `F` 修复了 `editable` 插件点击音视频时其他标签框不消失的问题 | |||
| ## v2.4.1(2022-12-25) | |||
| 1. `F` 修复了没有图片时 `ready` 事件可能不触发的问题 | |||
| 2. `F` 修复了加载过程中可能出现 `Root label not found` 错误的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/470) | |||
| 3. `F` 修复了 `audio` 插件退出页面可能会报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/457) | |||
| 4. `F` 修复了 `vue3` 运行到 `app` 在 `HBuilder X 3.6.10` 以上报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/480) | |||
| 5. `F` 修复了 `nvue` 端链接中包含 `%22` 时可能无法显示的问题 | |||
| 6. `F` 修复了 `vue3` 使用 `highlight` 插件可能报错的问题 | |||
| ## v2.4.0(2022-08-27) | |||
| 1. `A` 增加了 [setPlaybackRate](https://jin-yufeng.gitee.io/mp-html/#/advanced/api#setPlaybackRate) 的 `api`,可以设置音视频的播放速率 [详细](https://github.com/jin-yufeng/mp-html/issues/452) | |||
| 2. `A` 示例小程序代码开源 [详细](https://github.com/jin-yufeng/mp-html-demo) | |||
| 3. `U` 优化 `ready` 事件触发时机,未设置懒加载的情况下基本可以准确触发 [详细](https://github.com/jin-yufeng/mp-html/issues/195) | |||
| 4. `U` `highlight` 插件在编辑状态下不进行高亮处理,便于编辑 | |||
| 5. `F` 修复了 `flex` 布局下图片大小可能不正确的问题 | |||
| 6. `F` 修复了 `selectable` 属性没有设置 `force` 也可能出现渲染异常的问题 | |||
| 7. `F` 修复了表格中的图片大小可能不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/448) | |||
| 8. `F` 修复了含有合并单元格的表格可能无法设置竖直对齐的问题 | |||
| 9. `F` 修复了 `editable` 插件在 `scroll-view` 中使用时工具条位置可能不正确的问题 | |||
| 10. `F` 修复了 `vue3` 使用 [search](advanced/plugin#search) 插件可能导致错误换行的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/449) | |||
| ## v2.3.2(2022-08-13) | |||
| 1. `A` 增加 [latex](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#latex) 插件,可以渲染数学公式 [详细](https://github.com/jin-yufeng/mp-html/pull/447) by [@Zeng-J](https://github.com/Zeng-J) | |||
| 2. `U` 优化根节点下有很多标签的长内容渲染速度 | |||
| 3. `U` `highlight` 插件适配 `lang-xxx` 格式 | |||
| 4. `F` 修复了 `table` 标签设置 `border` 属性后可能无法修改边框样式的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/439) by [@zouxingjie](https://github.com/zouxingjie) | |||
| 5. `F` 修复了 `editable` 插件输入连续空格无效的问题 | |||
| 6. `F` 修复了 `vue3` 图片设置 `inline` 会报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/438) | |||
| 7. `F` 修复了 `vue3` 使用 `table` 可能报错的问题 | |||
| ## v2.3.1(2022-05-20) | |||
| 1. `U` `app` 端支持使用本地图片 | |||
| 2. `U` 优化了微信小程序 `selectable` 属性在 `ios` 端的处理 [详细](https://jin-yufeng.gitee.io/mp-html/#/basic/prop#selectable) | |||
| 3. `F` 修复了 `editable` 插件不在顶部时 `tooltip` 位置可能错误的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/430) | |||
| 4. `F` 修复了 `vue3` 运行到微信小程序可能报错丢失内容的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/414) | |||
| 5. `F` 修复了 `vue3` 部分标签可能被错误换行的问题 | |||
| 6. `F` 修复了 `editable` 插件 `app` 端插入视频无法预览的问题 | |||
| ## v2.3.0(2022-04-01) | |||
| 1. `A` 增加了 `play` 事件,音视频播放时触发,可用于与页面其他音视频进行互斥播放 [详细](basic/event#play) | |||
| 2. `U` `show-img-menu` 属性支持控制预览时是否长按弹出菜单 | |||
| 3. `U` 优化 `wxs` 处理,提高渲染性能 [详细](https://developers.weixin.qq.com/community/develop/article/doc/0006cc2b204740f601bd43fa25a413) | |||
| 4. `U` `video` 标签支持 `object-fit` 属性 | |||
| 5. `U` 增加支持一些常用实体编码 [详细](https://github.com/jin-yufeng/mp-html/issues/418) | |||
| 6. `F` 修复了图片仅设置高度可能不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/410) | |||
| 7. `F` 修复了 `video` 标签高度设置为 `auto` 不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/411) | |||
| 8. `F` 修复了使用 `grid` 布局时可能样式错误的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/413) | |||
| 9. `F` 修复了含有合并单元格的表格部分情况下显示异常的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/417) | |||
| 10. `F` 修复了 `editable` 插件连续插入内容时顺序不正确的问题 | |||
| 11. `F` 修复了 `uni-app` 包 `vue3` 使用 `audio` 插件报错的问题 | |||
| 12. `F` 修复了 `uni-app` 包 `highlight` 插件使用自定义的 `prism.min.js` 报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/416) | |||
| ## v2.2.2(2022-02-26) | |||
| 1. `A` 增加了 [pauseMedia](https://jin-yufeng.gitee.io/mp-html/#/advanced/api#pauseMedia) 的 `api`,可用于暂停播放音视频 [详细](https://github.com/jin-yufeng/mp-html/issues/317) | |||
| 2. `U` 优化了长内容的加载速度 | |||
| 3. `U` 适配 `vue3` [#389](https://github.com/jin-yufeng/mp-html/issues/389)、[#398](https://github.com/jin-yufeng/mp-html/pull/398) by [@zhouhuafei](https://github.com/zhouhuafei)、[#400](https://github.com/jin-yufeng/mp-html/issues/400) | |||
| 4. `F` 修复了小程序端图片高度设置为百分比时可能不显示的问题 | |||
| 5. `F` 修复了 `highlight` 插件部分情况下可能显示不完整的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/403) | |||
| ## v2.2.1(2021-12-24) | |||
| 1. `A` `editable` 插件增加上下移动标签功能 | |||
| 2. `U` `editable` 插件支持在文本中间光标处插入内容 | |||
| 3. `F` 修复了 `nvue` 端设置 `margin` 后可能导致高度不正确的问题 | |||
| 4. `F` 修复了 `highlight` 插件使用压缩版的 `prism.css` 可能导致背景失效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/367) | |||
| 5. `F` 修复了编辑状态下使用 `emoji` 插件内容为空时可能报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/371) | |||
| 6. `F` 修复了使用 `editable` 插件后将 `selectable` 属性设置为 `force` 不生效的问题 | |||
| ## v2.2.0(2021-10-12) | |||
| 1. `A` 增加 `customElements` 配置项,便于添加自定义功能性标签 [详细](https://github.com/jin-yufeng/mp-html/issues/350) | |||
| 2. `A` `editable` 插件增加切换音视频自动播放状态的功能 [详细](https://github.com/jin-yufeng/mp-html/pull/341) by [@leeseett](https://github.com/leeseett) | |||
| 3. `A` `editable` 插件删除媒体标签时触发 `remove` 事件,便于删除已上传的文件 | |||
| 4. `U` `editable` 插件 `insertImg` 方法支持同时插入多张图片 [详细](https://github.com/jin-yufeng/mp-html/issues/342) | |||
| 5. `U` `editable` 插入图片和音视频时支持拼接 `domian` 主域名 | |||
| 6. `F` 修复了内部链接参数中包含 `://` 时被认为是外部链接的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/356) | |||
| 7. `F` 修复了部分 `svg` 标签名或属性名大小写不正确时不生效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/351) | |||
| 8. `F` 修复了 `nvue` 页面运行到非 `app` 平台时可能样式错误的问题 | |||
| ## v2.1.5(2021-08-13) | |||
| 1. `A` 增加支持标签的 `dir` 属性 | |||
| 2. `F` 修复了 `ruby` 标签文字与拼音没有居中对齐的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/325) | |||
| 3. `F` 修复了音视频标签内有 `a` 标签时可能无法播放的问题 | |||
| 4. `F` 修复了 `externStyle` 中的 `class` 名包含下划线或数字时可能失效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/326) | |||
| 5. `F` 修复了 `h5` 端引入 `externStyle` 可能不生效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/326) | |||
| ## v2.1.4(2021-07-14) | |||
| 1. `F` 修复了 `rt` 标签无法设置样式的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/318) | |||
| 2. `F` 修复了表格中有单元格同时合并行和列时可能显示不正确的问题 | |||
| 3. `F` 修复了 `app` 端无法关闭图片长按菜单的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/322) | |||
| 4. `F` 修复了 `editable` 插件只能添加图片链接不能修改的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/312) by [@leeseett](https://github.com/leeseett) | |||
| ## v2.1.3(2021-06-12) | |||
| 1. `A` `editable` 插件增加 `insertTable` 方法 | |||
| 2. `U` `editable` 插件支持编辑表格中的空白单元格 [详细](https://github.com/jin-yufeng/mp-html/issues/310) | |||
| 3. `F` 修复了 `externStyle` 中使用伪类可能失效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/298) | |||
| 4. `F` 修复了多个组件同时使用时 `tag-style` 属性时可能互相影响的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/305) by [@woodguoyu](https://github.com/woodguoyu) | |||
| 5. `F` 修复了包含 `linearGradient` 的 `svg` 可能无法显示的问题 | |||
| 6. `F` 修复了编译到头条小程序时可能报错的问题 | |||
| 7. `F` 修复了 `nvue` 端不触发 `click` 事件的问题 | |||
| 8. `F` 修复了 `editable` 插件尾部插入时无法撤销的问题 | |||
| 9. `F` 修复了 `editable` 插件的 `insertHtml` 方法只能在末尾插入的问题 | |||
| 10. `F` 修复了 `editable` 插件插入音频不显示的问题 | |||
| ## v2.1.2(2021-04-24) | |||
| 1. `A` 增加了 [img-cache](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#img-cache) 插件,可以在 `app` 端缓存图片 [详细](https://github.com/jin-yufeng/mp-html/issues/292) by [@PentaTea](https://github.com/PentaTea) | |||
| 2. `U` 支持通过 `container-style` 属性设置 `white-space` 来保留连续空格和换行符 [详细](https://jin-yufeng.gitee.io/mp-html/#/question/faq#space) | |||
| 3. `U` 代码风格符合 [standard](https://standardjs.com) 标准 | |||
| 4. `U` `editable` 插件编辑状态下支持预览视频 [详细](https://github.com/jin-yufeng/mp-html/issues/286) | |||
| 5. `F` 修复了 `svg` 标签内嵌 `svg` 时无法显示的问题 | |||
| 6. `F` 修复了编译到支付宝和头条小程序时部分区域不可复制的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/291) | |||
| ## v2.1.1(2021-04-09) | |||
| 1. 修复了对 `p` 标签设置 `tag-style` 可能不生效的问题 | |||
| 2. 修复了 `svg` 标签中的文本无法显示的问题 | |||
| 3. 修复了使用 `editable` 插件编辑表格时可能报错的问题 | |||
| 4. 修复了使用 `highlight` 插件运行到头条小程序时可能没有样式的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/280) | |||
| 5. 修复了使用 `editable` 插件 `editable` 属性为 `false` 时会报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/284) | |||
| 6. 修复了 `style` 插件连续子选择器失效的问题 | |||
| 7. 修复了 `editable` 插件无法修改图片和字体大小的问题 | |||
| ## v2.1.0.2(2021-03-21) | |||
| 修复了 `nvue` 端使用可能报错的问题 | |||
| ## v2.1.0(2021-03-20) | |||
| 1. `A` 增加了 [container-style](https://jin-yufeng.gitee.io/mp-html/#/basic/prop#container-style) 属性 [详细](https://gitee.com/jin-yufeng/mp-html/pulls/1) | |||
| 2. `A` 增加支持 `strike` 标签 | |||
| 3. `A` `editable` 插件增加 `placeholder` 属性 [详细](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#editable) | |||
| 4. `A` `editable` 插件增加 `insertHtml` 方法 [详细](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#editable) | |||
| 5. `U` 外部样式支持标签名选择器 [详细](https://jin-yufeng.gitee.io/mp-html/#/overview/quickstart#setting) | |||
| 6. `F` 修复了 `nvue` 端部分情况下可能不显示的问题 | |||
| ## v2.0.5(2021-03-12) | |||
| 1. `U` [linktap](https://jin-yufeng.gitee.io/mp-html/#/basic/event#linktap) 事件增加返回内部文本内容 `innerText` [详细](https://github.com/jin-yufeng/mp-html/issues/271) | |||
| 2. `U` [selectable](https://jin-yufeng.gitee.io/mp-html/#/basic/prop#selectable) 属性设置为 `force` 时能够在微信 `iOS` 端生效(文本块会变成 `inline-block`) [详细](https://github.com/jin-yufeng/mp-html/issues/267) | |||
| 3. `F` 修复了部分情况下竖向无法滚动的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/182) | |||
| 4. `F` 修复了多次修改富文本数据时部分内容可能不显示的问题 | |||
| 5. `F` 修复了 [腾讯视频](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#txv-video) 插件可能无法播放的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/265) | |||
| 6. `F` 修复了 [highlight](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#highlight) 插件没有设置高亮语言时没有应用默认样式的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/276) by [@fuzui](https://github.com/fuzui) | |||
| @@ -0,0 +1,500 @@ | |||
| <template> | |||
| <view id="_root" :class="(selectable?'_select ':'')+'_root'" :style="containerStyle"> | |||
| <slot v-if="!nodes[0]" /> | |||
| <!-- #ifndef APP-PLUS-NVUE --> | |||
| <node v-else :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]" name="span" /> | |||
| <!-- #endif --> | |||
| <!-- #ifdef APP-PLUS-NVUE --> | |||
| <web-view ref="web" src="/uni_modules/mp-html/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" /> | |||
| <!-- #endif --> | |||
| </view> | |||
| </template> | |||
| <script> | |||
| /** | |||
| * mp-html v2.5.2 | |||
| * @description 富文本组件 | |||
| * @tutorial https://github.com/jin-yufeng/mp-html | |||
| * @property {String} container-style 容器的样式 | |||
| * @property {String} content 用于渲染的 html 字符串 | |||
| * @property {Boolean} copy-link 是否允许外部链接被点击时自动复制 | |||
| * @property {String} domain 主域名,用于拼接链接 | |||
| * @property {String} error-img 图片出错时的占位图链接 | |||
| * @property {Boolean} lazy-load 是否开启图片懒加载 | |||
| * @property {string} loading-img 图片加载过程中的占位图链接 | |||
| * @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频 | |||
| * @property {Boolean} preview-img 是否允许图片被点击时自动预览 | |||
| * @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动 | |||
| * @property {Boolean | String} selectable 是否开启长按复制 | |||
| * @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题 | |||
| * @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单 | |||
| * @property {Object} tag-style 标签的默认样式 | |||
| * @property {Boolean | Number} use-anchor 是否使用锚点链接 | |||
| * @event {Function} load dom 结构加载完毕时触发 | |||
| * @event {Function} ready 所有图片加载完毕时触发 | |||
| * @event {Function} imgtap 图片被点击时触发 | |||
| * @event {Function} linktap 链接被点击时触发 | |||
| * @event {Function} play 音视频播放时触发 | |||
| * @event {Function} error 媒体加载出错时触发 | |||
| * @event {Function} pause 音视频暂停时触发 | |||
| * @event {Function} fullscreenchange 视频全屏状态变化时触发 | |||
| */ | |||
| // #ifndef APP-PLUS-NVUE | |||
| import node from './node/node' | |||
| // #endif | |||
| import Parser from './parser' | |||
| const plugins=[] | |||
| // #ifdef APP-PLUS-NVUE | |||
| const dom = weex.requireModule('dom') | |||
| // #endif | |||
| export default { | |||
| name: 'mp-html', | |||
| data () { | |||
| return { | |||
| nodes: [], | |||
| // #ifdef APP-PLUS-NVUE | |||
| height: 3 | |||
| // #endif | |||
| } | |||
| }, | |||
| props: { | |||
| containerStyle: { | |||
| type: String, | |||
| default: '' | |||
| }, | |||
| content: { | |||
| type: String, | |||
| default: '' | |||
| }, | |||
| copyLink: { | |||
| type: [Boolean, String], | |||
| default: true | |||
| }, | |||
| domain: String, | |||
| errorImg: { | |||
| type: String, | |||
| default: '' | |||
| }, | |||
| lazyLoad: { | |||
| type: [Boolean, String], | |||
| default: false | |||
| }, | |||
| loadingImg: { | |||
| type: String, | |||
| default: '' | |||
| }, | |||
| pauseVideo: { | |||
| type: [Boolean, String], | |||
| default: true | |||
| }, | |||
| previewImg: { | |||
| type: [Boolean, String], | |||
| default: true | |||
| }, | |||
| scrollTable: [Boolean, String], | |||
| selectable: [Boolean, String], | |||
| setTitle: { | |||
| type: [Boolean, String], | |||
| default: true | |||
| }, | |||
| showImgMenu: { | |||
| type: [Boolean, String], | |||
| default: true | |||
| }, | |||
| tagStyle: Object, | |||
| useAnchor: [Boolean, Number] | |||
| }, | |||
| // #ifdef VUE3 | |||
| emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'], | |||
| // #endif | |||
| // #ifndef APP-PLUS-NVUE | |||
| components: { | |||
| node | |||
| }, | |||
| // #endif | |||
| watch: { | |||
| content (content) { | |||
| this.setContent(content) | |||
| } | |||
| }, | |||
| created () { | |||
| this.plugins = [] | |||
| for (let i = plugins.length; i--;) { | |||
| this.plugins.push(new plugins[i](this)) | |||
| } | |||
| }, | |||
| mounted () { | |||
| if (this.content && !this.nodes.length) { | |||
| this.setContent(this.content) | |||
| } | |||
| }, | |||
| beforeDestroy () { | |||
| this._hook('onDetached') | |||
| }, | |||
| methods: { | |||
| /** | |||
| * @description 将锚点跳转的范围限定在一个 scroll-view 内 | |||
| * @param {Object} page scroll-view 所在页面的示例 | |||
| * @param {String} selector scroll-view 的选择器 | |||
| * @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名 | |||
| */ | |||
| in (page, selector, scrollTop) { | |||
| // #ifndef APP-PLUS-NVUE | |||
| if (page && selector && scrollTop) { | |||
| this._in = { | |||
| page, | |||
| selector, | |||
| scrollTop | |||
| } | |||
| } | |||
| // #endif | |||
| }, | |||
| /** | |||
| * @description 锚点跳转 | |||
| * @param {String} id 要跳转的锚点 id | |||
| * @param {Number} offset 跳转位置的偏移量 | |||
| * @returns {Promise} | |||
| */ | |||
| navigateTo (id, offset) { | |||
| return new Promise((resolve, reject) => { | |||
| if (!this.useAnchor) { | |||
| reject(Error('Anchor is disabled')) | |||
| return | |||
| } | |||
| offset = offset || parseInt(this.useAnchor) || 0 | |||
| // #ifdef APP-PLUS-NVUE | |||
| if (!id) { | |||
| dom.scrollToElement(this.$refs.web, { | |||
| offset | |||
| }) | |||
| resolve() | |||
| } else { | |||
| this._navigateTo = { | |||
| resolve, | |||
| reject, | |||
| offset | |||
| } | |||
| this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})') | |||
| } | |||
| // #endif | |||
| // #ifndef APP-PLUS-NVUE | |||
| let deep = ' ' | |||
| // #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO | |||
| deep = '>>>' | |||
| // #endif | |||
| const selector = uni.createSelectorQuery() | |||
| // #ifndef MP-ALIPAY | |||
| .in(this._in ? this._in.page : this) | |||
| // #endif | |||
| .select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect() | |||
| if (this._in) { | |||
| selector.select(this._in.selector).scrollOffset() | |||
| .select(this._in.selector).boundingClientRect() | |||
| } else { | |||
| // 获取 scroll-view 的位置和滚动距离 | |||
| selector.selectViewport().scrollOffset() // 获取窗口的滚动距离 | |||
| } | |||
| selector.exec(res => { | |||
| if (!res[0]) { | |||
| reject(Error('Label not found')) | |||
| return | |||
| } | |||
| const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset | |||
| if (this._in) { | |||
| // scroll-view 跳转 | |||
| this._in.page[this._in.scrollTop] = scrollTop | |||
| } else { | |||
| // 页面跳转 | |||
| uni.pageScrollTo({ | |||
| scrollTop, | |||
| duration: 300 | |||
| }) | |||
| } | |||
| resolve() | |||
| }) | |||
| // #endif | |||
| }) | |||
| }, | |||
| /** | |||
| * @description 获取文本内容 | |||
| * @return {String} | |||
| */ | |||
| getText (nodes) { | |||
| let text = ''; | |||
| (function traversal (nodes) { | |||
| for (let i = 0; i < nodes.length; i++) { | |||
| const node = nodes[i] | |||
| if (node.type === 'text') { | |||
| text += node.text.replace(/&/g, '&') | |||
| } else if (node.name === 'br') { | |||
| text += '\n' | |||
| } else { | |||
| // 块级标签前后加换行 | |||
| const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7') | |||
| if (isBlock && text && text[text.length - 1] !== '\n') { | |||
| text += '\n' | |||
| } | |||
| // 递归获取子节点的文本 | |||
| if (node.children) { | |||
| traversal(node.children) | |||
| } | |||
| if (isBlock && text[text.length - 1] !== '\n') { | |||
| text += '\n' | |||
| } else if (node.name === 'td' || node.name === 'th') { | |||
| text += '\t' | |||
| } | |||
| } | |||
| } | |||
| })(nodes || this.nodes) | |||
| return text | |||
| }, | |||
| /** | |||
| * @description 获取内容大小和位置 | |||
| * @return {Promise} | |||
| */ | |||
| getRect () { | |||
| return new Promise((resolve, reject) => { | |||
| uni.createSelectorQuery() | |||
| // #ifndef MP-ALIPAY | |||
| .in(this) | |||
| // #endif | |||
| .select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found'))) | |||
| }) | |||
| }, | |||
| /** | |||
| * @description 暂停播放媒体 | |||
| */ | |||
| pauseMedia () { | |||
| for (let i = (this._videos || []).length; i--;) { | |||
| this._videos[i].pause() | |||
| } | |||
| // #ifdef APP-PLUS | |||
| const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()' | |||
| // #ifndef APP-PLUS-NVUE | |||
| let page = this.$parent | |||
| while (!page.$scope) page = page.$parent | |||
| page.$scope.$getAppWebview().evalJS(command) | |||
| // #endif | |||
| // #ifdef APP-PLUS-NVUE | |||
| this.$refs.web.evalJs(command) | |||
| // #endif | |||
| // #endif | |||
| }, | |||
| /** | |||
| * @description 设置媒体播放速率 | |||
| * @param {Number} rate 播放速率 | |||
| */ | |||
| setPlaybackRate (rate) { | |||
| this.playbackRate = rate | |||
| for (let i = (this._videos || []).length; i--;) { | |||
| this._videos[i].playbackRate(rate) | |||
| } | |||
| // #ifdef APP-PLUS | |||
| const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate | |||
| // #ifndef APP-PLUS-NVUE | |||
| let page = this.$parent | |||
| while (!page.$scope) page = page.$parent | |||
| page.$scope.$getAppWebview().evalJS(command) | |||
| // #endif | |||
| // #ifdef APP-PLUS-NVUE | |||
| this.$refs.web.evalJs(command) | |||
| // #endif | |||
| // #endif | |||
| }, | |||
| /** | |||
| * @description 设置内容 | |||
| * @param {String} content html 内容 | |||
| * @param {Boolean} append 是否在尾部追加 | |||
| */ | |||
| setContent (content, append) { | |||
| if (!append || !this.imgList) { | |||
| this.imgList = [] | |||
| } | |||
| const nodes = new Parser(this).parse(content) | |||
| // #ifdef APP-PLUS-NVUE | |||
| if (this._ready) { | |||
| this._set(nodes, append) | |||
| } | |||
| // #endif | |||
| this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes) | |||
| // #ifndef APP-PLUS-NVUE | |||
| this._videos = [] | |||
| this.$nextTick(() => { | |||
| this._hook('onLoad') | |||
| this.$emit('load') | |||
| }) | |||
| if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) { | |||
| // 设置懒加载,每 350ms 获取高度,不变则认为加载完毕 | |||
| let height = 0 | |||
| const callback = rect => { | |||
| if (!rect || !rect.height) rect = {} | |||
| // 350ms 总高度无变化就触发 ready 事件 | |||
| if (rect.height === height) { | |||
| this.$emit('ready', rect) | |||
| } else { | |||
| height = rect.height | |||
| setTimeout(() => { | |||
| this.getRect().then(callback).catch(callback) | |||
| }, 350) | |||
| } | |||
| } | |||
| this.getRect().then(callback).catch(callback) | |||
| } else { | |||
| // 未设置懒加载,等待所有图片加载完毕 | |||
| if (!this.imgList._unloadimgs) { | |||
| this.getRect().then(rect => { | |||
| this.$emit('ready', rect) | |||
| }).catch(() => { | |||
| this.$emit('ready', {}) | |||
| }) | |||
| } | |||
| } | |||
| // #endif | |||
| }, | |||
| /** | |||
| * @description 调用插件钩子函数 | |||
| */ | |||
| _hook (name) { | |||
| for (let i = plugins.length; i--;) { | |||
| if (this.plugins[i][name]) { | |||
| this.plugins[i][name]() | |||
| } | |||
| } | |||
| }, | |||
| // #ifdef APP-PLUS-NVUE | |||
| /** | |||
| * @description 设置内容 | |||
| */ | |||
| _set (nodes, append) { | |||
| this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes).replace(/%22/g, '') + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')') | |||
| }, | |||
| /** | |||
| * @description 接收到 web-view 消息 | |||
| */ | |||
| _onMessage (e) { | |||
| const message = e.detail.data[0] | |||
| switch (message.action) { | |||
| // web-view 初始化完毕 | |||
| case 'onJSBridgeReady': | |||
| this._ready = true | |||
| if (this.nodes) { | |||
| this._set(this.nodes) | |||
| } | |||
| break | |||
| // 内容 dom 加载完毕 | |||
| case 'onLoad': | |||
| this.height = message.height | |||
| this._hook('onLoad') | |||
| this.$emit('load') | |||
| break | |||
| // 所有图片加载完毕 | |||
| case 'onReady': | |||
| this.getRect().then(res => { | |||
| this.$emit('ready', res) | |||
| }).catch(() => { | |||
| this.$emit('ready', {}) | |||
| }) | |||
| break | |||
| // 总高度发生变化 | |||
| case 'onHeightChange': | |||
| this.height = message.height | |||
| break | |||
| // 图片点击 | |||
| case 'onImgTap': | |||
| this.$emit('imgtap', message.attrs) | |||
| if (this.previewImg) { | |||
| uni.previewImage({ | |||
| current: parseInt(message.attrs.i), | |||
| urls: this.imgList | |||
| }) | |||
| } | |||
| break | |||
| // 链接点击 | |||
| case 'onLinkTap': { | |||
| const href = message.attrs.href | |||
| this.$emit('linktap', message.attrs) | |||
| if (href) { | |||
| // 锚点跳转 | |||
| if (href[0] === '#') { | |||
| if (this.useAnchor) { | |||
| dom.scrollToElement(this.$refs.web, { | |||
| offset: message.offset | |||
| }) | |||
| } | |||
| } else if (href.includes('://')) { | |||
| // 打开外链 | |||
| if (this.copyLink) { | |||
| plus.runtime.openWeb(href) | |||
| } | |||
| } else { | |||
| uni.navigateTo({ | |||
| url: href, | |||
| fail () { | |||
| uni.switchTab({ | |||
| url: href | |||
| }) | |||
| } | |||
| }) | |||
| } | |||
| } | |||
| break | |||
| } | |||
| case 'onPlay': | |||
| this.$emit('play') | |||
| break | |||
| // 获取到锚点的偏移量 | |||
| case 'getOffset': | |||
| if (typeof message.offset === 'number') { | |||
| dom.scrollToElement(this.$refs.web, { | |||
| offset: message.offset + this._navigateTo.offset | |||
| }) | |||
| this._navigateTo.resolve() | |||
| } else { | |||
| this._navigateTo.reject(Error('Label not found')) | |||
| } | |||
| break | |||
| // 点击 | |||
| case 'onClick': | |||
| this.$emit('tap') | |||
| this.$emit('click') | |||
| break | |||
| // 出错 | |||
| case 'onError': | |||
| this.$emit('error', { | |||
| source: message.source, | |||
| attrs: message.attrs | |||
| }) | |||
| } | |||
| } | |||
| // #endif | |||
| } | |||
| } | |||
| </script> | |||
| <style> | |||
| /* #ifndef APP-PLUS-NVUE */ | |||
| /* 根节点样式 */ | |||
| ._root { | |||
| padding: 1px 0; | |||
| overflow-x: auto; | |||
| overflow-y: hidden; | |||
| -webkit-overflow-scrolling: touch; | |||
| } | |||
| /* 长按复制 */ | |||
| ._select { | |||
| user-select: text; | |||
| } | |||
| /* #endif */ | |||
| </style> | |||
| @@ -0,0 +1,626 @@ | |||
| <template> | |||
| <view :id="attrs.id" :class="'_block _'+name+' '+attrs.class" :style="attrs.style"> | |||
| <block v-for="(n, i) in nodes" v-bind:key="i"> | |||
| <!-- 图片 --> | |||
| <!-- 占位图 --> | |||
| <image v-if="n.name==='img'&&!n.t&&((opts[1]&&!ctrl[i])||ctrl[i]<0)" class="_img" :style="n.attrs.style" :src="ctrl[i]<0?opts[2]:opts[1]" mode="widthFix" /> | |||
| <!-- 显示图片 --> | |||
| <!-- #ifdef H5 || (APP-PLUS && VUE2) --> | |||
| <img v-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" /> | |||
| <!-- #endif --> | |||
| <!-- #ifndef H5 || (APP-PLUS && VUE2) --> | |||
| <!-- 表格中的图片,使用 rich-text 防止大小不正确 --> | |||
| <rich-text v-if="n.name==='img'&&n.t" :style="'display:'+n.t" :nodes="[{attrs:{style:n.attrs.style||'',src:n.attrs.src},name:'img'}]" :data-i="i" @tap.stop="imgTap" /> | |||
| <!-- #endif --> | |||
| <!-- #ifdef APP-HARMONY --> | |||
| <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+'width:'+ctrl[i]+'px;'+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :mode="!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" /> | |||
| <!-- #endif --> | |||
| <!-- #ifndef H5 || APP-PLUS || MP-KUAISHOU --> | |||
| <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+'width:'+(ctrl[i]||1)+'px;height:1px;'+n.attrs.style" :src="n.attrs.src" :mode="!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))" :lazy-load="opts[0]" :webp="n.webp" :show-menu-by-longpress="opts[3]&&!n.attrs.ignore" :image-menu-prevent="!opts[3]||n.attrs.ignore" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" /> | |||
| <!-- #endif --> | |||
| <!-- #ifdef MP-KUAISHOU --> | |||
| <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+n.attrs.style" :src="n.attrs.src" :lazy-load="opts[0]" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap"></image> | |||
| <!-- #endif --> | |||
| <!-- #ifdef APP-PLUS && VUE3 --> | |||
| <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl[i]===-1?'display:none;':'')+'width:'+(ctrl[i]||1)+'px;'+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :mode="!n.h?'widthFix':(!n.w?'heightFix':(n.m||''))" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" /> | |||
| <!-- #endif --> | |||
| <!-- 文本 --> | |||
| <!-- #ifdef MP-WEIXIN --> | |||
| <text v-else-if="n.text" :user-select="opts[4]=='force'&&isiOS" decode>{{n.text}}</text> | |||
| <!-- #endif --> | |||
| <!-- #ifndef MP-WEIXIN || MP-BAIDU || MP-ALIPAY || MP-TOUTIAO --> | |||
| <text v-else-if="n.text" decode>{{n.text}}</text> | |||
| <!-- #endif --> | |||
| <text v-else-if="n.name==='br'">{{'\n'}}</text> | |||
| <!-- 链接 --> | |||
| <view v-else-if="n.name==='a'" :id="n.attrs.id" :class="(n.attrs.href?'_a ':'')+n.attrs.class" hover-class="_hover" :style="'display:inline;'+n.attrs.style" :data-i="i" @tap.stop="linkTap"> | |||
| <node name="span" :childs="n.children" :opts="opts" style="display:inherit" /> | |||
| </view> | |||
| <!-- 视频 --> | |||
| <!-- #ifdef APP-PLUS --> | |||
| <view v-else-if="n.html" :id="n.attrs.id" :class="'_video '+n.attrs.class" :style="n.attrs.style" v-html="n.html" :data-i="i" @vplay.stop="play" /> | |||
| <!-- #endif --> | |||
| <!-- #ifndef APP-PLUS --> | |||
| <video v-else-if="n.name==='video'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :autoplay="n.attrs.autoplay" :controls="n.attrs.controls" :loop="n.attrs.loop" :muted="n.attrs.muted" :object-fit="n.attrs['object-fit']" :poster="n.attrs.poster" :src="n.src[ctrl[i]||0]" :data-i="i" @play="play" @pause="mediaEvent" @fullscreenchange="mediaEvent" @error="mediaError" /> | |||
| <!-- #endif --> | |||
| <!-- #ifdef H5 || APP-PLUS --> | |||
| <iframe v-else-if="n.name==='iframe'" :style="n.attrs.style" :allowfullscreen="n.attrs.allowfullscreen" :frameborder="n.attrs.frameborder" :src="n.attrs.src" /> | |||
| <embed v-else-if="n.name==='embed'" :style="n.attrs.style" :src="n.attrs.src" /> | |||
| <!-- #endif --> | |||
| <!-- #ifndef MP-TOUTIAO || ((H5 || APP-PLUS) && VUE3) --> | |||
| <!-- 音频 --> | |||
| <audio v-else-if="n.name==='audio'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :author="n.attrs.author" :controls="n.attrs.controls" :loop="n.attrs.loop" :name="n.attrs.name" :poster="n.attrs.poster" :src="n.src[ctrl[i]||0]" :data-i="i" @play="play" @pause="mediaEvent" @error="mediaError" /> | |||
| <!-- #endif --> | |||
| <view v-else-if="(n.name==='table'&&n.c)||n.name==='li'" :id="n.attrs.id" :class="'_'+n.name+' '+n.attrs.class" :style="n.attrs.style"> | |||
| <node v-if="n.name==='li'" :childs="n.children" :opts="opts" /> | |||
| <view v-else v-for="(tbody, x) in n.children" v-bind:key="x" :class="'_'+tbody.name+' '+tbody.attrs.class" :style="tbody.attrs.style"> | |||
| <node v-if="tbody.name==='td'||tbody.name==='th'" :childs="tbody.children" :opts="opts" /> | |||
| <block v-else v-for="(tr, y) in tbody.children" v-bind:key="y"> | |||
| <view v-if="tr.name==='td'||tr.name==='th'" :class="'_'+tr.name+' '+tr.attrs.class" :style="tr.attrs.style"> | |||
| <node :childs="tr.children" :opts="opts" /> | |||
| </view> | |||
| <view v-else :class="'_'+tr.name+' '+tr.attrs.class" :style="tr.attrs.style"> | |||
| <view v-for="(td, z) in tr.children" v-bind:key="z" :class="'_'+td.name+' '+td.attrs.class" :style="td.attrs.style"> | |||
| <node :childs="td.children" :opts="opts" /> | |||
| </view> | |||
| </view> | |||
| </block> | |||
| </view> | |||
| </view> | |||
| <!-- 富文本 --> | |||
| <!-- #ifdef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) --> | |||
| <rich-text v-else-if="!n.c&&!handler.isInline(n.name, n.attrs.style)" :id="n.attrs.id" :style="n.f" :user-select="opts[4]" :nodes="[n]" /> | |||
| <!-- #endif --> | |||
| <!-- #ifndef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) --> | |||
| <rich-text v-else-if="!n.c" :id="n.attrs.id" :style="'display:inline;'+n.f" :preview="false" :selectable="opts[4]" :user-select="opts[4]" :nodes="[n]" /> | |||
| <!-- #endif --> | |||
| <!-- 继续递归 --> | |||
| <view v-else-if="n.c===2" :id="n.attrs.id" :class="'_block _'+n.name+' '+n.attrs.class" :style="n.f+';'+n.attrs.style"> | |||
| <node v-for="(n2, j) in n.children" v-bind:key="j" :style="n2.f" :name="n2.name" :attrs="n2.attrs" :childs="n2.children" :opts="opts" /> | |||
| </view> | |||
| <node v-else :style="n.f" :name="n.name" :attrs="n.attrs" :childs="n.children" :opts="opts" /> | |||
| </block> | |||
| </view> | |||
| </template> | |||
| <script module="handler" lang="wxs"> | |||
| // 行内标签列表 | |||
| var inlineTags = { | |||
| abbr: true, | |||
| b: true, | |||
| big: true, | |||
| code: true, | |||
| del: true, | |||
| em: true, | |||
| i: true, | |||
| ins: true, | |||
| label: true, | |||
| q: true, | |||
| small: true, | |||
| span: true, | |||
| strong: true, | |||
| sub: true, | |||
| sup: true | |||
| } | |||
| /** | |||
| * @description 判断是否为行内标签 | |||
| */ | |||
| module.exports = { | |||
| isInline: function (tagName, style) { | |||
| return inlineTags[tagName] || (style || '').indexOf('display:inline') !== -1 | |||
| } | |||
| } | |||
| </script> | |||
| <script> | |||
| import node from './node' | |||
| export default { | |||
| name: 'node', | |||
| options: { | |||
| // #ifdef MP-WEIXIN | |||
| virtualHost: true, | |||
| // #endif | |||
| // #ifdef MP-TOUTIAO | |||
| addGlobalClass: false | |||
| // #endif | |||
| }, | |||
| data () { | |||
| return { | |||
| ctrl: {}, | |||
| nodes: [], | |||
| // #ifdef MP-WEIXIN | |||
| isiOS: (uni.canIUse('getDeviceInfo') ? uni.getDeviceInfo() : uni.getSystemInfoSync()).system.includes('iOS') | |||
| // #endif | |||
| } | |||
| }, | |||
| props: { | |||
| name: String, | |||
| attrs: { | |||
| type: Object, | |||
| default () { | |||
| return {} | |||
| } | |||
| }, | |||
| childs: Array, | |||
| opts: Array | |||
| }, | |||
| watch: { | |||
| childs: { | |||
| handler (nodes) { | |||
| // 列表缩短会刷新整个列表,因此进行空填充 | |||
| while (this.nodes.length > nodes.length) { | |||
| nodes.push({}) | |||
| } | |||
| this.nodes = nodes | |||
| }, | |||
| immediate: true | |||
| } | |||
| }, | |||
| components: { | |||
| // #ifndef ((H5 || APP-PLUS) && VUE3) || APP-HARMONY | |||
| node | |||
| // #endif | |||
| }, | |||
| mounted () { | |||
| this.$nextTick(() => { | |||
| for (this.root = this.$parent; this.root.$options.name !== 'mp-html'; this.root = this.root.$parent); | |||
| }) | |||
| // #ifdef H5 || APP-PLUS | |||
| if (this.opts[0]) { | |||
| let i | |||
| for (i = this.childs.length; i--;) { | |||
| if (this.childs[i].name === 'img') break | |||
| } | |||
| if (i !== -1) { | |||
| this.observer = uni.createIntersectionObserver(this).relativeToViewport({ | |||
| top: 500, | |||
| bottom: 500 | |||
| }) | |||
| this.observer.observe('._img', res => { | |||
| if (res.intersectionRatio) { | |||
| this.$set(this.ctrl, 'load', 1) | |||
| this.observer.disconnect() | |||
| } | |||
| }) | |||
| } | |||
| } | |||
| // #endif | |||
| }, | |||
| beforeDestroy () { | |||
| // #ifdef H5 || APP-PLUS | |||
| if (this.observer) { | |||
| this.observer.disconnect() | |||
| } | |||
| // #endif | |||
| }, | |||
| methods:{ | |||
| // #ifdef MP-WEIXIN | |||
| toJSON () { return this }, | |||
| // #endif | |||
| /** | |||
| * @description 播放视频事件 | |||
| * @param {Event} e | |||
| */ | |||
| play (e) { | |||
| const i = e.currentTarget.dataset.i | |||
| const node = this.childs[i] | |||
| this.root.$emit('play', { | |||
| source: node.name, | |||
| attrs: { | |||
| ...node.attrs, | |||
| src: node.src[this.ctrl[i] || 0] | |||
| } | |||
| }) | |||
| // #ifndef APP-PLUS | |||
| if (this.root.pauseVideo) { | |||
| let flag = false | |||
| const id = e.target.id | |||
| for (let i = this.root._videos.length; i--;) { | |||
| if (this.root._videos[i].id === id) { | |||
| flag = true | |||
| } else { | |||
| this.root._videos[i].pause() // 自动暂停其他视频 | |||
| } | |||
| } | |||
| // 将自己加入列表 | |||
| if (!flag) { | |||
| const ctx = uni.createVideoContext(id | |||
| // #ifndef MP-BAIDU | |||
| , this | |||
| // #endif | |||
| ) | |||
| ctx.id = id | |||
| if (this.root.playbackRate) { | |||
| ctx.playbackRate(this.root.playbackRate) | |||
| } | |||
| this.root._videos.push(ctx) | |||
| } | |||
| } | |||
| // #endif | |||
| }, | |||
| /** | |||
| * @description 音视频其他事件 | |||
| * @param {Event} e | |||
| */ | |||
| mediaEvent (e) { | |||
| const i = e.currentTarget.dataset.i | |||
| const node = this.childs[i] | |||
| this.root.$emit(e.type, { | |||
| ...e.detail, | |||
| source: node.name, | |||
| attrs: { | |||
| ...node.attrs, | |||
| src: node.src[this.ctrl[i] || 0] | |||
| } | |||
| }) | |||
| }, | |||
| /** | |||
| * @description 图片点击事件 | |||
| * @param {Event} e | |||
| */ | |||
| imgTap (e) { | |||
| const node = this.childs[e.currentTarget.dataset.i] | |||
| if (node.a) { | |||
| this.linkTap(node.a) | |||
| return | |||
| } | |||
| if (node.attrs.ignore) return | |||
| // #ifdef H5 || APP-PLUS | |||
| node.attrs.src = node.attrs.src || node.attrs['data-src'] | |||
| // #endif | |||
| // #ifndef APP-HARMONY | |||
| this.root.$emit('imgtap', node.attrs) | |||
| // #endif | |||
| // #ifdef APP-HARMONY | |||
| this.root.$emit('imgtap', { | |||
| ...node.attrs | |||
| }) | |||
| // #endif | |||
| // 自动预览图片 | |||
| if (this.root.previewImg) { | |||
| uni.previewImage({ | |||
| // #ifdef MP-WEIXIN | |||
| showmenu: this.root.showImgMenu, | |||
| // #endif | |||
| // #ifdef MP-ALIPAY | |||
| enablesavephoto: this.root.showImgMenu, | |||
| enableShowPhotoDownload: this.root.showImgMenu, | |||
| // #endif | |||
| current: parseInt(node.attrs.i), | |||
| urls: this.root.imgList | |||
| }) | |||
| } | |||
| }, | |||
| /** | |||
| * @description 图片长按 | |||
| */ | |||
| imgLongTap (e) { | |||
| // #ifdef APP-PLUS | |||
| const attrs = this.childs[e.currentTarget.dataset.i].attrs | |||
| if (this.opts[3] && !attrs.ignore) { | |||
| uni.showActionSheet({ | |||
| itemList: ['保存图片'], | |||
| success: () => { | |||
| const save = path => { | |||
| uni.saveImageToPhotosAlbum({ | |||
| filePath: path, | |||
| success () { | |||
| uni.showToast({ | |||
| title: '保存成功' | |||
| }) | |||
| } | |||
| }) | |||
| } | |||
| if (this.root.imgList[attrs.i].startsWith('http')) { | |||
| uni.downloadFile({ | |||
| url: this.root.imgList[attrs.i], | |||
| success: res => save(res.tempFilePath) | |||
| }) | |||
| } else { | |||
| save(this.root.imgList[attrs.i]) | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| // #endif | |||
| }, | |||
| /** | |||
| * @description 图片加载完成事件 | |||
| * @param {Event} e | |||
| */ | |||
| imgLoad (e) { | |||
| const i = e.currentTarget.dataset.i | |||
| /* #ifndef H5 || (APP-PLUS && VUE2) */ | |||
| if (!this.childs[i].w) { | |||
| // 设置原宽度 | |||
| this.$set(this.ctrl, i, e.detail.width) | |||
| } else /* #endif */ if ((this.opts[1] && !this.ctrl[i]) || this.ctrl[i] === -1) { | |||
| // 加载完毕,取消加载中占位图 | |||
| this.$set(this.ctrl, i, 1) | |||
| } | |||
| this.checkReady() | |||
| }, | |||
| /** | |||
| * @description 检查是否所有图片加载完毕 | |||
| */ | |||
| checkReady () { | |||
| if (this.root && !this.root.lazyLoad) { | |||
| this.root._unloadimgs -= 1 | |||
| if (!this.root._unloadimgs) { | |||
| setTimeout(() => { | |||
| this.root.getRect().then(rect => { | |||
| this.root.$emit('ready', rect) | |||
| }).catch(() => { | |||
| this.root.$emit('ready', {}) | |||
| }) | |||
| }, 350) | |||
| } | |||
| } | |||
| }, | |||
| /** | |||
| * @description 链接点击事件 | |||
| * @param {Event} e | |||
| */ | |||
| linkTap (e) { | |||
| const node = e.currentTarget ? this.childs[e.currentTarget.dataset.i] : {} | |||
| const attrs = node.attrs || e | |||
| const href = attrs.href | |||
| this.root.$emit('linktap', Object.assign({ | |||
| innerText: this.root.getText(node.children || []) // 链接内的文本内容 | |||
| }, attrs)) | |||
| if (href) { | |||
| if (href[0] === '#') { | |||
| // 跳转锚点 | |||
| this.root.navigateTo(href.substring(1)).catch(() => { }) | |||
| } else if (href.split('?')[0].includes('://')) { | |||
| // 复制外部链接 | |||
| if (this.root.copyLink) { | |||
| // #ifdef H5 | |||
| window.open(href) | |||
| // #endif | |||
| // #ifdef MP | |||
| uni.setClipboardData({ | |||
| data: href, | |||
| success: () => | |||
| uni.showToast({ | |||
| title: '链接已复制' | |||
| }) | |||
| }) | |||
| // #endif | |||
| // #ifdef APP-PLUS | |||
| plus.runtime.openWeb(href) | |||
| // #endif | |||
| } | |||
| } else { | |||
| // 跳转页面 | |||
| uni.navigateTo({ | |||
| url: href, | |||
| fail () { | |||
| uni.switchTab({ | |||
| url: href, | |||
| fail () { } | |||
| }) | |||
| } | |||
| }) | |||
| } | |||
| } | |||
| }, | |||
| /** | |||
| * @description 错误事件 | |||
| * @param {Event} e | |||
| */ | |||
| mediaError (e) { | |||
| const i = e.currentTarget.dataset.i | |||
| const node = this.childs[i] | |||
| // 加载其他源 | |||
| if (node.name === 'video' || node.name === 'audio') { | |||
| let index = (this.ctrl[i] || 0) + 1 | |||
| if (index > node.src.length) { | |||
| index = 0 | |||
| } | |||
| if (index < node.src.length) { | |||
| this.$set(this.ctrl, i, index) | |||
| return | |||
| } | |||
| } else if (node.name === 'img') { | |||
| // #ifdef H5 && VUE3 | |||
| if (this.opts[0] && !this.ctrl.load) return | |||
| // #endif | |||
| // 显示错误占位图 | |||
| if (this.opts[2]) { | |||
| this.$set(this.ctrl, i, -1) | |||
| } | |||
| this.checkReady() | |||
| } | |||
| if (this.root) { | |||
| this.root.$emit('error', { | |||
| source: node.name, | |||
| attrs: node.attrs, | |||
| // #ifndef H5 && VUE3 | |||
| errMsg: e.detail.errMsg | |||
| // #endif | |||
| }) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </script> | |||
| <style> | |||
| /* a 标签默认效果 */ | |||
| ._a { | |||
| padding: 1.5px 0 1.5px 0; | |||
| color: #366092; | |||
| word-break: break-all; | |||
| } | |||
| /* a 标签点击态效果 */ | |||
| ._hover { | |||
| text-decoration: underline; | |||
| opacity: 0.7; | |||
| } | |||
| /* 图片默认效果 */ | |||
| ._img { | |||
| max-width: 100%; | |||
| -webkit-touch-callout: none; | |||
| } | |||
| /* 内部样式 */ | |||
| ._block { | |||
| display: block; | |||
| } | |||
| ._b, | |||
| ._strong { | |||
| font-weight: bold; | |||
| } | |||
| ._code { | |||
| font-family: monospace; | |||
| } | |||
| ._del { | |||
| text-decoration: line-through; | |||
| } | |||
| ._em, | |||
| ._i { | |||
| font-style: italic; | |||
| } | |||
| ._h1 { | |||
| font-size: 2em; | |||
| } | |||
| ._h2 { | |||
| font-size: 1.5em; | |||
| } | |||
| ._h3 { | |||
| font-size: 1.17em; | |||
| } | |||
| ._h5 { | |||
| font-size: 0.83em; | |||
| } | |||
| ._h6 { | |||
| font-size: 0.67em; | |||
| } | |||
| ._h1, | |||
| ._h2, | |||
| ._h3, | |||
| ._h4, | |||
| ._h5, | |||
| ._h6 { | |||
| display: block; | |||
| font-weight: bold; | |||
| } | |||
| ._image { | |||
| height: 1px; | |||
| } | |||
| ._ins { | |||
| text-decoration: underline; | |||
| } | |||
| ._li { | |||
| display: list-item; | |||
| } | |||
| ._ol { | |||
| list-style-type: decimal; | |||
| } | |||
| ._ol, | |||
| ._ul { | |||
| display: block; | |||
| padding-left: 40px; | |||
| margin: 1em 0; | |||
| } | |||
| ._q::before { | |||
| content: '"'; | |||
| } | |||
| ._q::after { | |||
| content: '"'; | |||
| } | |||
| ._sub { | |||
| font-size: smaller; | |||
| vertical-align: sub; | |||
| } | |||
| ._sup { | |||
| font-size: smaller; | |||
| vertical-align: super; | |||
| } | |||
| ._thead, | |||
| ._tbody, | |||
| ._tfoot { | |||
| display: table-row-group; | |||
| } | |||
| ._tr { | |||
| display: table-row; | |||
| } | |||
| ._td, | |||
| ._th { | |||
| display: table-cell; | |||
| vertical-align: middle; | |||
| } | |||
| ._th { | |||
| font-weight: bold; | |||
| text-align: center; | |||
| } | |||
| ._ul { | |||
| list-style-type: disc; | |||
| } | |||
| ._ul ._ul { | |||
| margin: 0; | |||
| list-style-type: circle; | |||
| } | |||
| ._ul ._ul ._ul { | |||
| list-style-type: square; | |||
| } | |||
| ._abbr, | |||
| ._b, | |||
| ._code, | |||
| ._del, | |||
| ._em, | |||
| ._i, | |||
| ._ins, | |||
| ._label, | |||
| ._q, | |||
| ._span, | |||
| ._strong, | |||
| ._sub, | |||
| ._sup { | |||
| display: inline; | |||
| } | |||
| /* #ifdef APP-PLUS */ | |||
| ._video { | |||
| width: 300px; | |||
| height: 225px; | |||
| } | |||
| /* #endif */ | |||
| </style> | |||
| @@ -0,0 +1,99 @@ | |||
| { | |||
| "id": "mp-html", | |||
| "displayName": "mp-html 富文本组件【全端支持,支持编辑、latex等扩展】", | |||
| "version": "v2.5.2", | |||
| "description": "一个强大的富文本组件,高效轻量,功能丰富", | |||
| "keywords": [ | |||
| "富文本", | |||
| "编辑器", | |||
| "html", | |||
| "rich-text", | |||
| "editor" | |||
| ], | |||
| "repository": "https://github.com/jin-yufeng/mp-html", | |||
| "dcloudext": { | |||
| "sale": { | |||
| "regular": { | |||
| "price": "0.00" | |||
| }, | |||
| "sourcecode": { | |||
| "price": "0.00" | |||
| } | |||
| }, | |||
| "contact": { | |||
| "qq": "" | |||
| }, | |||
| "declaration": { | |||
| "ads": "无", | |||
| "data": "无", | |||
| "permissions": "无" | |||
| }, | |||
| "npmurl": "https://www.npmjs.com/package/mp-html", | |||
| "type": "component-vue", | |||
| "darkmode": "x", | |||
| "i18n": "x", | |||
| "widescreen": "x" | |||
| }, | |||
| "uni_modules": { | |||
| "platforms": { | |||
| "cloud": { | |||
| "tcb": "√", | |||
| "aliyun": "√", | |||
| "alipay": "x" | |||
| }, | |||
| "client": { | |||
| "uni-app": { | |||
| "vue": { | |||
| "vue2": "√", | |||
| "vue3": "√" | |||
| }, | |||
| "web": { | |||
| "safari": "√", | |||
| "chrome": "√" | |||
| }, | |||
| "app": { | |||
| "vue": "√", | |||
| "nvue": "√", | |||
| "android": "√", | |||
| "ios": "√", | |||
| "harmony": "-" | |||
| }, | |||
| "mp": { | |||
| "weixin": "√", | |||
| "alipay": "√", | |||
| "toutiao": "-", | |||
| "baidu": "√", | |||
| "kuaishou": "-", | |||
| "jd": "-", | |||
| "harmony": "-", | |||
| "qq": "√", | |||
| "lark": "√" | |||
| }, | |||
| "quickapp": { | |||
| "huawei": "-", | |||
| "union": "-" | |||
| } | |||
| }, | |||
| "uni-app-x": { | |||
| "web": { | |||
| "safari": "-", | |||
| "chrome": "-" | |||
| }, | |||
| "app": { | |||
| "android": "-", | |||
| "ios": "-", | |||
| "harmony": "-" | |||
| }, | |||
| "mp": { | |||
| "weixin": "-" | |||
| } | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| "engines": { | |||
| "HBuilderX": "^3.1.0", | |||
| "uni-app": "^3.6.15", | |||
| "uni-app-x": "" | |||
| } | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| "use strict";function t(t){for(var e=Object.create(null),n=t.attributes.length;n--;)e[t.attributes[n].name]=t.attributes[n].value;return e}function e(){a[1]&&(this.src=a[1],this.onerror=null),this.onclick=null,this.ontouchstart=null,uni.postMessage({data:{action:"onError",source:"img",attrs:t(this)}})}function n(){window.unloadimgs-=1,0===window.unloadimgs&&uni.postMessage({data:{action:"onReady"}})}function o(r,s,c){for(var d=0;d<r.length;d++)!function(){var u,l=r[d];if(l.type&&"node"!==l.type)u=document.createTextNode(l.text.replace(/&/g,"&"));else{var g=l.name;"svg"===g&&(c="http://www.w3.org/2000/svg"),"html"!==g&&"body"!==g||(g="div"),u=c?document.createElementNS(c,g):document.createElement(g);for(var p in l.attrs)u.setAttribute(p,l.attrs[p]);if(l.children&&o(l.children,u,c),"img"===g){if(window.unloadimgs+=1,u.onload=n,u.onerror=n,!u.src&&u.getAttribute("data-src")&&(u.src=u.getAttribute("data-src")),l.attrs.ignore||(u.onclick=function(e){e.stopPropagation(),uni.postMessage({data:{action:"onImgTap",attrs:t(this)}})}),a[2]){var h=new Image;h.src=u.src,u.src=a[2],h.onload=function(){u.src=this.src},h.onerror=function(){u.onerror()}}u.onerror=e}else if("a"===g)u.addEventListener("click",function(e){e.stopPropagation(),e.preventDefault();var n,o=this.getAttribute("href");o&&"#"===o[0]&&(n=(document.getElementById(o.substr(1))||{}).offsetTop),uni.postMessage({data:{action:"onLinkTap",attrs:t(this),offset:n}})},!0);else if("video"===g||"audio"===g)i.push(u),l.attrs.autoplay||l.attrs.controls||u.setAttribute("controls","true"),u.onplay=function(){if(uni.postMessage({data:{action:"onPlay"}}),a[3])for(var t=0;t<i.length;t++)i[t]!==this&&i[t].pause()},u.onerror=function(){uni.postMessage({data:{action:"onError",source:g,attrs:t(this)}})};else if("table"===g&&a[4]&&!u.style.cssText.includes("inline")){var f=document.createElement("div");f.style.overflow="auto",f.appendChild(u),u=f}else"svg"===g&&(c=void 0)}s.appendChild(u)}()}document.addEventListener("UniAppJSBridgeReady",function(){document.body.onclick=function(){return uni.postMessage({data:{action:"onClick"}})},uni.postMessage({data:{action:"onJSBridgeReady"}})});var a,i=[];window.setContent=function(t,e,n){var r=document.getElementById("content");e[0]&&(document.body.style.cssText=e[0]),e[5]||(r.style.userSelect="none"),n||(r.innerHTML="",i=[]),a=e,window.unloadimgs=0;var s=document.createDocumentFragment();o(t,s),r.appendChild(s);var c=r.scrollHeight;uni.postMessage({data:{action:"onLoad",height:c}}),window.unloadimgs||uni.postMessage({data:{action:"onReady",height:c}}),clearInterval(window.timer),window.timer=setInterval(function(){r.scrollHeight!==c&&(c=r.scrollHeight,uni.postMessage({data:{action:"onHeightChange",height:c}}))},350)},window.onunload=function(){clearInterval(window.timer)}; | |||
| @@ -0,0 +1 @@ | |||
| !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).uni=n()}(this,(function(){"use strict";try{var e={};Object.defineProperty(e,"passive",{get:function(){!0}}),window.addEventListener("test-passive",null,e)}catch(e){}var n=Object.prototype.hasOwnProperty;function t(e,t){return n.call(e,t)}var i=[],a=function(e,n){var t={options:{timestamp:+new Date},name:e,arg:n};if(window.__dcloud_weex_postMessage||window.__dcloud_weex_){if("postMessage"===e){var a={data:[n]};return window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessage(a):window.__dcloud_weex_.postMessage(JSON.stringify(a))}var o={type:"WEB_INVOKE_APPSERVICE",args:{data:t,webviewIds:i}};window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessageToService(o):window.__dcloud_weex_.postMessageToService(JSON.stringify(o))}if(!window.plus)return window.parent.postMessage({type:"WEB_INVOKE_APPSERVICE",data:t,pageId:""},"*");if(0===i.length){var r=plus.webview.currentWebview();if(!r)throw new Error("plus.webview.currentWebview() is undefined");var d=r.parent(),s="";s=d?d.id:r.id,i.push(s)}if(plus.webview.getWebviewById("__uniapp__service"))plus.webview.postMessageToUniNView({type:"WEB_INVOKE_APPSERVICE",args:{data:t,webviewIds:i}},"__uniapp__service");else{var w=JSON.stringify(t);plus.webview.getLaunchWebview().evalJS('UniPlusBridge.subscribeHandler("'.concat("WEB_INVOKE_APPSERVICE",'",').concat(w,",").concat(JSON.stringify(i),");"))}},o={navigateTo:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("navigateTo",{url:encodeURI(n)})},navigateBack:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.delta;a("navigateBack",{delta:parseInt(n)||1})},switchTab:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("switchTab",{url:encodeURI(n)})},reLaunch:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("reLaunch",{url:encodeURI(n)})},redirectTo:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;a("redirectTo",{url:encodeURI(n)})},getEnv:function(e){window.plus?e({plus:!0}):e({h5:!0})},postMessage:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};a("postMessage",e.data||{})}},r=/uni-app/i.test(navigator.userAgent),d=/Html5Plus/i.test(navigator.userAgent),s=/complete|loaded|interactive/;var w=window.my&&navigator.userAgent.indexOf("AlipayClient")>-1;var u=window.swan&&window.swan.webView&&/swan/i.test(navigator.userAgent);var c=window.qq&&window.qq.miniProgram&&/QQ/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);var g=window.tt&&window.tt.miniProgram&&/toutiaomicroapp/i.test(navigator.userAgent);var v=window.wx&&window.wx.miniProgram&&/micromessenger/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);var p=window.qa&&/quickapp/i.test(navigator.userAgent);for(var l,_=function(){window.UniAppJSBridge=!0,document.dispatchEvent(new CustomEvent("UniAppJSBridgeReady",{bubbles:!0,cancelable:!0}))},f=[function(e){if(r||d)return window.__dcloud_weex_postMessage||window.__dcloud_weex_?document.addEventListener("DOMContentLoaded",e):window.plus&&s.test(document.readyState)?setTimeout(e,0):document.addEventListener("plusready",e),o},function(e){if(v)return window.WeixinJSBridge&&window.WeixinJSBridge.invoke?setTimeout(e,0):document.addEventListener("WeixinJSBridgeReady",e),window.wx.miniProgram},function(e){if(c)return window.QQJSBridge&&window.QQJSBridge.invoke?setTimeout(e,0):document.addEventListener("QQJSBridgeReady",e),window.qq.miniProgram},function(e){if(w){document.addEventListener("DOMContentLoaded",e);var n=window.my;return{navigateTo:n.navigateTo,navigateBack:n.navigateBack,switchTab:n.switchTab,reLaunch:n.reLaunch,redirectTo:n.redirectTo,postMessage:n.postMessage,getEnv:n.getEnv}}},function(e){if(u)return document.addEventListener("DOMContentLoaded",e),window.swan.webView},function(e){if(g)return document.addEventListener("DOMContentLoaded",e),window.tt.miniProgram},function(e){if(p){window.QaJSBridge&&window.QaJSBridge.invoke?setTimeout(e,0):document.addEventListener("QaJSBridgeReady",e);var n=window.qa;return{navigateTo:n.navigateTo,navigateBack:n.navigateBack,switchTab:n.switchTab,reLaunch:n.reLaunch,redirectTo:n.redirectTo,postMessage:n.postMessage,getEnv:n.getEnv}}},function(e){return document.addEventListener("DOMContentLoaded",e),o}],m=0;m<f.length&&!(l=f[m](_));m++);l||(l={});var E="undefined"!=typeof uni?uni:{};if(!E.navigateTo)for(var b in l)t(l,b)&&(E[b]=l[b]);return E.webView=l,E})); | |||
| @@ -0,0 +1 @@ | |||
| <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"><style>body,html{width:100%;height:100%;overflow-x:scroll;overflow-y:hidden}body{margin:0}video{width:300px;height:225px}img{max-width:100%;-webkit-touch-callout:none}</style></head><body><div id="content" style="overflow:hidden"></div><script type="text/javascript" src="./js/uni.webview.min.js"></script><script type="text/javascript" src="./js/handler.js"></script></body> | |||
| @@ -0,0 +1,31 @@ | |||
| const TOKEN_KEY = "pap-cytx-token"; | |||
| const USER_INFO_KEY = "pap-cytx-user-info"; | |||
| export function setToken(token) { | |||
| uni.setStorageSync(TOKEN_KEY, token); | |||
| } | |||
| export function getToken() { | |||
| return uni.getStorageSync(TOKEN_KEY) || ""; | |||
| } | |||
| export function clearToken() { | |||
| uni.removeStorageSync(TOKEN_KEY); | |||
| } | |||
| export function setUserInfo(userInfo) { | |||
| uni.setStorageSync(USER_INFO_KEY, userInfo); | |||
| } | |||
| export function getUserInfo() { | |||
| return uni.getStorageSync(USER_INFO_KEY) || null; | |||
| } | |||
| export function clearUserInfo() { | |||
| uni.removeStorageSync(USER_INFO_KEY); | |||
| } | |||
| export function clearAll() { | |||
| clearToken(); | |||
| clearUserInfo(); | |||
| } | |||
| @@ -0,0 +1,86 @@ | |||
| /** | |||
| * uview-plus http 请求配置 | |||
| */ | |||
| import { getToken, clearAll } from './cache.js' | |||
| import envConfig from '@/config/env.js' | |||
| const BASE_URL = envConfig.BASE_URL | |||
| const CODE = { | |||
| SUCCESS: 0, | |||
| NEED_LOGIN: 1009 | |||
| } | |||
| export function initRequest(http) { | |||
| http.setConfig((config) => { | |||
| config.baseURL = BASE_URL | |||
| config.timeout = 20000 | |||
| config.header = { | |||
| 'Content-Type': 'application/json' | |||
| } | |||
| return config | |||
| }) | |||
| http.interceptors.request.use( | |||
| (config) => { | |||
| const token = getToken() | |||
| if (token) { | |||
| config.header.Authorization = `Bearer ${token}` | |||
| } | |||
| return config | |||
| }, | |||
| (error) => Promise.reject(error) | |||
| ) | |||
| http.interceptors.response.use( | |||
| (response) => { | |||
| const { code, data, list, msg } = response.data | |||
| if (code === CODE.SUCCESS) { | |||
| return { code, data, list, msg } | |||
| } | |||
| if (code === CODE.NEED_LOGIN) { | |||
| clearAll() | |||
| uni.showToast({ title: msg || '请先登录', icon: 'none' }) | |||
| setTimeout(() => { | |||
| uni.reLaunch({ url: '/pages/login/index' }) | |||
| }, 1500) | |||
| return Promise.reject({ code, msg: msg || '请先登录' }) | |||
| } | |||
| uni.showToast({ title: msg || '请求失败', icon: 'none' }) | |||
| return Promise.reject({ code, msg: msg || '请求失败' }) | |||
| }, | |||
| (error) => { | |||
| uni.showToast({ title: '网络请求失败', icon: 'none' }) | |||
| return Promise.reject({ code: -1, msg: error.errMsg || '网络请求失败' }) | |||
| } | |||
| ) | |||
| } | |||
| export function get(url, params = {}, config = {}) { | |||
| return uni.$u.http.get(url, { params, ...config }) | |||
| } | |||
| export function post(url, data = {}, config = {}) { | |||
| return uni.$u.http.post(url, data, config) | |||
| } | |||
| export function put(url, data = {}, config = {}) { | |||
| return uni.$u.http.put(url, data, config) | |||
| } | |||
| export function del(url, data = {}, config = {}) { | |||
| return uni.$u.http.delete(url, { data, ...config }) | |||
| } | |||
| export function upload(url, { filePath, name = 'file', formData = {} } = {}) { | |||
| return uni.$u.http.upload(url, { filePath, name, formData }) | |||
| } | |||
| export function download(url, config = {}) { | |||
| return uni.$u.http.download(url, config) | |||
| } | |||
| export default { get, post, put, del, upload, download } | |||