主题
uniapp 项目搭建
创建新项目
根目录创建 package.json
json
{
"dependencies": {
"animate.css": "^4.1.1",
"vue-i18n": "^9.3.0-beta.24",
"vuex-persist": "^3.1.3"
},
"devDependencies": {
"rollup-plugin-javascript-obfuscator": "^1.0.4"
}
}
创建完后执行命令
sh
yarn install
根目录创建 vite.config.js
js
import {defineConfig} from "vite"
import uni from "@dcloudio/vite-plugin-uni"
// eslint-disable-next-line no-control-regex
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g
const DRIVE_LETTER_REGEX = /^[a-z]:/i
// https://vitejs.dev/config/
export default defineConfig({
plugins: [uni()],
resolve: {
alias: {
"vue-i18n": "/node_modules/vue-i18n/",
},
},
server: {
hmr: true,
},
build: {
assetsInlineLimit: 5120,
cssCodeSplit: false,
minify: "terser",
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
rollupOptions: {
output: {
sanitizeFileName(name) {
const match = DRIVE_LETTER_REGEX.exec(name)
const driveLetter = match ? match[0] : ""
return driveLetter + name.slice(driveLetter.length).replace(INVALID_CHAR_REGEX, "")
},
},
},
},
})
pages.json 文件修改
json
{
"pages": [
//pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": ""
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "项目标题",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8",
"dynamicRpx": true,
"enablePullDownRefresh": false,
"navigationStyle": "custom",
"onReachBottomDistance": 100,
"app-plus": {
"bounce": "none",
"pullToRefresh": {
"color": "#e14e28" //下拉刷新的颜色
}
}
},
"uniIdRouter": {},
"tabBar": {
"backgroundColor": "#1d1c4e",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index"
},
{
"pagePath": "pages/Second/Second"
},
{
"pagePath": "pages/Second/Second"
},
{
"pagePath": "pages/mine/mine"
}
]
}
}
manifest.json 里面文件 h5 内容
json
"h5" : {
"optimization" : {
"treeShaking" : {
"enable" : true
}
},
"router" : {
"base" : "./"
},
"devServer" : {
"port" : 6006
},
"async" : {
//页面js异步加载配置
"loading" : "AsyncLoadingNew", //页面js加载时使用的组件(需注册为全局组件)
"error" : "AsyncErrorNew", //页面js加载失败时使用的组件(需注册为全局组件)
"delay" : 0, //展示 loading 加载组件的延时时间(页面 js 若在 delay 时间内加载完成,则不会显示 loading 组件)
"timeout" : 60000 //页面js加载超时时间(超时后展示 error 对应的组件)
},
"title" : ""//标题
}
AsyncLoadingNew 和 AsyncErrorNew 组件在下面添加并且注册为全局
引入 vk-uview
插件地址:https://ext.dcloud.net.cn/plugin?id=6692
uni.scss 文件前面添加
scss
@import "@/uni_modules/vk-uview-ui/theme.scss";
main.js
js
import App from "./App"
import store from "./store"
import Pub from "./utils/index.js"
import $http from "./utils/http.js"
import config from "./utils/config.js"
import i18n from "./locales/i18n.js"
import AsyncLoadingNew from "@/components/AsyncLoadingNew/AsyncLoadingNew"
import AsyncErrorNew from "@/components/AsyncErrorNew/AsyncErrorNew"
import "./utils/intercept.js"
// #ifdef APP-PLUS
import "./utils/exitApp.js"
// #endif
// #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 uView from "./uni_modules/vk-uview-ui"
export function createApp() {
const app = createSSRApp(App)
app.component("AsyncLoadingNew", AsyncLoadingNew)
app.component("AsyncErrorNew", AsyncErrorNew)
app.config.globalProperties.Pub = Pub
app.config.globalProperties.$http = $http
app.config.globalProperties.$config = config
app.use(uView)
app.use(i18n)
app.use(store)
return {
app,
}
}
// #endif
App.vue
vue
<script setup>
import {onLaunch, onShow, onHide} from "@dcloudio/uni-app"
import {ref, getCurrentInstance} from "vue"
const {proxy} = getCurrentInstance()
onLaunch(() => {
console.log("App Launch")
uni.hideTabBar()
proxy.Pub.exitApp()
})
onShow(() => {
getInit()
console.log("App Show")
})
onHide(() => {
console.log("App Hide")
})
</script>
<style>
@import "animate.css";
page {
background-color: transparent;
min-height: 100%;
max-width: 540px;
margin-left: auto;
margin-right: auto;
}
</style>
<style lang="scss">
/*每个页面公共css */
@import "@/uni_modules/vk-uview-ui/index.scss";
@import "@/styles/scss/global.scss";
@import "@/styles/scss/theme.scss";
@import "@/styles/scss/custom.scss";
view,
text {
box-sizing: border-box;
}
input {
background-color: transparent;
}
.arba {
direction: rtl;
}
.bg-main {
background: url("/static/images/img/bg_main.png") no-repeat;
background-size: 100% auto;
background-position: 50% 0;
background-attachment: fixed;
}
.bg-lead {
background: url("/static/images/img/bg_lead.png") no-repeat;
background-size: 100% 100%;
background-position: 50% 0;
background-attachment: fixed;
}
.bg-login {
background: url("/static/images/img/bg_login.png") no-repeat;
background-size: 100% auto;
background-position: 50% 0;
background-attachment: fixed;
}
.bg-invite {
background: url("/static/images/img/bg_invite.png") no-repeat;
background-size: 100% auto;
background-position: 50% 0;
background-attachment: fixed;
}
@media (min-width: 540px) {
.bg-main,
.bg-lead,
.bg-login,
.bg-invite {
background-attachment: scroll !important;
}
}
</style>
创建目录: styles/scss
global.scss
scss
@mixin px2rpx($name, $px) {
#{$name}: $px * 1rpx;
}
@mixin fontSize($px) {
@include px2rpx(font-size, $px);
}
page {
font-size: 24rpx;
}
@for $i from 0 through 60 {
.font-#{$i * 2} {
@include fontSize($i * 2);
}
}
.font-bold {
font-weight: 900;
}
.font-middle {
font-weight: 600;
}
.font-normal {
font-weight: normal;
}
.font-light {
font-weight: 100;
}
.line-height-1 {
line-height: 1;
}
@for $i from 375 through 0 {
.w-#{$i * 2}px {
@include px2rpx(width, $i * 2);
}
.h-#{$i * 2}px {
@include px2rpx(height, $i * 2);
}
}
.w-20 {
width: 20%;
}
.w-25 {
width: 25%;
}
.w-33 {
width: 33%;
}
.w-50 {
width: 50%;
}
.w-100 {
width: 100% !important;
}
.h-100 {
height: 100% !important;
}
.w-100vw {
width: 100vw;
}
.h-100vh {
height: 100vh;
}
@for $i from 50 through 0 {
.left-#{$i * 2} {
@include px2rpx(left, $i * 2);
}
.right-#{$i * 2} {
@include px2rpx(right, $i * 2);
}
.top-#{$i * 2} {
@include px2rpx(top, $i * 2);
}
.bottom-#{$i * 2} {
@include px2rpx(bottom, $i * 2);
}
}
@for $i from 60 through 0 {
.pd-#{$i * 2} {
@include px2rpx(padding-left, $i * 2);
@include px2rpx(padding-right, $i * 2);
@include px2rpx(padding-top, $i * 2);
@include px2rpx(padding-bottom, $i * 2);
}
.pdl-#{$i * 2} {
@include px2rpx(padding-left, $i * 2);
}
.pdr-#{$i * 2} {
@include px2rpx(padding-right, $i * 2);
}
.pdt-#{$i * 2} {
@include px2rpx(padding-top, $i * 2);
}
.pdb-#{$i * 2} {
@include px2rpx(padding-bottom, $i * 2);
}
.mg-#{$i * 2} {
@include px2rpx(margin-left, $i * 2);
@include px2rpx(margin-right, $i * 2);
@include px2rpx(margin-top, $i * 2);
@include px2rpx(margin-bottom, $i * 2);
}
.mgl-#{$i * 2} {
@include px2rpx(margin-left, $i * 2);
}
.mgr-#{$i * 2} {
@include px2rpx(margin-right, $i * 2);
}
.mgt-#{$i * 2} {
@include px2rpx(margin-top, $i * 2);
}
.mgb-#{$i * 2} {
@include px2rpx(margin-bottom, $i * 2);
}
}
@mixin border($name, $num, $color) {
#{$name}: $num * 1rpx solid $color;
}
@mixin border-radius($val) {
-webkit-border-radius: $val * 1rpx;
border-radius: $val * 1rpx;
}
@for $i from 50 through 0 {
.border-radius-#{$i * 2} {
@include border-radius($i * 2);
}
}
.bd-none {
border: none !important;
}
.border-radius-half {
border-radius: 50% !important;
-webkit-border-radius: 50% !important;
}
@mixin transition {
-webkit-transition: all 0.3s;
transition: all 0.3s;
}
.transition {
@include transition();
}
.translateY-50 {
-webkit-transform: translateY(-50%) !important;
transform: translateY(-50%) !important;
}
.translate-50 {
-webkit-transform: translate3d(-50%, -50%, 0) !important;
transform: translate3d(-50%, -50%, 0) !important;
top: 50%;
left: 50%;
}
// 文字超出省略号
@mixin text-overflow($num) {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $num; // @num 行数
}
.text-overflow-1 {
@include text-overflow(1);
}
.text-overflow-2 {
@include text-overflow(2);
}
.text-line {
text-decoration: underline;
}
.link {
cursor: pointer;
}
.active-effect {
cursor: pointer;
&:active {
opacity: 0.8;
}
}
.grayscale {
-webkit-filter: grayscale(100%);
-moz-filter: grayscale(100%);
-o-filter: grayscale(100%);
filter: grayscale(100%);
opacity: 0.5;
}
.grayscale-reduction {
-webkit-filter: grayscale(0%);
-moz-filter: grayscale(0%);
-o-filter: grayscale(0%);
filter: grayscale(0%);
opacity: 1;
}
// 文字渐变
@mixin text-gradual-left-right($color1, $color2, $color3) {
background-image: -webkit-linear-gradient(right, $color1, $color2, $color3);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@mixin text-gradual-top-bottom($color1, $color2, $color3) {
background-image: -webkit-linear-gradient(bottom, $color1, $color2, $color3);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.content-box {
box-sizing: content-box;
}
.overflow-y-auto {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.overflow-x-auto {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-visible {
overflow: visible;
}
.hidden {
opacity: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.word-break {
word-break: break-all;
}
.text-nowrap {
white-space: nowrap;
}
.disabled {
cursor: not-allowed !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
pointer-events: none;
filter: grayscale(1);
opacity: 0.5;
}
.btn-disabled {
cursor: not-allowed !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
pointer-events: none;
filter: grayscale(0.3);
opacity: 0.5;
}
.inline-block {
display: inline-block;
vertical-align: middle;
}
.block {
display: block;
}
/* 弹性布局盒模型 */
.flex {
display: -webkit-box; // 老版本语法: Safari, iOS, Android browser, older WebKit browsers
display: -moz-box; // 老版本语法: Firefox (buggy)
display: -ms-flexbox; // 混合版本语法: IE 10
display: -webkit-flex; // 新版本 语法: Chrome 21+
display: flex; // 新版本语法: Opera 12.1, Firefox 22+
}
.inline-flex {
display: -webkit-inline-flex;
display: inline-flex;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-align-start {
align-items: flex-start;
}
.flex-align-center {
align-items: center;
}
.flex-align-end {
align-items: flex-end;
}
.flex-justify-center {
justify-content: center;
}
.flex-between {
justify-content: space-between;
}
.flex-around {
justify-content: space-around;
}
.flex-start {
justify-content: flex-start;
}
.flex-end {
justify-content: flex-end;
}
.flex-column {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.flex-2 {
flex: 2;
}
.flex-3 {
flex: 3;
}
.flex-4 {
flex: 4;
}
.flex-5 {
flex: 5;
}
.flex-6 {
flex: 6;
}
.flex-7 {
flex: 7;
}
.flex-8 {
flex: 8;
}
.flex-none {
flex: none;
}
.flex-auto {
flex: auto;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.com-list {
display: grid;
grid-template-columns: 1fr 1fr;
@include px2rpx(grid-column-gap, 24);
@include px2rpx(grid-row-gap, 24);
&.columns-3 {
grid-template-columns: 1fr 1fr 1fr;
}
&.columns-4 {
grid-template-columns: 1fr 1fr 1fr 1fr;
@include px2rpx(grid-column-gap, 10);
@include px2rpx(grid-row-gap, 10);
}
}
.img-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.img-contain {
width: 100%;
height: 100%;
object-fit: contain;
}
.html-content {
img,
p,
span,
video {
max-width: 100% !important;
}
img,
video {
height: auto !important;
}
}
.shade-black {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.shade-white {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.1);
}
.dialog-middle {
position: fixed;
top: 50%;
left: 50%;
z-index: 1002;
transform: translate3d(-50%, -50%, 0) scale(0);
-webkit-transform: translate3d(-50%, -50%, 0) scale(0);
@include transition();
opacity: 0;
&.active {
transform: translate3d(-50%, -50%, 0) scale(1);
-webkit-transform: translate3d(-50%, -50%, 0) scale(1);
opacity: 1;
}
}
.dialog-top {
position: fixed;
top: -100%;
left: 0;
z-index: 1002;
width: 100%;
@include transition();
&.active {
top: 0;
}
}
.dialog-bottom {
position: fixed;
bottom: -100%;
left: 0;
z-index: 1002;
width: 100%;
@include transition();
&.active {
bottom: 0;
}
}
.position-sticky {
position: sticky;
top: 0;
left: 0;
z-index: 101;
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
.position-fixed {
position: fixed;
z-index: 100;
transform: translateZ(0) !important;
-webkit-transform: translateZ(0) !important;
@include transition();
}
.position-absolute {
position: absolute;
z-index: 100;
-webkit-transform: translateZ(0);
transform: translateZ(0);
}
.position-relative {
position: relative;
}
.z-index-0 {
z-index: 0;
}
.z-index-1 {
z-index: 1;
}
.z-index-3 {
z-index: 3;
}
.z-index-5 {
z-index: 5;
}
.z-index-9 {
z-index: 9;
}
.z-index-10 {
z-index: 10;
}
.z-index-11 {
z-index: 11;
}
.z-index-99 {
z-index: 99;
}
.z-index-100 {
z-index: 100;
}
.z-index-101 {
z-index: 101;
}
.z-index-102 {
z-index: 102;
}
.z-index-999 {
z-index: 999;
}
body {
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Segoe UI, Arial, Roboto, PingFang SC, miui, Hiragino Sans GB, Microsoft Yahei, sans-serif;
}
.click-effect {
cursor: pointer;
&:active {
opacity: 0.8;
}
}
.link {
cursor: pointer;
}
@mixin com-btn($color, $bgc) {
width: 600rpx;
height: 70rpx;
background-color: $bgc !important;
font-size: 28rpx;
color: $color !important;
display: flex;
justify-content: center;
align-items: center;
border-radius: 12rpx;
cursor: pointer;
&.full {
width: 100%;
}
&.round {
border-radius: 80rpx;
}
&:active {
opacity: 0.8;
}
}
.btn-mini {
display: inline-flex;
align-items: center;
justify-content: center;
height: 50rpx;
border-radius: 50rpx;
padding-left: 20rpx;
padding-right: 20rpx;
cursor: pointer;
min-width: 120rpx;
&:active {
opacity: 0.8;
}
}
.bg-transparent {
background: transparent !important;
}
theme.scss
scss
@mixin theme($c-primary: rgb(240, 185, 11), $c-second: #5eba89, $c-price: #ff0000, $c-text-1: #fff, $c-text-2: #aaacb1, $c-text-3: #666666, $c-fall: #0fcb81, $c-rise: #e53343, $c-black: #000, $c-white: #fff, $bgc-base: #21252f, $bgc-primary: #2a303c, $bgc-header: #21252f, $bgc-footer: rgb(33, 37, 47), $bgc-input: #2a303c, $bd-base: #1b1d29, $bd-second: #999) {
.c-primary {
color: $c-primary !important;
}
.c-second {
color: $c-second;
}
.c-text-1 {
color: $c-text-1 !important;
}
.c-text-2 {
color: $c-text-2;
}
.c-text-3 {
color: $c-text-3;
}
.c-price {
color: $c-price;
}
.c-fall {
color: $c-fall;
}
.c-rise {
color: $c-rise;
}
.c-black {
color: $c-black;
}
.bgc-base {
background-color: $bgc-base;
}
.bgc-primary {
background-color: $bgc-primary !important;
}
.bgc-header {
background-color: $bgc-header !important;
}
.bgc-footer {
background-color: $bgc-footer !important;
}
.bgc-input {
background-color: $bgc-input;
}
.bgc-c-primary {
background-color: $c-primary !important;
}
.bd-base {
border: 1px solid $bd-base;
border-color: $bd-base;
}
.bd-base-left {
border-left: 1px solid $bd-base;
border-color: $bd-base;
}
.bd-base-bottom {
border-bottom: 1px solid $bd-base;
border-color: $bd-base;
}
.bd-base-top {
border-top: 1px solid $bd-base;
border-color: $bd-base;
}
.bd-second {
border: 1px solid $bd-second;
border-color: $bd-second;
}
.bd-second-bottom {
border-bottom: 1px solid $bd-second;
border-color: $bd-second;
}
.btn-primary {
@include com-btn($c-black, $c-primary);
color: $c-black;
}
:deep(.u-notice-text) {
color: $c-text-1;
}
:deep(.uicon-nav-back) {
color: $c-text-1 !important;
}
:deep(.u-title) {
color: $c-text-1 !important;
}
:deep(.my-input .uni-input-placeholder) {
color: #aaacb1 !important;
}
:deep(.my-input .uni-input-input) {
color: $c-text-1 !important;
}
:deep(.u-dropdown__menu__item__text) {
color: $c-text-1 !important;
}
:deep(.uicon-arrow-down-fill) {
color: $c-text-1 !important;
}
}
.theme-dark {
@include theme();
color: #fff;
.com-box-shadow {
-webkit-box-shadow: 0 0 10rpx 10rpx rgba(0, 0, 0, 0.13);
box-shadow: 0 0 10rpx 10rpx rgba(0, 0, 0, 0.13);
}
.active-box-shadow {
box-shadow: 1px 1px 5px 1px #949494;
-webkit-box-shadow: 1px 1px 5px 1px #949494;
border: 1px solid rgba(138, 133, 133, 0.3);
}
.bd-photo-dashed {
border: 1rpx dashed #c0ccda;
}
$imgUrl: "/static/theme_dark";
.bg-invite {
background: url(#{$imgUrl}/img/Mask.png) no-repeat;
background-size: 100% 100%;
}
}
.theme-light {
@include theme($c-primary: #1374ec, $c-text-1: #212121, $c-text-2: #333, $c-text-3: #666, $bgc-base: #e9f0f5, $bgc-primary: #fff, $bgc-header: #e9f0f5, $bgc-footer: #ffffff, $bgc-input: #fff, $bd-base: #dfdfdf, $bd-second: #ccc);
color: #212121;
.bgc-main {
background-color: #e9f0f5;
}
.footer-shadow {
box-shadow: 0px 0 4px rgba(158, 160, 169, 0.25);
}
.com-box-shadow {
-webkit-box-shadow: 0 0 10rpx 10rpx rgba(0, 0, 0, 0.13);
box-shadow: 0 0 10rpx 10rpx rgba(0, 0, 0, 0.13);
}
.active-box-shadow {
box-shadow: 1px 1px 5px 1px #949494;
-webkit-box-shadow: 1px 1px 5px 1px #949494;
border: 1px solid rgba(138, 133, 133, 0.3);
}
.bd-photo-dashed {
border: 1rpx dashed #c0ccda;
}
.btn-primary {
color: #fff !important;
}
$imgUrl: "/static/theme_dark";
.bg-invite {
background: url(#{$imgUrl}/img/Mask.png) no-repeat;
background-size: 100% 100%;
}
}
custom.scss
scss
:deep(.u-size-medium) {
padding-left: 40rpx !important;
padding-right: 40rpx !important;
}
:deep(.u-input) {
padding-left: 20rpx !important;
padding-right: 20rpx !important;
}
:deep(.u-btn) {
line-height: 1 !important;
}
:deep(uni-button:after) {
border: none !important;
}
:deep(.my-dropdown .u-dropdown__menu__item) {
justify-content: flex-start !important;
}
:deep(.u-dropdown__content__mask) {
position: fixed !important;
}
:deep(.my-dropdown .u-dropdown__content__popup) {
display: inline-block;
}
:deep(.u-dropdown__content) {
height: 620rpx !important;
}
:deep(.between-dropdown .u-flex) {
width: 100%;
padding: 0 20rpx;
justify-content: space-between !important;
}
:deep(.my-upload .file-picker__progress) {
display: none !important;
}
:deep(.u-image__error) {
background: transparent !important;
}
:deep(.u-image__loading) {
background: transparent !important;
}
:deep(.uni-simple-toast__text) {
word-break: normal !important;
}
::v-deep .uni-input-placeholder {
white-space: normal;
line-height: 1;
}
创建目录: components
然后创建组件->创建同名目录
AsyncLoadingNew.vue
vue
<template>
<mypage>
<view class="w-100 h-100vh bgc-base flex flex-justify-center" style="padding-top: 15vh;" @click="reload">
<view>
<u-icon name="reload" size="80"></u-icon>
</view>
</view>
</mypage>
</template>
<script setup>
const reload = () => {
// #ifdef H5
window.location.reload()
// #endif
}
</script>
<style></style>
AsyncLoadingNew.vue
vue
<template>
<mypage>
<view class="w-100 h-100vh bgc-base flex flex-justify-center" style="padding-top: 15vh;">
<u-loading mode="flower" size="80" v-show="isShow"></u-loading>
</view>
</mypage>
</template>
<script setup>
import {ref} from "vue"
const isShow = ref(false)
setTimeout(() => {
isShow.value = true
}, 300)
</script>
<style></style>
avatar-cropper
头像裁剪
avatar-cropper.vue
vue
<template>
<view class="content">
<view class="cropper-wrapper" :style="{height: cropperOpt.height + 'px'}">
<canvas class="cropper" :disable-scroll="true" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" :style="{width: cropperOpt.width, height: cropperOpt.height, backgroundColor: 'rgba(0, 0, 0, 0.8)'}" canvas-id="cropper" id="cropper"></canvas>
<canvas
class="cropper"
:disable-scroll="true"
:style="{
position: 'fixed',
top: `-${cropperOpt.width * cropperOpt.pixelRatio}px`,
left: `-${cropperOpt.height * cropperOpt.pixelRatio}px`,
width: `${cropperOpt.width * cropperOpt.pixelRatio}px`,
height: `${cropperOpt.height * cropperOpt.pixelRatio}`,
}"
canvas-id="targetId"
id="targetId"
></canvas>
</view>
<view class="cropper-buttons safe-area-padding" :style="{height: bottomNavHeight + 'px'}">
<!-- #ifdef H5 -->
<view class="upload" @tap="uploadTap">{{ $t("Select_Image") }}</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="upload" @tap="uploadTap">{{ $t("reselect") }}</view>
<!-- #endif -->
<view class="getCropperImage" @tap="getCropperImage(false)">{{ $t("determine") }}</view>
</view>
</view>
</template>
<script>
import WeCropper from "./weCropper.js"
export default {
props: {
// 裁剪矩形框的样式,其中可包含的属性为lineWidth-边框宽度(单位rpx),color: 边框颜色,
// mask-遮罩颜色,一般设置为一个rgba的透明度,如"rgba(0, 0, 0, 0.35)"
boundStyle: {
type: Object,
default() {
return {
lineWidth: 4,
borderColor: "rgb(245, 245, 245)",
mask: "rgba(0, 0, 0, 0.35)",
}
},
},
// // 裁剪框宽度,单位rpx
// rectWidth: {
// type: [String, Number],
// default: 400
// },
// // 裁剪框高度,单位rpx
// rectHeight: {
// type: [String, Number],
// default: 400
// },
// // 输出图片宽度,单位rpx
// destWidth: {
// type: [String, Number],
// default: 400
// },
// // 输出图片高度,单位rpx
// destHeight: {
// type: [String, Number],
// default: 400
// },
// // 输出的图片类型,如果发现裁剪的图片很大,可能是因为设置为了"png",改成"jpg"即可
// fileType: {
// type: String,
// default: 'jpg',
// },
// // 生成的图片质量
// // H5上无效,目前不考虑使用此参数
// quality: {
// type: [Number, String],
// default: 1
// }
},
data() {
return {
// 底部导航的高度
bottomNavHeight: 50,
originWidth: 200,
width: 0,
height: 0,
cropperOpt: {
id: "cropper",
targetId: "targetCropper",
pixelRatio: 1,
width: 0,
height: 0,
scale: 2.5,
zoom: 8,
cut: {
x: (this.width - this.originWidth) / 2,
y: (this.height - this.originWidth) / 2,
width: this.originWidth,
height: this.originWidth,
},
boundStyle: {
lineWidth: uni.upx2px(this.boundStyle.lineWidth),
mask: this.boundStyle.mask,
color: this.boundStyle.borderColor,
},
},
// 裁剪框和输出图片的尺寸,高度默认等于宽度
// 输出图片宽度,单位px
destWidth: 200,
// 裁剪框宽度,单位px
rectWidth: 200,
// 输出的图片类型,如果'png'类型发现裁剪的图片太大,改成"jpg"即可
fileType: "jpg",
src: "", // 选择的图片路径,用于在点击确定时,判断是否选择了图片
}
},
onLoad(option) {
let rectInfo = uni.getSystemInfoSync()
this.width = rectInfo.windowWidth
this.height = rectInfo.windowHeight - this.bottomNavHeight
this.cropperOpt.width = this.width
this.cropperOpt.height = this.height
this.cropperOpt.pixelRatio = rectInfo.pixelRatio
if (option.destWidth) this.destWidth = option.destWidth
if (option.rectWidth) {
let rectWidth = Number(option.rectWidth)
this.cropperOpt.cut = {
x: (this.width - rectWidth) / 2,
y: (this.height - rectWidth) / 2,
width: rectWidth,
height: rectWidth,
}
}
this.rectWidth = option.rectWidth
if (option.fileType) this.fileType = option.fileType
// 初始化
this.cropper = new WeCropper(this.cropperOpt)
.on("ready", ctx => {
// wecropper is ready for work!
})
.on("beforeImageLoad", ctx => {
// before picture loaded, i can do something
})
.on("imageLoad", ctx => {
// picture loaded
})
.on("beforeDraw", (ctx, instance) => {
// before canvas draw,i can do something
})
// 设置导航栏样式,以免用户在page.json中没有设置为黑色背景
uni.setNavigationBarColor({
frontColor: "#ffffff",
backgroundColor: "#000000",
})
uni.chooseImage({
count: 1, // 默认9
sizeType: ["compressed"], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ["album", "camera"], // 可以指定来源是相册还是相机,默认二者都有
success: res => {
this.src = res.tempFilePaths[0]
// 获取裁剪图片资源后,给data添加src属性及其值
this.cropper.pushOrign(this.src)
},
})
},
methods: {
touchStart(e) {
this.cropper.touchStart(e)
},
touchMove(e) {
this.cropper.touchMove(e)
},
touchEnd(e) {
this.cropper.touchEnd(e)
},
getCropperImage(isPre = false) {
if (!this.src) return this.$u.toast(this.Pub.t("Please_select_a_picture_before_cropping"))
let cropper_opt = {
destHeight: Number(this.destWidth), // uni.canvasToTempFilePath要求这些参数为数值
destWidth: Number(this.destWidth),
fileType: this.fileType,
}
this.cropper.getCropperImage(cropper_opt, (path, err) => {
if (err) {
uni.showModal({
title: this.Pub.t("Kind_tips"),
content: err.message,
})
} else {
if (isPre) {
uni.previewImage({
current: "", // 当前显示图片的 http 链接
urls: [path], // 需要预览的图片 http 链接列表
})
} else {
uni.$emit("uAvatarCropper", path)
this.$u.route({
type: "back",
})
}
}
})
},
uploadTap() {
const self = this
uni.chooseImage({
count: 1, // 默认9
sizeType: ["original", "compressed"], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ["album", "camera"], // 可以指定来源是相册还是相机,默认二者都有
success: res => {
self.src = res.tempFilePaths[0]
// 获取裁剪图片资源后,给data添加src属性及其值
self.cropper.pushOrign(this.src)
},
})
},
},
}
</script>
<style scoped lang="scss">
@mixin vue-flex($direction: row) {
/* #ifndef APP-NVUE */
display: flex;
flex-direction: $direction;
/* #endif */
}
.content {
background: rgba(255, 255, 255, 1);
}
.cropper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 11;
}
.cropper-buttons {
background-color: #000000;
color: #eee;
}
.cropper-wrapper {
position: relative;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: #000;
}
.cropper-buttons {
width: 100vw;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: fixed;
bottom: 0;
left: 0;
font-size: 28rpx;
}
.cropper-buttons .upload,
.cropper-buttons .getCropperImage {
width: 50%;
text-align: center;
}
.cropper-buttons .upload {
text-align: left;
padding-left: 50rpx;
}
.cropper-buttons .getCropperImage {
text-align: right;
padding-right: 50rpx;
}
</style>
weCropper.js
js
/**
* we-cropper v1.3.9
* (c) 2020 dlhandsome
* @license MIT
*/
"use strict"
var device = void 0
var TOUCH_STATE = ["touchstarted", "touchmoved", "touchended"]
function firstLetterUpper(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
function setTouchState(instance) {
var arg = [],
len = arguments.length - 1
while (len-- > 0) arg[len] = arguments[len + 1]
TOUCH_STATE.forEach(function (key, i) {
if (arg[i] !== undefined) {
instance[key] = arg[i]
}
})
}
function validator(instance, o) {
Object.defineProperties(instance, o)
}
function getDevice() {
if (!device) {
device = uni.getSystemInfoSync()
}
return device
}
var tmp = {}
var ref = getDevice()
var pixelRatio = ref.pixelRatio
var DEFAULT = {
id: {
default: "cropper",
get: function get() {
return tmp.id
},
set: function set(value) {
if (typeof value !== "string") {
console.error("id:" + value + " is invalid")
}
tmp.id = value
},
},
width: {
default: 750,
get: function get() {
return tmp.width
},
set: function set(value) {
if (typeof value !== "number") {
console.error("width:" + value + " is invalid")
}
tmp.width = value
},
},
height: {
default: 750,
get: function get() {
return tmp.height
},
set: function set(value) {
if (typeof value !== "number") {
console.error("height:" + value + " is invalid")
}
tmp.height = value
},
},
pixelRatio: {
default: pixelRatio,
get: function get() {
return tmp.pixelRatio
},
set: function set(value) {
if (typeof value !== "number") {
console.error("pixelRatio:" + value + " is invalid")
}
tmp.pixelRatio = value
},
},
scale: {
default: 2.5,
get: function get() {
return tmp.scale
},
set: function set(value) {
if (typeof value !== "number") {
console.error("scale:" + value + " is invalid")
}
tmp.scale = value
},
},
zoom: {
default: 5,
get: function get() {
return tmp.zoom
},
set: function set(value) {
if (typeof value !== "number") {
console.error("zoom:" + value + " is invalid")
} else if (value < 0 || value > 10) {
console.error("zoom should be ranged in 0 ~ 10")
}
tmp.zoom = value
},
},
src: {
default: "",
get: function get() {
return tmp.src
},
set: function set(value) {
if (typeof value !== "string") {
console.error("src:" + value + " is invalid")
}
tmp.src = value
},
},
cut: {
default: {},
get: function get() {
return tmp.cut
},
set: function set(value) {
if (typeof value !== "object") {
console.error("cut:" + value + " is invalid")
}
tmp.cut = value
},
},
boundStyle: {
default: {},
get: function get() {
return tmp.boundStyle
},
set: function set(value) {
if (typeof value !== "object") {
console.error("boundStyle:" + value + " is invalid")
}
tmp.boundStyle = value
},
},
onReady: {
default: null,
get: function get() {
return tmp.ready
},
set: function set(value) {
tmp.ready = value
},
},
onBeforeImageLoad: {
default: null,
get: function get() {
return tmp.beforeImageLoad
},
set: function set(value) {
tmp.beforeImageLoad = value
},
},
onImageLoad: {
default: null,
get: function get() {
return tmp.imageLoad
},
set: function set(value) {
tmp.imageLoad = value
},
},
onBeforeDraw: {
default: null,
get: function get() {
return tmp.beforeDraw
},
set: function set(value) {
tmp.beforeDraw = value
},
},
}
var ref$1 = getDevice()
var windowWidth = ref$1.windowWidth
function prepare() {
var self = this
// v1.4.0 版本中将不再自动绑定we-cropper实例
self.attachPage = function () {
var pages = getCurrentPages()
// 获取到当前page上下文
var pageContext = pages[pages.length - 1]
// 把this依附在Page上下文的wecropper属性上,便于在page钩子函数中访问
Object.defineProperty(pageContext, "wecropper", {
get: function get() {
console.warn("Instance will not be automatically bound to the page after v1.4.0\n\n" + "Please use a custom instance name instead\n\n" + "Example: \n" + "this.mycropper = new WeCropper(options)\n\n" + "// ...\n" + "this.mycropper.getCropperImage()")
return self
},
configurable: true,
})
}
self.createCtx = function () {
var id = self.id
var targetId = self.targetId
if (id) {
self.ctx = self.ctx || uni.createCanvasContext(id)
self.targetCtx = self.targetCtx || uni.createCanvasContext(targetId)
} else {
console.error("constructor: create canvas context failed, 'id' must be valuable")
}
}
self.deviceRadio = windowWidth / 750
}
var commonjsGlobal = typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : {}
function createCommonjsModule(fn, module) {
return (
(module = {
exports: {},
}),
fn(module, module.exports),
module.exports
)
}
var tools = createCommonjsModule(function (module, exports) {
/**
* String type check
*/
exports.isStr = function (v) {
return typeof v === "string"
}
/**
* Number type check
*/
exports.isNum = function (v) {
return typeof v === "number"
}
/**
* Array type check
*/
exports.isArr = Array.isArray
/**
* undefined type check
*/
exports.isUndef = function (v) {
return v === undefined
}
exports.isTrue = function (v) {
return v === true
}
exports.isFalse = function (v) {
return v === false
}
/**
* Function type check
*/
exports.isFunc = function (v) {
return typeof v === "function"
}
/**
* Quick object check - this is primarily used to tell
* Objects from primitive values when we know the value
* is a JSON-compliant type.
*/
exports.isObj = exports.isObject = function (obj) {
return obj !== null && typeof obj === "object"
}
/**
* Strict object type check. Only returns true
* for plain JavaScript objects.
*/
var _toString = Object.prototype.toString
exports.isPlainObject = function (obj) {
return _toString.call(obj) === "[object Object]"
}
/**
* Check whether the object has the property.
*/
var hasOwnProperty = Object.prototype.hasOwnProperty
exports.hasOwn = function (obj, key) {
return hasOwnProperty.call(obj, key)
}
/**
* Perform no operation.
* Stubbing args to make Flow happy without leaving useless transpiled code
* with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/)
*/
exports.noop = function (a, b, c) {}
/**
* Check if val is a valid array index.
*/
exports.isValidArrayIndex = function (val) {
var n = parseFloat(String(val))
return n >= 0 && Math.floor(n) === n && isFinite(val)
}
})
var tools_7 = tools.isFunc
var tools_10 = tools.isPlainObject
var EVENT_TYPE = ["ready", "beforeImageLoad", "beforeDraw", "imageLoad"]
function observer() {
var self = this
self.on = function (event, fn) {
if (EVENT_TYPE.indexOf(event) > -1) {
if (tools_7(fn)) {
event === "ready" ? fn(self) : (self["on" + firstLetterUpper(event)] = fn)
}
} else {
console.error("event: " + event + " is invalid")
}
return self
}
}
function wxPromise(fn) {
return function (obj) {
var args = [],
len = arguments.length - 1
while (len-- > 0) args[len] = arguments[len + 1]
if (obj === void 0) obj = {}
return new Promise(function (resolve, reject) {
obj.success = function (res) {
resolve(res)
}
obj.fail = function (err) {
reject(err)
}
fn.apply(void 0, [obj].concat(args))
})
}
}
function draw(ctx, reserve) {
if (reserve === void 0) reserve = false
return new Promise(function (resolve) {
ctx.draw(reserve, resolve)
})
}
var getImageInfo = wxPromise(uni.getImageInfo)
var canvasToTempFilePath = wxPromise(uni.canvasToTempFilePath)
var base64 = createCommonjsModule(function (module, exports) {
/*! http://mths.be/base64 v0.1.0 by @mathias | MIT license */
;(function (root) {
// Detect free variables `exports`.
var freeExports = "object" == "object" && exports
// Detect free variable `module`.
var freeModule = "object" == "object" && module && module.exports == freeExports && module
// Detect free variable `global`, from Node.js or Browserified code, and use
// it as `root`.
var freeGlobal = typeof commonjsGlobal == "object" && commonjsGlobal
if (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal) {
root = freeGlobal
}
/*--------------------------------------------------------------------------*/
var InvalidCharacterError = function (message) {
this.message = message
}
InvalidCharacterError.prototype = new Error()
InvalidCharacterError.prototype.name = "InvalidCharacterError"
var error = function (message) {
// Note: the error messages used throughout this file match those used by
// the native `atob`/`btoa` implementation in Chromium.
throw new InvalidCharacterError(message)
}
var TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
// http://whatwg.org/html/common-microsyntaxes.html#space-character
var REGEX_SPACE_CHARACTERS = /[\t\n\f\r ]/g
// `decode` is designed to be fully compatible with `atob` as described in the
// HTML Standard. http://whatwg.org/html/webappapis.html#dom-windowbase64-atob
// The optimized base64-decoding algorithm used is based on @atk’s excellent
// implementation. https://gist.github.com/atk/1020396
var decode = function (input) {
input = String(input).replace(REGEX_SPACE_CHARACTERS, "")
var length = input.length
if (length % 4 == 0) {
input = input.replace(/==?$/, "")
length = input.length
}
if (
length % 4 == 1 ||
// http://whatwg.org/C#alphanumeric-ascii-characters
/[^+a-zA-Z0-9/]/.test(input)
) {
error("Invalid character: the string to be decoded is not correctly encoded.")
}
var bitCounter = 0
var bitStorage
var buffer
var output = ""
var position = -1
while (++position < length) {
buffer = TABLE.indexOf(input.charAt(position))
bitStorage = bitCounter % 4 ? bitStorage * 64 + buffer : buffer
// Unless this is the first of a group of 4 characters…
if (bitCounter++ % 4) {
// …convert the first 8 bits to a single ASCII character.
output += String.fromCharCode(0xff & (bitStorage >> ((-2 * bitCounter) & 6)))
}
}
return output
}
// `encode` is designed to be fully compatible with `btoa` as described in the
// HTML Standard: http://whatwg.org/html/webappapis.html#dom-windowbase64-btoa
var encode = function (input) {
input = String(input)
if (/[^\0-\xFF]/.test(input)) {
// Note: no need to special-case astral symbols here, as surrogates are
// matched, and the input is supposed to only contain ASCII anyway.
error("The string to be encoded contains characters outside of the " + "Latin1 range.")
}
var padding = input.length % 3
var output = ""
var position = -1
var a
var b
var c
var buffer
// Make sure any padding is handled outside of the loop.
var length = input.length - padding
while (++position < length) {
// Read three bytes, i.e. 24 bits.
a = input.charCodeAt(position) << 16
b = input.charCodeAt(++position) << 8
c = input.charCodeAt(++position)
buffer = a + b + c
// Turn the 24 bits into four chunks of 6 bits each, and append the
// matching character for each of them to the output.
output += TABLE.charAt((buffer >> 18) & 0x3f) + TABLE.charAt((buffer >> 12) & 0x3f) + TABLE.charAt((buffer >> 6) & 0x3f) + TABLE.charAt(buffer & 0x3f)
}
if (padding == 2) {
a = input.charCodeAt(position) << 8
b = input.charCodeAt(++position)
buffer = a + b
output += TABLE.charAt(buffer >> 10) + TABLE.charAt((buffer >> 4) & 0x3f) + TABLE.charAt((buffer << 2) & 0x3f) + "="
} else if (padding == 1) {
buffer = input.charCodeAt(position)
output += TABLE.charAt(buffer >> 2) + TABLE.charAt((buffer << 4) & 0x3f) + "=="
}
return output
}
var base64 = {
encode: encode,
decode: decode,
version: "0.1.0",
}
// Some AMD build optimizers, like r.js, check for specific condition patterns
// like the following:
if (typeof undefined == "function" && typeof undefined.amd == "object" && undefined.amd) {
undefined(function () {
return base64
})
} else if (freeExports && !freeExports.nodeType) {
if (freeModule) {
// in Node.js or RingoJS v0.8.0+
freeModule.exports = base64
} else {
// in Narwhal or RingoJS v0.7.0-
for (var key in base64) {
base64.hasOwnProperty(key) && (freeExports[key] = base64[key])
}
}
} else {
// in Rhino or a web browser
root.base64 = base64
}
})(commonjsGlobal)
})
function makeURI(strData, type) {
return "data:" + type + ";base64," + strData
}
function fixType(type) {
type = type.toLowerCase().replace(/jpg/i, "jpeg")
var r = type.match(/png|jpeg|bmp|gif/)[0]
return "image/" + r
}
function encodeData(data) {
var str = ""
if (typeof data === "string") {
str = data
} else {
for (var i = 0; i < data.length; i++) {
str += String.fromCharCode(data[i])
}
}
return base64.encode(str)
}
/**
* 获取图像区域隐含的像素数据
* @param canvasId canvas标识
* @param x 将要被提取的图像数据矩形区域的左上角 x 坐标
* @param y 将要被提取的图像数据矩形区域的左上角 y 坐标
* @param width 将要被提取的图像数据矩形区域的宽度
* @param height 将要被提取的图像数据矩形区域的高度
* @param done 完成回调
*/
function getImageData(canvasId, x, y, width, height, done) {
uni.canvasGetImageData({
canvasId: canvasId,
x: x,
y: y,
width: width,
height: height,
success: function success(res) {
done(res, null)
},
fail: function fail(res) {
done(null, res)
},
})
}
/**
* 生成bmp格式图片
* 按照规则生成图片响应头和响应体
* @param oData 用来描述 canvas 区域隐含的像素数据 { data, width, height } = oData
* @returns {*} base64字符串
*/
function genBitmapImage(oData) {
//
// BITMAPFILEHEADER: http://msdn.microsoft.com/en-us/library/windows/desktop/dd183374(v=vs.85).aspx
// BITMAPINFOHEADER: http://msdn.microsoft.com/en-us/library/dd183376.aspx
//
var biWidth = oData.width
var biHeight = oData.height
var biSizeImage = biWidth * biHeight * 3
var bfSize = biSizeImage + 54 // total header size = 54 bytes
//
// typedef struct tagBITMAPFILEHEADER {
// WORD bfType;
// DWORD bfSize;
// WORD bfReserved1;
// WORD bfReserved2;
// DWORD bfOffBits;
// } BITMAPFILEHEADER;
//
var BITMAPFILEHEADER = [
// WORD bfType -- The file type signature; must be "BM"
0x42,
0x4d,
// DWORD bfSize -- The size, in bytes, of the bitmap file
bfSize & 0xff,
(bfSize >> 8) & 0xff,
(bfSize >> 16) & 0xff,
(bfSize >> 24) & 0xff,
// WORD bfReserved1 -- Reserved; must be zero
0,
0,
// WORD bfReserved2 -- Reserved; must be zero
0,
0,
// DWORD bfOffBits -- The offset, in bytes, from the beginning of the BITMAPFILEHEADER structure to the bitmap bits.
54,
0,
0,
0,
]
//
// typedef struct tagBITMAPINFOHEADER {
// DWORD biSize;
// LONG biWidth;
// LONG biHeight;
// WORD biPlanes;
// WORD biBitCount;
// DWORD biCompression;
// DWORD biSizeImage;
// LONG biXPelsPerMeter;
// LONG biYPelsPerMeter;
// DWORD biClrUsed;
// DWORD biClrImportant;
// } BITMAPINFOHEADER, *PBITMAPINFOHEADER;
//
var BITMAPINFOHEADER = [
// DWORD biSize -- The number of bytes required by the structure
40,
0,
0,
0,
// LONG biWidth -- The width of the bitmap, in pixels
biWidth & 0xff,
(biWidth >> 8) & 0xff,
(biWidth >> 16) & 0xff,
(biWidth >> 24) & 0xff,
// LONG biHeight -- The height of the bitmap, in pixels
biHeight & 0xff,
(biHeight >> 8) & 0xff,
(biHeight >> 16) & 0xff,
(biHeight >> 24) & 0xff,
// WORD biPlanes -- The number of planes for the target device. This value must be set to 1
1,
0,
// WORD biBitCount -- The number of bits-per-pixel, 24 bits-per-pixel -- the bitmap
// has a maximum of 2^24 colors (16777216, Truecolor)
24,
0,
// DWORD biCompression -- The type of compression, BI_RGB (code 0) -- uncompressed
0,
0,
0,
0,
// DWORD biSizeImage -- The size, in bytes, of the image. This may be set to zero for BI_RGB bitmaps
biSizeImage & 0xff,
(biSizeImage >> 8) & 0xff,
(biSizeImage >> 16) & 0xff,
(biSizeImage >> 24) & 0xff,
// LONG biXPelsPerMeter, unused
0,
0,
0,
0,
// LONG biYPelsPerMeter, unused
0,
0,
0,
0,
// DWORD biClrUsed, the number of color indexes of palette, unused
0,
0,
0,
0,
// DWORD biClrImportant, unused
0,
0,
0,
0,
]
var iPadding = (4 - ((biWidth * 3) % 4)) % 4
var aImgData = oData.data
var strPixelData = ""
var biWidth4 = biWidth << 2
var y = biHeight
var fromCharCode = String.fromCharCode
do {
var iOffsetY = biWidth4 * (y - 1)
var strPixelRow = ""
for (var x = 0; x < biWidth; x++) {
var iOffsetX = x << 2
strPixelRow += fromCharCode(aImgData[iOffsetY + iOffsetX + 2]) + fromCharCode(aImgData[iOffsetY + iOffsetX + 1]) + fromCharCode(aImgData[iOffsetY + iOffsetX])
}
for (var c = 0; c < iPadding; c++) {
strPixelRow += String.fromCharCode(0)
}
strPixelData += strPixelRow
} while (--y)
var strEncoded = encodeData(BITMAPFILEHEADER.concat(BITMAPINFOHEADER)) + encodeData(strPixelData)
return strEncoded
}
/**
* 转换为图片base64
* @param canvasId canvas标识
* @param x 将要被提取的图像数据矩形区域的左上角 x 坐标
* @param y 将要被提取的图像数据矩形区域的左上角 y 坐标
* @param width 将要被提取的图像数据矩形区域的宽度
* @param height 将要被提取的图像数据矩形区域的高度
* @param type 转换图片类型
* @param done 完成回调
*/
function convertToImage(canvasId, x, y, width, height, type, done) {
if (done === void 0) done = function () {}
if (type === undefined) {
type = "png"
}
type = fixType(type)
if (/bmp/.test(type)) {
getImageData(canvasId, x, y, width, height, function (data, err) {
var strData = genBitmapImage(data)
tools_7(done) && done(makeURI(strData, "image/" + type), err)
})
} else {
console.error("暂不支持生成'" + type + "'类型的base64图片")
}
}
var CanvasToBase64 = {
convertToImage: convertToImage,
// convertToPNG: function (width, height, done) {
// return convertToImage(width, height, 'png', done)
// },
// convertToJPEG: function (width, height, done) {
// return convertToImage(width, height, 'jpeg', done)
// },
// convertToGIF: function (width, height, done) {
// return convertToImage(width, height, 'gif', done)
// },
convertToBMP: function (ref, done) {
if (ref === void 0) ref = {}
var canvasId = ref.canvasId
var x = ref.x
var y = ref.y
var width = ref.width
var height = ref.height
if (done === void 0) done = function () {}
return convertToImage(canvasId, x, y, width, height, "bmp", done)
},
}
function methods() {
var self = this
var boundWidth = self.width // 裁剪框默认宽度,即整个画布宽度
var boundHeight = self.height // 裁剪框默认高度,即整个画布高度
var id = self.id
var targetId = self.targetId
var pixelRatio = self.pixelRatio
var ref = self.cut
var x = ref.x
if (x === void 0) x = 0
var y = ref.y
if (y === void 0) y = 0
var width = ref.width
if (width === void 0) width = boundWidth
var height = ref.height
if (height === void 0) height = boundHeight
self.updateCanvas = function (done) {
if (self.croperTarget) {
// 画布绘制图片
self.ctx.drawImage(self.croperTarget, self.imgLeft, self.imgTop, self.scaleWidth, self.scaleHeight)
}
tools_7(self.onBeforeDraw) && self.onBeforeDraw(self.ctx, self)
self.setBoundStyle(self.boundStyle) // 设置边界样式
self.ctx.draw(false, done)
return self
}
self.pushOrigin = self.pushOrign = function (src) {
self.src = src
tools_7(self.onBeforeImageLoad) && self.onBeforeImageLoad(self.ctx, self)
return getImageInfo({
src: src,
})
.then(function (res) {
var innerAspectRadio = res.width / res.height
var customAspectRadio = width / height
self.croperTarget = res.path
if (innerAspectRadio < customAspectRadio) {
self.rectX = x
self.baseWidth = width
self.baseHeight = width / innerAspectRadio
self.rectY = y - Math.abs((height - self.baseHeight) / 2)
} else {
self.rectY = y
self.baseWidth = height * innerAspectRadio
self.baseHeight = height
self.rectX = x - Math.abs((width - self.baseWidth) / 2)
}
self.imgLeft = self.rectX
self.imgTop = self.rectY
self.scaleWidth = self.baseWidth
self.scaleHeight = self.baseHeight
self.update()
return new Promise(function (resolve) {
self.updateCanvas(resolve)
})
})
.then(function () {
tools_7(self.onImageLoad) && self.onImageLoad(self.ctx, self)
})
}
self.removeImage = function () {
self.src = ""
self.croperTarget = ""
return draw(self.ctx)
}
self.getCropperBase64 = function (done) {
if (done === void 0) done = function () {}
CanvasToBase64.convertToBMP(
{
canvasId: id,
x: x,
y: y,
width: width,
height: height,
},
done
)
}
self.getCropperImage = function (opt, fn) {
var customOptions = opt
var canvasOptions = {
canvasId: id,
x: x,
y: y,
width: width,
height: height,
}
var task = function () {
return Promise.resolve()
}
if (tools_10(customOptions) && customOptions.original) {
// original mode
task = function () {
self.targetCtx.drawImage(self.croperTarget, self.imgLeft * pixelRatio, self.imgTop * pixelRatio, self.scaleWidth * pixelRatio, self.scaleHeight * pixelRatio)
canvasOptions = {
canvasId: targetId,
x: x * pixelRatio,
y: y * pixelRatio,
width: width * pixelRatio,
height: height * pixelRatio,
}
return draw(self.targetCtx)
}
}
return task()
.then(function () {
if (tools_10(customOptions)) {
canvasOptions = Object.assign({}, canvasOptions, customOptions)
}
if (tools_7(customOptions)) {
fn = customOptions
}
var arg = canvasOptions.componentContext ? [canvasOptions, canvasOptions.componentContext] : [canvasOptions]
return canvasToTempFilePath.apply(null, arg)
})
.then(function (res) {
var tempFilePath = res.tempFilePath
return tools_7(fn) ? fn.call(self, tempFilePath, null) : tempFilePath
})
.catch(function (err) {
if (tools_7(fn)) {
fn.call(self, null, err)
} else {
throw err
}
})
}
}
/**
* 获取最新缩放值
* @param oldScale 上一次触摸结束后的缩放值
* @param oldDistance 上一次触摸结束后的双指距离
* @param zoom 缩放系数
* @param touch0 第一指touch对象
* @param touch1 第二指touch对象
* @returns {*}
*/
var getNewScale = function (oldScale, oldDistance, zoom, touch0, touch1) {
var xMove, yMove, newDistance
// 计算二指最新距离
xMove = Math.round(touch1.x - touch0.x)
yMove = Math.round(touch1.y - touch0.y)
newDistance = Math.round(Math.sqrt(xMove * xMove + yMove * yMove))
return oldScale + 0.001 * zoom * (newDistance - oldDistance)
}
function update() {
var self = this
if (!self.src) {
return
}
self.__oneTouchStart = function (touch) {
self.touchX0 = Math.round(touch.x)
self.touchY0 = Math.round(touch.y)
}
self.__oneTouchMove = function (touch) {
var xMove, yMove
// 计算单指移动的距离
if (self.touchended) {
return self.updateCanvas()
}
xMove = Math.round(touch.x - self.touchX0)
yMove = Math.round(touch.y - self.touchY0)
var imgLeft = Math.round(self.rectX + xMove)
var imgTop = Math.round(self.rectY + yMove)
self.outsideBound(imgLeft, imgTop)
self.updateCanvas()
}
self.__twoTouchStart = function (touch0, touch1) {
var xMove, yMove, oldDistance
self.touchX1 = Math.round(self.rectX + self.scaleWidth / 2)
self.touchY1 = Math.round(self.rectY + self.scaleHeight / 2)
// 计算两指距离
xMove = Math.round(touch1.x - touch0.x)
yMove = Math.round(touch1.y - touch0.y)
oldDistance = Math.round(Math.sqrt(xMove * xMove + yMove * yMove))
self.oldDistance = oldDistance
}
self.__twoTouchMove = function (touch0, touch1) {
var oldScale = self.oldScale
var oldDistance = self.oldDistance
var scale = self.scale
var zoom = self.zoom
self.newScale = getNewScale(oldScale, oldDistance, zoom, touch0, touch1)
// 设定缩放范围
self.newScale <= 1 && (self.newScale = 1)
self.newScale >= scale && (self.newScale = scale)
self.scaleWidth = Math.round(self.newScale * self.baseWidth)
self.scaleHeight = Math.round(self.newScale * self.baseHeight)
var imgLeft = Math.round(self.touchX1 - self.scaleWidth / 2)
var imgTop = Math.round(self.touchY1 - self.scaleHeight / 2)
self.outsideBound(imgLeft, imgTop)
self.updateCanvas()
}
self.__xtouchEnd = function () {
self.oldScale = self.newScale
self.rectX = self.imgLeft
self.rectY = self.imgTop
}
}
var handle = {
// 图片手势初始监测
touchStart: function touchStart(e) {
var self = this
var ref = e.touches
var touch0 = ref[0]
var touch1 = ref[1]
if (!self.src) {
return
}
setTouchState(self, true, null, null)
// 计算第一个触摸点的位置,并参照改点进行缩放
self.__oneTouchStart(touch0)
// 两指手势触发
if (Object.keys(e.touches).length >= 2) {
self.__twoTouchStart(touch0, touch1)
}
},
// 图片手势动态缩放
touchMove: function touchMove(e) {
var self = this
var ref = e.touches
var touch0 = ref[0]
var touch1 = ref[1]
if (!self.src) {
return
}
setTouchState(self, null, true)
// 单指手势时触发
if (Object.keys(e.touches).length === 1) {
self.__oneTouchMove(touch0)
}
// 两指手势触发
if (Object.keys(e.touches).length >= 2) {
self.__twoTouchMove(touch0, touch1)
}
},
touchEnd: function touchEnd(e) {
var self = this
if (!self.src) {
return
}
setTouchState(self, false, false, true)
self.__xtouchEnd()
},
}
function cut() {
var self = this
var boundWidth = self.width // 裁剪框默认宽度,即整个画布宽度
var boundHeight = self.height
// 裁剪框默认高度,即整个画布高度
var ref = self.cut
var x = ref.x
if (x === void 0) x = 0
var y = ref.y
if (y === void 0) y = 0
var width = ref.width
if (width === void 0) width = boundWidth
var height = ref.height
if (height === void 0) height = boundHeight
/**
* 设置边界
* @param imgLeft 图片左上角横坐标值
* @param imgTop 图片左上角纵坐标值
*/
self.outsideBound = function (imgLeft, imgTop) {
self.imgLeft = imgLeft >= x ? x : self.scaleWidth + imgLeft - x <= width ? x + width - self.scaleWidth : imgLeft
self.imgTop = imgTop >= y ? y : self.scaleHeight + imgTop - y <= height ? y + height - self.scaleHeight : imgTop
}
/**
* 设置边界样式
* @param color 边界颜色
*/
self.setBoundStyle = function (ref) {
if (ref === void 0) ref = {}
var color = ref.color
if (color === void 0) color = "#04b00f"
var mask = ref.mask
if (mask === void 0) mask = "rgba(0, 0, 0, 0.3)"
var lineWidth = ref.lineWidth
if (lineWidth === void 0) lineWidth = 1
var half = lineWidth / 2
var boundOption = [
{
start: {
x: x - half,
y: y + 10 - half,
},
step1: {
x: x - half,
y: y - half,
},
step2: {
x: x + 10 - half,
y: y - half,
},
},
{
start: {
x: x - half,
y: y + height - 10 + half,
},
step1: {
x: x - half,
y: y + height + half,
},
step2: {
x: x + 10 - half,
y: y + height + half,
},
},
{
start: {
x: x + width - 10 + half,
y: y - half,
},
step1: {
x: x + width + half,
y: y - half,
},
step2: {
x: x + width + half,
y: y + 10 - half,
},
},
{
start: {
x: x + width + half,
y: y + height - 10 + half,
},
step1: {
x: x + width + half,
y: y + height + half,
},
step2: {
x: x + width - 10 + half,
y: y + height + half,
},
},
]
// 绘制半透明层
self.ctx.beginPath()
self.ctx.setFillStyle(mask)
self.ctx.fillRect(0, 0, x, boundHeight)
self.ctx.fillRect(x, 0, width, y)
self.ctx.fillRect(x, y + height, width, boundHeight - y - height)
self.ctx.fillRect(x + width, 0, boundWidth - x - width, boundHeight)
self.ctx.fill()
boundOption.forEach(function (op) {
self.ctx.beginPath()
self.ctx.setStrokeStyle(color)
self.ctx.setLineWidth(lineWidth)
self.ctx.moveTo(op.start.x, op.start.y)
self.ctx.lineTo(op.step1.x, op.step1.y)
self.ctx.lineTo(op.step2.x, op.step2.y)
self.ctx.stroke()
})
}
}
var version = "1.3.9"
var WeCropper = function WeCropper(params) {
var self = this
var _default = {}
validator(self, DEFAULT)
Object.keys(DEFAULT).forEach(function (key) {
_default[key] = DEFAULT[key].default
})
Object.assign(self, _default, params)
self.prepare()
self.attachPage()
self.createCtx()
self.observer()
self.cutt()
self.methods()
self.init()
self.update()
return self
}
WeCropper.prototype.init = function init() {
var self = this
var src = self.src
self.version = version
typeof self.onReady === "function" && self.onReady(self.ctx, self)
if (src) {
self.pushOrign(src)
} else {
self.updateCanvas()
}
setTouchState(self, false, false, false)
self.oldScale = 1
self.newScale = 1
return self
}
Object.assign(WeCropper.prototype, handle)
WeCropper.prototype.prepare = prepare
WeCropper.prototype.observer = observer
WeCropper.prototype.methods = methods
WeCropper.prototype.cutt = cut
WeCropper.prototype.update = update
export default WeCropper
img-upload.vue
vue
<template>
<view class="u-upload" v-if="!disabled">
<view
v-if="showUploadList"
class="u-list-item u-preview-wrap"
v-for="(item, index) in lists"
:key="index"
:style="{
width: $u.addUnit(width),
height: $u.addUnit(height),
}"
>
<view
v-if="deletable"
class="u-delete-icon"
@tap.stop="deleteItem(index)"
:style="{
background: delBgColor,
}"
>
<u-icon class="u-icon" :name="delIcon" size="20" :color="delColor"></u-icon>
</view>
<!-- <view
v-if="item.progress >= 100"
class="u-success-icon"
>
<u-icon class="u-icon" :name="successIcon" size="20" :color="successColor"></u-icon>
</view> -->
<u-line-progress v-if="showProgress && item.progress > 0 && item.progress != 100 && !item.error" :show-percent="false" height="16" class="u-progress" :percent="item.progress"></u-line-progress>
<view @tap.stop="retry(index)" v-if="item.error" class="u-error-btn">点击重试</view>
<image @tap.stop="doPreviewImage(item.url || item.path, index)" class="u-preview-image" v-if="!item.isImage" :src="item.url || item.path" :mode="imageMode"></image>
</view>
<slot name="file" :file="lists"></slot>
<view style="display: inline-block;" @tap="selectFile" v-if="maxCount > lists.length">
<slot name="addBtn"></slot>
<view
v-if="!customBtn"
class="u-list-item u-add-wrap"
hover-class="u-add-wrap__hover"
hover-stay-time="150"
:style="{
width: $u.addUnit(width),
height: $u.addUnit(height),
}"
>
<u-icon name="plus" class="u-add-btn" size="40"></u-icon>
<view class="u-add-tips">{{ uploadText }}</view>
</view>
</view>
</view>
</template>
<script>
/**
* upload 图片上传
* @description 该组件用于上传图片场景
* @tutorial https://www.uviewui.com/components/upload.html
* @property {String} action 服务器上传地址
* @property {String Number} max-count 最大选择图片的数量(默认99)
* @property {Boolean} custom-btn 如果需要自定义选择图片的按钮,设置为true(默认false)
* @property {Boolean} show-progress 是否显示进度条(默认true)
* @property {Boolean} disabled 是否启用(显示/移仓)组件(默认false)
* @property {String} image-mode 预览图片等显示模式,可选值为uni的image的mode属性值(默认aspectFill)
* @property {String} del-icon 右上角删除图标名称,只能为uView内置图标
* @property {String} del-bg-color 右上角关闭按钮的背景颜色
* @property {String | Number} index 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件
* @property {String} del-color 右上角关闭按钮图标的颜色
* @property {Object} header 上传携带的头信息,对象形式
* @property {Object} form-data 上传额外携带的参数
* @property {String} name 上传文件的字段名,供后端获取使用(默认file)
* @property {Array<String>} size-type original 原图,compressed 压缩图,默认二者都有(默认['original', 'compressed'])
* @property {Array<String>} source-type 选择图片的来源,album-从相册选图,camera-使用相机,默认二者都有(默认['album', 'camera'])
* @property {Boolean} preview-full-image 是否可以通过uni.previewImage预览已选择的图片(默认true)
* @property {Boolean} multiple 是否开启图片多选,部分安卓机型不支持(默认true)
* @property {Boolean} deletable 是否显示删除图片的按钮(默认true)
* @property {String Number} max-size 选择单个文件的最大大小,单位B(byte),默认不限制(默认Number.MAX_VALUE)
* @property {Array<Object>} file-list 默认显示的图片列表,数组元素为对象,必须提供url属性
* @property {Boolean} upload-text 选择图片按钮的提示文字(默认“选择图片”)
* @property {Boolean} auto-upload 选择完图片是否自动上传,见上方说明(默认true)
* @property {Boolean} show-tips 特殊情况下是否自动提示toast,见上方说明(默认true)
* @property {Boolean} show-upload-list 是否显示组件内部的图片预览(默认true)
* @event {Function} on-oversize 图片大小超出最大允许大小
* @event {Function} on-preview 全屏预览图片时触发
* @event {Function} on-remove 移除图片时触发
* @event {Function} on-success 图片上传成功时触发
* @event {Function} on-change 图片上传后,无论成功或者失败都会触发
* @event {Function} on-error 图片上传失败时触发
* @event {Function} on-progress 图片上传过程中的进度变化过程触发
* @event {Function} on-uploaded 所有图片上传完毕触发
* @event {Function} on-choose-complete 每次选择图片后触发,只是让外部可以得知每次选择后,内部的文件列表
* @event {Function} on-list-change 当内部文件列表被加入文件、移除文件,或手动调用clear方法时触发
* @event {Function} on-choose-fail 选择文件出错时触发,比如选择文件时取消了操作,只在微信和APP有效
* @example <u-upload :action="action" :file-list="fileList" ></u-upload>
*/
export default {
name: "u-upload",
emits: ["update:file-list", "on-oversize", "on-list-change", "on-preview", "on-remove", "on-success", "on-change", "on-error", "on-progress", "on-uploaded", "on-choose-complete", "on-choose-fail"],
props: {
//是否显示组件自带的图片预览功能
showUploadList: {
type: Boolean,
default: true,
},
// 后端地址
action: {
type: String,
default: "",
},
// 最大上传数量
maxCount: {
type: [String, Number],
default: 52,
},
// 是否显示进度条
showProgress: {
type: Boolean,
default: true,
},
// 是否启用
disabled: {
type: Boolean,
default: false,
},
// 预览上传的图片时的裁剪模式,和image组件mode属性一致
imageMode: {
type: String,
default: "aspectFill",
},
// 头部信息
header: {
type: Object,
default() {
return {}
},
},
// 额外携带的参数
formData: {
type: Object,
default() {
return {}
},
},
// 上传的文件字段名
name: {
type: String,
default: "file",
},
// 所选的图片的尺寸, 可选值为original compressed
sizeType: {
type: Array,
default() {
return ["original", "compressed"]
},
},
sourceType: {
type: Array,
default() {
return ["album", "camera"]
},
},
// 是否在点击预览图后展示全屏图片预览
previewFullImage: {
type: Boolean,
default: true,
},
// 是否开启图片多选,部分安卓机型不支持
multiple: {
type: Boolean,
default: true,
},
// 是否展示删除按钮
deletable: {
type: Boolean,
default: true,
},
// 文件大小限制,单位为byte
maxSize: {
type: [String, Number],
default: Number.MAX_VALUE,
},
// 显示已上传的文件列表
fileList: {
type: Array,
default() {
return []
},
},
// 上传区域的提示文字
uploadText: {
type: String,
default: "选择图片",
},
// 是否自动上传
autoUpload: {
type: Boolean,
default: true,
},
// 是否显示toast消息提示
showTips: {
type: Boolean,
default: true,
},
// 是否通过slot自定义传入选择图标的按钮
customBtn: {
type: Boolean,
default: false,
},
// 内部预览图片区域和选择图片按钮的区域宽度
width: {
type: [String, Number],
default: 200,
},
// 内部预览图片区域和选择图片按钮的区域高度
height: {
type: [String, Number],
default: 200,
},
// 右上角关闭按钮的背景颜色
delBgColor: {
type: String,
default: "#fa3534",
},
// 右上角关闭按钮的叉号图标的颜色
delColor: {
type: String,
default: "#ffffff",
},
// 右上角删除图标名称,只能为uView内置图标
delIcon: {
type: String,
default: "close",
},
// 右下角成功图标名称,只能为uView内置图标
successIcon: {
type: String,
default: "checkbox-mark",
},
// 右下角成功的叉号图标的颜色
successColor: {
type: String,
default: "#ffffff",
},
// 如果上传后的返回值为json字符串,是否自动转json
toJson: {
type: Boolean,
default: true,
},
// 上传前的钩子,每个文件上传前都会执行
beforeUpload: {
type: Function,
default: null,
},
// 移除文件前的钩子
beforeRemove: {
type: Function,
default: null,
},
// 允许上传的图片后缀
limitType: {
type: Array,
default() {
// 支付宝小程序真机选择图片的后缀为"image"
// https://opendocs.alipay.com/mini/api/media-image
return ["png", "jpg", "jpeg", "webp", "gif", "image"]
},
},
// 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件
index: {
type: [Number, String],
default: "",
},
},
mounted() {},
data() {
return {
lists: [],
isInCount: true,
uploading: false,
}
},
watch: {
fileList: {
immediate: true,
handler(val) {
let that = this
let lists = JSON.parse(JSON.stringify(that.lists))
val.map(value => {
// 首先检查内部是否已经添加过这张图片,因为外部绑定了一个对象给fileList的话(对象引用),进行修改外部fileList
// 时,会触发watch,导致重新把原来的图片再次添加到this.lists
// 数组的some方法意思是,只要数组元素有任意一个元素条件符合,就返回true,而另一个数组的every方法的意思是数组所有元素都符合条件才返回true
let tmp = lists.some(val => {
return val.url == value.url
})
// 如果内部没有这个图片(tmp为false),则添加到内部
if (!tmp) {
lists.push({
url: value.url,
error: false,
progress: 100,
})
}
})
that.lists = JSON.parse(JSON.stringify(lists))
},
},
// 监听lists的变化,发出事件
lists: {
deep: true,
handler(n) {
this.$emit("update:file-list", n)
this.$emit("on-list-change", n, this.index)
},
},
},
methods: {
// 清除列表
clear() {
this.lists = []
},
// 重新上传队列中上传失败的所有文件
reUpload() {
this.uploadFile()
},
// 选择图片
selectFile() {
let that = this
if (that.disabled) return
const {name = "", maxCount, multiple, maxSize, sizeType, camera, compressed, maxDuration, sourceType} = that
let chooseFile = null
let lists = JSON.parse(JSON.stringify(that.lists))
const newMaxCount = maxCount - lists.length
// 设置为只选择图片的时候使用 chooseImage 来实现
chooseFile = new Promise((resolve, reject) => {
uni.chooseImage({
count: multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1,
sourceType: sourceType,
sizeType,
success: resolve,
fail: reject,
})
})
chooseFile
.then(res => {
let file = null
let listOldLength = that.lists.length
res.tempFiles.map((val, index) => {
// 检查文件后缀是否允许,如果不在that.limitType内,就会返回false
if (!that.checkFileExt(val)) return
// 如果是非多选,index大于等于1或者超出最大限制数量时,不处理
if (!multiple && index >= 1) return
if (val.size > maxSize) {
that.$emit("on-oversize", val, that.lists, that.index)
that.showToast("超出允许的文件大小")
} else {
if (maxCount <= lists.length) {
that.$emit("on-exceed", val, that.lists, that.index)
that.showToast("超出最大允许的文件个数")
return
}
lists.push({
url: val.path,
progress: 0,
error: false,
file: val,
})
}
})
// 这样实现深拷贝会导致在H5中file为空对象
// that.lists = JSON.parse(JSON.stringify(lists));
this.deepClone(lists, that.lists)
// 每次图片选择完,抛出一个事件,并将当前内部选择的图片数组抛出去
that.$emit("on-choose-complete", that.lists, that.index)
if (that.autoUpload) that.uploadFile(listOldLength)
})
.catch(error => {
that.$emit("on-choose-fail", error)
})
},
// 提示用户消息
showToast(message, force = false) {
if (this.showTips || force) {
uni.showToast({
title: message,
icon: "none",
})
}
},
// 该方法供用户通过ref调用,手动上传
upload() {
this.uploadFile()
},
// 对失败的图片重新上传
retry(index) {
this.lists[index].progress = 0
this.lists[index].error = false
this.lists[index].response = null
uni.showLoading({
title: "重新上传",
})
this.uploadFile(index)
},
// 上传图片
async uploadFile(index = 0) {
if (this.disabled) return
if (this.uploading) return
// 全部上传完成
if (index >= this.lists.length) {
this.$emit("on-uploaded", this.lists, this.index)
return
}
// 检查是否是已上传或者正在上传中
if (this.lists[index].progress == 100) {
if (this.autoUpload == false) this.uploadFile(index + 1)
return
}
// 执行before-upload钩子
if (this.beforeUpload && typeof this.beforeUpload === "function") {
// 执行回调,同时传入索引和文件列表当作参数
// 在微信,支付宝等环境(H5正常),会导致父组件定义的customBack()函数体中的this变成子组件的this
// 通过bind()方法,绑定父组件的this,让this.customBack()的this为父组件的上下文
// 因为upload组件可能会被嵌套在其他组件内,比如u-form,这时this.$parent其实为u-form的this,
// 非页面的this,所以这里需要往上历遍,一直寻找到最顶端的$parent,这里用了this.$u.$parent.call(this)
// 明白意思即可,无需纠结this.$u.$parent.call(this)的细节
let beforeResponse = this.beforeUpload.bind(this.$u.$parent.call(this))(index, this.lists)
// 判断是否返回了promise
if (!!beforeResponse && typeof beforeResponse.then === "function") {
await beforeResponse
.then(res => {
// promise返回成功,不进行动作,继续上传
})
.catch(err => {
// 进入catch回调的话,继续下一张
return this.uploadFile(index + 1)
})
} else if (beforeResponse === false) {
// 如果返回false,继续下一张图片的上传
return this.uploadFile(index + 1)
} else {
// 此处为返回"true"的情形,这里不写代码,就跳过此处,继续执行当前的上传逻辑
}
}
// 检查上传地址
if (!this.action) {
this.showToast("请配置上传地址", true)
return
}
this.lists[index].error = false
this.uploading = true
// 创建上传对象
const task = uni.uploadFile({
url: this.action,
filePath: this.lists[index].url,
name: this.name,
formData: this.formData,
header: this.header,
// #ifdef MP-ALIPAY
fileType: "image",
// #endif
success: res => {
// 判断是否json字符串,将其转为json格式
let data = this.toJson && this.$u.test.jsonString(res.data) ? JSON.parse(res.data) : res.data
if (![200, 201, 204].includes(res.statusCode)) {
this.uploadError(index, data)
} else {
// 上传成功
this.lists[index].response = data
this.lists[index].progress = 100
this.lists[index].error = false
this.$emit("on-success", data, index, this.lists, this.index)
}
},
fail: e => {
this.uploadError(index, e)
},
complete: res => {
uni.hideLoading()
this.uploading = false
this.uploadFile(index + 1)
this.$emit("on-change", res, index, this.lists, this.index)
},
})
task.onProgressUpdate(res => {
if (res.progress > 0) {
this.lists[index].progress = res.progress
this.$emit("on-progress", res, index, this.lists, this.index)
}
})
},
// 上传失败
uploadError(index, err) {
this.lists[index].progress = 0
this.lists[index].error = true
this.lists[index].response = null
this.$emit("on-error", err, index, this.lists, this.index)
this.showToast("上传失败,请重试")
},
// 删除一个图片
async deleteItem(index) {
// 先检查是否有定义before-remove移除前钩子
// 执行before-remove钩子
if (this.beforeRemove && typeof this.beforeRemove === "function") {
// 此处钩子执行 原理同before-remove参数,见上方注释
let beforeResponse = this.beforeRemove.bind(this.$u.$parent.call(this))(index, this.lists)
// 判断是否返回了promise
if (!!beforeResponse && typeof beforeResponse.then === "function") {
await beforeResponse
.then(res => {
// promise返回成功,不进行动作,继续上传
this.handlerDeleteItem(index)
})
.catch(err => {
// 如果进入promise的reject,终止删除操作
this.showToast("已终止移除")
})
} else if (beforeResponse === false) {
// 返回false,终止删除
this.showToast("已终止移除")
} else {
// 如果返回true,执行删除操作
this.handlerDeleteItem(index)
}
} else {
// 如果不存在before-remove钩子,
this.handlerDeleteItem(index)
}
// uni.showModal({
// title: "提示",
// content: "您确定要删除此项吗?",
// success: async res => {
// if (res.confirm) {
// // 先检查是否有定义before-remove移除前钩子
// // 执行before-remove钩子
// if (this.beforeRemove && typeof this.beforeRemove === "function") {
// // 此处钩子执行 原理同before-remove参数,见上方注释
// let beforeResponse = this.beforeRemove.bind(this.$u.$parent.call(this))(index,
// this.lists);
// // 判断是否返回了promise
// if (!!beforeResponse && typeof beforeResponse.then === "function") {
// await beforeResponse
// .then(res => {
// // promise返回成功,不进行动作,继续上传
// this.handlerDeleteItem(index);
// })
// .catch(err => {
// // 如果进入promise的reject,终止删除操作
// this.showToast("已终止移除");
// });
// } else if (beforeResponse === false) {
// // 返回false,终止删除
// this.showToast("已终止移除");
// } else {
// // 如果返回true,执行删除操作
// this.handlerDeleteItem(index);
// }
// } else {
// // 如果不存在before-remove钩子,
// this.handlerDeleteItem(index);
// }
// }
// }
// });
},
// 执行移除图片的动作,上方代码只是判断是否可以移除
handlerDeleteItem(index) {
// 如果文件正在上传中,终止上传任务,进度在0 < progress < 100则意味着正在上传
if (this.lists[index].progress < 100 && this.lists[index].progress > 0) {
typeof this.lists[index].uploadTask != "undefined" && this.lists[index].uploadTask.abort()
}
this.lists.splice(index, 1)
this.$forceUpdate()
this.$emit("on-remove", index, this.lists, this.index)
//this.showToast('移除成功');
},
// 用户通过ref手动的形式,移除一张图片
remove(index) {
// 判断索引的合法范围
if (index >= 0 && index < this.lists.length) {
this.lists.splice(index, 1)
this.$emit("on-list-change", this.lists, this.index)
}
},
// 预览图片
doPreviewImage(url, index) {
if (!this.previewFullImage) {
this.$emit("on-preview", url, this.lists, this.index)
return
}
const images = this.lists.map(item => item.url || item.path)
uni.previewImage({
urls: images,
current: url,
success: () => {
this.$emit("on-preview", url, this.lists, this.index)
},
fail: () => {
uni.showToast({
title: "预览图片失败",
icon: "none",
})
},
})
},
// 判断文件后缀是否允许
checkFileExt(file) {
// 检查是否在允许的后缀中
let noArrowExt = false
// 获取后缀名
let fileExt = ""
const reg = /.+\./
// 如果是H5,需要从name中判断
// #ifdef H5
fileExt = file.name.replace(reg, "").toLowerCase()
// #endif
// 非H5,需要从path中读取后缀
// #ifndef H5
fileExt = file.path.replace(reg, "").toLowerCase()
// #endif
// 使用数组的some方法,只要符合limitType中的一个,就返回true
noArrowExt = this.limitType.some(ext => {
// 转为小写
return ext.toLowerCase() === fileExt
})
if (!noArrowExt) this.showToast(`不允许选择${fileExt}格式的文件`)
return noArrowExt
},
// 深拷贝
deepClone(obj, newObj) {
for (let k in obj) {
const value = obj[k]
if (Array.isArray(value)) {
newObj[k] = []
this.deepClone(value, newObj[k])
} else if (value !== null && typeof value === "object") {
newObj[k] = {}
this.deepClone(value, newObj[k])
} else {
newObj[k] = value
}
}
},
},
}
</script>
<style lang="scss" scoped>
@mixin vue-flex($direction: row) {
/* #ifndef APP-NVUE */
display: flex;
flex-direction: $direction;
/* #endif */
}
.u-upload {
@include vue-flex;
flex-wrap: wrap;
align-items: center;
}
.u-list-item {
width: 200rpx;
height: 200rpx;
overflow: hidden;
margin: 10rpx;
background: rgb(244, 245, 246);
position: relative;
border-radius: 10rpx;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
justify-content: center;
}
.u-preview-wrap {
border: 1px solid rgb(235, 236, 238);
}
.u-add-wrap {
flex-direction: column;
color: $u-content-color;
font-size: 26rpx;
}
.u-add-tips {
margin-top: 20rpx;
line-height: 40rpx;
}
.u-add-wrap__hover {
background-color: rgb(235, 236, 238);
}
.u-preview-image {
display: block;
width: 100%;
height: 100%;
border-radius: 10rpx;
}
.u-delete-icon {
position: absolute;
top: 6rpx;
right: 6rpx;
z-index: 10;
background-color: $u-type-error;
border-radius: 100rpx;
width: 36rpx;
height: 36rpx;
@include vue-flex;
align-items: center;
justify-content: center;
}
.u-icon {
@include vue-flex;
align-items: center;
justify-content: center;
}
.u-success-icon {
position: absolute;
bottom: 6rpx;
right: 6rpx;
z-index: 10;
background-color: #5ac725;
border-radius: 100rpx;
width: 36rpx;
height: 36rpx;
@include vue-flex;
align-items: center;
justify-content: center;
}
.u-progress {
position: absolute;
bottom: 10rpx;
left: 8rpx;
right: 8rpx;
z-index: 9;
width: auto;
}
.u-error-btn {
color: #ffffff;
background-color: $u-type-error;
font-size: 20rpx;
padding: 4px 0;
text-align: center;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 9;
line-height: 1;
}
</style>
mypage.vue
vue
<template>
<view class="mypage" :style="{'min-height': winMinHeight + 'px'}" :class="`theme-${themeName}`">
<view class="page bgc-base bg-main" :style="{'min-height': winMinHeight + 'px'}" :class="className">
<slot></slot>
</view>
</view>
</template>
<script setup>
import {useStore} from "vuex"
import {ref, computed, onMounted, getCurrentInstance} from "vue"
import {onLoad, onReady, onShow, onHide, onPullDownRefresh, onReachBottom} from "@dcloudio/uni-app"
const {proxy} = getCurrentInstance()
const store = useStore()
const themeName = computed(() => store.state.themeName)
const props = defineProps({
className: String,
})
const winMinHeight = ref(proxy.Pub.winInfo().windowHeight)
uni.onWindowResize(() => {
winMinHeight.value = proxy.Pub.winInfo().windowHeight
})
onShow(() => {
// #ifdef APP-PLUS
if (themeName.value == "light") {
uni.setNavigationBarColor({
frontColor: "#000000",
backgroundColor: "rgba(255,255,255,0)",
})
} else {
uni.setNavigationBarColor({
frontColor: "#ffffff",
backgroundColor: "rgba(255,255,255,0)",
})
}
// #endif
})
</script>
<style lang="scss">
.mypage {
min-height: 100vh;
}
.page {
width: 100%;
min-height: 100vh;
overflow-x: hidden;
// -webkit-overflow-scrolling: touch;
}
</style>
com-tabbar.vue
vue
<template>
<view>
<view class="position-fixed bottom-0 w-100" style="max-width: 540px; left: 50%; transform: translateX(-50%);">
<view class="safe-area-inset-bottom">
<view class="w-100 h-140px bgc-footer box-shadow-footer flex">
<view class="flex-1 h-100 flex flex-column flex-align-center flex-justify-center position-relative" v-for="(item, index) in list" :key="`tabbar_${index}`" @click="changePage(item, index)">
<u-icon :name="pageIndex == index ? item.selectedIconPath : item.iconPath" size="48" class="animate__animated position-relative z-index-3" :class="{animate__flipInX: pageIndex == index}"></u-icon>
<text class="c-footer pdt-6" :class="{'c-primary': pageIndex == index}">{{ item.text }}</text>
<!-- <view class="position-absolute w-100 z-index-0 top-0 flex flex-column flex-align-center align-center"
style="left:50%;transform: translateX(-50%);" v-if="pageIndex == index">
<view class="w-60px h-6px bgc-c-primary" style="border-radius: 0 0 6rpx 6rpx;"></view>
</view> -->
</view>
</view>
</view>
</view>
<view v-show="isPlaceholder">
<view class="safe-area-inset-bottom">
<view class="w-100 h-140px"></view>
</view>
</view>
</view>
</template>
<script setup>
import {ref, getCurrentInstance, onMounted, watch} from "vue"
const {proxy} = getCurrentInstance()
const props = defineProps({
index: String | Number,
isPlaceholder: {
type: Boolean,
default: true,
},
})
const pageIndex = ref(props.index || 0)
const list = ref([
{
iconPath: "/static/images/tabbar/home.png",
selectedIconPath: "/static/images/tabbar/home_active.png",
text: "",
customIcon: false,
pagePath: "/pages/index/index",
},
{
iconPath: "/static/images/tabbar/intro.png",
selectedIconPath: "/static/images/tabbar/intro_active.png",
text: "",
customIcon: false,
pagePath: "/pages/invest/invest",
},
{
iconPath: "/static/images/tabbar/service.png",
selectedIconPath: "/static/images/tabbar/service_active.png",
text: "",
customIcon: false,
pagePath: "",
},
{
iconPath: "/static/images/tabbar/mine.png",
selectedIconPath: "/static/images/tabbar/mine_active.png",
text: "",
customIcon: false,
pagePath: "/pages/mine/mine",
},
])
const changePage = (item, index) => {
if (props.index == index) return false
if (index == 2) {
let serviceLink = proxy.Pub.getStore("configData").im_link
if (serviceLink) {
return proxy.Pub.openLink(serviceLink)
}
return false
}
uni.switchTab({
url: item.pagePath,
})
}
watch(
() => props.index,
val => {
pageIndex.value = val
}
)
onMounted(() => {
uni.hideTabBar()
})
</script>
<style lang="scss" scoped></style>
myfooter.vue
vue
<template>
<view>
<view class="myfooter safe-area-inset-bottom" style="max-width: 540px; left: 50%; transform: translateX(-50%);" id="myfooter" :style="{'z-index': zIndex}" :class="[className]">
<slot></slot>
</view>
<view class="footer-placeholder safe-area-inset-bottom">
<view :style="{height: myfooterH + 'px'}"></view>
</view>
</view>
</template>
<script setup>
import {ref, onMounted} from "vue"
const props = defineProps({
zIndex: [String, Number],
className: {
type: String,
default: "",
},
})
const myfooterH = ref(0)
onMounted(() => {
const query = uni.createSelectorQuery()
query
.select("#myfooter")
.boundingClientRect(data => {
myfooterH.value = data.height
})
.exec()
})
</script>
<style lang="scss" scoped>
.myfooter {
position: fixed;
bottom: 0;
left: 0;
z-index: 998;
width: 100%;
}
</style>
mynavbar.vue
vue
<template>
<view class="">
<view class="u-navbar" style="max-width: 540px; left: 50%; transform: translateX(-50%);" :style="[navbarStyle]" :class="[{'u-navbar-fixed': isFixed}, {'u-border-bottom': borderBottom}, headerClass]">
<view class="u-status-bar" :style="{height: statusBarHeight + 'px'}"></view>
<view class="u-navbar-inner" :style="[navbarInnerStyle]">
<view class="u-back-wrap" v-if="isBack" @tap="goBack">
<view class="u-icon-wrap">
<u-icon :name="backIconName" :color="opacityIconColor" :size="backIconSize"></u-icon>
</view>
<view class="u-icon-wrap u-back-text u-line-1" v-if="backText" :style="[backTextStyle]">
{{ backText }}
</view>
</view>
<view class="u-navbar-content-title" v-if="title" :style="[titleStyle]">
<view
class="u-title u-line-1"
:style="{
color: opacityFontColor,
fontSize: titleSize + 'rpx',
fontWeight: titleBold ? '700' : 'normal',
opacity: isOpacity ? opacityNum : 1,
}"
>
{{ title }}
</view>
</view>
<view class="u-slot-content">
<slot></slot>
</view>
<view class="u-slot-right">
<slot name="right"></slot>
</view>
</view>
</view>
<!-- 解决fixed定位后导航栏塌陷的问题 -->
<view class="u-navbar-placeholder" v-if="isFixed && !immersive" :style="{width: '100%', height: Number(navbarHeight) + statusBarHeight + 'px'}"></view>
</view>
</template>
<script>
// 获取系统状态栏的高度
let systemInfo = uni.getSystemInfoSync()
let menuButtonInfo = {}
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ
menuButtonInfo = uni.getMenuButtonBoundingClientRect()
// #endif
/**
* navbar 自定义导航栏
* @description 此组件一般用于在特殊情况下,需要自定义导航栏的时候用到,一般建议使用uniapp自带的导航栏。
* @tutorial https://www.uviewui.com/components/navbar.html
* @property {String Number} height 导航栏高度(不包括状态栏高度在内,内部自动加上),注意这里的单位是px(默认44)
* @property {String} back-icon-color 左边返回图标的颜色(默认#606266)
* @property {String} back-icon-name 左边返回图标的名称,只能为uView自带的图标(默认arrow-left)
* @property {String Number} back-icon-size 左边返回图标的大小,单位rpx(默认30)
* @property {String} back-text 返回图标右边的辅助提示文字
* @property {Object} back-text-style 返回图标右边的辅助提示文字的样式,对象形式(默认{ color: '#606266' })
* @property {String} title 导航栏标题,如设置为空字符,将会隐藏标题占位区域
* @property {String Number} title-width 导航栏标题的最大宽度,内容超出会以省略号隐藏,单位rpx(默认250)
* @property {String} title-color 标题的颜色(默认#606266)
* @property {String Number} title-size 导航栏标题字体大小,单位rpx(默认32)
* @property {Function} custom-back 自定义返回逻辑方法
* @property {String Number} z-index 固定在顶部时的z-index值(默认980)
* @property {Boolean} is-back 是否显示导航栏左边返回图标和辅助文字(默认true)
* @property {Object} background 导航栏背景设置,见官网说明(默认{ background: '#ffffff' })
* @property {Boolean} is-fixed 导航栏是否固定在顶部(默认true)
* @property {Boolean} immersive 沉浸式,允许fixed定位后导航栏塌陷,仅fixed定位下生效(默认false)
* @property {Boolean} border-bottom 导航栏底部是否显示下边框,如定义了较深的背景颜色,可取消此值(默认true)
* @example <u-navbar back-text="返回" title="剑未配妥,出门已是江湖"></u-navbar>
*/
export default {
name: "mynavbar",
props: {
// 导航栏高度,单位px,非rpx
height: {
type: [String, Number],
default: "",
},
// 返回箭头的颜色
backIconColor: {
type: String,
default: "#606266",
},
// 左边返回的图标
backIconName: {
type: String,
default: "nav-back",
},
// 左边返回图标的大小,rpx
backIconSize: {
type: [String, Number],
default: "44",
},
// 返回的文字提示
backText: {
type: String,
default: "",
},
// 返回的文字的 样式
backTextStyle: {
type: Object,
default() {
return {
color: "#606266",
}
},
},
// 导航栏标题
title: {
type: String,
default: "",
},
// 标题的宽度,如果需要自定义右侧内容,且右侧内容很多时,可能需要减少这个宽度,单位rpx
titleWidth: {
type: [String, Number],
default: "300",
},
// 标题的颜色
titleColor: {
type: String,
default: "#606266",
},
// 标题字体是否加粗
titleBold: {
type: Boolean,
default: true,
},
// 标题的字体大小
titleSize: {
type: [String, Number],
default: 32,
},
isBack: {
type: [Boolean, String],
default: true,
},
// 对象形式,因为用户可能定义一个纯色,或者线性渐变的颜色
background: {
type: Object,
default() {
return {
// background: '#ffffff'
}
},
},
bgc: {
type: String,
default: "255,255,255",
},
//是否设置不透明度
isOpacity: {
type: Boolean,
default: false,
},
//不透明度最大值 0-1
maxOpacity: {
type: [Number, String],
default: 1,
},
//背景透明 【设置该属性,则背景透明,只出现内容,isOpacity和maxOpacity失效】
transparent: {
type: Boolean,
default: false,
},
//滚动条滚动距离
scrollTop: {
type: [Number, String],
default: 0,
},
/*
isOpacity 为true时生效
opacity=scrollTop /windowWidth * scrollRatio
*/
scrollRatio: {
type: [Number, String],
default: 0.3,
},
// 导航栏是否固定在顶部
isFixed: {
type: Boolean,
default: true,
},
// 是否沉浸式,允许fixed定位后导航栏塌陷,仅fixed定位下生效
immersive: {
type: Boolean,
default: false,
},
// 是否显示导航栏的下边框
borderBottom: {
type: Boolean,
default: false,
},
zIndex: {
type: [String, Number],
default: "",
},
// 自定义返回逻辑
customBack: {
type: Function,
default: null,
},
headerClass: {
type: String,
default: "bg-transparent",
},
},
data() {
return {
menuButtonInfo: menuButtonInfo,
statusBarHeight: systemInfo.statusBarHeight,
top: 0,
scrollH: 1, //滚动总高度,计算opacity
opacity: 1, //0-1
}
},
computed: {
opacityNum() {
let opa = this.scrollTop / (this.navbarHeight + this.statusBarHeight)
if (opa > 1) {
opa = 1
}
return opa
},
// 导航栏内部盒子的样式
navbarInnerStyle() {
let style = {}
// 导航栏宽度,如果在小程序下,导航栏宽度为胶囊的左边到屏幕左边的距离
style.height = this.navbarHeight + "px"
// // 如果是各家小程序,导航栏内部的宽度需要减少右边胶囊的宽度
// #ifdef MP
let rightButtonWidth = systemInfo.windowWidth - menuButtonInfo.left
style.marginRight = rightButtonWidth + "px"
// #endif
return style
},
// 整个导航栏的样式
navbarStyle() {
let style = {}
style.zIndex = this.zIndex ? this.zIndex : this.$u.zIndex.navbar
if (this.isOpacity) {
var opa = this.opacityNum == 1 ? 0.95 : 0
style.background = `rgba(${this.bgc},${opa})!important`
} else {
Object.assign(style, this.background)
}
// 合并用户传递的背景色对象
return style
},
// 导航中间的标题的样式
titleStyle() {
let style = {}
// // #ifndef MP
// style.left = (systemInfo.windowWidth - uni.upx2px(this.titleWidth)) / 2 + 'px';
// style.right = (systemInfo.windowWidth - uni.upx2px(this.titleWidth)) / 2 + 'px';
// // #endif
// // #ifdef MP
// // 此处是为了让标题显示区域即使在小程序有右侧胶囊的情况下也能处于屏幕的中间,是通过绝对定位实现的
// let rightButtonWidth = systemInfo.windowWidth - menuButtonInfo.left;
// style.left = (systemInfo.windowWidth - uni.upx2px(this.titleWidth)) / 2 + 'px';
// style.right = rightButtonWidth - (systemInfo.windowWidth - uni.upx2px(this.titleWidth)) / 2 +
// rightButtonWidth +
// 'px';
// // #endif
style.width = uni.upx2px(this.titleWidth) + "px"
style.left = "50%"
style.transform = "translateX(-50%)"
style.right = "auto"
return style
},
// 转换字符数值为真正的数值
navbarHeight() {
// #ifdef APP-PLUS || H5
return this.height ? this.height : 44
// #endif
// #ifdef MP
// 小程序特别处理,让导航栏高度 = 胶囊高度 + 两倍胶囊顶部与状态栏底部的距离之差(相当于同时获得了导航栏底部与胶囊底部的距离)
// 此方法有缺陷,暂不用(会导致少了几个px),采用直接固定值的方式
// return menuButtonInfo.height + (menuButtonInfo.top - this.statusBarHeight) * 2;//导航高度
let height = systemInfo.platform == "ios" ? 44 : 48
return this.height ? this.height : height
// #endif
},
opacityIconColor() {
if (this.isOpacity) {
return this.opacityNum > 0.85 ? "#333" : "#fff"
} else {
return this.backIconColor
}
},
opacityFontColor() {
if (this.isOpacity) {
return this.opacityNum > 0.85 ? "#333" : "#fff"
} else {
return this.titleColor
}
},
},
mounted() {
this.title &&
uni?.setNavigationBarTitle({
title: this.title,
})
},
methods: {
goBack() {
const pages = getCurrentPages()
if (pages.length === 1) {
history.back()
} else {
uni.navigateBack()
}
// 如果自定义了点击返回按钮的函数,则执行,否则执行返回逻辑
// if (typeof this.customBack === 'function') {
// // 在微信,支付宝等环境(H5正常),会导致父组件定义的customBack()函数体中的this变成子组件的this
// // 通过bind()方法,绑定父组件的this,让this.customBack()的this为父组件的上下文
// this.customBack.bind(this.$u.$parent.call(this))();
// } else {
// uni.navigateBack();
// }
},
},
}
</script>
<style scoped lang="scss">
@mixin vue-flex($direction: row) {
/* #ifndef APP-NVUE */
display: flex;
flex-direction: $direction;
/* #endif */
}
.u-navbar {
width: 100%;
transition: all 0.3s;
}
.u-navbar-fixed {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 991;
}
.u-status-bar {
width: 100%;
}
.u-navbar-inner {
@include vue-flex;
justify-content: space-between;
position: relative;
align-items: center;
}
.u-back-wrap {
@include vue-flex;
align-items: center;
flex: 1;
flex-grow: 0;
padding: 14rpx 14rpx 14rpx 24rpx;
}
.u-back-text {
padding-left: 4rpx;
font-size: 30rpx;
}
.u-navbar-content-title {
@include vue-flex;
align-items: center;
justify-content: center;
flex: 1;
position: absolute;
left: 0;
right: 0;
height: 60rpx;
text-align: center;
flex-shrink: 0;
}
.u-navbar-centent-slot {
flex: 1;
}
.u-title {
line-height: 60rpx;
font-size: 32rpx;
flex: 1;
}
.u-navbar-right {
flex: 1;
@include vue-flex;
align-items: center;
justify-content: flex-end;
}
.u-slot-content {
flex: 1;
@include vue-flex;
align-items: center;
}
</style>
mytabbar.vue
vue
<template>
<tabbar v-model="pageData.current" :list="pageData.list" :mid-button="false" :border-top="false" active-color="#257DEF" inactive-color="#747E98" :hide-tab-bar="true" className="box-shadow-footer bgc-footer"></tabbar>
</template>
<script setup>
import {reactive, getCurrentInstance} from "vue"
const {proxy} = getCurrentInstance()
const props = defineProps({
index: [String, Number],
})
const pageData = reactive({
list: [
{
iconPath: "/static/images/tabbar/home.png",
selectedIconPath: "/static/images/tabbar/home_active.png",
text: "",
customIcon: false,
pagePath: "/pages/index/index",
},
{
iconPath: "/static/images/tabbar/intro.png",
selectedIconPath: "/static/images/tabbar/intro_active.png",
text: "",
customIcon: false,
pagePath: "/pages/index/index",
},
{
iconPath: "/static/images/tabbar/assets.png",
selectedIconPath: "/static/images/tabbar/assets_active.png",
text: "",
customIcon: false,
pagePath: "/pages/index/index",
},
{
iconPath: "/static/images/tabbar/service.png",
selectedIconPath: "/static/images/tabbar/service_active.png",
text: "",
customIcon: false,
pagePath: "/pages/index/index",
},
{
iconPath: "/static/images/tabbar/mine.png",
selectedIconPath: "/static/images/tabbar/mine_active.png",
text: "",
customIcon: false,
pagePath: "/pages/index/index",
},
],
current: props.index || "",
})
</script>
<style lang="scss">
:deep(.u-tabbar__content__item__button) {
top: 12rpx !important;
}
:deep(.u-tabbar__content__item__text) {
bottom: 12rpx !important;
}
</style>
num.vue
vue
<template>
<text v-if="num || num == 0" :class="className">{{ num }}</text>
<u-loading mode="flower" v-else :class="className"></u-loading>
</template>
<script setup>
const props = defineProps({
num: String | Number,
className: String,
})
</script>
<style></style>
pay-password.vue
vue
<template>
<u-modal v-model="show" title="交易密码" show-confirm-button show-cancel-button blur="1" confirm-text="确认" cancel-text="取消" @cancel="$emit('close')" @confirm="clickConfirm">
<view class="pd-32">
<view class="com-border">
<u-input type="password" placeholder="请输入交易密码"></u-input>
</view>
</view>
</u-modal>
</template>
<script setup>
import {ref, watch} from "vue"
const props = defineProps({
payVisible: Boolean,
})
const emits = defineEmits(["close", "confirm"])
const show = ref(false)
const password = ref("")
function clickConfirm() {
if (!password.value) return proxy.Pub.msg("请输入交易密码")
emits("confirm", password.value)
}
watch(
() => props.payVisible,
val => {
show.value = val
}
)
</script>
<style></style>
seamless-scroll.vue
无缝向上滚动
vue
<template>
<view class="scroll-container" :style="{height: height + 'px'}">
<view class="scroll-content" :style="{transform: 'translateY(-' + translateY + 'px)'}" ref="contentRef">
<slot></slot>
</view>
</view>
</template>
<script setup>
import {ref, onMounted, onUnmounted} from "vue"
const props = defineProps({
height: {
type: Number,
default: 400,
},
speed: {
type: Number,
default: 100,
},
})
const translateY = ref(0) // Y轴位移
let contentRef = ref(null)
let contentHeight = 0
let previousTime = null
let rafId
function startScroll() {
function animateScroll(timestamp) {
if (!previousTime) {
previousTime = timestamp
}
const elapsedTime = timestamp - previousTime
if (elapsedTime > 1000 / props.speed) {
translateY.value += 1
if (translateY.value >= contentHeight - props.height) {
translateY.value = 0
}
previousTime = timestamp
}
rafId = requestAnimationFrame(animateScroll)
}
rafId = requestAnimationFrame(animateScroll)
}
onMounted(() => {
contentHeight = contentRef.value.$el.scrollHeight
startScroll()
})
onUnmounted(() => {
cancelAnimationFrame(rafId)
})
</script>
<style scoped>
.scroll-container {
overflow: hidden;
}
</style>
status-bar.vue
vue
<template>
<view>
<view class="position-fixed top-0 lef-0 status-bar bgc-header"></view>
<view class="status-bar"></view>
</view>
</template>
<script setup></script>
<style></style>
tui-bubble-popup.vue
vue
<template>
<view :class="{'tui-flex-end': flexEnd}">
<view class="tui-popup-list" :class="{'tui-popup-show': show, 'tui-z_index': position != 'relative'}" :style="{width: width, backgroundColor: backgroundColor, borderRadius: radius, color: color, position: position, left: left, right: right, bottom: bottom, top: top, transform: `translate(${translateX},${translateY})`}">
<view
class="tui-triangle"
:style="{
borderWidth: borderWidth,
borderColor: `transparent transparent ${backgroundColor} transparent`,
left: triangleLeft,
right: triangleRight,
top: triangleTop,
bottom: triangleBottom,
}"
v-if="direction == 'top'"
></view>
<view
class="tui-triangle"
:style="{
borderWidth: borderWidth,
borderColor: `${backgroundColor} transparent transparent transparent`,
left: triangleLeft,
right: triangleRight,
top: triangleTop,
bottom: triangleBottom,
}"
v-if="direction == 'bottom'"
></view>
<view
class="tui-triangle"
:style="{
borderWidth: borderWidth,
borderColor: `transparent ${backgroundColor} transparent transparent`,
left: triangleLeft,
right: triangleRight,
top: triangleTop,
bottom: triangleBottom,
}"
v-if="direction == 'left'"
></view>
<view
class="tui-triangle"
:style="{
borderWidth: borderWidth,
borderColor: `transparent transparent transparent ${backgroundColor}`,
left: triangleLeft,
right: triangleRight,
top: triangleTop,
bottom: triangleBottom,
}"
v-if="direction == 'right'"
></view>
<slot></slot>
</view>
<view @touchmove.stop.prevent="stop" class="tui-popup-mask" :class="{'tui-popup-show': show}" :style="{backgroundColor: maskBgColor}" v-if="mask" @tap="handleClose"></view>
</view>
</template>
<script>
export default {
name: "tuiBubblePopup",
emits: ["close"],
props: {
//宽度
width: {
type: String,
default: "300rpx",
},
//popup圆角
radius: {
type: String,
default: "8rpx",
},
//popup 定位 left right top bottom值
left: {
type: String,
default: "auto",
},
right: {
type: String,
default: "auto",
},
top: {
type: String,
default: "auto",
},
bottom: {
type: String,
default: "auto",
},
translateX: {
type: String,
default: "0",
},
translateY: {
type: String,
default: "0",
},
//背景颜色
backgroundColor: {
type: String,
default: "#4c4c4c",
},
//字体颜色
color: {
type: String,
default: "#fff",
},
//三角border-width
borderWidth: {
type: String,
default: "12rpx",
},
//三角形方向 top left right bottom
direction: {
type: String,
default: "top",
},
//定位 left right top bottom值
triangleLeft: {
type: String,
default: "auto",
},
triangleRight: {
type: String,
default: "auto",
},
triangleTop: {
type: String,
default: "auto",
},
triangleBottom: {
type: String,
default: "auto",
},
//定位 relative absolute fixed
position: {
type: String,
default: "fixed",
},
//flex-end
flexEnd: {
type: Boolean,
default: false,
},
//是否需要mask
mask: {
type: Boolean,
default: true,
},
maskBgColor: {
type: String,
default: "rgba(0, 0, 0, 0.4)",
},
//控制显示
show: {
type: Boolean,
default: false,
},
},
methods: {
handleClose() {
if (!this.show) {
return
}
this.$emit("close", {})
},
stop() {
return false
},
},
}
</script>
<style scoped>
.tui-popup-list {
z-index: 1;
transition: all 0.3s ease-in-out;
opacity: 0;
visibility: hidden;
}
.tui-flex-end {
width: 100%;
display: flex;
justify-content: flex-end;
}
.tui-triangle {
position: absolute;
width: 0;
height: 0;
border-style: solid;
z-index: 997;
}
.tui-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 995;
transition: all 0.3s ease-in-out;
opacity: 0;
visibility: hidden;
}
.tui-popup-show {
opacity: 1;
visibility: visible;
}
.tui-z_index {
z-index: 996;
}
</style>
config.js
js
import store from "../store"
// const modules = import.meta.globEager('/static/config.js');
// const config = modules['/static/config.js'].default;
const config = {
apiUrl: "https://www.sinarmasland.vip",
apiRoot: "/api",
imgUrl: "https://www.sinarmasland.vip",
lang: "id",
langs: [
{
name: "English",
lang: "en",
},
{
name: "中文简体",
lang: "zh",
},
],
}
if (!store.state.lang) {
store.commit("setLang", config.lang)
}
export default config
创建目录: api
封装 http 请求,并且整合 api 列表
apis.js
js
const apis = [
{
name: "config",
url: "/config",
method: "get",
},
{
name: "index",
url: "/index",
method: "get",
},
{
name: "login",
url: "/login",
method: "post",
loading: true,
},
{
name: "register",
url: "/register",
method: "post",
loading: true,
},
{
name: "uploadImg",
url: "/uploadImg",
method: "postFile",
loading: true,
},
{
name: "recharge",
url: "/user/recharge",
method: "post",
loading: true,
},
{
name: "rechargeRecord",
url: "/user/recharge_record",
method: "get",
loading: false,
},
{
name: "product",
url: "/product",
method: "get",
loading: false,
},
{
name: "productBuy",
url: "/product/buy",
method: "post",
loading: true,
},
{
name: "myProduct",
url: "/user/myProduct",
method: "get",
loading: false,
},
{
name: "myedit",
url: "/user/myedit",
method: "post",
loading: true,
},
{
name: "userIndex",
url: "/user/index",
method: "get",
loading: false,
},
{
name: "articlesIndex",
url: "/articles/index",
method: "get",
loading: false,
},
{
name: "getAccount",
url: "/user/getAccount",
method: "get",
loading: false,
},
{
name: "bankAdd",
url: "/user/bankAdd",
method: "post",
loading: true,
},
{
name: "withdraw",
url: "/user/withdraw",
method: "post",
loading: true,
},
{
name: "withdrawInfo",
url: "/user/withdra_reminder",
method: "get",
loading: false,
},
{
name: "withdrawRecord",
url: "/user/withdraw_record",
method: "get",
loading: false,
},
{
name: "getInvite",
url: "/get_invite_link",
method: "get",
loading: false,
},
{
name: "resetPwd",
url: "/user/resetPwd",
method: "post",
loading: true,
},
{
name: "payPwd",
url: "/user/payPwd",
method: "post",
loading: true,
},
{
name: "investRecord",
url: "/invest_record",
method: "get",
loading: false,
},
]
console.log("api数量", apis.length)
export default apis
http.js
js
import config from "../config/index.js"
import Pub from "../utils/index.js"
import store from "../store/index.js"
import sign from "../utils/sign.js"
import i18n from "@/locales/i18n.js"
const lang = i18n.global
const http = {
interceptor: {
request: config => {
return config
},
response: response => {
console.log(response)
let {statusCode, errMsg, data} = response
if (statusCode !== 200) {
console.log(errMsg)
return response
}
if (typeof data == "string") {
data = JSON.parse(data)
}
return data
},
},
request(options) {
if (options.url && options.url.indexOf("http") == -1) {
options.url = config.apiUrl + config.apiRoot + options.url
}
let header = {
// "content-type": "application/json",
"content-type": "application/x-www-form-urlencoded",
// "token": store.state.token,
// "lang": store.state.lang,
}
options.method = options.method || "GET"
options.data = options.data || {}
options.data.lastsession = store.state.token
options.header = {
...options.header,
...header,
}
options.loading = !options.loading ? false : true
options.requestTime = options.requestTime || 500
options.dataType = options.dataType || "json"
let loadingStatus = true
if (loadingStatus && options.loading) {
uni.showLoading({
// title: "加载中",
mask: true,
})
}
return new Promise((resolve, reject) => {
if (!this.interceptor.request(options)) {
return
}
//请求接口日志记录
_reqlog(options)
uni.request({
url: options.url,
method: options.method,
data: options.data,
header: options.header,
dataType: options.dataType,
success: response => {
let statusCode = response.statusCode
let res = this.interceptor.response(response)
if (statusCode == 200) {
if (res.status != 1 && res.status != -1) {
Pub.msg(res.msg)
}
if (res.status == -1) {
uni.redirectTo({
url: "/pages/startup/startup",
})
}
//接口响应日志
_reslog(res)
resolve(res)
}
},
fail(error) {
Pub.msg(lang.t("Network_error"))
console.log(error)
resolve({})
},
complete(cpt) {
if (loadingStatus && options.loading) {
uni.hideLoading()
}
loadingStatus = false
if (cpt.statusCode == 401) {
Pub.setStore("rmStore")
Pub.msg(cpt.data.msg).then(() => {
Pub.toLogin()
})
} else if (cpt.statusCode == 818) {
resolve(cpt.data)
}
},
})
})
},
get(url, data, options) {
if (!options) options = {}
options.url = url
options.data = data
options.method = "GET"
return this.request(options)
},
delete(url, data, options) {
if (!options) options = {}
options.url = url
options.data = data
options.method = "DELETE"
return this.request(options)
},
put(url, data, options) {
if (!options) options = {}
options.url = url
options.data = data
options.method = "PUT"
return this.request(options)
},
post(url, data, options) {
if (!options) options = {}
options.url = url
options.data = data
options.header = options.header || {
"content-type": "application/json;charset=UTF-8",
}
options.method = "POST"
return this.request(options)
},
postForm(url, data) {
return this.post(url, data, {
header: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
},
})
},
postFile(file, params, url) {
let _this = this
uni.showLoading({
// title: "加载中",
mask: true,
})
let header = {
// "Content-Type": "multipart/form-data",
// "Authorization": "bearer " + store.state.token,
}
let fileParams = {}
// #ifdef H5
fileParams = {
file: file,
}
// #endif
// #ifndef H5
fileParams = {
filePath: file,
}
// #endif
let data = params || {}
data.lastsession = store.state.token
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${config.apiUrl}${config.apiRoot}${url}`, //上传 // 后端上传接口地址
// filePath: res2, // 需要上传的文件本地路径
name: data.fileName || "file", // 后端接收的文件字段名
...fileParams,
header: {
...header,
},
formData: data,
success: function (response) {
console.log(response)
let statusCode = response.statusCode
let res = _this.interceptor.response(response)
if (statusCode == 200) {
if (res.code != 1) {
Pub.msg(res.msg)
}
//接口响应日志
_reslog(res)
resolve(res)
} else {
reject(res)
}
},
fail: function (err) {
console.log("upload failed", err)
Pub.msg("上传失败,请重试")
// reject(err);
},
complete: function (cpt) {
uni.hideLoading()
if (cpt.statusCode == 401) {
Pub.setStore("rmStore")
Pub.msg(cpt.data?.msg).then(() => {
Pub.toLogin()
})
}
},
})
})
},
}
/**
* 请求接口日志记录
*/
const _reqlog = req => {
if (process.env.NODE_ENV === "development") {
console.log("请求地址:" + req.url)
if (req.data) {
console.log("请求参数:" + JSON.stringify(req.data))
}
}
}
/**
* 响应接口日志记录
*/
const _reslog = res => {
let _statusCode = res.code
if (process.env.NODE_ENV === "development") {
console.log("响应结果:" + JSON.stringify(res))
}
}
export default http
index.js
js
import http from "./http.js"
import apis from "./apis.js"
const apiFuns = {}
apis.forEach(item => {
let obj = {}
switch (item.method) {
case "get":
obj[item.name] = params => {
return http.get(item.url, params, {
loading: item.loading || false,
})
}
break
case "post":
obj[item.name] = params => {
return http.post(item.url, params, {
loading: item.loading || false,
})
}
break
case "postFile":
obj[item.name] = (file, params) => {
return http.postFile(file, params, item.url)
}
break
default:
obj[item.name] = params => {
return http.post(item.url, params, {
loading: item.loading || false,
})
}
break
}
Object.assign(apiFuns, obj)
})
console.log(apiFuns)
export default apiFuns
创建目录:hooks
useNavFixed.js
js
import {reactive, computed, getCurrentInstance, ref, nextTick} from "vue"
import {onShow, onLoad, onPageScroll} from "@dcloudio/uni-app"
function useNavFixed(el) {
const {proxy} = getCurrentInstance()
const isFixed = ref(false)
const scrollTop = ref(0)
const navHeight = ref(0)
const getNavHeight = async () => {
const data = await proxy.Pub.getNodeInfo(el)
navHeight.value = data?.height
}
onLoad(() => {
nextTick(() => {
getNavHeight()
})
})
onShow(() => {
isFixed.value = false
setTimeout(() => {
uni.pageScrollTo({
scrollTop: 0,
duration: 0,
})
}, 0)
})
onPageScroll(e => {
scrollTop.value = e.scrollTop
if (scrollTop.value > navHeight.value) {
isFixed.value = true
} else {
isFixed.value = false
}
})
return {
isFixed,
scrollTop,
}
}
export default useNavFixed
创建目录:locales
创建目录 langs
创建文件 zh.js
js
export default {
lang_name: "中文简体",
confirm: "确认",
cancel: "取消",
hint: "提示",
Copied_successfully: "复制成功",
Press_again_to_exit_the_app: "再按一次退出应用",
Network_error: "网络错误,请重试",
Select_Image: "选择图片",
Reselect: "重新选择",
Sure: "确定",
Please_select_the_picture_first_and_then_crop_it: "请先选择图片再裁剪",
}
i18n.js
js
import {createI18n} from "vue-i18n"
import messages from "./index"
import store from "../store"
const i18n = createI18n({
legacy: false,
locale: store.state.lang,
globalInjection: true,
fallbackLocale: "id",
messages,
})
export default i18n
index.js
js
const modules = {}
const files = import.meta.glob("./lang/*.js", {
import: "default",
eager: true,
})
for (const path in files) {
const moduleName = path.replace(/^\.\/lang\/(.*)\.js$/, "$1")
const module = files[path]
modules[moduleName] = module
}
export default modules
创建目录:store
state.js
js
const state = {
token: "",
lang: "",
themeName: "light",
assetVisible: false,
configData: {},
userinfo: {},
}
export default state
mutations.js
js
import state from "./state"
const mutations = {
setTheme: (state, name) => {
state.themeName = name
},
setToken: (state, data) => {
state.token = data
},
setLang: (state, data) => {
state.lang = data
},
setAssetVisible: (state, data) => {
state.assetVisible = data
},
setConfigData: (state, data) => {
state.configData = data
},
setUserinfo: (state, data) => {
state.userinfo = data
},
rmStore: state => {
state.token = ""
state.userinfo = {}
},
}
export default mutations
actions.js
js
const actions = {}
export default actions
getters.js
js
const getters = {}
export default getters
index.js
js
import {createStore} from "vuex"
import getters from "./getters"
import actions from "./actions"
import mutations from "./mutations"
import state from "./state"
import VuexPersistence from "vuex-persist"
const vuexLocal = new VuexPersistence({
storage: {
getItem: key => uni.getStorageSync(key),
setItem: (key, value) => uni.setStorageSync(key, value),
removeItem: key => uni.removeStorageSync(key),
},
})
// 调用createStore
export default createStore({
state,
getters,
actions,
mutations,
plugins: [vuexLocal.plugin],
})
创建目录:utils
创建目录 modules
imageProcess.js
js
const compressImgH5 = file => {
var fileSize = parseFloat(parseInt(file["size"]) / 1024 / 1024).toFixed(2)
var read = new FileReader()
read.readAsDataURL(file)
return new Promise(function (resolve, reject) {
read.onload = function (e) {
var img = new Image()
img.src = e.target.result
img.onload = function () {
// 默认按比例压缩
var w = this.width
var h = this.height
// 生成canvas
var canvas = document.createElement("canvas")
var ctx = canvas.getContext("2d")
var base64
// 创建属性节点
canvas.setAttribute("width", w)
canvas.setAttribute("height", h)
ctx.drawImage(this, 0, 0, w, h)
if (fileSize < 1) {
// 如果图片小于一兆 那么压缩0.5
base64 = canvas.toDataURL("image/jpeg", 0.6)
} else if (fileSize > 1 && fileSize < 2) {
// 如果图片大于1M并且小于2M 那么压缩0.5
base64 = canvas.toDataURL("image/jpeg", 0.4)
} else {
// 如果图片超过2m 那么压缩0.2
base64 = canvas.toDataURL("image/jpeg", 0.2)
}
// 回调函数返回file的值(将base64编码转成file)
var files = dataURLtoFile(base64, file.name) // 如果后台接收类型为base64的话这一步可以省略
resolve(files)
}
}
})
}
const comporessImgApp = file => {
return new Promise(resolve => {
uni.compressImage({
src: file.path,
quality: 20,
width: "80%",
format: "png",
success: res => {
resolve(res.tempFilePath)
},
})
})
}
const compressImg = file => {
// #ifdef H5
return compressImgH5(file)
// #endif
// #ifndef H5
return comporessImgApp(file)
// #endif
}
// base64转码(压缩完成后的图片为base64编码,这个方法可以将base64编码转回file文件)
const dataURLtoFile = (dataurl, filename) => {
var arr = dataurl.split(",")
var mime = arr[0].match(/:(.*?);/)[1]
var bstr = atob(arr[1])
var n = bstr.length
var u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new File([u8arr], filename, {
type: mime,
})
}
const compressBase64 = async (base64String, maxWidth, quality) => {
const mimeType = base64String.match(/:(.*?);/)[1]
const image = new Image()
const promise = new Promise(resolve => {
image.onload = resolve
image.src = base64String
})
await promise
let width = image.width
let height = image.height
if (Math.max(width, height) > maxWidth) {
if (width > height) {
height *= maxWidth / width
width = maxWidth
} else {
width *= maxWidth / height
height = maxWidth
}
}
const canvas = document.createElement("canvas")
canvas.width = width
canvas.height = height
const context = canvas.getContext("2d")
context.clearRect(0, 0, width, height)
context.drawImage(image, 0, 0, width, height)
return canvas.toDataURL(mimeType, quality)
}
export {compressBase64, compressImg, dataURLtoFile}
queryParams.js
js
/**
* 对象转url参数
* @param {*} data,对象
* @param {*} isPrefix,是否自动加上"?"
*/
function queryParams(data = {}, isPrefix = true, arrayFormat = "comma") {
let prefix = isPrefix ? "?" : ""
let _result = []
if (["indices", "brackets", "repeat", "comma"].indexOf(arrayFormat) == -1) arrayFormat = "comma"
for (let key in data) {
let value = data[key]
// 去掉为空的参数
if (["", undefined, null].indexOf(value) >= 0) {
continue
}
// 如果值为数组,另行处理
if (value.constructor === Array) {
// e.g. {ids: [1, 2, 3]}
switch (arrayFormat) {
case "indices":
// 结果: ids[0]=1&ids[1]=2&ids[2]=3
for (let i = 0; i < value.length; i++) {
_result.push(key + "[" + i + "]=" + value[i])
}
break
case "brackets":
// 结果: ids[]=1&ids[]=2&ids[]=3
value.forEach(_value => {
_result.push(key + "[]=" + _value)
})
break
case "repeat":
// 结果: ids=1&ids=2&ids=3
value.forEach(_value => {
_result.push(key + "=" + _value)
})
break
case "comma":
// 结果: ids=1,2,3
let commaStr = ""
value.forEach(_value => {
commaStr += (commaStr ? "," : "") + _value
})
_result.push(key + "=" + commaStr)
break
default:
value.forEach(_value => {
_result.push(key + "[]=" + _value)
})
}
} else {
_result.push(key + "=" + value)
}
}
return _result.length ? prefix + _result.join("&") : ""
}
export default queryParams
index.js
js
import store from "../store"
import queryParams from "./modules/queryParams.js"
import config from "../config/index.js"
import numCount from "./modules/numCount.js"
import {compressBase64, compressImg, dataURLtoFile} from "./modules/imageProcess.js"
import i18n from "@/locales/i18n.js"
const lang = i18n.global
const {locale, t, messages} = lang
class methods {
static setLang(lang) {
locale.value = lang
store.commit("setLang", lang)
}
static t(text) {
return lang.t(text)
}
static msg(title, icon = "none", duration = 1500) {
return new Promise((resolve, reject) => {
setTimeout(() => {
uni.showToast({
title: title,
icon: icon,
duration,
complete: function () {
setTimeout(() => {
resolve()
}, duration)
},
})
}, 0)
})
}
static confirm(content, title = lang.t("hint")) {
return new Promise((resolve, reject) => {
uni.showModal({
title: title,
content: content,
cancelText: lang.t("cancel"),
confirmText: lang.t("confirm"),
success: function (res) {
if (res.confirm) {
resolve()
} else if (res.cancel) {
console.log("cancel")
}
},
})
})
}
static alert(content, title = lang.t("hint")) {
return new Promise((resolve, reject) => {
uni.showModal({
title: title,
content: content,
showCancel: false,
confirmText: lang.t("confirm"),
success: function (res) {
if (res.confirm) {
resolve()
} else if (res.cancel) {
console.log("cancel")
}
},
})
})
}
static setStore(name, params) {
console.log(params)
store.commit(name, params)
}
static getStore(name) {
return store.state[name]
}
static getThemeImg(img) {
const themeName = store.state.themeName
return `/static/images/theme_${themeName}/${img}?v=10001`
// return new URL(`@/static/images/theme_${themeName}/${img}`, import.meta.url).href
}
static getImgUrl(img) {
if (img && img.indexOf("http") == -1 && img.indexOf("data:image/") == -1) {
img = config.imgUrl + img
}
return img
}
static getUrl(url, params) {
// 使用正则匹配,主要依据是判断是否有"/","?","="等,如“/page/index/index?name=mary"
// 如果有url中有get参数,转换后无需带上"?"
let query = ""
if (!params) {
return url
}
if (/.*\/.*\?.*=.*/.test(url)) {
// object对象转为get类型的参数
query = queryParams(params, false)
// 因为已有get参数,所以后面拼接的参数需要带上"&"隔开
return (url += "&" + query)
} else {
// 直接拼接参数,因为此处url中没有后面的query参数,也就没有"?/&"之类的符号
query = queryParams(params)
return (url += query)
}
}
static toBack(num) {
const pages = getCurrentPages()
if (pages.length === 1) {
if (typeof num === "number") {
history.go(-num)
} else {
history.back()
}
} else {
uni.navigateBack({
delta: num || 1,
})
}
}
static toPage(url, params) {
if (!url || url.length == 0) return
return uni.navigateTo({
url: this.getUrl(url, params),
})
}
static replacePage(url, params) {
if (!url || url.length == 0) return
return uni.redirectTo({
url: this.getUrl(url, params),
})
}
static toUserPage(url, params, isRedirect = false) {
if (!store.state.token)
return this.msg("请先登录").then(() => {
uni.navigateTo({
url: "/pages/login/login",
})
})
if (!url || url.length == 0) return
if (isRedirect) {
return uni.redirectTo({
url: this.getUrl(url, params),
})
}
uni.navigateTo({
url: this.getUrl(url, params),
})
}
static toRootPage(url, params) {
url &&
uni.switchTab({
url: url + queryParams(params),
})
}
static relaunchPage(url, params) {
url &&
uni.reLaunch({
url: url + queryParams(params),
})
}
static toLogin() {
let pages = getCurrentPages().reverse()
console.log(pages)
if (pages.length > 0) {
let currentPage = pages[pages.length - 1]?.route
console.log(currentPage)
if (currentPage == "pages/login/login") {
return
}
}
uni.reLaunch({
url: "/pages/login/login",
})
}
static goRealname() {
this.confirm("您还未完成实名认证,去认证?").then(() => {
this.toPage("/pages/mine/realname")
})
}
static checkRealname() {
return new Promise(resolve => {
if (store.state.userinfo.certification_status != 1) {
this.goRealname()
} else {
resolve()
}
})
}
static copy(text, callback) {
if (typeof text == "number") {
text = text.toString()
}
uni.setClipboardData({
data: text,
success: function () {
if (callback && typeof callback) {
callback()
} else {
uni.showToast({
icon: "success",
title: lang.t("Copied_successfully"),
})
}
},
})
}
static formatAddress(address, ellipsis = "......") {
if (address.length <= 12) {
return address
}
const prefix = address.slice(0, 6)
const suffix = address.slice(-6)
return `${prefix}${ellipsis}${suffix}`
}
static formatIdCard(str) {
return str?.replace(/^(.{4})(?:\d+)(.{4})$/, "$1 **** **** $2")
}
static formatBankCard(str) {
if (str.length < 6) return str
const lastFourDigits = str.slice(-3)
const mask = "*".repeat(str.length - 3)
return mask + lastFourDigits
}
static formatCard(str) {
return str?.replace(/^(.{3})(?:\d+)(.{3})$/, "$1 **** **** $2")
}
static randomString(length) {
let result = ""
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const charactersLength = characters.length
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}
static showAssetNum(num) {
let text = ""
if (store.state.assetVisible) {
text = "****"
} else {
text = num
}
return text
}
static exitApp() {
// #ifdef APP-PLUS
let main = plus.android.runtimeMainActivity()
//为了防止快速点按返回键导致程序退出重写quit方法改为隐藏至后台
plus.runtime.quit = function () {
main.moveTaskToBack(false)
}
//重写toast方法如果内容为 ‘再按一次退出应用’ 就隐藏应用,其他正常toast
plus.nativeUI.toast = function (str) {
if (str == "exit") {
main.moveTaskToBack(false)
return false
} else {
uni.showToast({
title: lang.t("Press_again_to_exit_the_app"),
icon: "none",
})
}
}
// #endif
}
static stickyTop() {
// #ifdef APP-PLUS
let systemInfo = uni.getSystemInfoSync()
let pxNum = systemInfo.statusBarHeight + 44
return (pxNum / systemInfo.windowWidth) * 750
// #endif
// #ifdef H5
return 0
// #endif
}
static getNodeInfo(node) {
return new Promise((resolve, reject) => {
try {
const query = uni.createSelectorQuery()
// #ifdef MP
query.in(this)
// #endif
query
?.select(node)
?.boundingClientRect(data => {
resolve(data)
})
.exec()
} catch (err) {
resolve({})
}
})
}
static getSystemInfo() {
return uni.getSystemInfoSync()
}
static winInfo() {
return uni.getWindowInfo()
}
static toNumber(str) {
if (str?.indexOf(",") == -1) return str
const numStr = str.replace(/,/g, "")
const num = parseFloat(numStr)
return isNaN(num) ? str : num
}
static formatNumber(val) {
if (!val) {
val = 0
}
return val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".")
}
static tofix(value, ex, lt) {
ex = typeof ex != "undefined" ? ex : ""
lt = typeof lt != "undefined" ? lt : ""
if (value < 1000) {
return ex + Number((value / 1).toFixed(2)).toLocaleString() + " " + lt
} else if (value < 1000000) {
return ex + Number((value / 1000).toFixed(2)).toLocaleString() + "K " + lt
} else {
return ex + Number((value / 1000000).toFixed(2)).toLocaleString() + "M " + lt
}
}
static html2text(cont) {
if (cont != null && cont != "") {
var re1 = new RegExp("<.+?>|&.+?;", "g") //匹配html标签的正则表达式,"g"是搜索匹配多个符合的内容
var msg = cont.replace(re1, "") //执行替换成空字符
msg = msg.replace(/\s/g, "") //去掉所有的空格(中文空格、英文空格都会被替换)
msg = msg.replace(/[\r\n]/g, "") //去掉所有的换行符
return msg.substr(0, 100) //获文本文字内容的前100个字符
} else return ""
}
static openLink(url) {
if (!url) return false
// #ifdef APP-PLUS
plus.runtime.openURL(url)
// #endif
// #ifdef H5
let link = window.open(url, "_blank")
if (!link) {
window.location.href = url
}
// #endif
// #ifdef MP
this.copy(url)
// #endif
}
static toDate(timestamp) {
const date = new Date(timestamp * 1000) // 将时间戳转换为毫秒
const year = date.getFullYear()
const month = ("0" + (date.getMonth() + 1)).slice(-2) //月份从0开始,所以要加1
const day = ("0" + date.getDate()).slice(-2)
return `${year}-${month}-${day}`
}
static toDateTime(timestamp) {
if (!timestamp) return ""
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
const seconds = String(date.getSeconds()).padStart(2, "0")
const formattedTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
return formattedTime
}
static getUrlParams(url) {
const params = {}
const paramStr = url.split("?")[1]
if (paramStr) {
const paramArr = paramStr.split("&")
paramArr.forEach(param => {
const [key, value] = param.split("=")
params[key] = decodeURIComponent(value)
})
}
return params
}
static compressBase64 = compressBase64
static compressImg = compressImg
static dataURLtoFile = dataURLtoFile
}
export default methods
intercept.js
js
import store from "../store/index.js"
const whiteList = ["/pages/index/index", "/pages/startup/startup", "/pages/login/login", "/pages/register/register"]
const isStringInArray = (str, array) => {
const isInArray = array.includes(str)
const isSubstringInArray = array.some(item => str.includes(item))
return isInArray || isSubstringInArray
}
const noPermission = url => {
if (!store.state.token && !isStringInArray(url, whiteList)) {
return true
}
return false
}
const interceptRoute = () => {
const list = ["navigateTo", "redirectTo", "reLaunch", "switchTab"]
list.forEach(item => {
uni.addInterceptor(item, {
invoke(args) {
console.log(args)
if (noPermission(args.url)) {
return uni.reLaunch({
url: "/pages/startup/startup",
})
}
},
success(args) {
// console.log(args)
},
fail(err) {
// console.log('interceptor-fail', err)
},
complete(res) {
// console.log('interceptor-complete', res)
},
})
})
}
interceptRoute()
sign.js
js
import md5Libs from "../uni_modules/vk-uview-ui/libs/function/md5.js"
export default function (res) {
let lsRes = JSON.parse(JSON.stringify(res))
let sign = ""
let secrect = "edUuCnsNyspRRObmP22TlO0bGY7l4td6i4fQN1GbEb5mCc1pHb"
let reg = /^([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\/])+$/
lsRes.client_key = 195265694483
lsRes.time_stamp = parseInt(new Date().getTime() / 1000)
Object.keys(lsRes)
.sort()
.forEach(key => {
if (Array.isArray(lsRes[key])) {
sign += key
lsRes[key].forEach((item, index) => {
if (!item) {
lsRes[key].splice(index, 1)
} else {
if (item.constructor === Object) {
Object.keys(item)
.sort()
.forEach(nestedKey => {
sign += nestedKey + item[nestedKey].toString().trim()
})
} else {
sign += index === 0 ? "" : ","
sign += typeof item === "string" ? item.trim() : item.toString()
}
}
})
} else {
if (lsRes[key] === "") {
delete lsRes[key]
} else {
sign += key + (typeof lsRes[key] === "string" ? lsRes[key].toString().trim() : lsRes[key].toString())
}
}
})
sign = secrect + sign + secrect
lsRes.sign = md5Libs.md5(sign).toUpperCase()
return lsRes
}
跟目录创建 AndroidManifest.xml
强制移除敏感权限
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
package="io.dcloud.nativeresouce">
<!--按下面方式配置需要移除的permissions-->
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
<application>
<!--meta-data-->
</application>
</manifest>
根目录创建 .gitignore
git 过滤
.hbuilderx
node_modules
unpackage/cache
unpackage/dist
unpackage/release
yarn.lock