Переглянути джерело

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

master
leiyun 3 дні тому
коміт
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. BIN
     
  21. BIN
     
  22. BIN
     
  23. BIN
     
  24. BIN
     
  25. BIN
     
  26. BIN
     
  27. BIN
     
  28. BIN
     
  29. BIN
     
  30. BIN
     
  31. BIN
     
  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 }

Завантаження…
Відмінити
Зберегти