Просмотр исходного кода

init: 肠愈同行小程序项目初始化

master
leiyun 3 дней назад
Сommit
bc497ec898
44 измененных файлов: 6602 добавлений и 0 удалений
  1. +28
    -0
      .gitignore
  2. +25
    -0
      App.vue
  3. +16
    -0
      config/env.js
  4. +20
    -0
      index.html
  5. +39
    -0
      main.js
  6. +64
    -0
      manifest.json
  7. +18
    -0
      package.json
  8. +109
    -0
      pages.json
  9. +230
    -0
      pages/change-phone/change-phone.vue
  10. +58
    -0
      pages/content/content.vue
  11. +199
    -0
      pages/index/index.vue
  12. +136
    -0
      pages/login/index.vue
  13. +172
    -0
      pages/message/detail.vue
  14. +195
    -0
      pages/message/message.vue
  15. +723
    -0
      pages/myinfo/myinfo.vue
  16. +441
    -0
      pages/profile/profile.vue
  17. +279
    -0
      pages/sign/sign.vue
  18. +614
    -0
      pages/verify/verify.vue
  19. +65
    -0
      pnpm-lock.yaml
  20. Двоичные данные
     
  21. Двоичные данные
     
  22. Двоичные данные
     
  23. Двоичные данные
     
  24. Двоичные данные
     
  25. Двоичные данные
     
  26. Двоичные данные
     
  27. Двоичные данные
     
  28. Двоичные данные
     
  29. Двоичные данные
     
  30. Двоичные данные
     
  31. Двоичные данные
     
  32. +13
    -0
      uni.promisify.adaptor.js
  33. +58
    -0
      uni.scss
  34. +192
    -0
      uni_modules/mp-html/README.md
  35. +163
    -0
      uni_modules/mp-html/changelog.md
  36. +500
    -0
      uni_modules/mp-html/components/mp-html/mp-html.vue
  37. +626
    -0
      uni_modules/mp-html/components/mp-html/node/node.vue
  38. +1400
    -0
      uni_modules/mp-html/components/mp-html/parser.js
  39. +99
    -0
      uni_modules/mp-html/package.json
  40. +1
    -0
      uni_modules/mp-html/static/app-plus/mp-html/js/handler.js
  41. +1
    -0
      uni_modules/mp-html/static/app-plus/mp-html/js/uni.webview.min.js
  42. +1
    -0
      uni_modules/mp-html/static/app-plus/mp-html/local.html
  43. +31
    -0
      utils/cache.js
  44. +86
    -0
      utils/request.js

+ 28
- 0
.gitignore Просмотреть файл

@@ -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

+ 25
- 0
App.vue Просмотреть файл

@@ -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>

+ 16
- 0
config/env.js Просмотреть файл

@@ -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'];

+ 20
- 0
index.html Просмотреть файл

@@ -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>

+ 39
- 0
main.js Просмотреть файл

@@ -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

+ 64
- 0
manifest.json Просмотреть файл

@@ -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"
}

+ 18
- 0
package.json Просмотреть файл

@@ -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"
}
}

+ 109
- 0
pages.json Просмотреть файл

@@ -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": {}
}

+ 230
- 0
pages/change-phone/change-phone.vue Просмотреть файл

@@ -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>

+ 58
- 0
pages/content/content.vue Просмотреть файл

@@ -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>

+ 199
- 0
pages/index/index.vue Просмотреть файл

@@ -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>

+ 136
- 0
pages/login/index.vue Просмотреть файл

@@ -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>

+ 172
- 0
pages/message/detail.vue Просмотреть файл

@@ -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>

+ 195
- 0
pages/message/message.vue Просмотреть файл

@@ -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>

+ 723
- 0
pages/myinfo/myinfo.vue Просмотреть файл

@@ -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>

+ 441
- 0
pages/profile/profile.vue Просмотреть файл

@@ -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>

+ 279
- 0
pages/sign/sign.vue Просмотреть файл

@@ -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>

+ 614
- 0
pages/verify/verify.vue Просмотреть файл

@@ -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>

+ 65
- 0
pnpm-lock.yaml Просмотреть файл

@@ -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: {}

Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


Двоичные данные
Просмотреть файл


+ 13
- 0
uni.promisify.adaptor.js Просмотреть файл

@@ -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])
});
});
},
});

+ 58
- 0
uni.scss Просмотреть файл

@@ -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;

+ 192
- 0
uni_modules/mp-html/README.md Просмотреть файл

@@ -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) 了解更多

+ 163
- 0
uni_modules/mp-html/changelog.md Просмотреть файл

@@ -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)

+ 500
- 0
uni_modules/mp-html/components/mp-html/mp-html.vue Просмотреть файл

@@ -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(/&amp;/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>

+ 626
- 0
uni_modules/mp-html/components/mp-html/node/node.vue Просмотреть файл

@@ -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>

+ 1400
- 0
uni_modules/mp-html/components/mp-html/parser.js
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 99
- 0
uni_modules/mp-html/package.json Просмотреть файл

@@ -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": ""
}
}

+ 1
- 0
uni_modules/mp-html/static/app-plus/mp-html/js/handler.js Просмотреть файл

@@ -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(/&amp;/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)};

+ 1
- 0
uni_modules/mp-html/static/app-plus/mp-html/js/uni.webview.min.js Просмотреть файл

@@ -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}));

+ 1
- 0
uni_modules/mp-html/static/app-plus/mp-html/local.html Просмотреть файл

@@ -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>

+ 31
- 0
utils/cache.js Просмотреть файл

@@ -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();
}

+ 86
- 0
utils/request.js Просмотреть файл

@@ -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 }

Загрузка…
Отмена
Сохранить